Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Ticker Plugin Licensing**: The `Ticker` plugin has transitioned to a licensed model. It remains completely free to use, but now requires validation via the Tempo Registry ecosystem.
- **Robust License Validation**: Hardened runtime integrity for Tempo Pro plugins to enhance state security and prevent tampering.
- **Edition Boundaries**: Added proactive runtime warnings for unlicensed attempts, establishing strict boundaries between the Community and Proprietary editions.
- **Extension Resolution**: Stabilized module resolution for plugins by migrating to `defineExtension` and correcting internal export paths.
- **Extension Resolution**: Stabilized module resolution for plugins by migrating to `definePlugin` and correcting internal export paths.
- **Logging Subsystem Decoupling**: Fully decoupled the internal parsing and error boundaries from the legacy `Logify` architecture, enabling zero-cost trace instrumentation.
- **Diagnostic Consistency**: Standardized fatal exceptions by introducing the `TempoError` class for internal invariant violations, and unified all console output through the centralized diagnostic bus (`logWarn`).
- **Documentation**: Clarified API behavior for the Duration engine (especially `.since()` return types) and relative time math. Improved visibility and navigation for the Tempo License Registry and installation guides.
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tempo-monorepo",
"version": "3.5.2",
"version": "3.6.0",
"private": true,
"engines": {
"node": ">=20.0.0"
Expand Down
2 changes: 1 addition & 1 deletion packages/library/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@magmacomputing/library",
"version": "3.5.2",
"version": "3.6.0",
"description": "Shared utility library for Tempo",
"author": "Magma Computing Solutions",
"license": "MIT",
Expand Down
1 change: 1 addition & 0 deletions packages/library/src/common.index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export * from './common/object.library.js';
export * from './common/pledge.class.js';
export * from './common/proxy.library.js';
export * from './common/reflection.library.js';
export * from './common/request.library.js';
export * from './common/serialize.library.js';
export * from './common/storage.library.js';
export * from './common/string.library.js';
Expand Down
5 changes: 4 additions & 1 deletion packages/library/src/common/assertion.library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ export const isDuration = (obj: unknown): obj is Temporal.Duration => isType<Tem
export const isDurationLike = (obj: unknown): obj is Temporal.DurationLike | string | Temporal.Duration => isString(obj) || isDuration(obj) || (isObject(obj) && (
'years' in obj || 'months' in obj || 'weeks' in obj || 'days' in obj ||
'hours' in obj || 'minutes' in obj || 'seconds' in obj ||
'milliseconds' in obj || 'microseconds' in obj || 'nanoseconds' in obj
'milliseconds' in obj || 'microseconds' in obj || 'nanoseconds' in obj ||
'yy' in obj || 'mm' in obj || 'ww' in obj || 'dd' in obj ||
'hh' in obj || 'mi' in obj || 'ss' in obj ||
'ms' in obj || 'us' in obj || 'ns' in obj
Comment thread
magmacomputing marked this conversation as resolved.
));
export const isZonedDateTimeLike = (obj: unknown): obj is Temporal.ZonedDateTimeLike | string | Temporal.ZonedDateTime => isString(obj) || isZonedDateTime(obj) || (isObject(obj) && (
'year' in obj || 'month' in obj || 'day' in obj || 'hour' in obj || 'minute' in obj || 'second' in obj ||
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ValueOf } from '#library/type.library.js';

const TWO_SECONDS = 2000; // default time-out for requests, in milliseconds
const TWO_SECONDS = 2_000; // default time-out for requests, in milliseconds

export const HTTP = {
Ok: 200,
Expand All @@ -25,8 +25,19 @@ type Config = {
/** response wrapper (eg. "alert({hello:'there'})" */ prefix?: string;
}

export class HttpError extends Error {
constructor(
public status: number,
public statusText: string,
public body: any
) {
super(`${status}: ${statusText}`);
this.name = 'HttpError';
}
}

/** get data from a resource-url */
export const httpRequest = <T>(url: string | URL, init = {} as RequestInit, config = {} as Config) => {
export const fetchRequest = <T>(url: string | URL, init = {} as RequestInit, config = {} as Config) => {
const signallingInit = {
...init,
signal: init.signal
Expand All @@ -37,28 +48,38 @@ export const httpRequest = <T>(url: string | URL, init = {} as RequestInit, conf
return fetch(url, signallingInit) // caller will handle the 'catch' if error
.then(async res => {
if (res.ok) {
const contentType = res.headers.get('Content-Type') || '';
const isJson = contentType.includes('application/json');

if (config.prefix) {
const text = await res.text(); // read raw text first
const json = text.startsWith(config.prefix) // if it starts with the specified prefix
? text.substring(config.prefix.length).replace(/\);?$/, '') // then strip the prefix AND any trailing closure
: text;
const rawPrefixText = await res.text(); // read raw text first
const json = rawPrefixText.startsWith(config.prefix) // if it starts with the specified prefix
? rawPrefixText.substring(config.prefix.length).replace(/\);?$/, '') // then strip the prefix AND any trailing closure
: rawPrefixText;

return JSON.parse(json) as T; // parse the unwrapped string
}

const json = await res.json(); // default JSON parsing
return json as T;
return await (isJson
? res.json() // default JSON parsing
: res.text()) as T;
}

throw new Error(`${res.status}: ${res.statusText}`); // fetch not successful
let errorBody: any = null;
try {
const errorText = await res.text();
try { errorBody = JSON.parse(errorText); } catch { errorBody = errorText; }
} catch { }

throw new HttpError(res.status, res.statusText, errorBody); // fetch not successful
})
}

/**
* get Response headers only (no data).
* useful for just checking that a URL exists
*/
export const headRequest = (url: string | URL) => {
export const fetchHead = (url: string | URL) => {
const signal = AbortSignal.timeout(TWO_SECONDS);
const init = { method: METHOD.Head, signal } // only interested in verifying that url responds

Expand All @@ -67,6 +88,6 @@ export const headRequest = (url: string | URL) => {
if (ok || status === HTTP.Forbidden) // forbidden, but at least we know url responds
return { status, headers }

throw new Error(`${status}: ${statusText}`); // fetch not successful
throw new HttpError(status, statusText, null); // fetch not successful
})
}
6 changes: 3 additions & 3 deletions packages/library/src/common/type.library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ export type OneKey<K extends keyof any, V, KK extends keyof any = K> =
{ [Q in keyof O]: O[Q] } : never
}[K]

/** @deprecated natively supported by modern IDEs via hover verbosity. Slated for removal in v4.0.0 */
export type Prettify<T> = { [K in keyof T]: T[K]; } & {}
export type ParseInt<T> = T extends `${infer N extends number}` ? N : never
export type Plural<T extends string> = `${T}s`;
Expand Down Expand Up @@ -402,15 +403,14 @@ export type Secure<T> = T extends Primitive | Function | Date | RegExp | Error |
? SecureObject<T>
: T
export interface SecureArray<T> extends ReadonlyArray<Secure<T>> { }
export type SecureObject<T> = { readonly [K in keyof T]: Secure<T[K]> };
export type SecureObject<T> = { readonly [K in keyof T]: Secure<T[K]> }

type LooseString = (string & {})
type LooseSymbol = (symbol & {})
type LooseProperty = (PropertyKey & {})

// https://www.youtube.com/watch?v=lraHlXpuhKs&t=43s
/** Loose union */
export type LooseUnion<T extends string> = T | LooseString
export type LooseUnion<T> = T | LooseString
/** Loose property key */
export type LooseKey<K extends PropertyKey = string> = K | LooseProperty
// /** Loose auto-complete */
Expand Down
1 change: 0 additions & 1 deletion packages/library/src/server.index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,3 @@

export * from './server/auth.library.js';
export * from './server/file.library.js';
export * from './server/request.library.js';
129 changes: 129 additions & 0 deletions packages/library/test/common/request.library.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { fetchRequest, fetchHead, HttpError } from '../../src/common/request.library.js';

describe('request.library', () => {
const mockFetch = vi.fn();

beforeEach(() => {
globalThis.fetch = mockFetch;
});

afterEach(() => {
vi.clearAllMocks();
});

describe('fetchRequest', () => {
it('should parse JSON when Content-Type is application/json', async () => {
const mockData = { message: 'hello' };
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers({ 'Content-Type': 'application/json; charset=utf-8' }),
json: async () => mockData
} as unknown as Response);

const result = await fetchRequest('https://example.com/api');
expect(result).toEqual(mockData);
});

it('should return raw text when Content-Type is not JSON', async () => {
const mockText = 'hello world';
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers({ 'Content-Type': 'text/plain' }),
text: async () => mockText
} as unknown as Response);

const result = await fetchRequest('https://example.com/text');
expect(result).toEqual(mockText);
});

it('should strip prefix if provided', async () => {
const prefix = ')]}\'\n';
const rawText = `${prefix}{"a":1}`;
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers({ 'Content-Type': 'application/json' }),
text: async () => rawText
} as unknown as Response);

const result = await fetchRequest('https://example.com/data', {}, { prefix });
expect(result).toEqual({ a: 1 });
});

it('should throw HttpError with JSON body on 400', async () => {
const errorBody = { error: 'Bad request' };
mockFetch.mockResolvedValueOnce({
ok: false,
status: 400,
statusText: 'Bad Request',
text: async () => JSON.stringify(errorBody)
} as unknown as Response);

try {
await fetchRequest('https://example.com/api');
expect.fail('Should have thrown HttpError');
} catch (err: any) {
expect(err).toBeInstanceOf(HttpError);
expect(err.status).toBe(400);
expect(err.statusText).toBe('Bad Request');
expect(err.body).toEqual(errorBody);
}
});

it('should throw HttpError with text body on 500 when JSON parsing fails', async () => {
const errorText = 'Internal Server Error Occurred';
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: 'Server Error',
text: async () => errorText
} as unknown as Response);

try {
await fetchRequest('https://example.com/api');
expect.fail('Should have thrown HttpError');
} catch (err: any) {
expect(err).toBeInstanceOf(HttpError);
expect(err.status).toBe(500);
expect(err.body).toBe(errorText);
}
});
});

