lambder 7.0.1 → 7.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/CHANGELOG.md +60 -0
  2. package/dist/api/LambderApiDefinition.d.ts +6 -3
  3. package/dist/api/LambderApiEnvelope.d.ts +1 -1
  4. package/dist/api/LambderApiEnvelope.js +1 -1
  5. package/dist/api/LambderApiGuards.d.ts +5 -0
  6. package/dist/api/LambderApiGuards.js +2 -2
  7. package/dist/api/LambderApiPipeline.d.ts +30 -14
  8. package/dist/api/LambderApiPipeline.js +28 -18
  9. package/dist/api/LambderApiRequest.d.ts +3 -1
  10. package/dist/api/LambderApiRequest.js +1 -0
  11. package/dist/api/LambderApiSignature.d.ts +41 -0
  12. package/dist/api/LambderApiSignature.js +91 -0
  13. package/dist/client/LambderCaller.d.ts +13 -0
  14. package/dist/client/LambderCaller.js +21 -1
  15. package/dist/client/LambderReloadLoopBreaker.d.ts +38 -0
  16. package/dist/client/LambderReloadLoopBreaker.js +71 -0
  17. package/dist/client.d.ts +3 -0
  18. package/dist/client.js +3 -0
  19. package/dist/core/Lambder.d.ts +15 -1
  20. package/dist/core/Lambder.js +30 -7
  21. package/dist/core/LambderCreateOptions.d.ts +6 -0
  22. package/dist/core/LambderCreateOptions.js +0 -5
  23. package/dist/index.d.ts +5 -0
  24. package/dist/index.js +4 -0
  25. package/dist/invoke/LambderInvokeCaller.d.ts +12 -1
  26. package/dist/invoke/LambderInvokeCaller.js +10 -1
  27. package/dist/invoke/LambderLambdaEvent.d.ts +1 -0
  28. package/dist/invoke/LambderLambdaEvent.js +1 -0
  29. package/dist/mock/LambderMockApp.d.ts +2 -2
  30. package/dist/mock/LambderMockApp.js +21 -13
  31. package/dist/mock/LambderMockCreateOptions.d.ts +10 -1
  32. package/dist/mock/LambderMockCreateOptions.js +0 -8
  33. package/dist/mock/LambderMockTypes.d.ts +2 -0
  34. package/dist/shared/transport/LambderApiTransport.d.ts +3 -0
  35. package/dist/shared/transport/LambderApiTransport.js +2 -0
  36. package/dist/shared/wire/LambderApiSignature.d.ts +33 -0
  37. package/dist/shared/wire/LambderApiSignature.js +44 -0
  38. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -9,6 +9,66 @@ sit on its first published patch, and later patches list only what they changed.
9
9
  Releases up to 3.2.6 carry git tags; the ones after it were published without
10
10
  one, so versions are not cross-linked to tag comparisons here.
11
11
 
12
+ ## [7.1.1] - 2026-09-15
13
+
14
+ The version gate is replaced by a signature gate: whether a client is stale is
15
+ decided per endpoint, by a signature of the endpoint's client-facing shape that
16
+ the client carries and the server digests from its own registrations. A deploy
17
+ now forces a reload only on the clients that call an endpoint whose shape
18
+ changed; a tab whose endpoints are unchanged keeps working. The wire format
19
+ gains one optional request field, `signature`; answers are unchanged, and a
20
+ caller that sends no signature is treated as before, minus the version check.
21
+
22
+ ### Changed
23
+
24
+ - **`apiVersion` no longer gates.** A request naming another version is not
25
+ refused any more; the string is stamped on every answer's envelope and does
26
+ nothing else. `LambderApiPipeline.isVersionStale` is gone, and `create()`
27
+ no longer refuses `apiVersion: ""`, since there is no gate for it to turn
28
+ off. An app that relied on the equality gate hands its callers
29
+ `apiSignatures` instead (below).
30
+ - **`LambderApiPipeline.prepare(request, definition)`** takes the definition
31
+ the request's name resolved to, or null, because the signature gate needs
32
+ it. Both adapters resolve the name before the pre-pass now, which is also
33
+ why a signed request for an unknown name answers `versionExpired` rather
34
+ than `apiNotFound`: the client was built against a contract that had it.
35
+ - **`LambderApiRequest` carries `signature: string | null`**, so a request
36
+ literal built by hand needs the field. `LambderApiDefinition` gains an
37
+ optional `output` schema, which `addApi`/`addSessionApi` record.
38
+
39
+ ### Added
40
+
41
+ - **`lambder.apiSignatures()`**: every registered endpoint's signature keyed
42
+ by its hashed name, a `LambderApiSignatureMap`. A generator imports the
43
+ finished instance, awaits this, and writes the object to a file the
44
+ frontend ships with its build. The signature covers the name, the mode, the
45
+ input and output schemas as JSON Schema, each declared guard's schema, and
46
+ whether the endpoint takes an idempotency key; rate limits, guard
47
+ parameters and the handler are left out, so changing them never forces a
48
+ reload. Keys are hashed so the file lists no endpoint names. See
49
+ docs/apis.md, "Signatures: when a client must update".
50
+ - **`apiSignatures` on `LambderCaller` and `LambderInvokeCaller`**: the
51
+ generated map. Each call sends its endpoint's signature; a name the map
52
+ lacks fails the call before it is sent, as an `unknown` outcome whose error
53
+ says to regenerate. Optional: a caller without the map is never gated.
54
+ - **`apiSignatures` on the mock runtime**: given the same map, the runtime
55
+ refuses a stale signature exactly as the server would; without it every
56
+ signature passes, since it holds no server schema to digest. The request
57
+ event carries `signature`.
58
+ - **Reload-loop protection in `LambderCaller`.** A `versionExpired` for the
59
+ same endpoint and signature within five minutes of the last one means the
60
+ reload brought the same bundle back (a frontend shipped with a stale map, a
61
+ cached bundle, a server deploy that failed behind it). The handler is not
62
+ called again; the failure goes to `errorHandler` and the outcome still says
63
+ `versionExpired`. Once confirmed, every `versionExpired` inside the window
64
+ counts, and after it a reload is allowed again. Kept per tab in
65
+ `sessionStorage`, in memory where there is none. `RELOAD_LOOP_WINDOW_MS` is
66
+ exported.
67
+ - `apiNameKeyOf`, `lookupApiSignature`, `readApiSignature`,
68
+ `API_SIGNATURE_HEX_LENGTH` and the `LambderApiSignatureMap` type from both
69
+ entries; `apiSignatureOf`, `LambderApiSignatureDigests` and the
70
+ `LambderApiSignatureSource` type from the root.
71
+
12
72
  ## [7.0.0] - 2026-09-15
