lambder 7.0.2 → 7.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +112 -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 +41 -11
  8. package/dist/api/LambderApiPipeline.js +55 -13
  9. package/dist/api/LambderApiRequest.d.ts +3 -1
  10. package/dist/api/LambderApiRequest.js +1 -0
  11. package/dist/api/LambderApiSignature.d.ts +19 -0
  12. package/dist/api/LambderApiSignature.js +96 -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 +4 -0
  18. package/dist/client.js +4 -0
  19. package/dist/core/Lambder.d.ts +17 -1
  20. package/dist/core/Lambder.js +30 -5
  21. package/dist/core/LambderCreateOptions.d.ts +29 -0
  22. package/dist/core/LambderCreateOptions.js +0 -5
  23. package/dist/index.d.ts +5 -0
  24. package/dist/index.js +5 -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 +6 -4
  31. package/dist/mock/LambderMockCreateOptions.d.ts +6 -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 +46 -0
  37. package/dist/shared/wire/LambderApiSignature.js +45 -0
  38. package/dist/shared/wire/LambderVersionOrder.d.ts +11 -0
  39. package/dist/shared/wire/LambderVersionOrder.js +28 -0
  40. package/package.json +1 -1
@@ -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
+ }
@@ -0,0 +1,71 @@
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 const RELOAD_LOOP_WINDOW_MS = 5 * 60 * 1000;
28
+ const STORAGE_KEY = "lambder:version-expired";
29
+ const isExpiredRecord = (value) => typeof value === "object" && value !== null
30
+ && typeof value.apiName === "string"
31
+ && typeof value.signature === "string"
32
+ && typeof value.at === "number"
33
+ && typeof value.confirmed === "boolean";
34
+ export class LambderReloadLoopBreaker {
35
+ /** The record when sessionStorage is unavailable; sessionStorage is read first wherever it exists. */
36
+ memory = null;
37
+ /**
38
+ * Records this versionExpired and says whether it repeats a recent one,
39
+ * in which case the caller must not invoke versionExpiredHandler again.
40
+ */
41
+ isRepeat(apiName, signature, now = Date.now()) {
42
+ const last = this.read();
43
+ if (last && now - last.at < RELOAD_LOOP_WINDOW_MS && (last.confirmed || (last.apiName === apiName && last.signature === signature))) {
44
+ // `at` stays the first event's, so the window runs from the start
45
+ // of the loop rather than being pushed forward by every repeat.
46
+ this.write({ apiName, signature, at: last.at, confirmed: true });
47
+ return true;
48
+ }
49
+ this.write({ apiName, signature, at: now, confirmed: false });
50
+ return false;
51
+ }
52
+ read() {
53
+ try {
54
+ const raw = globalThis.sessionStorage?.getItem(STORAGE_KEY);
55
+ if (raw) {
56
+ const parsed = JSON.parse(raw);
57
+ if (isExpiredRecord(parsed))
58
+ return parsed;
59
+ }
60
+ }
61
+ catch { /* a private window or blocked storage: the in-memory record stands in */ }
62
+ return this.memory;
63
+ }
64
+ write(record) {
65
+ this.memory = record;
66
+ try {
67
+ globalThis.sessionStorage?.setItem(STORAGE_KEY, JSON.stringify(record));
68
+ }
69
+ catch { /* same: the in-memory record stands in */ }
70
+ }
71
+ }
package/dist/client.d.ts CHANGED
@@ -15,6 +15,10 @@ export type { LambderApiTransport, LambderApiTransportRequest, LambderTransportF
15
15
  export { LambderCookieJar, parseSetCookie } from "./shared/transport/LambderCookieJar.js";
16
16
  export type { LambderStoredCookie } from "./shared/transport/LambderCookieJar.js";
17
17
  export { resolveApiOutcome } from "./shared/wire/LambderApiOutcome.js";
18
+ export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH } from "./shared/wire/LambderApiSignature.js";
19
+ export type { LambderApiSignatureMap } from "./shared/wire/LambderApiSignature.js";
20
+ export { RELOAD_LOOP_WINDOW_MS } from "./client/LambderReloadLoopBreaker.js";
21
+ export { compareDottedVersions, isDottedVersion } from "./shared/wire/LambderVersionOrder.js";
18
22
  export type { LambderApiAnswerOutcome, LambderApiSuccessOutcome, LambderApiCallFailure, LambderApiValidationFailure, LambderApiEnvelopeFailure, LambderApiHttpAnswer, } from "./shared/wire/LambderApiOutcome.js";
