lambder 7.0.2 → 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
@@ -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,9 @@ 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";
18
21
  export type { LambderApiAnswerOutcome, LambderApiSuccessOutcome, LambderApiCallFailure, LambderApiValidationFailure, LambderApiEnvelopeFailure, LambderApiHttpAnswer, } from "./shared/wire/LambderApiOutcome.js";
19
22
  export type { LambderApiOutcome, LambderApiFailureReason, LambderValidationError, LambderCallOptions, LambderCallerOptions, LambderGuardInputsProvider, LambderProvidedGuardInputs, LambderIdempotencyKeyScope, LambderLogListHandler, } from "./client/LambderCaller.js";
20
23
  export { LambderApiRefusal, isLambderApiRefusal, refuse, LAMBDER_REFUSAL_CODES } from "./shared/wire/LambderApiRefusal.js";
package/dist/client.js CHANGED
@@ -14,6 +14,9 @@ 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";
17
20
  // Typed API refusals (isomorphic: shared code may throw them from anywhere;
18
21
  // in the browser they are plain Errors).
19
22
  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: what resolves a request's name to its definition ahead of the pipeline, and what apiSignatures() digests. */
73
+ private readonly apiDefinitions;
74
+ /** The signature of each endpoint as this server serves it, digested once per endpoint on first use. */
75
+ private readonly signatureDigests;
71
76
  private hookList;
72
77
  private createdHooks;
73
78
  private initPromise;
@@ -165,6 +170,15 @@ 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 a client build ships with. A generator imports
176
+ * the finished instance, awaits this, and writes the result to a file the
177
+ * frontend passes to LambderCaller as apiSignatures; at request time the
178
+ * server compares each call's signature against these same digests. Keys
179
+ * are sorted, so the generated file diffs by endpoint.
180
+ */
181
+ apiSignatures(): Promise<LambderApiSignatureMap>;
168
182
  getResponseBuilder(ctx?: LambderRenderContext): LambderResponseBuilder<any>;
169
183
  private getResolver;
170
184
  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 { LambderApiSignatureDigests } 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: what resolves a request's name to its definition ahead of the pipeline, and what apiSignatures() digests. */
69
+ apiDefinitions = new Map();
70
+ /** The signature of each endpoint as this server serves it, digested once per endpoint on first use. */
71
+ signatureDigests;
66
72
  hookList = { "beforeRender": [], "afterRender": [], "fallback": [] };
67
73
  createdHooks = [];
68
74
  initPromise = null;
@@ -95,8 +101,10 @@ export default class Lambder {
95
101
  this.corsConfig = options.cors === true ? {} : options.cors;
96
102
  }
97
103
  const session = options.session;