13
73
 
14
74
  A major. The API pipeline moved out of the Lambda server into an isomorphic
@@ -4,9 +4,11 @@ import type { LambderApiIdempotencyOption, LambderGuardsOptionValue, LambderRate
4
4
  /**
5
5
  * One endpoint's declaration as the pipeline runs it: what the server's
6
6
  * addApi/addSessionApi options carry, minus the handler, in a shape the mock
7
- * runtime can restate from a type-only contract. The schema is optional
8
- * because the mock has none; when present, input validation runs and the
9
- * handler sees the parsed payload.
7
+ * runtime can restate from a type-only contract. The schemas are optional
8
+ * because the mock has none; when input is present, validation runs and the
9
+ * handler sees the parsed payload. Output is read by nothing at request
10
+ * time: it is part of the endpoint's signature (apiSignatureOf), which is
11
+ * what a client's build is checked against.
10
12
  */
11
13
  export type LambderApiDefinition = {
12
14
  name: string;
@@ -15,4 +17,5 @@ export type LambderApiDefinition = {
15
17
  rateLimit?: LambderRateLimitOptionValue;
16
18
  idempotency?: LambderApiIdempotencyOption;
17
19
  input?: z.ZodType;
20
+ output?: z.ZodType;
18
21
  };
@@ -54,7 +54,7 @@ export declare const validationAnswer: (zodError: z.ZodError, logList?: unknown[
54
54
  export declare const apiNotFoundAnswer: (apiVersion: string | null | undefined, logList?: unknown[]) => LambderApiAnswer;
55
55
  /** A session API called without a live session: the protocol's sessionExpired flag, which the caller clears its cookies on. */
56
56
  export declare const sessionExpiredAnswer: (apiVersion: string | null | undefined, logList?: unknown[]) => LambderApiAnswer;
57
- /** The caller's version is behind the server's: the protocol's versionExpired flag, which the caller reloads on. */
57
+ /** The caller was built against another shape of the endpoint (the signature gate), or the app judged it stale: the protocol's versionExpired flag, which the caller reloads on. */
58
58
  export declare const versionExpiredAnswer: (apiVersion: string | null | undefined) => LambderApiAnswer;
59
59
  /** A compressed request payload that could not be restored: a 400 with the reason, never a crash. */
60
60
  export declare const invalidPayloadAnswer: (apiVersion: string | null | undefined, message: string) => LambderApiAnswer;
@@ -165,7 +165,7 @@ export const apiNotFoundAnswer = (apiVersion, logList) => envelopeAnswer(buildAp
165
165
  }));
166
166
  /** A session API called without a live session: the protocol's sessionExpired flag, which the caller clears its cookies on. */
167
167
  export const sessionExpiredAnswer = (apiVersion, logList) => envelopeAnswer(buildApiEnvelope(apiVersion, null, { sessionExpired: true, logList }));
168
- /** The caller's version is behind the server's: the protocol's versionExpired flag, which the caller reloads on. */
168
+ /** The caller was built against another shape of the endpoint (the signature gate), or the app judged it stale: the protocol's versionExpired flag, which the caller reloads on. */
169
169
  export const versionExpiredAnswer = (apiVersion) => envelopeAnswer(buildApiEnvelope(apiVersion, null, { versionExpired: true }));
170
170
  /** A compressed request payload that could not be restored: a 400 with the reason, never a crash. */
171
171
  export const invalidPayloadAnswer = (apiVersion, message) => envelopeAnswer(buildApiEnvelope(apiVersion, null, {
@@ -277,6 +277,11 @@ type GuardInputsEntries<TGuards, TOpt> = {
277
277
  };
278
278
  /** The guardInputs map an API's contract requires clients to send; never when no declared guard uses guardInput mode. */
279
279
  export type LambderGuardInputsOf<TGuards, TOpt> = keyof GuardInputsEntries<TGuards, TOpt> extends never ? never : GuardInputsEntries<TGuards, TOpt>;
280
+ /** Normalize the three guards-option forms into ordered { name, param } entries. Read by the engine, and by the signature digest for the names alone. */
281
+ export declare const toGuardEntries: (value?: LambderGuardsOptionValue) => {
282
+ name: string;
283
+ param: unknown;
284
+ }[];
280
285
  /**
281
286
  * Runtime side of the guards subsystem: holds the defined guards, asserts
282
287
  * API registrations against them at startup, and executes an API's declared
@@ -7,8 +7,8 @@ import { LAMBDER_RESPONSE_BRAND, isLambderResponseLike } from "../shared/util/La
7
7
  * as server guards and run through the same engine.
8
8
  */
9
9
  export const lambderGuardBuilder = () => ((guard) => guard);
10
- /** Normalize the three guards-option forms into ordered { name, param } entries. Internal to the engine: nothing outside it reads a guards option. */
11
- const toGuardEntries = (value) => {
10
+ /** Normalize the three guards-option forms into ordered { name, param } entries. Read by the engine, and by the signature digest for the names alone. */
11
+ export const toGuardEntries = (value) => {
12
12
  if (value === undefined)
13
13
  return [];
14
14
  if (typeof value === "string")
@@ -4,6 +4,7 @@ import type { LambderApiAnswer } from "./LambderApiAnswer.js";
4
4
  import type { LambderApiCallContext } from "./LambderApiCallContext.js";
5
5
  import type { LambderApiCallTrace } from "./LambderApiCallContext.js";
6
6
  import type { LambderApiDefinition } from "./LambderApiDefinition.js";
7
+ import type { LambderApiSignatureSource } from "./LambderApiSignature.js";
7
8
  import type { LambderApiGuard } from "./LambderApiGuards.js";
8
9
  import type { LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig } from "./LambderApiRateLimits.js";
9
10
  import type { LambderApiIdempotencyConfig } from "./LambderApiIdempotency.js";
@@ -28,8 +29,15 @@ export type LambderApiSessionsConfig<TSessionData> = {
28
29
  cookieOptions?: LambderSessionCookieOptions;
29
30
  };
30
31
  export type LambderApiPipelineOptions<TCtx extends LambderApiCallContext<TSessionData>, TSessionData = any> = {
31
- /** Enables the version gate: a request naming another version answers versionExpired. */
32
+ /** Stamped on every answer's envelope as apiVersion, so a client can tell which build answered; null when the app set none. */
32
33
  apiVersion?: string | null;
34
+ /**
35
+ * Enables the signature gate: a request carrying a signature that is not
36
+ * the one this source expects for its endpoint answers versionExpired.
37
+ * Without a source every signature passes, which is what the mock runtime
38
+ * does unless it is given the generated map.
39
+ */
40
+ signatures?: LambderApiSignatureSource;
33
41
  /** Ceiling on what a compressed request payload may restore to. Default: 20,000,000. */
34
42
  maxRequestPayloadBytes?: number;
35
43
  onInvalidInput?: LambderApiInputRefusal<TCtx>;
@@ -52,7 +60,7 @@ export type LambderApiExec<TCtx> = (ctx: TCtx) => Promise<LambderApiAnswer>;
52
60
  * are adapters over this class; neither reimplements a step of it.
53
61
  *
54
62
  * ```
55
- * version gate → restore payload → rate limits that need no session
63
+ * signature gate → restore payload → rate limits that need no session
56
64
  * → session (session mode) → idempotency replay → the remaining rate limits
57
65
  * → guards → input validation → exec, inside the idempotency claim
58
66
  * → drain response headers → answer
@@ -75,6 +83,7 @@ export declare class LambderApiPipeline<TCtx extends LambderApiCallContext<TSess
75
83
  private readonly maxRequestPayloadBytes;
76
84
  private readonly onInvalidInput;
77
85
  private readonly sessions;
86
+ private readonly signatures;
78
87
  constructor(options?: LambderApiPipelineOptions<TCtx, TSessionData>);
79
88
  /** True when a session manager was configured. */
80
89
  get hasSessions(): boolean;
@@ -90,34 +99,41 @@ export declare class LambderApiPipeline<TCtx extends LambderApiCallContext<TSess
90
99
  static sessionInfoOf(request: LambderApiRequest): LambderSessionRequestInfo;
91
100
  /** Registration-time checks of one definition's declarative options; the same messages on the server and in the mock. */
92
101
  assertRegistration(definition: LambderApiDefinition): void;
93
- /** True when the gate is on and the request names a different version. */
94
- isVersionStale(request: LambderApiRequest): boolean;
95
102
  /**
96
103
  * The answer for a request naming no registered API: the apiNotFound
97
104
  * refusal, carrying whatever the call already wrote (a CORS header, a
98
- * cookie eviction). No version gate here: both adapters run prepare() on
99
- * the way in, before a name is resolved, so a stale client has already
100
- * been answered by the time anything asks for an unknown name.
105
+ * cookie eviction). No signature gate here: both adapters run prepare()
106
+ * on the way in, with the definition the name resolved to or null, so a
107
+ * signed request for an unknown name (a client built against a contract
108
+ * that had it) has already been answered versionExpired by the time
109
+ * anything asks for an unknown name.
101
110
  */
102
111
  answerUnknownApi(request: LambderApiRequest, ctx?: TCtx): LambderApiAnswer;
103
112
  /**
104
- * The steps that come before anything may read the request: the version
105
- * gate, then the compressed-payload restore that every later reader (a
106
- * rate-limit key slice, a guard, the input schema) depends on having
107
- * happened.
113
+ * The steps that come before anything may read the request: the
114
+ * signature gate, then the compressed-payload restore that every later
115
+ * reader (a rate-limit key slice, a guard, the input schema) depends on
116
+ * having happened.
117
+ *
118
+ * The gate compares the signature the request carries with the one the
119
+ * source expects for the endpoint the name resolved to (`definition`,
120
+ * null for a name the adapter does not know). A match runs; anything
121
+ * else is a client built against another shape of this endpoint, or
122
+ * against an endpoint that no longer exists, and is answered
123
+ * versionExpired. A request carrying no signature is never gated.
108
124
  *
109
125
  * Public and named because the server runs them earlier than run() does,
110
126
  * on the way in, so that its hooks see a plain payload and a stale client
111
127
  * is answered before any of them, whether or not the name it asked for
112
128
  * exists. run() calls it too, so an adapter that has no such step still
113
129
  * gets the whole protocol. Calling it twice is safe by construction: the
114
- * gate is a pure comparison and the restore has already removed the wire
115
- * fields it reads.
130
+ * gate compares against a memoized digest and the restore has already
131
+ * removed the wire fields it reads.
116
132
  *
117
133
  * Returns the answer that ends the call, or null when the request is
118
134
  * ready to dispatch.
119
135
  */
120
- prepare(request: LambderApiRequest): Promise<LambderApiAnswer | null>;
136
+ prepare(request: LambderApiRequest, definition: LambderApiDefinition | null): Promise<LambderApiAnswer | null>;
121
137
  /**
122
138
  * One call, one answer. Refusals are rendered; crashes propagate.
123
139
  *
@@ -13,7 +13,7 @@ import { DEFAULT_SESSION_CSRF_COOKIE_KEY, DEFAULT_SESSION_TOKEN_COOKIE_KEY } fro
13
13
  * are adapters over this class; neither reimplements a step of it.
14
14
  *
15
15
  * ```
16
- * version gate → restore payload → rate limits that need no session
16
+ * signature gate → restore payload → rate limits that need no session
17
17
  * → session (session mode) → idempotency replay → the remaining rate limits
18
18
  * → guards → input validation → exec, inside the idempotency claim
19
19
  * → drain response headers → answer
@@ -36,8 +36,10 @@ export class LambderApiPipeline {
36
36
  maxRequestPayloadBytes;
37
37
  onInvalidInput;
38
38
  sessions;
39
+ signatures;
39
40
  constructor(options = {}) {
40
41
  this.apiVersion = options.apiVersion ?? null;
42
+ this.signatures = options.signatures ?? null;
41
43
  this.maxRequestPayloadBytes = assertPositiveInteger(options.maxRequestPayloadBytes ?? DEFAULT_MAX_RESTORED_PAYLOAD_BYTES, "maxRequestPayloadBytes");
42
44
  this.onInvalidInput = options.onInvalidInput ?? null;
43
45
  this.sessions = options.sessions
@@ -90,16 +92,14 @@ export class LambderApiPipeline {
90
92
  assertRegistration(definition) {
91
93
  this.policies.assertRegistration(definition);
92
94
  }
93
- /** True when the gate is on and the request names a different version. */
94
- isVersionStale(request) {
95
- return !!this.apiVersion && !!request.version && request.version !== this.apiVersion;
96
- }
97
95
  /**
98
96
  * The answer for a request naming no registered API: the apiNotFound
99
97
  * refusal, carrying whatever the call already wrote (a CORS header, a
100
- * cookie eviction). No version gate here: both adapters run prepare() on
101
- * the way in, before a name is resolved, so a stale client has already
102
- * been answered by the time anything asks for an unknown name.
98
+ * cookie eviction). No signature gate here: both adapters run prepare()
99
+ * on the way in, with the definition the name resolved to or null, so a
100
+ * signed request for an unknown name (a client built against a contract
101
+ * that had it) has already been answered versionExpired by the time
102
+ * anything asks for an unknown name.
103
103
  */
104
104
  answerUnknownApi(request, ctx) {
105
105
  const answer = apiNotFoundAnswer(this.apiVersion, ctx?.logList);
@@ -107,25 +107,35 @@ export class LambderApiPipeline {
107
107
  return answer;
108
108
  }
109
109
  /**
110
- * The steps that come before anything may read the request: the version
111
- * gate, then the compressed-payload restore that every later reader (a
112
- * rate-limit key slice, a guard, the input schema) depends on having
113
- * happened.
110
+ * The steps that come before anything may read the request: the
111
+ * signature gate, then the compressed-payload restore that every later
112
+ * reader (a rate-limit key slice, a guard, the input schema) depends on
113
+ * having happened.
114
+ *
115
+ * The gate compares the signature the request carries with the one the
116
+ * source expects for the endpoint the name resolved to (`definition`,
117
+ * null for a name the adapter does not know). A match runs; anything
118
+ * else is a client built against another shape of this endpoint, or
119
+ * against an endpoint that no longer exists, and is answered
120
+ * versionExpired. A request carrying no signature is never gated.
114
121
  *
115
122
  * Public and named because the server runs them earlier than run() does,
116
123
  * on the way in, so that its hooks see a plain payload and a stale client
117
124
  * is answered before any of them, whether or not the name it asked for
118
125
  * exists. run() calls it too, so an adapter that has no such step still
119
126
  * gets the whole protocol. Calling it twice is safe by construction: the
120
- * gate is a pure comparison and the restore has already removed the wire
121
- * fields it reads.
127
+ * gate compares against a memoized digest and the restore has already
128
+ * removed the wire fields it reads.
122
129
  *
123
130
  * Returns the answer that ends the call, or null when the request is
124
131
  * ready to dispatch.
125
132
  */
126
- async prepare(request) {
127
- if (this.isVersionStale(request))
128
- return versionExpiredAnswer(this.apiVersion);
133
+ async prepare(request, definition) {
134
+ if (request.signature !== null && this.signatures) {
135
+ const expected = await this.signatures.expectedSignatureOf(request.apiName, definition);
136
+ if (expected !== request.signature)
137
+ return versionExpiredAnswer(this.apiVersion);
138
+ }
129
139
  const restored = await restoreCompressedPayload(request, this.maxRequestPayloadBytes);
130
140
  if (!restored.ok)
131
141
  return invalidPayloadAnswer(this.apiVersion, restored.message);
@@ -165,7 +175,7 @@ export class LambderApiPipeline {
165
175
  return { answer, ...trace };
166
176
  }
167
177
  async execute(request, ctx, definition, exec, trace) {
168
- const unprepared = await this.prepare(request);
178
+ const unprepared = await this.prepare(request, definition);
169
179
  if (unprepared)
170
180
  return unprepared;
171
181
  // The limits whose key is known from the request alone, before the
@@ -20,8 +20,10 @@ export declare const lowercaseHeaderNames: (headers: Record<string, string | und
20
20
  */
21
21
  export type LambderApiRequest = {
22
22
  apiName: string;
23
- /** The caller's apiVersion, for the version gate; null when it sent none. */
23
+ /** The caller's apiVersion, informational; null when it sent none. */
24
24
  version: string | null;
25
+ /** The signature the caller carries for this endpoint (see LambderApiSignatureMap), for the signature gate; null when it sent none. */
26
+ signature: string | null;
25
27
  /** The CSRF token the caller posted in the envelope; "" when it holds none. */
26
28
  token: string;
27
29
  siteHost: string;
@@ -50,6 +50,7 @@ export const readApiEnvelope = (post, info) => {
50
50
  return {
51
51
  apiName: post.apiName,
52
52
  version: typeof post.version === "string" ? post.version : null,
53
+ signature: typeof post.signature === "string" ? post.signature : null,
53
54
  token: typeof post.token === "string" ? post.token : "",
54
55
  siteHost: typeof post.siteHost === "string" ? post.siteHost : "",
55
56
  payload: post.payload,
@@ -0,0 +1,41 @@
1
+ import type { LambderApiDefinition } from "./LambderApiDefinition.js";
2
+ import { type LambderApiGuard } from "./LambderApiGuards.js";
3
+ /**
4
+ * Where the pipeline asks what signature a request should carry. Null for
5
+ * an endpoint the source does not know, so a signed call for a name the
6
+ * server does not have is answered versionExpired rather than apiNotFound:
7
+ * the client was built against a contract that had it. The server answers
8
+ * from its own schemas (LambderApiSignatureDigests); the mock runtime, which
9
+ * holds no server schema, answers from the generated map when given one.
10
+ */
11
+ export type LambderApiSignatureSource = {
12
+ expectedSignatureOf(apiName: string, definition: LambderApiDefinition | null): Promise<string | null>;
13
+ };
14
+ /**
15
+ * The digest of an endpoint's client-facing shape: its name and mode, its
16
+ * input and output schemas as JSON Schema, every guard it declares with the
17
+ * schema that guard validates (the guardInput the client sends separately,
18
+ * or the apiInput slice of the payload), and whether it demands an
19
+ * idempotency key. Anything else about the endpoint (its rate limits, a
20
+ * guard's parameter, the handler) changes nothing for a client and is left
21
+ * out, so changing it never forces a reload.
22
+ *
23
+ * The description is hashed as built, descriptions and titles included: a
24
+ * schema is what the server says it is, and a client built against a
25
+ * different one reloads once.
26
+ */
27
+ export declare const apiSignatureOf: (definition: LambderApiDefinition, guards: Record<string, LambderApiGuard<any, any, any>> | undefined) => Promise<string>;
28
+ /**
29
+ * The server's signature source: every registered endpoint digested from
30
+ * its own schemas, once per endpoint per container, on first use. What
31
+ * Lambder.apiSignatures() reads to build the client's map, and what the
32
+ * pipeline compares a request's signature against.
33
+ */
34
+ export declare class LambderApiSignatureDigests implements LambderApiSignatureSource {
35
+ private readonly guards;
36
+ private readonly digests;
37
+ constructor(guards: Record<string, LambderApiGuard<any, any, any>> | undefined);
38
+ /** The endpoint's signature, computed on the first ask and kept. A digest that failed is not kept, so the next call tries again rather than failing forever. */
39
+ signatureOf(definition: LambderApiDefinition): Promise<string>;
40
+ expectedSignatureOf(apiName: string, definition: LambderApiDefinition | null): Promise<string | null>;
41
+ }
@@ -0,0 +1,91 @@
1
+ import { z } from "zod";
2
+ import { toGuardEntries } from "./LambderApiGuards.js";
3
+ import { API_SIGNATURE_HEX_LENGTH } from "../shared/wire/LambderApiSignature.js";
4
+ import { sha256HexOf } from "../shared/util/LambderTextDigest.js";
5
+ /**
6
+ * JSON with object keys sorted at every level, so two descriptions of the
7
+ * same shape hash the same whatever order they were built in. Arrays keep
8
+ * their order: a tuple's positions and an enum's values are part of the
9
+ * shape. Undefined entries are dropped, as JSON.stringify would drop them.
10
+ */
11
+ const canonicalJson = (value) => JSON.stringify(sortKeys(value));
12
+ const sortKeys = (value) => {
13
+ if (Array.isArray(value))
14
+ return value.map(sortKeys);
15
+ if (value === null || typeof value !== "object")
16
+ return value;
17
+ const source = value;
18
+ const sorted = {};
19
+ for (const key of Object.keys(source).sort()) {
20
+ if (source[key] !== undefined)
21
+ sorted[key] = sortKeys(source[key]);
22
+ }
23
+ return sorted;
24
+ };
25
+ /**
26
+ * A schema as JSON Schema, exactly as zod emits it. A type JSON Schema
27
+ * cannot express (a transform's output, a custom check) becomes `{}` rather
28
+ * than throwing, because a digest has to exist for every endpoint; what the
29
+ * digest cannot see is documented with it.
30
+ */
31
+ const jsonSchemaOf = (schema, io) => schema ? z.toJSONSchema(schema, { io, unrepresentable: "any" }) : null;
32
+ const ownGuard = (guards, name) => guards !== undefined && Object.prototype.hasOwnProperty.call(guards, name) ? guards[name] : undefined;
33
+ /**
34
+ * The digest of an endpoint's client-facing shape: its name and mode, its
35
+ * input and output schemas as JSON Schema, every guard it declares with the
36
+ * schema that guard validates (the guardInput the client sends separately,
37
+ * or the apiInput slice of the payload), and whether it demands an
38
+ * idempotency key. Anything else about the endpoint (its rate limits, a
39
+ * guard's parameter, the handler) changes nothing for a client and is left
40
+ * out, so changing it never forces a reload.
41
+ *
42
+ * The description is hashed as built, descriptions and titles included: a
43
+ * schema is what the server says it is, and a client built against a
44
+ * different one reloads once.
45
+ */
46
+ export const apiSignatureOf = async (definition, guards) => {
47
+ const guardShapes = toGuardEntries(definition.guards).map(({ name }) => {
48
+ const guard = ownGuard(guards, name);
49
+ return [name, {
50
+ apiInput: jsonSchemaOf(guard?.apiInput, "input"),
51
+ guardInput: jsonSchemaOf(guard?.guardInput, "input"),
52
+ }];
53
+ });
54
+ guardShapes.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
55
+ const description = {
56
+ name: definition.name,
57
+ mode: definition.mode,
58
+ input: jsonSchemaOf(definition.input, "input"),
59
+ output: jsonSchemaOf(definition.output, "output"),
60
+ guards: guardShapes,
61
+ idempotency: definition.idempotency !== undefined && definition.idempotency !== false,
62
+ };
63
+ const hex = await sha256HexOf(canonicalJson(description));
64
+ return hex.slice(0, API_SIGNATURE_HEX_LENGTH);
65
+ };
66
+ /**
67
+ * The server's signature source: every registered endpoint digested from
68
+ * its own schemas, once per endpoint per container, on first use. What
69
+ * Lambder.apiSignatures() reads to build the client's map, and what the
70
+ * pipeline compares a request's signature against.
71
+ */
72
+ export class LambderApiSignatureDigests {
73
+ guards;
74
+ digests = new Map();
75
+ constructor(guards) {
76
+ this.guards = guards;
77
+ }
78
+ /** The endpoint's signature, computed on the first ask and kept. A digest that failed is not kept, so the next call tries again rather than failing forever. */
79
+ signatureOf(definition) {
80
+ let pending = this.digests.get(definition.name);
81
+ if (!pending) {
82
+ pending = apiSignatureOf(definition, this.guards);
83
+ this.digests.set(definition.name, pending);
84
+ pending.catch(() => this.digests.delete(definition.name));
85
+ }
86
+ return pending;
87
+ }
88
+ async expectedSignatureOf(apiName, definition) {
89
+ return definition ? await this.signatureOf(definition) : null;
90
+ }
91
+ }
@@ -4,6 +4,7 @@ import type { LambderApiContractShape } from '../shared/wire/LambderApiContract.
4
4
  import { type LambderApiOutcome, type LambderValidationError } from '../shared/wire/LambderApiOutcome.js';
5
5
  import { type LambderCallArgs, type LambderContractOutputOf, type LambderGuardInputsProviderOption, type LambderSharedCallOptions } from '../shared/wire/LambderCallOptions.js';
6
6
  import { type LambderApiTransport } from '../shared/transport/LambderApiTransport.js';
7
+ import { type LambderApiSignatureMap } from '../shared/wire/LambderApiSignature.js';
7
8
  export type { LambderApiOutcome, LambderApiFailureReason, LambderValidationError } from '../shared/wire/LambderApiOutcome.js';
8
9
  export type { LambderProvidedGuardInputs, LambderGuardInputsProvider } from '../shared/wire/LambderCallOptions.js';
9
10
  /** A handler told that something happened, with nothing to hand it. */
@@ -56,7 +57,16 @@ export type LambderCallOptions = LambderSharedCallOptions & {
56
57
  };
57
58
  type LambderCallerBaseOptions = {
58
59
  apiPath: string;
60
+ /** Sent with every call as `version`, informational: the server stamps its own on every answer. */
59
61
  apiVersion?: string;
62
+ /**
63
+ * The server's signature map, generated from its instance
64
+ * (Lambder.apiSignatures()) and shipped with this build. Sent per call as
65
+ * `signature`, so the server answers versionExpired to a call built
66
+ * against another shape of the endpoint and runs every other call. Leave
67
+ * it out and no call is gated.
68
+ */
69
+ apiSignatures?: LambderApiSignatureMap;
60
70
  isCorsEnabled: boolean;
61
71
  /** Default per-request timeout in ms (none unless set; API Gateway caps around 29s, so ~30000 is a sensible value). Overridable per call. */
62
72
  timeoutMs?: number;
@@ -101,7 +111,10 @@ export default class LambderCaller<TContract extends LambderApiContractShape = a
101
111
  private isCorsEnabled;
102
112
  private apiPath;
103
113
  private apiVersion?;
114
+ private apiSignatures?;
104
115
  private timeoutMs?;
116
+ /** What keeps a stale bundle from reloading itself forever; see the class. */
117
+ private readonly reloadLoopBreaker;
105
118
  /** The calls currently in flight, in the order they started. */
106
119
  fetchTrackerList: FetchTracker[];
107
120
  /** Whether any call is in flight. Derived, so it cannot drift from the list the way a separate flag did. */
@@ -7,6 +7,8 @@ import { createCallAbort } from '../shared/util/LambderCallAbort.js';
7
7
  import { coerceToError } from '../shared/wire/LambderCrashDetail.js';
8
8
  import { isLambderTransportFailure } from '../shared/transport/LambderApiTransport.js';
9
9
  import { DEFAULT_SESSION_TOKEN_COOKIE_KEY, DEFAULT_SESSION_CSRF_COOKIE_KEY } from '../shared/wire/LambderSessionCookieNames.js';
10
+ import { readApiSignature } from '../shared/wire/LambderApiSignature.js';
11
+ import { LambderReloadLoopBreaker, RELOAD_LOOP_WINDOW_MS } from './LambderReloadLoopBreaker.js';
10
12
  import { lambderFetchTransport } from './lambderFetchTransport.js';
11
13
  /**
12
14
  * @typeParam TContract - The API contract, for typed names, payloads and guard inputs.
@@ -16,7 +18,10 @@ export default class LambderCaller {
16
18
  isCorsEnabled;
17
19
  apiPath;
18
20
  apiVersion;
21
+ apiSignatures;
19
22
  timeoutMs;
23
+ /** What keeps a stale bundle from reloading itself forever; see the class. */
24
+ reloadLoopBreaker = new LambderReloadLoopBreaker();
20
25
  /** The calls currently in flight, in the order they started. */
21
26
  fetchTrackerList = [];
22
27
  /** Whether any call is in flight. Derived, so it cannot drift from the list the way a separate flag did. */
@@ -40,9 +45,10 @@ export default class LambderCaller {
40
45
  constructor(options) {
41
46
  // The conditional provider option is resolved per instantiation;
42
47
  // inside the class it is read through the plain shape.
43
- const { apiPath, apiVersion, isCorsEnabled, timeoutMs, versionExpiredHandler, sessionExpiredHandler, messageHandler, errorMessageHandler, notAuthorizedHandler, errorHandler, logListHandler, fetchStartedHandler, fetchEndedHandler, apiInputValidationErrorHandler, sessionCookieDomain, requestCompression, guardInputsProvider, transport, } = options;
48
+ const { apiPath, apiVersion, apiSignatures, isCorsEnabled, timeoutMs, versionExpiredHandler, sessionExpiredHandler, messageHandler, errorMessageHandler, notAuthorizedHandler, errorHandler, logListHandler, fetchStartedHandler, fetchEndedHandler, apiInputValidationErrorHandler, sessionCookieDomain, requestCompression, guardInputsProvider, transport, } = options;
44
49
  this.apiPath = apiPath;
45
50
  this.apiVersion = apiVersion;
51
+ this.apiSignatures = apiSignatures;
46
52
  this.isCorsEnabled = isCorsEnabled;
47
53
  this.timeoutMs = timeoutMs;
48
54
  this.sessionCookieDomain = sessionCookieDomain;
@@ -198,6 +204,10 @@ export default class LambderCaller {
198
204
  activeFetchList: [...this.fetchTrackerList],
199
205
  });
200
206
  const version = this.apiVersion;
207
+ // The server's signature for this endpoint, when this build
208
+ // carries the map. A name the map lacks fails the call here, as a
209
+ // provider that threw would: the map predates the endpoint.
210
+ const signature = this.apiSignatures ? await readApiSignature(this.apiSignatures, apiName) : undefined;
201
211
  // js-cookie reads nothing without a document, and there is no
202
212
  // location outside a page: both are "" then, and a transport that
203
213
  // carries a cookie jar fills the token in from it.
@@ -228,6 +238,7 @@ export default class LambderCaller {
228
238
  answer = await this.transport({
229
239
  apiPath: this.apiPath,
230
240
  apiName, version, token, siteHost,
241
+ ...(signature !== undefined ? { signature } : {}),
231
242
  csrfCookieKey: this.sessionCsrfCookieKey,
232
243
  ...(compressedPayload ? { compressed: compressedPayload } : { payload }),
233
244
  ...(guardInputs !== undefined ? { guardInputs } : {}),
@@ -292,6 +303,15 @@ export default class LambderCaller {
292
303
  const data = outcome.response;
293
304
  await fetchEnded(data);
294
305
  if (!outcome.ok && outcome.reason === 'versionExpired') {
306
+ // A repeat of a recent versionExpired for the same endpoint and
307
+ // signature means the reload the handler performed brought the
308
+ // same bundle back, and reloading again would loop. The
309
+ // handler is not called; the failure is reported instead, and
310
+ // the outcome still says versionExpired.
311
+ if (this.reloadLoopBreaker.isRepeat(apiName, signature ?? "")) {
312
+ await reportError(new Error(`Version expired again for API "${apiName}" within ${RELOAD_LOOP_WINDOW_MS / 60000} minutes with the same signature: the bundle being served is still the stale one, so versionExpiredHandler was not called again.`));
313
+ return outcome;
314
+ }
295
315
  if (versionExpiredHandler) {
296
316
  await versionExpiredHandler();
297
317
  }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Stops a stale client from reloading forever.
3
+ *
4
+ * A versionExpired answer means "this client's signature for the endpoint is
5
+ * not the one the server holds", and the ordinary response is to reload and
6
+ * get the current bundle. When the bundle being served is itself the stale
7
+ * one (a frontend deployed with a signature map the server does not match, a
8
+ * cached bundle, a server deploy that failed behind a fresh frontend), the
9
+ * reload brings back the same signature, the same call fails the same way,
10
+ * and the page reloads again, indefinitely.
11
+ *
12
+ * The evidence of that loop is a versionExpired for the same endpoint with
13
+ * the same signature shortly after the last one: a bundle that had actually
14
+ * changed the endpoint would carry a different signature. The record lives
15
+ * in sessionStorage, which is per tab and survives a reload, so the new page
16
+ * instance sees what the previous one saw; without sessionStorage (a test, a
17
+ * non-browser runtime) an in-memory record does the same within one page.
18
+ *
19
+ * Once a repeat is confirmed, every versionExpired within the window from
20
+ * the first one counts as a repeat too, whichever endpoint it names: a stale
21
+ * bundle is usually stale for several endpoints, and one reload per endpoint
22
+ * is still a loop, only a slower one. After the window a reload is allowed
23
+ * again, so a client stuck on a stale bundle retries a few times an hour and
24
+ * recovers by itself once the deploy is fixed.
25
+ */
26
+ /** How long after the first versionExpired a repeat counts as the same loop. */
27
+ export declare const RELOAD_LOOP_WINDOW_MS: number;
28
+ export declare class LambderReloadLoopBreaker {
29
+ /** The record when sessionStorage is unavailable; sessionStorage is read first wherever it exists. */
30
+ private memory;
31
+ /**
32
+ * Records this versionExpired and says whether it repeats a recent one,
33
+ * in which case the caller must not invoke versionExpiredHandler again.
34
+ */
35
+ isRepeat(apiName: string, signature: string, now?: number): boolean;
36
+ private read;
37
+ private write;
38
+ }