19
23
  export type { LambderApiOutcome, LambderApiFailureReason, LambderValidationError, LambderCallOptions, LambderCallerOptions, LambderGuardInputsProvider, LambderProvidedGuardInputs, LambderIdempotencyKeyScope, LambderLogListHandler, } from "./client/LambderCaller.js";
20
24
  export { LambderApiRefusal, isLambderApiRefusal, refuse, LAMBDER_REFUSAL_CODES } from "./shared/wire/LambderApiRefusal.js";
package/dist/client.js CHANGED
@@ -14,6 +14,10 @@ export { buildTransportEnvelope, LambderTransportFailure, isLambderTransportFail
14
14
  export { lambderCookieJarTransport } from "./shared/transport/lambderCookieJarTransport.js";
15
15
  export { LambderCookieJar, parseSetCookie } from "./shared/transport/LambderCookieJar.js";
16
16
  export { resolveApiOutcome } from "./shared/wire/LambderApiOutcome.js";
17
+ // The per-endpoint signature map a build ships with, how a caller reads it, and the reload-loop window.
18
+ export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH } from "./shared/wire/LambderApiSignature.js";
19
+ export { RELOAD_LOOP_WINDOW_MS } from "./client/LambderReloadLoopBreaker.js";
20
+ export { compareDottedVersions, isDottedVersion } from "./shared/wire/LambderVersionOrder.js";
17
21
  // Typed API refusals (isomorphic: shared code may throw them from anywhere;
18
22
  // in the browser they are plain Errors).
19
23
  export { LambderApiRefusal, isLambderApiRefusal, refuse, LAMBDER_REFUSAL_CODES } from "./shared/wire/LambderApiRefusal.js";
@@ -9,6 +9,7 @@ import type LambderSessionController from "../session/LambderSessionController.j
9
9
  import { type LambderPublicFilesOptions } from "./LambderPublicFiles.js";
10
10
  import { type LambderIndexHtmlOptions } from "./LambderIndexHtml.js";
11
11
  import { LambderFiles } from "./LambderFiles.js";
12
+ import { type LambderApiSignatureMap } from "../shared/wire/LambderApiSignature.js";
12
13
  import type { LambderApiIdempotencyOption } from "../shared/wire/LambderApiOptionValues.js";
13
14
  import type { LambderApiGuard, LambderGuardMetaMap, LambderGuardsOption, LambderGuardDataOf, LambderGuardInputsOf } from "../api/LambderApiGuards.js";
14
15
  import type { LambderApiRateLimitPolicyConfig, LambderRateLimitOption } from "../api/LambderApiRateLimits.js";
