@pixelume/sdk 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Error hierarchy of the SDK.
3
+ *
4
+ * Every error thrown by the SDK is a {@link PixelumeError} with a stable `kind` discriminator.
5
+ * Messages are deterministic and never include request/response bodies, headers, cookies or
6
+ * query strings, so they are safe to log. Raw response payloads are kept on
7
+ * {@link PixelumeHttpError.payload} as a non-enumerable property for programmatic inspection only.
8
+ */
9
+ const PIXELUME_ERROR_BRAND = Symbol.for('pixelume.sdk.error');
10
+ /** Base class of every error thrown by the SDK. */
11
+ export class PixelumeError extends Error {
12
+ constructor(name, message, options) {
13
+ super(message, options?.cause === undefined ? undefined : { cause: options.cause });
14
+ // Explicit names survive minification of consumer bundles.
15
+ this.name = name;
16
+ Object.defineProperty(this, PIXELUME_ERROR_BRAND, { value: true });
17
+ }
18
+ }
19
+ /** Invalid SDK configuration or invalid use of the SDK. Never caused by the network. */
20
+ export class PixelumeConfigError extends PixelumeError {
21
+ kind = 'config';
22
+ constructor(message) {
23
+ super('PixelumeConfigError', message);
24
+ }
25
+ }
26
+ /** Base class of errors tied to a specific HTTP request. */
27
+ export class PixelumeRequestError extends PixelumeError {
28
+ method;
29
+ path;
30
+ constructor(name, message, request, options) {
31
+ super(name, message, options);
32
+ this.method = request.method;
33
+ this.path = request.path;
34
+ }
35
+ }
36
+ /** The API answered with a non-2xx status. */
37
+ export class PixelumeHttpError extends PixelumeRequestError {
38
+ kind = 'http';
39
+ status;
40
+ /** `error.code` from the API error envelope, or `null` when the body is not an envelope. */
41
+ code;
42
+ /** `error.message` from the API error envelope (already sanitized by the API). */
43
+ apiMessage;
44
+ /** Request ID from the envelope or the `x-request-id` header, useful for support. */
45
+ requestId;
46
+ constructor(details) {
47
+ const suffix = details.code ? ` (${details.code})` : '';
48
+ super('PixelumeHttpError', `Pixelume API request ${details.request.method} ${details.request.path} failed with status ${details.status}${suffix}`, details.request);
49
+ this.status = details.status;
50
+ this.code = details.code;
51
+ this.apiMessage = details.apiMessage;
52
+ this.requestId = details.requestId;
53
+ Object.defineProperty(this, 'payload', {
54
+ value: details.payload,
55
+ enumerable: false,
56
+ writable: false,
57
+ });
58
+ }
59
+ }
60
+ /**
61
+ * The request did not produce an HTTP response: offline, DNS/TLS failure, connection reset,
62
+ * or a browser CORS rejection (browsers do not distinguish these for security reasons).
63
+ */
64
+ export class PixelumeNetworkError extends PixelumeRequestError {
65
+ kind = 'network';
66
+ constructor(request, options) {
67
+ super('PixelumeNetworkError', `Pixelume API request ${request.method} ${request.path} failed before a response was received (network failure or blocked by CORS)`, request, options);
68
+ }
69
+ }
70
+ /** The SDK timeout elapsed before the response (including its body) was received. */
71
+ export class PixelumeTimeoutError extends PixelumeRequestError {
72
+ kind = 'timeout';
73
+ timeoutMs;
74
+ constructor(request, timeoutMs, options) {
75
+ super('PixelumeTimeoutError', `Pixelume API request ${request.method} ${request.path} timed out after ${timeoutMs} ms`, request, options);
76
+ this.timeoutMs = timeoutMs;
77
+ }
78
+ }
79
+ /** The caller aborted the request through its `AbortSignal`. `cause` holds the abort reason. */
80
+ export class PixelumeAbortError extends PixelumeRequestError {
81
+ kind = 'aborted';
82
+ constructor(request, options) {
83
+ super('PixelumeAbortError', `Pixelume API request ${request.method} ${request.path} was aborted by the caller`, request, options);
84
+ }
85
+ }
86
+ /** A 2xx response did not match the contract expected by the SDK. */
87
+ export class PixelumeInvalidResponseError extends PixelumeRequestError {
88
+ kind = 'invalid_response';
89
+ status;
90
+ /** Short, payload-free description of the contract violation. */
91
+ reason;
92
+ constructor(request, status, reason) {
93
+ super('PixelumeInvalidResponseError', `Pixelume API request ${request.method} ${request.path} returned an unexpected response: ${reason}`, request);
94
+ this.status = status;
95
+ this.reason = reason;
96
+ }
97
+ }
98
+ /**
99
+ * Returns `true` for any error thrown by the SDK. Works across duplicated copies of the package
100
+ * (where `instanceof` would fail) because it checks a global symbol brand.
101
+ */
102
+ export function isPixelumeError(value) {
103
+ return (value instanceof Error &&
104
+ value[PIXELUME_ERROR_BRAND] === true);
105
+ }
106
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/core/errors.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Error hierarchy of the SDK.\n *\n * Every error thrown by the SDK is a {@link PixelumeError} with a stable `kind` discriminator.\n * Messages are deterministic and never include request/response bodies, headers, cookies or\n * query strings, so they are safe to log. Raw response payloads are kept on\n * {@link PixelumeHttpError.payload} as a non-enumerable property for programmatic inspection only.\n */\n\nconst PIXELUME_ERROR_BRAND = Symbol.for('pixelume.sdk.error');\n\nexport type PixelumeErrorKind =\n 'config' | 'http' | 'network' | 'timeout' | 'aborted' | 'invalid_response';\n\n/**\n * Error codes documented in the Pixelume API OpenAPI `ErrorEnvelope`.\n * The API may introduce new codes, so any string is accepted at runtime.\n */\nexport type PixelumeApiErrorCode =\n | 'INVALID_INPUT'\n | 'UNAUTHENTICATED'\n | 'FORBIDDEN_ORIGIN'\n | 'FORBIDDEN_HOST'\n | 'RATE_LIMITED'\n | 'REGISTRATION_DISABLED'\n | 'AUTH_TOKEN_INVALID'\n | 'INTERNAL_ERROR'\n | 'NOT_FOUND'\n | (string & {});\n\n/** Identifies the request that failed. `path` never contains the query string. */\nexport interface PixelumeRequestInfo {\n readonly method: string;\n readonly path: string;\n}\n\n/** Base class of every error thrown by the SDK. */\nexport abstract class PixelumeError extends Error {\n abstract readonly kind: PixelumeErrorKind;\n\n protected constructor(name: string, message: string, options?: { cause?: unknown }) {\n super(message, options?.cause === undefined ? undefined : { cause: options.cause });\n // Explicit names survive minification of consumer bundles.\n this.name = name;\n Object.defineProperty(this, PIXELUME_ERROR_BRAND, { value: true });\n }\n}\n\n/** Invalid SDK configuration or invalid use of the SDK. Never caused by the network. */\nexport class PixelumeConfigError extends PixelumeError {\n readonly kind = 'config' as const;\n\n constructor(message: string) {\n super('PixelumeConfigError', message);\n }\n}\n\n/** Base class of errors tied to a specific HTTP request. */\nexport abstract class PixelumeRequestError extends PixelumeError {\n readonly method: string;\n readonly path: string;\n\n protected constructor(\n name: string,\n message: string,\n request: PixelumeRequestInfo,\n options?: { cause?: unknown },\n ) {\n super(name, message, options);\n this.method = request.method;\n this.path = request.path;\n }\n}\n\n/** The API answered with a non-2xx status. */\nexport class PixelumeHttpError extends PixelumeRequestError {\n readonly kind = 'http' as const;\n readonly status: number;\n /** `error.code` from the API error envelope, or `null` when the body is not an envelope. */\n readonly code: PixelumeApiErrorCode | null;\n /** `error.message` from the API error envelope (already sanitized by the API). */\n readonly apiMessage: string | null;\n /** Request ID from the envelope or the `x-request-id` header, useful for support. */\n readonly requestId: string | null;\n /**\n * Decoded response body (JSON value, text or `undefined`). Non-enumerable so that\n * `JSON.stringify(error)` and structured loggers do not dump it by accident.\n */\n declare readonly payload: unknown;\n\n constructor(details: {\n request: PixelumeRequestInfo;\n status: number;\n code: string | null;\n apiMessage: string | null;\n requestId: string | null;\n payload: unknown;\n }) {\n const suffix = details.code ? ` (${details.code})` : '';\n super(\n 'PixelumeHttpError',\n `Pixelume API request ${details.request.method} ${details.request.path} failed with status ${details.status}${suffix}`,\n details.request,\n );\n this.status = details.status;\n this.code = details.code;\n this.apiMessage = details.apiMessage;\n this.requestId = details.requestId;\n Object.defineProperty(this, 'payload', {\n value: details.payload,\n enumerable: false,\n writable: false,\n });\n }\n}\n\n/**\n * The request did not produce an HTTP response: offline, DNS/TLS failure, connection reset,\n * or a browser CORS rejection (browsers do not distinguish these for security reasons).\n */\nexport class PixelumeNetworkError extends PixelumeRequestError {\n readonly kind = 'network' as const;\n\n constructor(request: PixelumeRequestInfo, options?: { cause?: unknown }) {\n super(\n 'PixelumeNetworkError',\n `Pixelume API request ${request.method} ${request.path} failed before a response was received (network failure or blocked by CORS)`,\n request,\n options,\n );\n }\n}\n\n/** The SDK timeout elapsed before the response (including its body) was received. */\nexport class PixelumeTimeoutError extends PixelumeRequestError {\n readonly kind = 'timeout' as const;\n readonly timeoutMs: number;\n\n constructor(request: PixelumeRequestInfo, timeoutMs: number, options?: { cause?: unknown }) {\n super(\n 'PixelumeTimeoutError',\n `Pixelume API request ${request.method} ${request.path} timed out after ${timeoutMs} ms`,\n request,\n options,\n );\n this.timeoutMs = timeoutMs;\n }\n}\n\n/** The caller aborted the request through its `AbortSignal`. `cause` holds the abort reason. */\nexport class PixelumeAbortError extends PixelumeRequestError {\n readonly kind = 'aborted' as const;\n\n constructor(request: PixelumeRequestInfo, options?: { cause?: unknown }) {\n super(\n 'PixelumeAbortError',\n `Pixelume API request ${request.method} ${request.path} was aborted by the caller`,\n request,\n options,\n );\n }\n}\n\n/** A 2xx response did not match the contract expected by the SDK. */\nexport class PixelumeInvalidResponseError extends PixelumeRequestError {\n readonly kind = 'invalid_response' as const;\n readonly status: number;\n /** Short, payload-free description of the contract violation. */\n readonly reason: string;\n\n constructor(request: PixelumeRequestInfo, status: number, reason: string) {\n super(\n 'PixelumeInvalidResponseError',\n `Pixelume API request ${request.method} ${request.path} returned an unexpected response: ${reason}`,\n request,\n );\n this.status = status;\n this.reason = reason;\n }\n}\n\n/**\n * Returns `true` for any error thrown by the SDK. Works across duplicated copies of the package\n * (where `instanceof` would fail) because it checks a global symbol brand.\n */\nexport function isPixelumeError(value: unknown): value is PixelumeError {\n return (\n value instanceof Error &&\n (value as unknown as Record<symbol, unknown>)[PIXELUME_ERROR_BRAND] === true\n );\n}\n"]}
@@ -0,0 +1,42 @@
1
+ import type { CredentialsPolicy, FetchLike } from '../http/types.js';
2
+ export type PixelumeCredentialsPolicy = CredentialsPolicy;
3
+ export type PixelumeFetch = FetchLike;
4
+ export interface PixelumeClientOptions {
5
+ /**
6
+ * Base URL of the Pixelume API. Defaults to `https://api.pixelume.com.br`.
7
+ * Must be `https:`; `http:` is accepted for loopback hosts during local development.
8
+ */
9
+ apiUrl?: string;
10
+ /**
11
+ * Public identifier of the game, e.g. `rail-puzzle`. Optional in Phase 1 because no current
12
+ * API contract uses it; future modules that need it will fail fast with a config error.
13
+ * It is an identifier, never an authorization credential.
14
+ */
15
+ gameId?: string;
16
+ /** Per-request timeout in milliseconds. Defaults to 10 000. */
17
+ timeoutMs?: number;
18
+ /**
19
+ * `fetch` credentials policy used by modules that do not set their own. Defaults to `'omit'`.
20
+ * Cross-origin cookies are subject to the API's CORS allowlist and to browser policies;
21
+ * the SDK never assumes they work.
22
+ */
23
+ credentials?: PixelumeCredentialsPolicy;
24
+ /** Custom `fetch` implementation (tests, SSR, instrumentation). Defaults to `globalThis.fetch`. */
25
+ fetch?: PixelumeFetch;
26
+ /** Allows `http:` for non-loopback hosts (e.g. a LAN IP). Never enable it in production. */
27
+ allowInsecureHttp?: boolean;
28
+ }
29
+ /** Effective, validated configuration of a client. */
30
+ export interface PixelumeClientConfig {
31
+ readonly apiUrl: string;
32
+ readonly gameId: string | null;
33
+ readonly timeoutMs: number;
34
+ readonly credentials: PixelumeCredentialsPolicy;
35
+ }
36
+ /** Options accepted by every SDK operation. */
37
+ export interface PixelumeRequestOptions {
38
+ /** Aborts the operation; the promise rejects with `PixelumeAbortError`. */
39
+ signal?: AbortSignal;
40
+ /** Overrides the client timeout for this operation. */
41
+ timeoutMs?: number;
42
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/core/types.ts"],"names":[],"mappings":"","sourcesContent":["import type { CredentialsPolicy, FetchLike } from '../http/types.js';\n\nexport type PixelumeCredentialsPolicy = CredentialsPolicy;\nexport type PixelumeFetch = FetchLike;\n\nexport interface PixelumeClientOptions {\n /**\n * Base URL of the Pixelume API. Defaults to `https://api.pixelume.com.br`.\n * Must be `https:`; `http:` is accepted for loopback hosts during local development.\n */\n apiUrl?: string;\n /**\n * Public identifier of the game, e.g. `rail-puzzle`. Optional in Phase 1 because no current\n * API contract uses it; future modules that need it will fail fast with a config error.\n * It is an identifier, never an authorization credential.\n */\n gameId?: string;\n /** Per-request timeout in milliseconds. Defaults to 10 000. */\n timeoutMs?: number;\n /**\n * `fetch` credentials policy used by modules that do not set their own. Defaults to `'omit'`.\n * Cross-origin cookies are subject to the API's CORS allowlist and to browser policies;\n * the SDK never assumes they work.\n */\n credentials?: PixelumeCredentialsPolicy;\n /** Custom `fetch` implementation (tests, SSR, instrumentation). Defaults to `globalThis.fetch`. */\n fetch?: PixelumeFetch;\n /** Allows `http:` for non-loopback hosts (e.g. a LAN IP). Never enable it in production. */\n allowInsecureHttp?: boolean;\n}\n\n/** Effective, validated configuration of a client. */\nexport interface PixelumeClientConfig {\n readonly apiUrl: string;\n readonly gameId: string | null;\n readonly timeoutMs: number;\n readonly credentials: PixelumeCredentialsPolicy;\n}\n\n/** Options accepted by every SDK operation. */\nexport interface PixelumeRequestOptions {\n /** Aborts the operation; the promise rejects with `PixelumeAbortError`. */\n signal?: AbortSignal;\n /** Overrides the client timeout for this operation. */\n timeoutMs?: number;\n}\n"]}
@@ -0,0 +1,16 @@
1
+ import type { HttpClientOptions, HttpRequest, HttpResponse } from './types.js';
2
+ /**
3
+ * Small typed HTTP client over `fetch`. Internal to the SDK: feature modules build on it,
4
+ * consumers use the modules.
5
+ *
6
+ * Policy:
7
+ * - timeout covers the whole exchange, including reading the body;
8
+ * - the caller's `AbortSignal` is honoured before and during the request;
9
+ * - no automatic retries (the caller decides; see `docs/architecture.md`);
10
+ * - credentials default to the client policy and can be overridden per request.
11
+ */
12
+ export declare class HttpClient {
13
+ #private;
14
+ constructor(options: HttpClientOptions);
15
+ request<T>(request: HttpRequest<T>): Promise<HttpResponse<T>>;
16
+ }
@@ -0,0 +1,149 @@
1
+ import { parseErrorEnvelope } from '../contracts/index.js';
2
+ import { PixelumeAbortError, PixelumeConfigError, PixelumeHttpError, PixelumeInvalidResponseError, PixelumeNetworkError, PixelumeTimeoutError, } from '../core/errors.js';
3
+ import { validateTimeout } from '../core/config.js';
4
+ import { buildUrl, normalizePath } from './url.js';
5
+ /**
6
+ * Headers that only the runtime (or the SDK itself) may set. `origin`/`referer` in particular
7
+ * must never be forged: the API uses them as a CSRF defence and the SDK does not rely on them.
8
+ */
9
+ const RESERVED_HEADERS = new Set([
10
+ 'content-length',
11
+ 'cookie',
12
+ 'cookie2',
13
+ 'host',
14
+ 'origin',
15
+ 'referer',
16
+ 'set-cookie',
17
+ ]);
18
+ /**
19
+ * Small typed HTTP client over `fetch`. Internal to the SDK: feature modules build on it,
20
+ * consumers use the modules.
21
+ *
22
+ * Policy:
23
+ * - timeout covers the whole exchange, including reading the body;
24
+ * - the caller's `AbortSignal` is honoured before and during the request;
25
+ * - no automatic retries (the caller decides; see `docs/architecture.md`);
26
+ * - credentials default to the client policy and can be overridden per request.
27
+ */
28
+ export class HttpClient {
29
+ #baseUrl;
30
+ #fetch;
31
+ #timeoutMs;
32
+ #credentials;
33
+ constructor(options) {
34
+ this.#baseUrl = options.baseUrl;
35
+ this.#fetch = options.fetch;
36
+ this.#timeoutMs = options.timeoutMs;
37
+ this.#credentials = options.credentials;
38
+ }
39
+ async request(request) {
40
+ const info = { method: request.method, path: normalizePath(request.path) };
41
+ const url = buildUrl(this.#baseUrl, request.path, request.query);
42
+ const timeoutMs = request.timeoutMs === undefined ? this.#timeoutMs : validateTimeout(request.timeoutMs);
43
+ const init = {
44
+ method: request.method,
45
+ headers: buildHeaders(request),
46
+ credentials: request.credentials ?? this.#credentials,
47
+ };
48
+ if (request.json !== undefined)
49
+ init.body = JSON.stringify(request.json);
50
+ const callerSignal = request.signal;
51
+ if (callerSignal?.aborted)
52
+ throw new PixelumeAbortError(info, { cause: callerSignal.reason });
53
+ // Timeout and caller abort share one controller; the first abort reason wins.
54
+ const controller = new AbortController();
55
+ const timeoutReason = new DOMException(`Timed out after ${timeoutMs} ms`, 'TimeoutError');
56
+ const timer = setTimeout(() => {
57
+ controller.abort(timeoutReason);
58
+ }, timeoutMs);
59
+ const onCallerAbort = () => {
60
+ controller.abort(callerSignal?.reason);
61
+ };
62
+ callerSignal?.addEventListener('abort', onCallerAbort, { once: true });
63
+ // Settles on abort even if an injected `fetch` ignores the signal.
64
+ const abortion = new Promise((_, reject) => {
65
+ controller.signal.addEventListener('abort', () => {
66
+ reject(controller.signal.reason);
67
+ }, { once: true });
68
+ });
69
+ abortion.catch(() => undefined);
70
+ // The function is invoked without a receiver on purpose: calling a browser's native `fetch`
71
+ // with `this` bound to another object throws "Illegal invocation".
72
+ const fetchFn = this.#fetch;
73
+ let response;
74
+ let body;
75
+ try {
76
+ const pending = Promise.resolve().then(() => fetchFn(url, { ...init, signal: controller.signal }));
77
+ // Promise.race subscribes to every contender, so a losing promise that rejects later is
78
+ // handled and never surfaces as an unhandled rejection.
79
+ response = await Promise.race([pending, abortion]);
80
+ body = await Promise.race([readBody(response, request.method), abortion]);
81
+ }
82
+ catch (error) {
83
+ if (controller.signal.reason === timeoutReason) {
84
+ throw new PixelumeTimeoutError(info, timeoutMs, { cause: error });
85
+ }
86
+ if (callerSignal?.aborted) {
87
+ throw new PixelumeAbortError(info, { cause: callerSignal.reason });
88
+ }
89
+ throw new PixelumeNetworkError(info, { cause: error });
90
+ }
91
+ finally {
92
+ clearTimeout(timer);
93
+ callerSignal?.removeEventListener('abort', onCallerAbort);
94
+ }
95
+ if (!response.ok)
96
+ throw toHttpError(info, response, body);
97
+ if (body.type === 'invalid_json') {
98
+ throw new PixelumeInvalidResponseError(info, response.status, 'body is not valid JSON');
99
+ }
100
+ const parsed = request.parse(body.value);
101
+ if (!parsed.ok)
102
+ throw new PixelumeInvalidResponseError(info, response.status, parsed.reason);
103
+ return { status: response.status, headers: response.headers, data: parsed.value };
104
+ }
105
+ }
106
+ function buildHeaders(request) {
107
+ const headers = new Headers({ accept: 'application/json' });
108
+ for (const [name, value] of Object.entries(request.headers ?? {})) {
109
+ if (RESERVED_HEADERS.has(name.toLowerCase())) {
110
+ throw new PixelumeConfigError(`Header "${name}" is managed by the runtime and cannot be set`);
111
+ }
112
+ headers.set(name, value);
113
+ }
114
+ if (request.json !== undefined)
115
+ headers.set('content-type', 'application/json');
116
+ return headers;
117
+ }
118
+ async function readBody(response, method) {
119
+ if (method === 'HEAD' || [204, 205, 304].includes(response.status)) {
120
+ return { type: 'empty', value: undefined };
121
+ }
122
+ const text = await response.text();
123
+ if (text === '')
124
+ return { type: 'empty', value: undefined };
125
+ if (!isJsonContentType(response.headers.get('content-type')))
126
+ return { type: 'text', value: text };
127
+ try {
128
+ return { type: 'json', value: JSON.parse(text) };
129
+ }
130
+ catch {
131
+ return { type: 'invalid_json', value: text };
132
+ }
133
+ }
134
+ function isJsonContentType(contentType) {
135
+ const mediaType = contentType?.split(';')[0]?.trim().toLowerCase() ?? '';
136
+ return mediaType === 'application/json' || mediaType.endsWith('+json');
137
+ }
138
+ function toHttpError(info, response, body) {
139
+ const envelope = body.type === 'json' ? parseErrorEnvelope(body.value) : null;
140
+ return new PixelumeHttpError({
141
+ request: info,
142
+ status: response.status,
143
+ code: envelope?.error.code ?? null,
144
+ apiMessage: envelope?.error.message ?? null,
145
+ requestId: envelope?.error.requestId ?? response.headers.get('x-request-id'),
146
+ payload: body.value,
147
+ });
148
+ }
149
+ //# sourceMappingURL=HttpClient.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"HttpClient.js","sourceRoot":"","sources":["../../src/http/HttpClient.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,EACL,kBAAkB,EAClB,mBAAmB,EACnB,iBAAiB,EACjB,4BAA4B,EAC5B,oBAAoB,EACpB,oBAAoB,GAErB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAQpD,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEnD;;;GAGG;AACH,MAAM,gBAAgB,GAAG,IAAI,GAAG,CAAC;IAC/B,gBAAgB;IAChB,QAAQ;IACR,SAAS;IACT,MAAM;IACN,QAAQ;IACR,SAAS;IACT,YAAY;CACb,CAAC,CAAC;AAQH;;;;;;;;;GASG;AACH,MAAM,OAAO,UAAU;IACZ,QAAQ,CAAS;IACjB,MAAM,CAAY;IAClB,UAAU,CAAS;IACnB,YAAY,CAAmC;IAExD,YAAY,OAA0B;QACpC,IAAI,CAAC,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC;QAChC,IAAI,CAAC,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC;QAC5B,IAAI,CAAC,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC;QACpC,IAAI,CAAC,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC;IAC1C,CAAC;IAED,KAAK,CAAC,OAAO,CAAI,OAAuB;QACtC,MAAM,IAAI,GAAwB,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,IAAI,EAAE,aAAa,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QAChG,MAAM,GAAG,GAAG,QAAQ,CAAC,IAAI,CAAC,QAAQ,EAAE,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,KAAK,CAAC,CAAC;QACjE,MAAM,SAAS,GACb,OAAO,CAAC,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,eAAe,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;QACzF,MAAM,IAAI,GAAgB;YACxB,MAAM,EAAE,OAAO,CAAC,MAAM;YACtB,OAAO,EAAE,YAAY,CAAC,OAAO,CAAC;YAC9B,WAAW,EAAE,OAAO,CAAC,WAAW,IAAI,IAAI,CAAC,YAAY;SACtD,CAAC;QACF,IAAI,OAAO,CAAC,IAAI,KAAK,SAAS;YAAE,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;QAEzE,MAAM,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC;QACpC,IAAI,YAAY,EAAE,OAAO;YAAE,MAAM,IAAI,kBAAkB,CAAC,IAAI,EAAE,EAAE,KAAK,EAAE,YAAY,CAAC,MAAM,EAAE,CAAC,CAAC;QAE9F,8EAA8E;QAC9E,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;QACzC,MAAM,aAAa,GAAG,IAAI,YAAY,CAAC,mBAAmB,SAAS,KAAK,EAAE,cAAc,CAAC,CAAC;QAC1F,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE;YAC5B,UAAU,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC;QAClC,CAAC,EAAE,SAAS,CAAC,CAAC;QACd,MAAM,aAAa,GAAG,GAAG,EAAE;YACzB,UAAU,CAAC,KAAK,CAAC,YAAY,EAAE,MAAM,CAAC,CAAC;QACzC,CAAC,CAAC;QACF,YAAY,EAAE,gBAAgB,CAAC,OAAO,EAAE,aAAa,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;QACvE,mEAAmE;QACnE,MAAM,QAAQ,GAAG,IAAI,OAAO,CAAQ,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE;YAChD,UAAU,CAAC,MAAM,CAAC,gBAAgB,CAChC,OAAO,EACP,GAAG,EAAE;gBACH,MAAM,CAAC,UAAU,CAAC,MAAM,CAAC,MAAe,CAAC,CAAC;YAC5C,CAAC,EACD,EAAE,IAAI,EAAE,IAAI,EAAE,CACf,CAAC;QACJ,CAAC,CAAC,CAAC;QACH,QAAQ,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;QAEhC,4FAA4F;QAC5F,mEAAmE;QACnE,MAAM,OAAO,GAAG,IAAI,CAAC,MAAM,CAAC;QAC5B,IAAI,QAAkB,CAAC;QACvB,IAAI,IAAiB,CAAC;QACtB,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,CAC1C,OAAO,CAAC,GAAG,EAAE,EAAE,GAAG,IAAI,EAAE,MAAM,EAAE,UAAU,CAAC,MAAM,EAAE,CAAC,CACrD,CAAC;YACF,wFAAwF;YACxF,wDAAwD;YACxD,QAAQ,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC;YACnD,IAAI,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC,MAAM,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC;QAC5E,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAI,UAAU,CAAC,MAAM,CAAC,MAAM,KAAK,aAAa,EAAE,CAAC;gBAC/C,MAAM,IAAI,oBAAoB,CAAC,IAAI,EAAE,SAAS,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;YACpE,CAAC;YACD,IAAI,YAAY,EAAE,OAAO,EAAE,CAAC;gBAC1B,MAAM,IAAI,kBAAkB,CAAC,IAAI,EAAE,EAAE,KAAK,EAAE,YAAY,CAAC,MAAM,EAAE,CAAC,CAAC;YACrE,CAAC;YACD,MAAM,IAAI,oBAAoB,CAAC,IAAI,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;QACzD,CAAC;gBAAS,CAAC;YACT,YAAY,CAAC,KAAK,CAAC,CAAC;YACpB,YAAY,EAAE,mBAAmB,CAAC,OAAO,EAAE,aAAa,CAAC,CAAC;QAC5D,CAAC;QAED,IAAI,CAAC,QAAQ,CAAC,EAAE;YAAE,MAAM,WAAW,CAAC,IAAI,EAAE,QAAQ,EAAE,IAAI,CAAC,CAAC;QAC1D,IAAI,IAAI,CAAC,IAAI,KAAK,cAAc,EAAE,CAAC;YACjC,MAAM,IAAI,4BAA4B,CAAC,IAAI,EAAE,QAAQ,CAAC,MAAM,EAAE,wBAAwB,CAAC,CAAC;QAC1F,CAAC;QACD,MAAM,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACzC,IAAI,CAAC,MAAM,CAAC,EAAE;YAAE,MAAM,IAAI,4BAA4B,CAAC,IAAI,EAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC;QAC7F,OAAO,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,CAAC,OAAO,EAAE,IAAI,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC;IACpF,CAAC;CACF;AAED,SAAS,YAAY,CAAC,OAA6B;IACjD,MAAM,OAAO,GAAG,IAAI,OAAO,CAAC,EAAE,MAAM,EAAE,kBAAkB,EAAE,CAAC,CAAC;IAC5D,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,OAAO,IAAI,EAAE,CAAC,EAAE,CAAC;QAClE,IAAI,gBAAgB,CAAC,GAAG,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,EAAE,CAAC;YAC7C,MAAM,IAAI,mBAAmB,CAAC,WAAW,IAAI,+CAA+C,CAAC,CAAC;QAChG,CAAC;QACD,OAAO,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;IAC3B,CAAC;IACD,IAAI,OAAO,CAAC,IAAI,KAAK,SAAS;QAAE,OAAO,CAAC,GAAG,CAAC,cAAc,EAAE,kBAAkB,CAAC,CAAC;IAChF,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,KAAK,UAAU,QAAQ,CAAC,QAAkB,EAAE,MAAkB;IAC5D,IAAI,MAAM,KAAK,MAAM,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;QACnE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC;IAC7C,CAAC;IACD,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC;IACnC,IAAI,IAAI,KAAK,EAAE;QAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC;IAC5D,IAAI,CAAC,iBAAiB,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;QAC1D,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC;IACvC,IAAI,CAAC;QACH,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,IAAI,CAAY,EAAE,CAAC;IAC9D,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,IAAI,EAAE,cAAc,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC;IAC/C,CAAC;AACH,CAAC;AAED,SAAS,iBAAiB,CAAC,WAA0B;IACnD,MAAM,SAAS,GAAG,WAAW,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,IAAI,EAAE,CAAC;IACzE,OAAO,SAAS,KAAK,kBAAkB,IAAI,SAAS,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;AACzE,CAAC;AAED,SAAS,WAAW,CAClB,IAAyB,EACzB,QAAkB,EAClB,IAAiB;IAEjB,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC,kBAAkB,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IAC9E,OAAO,IAAI,iBAAiB,CAAC;QAC3B,OAAO,EAAE,IAAI;QACb,MAAM,EAAE,QAAQ,CAAC,MAAM;QACvB,IAAI,EAAE,QAAQ,EAAE,KAAK,CAAC,IAAI,IAAI,IAAI;QAClC,UAAU,EAAE,QAAQ,EAAE,KAAK,CAAC,OAAO,IAAI,IAAI;QAC3C,SAAS,EAAE,QAAQ,EAAE,KAAK,CAAC,SAAS,IAAI,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC;QAC5E,OAAO,EAAE,IAAI,CAAC,KAAK;KACpB,CAAC,CAAC;AACL,CAAC","sourcesContent":["import { parseErrorEnvelope } from '../contracts/index.js';\nimport {\n PixelumeAbortError,\n PixelumeConfigError,\n PixelumeHttpError,\n PixelumeInvalidResponseError,\n PixelumeNetworkError,\n PixelumeTimeoutError,\n type PixelumeRequestInfo,\n} from '../core/errors.js';\nimport { validateTimeout } from '../core/config.js';\nimport type {\n FetchLike,\n HttpClientOptions,\n HttpMethod,\n HttpRequest,\n HttpResponse,\n} from './types.js';\nimport { buildUrl, normalizePath } from './url.js';\n\n/**\n * Headers that only the runtime (or the SDK itself) may set. `origin`/`referer` in particular\n * must never be forged: the API uses them as a CSRF defence and the SDK does not rely on them.\n */\nconst RESERVED_HEADERS = new Set([\n 'content-length',\n 'cookie',\n 'cookie2',\n 'host',\n 'origin',\n 'referer',\n 'set-cookie',\n]);\n\ntype DecodedBody =\n | { readonly type: 'empty'; readonly value: undefined }\n | { readonly type: 'json'; readonly value: unknown }\n | { readonly type: 'text'; readonly value: string }\n | { readonly type: 'invalid_json'; readonly value: string };\n\n/**\n * Small typed HTTP client over `fetch`. Internal to the SDK: feature modules build on it,\n * consumers use the modules.\n *\n * Policy:\n * - timeout covers the whole exchange, including reading the body;\n * - the caller's `AbortSignal` is honoured before and during the request;\n * - no automatic retries (the caller decides; see `docs/architecture.md`);\n * - credentials default to the client policy and can be overridden per request.\n */\nexport class HttpClient {\n readonly #baseUrl: string;\n readonly #fetch: FetchLike;\n readonly #timeoutMs: number;\n readonly #credentials: HttpClientOptions['credentials'];\n\n constructor(options: HttpClientOptions) {\n this.#baseUrl = options.baseUrl;\n this.#fetch = options.fetch;\n this.#timeoutMs = options.timeoutMs;\n this.#credentials = options.credentials;\n }\n\n async request<T>(request: HttpRequest<T>): Promise<HttpResponse<T>> {\n const info: PixelumeRequestInfo = { method: request.method, path: normalizePath(request.path) };\n const url = buildUrl(this.#baseUrl, request.path, request.query);\n const timeoutMs =\n request.timeoutMs === undefined ? this.#timeoutMs : validateTimeout(request.timeoutMs);\n const init: RequestInit = {\n method: request.method,\n headers: buildHeaders(request),\n credentials: request.credentials ?? this.#credentials,\n };\n if (request.json !== undefined) init.body = JSON.stringify(request.json);\n\n const callerSignal = request.signal;\n if (callerSignal?.aborted) throw new PixelumeAbortError(info, { cause: callerSignal.reason });\n\n // Timeout and caller abort share one controller; the first abort reason wins.\n const controller = new AbortController();\n const timeoutReason = new DOMException(`Timed out after ${timeoutMs} ms`, 'TimeoutError');\n const timer = setTimeout(() => {\n controller.abort(timeoutReason);\n }, timeoutMs);\n const onCallerAbort = () => {\n controller.abort(callerSignal?.reason);\n };\n callerSignal?.addEventListener('abort', onCallerAbort, { once: true });\n // Settles on abort even if an injected `fetch` ignores the signal.\n const abortion = new Promise<never>((_, reject) => {\n controller.signal.addEventListener(\n 'abort',\n () => {\n reject(controller.signal.reason as Error);\n },\n { once: true },\n );\n });\n abortion.catch(() => undefined);\n\n // The function is invoked without a receiver on purpose: calling a browser's native `fetch`\n // with `this` bound to another object throws \"Illegal invocation\".\n const fetchFn = this.#fetch;\n let response: Response;\n let body: DecodedBody;\n try {\n const pending = Promise.resolve().then(() =>\n fetchFn(url, { ...init, signal: controller.signal }),\n );\n // Promise.race subscribes to every contender, so a losing promise that rejects later is\n // handled and never surfaces as an unhandled rejection.\n response = await Promise.race([pending, abortion]);\n body = await Promise.race([readBody(response, request.method), abortion]);\n } catch (error) {\n if (controller.signal.reason === timeoutReason) {\n throw new PixelumeTimeoutError(info, timeoutMs, { cause: error });\n }\n if (callerSignal?.aborted) {\n throw new PixelumeAbortError(info, { cause: callerSignal.reason });\n }\n throw new PixelumeNetworkError(info, { cause: error });\n } finally {\n clearTimeout(timer);\n callerSignal?.removeEventListener('abort', onCallerAbort);\n }\n\n if (!response.ok) throw toHttpError(info, response, body);\n if (body.type === 'invalid_json') {\n throw new PixelumeInvalidResponseError(info, response.status, 'body is not valid JSON');\n }\n const parsed = request.parse(body.value);\n if (!parsed.ok) throw new PixelumeInvalidResponseError(info, response.status, parsed.reason);\n return { status: response.status, headers: response.headers, data: parsed.value };\n }\n}\n\nfunction buildHeaders(request: HttpRequest<unknown>): Headers {\n const headers = new Headers({ accept: 'application/json' });\n for (const [name, value] of Object.entries(request.headers ?? {})) {\n if (RESERVED_HEADERS.has(name.toLowerCase())) {\n throw new PixelumeConfigError(`Header \"${name}\" is managed by the runtime and cannot be set`);\n }\n headers.set(name, value);\n }\n if (request.json !== undefined) headers.set('content-type', 'application/json');\n return headers;\n}\n\nasync function readBody(response: Response, method: HttpMethod): Promise<DecodedBody> {\n if (method === 'HEAD' || [204, 205, 304].includes(response.status)) {\n return { type: 'empty', value: undefined };\n }\n const text = await response.text();\n if (text === '') return { type: 'empty', value: undefined };\n if (!isJsonContentType(response.headers.get('content-type')))\n return { type: 'text', value: text };\n try {\n return { type: 'json', value: JSON.parse(text) as unknown };\n } catch {\n return { type: 'invalid_json', value: text };\n }\n}\n\nfunction isJsonContentType(contentType: string | null): boolean {\n const mediaType = contentType?.split(';')[0]?.trim().toLowerCase() ?? '';\n return mediaType === 'application/json' || mediaType.endsWith('+json');\n}\n\nfunction toHttpError(\n info: PixelumeRequestInfo,\n response: Response,\n body: DecodedBody,\n): PixelumeHttpError {\n const envelope = body.type === 'json' ? parseErrorEnvelope(body.value) : null;\n return new PixelumeHttpError({\n request: info,\n status: response.status,\n code: envelope?.error.code ?? null,\n apiMessage: envelope?.error.message ?? null,\n requestId: envelope?.error.requestId ?? response.headers.get('x-request-id'),\n payload: body.value,\n });\n}\n"]}
@@ -0,0 +1,34 @@
1
+ import type { BodyParser } from '../contracts/index.js';
2
+ export type HttpMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
3
+ /** Credentials policy forwarded to `fetch`. */
4
+ export type CredentialsPolicy = 'omit' | 'same-origin' | 'include';
5
+ /** Minimal `fetch` signature used by the SDK, compatible with browsers, Node.js 22+ and workers. */
6
+ export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
7
+ export type QueryValue = string | number | boolean | undefined;
8
+ export interface HttpClientOptions {
9
+ /** Absolute, already validated base URL without trailing slash. */
10
+ baseUrl: string;
11
+ fetch: FetchLike;
12
+ timeoutMs: number;
13
+ credentials: CredentialsPolicy;
14
+ }
15
+ export interface HttpRequest<T> {
16
+ method: HttpMethod;
17
+ /** Path relative to the base URL, e.g. `/health`. Absolute URLs are rejected. */
18
+ path: string;
19
+ query?: Readonly<Record<string, QueryValue>>;
20
+ /** Serialized with `JSON.stringify` and sent as `application/json`. */
21
+ json?: unknown;
22
+ /** Extra headers. Headers the SDK must control (cookie, origin, host…) are rejected. */
23
+ headers?: Readonly<Record<string, string>>;
24
+ signal?: AbortSignal | undefined;
25
+ timeoutMs?: number | undefined;
26
+ credentials?: CredentialsPolicy;
27
+ /** Validates the decoded body of a 2xx response (JSON value, text or `undefined` when empty). */
28
+ parse: BodyParser<T>;
29
+ }
30
+ export interface HttpResponse<T> {
31
+ status: number;
32
+ headers: Headers;
33
+ data: T;
34
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/http/types.ts"],"names":[],"mappings":"","sourcesContent":["import type { BodyParser } from '../contracts/index.js';\n\nexport type HttpMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';\n\n/** Credentials policy forwarded to `fetch`. */\nexport type CredentialsPolicy = 'omit' | 'same-origin' | 'include';\n\n/** Minimal `fetch` signature used by the SDK, compatible with browsers, Node.js 22+ and workers. */\nexport type FetchLike = (input: string, init: RequestInit) => Promise<Response>;\n\nexport type QueryValue = string | number | boolean | undefined;\n\nexport interface HttpClientOptions {\n /** Absolute, already validated base URL without trailing slash. */\n baseUrl: string;\n fetch: FetchLike;\n timeoutMs: number;\n credentials: CredentialsPolicy;\n}\n\nexport interface HttpRequest<T> {\n method: HttpMethod;\n /** Path relative to the base URL, e.g. `/health`. Absolute URLs are rejected. */\n path: string;\n query?: Readonly<Record<string, QueryValue>>;\n /** Serialized with `JSON.stringify` and sent as `application/json`. */\n json?: unknown;\n /** Extra headers. Headers the SDK must control (cookie, origin, host…) are rejected. */\n headers?: Readonly<Record<string, string>>;\n signal?: AbortSignal | undefined;\n timeoutMs?: number | undefined;\n credentials?: CredentialsPolicy;\n /** Validates the decoded body of a 2xx response (JSON value, text or `undefined` when empty). */\n parse: BodyParser<T>;\n}\n\nexport interface HttpResponse<T> {\n status: number;\n headers: Headers;\n data: T;\n}\n"]}
@@ -0,0 +1,7 @@
1
+ import type { QueryValue } from './types.js';
2
+ /**
3
+ * Joins `baseUrl` and `path` without duplicating slashes and appends an encoded query string.
4
+ * Only relative paths are accepted so a path can never redirect a request to another host.
5
+ */
6
+ export declare function buildUrl(baseUrl: string, path: string, query?: Readonly<Record<string, QueryValue>>): string;
7
+ export declare function normalizePath(path: string): string;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Joins `baseUrl` and `path` without duplicating slashes and appends an encoded query string.
3
+ * Only relative paths are accepted so a path can never redirect a request to another host.
4
+ */
5
+ export function buildUrl(baseUrl, path, query) {
6
+ return `${baseUrl.replace(/\/+$/u, '')}${normalizePath(path)}${encodeQuery(query)}`;
7
+ }
8
+ export function normalizePath(path) {
9
+ if (/^[a-z][a-z\d+.-]*:/iu.test(path) || path.startsWith('//') || path.includes('\\')) {
10
+ throw new TypeError('Request path must be relative to the API base URL');
11
+ }
12
+ if (/[?#]/u.test(path)) {
13
+ throw new TypeError('Request path must not contain a query string or fragment; use `query`');
14
+ }
15
+ const normalized = `/${path}`.replace(/\/{2,}/gu, '/');
16
+ if (normalized.split('/').some((segment) => segment === '..' || segment === '.')) {
17
+ throw new TypeError('Request path must not contain dot segments');
18
+ }
19
+ return normalized;
20
+ }
21
+ function encodeQuery(query) {
22
+ if (!query)
23
+ return '';
24
+ const params = new URLSearchParams();
25
+ for (const [key, value] of Object.entries(query)) {
26
+ if (value !== undefined)
27
+ params.append(key, String(value));
28
+ }
29
+ const encoded = params.toString();
30
+ return encoded ? `?${encoded}` : '';
31
+ }
32
+ //# sourceMappingURL=url.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"url.js","sourceRoot":"","sources":["../../src/http/url.ts"],"names":[],"mappings":"AAEA;;;GAGG;AACH,MAAM,UAAU,QAAQ,CACtB,OAAe,EACf,IAAY,EACZ,KAA4C;IAE5C,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,GAAG,aAAa,CAAC,IAAI,CAAC,GAAG,WAAW,CAAC,KAAK,CAAC,EAAE,CAAC;AACtF,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,IAAY;IACxC,IAAI,sBAAsB,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;QACtF,MAAM,IAAI,SAAS,CAAC,mDAAmD,CAAC,CAAC;IAC3E,CAAC;IACD,IAAI,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;QACvB,MAAM,IAAI,SAAS,CAAC,uEAAuE,CAAC,CAAC;IAC/F,CAAC;IACD,MAAM,UAAU,GAAG,IAAI,IAAI,EAAE,CAAC,OAAO,CAAC,UAAU,EAAE,GAAG,CAAC,CAAC;IACvD,IAAI,UAAU,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,KAAK,IAAI,IAAI,OAAO,KAAK,GAAG,CAAC,EAAE,CAAC;QACjF,MAAM,IAAI,SAAS,CAAC,4CAA4C,CAAC,CAAC;IACpE,CAAC;IACD,OAAO,UAAU,CAAC;AACpB,CAAC;AAED,SAAS,WAAW,CAAC,KAA4C;IAC/D,IAAI,CAAC,KAAK;QAAE,OAAO,EAAE,CAAC;IACtB,MAAM,MAAM,GAAG,IAAI,eAAe,EAAE,CAAC;IACrC,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QACjD,IAAI,KAAK,KAAK,SAAS;YAAE,MAAM,CAAC,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;IAC7D,CAAC;IACD,MAAM,OAAO,GAAG,MAAM,CAAC,QAAQ,EAAE,CAAC;IAClC,OAAO,OAAO,CAAC,CAAC,CAAC,IAAI,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;AACtC,CAAC","sourcesContent":["import type { QueryValue } from './types.js';\n\n/**\n * Joins `baseUrl` and `path` without duplicating slashes and appends an encoded query string.\n * Only relative paths are accepted so a path can never redirect a request to another host.\n */\nexport function buildUrl(\n baseUrl: string,\n path: string,\n query?: Readonly<Record<string, QueryValue>>,\n): string {\n return `${baseUrl.replace(/\\/+$/u, '')}${normalizePath(path)}${encodeQuery(query)}`;\n}\n\nexport function normalizePath(path: string): string {\n if (/^[a-z][a-z\\d+.-]*:/iu.test(path) || path.startsWith('//') || path.includes('\\\\')) {\n throw new TypeError('Request path must be relative to the API base URL');\n }\n if (/[?#]/u.test(path)) {\n throw new TypeError('Request path must not contain a query string or fragment; use `query`');\n }\n const normalized = `/${path}`.replace(/\\/{2,}/gu, '/');\n if (normalized.split('/').some((segment) => segment === '..' || segment === '.')) {\n throw new TypeError('Request path must not contain dot segments');\n }\n return normalized;\n}\n\nfunction encodeQuery(query?: Readonly<Record<string, QueryValue>>): string {\n if (!query) return '';\n const params = new URLSearchParams();\n for (const [key, value] of Object.entries(query)) {\n if (value !== undefined) params.append(key, String(value));\n }\n const encoded = params.toString();\n return encoded ? `?${encoded}` : '';\n}\n"]}
@@ -0,0 +1,7 @@
1
+ export { PixelumeClient } from './core/PixelumeClient.js';
2
+ export { DEFAULT_API_URL, DEFAULT_TIMEOUT_MS } from './core/config.js';
3
+ export { SDK_VERSION } from './version.js';
4
+ export { PixelumeError, PixelumeConfigError, PixelumeRequestError, PixelumeHttpError, PixelumeNetworkError, PixelumeTimeoutError, PixelumeAbortError, PixelumeInvalidResponseError, isPixelumeError, } from './core/errors.js';
5
+ export type { PixelumeErrorKind, PixelumeApiErrorCode, PixelumeRequestInfo, } from './core/errors.js';
6
+ export type { PixelumeClientOptions, PixelumeClientConfig, PixelumeRequestOptions, PixelumeCredentialsPolicy, PixelumeFetch, } from './core/types.js';
7
+ export type { PixelumeHealthApi, PixelumeHealthStatus, PixelumeRegistrationMode, } from './modules/health/HealthModule.js';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { PixelumeClient } from './core/PixelumeClient.js';
2
+ export { DEFAULT_API_URL, DEFAULT_TIMEOUT_MS } from './core/config.js';
3
+ export { SDK_VERSION } from './version.js';
4
+ export { PixelumeError, PixelumeConfigError, PixelumeRequestError, PixelumeHttpError, PixelumeNetworkError, PixelumeTimeoutError, PixelumeAbortError, PixelumeInvalidResponseError, isPixelumeError, } from './core/errors.js';
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAC1D,OAAO,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACvE,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAC3C,OAAO,EACL,aAAa,EACb,mBAAmB,EACnB,oBAAoB,EACpB,iBAAiB,EACjB,oBAAoB,EACpB,oBAAoB,EACpB,kBAAkB,EAClB,4BAA4B,EAC5B,eAAe,GAChB,MAAM,kBAAkB,CAAC","sourcesContent":["export { PixelumeClient } from './core/PixelumeClient.js';\nexport { DEFAULT_API_URL, DEFAULT_TIMEOUT_MS } from './core/config.js';\nexport { SDK_VERSION } from './version.js';\nexport {\n PixelumeError,\n PixelumeConfigError,\n PixelumeRequestError,\n PixelumeHttpError,\n PixelumeNetworkError,\n PixelumeTimeoutError,\n PixelumeAbortError,\n PixelumeInvalidResponseError,\n isPixelumeError,\n} from './core/errors.js';\nexport type {\n PixelumeErrorKind,\n PixelumeApiErrorCode,\n PixelumeRequestInfo,\n} from './core/errors.js';\nexport type {\n PixelumeClientOptions,\n PixelumeClientConfig,\n PixelumeRequestOptions,\n PixelumeCredentialsPolicy,\n PixelumeFetch,\n} from './core/types.js';\nexport type {\n PixelumeHealthApi,\n PixelumeHealthStatus,\n PixelumeRegistrationMode,\n} from './modules/health/HealthModule.js';\n"]}
@@ -0,0 +1,26 @@
1
+ import type { PixelumeRequestOptions } from '../../core/types.js';
2
+ import type { HttpClient } from '../../http/HttpClient.js';
3
+ /** Registration modes documented by the API. Unknown future values are passed through. */
4
+ export type PixelumeRegistrationMode = 'disabled' | 'development' | 'email-verification' | (string & {});
5
+ export interface PixelumeHealthStatus {
6
+ status: 'ok';
7
+ /** Public registration mode configured on the API. */
8
+ registration: PixelumeRegistrationMode;
9
+ /** Commit SHA of the deployed API, or `null` when the API does not report it. */
10
+ release: string | null;
11
+ }
12
+ export interface PixelumeHealthApi {
13
+ /**
14
+ * Calls `GET /health` without credentials.
15
+ *
16
+ * Browser note: the API does not send CORS headers on `/health` (only on `/v1/*`), so from a
17
+ * page served on another origin the browser blocks the response and this rejects with
18
+ * `PixelumeNetworkError`. It works server-side (Node.js, SSR, workers, tooling) and same-origin.
19
+ */
20
+ check(options?: PixelumeRequestOptions): Promise<PixelumeHealthStatus>;
21
+ }
22
+ export declare class HealthModule implements PixelumeHealthApi {
23
+ #private;
24
+ constructor(http: HttpClient);
25
+ check(options?: PixelumeRequestOptions): Promise<PixelumeHealthStatus>;
26
+ }
@@ -0,0 +1,23 @@
1
+ import { parseHealthResponse } from '../../contracts/index.js';
2
+ export class HealthModule {
3
+ #http;
4
+ constructor(http) {
5
+ this.#http = http;
6
+ }
7
+ async check(options = {}) {
8
+ const response = await this.#http.request({
9
+ method: 'GET',
10
+ path: '/health',
11
+ credentials: 'omit',
12
+ signal: options.signal,
13
+ timeoutMs: options.timeoutMs,
14
+ parse: parseHealthResponse,
15
+ });
16
+ return {
17
+ status: response.data.status,
18
+ registration: response.data.registration,
19
+ release: response.data.release ?? null,
20
+ };
21
+ }
22
+ }
23
+ //# sourceMappingURL=HealthModule.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"HealthModule.js","sourceRoot":"","sources":["../../../src/modules/health/HealthModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AA2B/D,MAAM,OAAO,YAAY;IACd,KAAK,CAAa;IAE3B,YAAY,IAAgB;QAC1B,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC;IACpB,CAAC;IAED,KAAK,CAAC,KAAK,CAAC,UAAkC,EAAE;QAC9C,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC;YACxC,MAAM,EAAE,KAAK;YACb,IAAI,EAAE,SAAS;YACf,WAAW,EAAE,MAAM;YACnB,MAAM,EAAE,OAAO,CAAC,MAAM;YACtB,SAAS,EAAE,OAAO,CAAC,SAAS;YAC5B,KAAK,EAAE,mBAAmB;SAC3B,CAAC,CAAC;QACH,OAAO;YACL,MAAM,EAAE,QAAQ,CAAC,IAAI,CAAC,MAAM;YAC5B,YAAY,EAAE,QAAQ,CAAC,IAAI,CAAC,YAAY;YACxC,OAAO,EAAE,QAAQ,CAAC,IAAI,CAAC,OAAO,IAAI,IAAI;SACvC,CAAC;IACJ,CAAC;CACF","sourcesContent":["import { parseHealthResponse } from '../../contracts/index.js';\nimport type { PixelumeRequestOptions } from '../../core/types.js';\nimport type { HttpClient } from '../../http/HttpClient.js';\n\n/** Registration modes documented by the API. Unknown future values are passed through. */\nexport type PixelumeRegistrationMode =\n 'disabled' | 'development' | 'email-verification' | (string & {});\n\nexport interface PixelumeHealthStatus {\n status: 'ok';\n /** Public registration mode configured on the API. */\n registration: PixelumeRegistrationMode;\n /** Commit SHA of the deployed API, or `null` when the API does not report it. */\n release: string | null;\n}\n\nexport interface PixelumeHealthApi {\n /**\n * Calls `GET /health` without credentials.\n *\n * Browser note: the API does not send CORS headers on `/health` (only on `/v1/*`), so from a\n * page served on another origin the browser blocks the response and this rejects with\n * `PixelumeNetworkError`. It works server-side (Node.js, SSR, workers, tooling) and same-origin.\n */\n check(options?: PixelumeRequestOptions): Promise<PixelumeHealthStatus>;\n}\n\nexport class HealthModule implements PixelumeHealthApi {\n readonly #http: HttpClient;\n\n constructor(http: HttpClient) {\n this.#http = http;\n }\n\n async check(options: PixelumeRequestOptions = {}): Promise<PixelumeHealthStatus> {\n const response = await this.#http.request({\n method: 'GET',\n path: '/health',\n credentials: 'omit',\n signal: options.signal,\n timeoutMs: options.timeoutMs,\n parse: parseHealthResponse,\n });\n return {\n status: response.data.status,\n registration: response.data.registration,\n release: response.data.release ?? null,\n };\n }\n}\n"]}
@@ -0,0 +1,2 @@
1
+ /** Version of the SDK package. Kept in sync with `package.json` (checked by tests and by the release workflow). */
2
+ export declare const SDK_VERSION = "0.1.0";
@@ -0,0 +1,3 @@
1
+ /** Version of the SDK package. Kept in sync with `package.json` (checked by tests and by the release workflow). */
2
+ export const SDK_VERSION = '0.1.0';
3
+ //# sourceMappingURL=version.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"version.js","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,mHAAmH;AACnH,MAAM,CAAC,MAAM,WAAW,GAAG,OAAO,CAAC","sourcesContent":["/** Version of the SDK package. Kept in sync with `package.json` (checked by tests and by the release workflow). */\nexport const SDK_VERSION = '0.1.0';\n"]}