Skip to content

Commit 9a2fa96

Browse files
Feature/fetch (#54)
* relocated request.library to common folder * add Tempo.epoch * add shorthand to set, add, until, since * ready for review * PR 1st review * tighten hh|mi|ss upper boundaries
1 parent 5832b73 commit 9a2fa96

34 files changed

Lines changed: 521 additions & 241 deletions

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2828
- **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.
2929
- **Robust License Validation**: Hardened runtime integrity for Tempo Pro plugins to enhance state security and prevent tampering.
3030
- **Edition Boundaries**: Added proactive runtime warnings for unlicensed attempts, establishing strict boundaries between the Community and Proprietary editions.
31-
- **Extension Resolution**: Stabilized module resolution for plugins by migrating to `defineExtension` and correcting internal export paths.
31+
- **Extension Resolution**: Stabilized module resolution for plugins by migrating to `definePlugin` and correcting internal export paths.
3232
- **Logging Subsystem Decoupling**: Fully decoupled the internal parsing and error boundaries from the legacy `Logify` architecture, enabling zero-cost trace instrumentation.
3333
- **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`).
3434
- **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.

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "tempo-monorepo",
3-
"version": "3.5.2",
3+
"version": "3.6.0",
44
"private": true,
55
"engines": {
66
"node": ">=20.0.0"

packages/library/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@magmacomputing/library",
3-
"version": "3.5.2",
3+
"version": "3.6.0",
44
"description": "Shared utility library for Tempo",
55
"author": "Magma Computing Solutions",
66
"license": "MIT",

packages/library/src/common.index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export * from './common/object.library.js';
1818
export * from './common/pledge.class.js';
1919
export * from './common/proxy.library.js';
2020
export * from './common/reflection.library.js';
21+
export * from './common/request.library.js';
2122
export * from './common/serialize.library.js';
2223
export * from './common/storage.library.js';
2324
export * from './common/string.library.js';

packages/library/src/common/assertion.library.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,10 @@ export const isDuration = (obj: unknown): obj is Temporal.Duration => isType<Tem
7474
export const isDurationLike = (obj: unknown): obj is Temporal.DurationLike | string | Temporal.Duration => isString(obj) || isDuration(obj) || (isObject(obj) && (
7575
'years' in obj || 'months' in obj || 'weeks' in obj || 'days' in obj ||
7676
'hours' in obj || 'minutes' in obj || 'seconds' in obj ||
77-
'milliseconds' in obj || 'microseconds' in obj || 'nanoseconds' in obj
77+
'milliseconds' in obj || 'microseconds' in obj || 'nanoseconds' in obj ||
78+
'yy' in obj || 'mm' in obj || 'ww' in obj || 'dd' in obj ||
79+
'hh' in obj || 'mi' in obj || 'ss' in obj ||
80+
'ms' in obj || 'us' in obj || 'ns' in obj
7881
));
7982
export const isZonedDateTimeLike = (obj: unknown): obj is Temporal.ZonedDateTimeLike | string | Temporal.ZonedDateTime => isString(obj) || isZonedDateTime(obj) || (isObject(obj) && (
8083
'year' in obj || 'month' in obj || 'day' in obj || 'hour' in obj || 'minute' in obj || 'second' in obj ||

packages/library/src/server/request.library.ts renamed to packages/library/src/common/request.library.ts

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ValueOf } from '#library/type.library.js';
22

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

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

28+
export class HttpError extends Error {
29+
constructor(
30+
public status: number,
31+
public statusText: string,
32+
public body: any
33+
) {
34+
super(`${status}: ${statusText}`);
35+
this.name = 'HttpError';
36+
}
37+
}
38+
2839
/** get data from a resource-url */
29-
export const httpRequest = <T>(url: string | URL, init = {} as RequestInit, config = {} as Config) => {
40+
export const fetchRequest = <T>(url: string | URL, init = {} as RequestInit, config = {} as Config) => {
3041
const signallingInit = {
3142
...init,
3243
signal: init.signal
@@ -37,28 +48,38 @@ export const httpRequest = <T>(url: string | URL, init = {} as RequestInit, conf
3748
return fetch(url, signallingInit) // caller will handle the 'catch' if error
3849
.then(async res => {
3950
if (res.ok) {
51+
const contentType = res.headers.get('Content-Type') || '';
52+
const isJson = contentType.includes('application/json');
53+
4054
if (config.prefix) {
41-
const text = await res.text(); // read raw text first
42-
const json = text.startsWith(config.prefix) // if it starts with the specified prefix
43-
? text.substring(config.prefix.length).replace(/\);?$/, '') // then strip the prefix AND any trailing closure
44-
: text;
55+
const rawPrefixText = await res.text(); // read raw text first
56+
const json = rawPrefixText.startsWith(config.prefix) // if it starts with the specified prefix
57+
? rawPrefixText.substring(config.prefix.length).replace(/\);?$/, '') // then strip the prefix AND any trailing closure
58+
: rawPrefixText;
4559

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

49-
const json = await res.json(); // default JSON parsing
50-
return json as T;
63+
return await (isJson
64+
? res.json() // default JSON parsing
65+
: res.text()) as T;
5166
}
5267

53-
throw new Error(`${res.status}: ${res.statusText}`); // fetch not successful
68+
let errorBody: any = null;
69+
try {
70+
const errorText = await res.text();
71+
try { errorBody = JSON.parse(errorText); } catch { errorBody = errorText; }
72+
} catch { }
73+
74+
throw new HttpError(res.status, res.statusText, errorBody); // fetch not successful
5475
})
5576
}
5677

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

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

70-
throw new Error(`${status}: ${statusText}`); // fetch not successful
91+
throw new HttpError(status, statusText, null); // fetch not successful
7192
})
7293
}

packages/library/src/common/type.library.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ export type OneKey<K extends keyof any, V, KK extends keyof any = K> =
177177
{ [Q in keyof O]: O[Q] } : never
178178
}[K]
179179

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

407408
type LooseString = (string & {})
408-
type LooseSymbol = (symbol & {})
409409
type LooseProperty = (PropertyKey & {})
410410

411411
// https://www.youtube.com/watch?v=lraHlXpuhKs&t=43s
412412
/** Loose union */
413-
export type LooseUnion<T extends string> = T | LooseString
413+
export type LooseUnion<T> = T | LooseString
414414
/** Loose property key */
415415
export type LooseKey<K extends PropertyKey = string> = K | LooseProperty
416416
// /** Loose auto-complete */

packages/library/src/server.index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,3 @@
44

55
export * from './server/auth.library.js';
66
export * from './server/file.library.js';
7-
export * from './server/request.library.js';
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { fetchRequest, fetchHead, HttpError } from '../../src/common/request.library.js';
2+
3+
describe('request.library', () => {
4+
const mockFetch = vi.fn();
5+
6+
beforeEach(() => {
7+
globalThis.fetch = mockFetch;
8+
});
9+
10+
afterEach(() => {
11+
vi.clearAllMocks();
12+
});
13+
14+
describe('fetchRequest', () => {
15+
it('should parse JSON when Content-Type is application/json', async () => {
16+
const mockData = { message: 'hello' };
17+
mockFetch.mockResolvedValueOnce({
18+
ok: true,
19+
headers: new Headers({ 'Content-Type': 'application/json; charset=utf-8' }),
20+
json: async () => mockData
21+
} as unknown as Response);
22+
23+
const result = await fetchRequest('https://example.com/api');
24+
expect(result).toEqual(mockData);
25+
});
26+
27+
it('should return raw text when Content-Type is not JSON', async () => {
28+
const mockText = 'hello world';
29+
mockFetch.mockResolvedValueOnce({
30+
ok: true,
31+
headers: new Headers({ 'Content-Type': 'text/plain' }),
32+
text: async () => mockText
33+
} as unknown as Response);
34+
35+
const result = await fetchRequest('https://example.com/text');
36+
expect(result).toEqual(mockText);
37+
});
38+
39+
it('should strip prefix if provided', async () => {
40+
const prefix = ')]}\'\n';
41+
const rawText = `${prefix}{"a":1}`;
42+
mockFetch.mockResolvedValueOnce({
43+
ok: true,
44+
headers: new Headers({ 'Content-Type': 'application/json' }),
45+
text: async () => rawText
46+
} as unknown as Response);
47+
48+
const result = await fetchRequest('https://example.com/data', {}, { prefix });
49+
expect(result).toEqual({ a: 1 });
50+
});
51+
52+
it('should throw HttpError with JSON body on 400', async () => {
53+
const errorBody = { error: 'Bad request' };
54+
mockFetch.mockResolvedValueOnce({
55+
ok: false,
56+
status: 400,
57+
statusText: 'Bad Request',
58+
text: async () => JSON.stringify(errorBody)
59+
} as unknown as Response);
60+
61+
try {
62+
await fetchRequest('https://example.com/api');
63+
expect.fail('Should have thrown HttpError');
64+
} catch (err: any) {
65+
expect(err).toBeInstanceOf(HttpError);
66+
expect(err.status).toBe(400);
67+
expect(err.statusText).toBe('Bad Request');
68+
expect(err.body).toEqual(errorBody);
69+
}
70+
});
71+
72+
it('should throw HttpError with text body on 500 when JSON parsing fails', async () => {
73+
const errorText = 'Internal Server Error Occurred';
74+
mockFetch.mockResolvedValueOnce({
75+
ok: false,
76+
status: 500,
77+
statusText: 'Server Error',
78+
text: async () => errorText
79+
} as unknown as Response);
80+
81+
try {
82+
await fetchRequest('https://example.com/api');
83+
expect.fail('Should have thrown HttpError');
84+
} catch (err: any) {
85+
expect(err).toBeInstanceOf(HttpError);
86+
expect(err.status).toBe(500);
87+
expect(err.body).toBe(errorText);
88+
}
89+
});
90+
});
91+
92+
describe('fetchHead', () => {
93+
it('should return status and headers on ok response', async () => {
94+
const mockHeaders = new Headers({ 'Content-Length': '123' });
95+
mockFetch.mockResolvedValueOnce({
96+
ok: true,
97+
status: 200,
98+
headers: mockHeaders
99+
} as unknown as Response);
100+
101+
const result = await fetchHead('https://example.com/api');
102+
expect(result.status).toBe(200);
103+
expect(result.headers).toBe(mockHeaders);
104+
});
105+
106+
it('should throw Error on non-ok response (except Forbidden)', async () => {
107+
mockFetch.mockResolvedValueOnce({
108+
ok: false,
109+
status: 404,
110+
statusText: 'Not Found'
111+
} as unknown as Response);
112+
113+
await expect(fetchHead('https://example.com/api')).rejects.toThrow('404: Not Found');
114+
});
115+
116+
it('should return status and headers on Forbidden (403)', async () => {
117+
const mockHeaders = new Headers();
118+
mockFetch.mockResolvedValueOnce({
119+
ok: false,
120+
status: 403,
121+
headers: mockHeaders
122+
} as unknown as Response);
123+
124+
const result = await fetchHead('https://example.com/api');
125+
expect(result.status).toBe(403);
126+
expect(result.headers).toBe(mockHeaders);
127+
});
128+
});
129+
});

0 commit comments

Comments
 (0)