@@ -50,6 +51,7 @@ export type LambderCreatedHook = (lambderInstance: Lambder<any, any, any, any, a
50
51
  */
51
52
  export default class Lambder<TSessionData = any, _TContract extends Record<string, any> = {}, _TRateLimitPolicies extends Record<string, LambderApiRateLimitPolicyConfig> = {}, _TGuards extends Record<string, any> = {}, _TIdempotencyEnabled extends boolean = false, _TSessionGuardsRequired extends boolean = false, _TPublicGuardsRequired extends boolean = false, _TSessionsEnabled extends boolean = true> {
52
53
  apiPath: string;
54
+ /** Stamped on every API answer's envelope as apiVersion. Informational: a client's staleness is judged per endpoint by its signature, see apiSignatures(). */
53
55
  apiVersion: null | string;
54
56
  /** The instance's file reader (source + caches), or null without the files option. */
55
57
  files: LambderFiles | null;
@@ -67,7 +69,10 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
67
69
  private actionList;
68
70
  /** The API core: the pipeline every API call runs through, shared in shape with the mock runtime. */
69
71
  private readonly pipeline;
70
- private registeredApiNames;
72
+ /** Every registered API by name: the duplicate-name check, and what apiSignatures() digests. */
73
+ private readonly apiDefinitions;
74
+ /** The guards map given at creation, kept for apiSignatures(): a guard's schema is part of the signature of every endpoint declaring it. */
75
+ private readonly guards;
71
76
  private hookList;
72
77
  private createdHooks;
73
78
  private initPromise;
@@ -165,6 +170,17 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
165
170
  getSessionController(ctx: LambderRenderContext | LambderSessionRenderContext<any, TSessionData>): LambderSessionController<TSessionData>;
166
171
  /** The session manager, for code that works on sessions outside a request (maintenance, tests). */
167
172
  getSessionManager(): LambderSessionManager<TSessionData>;
173
+ /**
174
+ * Every registered endpoint's signature, keyed by its hashed name: the
175
+ * LambderApiSignatureMap both sides ship with. A generator imports the
176
+ * finished instance, awaits this, and writes the result to a file the
177
+ * frontend passes to LambderCaller as apiSignatures and the server passes
178
+ * to create() as apiSignatures; at request time the pipeline compares a
179
+ * call's signature with the server's copy of the same map. This is the
180
+ * one place a digest is computed, so it has nothing to agree with but
181
+ * itself. Keys are sorted, so the generated file diffs by endpoint.
182
+ */
183
+ apiSignatures(): Promise<LambderApiSignatureMap>;
168
184
  getResponseBuilder(ctx?: LambderRenderContext): LambderResponseBuilder<any>;
169
185
  private getResolver;
170
186
  getHandler(): LambderHandler;
@@ -10,6 +10,8 @@ import { LambderIndexHtmlHandler } from "./LambderIndexHtml.js";
10
10
  import { LambderFiles } from "./LambderFiles.js";
11
11
  import { isLambderApiRefusal } from "../shared/wire/LambderApiRefusal.js";
12
12
  import { LambderApiPipeline } from "../api/LambderApiPipeline.js";
13
+ import { apiSignatureOf } from "../api/LambderApiSignature.js";
14
+ import { apiNameKeyOf } from "../shared/wire/LambderApiSignature.js";
13
15
  import { apiNotFoundAnswer, crashAnswer, refusalAnswer, } from "../api/LambderApiEnvelope.js";
14
16
  import { createContext, isV2HttpEvent } from "./LambderContext.js";
15
17
  import { COMPRESSED_PAYLOAD_GZ_FIELD, COMPRESSED_PAYLOAD_BR_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD } from "../shared/wire/LambderRequestPayload.js";
@@ -45,6 +47,7 @@ export default class Lambder {
45
47
  // Everything an instance is, fixed before the first registration.
46
48
  // =====================================================================
47
49
  apiPath;
50
+ /** Stamped on every API answer's envelope as apiVersion. Informational: a client's staleness is judged per endpoint by its signature, see apiSignatures(). */
48
51
  apiVersion;
49
52
  /** The instance's file reader (source + caches), or null without the files option. */
50
53
  files;
@@ -62,7 +65,10 @@ export default class Lambder {
62
65
  actionList = [];
63
66
  /** The API core: the pipeline every API call runs through, shared in shape with the mock runtime. */
64
67
  pipeline;
65
- registeredApiNames = new Set();
68
+ /** Every registered API by name: the duplicate-name check, and what apiSignatures() digests. */
69
+ apiDefinitions = new Map();
70
+ /** The guards map given at creation, kept for apiSignatures(): a guard's schema is part of the signature of every endpoint declaring it. */
71
+ guards;
66
72
  hookList = { "beforeRender": [], "afterRender": [], "fallback": [] };
67
73
  createdHooks = [];
68
74
  initPromise = null;
@@ -95,8 +101,11 @@ export default class Lambder {
95
101
  this.corsConfig = options.cors === true ? {} : options.cors;
96
102
  }
97
103
  const session = options.session;
104
+ this.guards = options.guards;
98
105
  this.pipeline = new LambderApiPipeline({
99
106
  apiVersion: this.apiVersion,
107
+ minApiVersion: options.minApiVersion,
108
+ apiSignatures: options.apiSignatures,
100
109
  maxRequestPayloadBytes: options.maxRequestPayloadBytes,
101
110
  // The app's own validation handler is read at call time, since
102
111
  // setApiInputValidationErrorHandler runs after creation.
@@ -200,7 +209,8 @@ export default class Lambder {
200
209
  // Typed API with Zod
201
210
  addApi(name, schema, handler) {
202
211
  this.assertApiRegistration(name, "public", schema);
203
- const definition = { name, mode: "public", guards: schema.guards, rateLimit: schema.rateLimit, idempotency: schema.idempotency, input: schema.input };
212
+ const definition = { name, mode: "public", guards: schema.guards, rateLimit: schema.rateLimit, idempotency: schema.idempotency, input: schema.input, output: schema.output };
213
+ this.apiDefinitions.set(name, definition);
204
214
  this.actionList.push({
205
215
  match: (ctx) => ctx.apiName === name ? {} : false,
206
216
  actionFn: (ctx, resolver) => this.runApi(ctx, resolver, definition, handler),
@@ -210,7 +220,8 @@ export default class Lambder {
210
220
  // Typed Session API with Zod
211
221
  addSessionApi(name, schema, handler) {
212
222
  this.assertApiRegistration(name, "session", schema);
213
- const definition = { name, mode: "session", guards: schema.guards, rateLimit: schema.rateLimit, idempotency: schema.idempotency, input: schema.input };
223
+ const definition = { name, mode: "session", guards: schema.guards, rateLimit: schema.rateLimit, idempotency: schema.idempotency, input: schema.input, output: schema.output };
224
+ this.apiDefinitions.set(name, definition);
214
225
  this.actionList.push({
215
226
  match: (ctx) => ctx.apiName === name ? {} : false,
216
227
  actionFn: (ctx, resolver) => this.runApi(ctx, resolver, definition, handler),
@@ -276,6 +287,21 @@ export default class Lambder {
276
287
  getSessionManager() {
277
288
  return this.pipeline.sessionManager;
278
289
  }
290
+ /**
291
+ * Every registered endpoint's signature, keyed by its hashed name: the
292
+ * LambderApiSignatureMap both sides ship with. A generator imports the
293
+ * finished instance, awaits this, and writes the result to a file the
294
+ * frontend passes to LambderCaller as apiSignatures and the server passes
295
+ * to create() as apiSignatures; at request time the pipeline compares a
296
+ * call's signature with the server's copy of the same map. This is the
297
+ * one place a digest is computed, so it has nothing to agree with but
298
+ * itself. Keys are sorted, so the generated file diffs by endpoint.
299
+ */
300
+ async apiSignatures() {
301
+ const entries = await Promise.all([...this.apiDefinitions.values()].map(async (definition) => [await apiNameKeyOf(definition.name), await apiSignatureOf(definition, this.guards)]));
302
+ entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
303
+ return Object.fromEntries(entries);
304
+ }
279
305
  getResponseBuilder(ctx) {
280
306
  return new LambderResponseBuilder({
281
307
  files: this.files,
@@ -583,7 +609,7 @@ export default class Lambder {
583
609
  // =====================================================================
584
610
  /** Registration-time checks shared by addApi/addSessionApi. */
585
611
  assertApiRegistration(name, mode, options) {
586
- if (this.registeredApiNames.has(name)) {
612
+ if (this.apiDefinitions.has(name)) {
587
613
  throw new Error(`Lambder: duplicate API name "${name}". Dispatch is first-match, so the second registration would be silently dead code.`);
588
614
  }
589
615
  // Everything that can refuse this registration runs before the name is
@@ -603,7 +629,6 @@ export default class Lambder {
603
629
  `Declare the guard that authorizes it, or ${optOut}.`);
604
630
  }
605
631
  this.pipeline.assertRegistration({ name, mode, guards: options.guards, rateLimit: options.rateLimit, idempotency: options.idempotency });
606
- this.registeredApiNames.add(name);
607
632
  }
608
633
  /**
609
634
  * The answer for a rejected input: the app's
@@ -1,4 +1,5 @@
1
1
  import type { z } from "zod";
2
+ import type { LambderApiSignatureMap } from "../shared/wire/LambderApiSignature.js";
2
3
  import type { Context } from "aws-lambda";
3
4
  import type LambderResolver from "./LambderResolver.js";
4
5
  import type LambderResponseBuilder from "./LambderResponseBuilder.js";
@@ -99,7 +100,35 @@ export type LambderCreateOptions<TSessionData = any> = {
99
100
  */
100
101
  files?: LambderFilesOption;
101
102
  apiPath?: string;
103
+ /**
104
+ * Stamped on every API answer's envelope as `apiVersion`, so a client can
105
+ * tell which build answered. Whether a client is stale is decided per
106
+ * endpoint by the signature it sends (see Lambder.apiSignatures()), not
107
+ * by this string; `minApiVersion` is the one thing that reads it. Dotted
108
+ * numbers ("1.2.10"), since that is how the floor compares it, so a
109
+ * commit sha or a build date is refused rather than read as zero.
110
+ */
102
111
  apiVersion?: string;
112
+ /**
113
+ * The oldest client build still served: a call naming a `version` below
114
+ * it answers `versionExpired` whatever its signature says. The lever for
115
+ * a change the signatures cannot see (a security fix, a field whose
116
+ * meaning changed under the same shape). Dotted numbers ("1.2.10"),
117
+ * compared segment by segment; a call naming no version is not judged.
118
+ * A floor above `apiVersion` is taken as `apiVersion`, with a warning,
119
+ * so a mistaken floor cannot refuse this build's own clients. Default:
120
+ * none.
121
+ */
122
+ minApiVersion?: string;
123
+ /**
124
+ * The generated signature map (Lambder.apiSignatures()), the same file
125
+ * the frontend ships with. Enables the signature gate: a call carrying a
126
+ * signature that is not this map's entry for its endpoint answers
127
+ * `versionExpired`. Generated once, at build time, and handed to both
128
+ * sides, so nothing is digested at request time and the two sides cannot
129
+ * disagree on a digest. Default: none, and no gate.
130
+ */
131
+ apiSignatures?: LambderApiSignatureMap;
103
132
  /**
104
133
  * Automatic compression for compressible responses. `true` (the default)
105
134
  * is `{ minBytes: 860, encodings: ["br", "gzip"], quality: 5 }`; `false`
@@ -23,11 +23,6 @@ export const assertCreateOptions = (options) => {
23
23
  if (options.apiPath !== undefined && (options.apiPath === "" || !options.apiPath.startsWith("/"))) {
24
24
  throw new Error(`Lambder: apiPath must be a path starting with "/", got ${JSON.stringify(options.apiPath)}.`);
25
25
  }
26
- // "" is the one string that turns the version gate off while looking like
27
- // it was set; say so rather than accepting every version a client names.
28
- if (options.apiVersion === "") {
29
- throw new Error("Lambder: apiVersion must not be empty. Leave it out to run without the version gate.");
30
- }
31
26
  // 0 or a negative ceiling turned every response into the size guard's own 500.
32
27
  if (options.maxResponseBytes !== undefined)
33
28
  assertPositiveInteger(options.maxResponseBytes, "maxResponseBytes");
package/dist/index.d.ts CHANGED
@@ -25,6 +25,11 @@ export { LambderAnswerHeaders, getAnswerHeader, setAnswerHeader, addAnswerHeader
25
25
  export { createApiCallContext } from "./api/LambderApiCallContext.js";
26
26
  export type { LambderApiCallContext, LambderApiCallTrace } from "./api/LambderApiCallContext.js";
27
27
  export type { LambderApiDefinition } from "./api/LambderApiDefinition.js";
28
+ export { apiSignatureOf } from "./api/LambderApiSignature.js";
29
+ export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH } from "./shared/wire/LambderApiSignature.js";
30
+ export type { LambderApiSignatureMap } from "./shared/wire/LambderApiSignature.js";
31
+ export { RELOAD_LOOP_WINDOW_MS } from "./client/LambderReloadLoopBreaker.js";
32
+ export { compareDottedVersions, isDottedVersion } from "./shared/wire/LambderVersionOrder.js";
28
33
  export { buildApiEnvelope, envelopeAnswer, refusalAnswer, validationAnswer, apiNotFoundAnswer, sessionExpiredAnswer, versionExpiredAnswer, invalidPayloadAnswer, crashAnswer, API_ANSWER_CONTENT_TYPE, } from "./api/LambderApiEnvelope.js";
29
34
  export type { LambderApiEnvelopeConfig, LambderValidationAnswerBody } from "./api/LambderApiEnvelope.js";
30
35
  export { LambderApiValidationRefusal, isLambderApiValidationRefusal } from "./api/LambderApiValidationRefusal.js";
package/dist/index.js CHANGED
@@ -16,6 +16,11 @@ export { readApiEnvelope, restoreCompressedPayload } from "./api/LambderApiReque
16
16
  export { toHttpAnswer } from "./api/LambderApiAnswer.js";
17
17
  export { LambderAnswerHeaders, getAnswerHeader, setAnswerHeader, addAnswerHeader } from "./shared/wire/LambderAnswerHeaders.js";
18
18
  export { createApiCallContext } from "./api/LambderApiCallContext.js";
19
+ // Per-endpoint signatures: what a client build ships with, digested from the server's own registrations.
20
+ export { apiSignatureOf } from "./api/LambderApiSignature.js";
21
+ export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH } from "./shared/wire/LambderApiSignature.js";
22
+ export { RELOAD_LOOP_WINDOW_MS } from "./client/LambderReloadLoopBreaker.js";
23
+ export { compareDottedVersions, isDottedVersion } from "./shared/wire/LambderVersionOrder.js";
19
24
  export { buildApiEnvelope, envelopeAnswer, refusalAnswer, validationAnswer, apiNotFoundAnswer, sessionExpiredAnswer, versionExpiredAnswer, invalidPayloadAnswer, crashAnswer, API_ANSWER_CONTENT_TYPE, } from "./api/LambderApiEnvelope.js";
20
25
  export { LambderApiValidationRefusal, isLambderApiValidationRefusal } from "./api/LambderApiValidationRefusal.js";
21
26
  // Calling a Lambder app from another lambda (server-only: the Lambda SDK, zlib)
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import type { APIGatewayProxyEventV2, Context } from "aws-lambda";
24
24
  import { type LambderInvokeFailure, type LambderInvokeOutcome } from "./LambderInvokeOutcome.js";
25
+ import { type LambderApiSignatureMap } from "../shared/wire/LambderApiSignature.js";
25
26
  import type { LambdaClient, LambdaClientConfig } from "@aws-sdk/client-lambda";
26
27
  import type { LambderApiContractShape } from "../shared/wire/LambderApiContract.js";
27
28
  import { type LambderCallArgs, type LambderContractOutputOf, type LambderGuardInputsProviderOption, type LambderSharedCallOptions } from "../shared/wire/LambderCallOptions.js";
@@ -92,8 +93,15 @@ type LambderInvokeCallerBaseOptions = {
92
93
  clientConfig?: LambdaClientConfig;
93
94
  /** Must match the callee's apiPath. Default: "/api". */
94
95
  apiPath?: string;
95
- /** Sent as `version`; the callee answers versionExpired on a mismatch when it has one too. Default: none. */
96
+ /** Sent as `version`, informational: the callee stamps its own on every answer. Default: none. */
96
97
  apiVersion?: string;
98
+ /**
99
+ * The callee's signature map, generated from its instance
100
+ * (Lambder.apiSignatures()) when this caller was built. Sent per call as
101
+ * `signature`, so the callee answers versionExpired to a call built
102
+ * against another shape of the endpoint. Default: none, and no gate.
103
+ */
104
+ apiSignatures?: LambderApiSignatureMap;
97
105
  /** The Host the callee sees (ctx.host). Default: functionName. */
98
106
  host?: string;
99
107
  /**
@@ -146,6 +154,8 @@ export type LambderInvokeEventInit = {
146
154
  /** Default: "lambder-invoke". */
147
155
  host?: string;
148
156
  apiVersion?: string;
157
+ /** The caller's signature for the endpoint, out of the callee's map. */
158
+ signature?: string;
149
159
  guardInputs?: Record<string, unknown>;
150
160
  idempotencyKey?: string;
151
161
  clientIp?: string;
@@ -161,6 +171,7 @@ export default class LambderInvokeCaller<TContract extends LambderApiContractSha
161
171
  private readonly functionName;
162
172
  private readonly apiPath;
163
173
  private readonly apiVersion?;
174
+ private readonly apiSignatures?;
164
175
  private readonly host;
165
176
  private readonly requestCompression;
166
177
  private readonly maxResponsePayloadBytes;
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import { classifyDeliveryFailure, describeFailure, errorFromFunctionError, LambderInvokeError, parseFunctionError, } from "./LambderInvokeOutcome.js";
24
24
  import { DEFAULT_SESSION_TOKEN_COOKIE_KEY } from "../shared/wire/LambderSessionCookieNames.js";
25
+ import { readApiSignature } from "../shared/wire/LambderApiSignature.js";
25
26
  import { resolveApiOutcome } from "../shared/wire/LambderApiOutcome.js";
26
27
  import { mergeGuardInputs, } from "../shared/wire/LambderCallOptions.js";
27
28
  import { createCallAbort, stopWaitingWhenAborted } from "../shared/util/LambderCallAbort.js";
@@ -44,6 +45,7 @@ export default class LambderInvokeCaller {
44
45
  functionName;
45
46
  apiPath;
46
47
  apiVersion;
48
+ apiSignatures;
47
49
  host;
48
50
  requestCompression;
49
51
  maxResponsePayloadBytes;
@@ -57,7 +59,7 @@ export default class LambderInvokeCaller {
57
59
  client;
58
60
  sdk;
59
61
  constructor(options) {
60
- const { functionName, client, clientConfig, apiPath, apiVersion, host, requestCompression, maxResponsePayloadBytes, timeoutMs, onLogList, onFailure, sessionTokenCookieKey, transport, guardInputsProvider, } = options;
62
+ const { functionName, client, clientConfig, apiPath, apiVersion, apiSignatures, host, requestCompression, maxResponsePayloadBytes, timeoutMs, onLogList, onFailure, sessionTokenCookieKey, transport, guardInputsProvider, } = options;
61
63
  if (!functionName?.trim())
62
64
  throw new Error("LambderInvokeCaller: functionName is required");
63
65
  this.functionName = functionName;
@@ -65,6 +67,7 @@ export default class LambderInvokeCaller {
65
67
  this.clientConfig = clientConfig;
66
68
  this.apiPath = apiPath ?? "/api";
67
69
  this.apiVersion = apiVersion;
70
+ this.apiSignatures = apiSignatures;
68
71
  this.host = host ?? functionName;
69
72
  // `?? false`: like the browser caller, off unless asked for.
70
73
  this.requestCompression = resolveCompressionOption(requestCompression ?? false, DEFAULT_INVOKE_REQUEST_COMPRESSION_SETTINGS);
@@ -93,6 +96,7 @@ export default class LambderInvokeCaller {
93
96
  body: buildEnvelopeJson({
94
97
  apiName: init.apiName,
95
98
  version: init.apiVersion,
99
+ signature: init.signature,
96
100
  csrf: init.session?.csrf,
97
101
  siteHost: host,
98
102
  payloadJson: init.payload !== undefined ? JSON.stringify(init.payload) : undefined,
@@ -290,6 +294,10 @@ export default class LambderInvokeCaller {
290
294
  let event;
291
295
  let eventJson;
292
296
  try {
297
+ // The callee's signature for this endpoint, when this caller was
298
+ // built with the callee's map. A name the map lacks fails here,
299
+ // as a provider that threw would: the map predates the endpoint.
300
+ const signature = this.apiSignatures ? await readApiSignature(this.apiSignatures, apiName) : undefined;
293
301
  // Provider values underneath, per-call values on top.
294
302
  const provided = this.guardInputsProvider
295
303
  ? await this.guardInputsProvider(apiName)
@@ -314,6 +322,7 @@ export default class LambderInvokeCaller {
314
322
  body: buildEnvelopeJson({
315
323
  apiName,
316
324
  version: this.apiVersion,
325
+ signature,
317
326
  csrf: options.session?.csrf,
318
327
  siteHost: this.host,
319
328
  payloadJson: compressed ? undefined : payloadJson,
@@ -57,6 +57,7 @@ export declare const synthesizeLambdaHttpEvent: (request: LambderSynthesizedRequ
57
57
  export declare const buildEnvelopeJson: (fields: {
58
58
  apiName: string;
59
59
  version?: string;
60
+ signature?: string;
60
61
  csrf?: string;
61
62
  siteHost: string;
62
63
  /** The payload's own JSON, when it goes plainly. */
@@ -117,6 +117,7 @@ export const buildEnvelopeJson = (fields) => {
117
117
  const withoutPayload = JSON.stringify(buildEnvelopeFields({
118
118
  apiName: fields.apiName,
119
119
  version: fields.version,
120
+ signature: fields.signature,
120
121
  token: fields.csrf ?? "",
121
122
  siteHost: fields.siteHost,
122
123
  compressed: fields.compressed,
@@ -16,7 +16,7 @@ import type { LambderMockCallContext, LambderMockCallRecord, LambderMockEntry, L
16
16
  * Lambda server runs) over memory stores, with a registry of typed mock
17
17
  * handlers where the server has app handlers, and mock guards where it has
18
18
  * app guards. Everything the protocol does (envelope, refusals, sessions
19
- * and their cookies, guards, rate limits, idempotency, the version gate)
19
+ * and their cookies, guards, rate limits, idempotency, the signature gate)
20
20
  * happens in the core; this class only resolves a name to an entry, wraps
21
21
  * the handler's return into the envelope, and adds what a mock needs on
22
22
  * top: failure injection, latency, a subscription, a call log, reset.
@@ -176,7 +176,7 @@ export declare class LambderMockApp<C extends LambderApiContractShape, S = any,
176
176
  *
177
177
  * Public because the mode of an unregistered name cannot be recovered at
178
178
  * runtime, the contract being a type. Everything that precedes dispatch
179
- * still runs (the version gate, the payload restore); the session read is
179
+ * still runs (the signature gate, the payload restore); the session read is
180
180
  * the one step this answer cannot have, which is the fidelity limit
181
181
  * restNotMocked documents.
182
182
  */
@@ -40,7 +40,7 @@ const defaultCookieHost = () => globalThis.location?.host || "localhost";
40
40
  * Lambda server runs) over memory stores, with a registry of typed mock
41
41
  * handlers where the server has app handlers, and mock guards where it has
42
42
  * app guards. Everything the protocol does (envelope, refusals, sessions
43
- * and their cookies, guards, rate limits, idempotency, the version gate)
43
+ * and their cookies, guards, rate limits, idempotency, the signature gate)
44
44
  * happens in the core; this class only resolves a name to an entry, wraps
45
45
  * the handler's return into the envelope, and adds what a mock needs on
46
46
  * top: failure injection, latency, a subscription, a call log, reset.
@@ -118,6 +118,8 @@ export class LambderMockApp {
118
118
  this.idempotencyStore = memoryIdempotency;
119
119
  this.pipeline = new LambderApiPipeline({
120
120
  apiVersion: this.apiVersion,
121
+ minApiVersion: options.minApiVersion,
122
+ apiSignatures: options.apiSignatures,
121
123
  maxRequestPayloadBytes: options.maxRequestPayloadBytes,
122
124
  sessions: sessionOptions
123
125
  ? {
@@ -351,7 +353,7 @@ export class LambderMockApp {
351
353
  *
352
354
  * Public because the mode of an unregistered name cannot be recovered at
353
355
  * runtime, the contract being a type. Everything that precedes dispatch
354
- * still runs (the version gate, the payload restore); the session read is
356
+ * still runs (the signature gate, the payload restore); the session read is
355
357
  * the one step this answer cannot have, which is the fidelity limit
356
358
  * restNotMocked documents.
357
359
  */
@@ -549,7 +551,7 @@ export class LambderMockApp {
549
551
  return {
550
552
  phase: "request", id, apiName: request.apiName, mode,
551
553
  payload: request.payload, guardInputs: request.guardInputs, idempotencyKey: request.idempotencyKey,
552
- version: request.version, headers: request.headers,
554
+ version: request.version, signature: request.signature, headers: request.headers,
553
555
  hasSessionCookie: (request.cookies[this.tokenCookieKey]?.length ?? 0) > 0,
554
556
  at,
555
557
  };
@@ -595,7 +597,7 @@ export class LambderMockApp {
595
597
  try {
596
598
  // The protocol's pre-pass, run before the name is resolved, which
597
599
  // is where the server runs it. Two things depended on it: an
598
- // unknown name reached the notFound refusal without the version
600
+ // unknown name reached the notFound refusal without the signature
599
601
  // gate or the payload restore, so a stale client or a malformed
600
602
  // compressed payload was answered differently here than on the
601
603
  // server; and the request event carried the wire fields instead of
@@ -1,3 +1,4 @@
1
+ import type { LambderApiSignatureMap } from "../shared/wire/LambderApiSignature.js";
1
2
  import type { LambderContractGuardNames } from "../shared/wire/LambderApiContract.js";
2
3
  import type { LambderApiGuard } from "../api/LambderApiGuards.js";
3
4
  import type { LambderApiRateLimitPolicyConfig } from "../api/LambderApiRateLimits.js";
@@ -99,8 +100,12 @@ type LambderMockGuardShapes<S, G> = {
99
100
  [N in keyof G]: LambderMockSurplusKeys<G[N], LambderApiGuard<any, any, any, LambderMockCallContext<S>, LambderMockSessionCallContext<S>>>;
100
101
  };
101
102
  export type LambderMockAppOptions<C, S, G, P extends LambderMockRateLimitPolicies<S> = LambderMockRateLimitPolicies<S>, I extends boolean | LambderMockIdempotencyOptions<S> = boolean | LambderMockIdempotencyOptions<S>> = LambderMockGuardsOption<C, S, G> & {
102
- /** Enables the version gate: a call naming another version answers versionExpired, exactly as the server would. */
103
+ /** Stamped on every answer's envelope as apiVersion, as the server's option is. */
103
104
  apiVersion?: string;
105
+ /** The version floor, as on the server: a call naming a lower `version` answers versionExpired whatever its signature says. */
106
+ minApiVersion?: string;
107
+ /** The generated signature map, as the server's option is: a call whose signature is not the map's entry for its endpoint answers versionExpired. Without it every signature passes. */
108
+ apiSignatures?: LambderApiSignatureMap;
104
109
  /** Artificial latency per call; off by default. */
105
110
  latency?: LambderMockLatency;
106
111
  /** Sessions over the memory store: `true` for the defaults, or the options. Off by default: session endpoints then fail at registration. */