@jskit-ai/http-runtime 0.1.4

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.
Files changed (38) hide show
  1. package/package.descriptor.mjs +83 -0
  2. package/package.json +24 -0
  3. package/src/client/index.js +14 -0
  4. package/src/client/providers/HttpClientRuntimeClientProvider.js +21 -0
  5. package/src/client/providers/HttpValidatorsClientProvider.js +14 -0
  6. package/src/client/validationErrors.js +23 -0
  7. package/src/server/providers/HttpClientRuntimeServiceProvider.js +21 -0
  8. package/src/server/providers/HttpValidatorsServiceProvider.js +14 -0
  9. package/src/shared/clientRuntime/client.js +632 -0
  10. package/src/shared/clientRuntime/errors.js +35 -0
  11. package/src/shared/clientRuntime/headers.js +28 -0
  12. package/src/shared/clientRuntime/index.js +4 -0
  13. package/src/shared/clientRuntime/retry.js +38 -0
  14. package/src/shared/index.js +30 -0
  15. package/src/shared/providers/singletonApiProvider.js +27 -0
  16. package/src/shared/support/fieldErrors.js +31 -0
  17. package/src/shared/validators/command.js +34 -0
  18. package/src/shared/validators/errorResponses.js +108 -0
  19. package/src/shared/validators/httpValidatorsApi.js +58 -0
  20. package/src/shared/validators/operationMessages.js +149 -0
  21. package/src/shared/validators/operationValidation.js +126 -0
  22. package/src/shared/validators/paginationQuery.js +32 -0
  23. package/src/shared/validators/resource.js +43 -0
  24. package/src/shared/validators/schemaUtils.js +9 -0
  25. package/src/shared/validators/typeboxFormats.js +43 -0
  26. package/test/client.test.js +246 -0
  27. package/test/command.test.js +49 -0
  28. package/test/entrypoints.boundary.test.js +36 -0
  29. package/test/errorResponses.test.js +84 -0
  30. package/test/operationMessages.test.js +93 -0
  31. package/test/operationValidation.test.js +137 -0
  32. package/test/paginationQuery.test.js +32 -0
  33. package/test/providerRuntime.httpClient.test.js +35 -0
  34. package/test/providerRuntime.validators.test.js +39 -0
  35. package/test/resource.test.js +94 -0
  36. package/test/retry.test.js +41 -0
  37. package/test/typeboxFormats.test.js +42 -0
  38. package/test/validationErrors.test.js +100 -0