describe('fetchHead', () => {
it('should return status and headers on ok response', async () => {
const mockHeaders = new Headers({ 'Content-Length': '123' });
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
headers: mockHeaders
} as unknown as Response);

const result = await fetchHead('https://example.com/api');
expect(result.status).toBe(200);
expect(result.headers).toBe(mockHeaders);
});

it('should throw Error on non-ok response (except Forbidden)', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found'
} as unknown as Response);

await expect(fetchHead('https://example.com/api')).rejects.toThrow('404: Not Found');
});

it('should return status and headers on Forbidden (403)', async () => {
const mockHeaders = new Headers();
mockFetch.mockResolvedValueOnce({
ok: false,
status: 403,
headers: mockHeaders
} as unknown as Response);

const result = await fetchHead('https://example.com/api');
expect(result.status).toBe(403);
expect(result.headers).toBe(mockHeaders);
});
});
});
2 changes: 1 addition & 1 deletion packages/library/test/common/temporal_library.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ describe('Temporal Library Helpers', () => {

it('should handle "Z" as a zone designator and pass it through (even if ZonedDateTime.from throws without a bracket)', () => {
const bag = '2024-01-01T12:00:00Z';
expect(() => toZonedDateTime(bag, 'Australia/Sydney')).toThrow(/requires a time zone ID in brackets/);
expect(() => toZonedDateTime(bag, 'Australia/Sydney')).toThrow(/time\s?zone/i);
});
});
});
25 changes: 23 additions & 2 deletions packages/tempo/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,27 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [3.6.0] - 2026-07-05

