@beignet/core 0.0.47 → 0.0.49

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 (98) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +81 -15
  3. package/dist/client/client.d.ts +0 -2
  4. package/dist/client/client.d.ts.map +1 -1
  5. package/dist/client/client.js +28 -25
  6. package/dist/client/client.js.map +1 -1
  7. package/dist/contracts/contract-builder.d.ts +7 -2
  8. package/dist/contracts/contract-builder.d.ts.map +1 -1
  9. package/dist/contracts/contract-builder.js +20 -2
  10. package/dist/contracts/contract-builder.js.map +1 -1
  11. package/dist/contracts/contract-group.d.ts.map +1 -1
  12. package/dist/contracts/contract-group.js +1 -0
  13. package/dist/contracts/contract-group.js.map +1 -1
  14. package/dist/contracts/contract-like.d.ts +2 -0
  15. package/dist/contracts/contract-like.d.ts.map +1 -1
  16. package/dist/contracts/contract-like.js +27 -1
  17. package/dist/contracts/contract-like.js.map +1 -1
  18. package/dist/contracts/index.d.ts +4 -0
  19. package/dist/contracts/index.d.ts.map +1 -1
  20. package/dist/contracts/index.js +4 -0
  21. package/dist/contracts/index.js.map +1 -1
  22. package/dist/contracts/query-transport.d.ts +126 -0
  23. package/dist/contracts/query-transport.d.ts.map +1 -0
  24. package/dist/contracts/query-transport.js +406 -0
  25. package/dist/contracts/query-transport.js.map +1 -0
  26. package/dist/contracts/schema-shape.d.ts +11 -0
  27. package/dist/contracts/schema-shape.d.ts.map +1 -1
  28. package/dist/contracts/schema-shape.js +13 -0
  29. package/dist/contracts/schema-shape.js.map +1 -1
  30. package/dist/contracts/types.d.ts +5 -0
  31. package/dist/contracts/types.d.ts.map +1 -1
  32. package/dist/contracts/types.js.map +1 -1
  33. package/dist/openapi/index.d.ts +8 -0
  34. package/dist/openapi/index.d.ts.map +1 -1
  35. package/dist/openapi/index.js +79 -5
  36. package/dist/openapi/index.js.map +1 -1
  37. package/dist/outbox/index.d.ts +8 -5
  38. package/dist/outbox/index.d.ts.map +1 -1
  39. package/dist/outbox/index.js +17 -3
  40. package/dist/outbox/index.js.map +1 -1
  41. package/dist/ports/cache.d.ts +9 -1
  42. package/dist/ports/cache.d.ts.map +1 -1
  43. package/dist/ports/cache.js +20 -5
  44. package/dist/ports/cache.js.map +1 -1
  45. package/dist/ports/index.d.ts +1 -1
  46. package/dist/ports/index.d.ts.map +1 -1
  47. package/dist/ports/index.js +1 -1
  48. package/dist/ports/index.js.map +1 -1
  49. package/dist/providers/instrumentation.d.ts.map +1 -1
  50. package/dist/providers/instrumentation.js +14 -2
  51. package/dist/providers/instrumentation.js.map +1 -1
  52. package/dist/providers/metadata.d.ts +2 -0
  53. package/dist/providers/metadata.d.ts.map +1 -1
  54. package/dist/providers/metadata.js +78 -16
  55. package/dist/providers/metadata.js.map +1 -1
  56. package/dist/query-codec.d.ts +24 -0
  57. package/dist/query-codec.d.ts.map +1 -1
  58. package/dist/query-codec.js +207 -72
  59. package/dist/query-codec.js.map +1 -1
  60. package/dist/server/hooks/rate-limit.d.ts +16 -8
  61. package/dist/server/hooks/rate-limit.d.ts.map +1 -1
  62. package/dist/server/hooks/rate-limit.js +31 -17
  63. package/dist/server/hooks/rate-limit.js.map +1 -1
  64. package/dist/server/request-executor.d.ts.map +1 -1
  65. package/dist/server/request-executor.js +9 -9
  66. package/dist/server/request-executor.js.map +1 -1
  67. package/dist/server/request-preparation.d.ts.map +1 -1
  68. package/dist/server/request-preparation.js +11 -9
  69. package/dist/server/request-preparation.js.map +1 -1
  70. package/dist/server/server.d.ts.map +1 -1
  71. package/dist/server/server.js +2 -0
  72. package/dist/server/server.js.map +1 -1
  73. package/dist/server/use-case-route.d.ts +8 -5
  74. package/dist/server/use-case-route.d.ts.map +1 -1
  75. package/dist/server/use-case-route.js +44 -17
  76. package/dist/server/use-case-route.js.map +1 -1
  77. package/package.json +3 -3
  78. package/skills/app-architecture/SKILL.md +27 -1
  79. package/src/client/client.ts +29 -28
  80. package/src/contracts/contract-builder.ts +32 -2
  81. package/src/contracts/contract-group.ts +1 -0
  82. package/src/contracts/contract-like.ts +40 -1
  83. package/src/contracts/index.ts +23 -0
  84. package/src/contracts/query-transport.ts +697 -0
  85. package/src/contracts/schema-shape.ts +24 -0
  86. package/src/contracts/types.ts +5 -0
  87. package/src/openapi/index.ts +126 -2
  88. package/src/outbox/index.ts +26 -5
  89. package/src/ports/cache.ts +29 -7
  90. package/src/ports/index.ts +1 -1
  91. package/src/providers/instrumentation.ts +15 -2
  92. package/src/providers/metadata.ts +132 -16
  93. package/src/server/hooks/rate-limit.ts +50 -24
  94. package/src/server/request-executor.ts +17 -9
  95. package/src/server/request-preparation.ts +22 -9
  96. package/src/server/server.ts +2 -0
  97. package/src/server/use-case-route.ts +62 -23
  98. package/src/query-codec.ts +0 -130