104
+ this.signatureDigests = new LambderApiSignatureDigests(options.guards);
98
105
  this.pipeline = new LambderApiPipeline({
99
106
  apiVersion: this.apiVersion,
107
+ signatures: this.signatureDigests,
100
108
  maxRequestPayloadBytes: options.maxRequestPayloadBytes,
101
109
  // The app's own validation handler is read at call time, since
102
110
  // setApiInputValidationErrorHandler runs after creation.
@@ -200,7 +208,8 @@ export default class Lambder {
200
208
  // Typed API with Zod
201
209
  addApi(name, schema, handler) {
202
210
  this.assertApiRegistration(name, "public", schema);
203
- const definition = { name, mode: "public", guards: schema.guards, rateLimit: schema.rateLimit, idempotency: schema.idempotency, input: schema.input };
211
+ const definition = { name, mode: "public", guards: schema.guards, rateLimit: schema.rateLimit, idempotency: schema.idempotency, input: schema.input, output: schema.output };
212
+ this.apiDefinitions.set(name, definition);
204
213
  this.actionList.push({
205
214
  match: (ctx) => ctx.apiName === name ? {} : false,
206
215
  actionFn: (ctx, resolver) => this.runApi(ctx, resolver, definition, handler),
@@ -210,7 +219,8 @@ export default class Lambder {
210
219
  // Typed Session API with Zod
211
220
  addSessionApi(name, schema, handler) {
212
221
  this.assertApiRegistration(name, "session", schema);
213
- const definition = { name, mode: "session", guards: schema.guards, rateLimit: schema.rateLimit, idempotency: schema.idempotency, input: schema.input };
222
+ const definition = { name, mode: "session", guards: schema.guards, rateLimit: schema.rateLimit, idempotency: schema.idempotency, input: schema.input, output: schema.output };
223
+ this.apiDefinitions.set(name, definition);
214
224
  this.actionList.push({
215
225
  match: (ctx) => ctx.apiName === name ? {} : false,
216
226
  actionFn: (ctx, resolver) => this.runApi(ctx, resolver, definition, handler),
@@ -276,6 +286,19 @@ export default class Lambder {
276
286
  getSessionManager() {
277
287
  return this.pipeline.sessionManager;
278
288
  }
289
+ /**
290
+ * Every registered endpoint's signature, keyed by its hashed name: the
291
+ * LambderApiSignatureMap a client build ships with. A generator imports
292
+ * the finished instance, awaits this, and writes the result to a file the
293
+ * frontend passes to LambderCaller as apiSignatures; at request time the
294
+ * server compares each call's signature against these same digests. Keys
295
+ * are sorted, so the generated file diffs by endpoint.
296
+ */
297
+ async apiSignatures() {
298
+ const entries = await Promise.all([...this.apiDefinitions.values()].map(async (definition) => [await apiNameKeyOf(definition.name), await this.signatureDigests.signatureOf(definition)]));
299
+ entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
300
+ return Object.fromEntries(entries);
301
+ }
279
302
  getResponseBuilder(ctx) {
280
303
  return new LambderResponseBuilder({
281
304
  files: this.files,
@@ -401,8 +424,9 @@ export default class Lambder {
401
424
  // The protocol's own pre-pass, run here rather than left to the
402
425
  // pipeline so that hooks and route matching see a plain payload,
403
426
  // and so a stale client is answered before any of them, whether or
404
- // not the name it asked for exists.
405
- const prepared = await this.pipeline.prepare(ctx.api);
427
+ // not the name it asked for exists: the gate is handed the
428
+ // definition the name resolves to, or null.
429
+ const prepared = await this.pipeline.prepare(ctx.api, this.apiDefinitions.get(ctx.api.apiName) ?? null);
406
430
  if (prepared)
407
431
  return responseFromAnswer(prepared);
408
432
  // ctx.post is the raw body view; it shows the restored payload and
@@ -583,7 +607,7 @@ export default class Lambder {
583
607
  // =====================================================================
584
608
  /** Registration-time checks shared by addApi/addSessionApi. */
585
609
  assertApiRegistration(name, mode, options) {
586
- if (this.registeredApiNames.has(name)) {
610
+ if (this.apiDefinitions.has(name)) {
587
611
  throw new Error(`Lambder: duplicate API name "${name}". Dispatch is first-match, so the second registration would be silently dead code.`);
588
612
  }
589
613
  // Everything that can refuse this registration runs before the name is
@@ -603,7 +627,6 @@ export default class Lambder {
603
627
  `Declare the guard that authorizes it, or ${optOut}.`);
604
628
  }
605
629
  this.pipeline.assertRegistration({ name, mode, guards: options.guards, rateLimit: options.rateLimit, idempotency: options.idempotency });
606
- this.registeredApiNames.add(name);
607
630
  }
608
631
  /**
609
632
  * The answer for a rejected input: the app's
@@ -99,6 +99,12 @@ export type LambderCreateOptions<TSessionData = any> = {
99
99
  */
100
100
  files?: LambderFilesOption;
101
101
  apiPath?: string;
102
+ /**
103
+ * Stamped on every API answer's envelope as `apiVersion`, so a client can
104
+ * tell which build answered. Informational: whether a client is stale is
105
+ * decided per endpoint by the signature it sends (see
106
+ * Lambder.apiSignatures()), not by this string.
107
+ */
102
108
  apiVersion?: string;
103
109
  /**
104
110
  * Automatic compression for compressible responses. `true` (the default)
@@ -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, LambderApiSignatureDigests } from "./api/LambderApiSignature.js";
29
+ export type { LambderApiSignatureSource } from "./api/LambderApiSignature.js";
30
+ export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH } from "./shared/wire/LambderApiSignature.js";
31
+ export type { LambderApiSignatureMap } from "./shared/wire/LambderApiSignature.js";
32
+ export { RELOAD_LOOP_WINDOW_MS } from "./client/LambderReloadLoopBreaker.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,10 @@ 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, LambderApiSignatureDigests } 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";
19
23
  export { buildApiEnvelope, envelopeAnswer, refusalAnswer, validationAnswer, apiNotFoundAnswer, sessionExpiredAnswer, versionExpiredAnswer, invalidPayloadAnswer, crashAnswer, API_ANSWER_CONTENT_TYPE, } from "./api/LambderApiEnvelope.js";
20
24
  export { LambderApiValidationRefusal, isLambderApiValidationRefusal } from "./api/LambderApiValidationRefusal.js";
21
25
  // 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
  */
@@ -1,3 +1,4 @@
1
+ import { lookupApiSignature } from "../shared/wire/LambderApiSignature.js";
1
2
  import { LambderApiPipeline } from "../api/LambderApiPipeline.js";
2
3
  import { readApiEnvelope, cookieValuesByName, lowercaseHeaderNames } from "../api/LambderApiRequest.js";
3
4
  import { createApiCallContext } from "../api/LambderApiCallContext.js";
@@ -40,7 +41,7 @@ const defaultCookieHost = () => globalThis.location?.host || "localhost";
40
41
  * Lambda server runs) over memory stores, with a registry of typed mock
41
42
  * handlers where the server has app handlers, and mock guards where it has
42
43
  * app guards. Everything the protocol does (envelope, refusals, sessions
43
- * and their cookies, guards, rate limits, idempotency, the version gate)
44
+ * and their cookies, guards, rate limits, idempotency, the signature gate)
44
45
  * happens in the core; this class only resolves a name to an entry, wraps
45
46
  * the handler's return into the envelope, and adds what a mock needs on
46
47
  * top: failure injection, latency, a subscription, a call log, reset.
@@ -116,8 +117,13 @@ export class LambderMockApp {
116
117
  const idempotencyOptions = options.idempotency === true ? {} : options.idempotency || null;
117
118
  const memoryIdempotency = idempotencyOptions && !idempotencyOptions.store ? new LambderMemoryIdempotencyStore() : null;
118
119
  this.idempotencyStore = memoryIdempotency;
120
+ // The generated map stands in for the server's schemas: the runtime
121
+ // cannot digest what it does not hold, so it answers with the map's
122
+ // entry for the name and the pipeline compares, as on the server.
123
+ const apiSignatures = options.apiSignatures;
119
124
  this.pipeline = new LambderApiPipeline({
120
125
  apiVersion: this.apiVersion,
126
+ signatures: apiSignatures ? { expectedSignatureOf: (apiName) => lookupApiSignature(apiSignatures, apiName) } : undefined,
121
127
  maxRequestPayloadBytes: options.maxRequestPayloadBytes,
122
128
  sessions: sessionOptions
123
129
  ? {
@@ -351,7 +357,7 @@ export class LambderMockApp {
351
357
  *
352
358
  * Public because the mode of an unregistered name cannot be recovered at
353
359
  * runtime, the contract being a type. Everything that precedes dispatch
354
- * still runs (the version gate, the payload restore); the session read is
360
+ * still runs (the signature gate, the payload restore); the session read is
355
361
  * the one step this answer cannot have, which is the fidelity limit
356
362
  * restNotMocked documents.
357
363
  */
@@ -549,7 +555,7 @@ export class LambderMockApp {
549
555
  return {
550
556
  phase: "request", id, apiName: request.apiName, mode,
551
557
  payload: request.payload, guardInputs: request.guardInputs, idempotencyKey: request.idempotencyKey,
552
- version: request.version, headers: request.headers,
558
+ version: request.version, signature: request.signature, headers: request.headers,
553
559
  hasSessionCookie: (request.cookies[this.tokenCookieKey]?.length ?? 0) > 0,
554
560
  at,
555
561
  };
@@ -593,16 +599,18 @@ export class LambderMockApp {
593
599
  let outcome;
594
600
  let error;
595
601
  try {
596
- // The protocol's pre-pass, run before the name is resolved, which
597
- // is where the server runs it. Two things depended on it: an
598
- // unknown name reached the notFound refusal without the version
599
- // gate or the payload restore, so a stale client or a malformed
600
- // compressed payload was answered differently here than on the
601
- // server; and the request event carried the wire fields instead of
602
- // the payload, so a dev panel watching calls in flight showed
603
- // nothing for exactly the compressed calls someone opens a panel
604
- // for. run() calls prepare again, which is safe by construction.
605
- const prepared = await this.pipeline.prepare(request);
602
+ // The protocol's pre-pass, run ahead of dispatch with the
603
+ // definition the name resolved to (null for a name nothing
604
+ // registered), which is where the server runs it. Two things
605
+ // depended on it: an unknown name reached the notFound refusal
606
+ // without the signature gate or the payload restore, so a stale
607
+ // client or a malformed compressed payload was answered
608
+ // differently here than on the server; and the request event
609
+ // carried the wire fields instead of the payload, so a dev panel
610
+ // watching calls in flight showed nothing for exactly the
611
+ // compressed calls someone opens a panel for. run() calls prepare
612
+ // again, which is safe by construction.
613
+ const prepared = await this.pipeline.prepare(request, registered?.definition ?? null);
606
614
  this.emit(this.requestEvent(id, request, mode, startedAt));
607
615
  await this.failures.wait(this.failures.latencyFor(request.apiName), request.signal);
608
616
  if (this.failures.offline)
@@ -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,16 @@ 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
+ /**
106
+ * The generated signature map the caller carries, so the runtime refuses a
107
+ * stale signature exactly as the server would: a call whose signature is
108
+ * not the map's entry for its endpoint answers versionExpired. Without it
109
+ * every signature passes, since the runtime holds no server schema to
110
+ * digest.
111
+ */
112
+ apiSignatures?: LambderApiSignatureMap;
104
113
  /** Artificial latency per call; off by default. */
105
114
  latency?: LambderMockLatency;
106
115
  /** Sessions over the memory store: `true` for the defaults, or the options. Off by default: session endpoints then fail at registration. */
@@ -1,9 +1 @@
1
- /*
2
- * What a mock runtime is configured with, and the shapes of what a mock
3
- * transport takes.
4
- *
5
- * Everything create() takes lives here, beside the rules that decide which
6
- * keys it accepts (the guards option and the surplus-key checks under it),
7
- * exactly as core/LambderCreateOptions.ts holds the server's.
8
- */
9
1
  export {};
@@ -374,6 +374,8 @@ export type LambderMockRequestEvent = {
374
374
  /** Exactly as posted, so `unknown`: the key is client data and only the idempotency engine judges it. */
375
375
  idempotencyKey: unknown;
376
376
  version: string | null;
377
+ /** The signature the caller sent for the endpoint; null when it carries no map. */
378
+ signature: string | null;
377
379
  headers: Record<string, string>;
378
380
  /** True when the request carried a session cookie. */
379
381
  hasSessionCookie: boolean;
@@ -10,6 +10,8 @@ export type LambderApiTransportRequest = {
10
10
  apiPath: string;
11
11
  apiName: string;
12
12
  version?: string;
13
+ /** The caller's signature for this endpoint, out of its LambderApiSignatureMap; absent when it carries no map. */
14
+ signature?: string;
13
15
  /** The CSRF token the caller read from its cookie; "" when it holds none. */
14
16
  token: string;
15
17
  /**
@@ -101,6 +103,7 @@ export type LambderApiTransport = (request: LambderApiTransportRequest) => Promi
101
103
  export declare const buildEnvelopeFields: (fields: {
102
104
  apiName: string;
103
105
  version?: string;
106
+ signature?: string;
104
107
  /** The CSRF token, as the envelope names it. */
105
108
  token: string;
106
109
  siteHost: string;
@@ -29,6 +29,7 @@ export const isLambderTransportFailure = (err) => err instanceof Error && err.is
29
29
  export const buildEnvelopeFields = (fields) => ({
30
30
  apiName: fields.apiName,
31
31
  version: fields.version,
32
+ ...(fields.signature !== undefined ? { signature: fields.signature } : {}),
32
33
  token: fields.token,
33
34
  siteHost: fields.siteHost,
34
35
  ...(fields.compressed ?? fields.payloadSlot ?? {}),
@@ -43,6 +44,7 @@ export const buildEnvelopeFields = (fields) => ({
43
44
  export const buildTransportEnvelope = (request) => buildEnvelopeFields({
44
45
  apiName: request.apiName,
45
46
  version: request.version,
47
+ signature: request.signature,
46
48
  token: request.token,
47
49
  siteHost: request.siteHost,
48
50
  payloadSlot: { payload: request.payload },
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The per-endpoint signatures a client carries, generated from the server's
3
+ * own registrations (Lambder.apiSignatures()) and shipped with the client
4
+ * build. The key is the endpoint's name hashed (apiNameKeyOf); the value is
5
+ * the digest of its client-facing shape (apiSignatureOf, computed on the
6
+ * server side). A caller given the map sends the value with every call, and
7
+ * the server answers versionExpired when it differs from the digest of what
8
+ * it serves now. So a client built against an endpoint that has since
9
+ * changed reloads, while one whose endpoint is unchanged keeps working
10
+ * across deploys.
11
+ *
12
+ * Keys are hashed so the map lists no endpoint names: the names a client
13
+ * calls are in its own code already, and the rest of the surface stays out
14
+ * of the bundle.
15
+ */
16
+ export type LambderApiSignatureMap = Record<string, string>;
17
+ /**
18
+ * How many hex characters a key and a signature keep. This is change
19
+ * detection, not authentication: 64 bits cannot collide by accident across
20
+ * the shapes one endpoint takes over its life, and the map stays small.
21
+ */
22
+ export declare const API_SIGNATURE_HEX_LENGTH = 16;
23
+ /** The key an endpoint's signature is stored under: SHA-256 over the prefixed name, cut to API_SIGNATURE_HEX_LENGTH hex characters. */
24
+ export declare const apiNameKeyOf: (apiName: string) => Promise<string>;
25
+ /** The map's signature for one endpoint, or null when the map holds none for it. */
26
+ export declare const lookupApiSignature: (signatures: LambderApiSignatureMap, apiName: string) => Promise<string | null>;
27
+ /**
28
+ * The signature a caller sends for one endpoint. A name the map does not
29
+ * hold throws: the map was generated from a server that did not have this
30
+ * endpoint, so the file is stale, and a call sent without a signature would
31
+ * run instead of saying so.
32
+ */
33
+ export declare const readApiSignature: (signatures: LambderApiSignatureMap, apiName: string) => Promise<string>;