### Added
- **Shorthand Mutation Keys**: Added native support for Tempo's shorthand format tokens (e.g., `mi`, `ss`, `yy`, `ww`) across both `.add()` and `.set()` mutations, streamlining developer experience and aligning TypeScript definitions with the underlying runtime engine.
- **Shorthand Duration Keys**: Expanded shorthand token support directly into the `DurationModule`. You can now seamlessly use shorthand keys for duration instantiation (`Tempo.duration({ mi: 5 })`), comparisons (`t.until(other, 'mi')`), and strict balancing (`t.until(other).balance({ largestUnit: 'mi' })`), bringing total API consistency across the core library.

### Changed
- **Enum Performance Optimization**: Upgraded internal dictionary traversals across the parsing engine to utilize native, memoized `.keys()` methods provided by the `enumify` registry, eliminating the overhead of standard `Object.keys()` iterations.

### Fixed
- **Prototype Bleed Vulnerability**: Replaced `in` operator checks with secure `.has()` methods across the core engine and duration parser, ensuring strict enum member validation and completely eliminating the risk of arbitrary property or method-based prototype leakage.

## [3.5.3] - 2026-07-05

### Added
- **Flexible Epoch Getters**: Added a static `Tempo.epoch` getter that perfectly mirrors the instance `.epoch` property, enabling direct retrieval of current Unix timestamps (e.g. `Tempo.epoch.ss`).
- **Static Now Modifiers**: Extended the static `Tempo.now(unit)` method to accept optional string units (`'ns'`, `'us'`, `'ms'`, `'ss'`), defaulting to nanosecond precision for strict backwards compatibility.