@@ -4,7 +4,11 @@
4
4
 
5
5
  import type { RateLimitScope } from "../../contracts/index.js";
6
6
  import { AppError, httpErrors } from "../../errors/index.js";
7
- import type { ActivityActor, RateLimitPort } from "../../ports/index.js";
7
+ import {
8
+ type ActivityActor,
9
+ AuthUnauthorizedError,
10
+ type RateLimitPort,
11
+ } from "../../ports/index.js";
8
12
  import {
9
13
  createProviderInstrumentation,
10
14
  type ProviderInstrumentationTarget,
@@ -38,8 +42,9 @@ type EarlyRateLimitScope = Exclude<RateLimitScope, "user">;
38
42
  * Strategy for resolving the client IP used by `ip`-scoped limits.
39
43
  *
40
44
  * - `"none"`: do not trust request headers for IP resolution. Every request
41
- * shares one `ip:unknown` bucket; this is the explicit opt-out for apps
42
- * that declare `ip` scopes without a trusted client-IP source.
45
+ * to one contract shares that contract's unknown-client bucket; this is the
46
+ * explicit opt-out for apps that declare `ip` scopes without a trusted
47
+ * client-IP source.
43
48
  * - `"x-forwarded-for-last"`: the last `x-forwarded-for` entry. Use this only
44
49
  * when the app is always behind a trusted reverse proxy that appends the
45
50
  * socket address.
@@ -59,7 +64,9 @@ export interface RateLimitOptions<Ctx> {
59
64
  /**
60
65
  * Build a rate-limit key after context exists.
61
66
  *
62
- * This is used for user-scoped limits and any late key strategy.
67
+ * This is used for user-scoped limits and any late key strategy. The
68
+ * returned value is the complete key and is not automatically namespaced by
69
+ * contract.
63
70
  */
64
71
  key?: (args: {
65
72
  ctx: Ctx;
@@ -69,7 +76,8 @@ export interface RateLimitOptions<Ctx> {
69
76
  /**
70
77
  * Build a rate-limit key before request parsing and context creation.
71
78
  *
72
- * This is used for global and IP-scoped limits.
79
+ * This is used for global and IP-scoped limits. The returned value is the
80
+ * complete key and is not automatically namespaced by contract.
73
81
  */
74
82
  earlyKey?: (args: {
75
83
  req: HttpRequestLike;
@@ -124,16 +132,23 @@ function ipSourceConfigurationError(contractNames: string): Error {
124
132
  `createRateLimitHooks(...) has no client IP source configured, but contract(s) ${contractNames} declare an "ip"-scoped rate limit. ` +
125
133
  `Set trustedProxy.clientIp or ipSource to a header source written by a trusted edge, ` +
126
134
  `for example "x-forwarded-for-last", "x-forwarded-for-first", "x-real-ip", "cf-connecting-ip", or a custom function, ` +
127
- `or "none" to explicitly accept one shared ip:unknown bucket for all clients.`,
135
+ `or "none" to explicitly accept one shared unknown-client bucket per contract.`,
128
136
  );
129
137
  }
130
138
 
131
- function emitUserKey(userId: string): string {
132
- return `user:${userId}`;
139
+ function namespaceContractKey(contractName: string, key: string): string {
140
+ const encodedContractName = contractName
141
+ .replaceAll("%", "%25")
142
+ .replaceAll(":", "%3A");
143
+ return `contract:${encodedContractName}:${key}`;
144
+ }
145
+
146
+ function emitUserKey(contractName: string, userId: string): string {
147
+ return namespaceContractKey(contractName, `user:${userId}`);
133
148
  }
134
149
 
135
- function emitIpKey(ip: string): string {
136
- return `ip:${ip}`;
150
+ function emitIpKey(contractName: string, ip: string): string {
151
+ return namespaceContractKey(contractName, `ip:${ip}`);
137
152
  }
138
153
 
139
154
  function defaultRateLimitKey<Ctx extends CtxWithRateLimit>(
@@ -141,36 +156,41 @@ function defaultRateLimitKey<Ctx extends CtxWithRateLimit>(
141
156
  ctx: Ctx;
142
157
  req: HttpRequestLike;
143
158
  scope: RateLimitScope;
159
+ contractName: string;
144
160
  },
145
161
  getClientIp: (req: HttpRequestLike) => string | undefined,
146
162
  ): string {
147
- const { ctx, req, scope } = args;
163
+ const { contractName, ctx, req, scope } = args;
148
164
 
149
- if (scope === "user" && ctx.actor?.type === "user" && ctx.actor.id) {
150
- return emitUserKey(ctx.actor.id);
165
+ if (scope === "user") {
166
+ if (ctx.actor?.type !== "user" || !ctx.actor.id) {
167
+ throw new AuthUnauthorizedError();
168
+ }
169
+ return emitUserKey(contractName, ctx.actor.id);
151
170
  }
152
171
 
153
172
  if (scope === "ip") {
154
173
  const ip = getClientIp(req) || "unknown";
155
- return emitIpKey(ip);
174
+ return emitIpKey(contractName, ip);
156
175
  }
157
176
 
158
- return "global";
177
+ return namespaceContractKey(contractName, "global");
159
178
  }
160
179
 
161
180
  function defaultEarlyRateLimitKey(
162
181
  args: {
163
182
  req: HttpRequestLike;
164
183
  scope: EarlyRateLimitScope;
184
+ contractName: string;
165
185
  },
166
186
  getClientIp: (req: HttpRequestLike) => string | undefined,
167
187
  ): string {
168
188
  if (args.scope === "ip") {
169
189
  const ip = getClientIp(args.req) || "unknown";
170
- return emitIpKey(ip);
190
+ return emitIpKey(args.contractName, ip);
171
191
  }
172
192
 
173
- return "global";
193
+ return namespaceContractKey(args.contractName, "global");
174
194
  }
175
195
 
176
196
  async function enforceRateLimit(
@@ -233,7 +253,10 @@ async function enforceRateLimit(
233
253
  * The hook reads `contract.metadata.rateLimit`. Global and IP-scoped limits run
234
254
  * in `onRequest` before context creation; user-scoped limits run in
235
255
  * `beforeHandle` after route hooks have resolved identity and `ctx.actor` is
236
- * available. Exceeded limits throw the framework `TooManyRequests` app error
256
+ * available. Default keys include the contract name so unrelated contracts do
257
+ * not share counters. A user-scoped limit without a resolved user actor fails
258
+ * with `AuthUnauthorizedError` instead of falling back to a global bucket.
259
+ * Exceeded limits throw the framework `TooManyRequests` app error
237
260
  * with `scope`, `retryAfterSeconds`, and `resetAt` details, and the 429
238
261
  * response carries a `Retry-After` header when the limiter reports a reset
239
262
  * time. The bucket key is
@@ -243,11 +266,12 @@ async function enforceRateLimit(
243
266
  * `ip`-scoped limits require an explicit `trustedProxy.clientIp`, `ipSource`,
244
267
  * or custom `earlyKey`: the hook's `validate` phase fails `createServer(...)`
245
268
  * startup when a registered contract declares an `ip` scope without one,
246
- * instead of silently collapsing all clients into a shared `ip:unknown` bucket.
269
+ * instead of silently collapsing all clients into one shared bucket.
247
270
  * Contracts added later through `server.route(...)` are not visible to
248
271
  * `validate`, so enforcing an `ip`-scoped limit without a client-IP source
249
272
  * throws the same configuration error at request time as a backstop. Pass
250
- * `ipSource: "none"` to explicitly opt in to the shared `ip:unknown` bucket.
273
+ * `ipSource: "none"` to explicitly opt in to one unknown-client bucket per
274
+ * contract.
251
275
  *
252
276
  * @param options - Optional key builders and client-IP source.
253
277
  * @returns A server hook backed by `ctx.ports.rateLimit`.
@@ -309,8 +333,9 @@ export function createRateLimitHooks<Ctx extends CtxWithRateLimit>(
309
333
 
310
334
  const key =
311
335
  options.earlyKey?.({ req, scope }) ??
312
- defaultEarlyRateLimitKey({ req, scope }, (r) =>
313
- getClientIp(r, requestInfo, contract.name),
336
+ defaultEarlyRateLimitKey(
337
+ { req, scope, contractName: contract.name },
338
+ (r) => getClientIp(r, requestInfo, contract.name),
314
339
  );
315
340
 
316
341
  await enforceRateLimit(ports, {
@@ -335,8 +360,9 @@ export function createRateLimitHooks<Ctx extends CtxWithRateLimit>(
335
360
 
336
361
  const key =
337
362
  options.key?.({ ctx, req, scope }) ??
338
- defaultRateLimitKey({ ctx, req, scope }, (r) =>
339
- getClientIp(r, requestInfo, contract.name),
363
+ defaultRateLimitKey(
364
+ { ctx, req, scope, contractName: contract.name },
365
+ (r) => getClientIp(r, requestInfo, contract.name),
340
366
  );
341
367
 
342
368
  await enforceRateLimit(ctx.ports, {
@@ -949,17 +949,25 @@ export function createRequestExecutor<
949
949
  error,
950
950
  owner: "framework",
951
951
  };
952
- finalResponse = normalizeHttpResponse(result.response);
953
- finalError = result.error;
954
- finalOwner = responseOwnerFor(finalResponse, result.owner);
955
- result = await applyTransformHooks(result, true);
956
- finalResponse = normalizeHttpResponse(result.response);
957
- finalError = result.error;
958
- finalOwner = responseOwnerFor(finalResponse, result.owner);
959
- responseValidation = "not-applicable";
960
952
  } else {
961
- throw error;
953
+ result = await resolveErrorResult(
954
+ error,
955
+ result.ctx,
956
+ pathValue,
957
+ queryValue,
958
+ headersValue,
959
+ bodyValue,
960
+ { owner: "framework" },
961
+ );
962
962
  }
963
+ finalResponse = normalizeHttpResponse(result.response);
964
+ finalError = result.error;
965
+ finalOwner = responseOwnerFor(finalResponse, result.owner);
966
+ result = await applyTransformHooks(result, true);
967
+ finalResponse = normalizeHttpResponse(result.response);
968
+ finalError = result.error;
969
+ finalOwner = responseOwnerFor(finalResponse, result.owner);
970
+ responseValidation = "not-applicable";
963
971
  }
964
972
  }
965
973
 
@@ -1,10 +1,11 @@
1
1
  import {
2
+ decodeQueryTransport,
2
3
  getContractHeaderSchemas,
3
4
  type HttpContractConfig,
4
5
  methodSupportsRequestBody,
6
+ QueryTransportError,
5
7
  type StandardSchema,
6
8
  } from "../contracts/index.js";
7
- import { decodeQueryValue } from "../query-codec.js";
8
9
  import type { HttpRequestLike, HttpResponseLike } from "./http.js";
9
10
  import {
10
11
  parseStandardSchema,
@@ -82,7 +83,10 @@ function requestValidationDetails(
82
83
  ...additionalDetails,
83
84
  };
84
85
 
85
- if (error instanceof SchemaValidationError) {
86
+ if (
87
+ error instanceof SchemaValidationError ||
88
+ error instanceof QueryTransportError
89
+ ) {
86
90
  return {
87
91
  ...details,
88
92
  issues: error.issues,
@@ -282,17 +286,26 @@ export async function prepareRequestInputs(args: {
282
286
  maxRequestBodyBytes,
283
287
  rawRoute,
284
288
  } = args;
285
- const rawQuery: Record<string, unknown> = {};
286
- for (const key of new Set(url.searchParams.keys())) {
287
- const values = url.searchParams.getAll(key);
288
- const decoded = values.map(decodeQueryValue);
289
- rawQuery[key] = decoded.length === 1 ? decoded[0] : decoded;
290
- }
289
+ const rawQuery = Object.fromEntries(
290
+ [...new Set(url.searchParams.keys())].map((key) => {
291
+ const values = url.searchParams.getAll(key);
292
+ return [key, values.length === 1 ? values[0] : values];
293
+ }),
294
+ );
291
295
 
292
296
  let query: unknown = rawQuery;
293
297
  if (contract.query) {
298
+ if (!contract.queryTransport) {
299
+ throw new Error(
300
+ `Contract "${contract.name}" declares a query schema without a query transport.`,
301
+ );
302
+ }
294
303
  try {
295
- query = await parseStandardSchema(contract.query, query);
304
+ const decoded = decodeQueryTransport(
305
+ contract.queryTransport,
306
+ url.searchParams,
307
+ );
308
+ query = await parseStandardSchema(contract.query, decoded);
296
309
  } catch (error) {
297
310
  return {
298
311
  ok: false,
@@ -807,6 +807,7 @@ export async function createServer<
807
807
  path,
808
808
  pathParams: null,
809
809
  query: null,
810
+ queryTransport: null,
810
811
  body: null,
811
812
  responses: {},
812
813
  metadata: {},
@@ -929,6 +930,7 @@ function rawRouteContract(init: RawRouteInit): HttpContractConfig {
929
930
  path: init.path,
930
931
  pathParams: null,
931
932
  query: null,
933
+ queryTransport: null,
932
934
  body: null,
933
935
  responses: {},
934
936
  metadata: init.metadata ?? {},
@@ -192,8 +192,10 @@ type UseCaseRouteShape<
192
192
  /**
193
193
  * Map parsed request parts to the use case input.
194
194
  *
195
- * Defaults to `defaultBinderInput`, which merges query, body, and path
196
- * objects (path wins collisions) and never merges headers.
195
+ * A sole declared path, query, or body schema is passed through unchanged
196
+ * when no additional path, query, or object body values are present.
197
+ * Otherwise `defaultBinderInput` merges query, body, and path objects (path
198
+ * wins collisions) and never merges headers.
197
199
  */
198
200
  input?: (parts: UseCaseRouteInputParts<C>) => UseCaseRouteInput<UC>;
199
201
  handle?: never;
@@ -330,9 +332,10 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
330
332
  * Merges parsed query, body, and path objects into one input object. Path
331
333
  * keys win all collisions, then body keys, then query keys. Headers are never
332
334
  * merged: parsed headers include every raw request header, so merging them
333
- * would poison the use case input. Non-object bodies (text, arrays, scalars)
334
- * are excluded. Routes that need headers or non-object bodies declare an
335
- * explicit `input` mapper.
335
+ * would poison the use case input. The route binder passes a sole declared
336
+ * input schema through unchanged when no other object input contains values;
337
+ * this merge handles every other default mapping. Routes that combine a
338
+ * non-object body with another source declare an explicit `input` mapper.
336
339
  */
337
340
  export function defaultBinderInput(parts: {
338
341
  path: unknown;
@@ -356,16 +359,42 @@ export function isUseCaseRouteDef(route: {
356
359
  return route.useCase !== undefined && route.useCase !== null;
357
360
  }
358
361
 
359
- function computeTrustedInput(
362
+ function computeSingleInput(
360
363
  contract: HttpContractConfig,
361
364
  def: RuntimeUseCaseRouteDef,
362
- ): boolean {
363
- if (def.input) return false;
365
+ ): { source: "path" | "query" | "body"; schema: unknown } | undefined {
366
+ if (def.input) return undefined;
367
+
368
+ const sources = [
369
+ { source: "path", schema: contract.pathParams },
370
+ {
371
+ source: "query",
372
+ schema: contract.query,
373
+ },
374
+ {
375
+ source: "body",
376
+ schema: contract.body,
377
+ },
378
+ ] as const;
379
+ const present = sources.filter(
380
+ (candidate) => candidate.schema !== null && candidate.schema !== undefined,
381
+ );
382
+ const single = present[0];
383
+ return present.length === 1 && single
384
+ ? { source: single.source, schema: single.schema }
385
+ : undefined;
386
+ }
364
387
 
365
- const sources = [contract.pathParams, contract.query, contract.body].filter(
366
- (schema) => schema !== null && schema !== undefined,
388
+ function canPassSingleInput(
389
+ parts: { path: unknown; query: unknown; body: unknown },
390
+ source: "path" | "query" | "body",
391
+ ): boolean {
392
+ return (["path", "query", "body"] as const).every(
393
+ (candidate) =>
394
+ candidate === source ||
395
+ !isPlainObject(parts[candidate]) ||
396
+ Object.keys(parts[candidate]).length === 0,
367
397
  );
368
- return sources.length === 1 && sources[0] === def.useCase.inputSchema;
369
398
  }
370
399
 
371
400
  function computeResponseExemption(
@@ -405,11 +434,11 @@ export function createUseCaseRouteHandler<Ctx, C extends HttpContractConfig>(
405
434
  );
406
435
  }
407
436
 
408
- const mapInput = def.input ?? defaultBinderInput;
409
- const trustedRun = computeTrustedInput(contract, def)
410
- ? def.useCase[USE_CASE_TRUSTED_RUN_KEY]
411
- : undefined;
412
- const run = trustedRun ?? def.useCase.run;
437
+ const singleInput = computeSingleInput(contract, def);
438
+ const trustedRun =
439
+ singleInput?.schema === def.useCase.inputSchema
440
+ ? def.useCase[USE_CASE_TRUSTED_RUN_KEY]
441
+ : undefined;
413
442
 
414
443
  const handler: Handler<Ctx, C> = async ({
415
444
  ctx,
@@ -417,13 +446,23 @@ export function createUseCaseRouteHandler<Ctx, C extends HttpContractConfig>(
417
446
  query,
418
447
  headers,
419
448
  body,
420
- }) => ({
421
- status,
422
- body: await run.call(def.useCase, {
423
- ctx,
424
- input: mapInput({ path, query, headers, body }),
425
- }),
426
- });
449
+ }) => {
450
+ const parts = { path, query, headers, body };
451
+ const passSingle =
452
+ singleInput !== undefined &&
453
+ canPassSingleInput(parts, singleInput.source);
454
+ const input = def.input
455
+ ? def.input(parts)
456
+ : passSingle
457
+ ? parts[singleInput.source]
458
+ : defaultBinderInput(parts);
459
+ const run = passSingle && trustedRun ? trustedRun : def.useCase.run;
460
+
461
+ return {
462
+ status,
463
+ body: await run.call(def.useCase, { ctx, input }),
464
+ };
465
+ };
427
466
 
428
467
  return {
429
468
  handler,
@@ -1,130 +0,0 @@
1
- const QUERY_VALUE_PREFIX = "~beignet-query:";
2
-
3
- type EncodedQueryValue =
4
- | { type: "null" }
5
- | { type: "string"; value: string }
6
- | { type: "number"; value: number }
7
- | { type: "boolean"; value: boolean }
8
- | { type: "date"; value: string }
9
- | { type: "array"; value: EncodedQueryValue[] }
10
- | { type: "object"; value: Record<string, EncodedQueryValue> };
11
-
12
- function encodeStructuredValue(
13
- value: unknown,
14
- ancestors: Set<object>,
15
- ): EncodedQueryValue {
16
- if (value === null) return { type: "null" };
17
- if (typeof value === "string") return { type: "string", value };
18
- if (typeof value === "number" && Number.isFinite(value)) {
19
- return { type: "number", value };
20
- }
21
- if (typeof value === "boolean") return { type: "boolean", value };
22
- if (value instanceof Date && Number.isFinite(value.getTime())) {
23
- return { type: "date", value: value.toISOString() };
24
- }
25
- if (typeof value !== "object" || value === null) {
26
- throw new TypeError("Query value is not serializable.");
27
- }
28
- if (ancestors.has(value)) {
29
- throw new TypeError("Query value contains a circular reference.");
30
- }
31
-
32
- ancestors.add(value);
33
- try {
34
- if (Array.isArray(value)) {
35
- return {
36
- type: "array",
37
- value: value.map((entry) => encodeStructuredValue(entry, ancestors)),
38
- };
39
- }
40
-
41
- const prototype = Object.getPrototypeOf(value);
42
- if (prototype !== Object.prototype && prototype !== null) {
43
- throw new TypeError("Query value contains a non-plain object.");
44
- }
45
- return {
46
- type: "object",
47
- value: Object.fromEntries(
48
- Object.entries(value)
49
- .filter(([, entry]) => entry !== undefined)
50
- .map(([key, entry]) => [
51
- key,
52
- encodeStructuredValue(entry, ancestors),
53
- ]),
54
- ),
55
- };
56
- } finally {
57
- ancestors.delete(value);
58
- }
59
- }
60
-
61
- function decodeStructuredValue(value: unknown): unknown {
62
- if (typeof value !== "object" || value === null || !("type" in value)) {
63
- throw new TypeError("Invalid encoded query value.");
64
- }
65
- const encoded = value as { type: unknown; value?: unknown };
66
-
67
- switch (encoded.type) {
68
- case "null":
69
- return null;
70
- case "string":
71
- if (typeof encoded.value === "string") return encoded.value;
72
- break;
73
- case "number":
74
- if (typeof encoded.value === "number" && Number.isFinite(encoded.value)) {
75
- return encoded.value;
76
- }
77
- break;
78
- case "boolean":
79
- if (typeof encoded.value === "boolean") return encoded.value;
80
- break;
81
- case "date": {
82
- if (typeof encoded.value !== "string") break;
83
- const date = new Date(encoded.value);
84
- if (Number.isFinite(date.getTime())) return date;
85
- break;
86
- }
87
- case "array":
88
- if (Array.isArray(encoded.value)) {
89
- return encoded.value.map(decodeStructuredValue);
90
- }
91
- break;
92
- case "object":
93
- if (
94
- typeof encoded.value === "object" &&
95
- encoded.value !== null &&
96
- !Array.isArray(encoded.value)
97
- ) {
98
- return Object.fromEntries(
99
- Object.entries(encoded.value).map(([key, entry]) => [
100
- key,
101
- decodeStructuredValue(entry),
102
- ]),
103
- );
104
- }
105
- break;
106
- }
107
-
108
- throw new TypeError("Invalid encoded query value.");
109
- }
110
-
111
- export function encodeQueryValue(value: unknown): string {
112
- if (typeof value === "string" && !value.startsWith(QUERY_VALUE_PREFIX)) {
113
- return value;
114
- }
115
- return `${QUERY_VALUE_PREFIX}${JSON.stringify(
116
- encodeStructuredValue(value, new Set()),
117
- )}`;
118
- }
119
-
120
- export function decodeQueryValue(value: string): unknown {
121
- if (!value.startsWith(QUERY_VALUE_PREFIX)) return value;
122
-
123
- try {
124
- return decodeStructuredValue(
125
- JSON.parse(value.slice(QUERY_VALUE_PREFIX.length)) as unknown,
126
- );
127
- } catch {
128
- return value;
129
- }
130
- }