@archora/forge-runtime 1.2.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,68 @@
1
+ type ApiClientOptions = {
2
+ baseUrl: string;
3
+ headers?: Record<string, string>;
4
+ getHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
5
+ auth?: ApiAuthOptions;
6
+ fetchImpl?: typeof fetch;
7
+ timeoutMs?: number;
8
+ retry?: ApiRetryOptions;
9
+ onRequest?: (request: {
10
+ method: string;
11
+ url: string;
12
+ options?: ApiRequestOptions;
13
+ }) => void | Promise<void>;
14
+ onResponse?: (response: Response) => void | Promise<void>;
15
+ onError?: (error: unknown) => void | Promise<void>;
16
+ };
17
+ type ApiRetryOptions = {
18
+ attempts?: number;
19
+ delayMs?: number;
20
+ methods?: string[];
21
+ statuses?: number[];
22
+ };
23
+ type ApiQueryParamOptions = {
24
+ style?: 'form';
25
+ explode?: boolean;
26
+ };
27
+ type ApiQueryParam = ApiQueryParamOptions & {
28
+ value: unknown;
29
+ };
30
+ type ApiAuthValue = string | (() => string | Promise<string>);
31
+ type ApiAuthOptions = {
32
+ type: 'bearer';
33
+ token: ApiAuthValue;
34
+ } | {
35
+ type: 'apiKey';
36
+ headerName: string;
37
+ value: ApiAuthValue;
38
+ };
39
+ type ApiRequestOptions = {
40
+ params?: Record<string, unknown | ApiQueryParam>;
41
+ body?: unknown;
42
+ headers?: Record<string, string>;
43
+ signal?: AbortSignal;
44
+ timeoutMs?: number;
45
+ };
46
+ type ApiClient = {
47
+ request: <TResponse>(method: string, path: string, options?: ApiRequestOptions) => Promise<TResponse>;
48
+ };
49
+ declare class ForgeHttpError<TBody = unknown> extends Error {
50
+ readonly status: number;
51
+ readonly statusText: string;
52
+ readonly body: TBody;
53
+ readonly method: string;
54
+ readonly url: string;
55
+ constructor(input: {
56
+ status: number;
57
+ statusText: string;
58
+ body: TBody;
59
+ method: string;
60
+ url: string;
61
+ });
62
+ }
63
+ declare function isForgeHttpError<TBody = unknown>(error: unknown): error is ForgeHttpError<TBody>;
64
+ declare function createApiClient(options: ApiClientOptions): ApiClient;
65
+ declare function createApiClientOptions(options: ApiClientOptions): ApiClient;
66
+ declare function queryParam(value: unknown, options?: ApiQueryParamOptions): ApiQueryParam;
67
+
68
+ export { type ApiAuthOptions, type ApiAuthValue, type ApiClient, type ApiClientOptions, type ApiQueryParam, type ApiQueryParamOptions, type ApiRequestOptions, type ApiRetryOptions, ForgeHttpError, createApiClient, createApiClientOptions, isForgeHttpError, queryParam };
package/dist/index.js ADDED
@@ -0,0 +1,219 @@
1
+ // src/index.ts
2
+ var ForgeHttpError = class extends Error {
3
+ status;
4
+ statusText;
5
+ body;
6
+ method;
7
+ url;
8
+ constructor(input) {
9
+ super(`${input.method} ${input.url} failed with ${input.status} ${input.statusText}`.trim());
10
+ this.name = "ForgeHttpError";
11
+ this.status = input.status;
12
+ this.statusText = input.statusText;
13
+ this.body = input.body;
14
+ this.method = input.method;
15
+ this.url = input.url;
16
+ }
17
+ };
18
+ function isForgeHttpError(error) {
19
+ return error instanceof ForgeHttpError;
20
+ }
21
+ function createApiClient(options) {
22
+ return {
23
+ async request(method, path, requestOptions = {}) {
24
+ const fetchImpl = options.fetchImpl ?? fetch;
25
+ const url = buildUrl(options.baseUrl, path, requestOptions.params);
26
+ const headers = await buildHeaders(options, requestOptions);
27
+ const abortScope = createAbortScope(requestOptions.signal, requestOptions.timeoutMs ?? options.timeoutMs);
28
+ const init = {
29
+ method,
30
+ headers,
31
+ signal: abortScope.signal
32
+ };
33
+ if (requestOptions.body !== void 0 && method.toUpperCase() !== "GET") {
34
+ const body = requestOptions.body;
35
+ init.body = isNativeRequestBody(body) ? body : JSON.stringify(body);
36
+ if (!isNativeRequestBody(body) && !hasHeader(headers, "content-type")) {
37
+ headers["content-type"] = "application/json";
38
+ }
39
+ }
40
+ await options.onRequest?.({ method, url, options: requestOptions });
41
+ try {
42
+ const retry = normalizeRetryOptions(options.retry);
43
+ let attempt = 0;
44
+ while (true) {
45
+ attempt += 1;
46
+ try {
47
+ const response = await fetchImpl(url, init);
48
+ await options.onResponse?.(response);
49
+ const body = await parseResponseBody(response);
50
+ if (!response.ok) {
51
+ const error = new ForgeHttpError({ status: response.status, statusText: response.statusText, body, method, url });
52
+ if (shouldRetryHttpError(error, method, retry, attempt)) {
53
+ await wait(retry.delayMs);
54
+ continue;
55
+ }
56
+ throw error;
57
+ }
58
+ return body;
59
+ } catch (error) {
60
+ if (error instanceof ForgeHttpError || isAbortError(error) || !shouldRetryNetworkError(method, retry, attempt)) {
61
+ throw error;
62
+ }
63
+ await wait(retry.delayMs);
64
+ }
65
+ }
66
+ } catch (error) {
67
+ await options.onError?.(error);
68
+ throw error;
69
+ } finally {
70
+ abortScope.cleanup();
71
+ }
72
+ }
73
+ };
74
+ }
75
+ function createApiClientOptions(options) {
76
+ return createApiClient(options);
77
+ }
78
+ function queryParam(value, options = {}) {
79
+ return { value, ...options };
80
+ }
81
+ function buildUrl(baseUrl, path, params) {
82
+ if (baseUrl === "") {
83
+ return buildRelativeUrl(path, params);
84
+ }
85
+ const url = new URL(path, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`);
86
+ for (const [key, value] of Object.entries(params ?? {})) {
87
+ appendQueryParam(url.searchParams, key, value);
88
+ }
89
+ return url.toString();
90
+ }
91
+ function buildRelativeUrl(path, params) {
92
+ const searchParams = new URLSearchParams();
93
+ for (const [key, value] of Object.entries(params ?? {})) {
94
+ appendQueryParam(searchParams, key, value);
95
+ }
96
+ const query = searchParams.toString();
97
+ if (!query) return path;
98
+ return `${path}${path.includes("?") ? "&" : "?"}${query}`;
99
+ }
100
+ function appendQueryParam(searchParams, key, input) {
101
+ const param = isApiQueryParam(input) ? input : { value: input };
102
+ const { value } = param;
103
+ if (value === void 0 || value === null) return;
104
+ if (!Array.isArray(value)) {
105
+ searchParams.append(key, String(value));
106
+ return;
107
+ }
108
+ const items = value.filter((item) => item !== void 0 && item !== null);
109
+ if (items.length === 0) return;
110
+ if (param.style === "form" && param.explode === false) {
111
+ searchParams.append(key, items.map(String).join(","));
112
+ return;
113
+ }
114
+ for (const item of items) {
115
+ searchParams.append(key, String(item));
116
+ }
117
+ }
118
+ function isApiQueryParam(value) {
119
+ return typeof value === "object" && value !== null && "value" in value && ("style" in value || "explode" in value);
120
+ }
121
+ async function buildHeaders(options, requestOptions) {
122
+ return {
123
+ ...options.headers ?? {},
124
+ ...await buildAuthHeaders(options.auth),
125
+ ...await options.getHeaders?.() ?? {},
126
+ ...requestOptions.headers ?? {}
127
+ };
128
+ }
129
+ async function buildAuthHeaders(auth) {
130
+ if (!auth) return {};
131
+ if (auth.type === "bearer") {
132
+ const token = await resolveAuthValue(auth.token);
133
+ return token ? { authorization: `Bearer ${token}` } : {};
134
+ }
135
+ const value = await resolveAuthValue(auth.value);
136
+ return value ? { [auth.headerName]: value } : {};
137
+ }
138
+ async function resolveAuthValue(value) {
139
+ return typeof value === "function" ? value() : value;
140
+ }
141
+ async function parseResponseBody(response) {
142
+ if (response.status === 204) return void 0;
143
+ const contentType = response.headers.get("content-type") ?? "";
144
+ if (isJsonContentType(contentType)) {
145
+ const text = await response.text();
146
+ return text.trim() ? JSON.parse(text) : void 0;
147
+ }
148
+ if (isBinaryContentType(contentType)) {
149
+ return response.blob();
150
+ }
151
+ return response.text();
152
+ }
153
+ function isJsonContentType(contentType) {
154
+ const normalized = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
155
+ return normalized === "application/json" || normalized.endsWith("+json");
156
+ }
157
+ function isBinaryContentType(contentType) {
158
+ const normalized = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
159
+ return normalized === "application/octet-stream" || normalized === "application/pdf" || normalized.startsWith("image/");
160
+ }
161
+ function hasHeader(headers, name) {
162
+ return Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase());
163
+ }
164
+ function isNativeRequestBody(body) {
165
+ return typeof body === "string" || isInstanceOfGlobal(body, "FormData") || isInstanceOfGlobal(body, "URLSearchParams") || isInstanceOfGlobal(body, "Blob") || isInstanceOfGlobal(body, "ArrayBuffer") || isInstanceOfGlobal(body, "ReadableStream") || ArrayBuffer.isView(body);
166
+ }
167
+ function isInstanceOfGlobal(value, name) {
168
+ const constructor = globalThis[name];
169
+ return typeof constructor === "function" && value instanceof constructor;
170
+ }
171
+ function normalizeRetryOptions(retry) {
172
+ return {
173
+ attempts: Math.max(1, retry?.attempts ?? 1),
174
+ delayMs: Math.max(0, retry?.delayMs ?? 0),
175
+ methods: retry?.methods ?? ["GET", "HEAD", "OPTIONS"],
176
+ statuses: retry?.statuses ?? [408, 429, 500, 502, 503, 504]
177
+ };
178
+ }
179
+ function shouldRetryHttpError(error, method, retry, attempt) {
180
+ return attempt < retry.attempts && retry.methods.includes(method.toUpperCase()) && retry.statuses.includes(error.status);
181
+ }
182
+ function shouldRetryNetworkError(method, retry, attempt) {
183
+ return attempt < retry.attempts && retry.methods.includes(method.toUpperCase());
184
+ }
185
+ function createAbortScope(signal, timeoutMs) {
186
+ if (timeoutMs === void 0) {
187
+ return { signal, cleanup: () => {
188
+ } };
189
+ }
190
+ const controller = new AbortController();
191
+ const abortFromSignal = () => controller.abort(signal?.reason);
192
+ const timeout = setTimeout(() => controller.abort(new DOMException(`Request timed out after ${timeoutMs}ms`, "TimeoutError")), Math.max(0, timeoutMs));
193
+ if (signal?.aborted) {
194
+ abortFromSignal();
195
+ } else {
196
+ signal?.addEventListener("abort", abortFromSignal, { once: true });
197
+ }
198
+ return {
199
+ signal: controller.signal,
200
+ cleanup: () => {
201
+ clearTimeout(timeout);
202
+ signal?.removeEventListener("abort", abortFromSignal);
203
+ }
204
+ };
205
+ }
206
+ function isAbortError(error) {
207
+ return error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError") || error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
208
+ }
209
+ async function wait(delayMs) {
210
+ if (delayMs <= 0) return;
211
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
212
+ }
213
+ export {
214
+ ForgeHttpError,
215
+ createApiClient,
216
+ createApiClientOptions,
217
+ isForgeHttpError,
218
+ queryParam
219
+ };
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@archora/forge-runtime",
3
+ "version": "1.2.0",
4
+ "description": "Runtime helpers used by Archora Forge generated frontend modules.",
5
+ "license": "SEE LICENSE IN LICENSE",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/archora-dev/archora-forge.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/archora-dev/archora-forge/issues"
12
+ },
13
+ "homepage": "https://github.com/archora-dev/archora-forge#readme",
14
+ "type": "module",
15
+ "main": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js"
21
+ }
22
+ },
23
+ "files": [
24
+ "dist"
25
+ ],
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "scripts": {
30
+ "build": "tsup src/index.ts --format esm --dts --clean",
31
+ "dev": "tsup src/index.ts --format esm --dts --watch",
32
+ "typecheck": "tsc --noEmit"
33
+ },
34
+ "devDependencies": {
35
+ "tsup": "^8.4.0",
36
+ "typescript": "^5.8.3"
37
+ }
38
+ }