### Changed
- **DRY Refactoring**: Centralized instance and static `epoch` property calculations to a single internal static helper, ensuring absolute uniformity of mathematical transformations across the core library.

## [3.5.2] - 2026-07-04

### Added
Expand All @@ -24,7 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [3.5.0] - 2026-06-28

### Added
- **Unified Plugin API Barrel**: Introduced `@magmacomputing/tempo/plugin-api` barrel export. This centralizes all plugin-authoring utilities (`defineModule`, `defineExtension`, `defineTerm`, etc.) and internal types into a single, highly discoverable endpoint. This architecture significantly cleans up application-level code and documentation by separating end-user imports from Plugin Developer imports.
- **Unified Plugin API Barrel**: Introduced `@magmacomputing/tempo/plugin-api` barrel export. This centralizes all plugin-authoring utilities (`defineModule`, `definePlugin`, `defineTerm`, etc.) and internal types into a single, highly discoverable endpoint. This architecture significantly cleans up application-level code and documentation by separating end-user imports from Plugin Developer imports.
- **Strict Peer Dependency Validation**: Tempo Plugins now enforce strict `peerDependencies` on the new `plugin-api` barrel export, ensuring robust resolution for complex environments (like Smart CDNs or Vite) while preventing duplicate instances of internal utility functions.

### Changed
Expand Down Expand Up @@ -465,7 +486,7 @@ _Version 2.4.0 was not released; the project merged new functionality from 2.4.0
## [2.2.2] - 2026-04-18

### Fixed
- **Plugin Infrastructure Preservation**: Refactored the Rollup configuration to treat all library files as public entry points. This prevents critical utilities (like `defineExtension`) from being tree-shaken during the build process, ensuring that modular plugins can register correctly.
- **Plugin Infrastructure Preservation**: Refactored the Rollup configuration to treat all library files as public entry points. This prevents critical utilities (like `definePlugin`) from being tree-shaken during the build process, ensuring that modular plugins can register correctly.
- **API Surface Hardening**: Explicitly exported all registration and utility helpers (`defineModule`, `defineTerm`, etc.) from the main entry point to guarantee their availability for third-party extensions.
- **Documentation Build Stability**: Updated the documentation configuration to utilize pre-compiled `dist/` assets. This resolves runtime `SyntaxError` issues in the browser caused by the presence of modern TC39 decorators in the raw TypeScript source files.
- **Decorator Transpilation**: Refactored utility functions to ensure standard function declarations are used where appropriate, improving the reliability of the transpilation phase.
Expand Down
2 changes: 1 addition & 1 deletion packages/tempo/doc/ecosystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,5 @@ Browse the live catalog below to discover extensions for scheduling, calendar ma
::: tip Submit Your Own Plugin!
Have you built something amazing for Tempo? We want to see it!

You can have your own packages listed in this live catalog. Simply build your extension using `defineExtension()` or `defineTerm()` and publish it to NPM with the `"tempo-plugin"` keyword. Open an Issue on our GitHub repository with a link to your package, and we'll review it for inclusion in the global registry!
You can have your own packages listed in this live catalog. Simply build your extension using `definePlugin()` or `defineTerm()` and publish it to NPM with the `"tempo-plugin"` keyword. Open an Issue on our GitHub repository with a link to your package, and we'll review it for inclusion in the global registry!
:::
Loading
Loading