@@ -0,0 +1,632 @@
1
+ import { createHttpError, createNetworkError } from "./errors.js";
2
+ import { hasHeader, setHeaderIfMissing } from "./headers.js";
3
+ import { DEFAULT_RETRYABLE_CSRF_ERROR_CODES, shouldRetryForCsrfFailure } from "./retry.js";
4
+
5
+ const DEFAULT_UNSAFE_METHODS = Object.freeze(["POST", "PUT", "PATCH", "DELETE"]);
6
+ const DEFAULT_NDJSON_CONTENT_TYPE = "application/x-ndjson";
7
+
8
+ function normalizeMethod(method) {
9
+ return String(method || "GET")
10
+ .trim()
11
+ .toUpperCase();
12
+ }
13
+
14
+ function resolveUnsafeMethods(value) {
15
+ const source = Array.isArray(value) ? value : DEFAULT_UNSAFE_METHODS;
16
+ return new Set(source.map((method) => normalizeMethod(method)).filter(Boolean));
17
+ }
18
+
19
+ function resolveFetch() {
20
+ if (typeof fetch === "function") {
21
+ return fetch;
22
+ }
23
+
24
+ throw new Error("createHttpClient requires fetchImpl when global fetch is unavailable.");
25
+ }
26
+
27
+ function isObjectBody(value) {
28
+ return Boolean(value) && typeof value === "object" && !(value instanceof FormData);
29
+ }
30
+
31
+ function parseJsonSafely(response) {
32
+ const contentType = String(response?.headers?.get?.("content-type") || "");
33
+ const isJson = contentType.includes("application/json");
34
+ if (!isJson) {
35
+ return Promise.resolve({
36
+ contentType,
37
+ isJson,
38
+ data: {}
39
+ });
40
+ }
41
+
42
+ return Promise.resolve(response?.json?.())
43
+ .catch(() => ({}))
44
+ .then((data) => ({
45
+ contentType,
46
+ isJson,
47
+ data: data && typeof data === "object" ? data : {}
48
+ }));
49
+ }
50
+
51
+ function emitNdjsonLine(line, handlers) {
52
+ const normalizedLine = String(line || "").trim();
53
+ if (!normalizedLine) {
54
+ return;
55
+ }
56
+
57
+ try {
58
+ const payload = JSON.parse(normalizedLine);
59
+ if (typeof handlers?.onEvent === "function") {
60
+ handlers.onEvent(payload);
61
+ }
62
+ } catch (error) {
63
+ if (typeof handlers?.onMalformedLine === "function") {
64
+ handlers.onMalformedLine(normalizedLine, error);
65
+ }
66
+ }
67
+ }
68
+
69
+ async function readNdjsonStream(response, handlers = {}) {
70
+ if (!response?.body || typeof response.body.getReader !== "function") {
71
+ if (typeof response?.text === "function") {
72
+ const rawText = await response.text().catch(() => "");
73
+ for (const line of String(rawText || "").split(/\r?\n/g)) {
74
+ emitNdjsonLine(line, handlers);
75
+ }
76
+ }
77
+ return;
78
+ }
79
+
80
+ const reader = response.body.getReader();
81
+ const decoder = new TextDecoder();
82
+ let buffered = "";
83
+
84
+ while (true) {
85
+ const { value, done } = await reader.read();
86
+ if (done) {
87
+ break;
88
+ }
89
+
90
+ buffered += decoder.decode(value, {
91
+ stream: true
92
+ });
93
+
94
+ const lines = buffered.split(/\r?\n/g);
95
+ buffered = lines.pop() || "";
96
+ for (const line of lines) {
97
+ emitNdjsonLine(line, handlers);
98
+ }
99
+ }
100
+
101
+ buffered += decoder.decode();
102
+ if (buffered) {
103
+ emitNdjsonLine(buffered, handlers);
104
+ }
105
+ }
106
+
107
+ function createHttpClient(options = {}) {
108
+ const configuredFetchImpl = typeof options.fetchImpl === "function" ? options.fetchImpl : null;
109
+ const unsafeMethods = resolveUnsafeMethods(options.unsafeMethods);
110
+ const hooks = options?.hooks && typeof options.hooks === "object" ? options.hooks : {};
111
+
112
+ const csrf = {
113
+ enabled: options?.csrf?.enabled !== false,
114
+ sessionPath: String(options?.csrf?.sessionPath || "/api/session"),
115
+ headerName: String(options?.csrf?.headerName || "csrf-token"),
116
+ tokenField: String(options?.csrf?.tokenField || "csrfToken"),
117
+ retryableErrorCodes: Array.isArray(options?.csrf?.retryableErrorCodes)
118
+ ? options.csrf.retryableErrorCodes
119
+ : DEFAULT_RETRYABLE_CSRF_ERROR_CODES
120
+ };
121
+
122
+ let csrfTokenCache = "";
123
+ let csrfFetchPromise = null;
124
+
125
+ function updateCsrfTokenFromPayload(data) {
126
+ const token = String(data?.[csrf.tokenField] || "");
127
+ if (token) {
128
+ csrfTokenCache = token;
129
+ }
130
+ }
131
+
132
+ async function fetchSessionForCsrf() {
133
+ const activeFetch = configuredFetchImpl || resolveFetch();
134
+ let response;
135
+ try {
136
+ response = await activeFetch(csrf.sessionPath, {
137
+ method: "GET",
138
+ credentials: String(options?.credentials || "same-origin")
139
+ });
140
+ } catch (cause) {
141
+ throw createNetworkError(cause);
142
+ }
143
+
144
+ const { data } = await parseJsonSafely(response);
145
+ updateCsrfTokenFromPayload(data);
146
+
147
+ if (!response.ok) {
148
+ throw createHttpError(response, data);
149
+ }
150
+
151
+ return data;
152
+ }
153
+
154
+ async function ensureCsrfToken(forceRefresh = false) {
155
+ if (!csrf.enabled) {
156
+ return "";
157
+ }
158
+
159
+ if (!forceRefresh && csrfTokenCache) {
160
+ return csrfTokenCache;
161
+ }
162
+
163
+ if (!csrfFetchPromise || forceRefresh) {
164
+ csrfFetchPromise = fetchSessionForCsrf().finally(() => {
165
+ csrfFetchPromise = null;
166
+ });
167
+ }
168
+
169
+ await csrfFetchPromise;
170
+ return csrfTokenCache;
171
+ }
172
+
173
+ function decorateHeaders({ url, method, headers, requestOptions, state, stream }) {
174
+ if (typeof hooks.decorateHeaders !== "function") {
175
+ return null;
176
+ }
177
+
178
+ return hooks.decorateHeaders({
179
+ url,
180
+ method,
181
+ headers,
182
+ requestOptions,
183
+ state,
184
+ stream
185
+ });
186
+ }
187
+
188
+ async function notifyFailure(payload) {
189
+ if (typeof hooks.onFailure === "function") {
190
+ await hooks.onFailure(payload);
191
+ }
192
+ }
193
+
194
+ async function notifySuccess(payload) {
195
+ if (typeof hooks.onSuccess === "function") {
196
+ await hooks.onSuccess(payload);
197
+ }
198
+ }
199
+
200
+ async function maybeRetry({ response, method, state, data, stream }) {
201
+ if (!csrf.enabled) {
202
+ return false;
203
+ }
204
+
205
+ const customShouldRetry =
206
+ typeof hooks.shouldRetryRequest === "function"
207
+ ? await hooks.shouldRetryRequest({
208
+ response,
209
+ method,
210
+ state,
211
+ data,
212
+ stream
213
+ })
214
+ : null;
215
+ const shouldRetry =
216
+ customShouldRetry == null
217
+ ? shouldRetryForCsrfFailure({
218
+ response,
219
+ method,
220
+ state,
221
+ data,
222
+ unsafeMethods,
223
+ retryableErrorCodes: csrf.retryableErrorCodes
224
+ })
225
+ : Boolean(customShouldRetry);
226
+ if (!shouldRetry) {
227
+ return false;
228
+ }
229
+
230
+ if (typeof hooks.onRetryableFailure === "function") {
231
+ await hooks.onRetryableFailure({
232
+ response,
233
+ method,
234
+ state,
235
+ data,
236
+ stream
237
+ });
238
+ }
239
+
240
+ csrfTokenCache = "";
241
+ await ensureCsrfToken(true);
242
+ state.csrfRetried = true;
243
+ return true;
244
+ }
245
+
246
+ function resolveRequestState(state) {
247
+ const resolvedState = state && typeof state === "object" ? state : {};
248
+ if (typeof resolvedState.csrfRetried !== "boolean") {
249
+ resolvedState.csrfRetried = false;
250
+ }
251
+
252
+ return resolvedState;
253
+ }
254
+
255
+ async function prepareRequestConfig(url, requestOptions, state, stream) {
256
+ const resolvedState = resolveRequestState(state);
257
+
258
+ const method = normalizeMethod(requestOptions.method);
259
+ const headers =
260
+ requestOptions.headers && typeof requestOptions.headers === "object" ? { ...requestOptions.headers } : {};
261
+
262
+ const decorateHeadersResult = decorateHeaders({
263
+ url,
264
+ method,
265
+ headers,
266
+ requestOptions,
267
+ state: resolvedState,
268
+ stream: Boolean(stream)
269
+ });
270
+ if (decorateHeadersResult && typeof decorateHeadersResult.then === "function") {
271
+ await decorateHeadersResult;
272
+ }
273
+
274
+ const config = {
275
+ credentials: String(options?.credentials || "same-origin"),
276
+ ...requestOptions,
277
+ method,
278
+ headers
279
+ };
280
+
281
+ if (isObjectBody(config.body)) {
282
+ setHeaderIfMissing(headers, "Content-Type", "application/json");
283
+ config.body = JSON.stringify(config.body);
284
+ }
285
+
286
+ if (csrf.enabled && unsafeMethods.has(method) && !hasHeader(headers, csrf.headerName)) {
287
+ const token = await ensureCsrfToken();
288
+ if (token) {
289
+ setHeaderIfMissing(headers, csrf.headerName, token);
290
+ }
291
+ }
292
+
293
+ return {
294
+ method,
295
+ config,
296
+ state: resolvedState
297
+ };
298
+ }
299
+
300
+ async function executePreparedRequest(url, config, { method, state }, onNetworkFailure) {
301
+ let response;
302
+ try {
303
+ const activeFetch = configuredFetchImpl || resolveFetch();
304
+ response = await activeFetch(url, config);
305
+ } catch (cause) {
306
+ return onNetworkFailure(cause);
307
+ }
308
+
309
+ const { contentType, isJson, data } = await parseJsonSafely(response);
310
+ updateCsrfTokenFromPayload(data);
311
+
312
+ return {
313
+ response,
314
+ data,
315
+ contentType,
316
+ isJson,
317
+ method,
318
+ state
319
+ };
320
+ }
321
+
322
+ async function handleHttpFailure({
323
+ url,
324
+ method,
325
+ state,
326
+ response,
327
+ data,
328
+ contentType,
329
+ isJson,
330
+ stream,
331
+ retryRequest
332
+ }) {
333
+ if (
334
+ await maybeRetry({
335
+ response,
336
+ method,
337
+ state,
338
+ data,
339
+ stream
340
+ })
341
+ ) {
342
+ return retryRequest();
343
+ }
344
+
345
+ const error = createHttpError(response, data);
346
+ if (Number(response.status) === 401 && typeof hooks.onUnauthorized === "function") {
347
+ await hooks.onUnauthorized(error);
348
+ }
349
+
350
+ await notifyFailure({
351
+ url,
352
+ method,
353
+ state,
354
+ reason: `http_${response.status}`,
355
+ error,
356
+ response,
357
+ data,
358
+ contentType,
359
+ isJson,
360
+ stream
361
+ });
362
+ throw error;
363
+ }
364
+
365
+ async function executeRequestLifecycle({
366
+ url,
367
+ requestOptions = {},
368
+ state = null,
369
+ stream = false,
370
+ handleNetworkFailure,
371
+ retryRequest
372
+ } = {}) {
373
+ if (typeof handleNetworkFailure !== "function") {
374
+ throw new TypeError("executeRequestLifecycle requires handleNetworkFailure().");
375
+ }
376
+ if (typeof retryRequest !== "function") {
377
+ throw new TypeError("executeRequestLifecycle requires retryRequest().");
378
+ }
379
+
380
+ const requestContext = await prepareRequestConfig(url, requestOptions, state, stream);
381
+ const {
382
+ method,
383
+ state: resolvedState
384
+ } = requestContext;
385
+ const result = await executePreparedRequest(
386
+ url,
387
+ requestContext.config,
388
+ requestContext,
389
+ (cause) =>
390
+ handleNetworkFailure({
391
+ cause,
392
+ url,
393
+ method,
394
+ state: resolvedState,
395
+ stream
396
+ })
397
+ );
398
+
399
+ if (!result.response.ok) {
400
+ return {
401
+ handled: true,
402
+ value: await handleHttpFailure({
403
+ url,
404
+ method,
405
+ state: resolvedState,
406
+ response: result.response,
407
+ data: result.data,
408
+ contentType: result.contentType,
409
+ isJson: result.isJson,
410
+ stream,
411
+ retryRequest() {
412
+ return retryRequest(resolvedState);
413
+ }
414
+ })
415
+ };
416
+ }
417
+
418
+ return {
419
+ handled: false,
420
+ value: {
421
+ method,
422
+ state: resolvedState,
423
+ result
424
+ }
425
+ };
426
+ }
427
+
428
+ async function request(url, requestOptions = {}, state = null) {
429
+ const execution = await executeRequestLifecycle({
430
+ url,
431
+ requestOptions,
432
+ state,
433
+ stream: false,
434
+ async handleNetworkFailure({ cause, method, state: resolvedState }) {
435
+ const error = createNetworkError(cause);
436
+ await notifyFailure({
437
+ url,
438
+ method,
439
+ state: resolvedState,
440
+ reason: "network_error",
441
+ error,
442
+ stream: false
443
+ });
444
+ throw error;
445
+ },
446
+ retryRequest(nextState) {
447
+ return request(url, requestOptions, nextState);
448
+ }
449
+ });
450
+ if (execution.handled) {
451
+ return execution.value;
452
+ }
453
+
454
+ const {
455
+ method,
456
+ state: resolvedState,
457
+ result
458
+ } = execution.value;
459
+
460
+ await notifySuccess({
461
+ url,
462
+ method,
463
+ state: resolvedState,
464
+ response: result.response,
465
+ data: result.data,
466
+ contentType: result.contentType,
467
+ isJson: result.isJson,
468
+ stream: false
469
+ });
470
+ return result.data;
471
+ }
472
+
473
+ async function requestStream(url, requestOptions = {}, handlers = {}, state = null) {
474
+ const execution = await executeRequestLifecycle({
475
+ url,
476
+ requestOptions,
477
+ state,
478
+ stream: true,
479
+ async handleNetworkFailure({ cause, method, state: resolvedState }) {
480
+ const aborted = String(cause?.name || "") === "AbortError";
481
+ const reason = aborted ? "aborted" : "network_error";
482
+ await notifyFailure({
483
+ url,
484
+ method,
485
+ state: resolvedState,
486
+ reason,
487
+ error: cause,
488
+ stream: true
489
+ });
490
+ if (aborted) {
491
+ throw cause;
492
+ }
493
+
494
+ throw createNetworkError(cause);
495
+ },
496
+ retryRequest(nextState) {
497
+ return requestStream(url, requestOptions, handlers, nextState);
498
+ }
499
+ });
500
+ if (execution.handled) {
501
+ return execution.value;
502
+ }
503
+
504
+ const {
505
+ method,
506
+ state: resolvedState,
507
+ result
508
+ } = execution.value;
509
+
510
+ try {
511
+ let shouldParseAsNdjson = result.contentType.includes(DEFAULT_NDJSON_CONTENT_TYPE);
512
+ if (!shouldParseAsNdjson && typeof hooks.shouldTreatAsNdjsonStream === "function") {
513
+ shouldParseAsNdjson = Boolean(
514
+ await hooks.shouldTreatAsNdjsonStream({
515
+ url,
516
+ method,
517
+ state: resolvedState,
518
+ contentType: result.contentType,
519
+ isJson: result.isJson,
520
+ data: result.data,
521
+ response: result.response
522
+ })
523
+ );
524
+ }
525
+
526
+ if (shouldParseAsNdjson) {
527
+ await readNdjsonStream(result.response, handlers);
528
+ } else if (typeof handlers?.onEvent === "function" && Object.keys(result.data).length > 0) {
529
+ handlers.onEvent(result.data);
530
+ } else if (typeof handlers?.onEvent === "function" && typeof result.response?.text === "function") {
531
+ const rawText = await result.response.text().catch(() => "");
532
+ for (const line of String(rawText || "").split(/\r?\n/g)) {
533
+ emitNdjsonLine(line, handlers);
534
+ }
535
+ }
536
+ } catch (error) {
537
+ await notifyFailure({
538
+ url,
539
+ method,
540
+ state: resolvedState,
541
+ reason: "stream_error",
542
+ error,
543
+ response: result.response,
544
+ data: result.data,
545
+ contentType: result.contentType,
546
+ isJson: result.isJson,
547
+ stream: true
548
+ });
549
+ throw error;
550
+ }
551
+
552
+ await notifySuccess({
553
+ url,
554
+ method,
555
+ state: resolvedState,
556
+ response: result.response,
557
+ data: result.data,
558
+ contentType: result.contentType,
559
+ isJson: result.isJson,
560
+ stream: true
561
+ });
562
+ }
563
+
564
+ function clearCsrfTokenCache() {
565
+ csrfTokenCache = "";
566
+ }
567
+
568
+ function resetForTests() {
569
+ csrfTokenCache = "";
570
+ csrfFetchPromise = null;
571
+ }
572
+
573
+ function get(url, requestOptions = {}) {
574
+ return request(url, {
575
+ ...requestOptions,
576
+ method: "GET"
577
+ });
578
+ }
579
+
580
+ function post(url, body, requestOptions = {}) {
581
+ return request(url, {
582
+ ...requestOptions,
583
+ method: "POST",
584
+ body
585
+ });
586
+ }
587
+
588
+ function put(url, body, requestOptions = {}) {
589
+ return request(url, {
590
+ ...requestOptions,
591
+ method: "PUT",
592
+ body
593
+ });
594
+ }
595
+
596
+ function patch(url, body, requestOptions = {}) {
597
+ return request(url, {
598
+ ...requestOptions,
599
+ method: "PATCH",
600
+ body
601
+ });
602
+ }
603
+
604
+ function del(url, requestOptions = {}) {
605
+ return request(url, {
606
+ ...requestOptions,
607
+ method: "DELETE"
608
+ });
609
+ }
610
+
611
+ return {
612
+ request,
613
+ requestStream,
614
+ ensureCsrfToken,
615
+ clearCsrfTokenCache,
616
+ resetForTests,
617
+ get,
618
+ post,
619
+ put,
620
+ patch,
621
+ delete: del,
622
+ __testables: {
623
+ fetchSessionForCsrf,
624
+ updateCsrfTokenFromPayload,
625
+ createHttpError,
626
+ createNetworkError,
627
+ readNdjsonStream
628
+ }
629
+ };
630
+ }
631
+
632
+ export { createHttpClient };
@@ -0,0 +1,35 @@
1
+ import { isRecord, resolveFieldErrors } from "../support/fieldErrors.js";
2
+
3
+ function createHttpError(response, data = {}) {
4
+ const payload = isRecord(data) ? data : {};
5
+ const error = new Error(payload.error || `Request failed with status ${response.status}.`);
6
+ const normalizedFieldErrors = resolveFieldErrors(payload);
7
+ error.status = Number(response?.status || 0);
8
+ error.code = String(payload.code || "").trim() || null;
9
+ error.fieldErrors = Object.keys(normalizedFieldErrors).length > 0 ? normalizedFieldErrors : null;
10
+ if (isRecord(payload.details)) {
11
+ error.details = payload.details;
12
+ } else if (error.fieldErrors) {
13
+ error.details = {
14
+ fieldErrors: error.fieldErrors
15
+ };
16
+ } else {
17
+ error.details = null;
18
+ }
19
+ return error;
20
+ }
21
+
22
+ function createNetworkError(cause) {
23
+ const error = new Error("Network request failed.");
24
+ error.status = 0;
25
+ error.code = null;
26
+ error.fieldErrors = null;
27
+ error.details = null;
28
+ error.cause = cause;
29
+ return error;
30
+ }
31
+
32
+ export {
33
+ createHttpError,
34
+ createNetworkError
35
+ };
@@ -0,0 +1,28 @@
1
+ function normalizeHeaderName(name) {
2
+ return String(name || "")
3
+ .trim()
4
+ .toLowerCase();
5
+ }
6
+
7
+ function hasHeader(headers, name) {
8
+ const normalizedTarget = normalizeHeaderName(name);
9
+ if (!normalizedTarget || !headers || typeof headers !== "object") {
10
+ return false;
11
+ }
12
+
13
+ return Object.keys(headers).some((key) => normalizeHeaderName(key) === normalizedTarget);
14
+ }
15
+
16
+ function setHeaderIfMissing(headers, name, value) {
17
+ if (!headers || typeof headers !== "object" || !name || value == null) {
18
+ return;
19
+ }
20
+
21
+ if (hasHeader(headers, name)) {
22
+ return;
23
+ }
24
+
25
+ headers[name] = value;
26
+ }
27
+
28
+ export { normalizeHeaderName, hasHeader, setHeaderIfMissing };
@@ -0,0 +1,4 @@
1
+ export { createHttpClient } from "./client.js";
2
+ export { createHttpError, createNetworkError } from "./errors.js";
3
+ export { DEFAULT_RETRYABLE_CSRF_ERROR_CODES, shouldRetryForCsrfFailure } from "./retry.js";
4
+ export { normalizeHeaderName, hasHeader, setHeaderIfMissing } from "./headers.js";