lambder 4.3.1 → 4.4.1

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.
package/Readme.md CHANGED
@@ -2,10 +2,18 @@
2
2
 
3
3
  Lambder is a highly opinionated dynamic serverless framework designed to facilitate the management and implementation of routes and APIs within AWS Lambda functions, specifically tailored for TypeScript projects. It provides a streamlined approach to handling HTTP requests, managing sessions, and defining API routes, making serverless application development more intuitive and structured.
4
4
 
5
+ **New in 4.4:**
6
+
7
+ - **`guardInputsProvider`** on `LambderCaller`: supply guardInput-mode guard values for every call from one place (the organization the UI is on, a device token) instead of at each call site; per-call `guardInputs` merge on top. Name the covered guards in the caller's second type parameter, `new LambderCaller<Contract, "orgPermission">({ guardInputsProvider, ... })`: calls to APIs whose guardInput guards are all covered no longer require the options argument, uncovered ones (a Turnstile token) still do, and naming guards makes the provider itself mandatory.
8
+ - **Public file sources**: `servePublicFiles({ source })` serves from any `LambderPublicFileSource`: `LambderLocalFileSource` (a folder; the default, over `publicPath`), `LambderS3FileSource` (S3, or Cloudflare R2 and other S3-compatible stores via `clientConfig.endpoint`; `@aws-sdk/client-s3` is an optional peer dependency loaded on first read), or your own `{ read(relativePath) }`. The handler's traversal check, memory cache, mime fallback from the extension, Cache-Control, ETag and compression apply to every source. The `cacheControl` callback receives the relative file path.
9
+ - **`expireSessionDataAllByKey(sessionKey)`** on the session manager and controller: marks the data of every session of a subject stale, so each renews via `dataRefresh` on its next read. The way to apply a role or permission change to a user immediately, without logging them out (`deleteSessionAllByKey`) and without waiting for the data TTL.
10
+
5
11
  **New in 4.3:**
6
12
 
7
13
  - **Compressed sessions**: `session.data` is stored Brotli-compressed by default, as `dataBr` + `dataBytes` on the record, the same scheme LambderDdbCache and LambderDdbIdempotency use (one shared implementation). A session that caches roles, permissions or product lists shrinks 2-3x and stays within one DynamoDB read unit for longer. `session.compression` is `true` by default (the same as `{ minBytes: 0 }`: every record compressed); `false` turns it off and `{ minBytes }` compresses only from that JSON size. Records written under either setting read back, so it can be switched on or off on a live table.
8
14
 
15
+ - **One compression option everywhere**: `LambderDdbCache`, `LambderDdbIdempotency` and sessions take the same `compression` option (`true` for that store's defaults, `false` for off, `{ minBytes, quality }` to override), resolved by one shared function, and each store records a value's encoding so the option can be switched on or off on a live table. Defaults keep the previous behavior: the cache compresses everything, the idempotency store from 1KB. `compressionQuality` on the cache and idempotency store is replaced by `compression: { quality }`, and HTTP `compression` accepts `true` as `{ minBytes: 860 }`.
16
+
9
17
  **New in 4.2:**
10
18
 
11
19
  - **Rate-limit budgets**: a policy's `budget` is `"perApi"` (default: each referencing API gets its own counter, so the numbers are a per-API ceiling and three APIs on a 60/min policy allow one IP 180/min in total) or `"perPolicy"` (one counter shared by every API referencing the policy). The policy is the group, and two separate shared budgets are two policies.
@@ -158,8 +166,9 @@ lambder
158
166
  .addRoute({ path: "/stripe-webhook", method: "POST" }, (ctx, res) => {
159
167
  return res.json({ received: true });
160
168
  })
161
- // Serve real files from publicPath. This is a terminal fallback slot, NOT
162
- // a catch-all route, so it can never shadow routes registered after it.
169
+ // Serve real files (from publicPath by default; see "Public file sources"
170
+ // below for S3/R2). This is a terminal fallback slot, NOT a catch-all
171
+ // route, so it can never shadow routes registered after it.
163
172
  .servePublicFiles()
164
173
  // Serve the app shell for GET/HEAD page requests nothing else handled
165
174
  // (see "Hosting a frontend build" below).
@@ -357,6 +366,7 @@ Semantics:
357
366
  - The renewal write and the sliding-expiration write share a single DynamoDB put when both are due.
358
367
  - Records created before `dataRefresh` was enabled renew on their first read.
359
368
  - `updateSessionData()` marks data fresh (it was just written deliberately); `regenerateSession()` carries the old freshness stamp over.
369
+ - `expireSessionDataAllByKey(sessionKey)` stamps every session of a subject stale at once: call it after changing that subject's roles or permissions, and the change applies on their next request instead of within `ttlSeconds`, with no logout. It updates only `dataExpiresAt`, conditionally on the record still existing, so it neither resurrects a deleted session nor clobbers a concurrent write.
360
370
 
361
371
  #### Session data at rest (`compression`)
362
372
 
@@ -371,7 +381,7 @@ session: {
371
381
  }
372
382
  ```
373
383
 
374
- `quality` (Brotli 0-11, default 5) is also accepted. Reads accept both record shapes, so the setting can be switched on or off on a live table: records written under the other setting keep reading, and each is rewritten in the current shape on its next write (a sliding-expiration or `dataRefresh` write included). A compressed record that fails to decode is treated like any malformed record: no session.
384
+ `quality` (Brotli 0-11, default 5) is also accepted; the option (`LambderCompressionOption`) is the same one `LambderDdbCache` and `LambderDdbIdempotency` take. Reads accept both record shapes, so the setting can be switched on or off on a live table: records written under the other setting keep reading, and each is rewritten in the current shape on its next write (a sliding-expiration or `dataRefresh` write included). A compressed record that fails to decode is treated like any malformed record: no session.
375
385
 
376
386
  #### Session Controller
377
387
 
@@ -387,6 +397,7 @@ Access the session controller with `lambder.getSessionController(ctx)`:
387
397
  | `endSession()` | End session, delete from DDB |
388
398
  | `endSessionAll()` | End all sessions for this sessionKey (all devices) |
389
399
  | `deleteSessionAllByKey(sessionKey)` | Delete all sessions of any sessionKey (e.g. "log user X out everywhere") |
400
+ | `expireSessionDataAllByKey(sessionKey)` | Mark the data of all sessions of a sessionKey stale, so each renews via `dataRefresh` on its next read (no logout) |
390
401
  | `regenerateSession()` | Regenerate token (use after password change) |
391
402
 
392
403
  ### Type-Safe Templating (html / xml)
@@ -430,6 +441,33 @@ const output = template.render({
430
441
 
431
442
  Lambder has no SPA-specific machinery; hosting a frontend build is a recipe built from three generic primitives: `servePublicFiles()` (terminal slot serving real files: memory-cached, immutable Cache-Control for hashed assets, ETag/gzip, falls through when missing), `serveIndexHtml()` (next fallback slot, GET/HEAD + non-file-path gated) and `res.templateFile()` (render an HTML file through the templating engine, compiled once and cached). **Full guide with the multi-tenant recipe: [docs/TEMPLATING.md](./docs/TEMPLATING.md).**
432
443
 
444
+ #### Public file sources
445
+
446
+ `servePublicFiles` reads through a `LambderPublicFileSource`, an object with one method, `read(relativePath)`, returning `{ body, mimeType? }` or `null` (the request then falls through). The handler does everything else for every source: traversal check, memory cache for warm invocations, mime fallback from the extension, Cache-Control (immutable for content-hashed names), ETag and compression. Built in:
447
+
448
+ ```typescript
449
+ // Default: the publicPath folder bundled with the deployment.
450
+ lambder.servePublicFiles();
451
+
452
+ // S3. @aws-sdk/client-s3 is an optional peer dependency, loaded on first read.
453
+ lambder.servePublicFiles({
454
+ source: new LambderS3FileSource({ bucket: "myapp-web", prefix: "v42/", clientConfig: { region: "eu-central-1" } }),
455
+ });
456
+
457
+ // Cloudflare R2, or any S3-compatible store: point the client at its endpoint.
458
+ lambder.servePublicFiles({
459
+ source: new LambderS3FileSource({
460
+ bucket: "myapp-web",
461
+ clientConfig: { region: "auto", endpoint: "https://<account>.r2.cloudflarestorage.com", credentials: { accessKeyId, secretAccessKey } },
462
+ }),
463
+ });
464
+
465
+ // Anything else: implement read().
466
+ lambder.servePublicFiles({ source: { read: async (relativePath) => myStore.get(relativePath) } });
467
+ ```
468
+
469
+ A missing S3 object reads as null; grant `s3:ListBucket` besides `s3:GetObject`, otherwise S3 answers a missing key with AccessDenied, which propagates as an error instead of falling through. The object's Content-Type is used unless it is a generic octet-stream, in which case the extension decides. Lambda's ~6MB response cap still applies to anything proxied this way: redirect large downloads to the bucket or CDN URL instead of serving them.
470
+
433
471
  ```typescript
434
472
  // Zero-config single-tenant hosting:
435
473
  lambder.servePublicFiles().serveIndexHtml();
@@ -664,13 +702,13 @@ Request flow per API: session (session APIs) → idempotency replay lookup → r
664
702
 
665
703
  Preflight input slices (guard `apiInput`/`guardInput` values, rate-limit `apiInput` keys) answer a rejection through the same path as the API's own schema: `setApiInputValidationErrorHandler` when set, otherwise the standard 422 body. One failure, one shape, whichever schema rejected it.
666
704
 
667
- **Idempotency semantics**: the client sends an `idempotencyKey` per call (see LambderCaller below); generate it once per logical operation with `LambderCaller.createIdempotencyKey()` and reuse it on retries. Keys must be 16-200 characters and UNGUESSABLE random (shorter keys refuse with 400): on session APIs the scope is session + API name + key, and on public APIs it is the key itself + API name, deliberately NOT the client IP, because the retry idempotency exists for (a timeout followed by a network switch) frequently arrives from a new IP. Concurrent duplicates of an in-flight request refuse with 409, repeats of a completed one replay the stored response verbatim until the TTL (response headers included, so headers set via `res.setHeader`/`res.addHeader` replay too), and a crashed original releases its claim so a retry actually retries. The replay rule for failures: RESPONSES are stored and replayed, refusals returned as envelopes (`res.api(null, { errorMessage })`) and thrown responses (`res.die.*`) included; EXCEPTIONS are not, so a thrown `LambderApiError`/`refuse()` releases the claim and a retry re-executes and decides afresh. Stored bodies of 1KB or more are Brotli-compressed (the same scheme as LambderDdbCache; `compressionQuality` on the store, default 5): JSON envelopes typically shrink 5-10x, which cuts DynamoDB write cost, and the ~350KB item budget applies to the COMPRESSED bytes, so even large responses usually stay replayable. Responses with status ≥ 500, bodies over the budget even compressed, and responses that set cookies are never stored (replaying one request's Set-Cookie, e.g. session tokens, into another would be wrong; such APIs still get in-flight 409 dedupe, just not replays). Claims are owner-checked, so an original that stalls past the pending window can no longer overwrite or delete the claim a retry has since taken. Requests without a key execute normally.
705
+ **Idempotency semantics**: the client sends an `idempotencyKey` per call (see LambderCaller below); generate it once per logical operation with `LambderCaller.createIdempotencyKey()` and reuse it on retries. Keys must be 16-200 characters and UNGUESSABLE random (shorter keys refuse with 400): on session APIs the scope is session + API name + key, and on public APIs it is the key itself + API name, deliberately NOT the client IP, because the retry idempotency exists for (a timeout followed by a network switch) frequently arrives from a new IP. Concurrent duplicates of an in-flight request refuse with 409, repeats of a completed one replay the stored response verbatim until the TTL (response headers included, so headers set via `res.setHeader`/`res.addHeader` replay too), and a crashed original releases its claim so a retry actually retries. The replay rule for failures: RESPONSES are stored and replayed, refusals returned as envelopes (`res.api(null, { errorMessage })`) and thrown responses (`res.die.*`) included; EXCEPTIONS are not, so a thrown `LambderApiError`/`refuse()` releases the claim and a retry re-executes and decides afresh. Stored bodies of 1KB or more are Brotli-compressed by default (the same scheme and `compression` option as LambderDdbCache: `true`, `false`, or `{ minBytes, quality }`, default `{ minBytes: 1024, quality: 5 }`; records of either shape read back, so it can be switched on a live table): JSON envelopes typically shrink 5-10x, which cuts DynamoDB write cost, and the ~350KB item budget applies to the COMPRESSED bytes, so even large responses usually stay replayable. Responses with status ≥ 500, bodies over the budget even compressed, and responses that set cookies are never stored (replaying one request's Set-Cookie, e.g. session tokens, into another would be wrong; such APIs still get in-flight 409 dedupe, just not replays). Claims are owner-checked, so an original that stalls past the pending window can no longer overwrite or delete the claim a retry has since taken. Requests without a key execute normally.
668
706
 
669
707
  Also enforced at registration: **duplicate API names throw** (dispatch is first-match, so a second registration of the same name would be silently dead code).
670
708
 
671
709
  ### DynamoDB Cache (LambderDdbCache)
672
710
 
673
- Standalone, persistent JSON cache backed by a DynamoDB table (`pk`/`sk` keys + `expiresAt` TTL attribute, same shape as the session table). Items are prefixed `CACHE#<namespace>#`, and the rate limiter (`RL#`) and idempotency store (`IDEM#`) prefix theirs too, so all three non-session systems can share one table without collisions; keep sessions in their own table for IAM scoping. Brotli-compressed values, in-memory LRU layer, single-flight deduplication, a DynamoDB lease so only one Lambda fills a missing key, and fail-open semantics. Server-only. **Full guide with table setup: [docs/DDB_CACHE.md](./docs/DDB_CACHE.md).**
711
+ Standalone, persistent JSON cache backed by a DynamoDB table (`pk`/`sk` keys + `expiresAt` TTL attribute, same shape as the session table). Items are prefixed `CACHE#<namespace>#`, and the rate limiter (`RL#`) and idempotency store (`IDEM#`) prefix theirs too, so all three non-session systems can share one table without collisions; keep sessions in their own table for IAM scoping. Brotli-compressed values (the shared `compression` option), in-memory LRU layer, single-flight deduplication, a DynamoDB lease so only one Lambda fills a missing key, and fail-open semantics. Server-only. **Full guide with table setup: [docs/DDB_CACHE.md](./docs/DDB_CACHE.md).**
674
712
 
675
713
  ```typescript
676
714
  import { LambderDdbCache } from "lambder";
@@ -770,7 +808,7 @@ Also available:
770
808
 
771
809
  - **Timeouts**: pass `timeoutMs` in the constructor for a default (API Gateway caps around 29s, so ~30000 is sensible) and/or per call; timed-out calls abort the fetch and report `reason: 'timeout'`. A per-call `signal` combines with the timeout.
772
810
  - **Per-call handler overrides**: every constructor handler (`errorHandler`, `sessionExpiredHandler`, `errorMessageHandler`, ...) can be overridden in the options of a single `api`/`apiOutcome` call.
773
- - **Guard inputs**: for APIs whose guards run in guardInput mode, pass their values per call as `guardInputs: { <guardName>: value }`; the typed contract makes the options argument (and the correct value shape) mandatory for those APIs.
811
+ - **Guard inputs**: for APIs whose guards run in guardInput mode, pass their values per call as `guardInputs: { <guardName>: value }`; the typed contract makes the options argument (and the correct value shape) mandatory for those APIs. A `guardInputsProvider` on the caller supplies values for every call from one place, keyed by guard name, with per-call `guardInputs` merged on top; name the guards it covers in the caller's second type parameter, `new LambderCaller<Contract, "orgPermission">({ guardInputsProvider: () => ({ orgPermission: { orgSlug } }), ... })`, and calls to APIs whose guardInput guards are all covered take an optional options argument again.
774
812
  - **Idempotency keys**: pass `idempotencyKey` per call for APIs declared idempotent on the server (see Declarative API Policies). Generate it once per logical operation with `LambderCaller.createIdempotencyKey()` (safe in insecure contexts where `crypto.randomUUID` is missing) and send the same key on retries; rotate after a confirmed success. `LambderCaller.createIdempotencyKeyScope()` packages that pattern for a component performing one operation repeatedly: read `scope.current` on every attempt, call `scope.rotate()` after a confirmed success. Keys must be unguessable random and 16-200 characters (they scope the replay record for logged-out clients); the server refuses shorter keys with a 400.
775
813
 
776
814
  ### Benefits
@@ -5,14 +5,52 @@ type IsAny<T> = 0 extends (1 & T) ? true : false;
5
5
  type GuardInputsOf<TEntry> = TEntry extends {
6
6
  guardInputs: infer G;
7
7
  } ? G : never;
8
+ /** Input type of guard G on one contract entry; never when that API does not declare it. */
9
+ type GuardInputOf<TEntry, G extends string> = GuardInputsOf<TEntry> extends infer I ? (G extends keyof I ? I[G] : never) : never;
10
+ /**
11
+ * What guardInputsProvider returns: for every provided guard name, the value
12
+ * the contract's APIs expect for it (a union across APIs when they differ).
13
+ * Naming a guard no API declares in guardInput mode resolves to never, so a
14
+ * typo fails the provider's return type instead of going missing at runtime.
15
+ */
16
+ export type LambderProvidedGuardInputs<TContract, TProvided extends string> = IsAny<TContract> extends true ? Record<TProvided, unknown> : {
17
+ [G in TProvided]: {
18
+ [K in keyof TContract]: GuardInputOf<TContract[K], G>;
19
+ }[keyof TContract];
20
+ };
21
+ /**
22
+ * Supplies guardInputs for every call from one place (the organization the
23
+ * UI is on, a device token), keyed by guard name; per-call guardInputs
24
+ * merge on top. Name the guards it covers in the caller's second type
25
+ * parameter, `new LambderCaller<Contract, "orgPermission">`, and calls to
26
+ * APIs whose guardInput guards are all covered no longer require the
27
+ * options argument. May be async; a throw fails the call as an unknown
28
+ * error before anything is sent.
29
+ */
30
+ export type LambderGuardInputsProvider<TContract, TProvided extends string> = (apiName: keyof TContract & string) => LambderProvidedGuardInputs<TContract, TProvided> | Promise<LambderProvidedGuardInputs<TContract, TProvided>>;
31
+ /** Optional until the caller names provided guards: naming them without a provider would send nothing. */
32
+ type GuardInputsProviderOption<TContract, TProvided extends string> = [
33
+ TProvided
34
+ ] extends [never] ? {
35
+ guardInputsProvider?: LambderGuardInputsProvider<TContract, TProvided>;
36
+ } : {
37
+ guardInputsProvider: LambderGuardInputsProvider<TContract, TProvided>;
38
+ };
39
+ /** An API's guardInput guards the provider does not cover: those the call must still pass. */
40
+ type RemainingGuardInputs<TEntry, TProvided extends string> = Omit<GuardInputsOf<TEntry>, TProvided>;
8
41
  /**
9
42
  * The options argument: optional normally, REQUIRED (with guardInputs) when
10
- * the API's contract declares guardInput-mode guards, so forgetting to send
11
- * a guard's value is a compile error at the call site.
43
+ * the API's contract declares guardInput-mode guards the provider does not
44
+ * cover, so forgetting to send a guard's value is a compile error at the
45
+ * call site. Provided guards may still be overridden per call.
12
46
  */
13
- type CallOptionsArg<TContract, TApiName> = IsAny<TContract> extends true ? [options?: LambderCallOptions] : TApiName extends keyof TContract ? [GuardInputsOf<TContract[TApiName]>] extends [never] ? [options?: LambderCallOptions] : [options: LambderCallOptions & {
14
- guardInputs: GuardInputsOf<TContract[TApiName]>;
15
- }] : [options?: LambderCallOptions];
47
+ type CallOptionsArg<TContract, TApiName, TProvided extends string> = IsAny<TContract> extends true ? [options?: LambderCallOptions] : TApiName extends keyof TContract ? [GuardInputsOf<TContract[TApiName]>] extends [never] ? [options?: LambderCallOptions] : [keyof RemainingGuardInputs<TContract[TApiName], TProvided>] extends [never] ? [options?: LambderCallOptions & {
48
+ guardInputs?: Partial<GuardInputsOf<TContract[TApiName]>>;
49
+ }] : [
50
+ options: LambderCallOptions & {
51
+ guardInputs: RemainingGuardInputs<TContract[TApiName], TProvided> & Partial<GuardInputsOf<TContract[TApiName]>>;
52
+ }
53
+ ] : [options?: LambderCallOptions];
16
54
  type VoidFunction = () => void | Promise<void>;
17
55
  type FetchTracker = {
18
56
  apiName: string;
@@ -79,7 +117,9 @@ export type LambderCallOptions = {
79
117
  /**
80
118
  * Values for the API's guardInput-mode guards, keyed by guard name; sent
81
119
  * beside the payload and consumed by the guards before validation. The
82
- * typed contract makes this REQUIRED for APIs that declare such guards.
120
+ * typed contract makes this REQUIRED for APIs that declare such guards,
121
+ * except the guards a guardInputsProvider covers (these merge on top of
122
+ * the provider's values).
83
123
  */
84
124
  guardInputs?: Record<string, unknown>;
85
125
  /**
@@ -102,7 +142,31 @@ export type LambderCallOptions = {
102
142
  fetchStartedHandler?: FetchStartEventHandler;
103
143
  fetchEndedHandler?: FetchEndEventHandler;
104
144
  };
105
- export default class LambderCaller<TContract extends ApiContractShape = any> {
145
+ type LambderCallerBaseOptions = {
146
+ apiPath: string;
147
+ apiVersion?: string;
148
+ isCorsEnabled: boolean;
149
+ /** Default per-request timeout in ms (none unless set; API Gateway caps around 29s, so ~30000 is a sensible value). Overridable per call. */
150
+ timeoutMs?: number;
151
+ versionExpiredHandler?: VoidFunction;
152
+ sessionExpiredHandler?: VoidFunction;
153
+ messageHandler?: MessageHandler;
154
+ errorMessageHandler?: MessageHandler;
155
+ notAuthorizedHandler?: VoidFunction;
156
+ errorHandler?: ErrorHandler;
157
+ fetchStartedHandler?: FetchStartEventHandler;
158
+ fetchEndedHandler?: FetchEndEventHandler;
159
+ apiInputValidationErrorHandler?: ValidationErrorHandler;
160
+ /** Must mirror the server's session cookie Domain, otherwise expired cookies cannot be cleared. */
161
+ sessionCookieDomain?: string | ((hostname: string) => string | undefined | null);
162
+ };
163
+ /** Constructor options: the base options plus guardInputsProvider, mandatory once TProvided names guards. */
164
+ export type LambderCallerOptions<TContract, TProvided extends string = never> = LambderCallerBaseOptions & GuardInputsProviderOption<TContract, TProvided>;
165
+ /**
166
+ * @typeParam TContract - The API contract, for typed names, payloads and guard inputs.
167
+ * @typeParam TProvidedGuards - Guard names guardInputsProvider covers; those APIs' options argument becomes optional.
168
+ */
169
+ export default class LambderCaller<TContract extends ApiContractShape = any, TProvidedGuards extends string = never> {
106
170
  private isCorsEnabled;
107
171
  private apiPath;
108
172
  private apiVersion?;
@@ -118,27 +182,11 @@ export default class LambderCaller<TContract extends ApiContractShape = any> {
118
182
  private apiInputValidationErrorHandler?;
119
183
  private fetchStartedHandler?;
120
184
  private fetchEndedHandler?;
185
+ private guardInputsProvider?;
121
186
  private sessionTokenCookieKey;
122
187
  private sessionCsrfCookieKey;
123
188
  private sessionCookieDomain?;
124
- constructor({ apiPath, apiVersion, isCorsEnabled, timeoutMs, versionExpiredHandler, sessionExpiredHandler, messageHandler, errorMessageHandler, notAuthorizedHandler, errorHandler, fetchStartedHandler, fetchEndedHandler, apiInputValidationErrorHandler, sessionCookieDomain, }: {
125
- apiPath: string;
126
- apiVersion?: string;
127
- isCorsEnabled: boolean;
128
- /** Default per-request timeout in ms (none unless set; API Gateway caps around 29s, so ~30000 is a sensible value). Overridable per call. */
129
- timeoutMs?: number;
130
- versionExpiredHandler?: VoidFunction;
131
- sessionExpiredHandler?: VoidFunction;
132
- messageHandler?: MessageHandler;
133
- errorMessageHandler?: MessageHandler;
134
- notAuthorizedHandler?: VoidFunction;
135
- errorHandler?: ErrorHandler;
136
- fetchStartedHandler?: FetchStartEventHandler;
137
- fetchEndedHandler?: FetchEndEventHandler;
138
- apiInputValidationErrorHandler?: ValidationErrorHandler;
139
- /** Must mirror the server's session cookie Domain, otherwise expired cookies cannot be cleared. */
140
- sessionCookieDomain?: string | ((hostname: string) => string | undefined | null);
141
- });
189
+ constructor(options: LambderCallerOptions<TContract, TProvidedGuards>);
142
190
  setSessionCookieKey(sessionTokenCookieKey: string, sessionCsrfCookieKey: string): void;
143
191
  /**
144
192
  * A self-rotating idempotency key for a component or form that performs
@@ -171,8 +219,8 @@ export default class LambderCaller<TContract extends ApiContractShape = any> {
171
219
  * Full-fidelity call: resolves to a discriminated LambderApiOutcome
172
220
  * instead of collapsing every failure to null. Never throws.
173
221
  */
174
- apiOutcome<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, ...rest: CallOptionsArg<TContract, TApiName>): Promise<LambderApiOutcome<TOutput>>;
222
+ apiOutcome<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, ...rest: CallOptionsArg<TContract, TApiName, TProvidedGuards>): Promise<LambderApiOutcome<TOutput>>;
175
223
  /** Payload on success, null/undefined otherwise (indistinguishable from a null payload; prefer apiOutcome() when that matters). */
176
- api<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, ...rest: CallOptionsArg<TContract, TApiName>): Promise<TOutput | null | undefined>;
224
+ api<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, ...rest: CallOptionsArg<TContract, TApiName, TProvidedGuards>): Promise<TOutput | null | undefined>;
177
225
  }
178
226
  export {};
@@ -1,4 +1,8 @@
1
1
  import Cookies from 'js-cookie';
2
+ /**
3
+ * @typeParam TContract - The API contract, for typed names, payloads and guard inputs.
4
+ * @typeParam TProvidedGuards - Guard names guardInputsProvider covers; those APIs' options argument becomes optional.
5
+ */
2
6
  export default class LambderCaller {
3
7
  isCorsEnabled;
4
8
  apiPath;
@@ -15,10 +19,14 @@ export default class LambderCaller {
15
19
  apiInputValidationErrorHandler;
16
20
  fetchStartedHandler;
17
21
  fetchEndedHandler;
22
+ guardInputsProvider;
18
23
  sessionTokenCookieKey = "LMDRSESSIONTKID";
19
24
  sessionCsrfCookieKey = "LMDRSESSIONCSTK";
20
25
  sessionCookieDomain;
21
- constructor({ apiPath, apiVersion, isCorsEnabled = false, timeoutMs, versionExpiredHandler, sessionExpiredHandler, messageHandler, errorMessageHandler, notAuthorizedHandler, errorHandler, fetchStartedHandler, fetchEndedHandler, apiInputValidationErrorHandler, sessionCookieDomain, }) {
26
+ constructor(options) {
27
+ // The conditional provider option is resolved per instantiation;
28
+ // inside the class it is read through the plain shape.
29
+ const { apiPath, apiVersion, isCorsEnabled = false, timeoutMs, versionExpiredHandler, sessionExpiredHandler, messageHandler, errorMessageHandler, notAuthorizedHandler, errorHandler, fetchStartedHandler, fetchEndedHandler, apiInputValidationErrorHandler, sessionCookieDomain, guardInputsProvider, } = options;
22
30
  this.apiPath = apiPath ?? "/api";
23
31
  this.apiVersion = apiVersion;
24
32
  this.isCorsEnabled = isCorsEnabled;
@@ -33,6 +41,7 @@ export default class LambderCaller {
33
41
  this.apiInputValidationErrorHandler = apiInputValidationErrorHandler;
34
42
  this.fetchStartedHandler = fetchStartedHandler;
35
43
  this.fetchEndedHandler = fetchEndedHandler;
44
+ this.guardInputsProvider = guardInputsProvider;
36
45
  }
37
46
  ;
38
47
  setSessionCookieKey(sessionTokenCookieKey, sessionCsrfCookieKey) {
@@ -159,6 +168,13 @@ export default class LambderCaller {
159
168
  const version = this.apiVersion;
160
169
  const token = Cookies.get(this.sessionCsrfCookieKey) || "";
161
170
  const siteHost = window.location.hostname;
171
+ // Provider values underneath, per-call values on top.
172
+ const providedGuardInputs = this.guardInputsProvider
173
+ ? await this.guardInputsProvider(apiName)
174
+ : undefined;
175
+ const guardInputs = providedGuardInputs !== undefined || options?.guardInputs !== undefined
176
+ ? { ...providedGuardInputs, ...options?.guardInputs }
177
+ : undefined;
162
178
  let res;
163
179
  try {
164
180
  res = await fetch(this.apiPath, {
@@ -170,7 +186,7 @@ export default class LambderCaller {
170
186
  headers: { 'Content-Type': 'application/json', ...(headers || {}) },
171
187
  body: JSON.stringify({
172
188
  apiName, version, token, siteHost, payload,
173
- ...(options?.guardInputs !== undefined ? { guardInputs: options.guardInputs } : {}),
189
+ ...(guardInputs !== undefined ? { guardInputs } : {}),
174
190
  ...(options?.idempotencyKey !== undefined ? { idempotencyKey: options.idempotencyKey } : {}),
175
191
  }),
176
192
  ...(signal ? { signal } : {}),
package/dist/client.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * the root entry (`"lambder"`) is the server surface.
8
8
  */
9
9
  export { default as LambderCaller } from "./client/LambderCaller.js";
10
- export type { LambderApiOutcome, LambderApiFailureReason, LambderCallOptions, LambderIdempotencyKeyScope, } from "./client/LambderCaller.js";
10
+ export type { LambderApiOutcome, LambderApiFailureReason, LambderCallOptions, LambderCallerOptions, LambderGuardInputsProvider, LambderProvidedGuardInputs, LambderIdempotencyKeyScope, } from "./client/LambderCaller.js";
11
11
  export { LambderApiError, isLambderApiError, refuse, LAMBDER_REFUSAL_CODES } from "./shared/LambderApiError.js";
12
12
  export type { LambderApiErrorOptions, LambderRefusalMessage, LambderRefusalCode, LambderRefuseOptions } from "./shared/LambderApiError.js";
13
13
  export type { ApiContractShape, LambderApiResponse, LambderApiResponseConfig } from "./shared/LambderApiContract.js";
@@ -5,7 +5,8 @@ import LambderResponseBuilder from "./LambderResponseBuilder.js";
5
5
  import { LambderResponse, type LambderHttpResponse } from "./LambderResponse.js";
6
6
  import { type ConditionFunction, type LambderRouteMatcher, type PathParamsOf } from "./LambderRouting.js";
7
7
  import { type LambderCorsConfig } from "./LambderCors.js";
8
- import { type LambderSessionDataRefreshConfig, type LambderSessionCompressionConfig } from "../session/LambderSessionManager.js";
8
+ import { type LambderSessionDataRefreshConfig } from "../session/LambderSessionManager.js";
9
+ import type { LambderCompressionOption } from "../stores/LambderDdbCompression.js";
9
10
  import LambderSessionController, { type LambderSessionCookieOptions } from "../session/LambderSessionController.js";
10
11
  import { type LambderPublicFilesOptions } from "./LambderPublicFiles.js";
11
12
  import type { LambderApiGuard, LambderGuardMetaMap, LambderGuardsOption, LambderGuardDataOf, LambderGuardInputsOf } from "../policies/LambderApiGuards.js";
@@ -95,7 +96,7 @@ export type LambderSessionOptions<TSessionData = any> = {
95
96
  * that many bytes. Records written under either setting read back, so
96
97
  * it can be switched on or off on a live table.
97
98
  */
98
- compression?: boolean | LambderSessionCompressionConfig;
99
+ compression?: LambderCompressionOption;
99
100
  };
100
101
  /**
101
102
  * Everything an instance is configured with, in ONE declaration: base
@@ -109,8 +110,8 @@ export type LambderCreateOptions<TSessionData = any> = {
109
110
  publicPath?: string;
110
111
  apiPath?: string;
111
112
  apiVersion?: string;
112
- /** Automatic gzip for compressible responses. Default: { minBytes: 860 }. Set false to disable. */
113
- compression?: false | {
113
+ /** Automatic gzip for compressible responses. `true` (the default) is `{ minBytes: 860 }`; `false` disables it. */
114
+ compression?: boolean | {
114
115
  minBytes?: number;
115
116
  };
116
117
  /** Automatic ETag + If-None-Match 304 on GET/HEAD 200 responses. Default: true. */
@@ -193,11 +194,13 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
193
194
  setSessionExpiredRouteHandler(handler: FallbackHandlerFunction): this;
194
195
  /**
195
196
  * Terminal public-file layer. Runs only when no route matched, so it can
196
- * never shadow routes registered after it. Serves real files under
197
- * publicPath (traversal-safe, mime-typed, memory-cached, immutable-cache
198
- * heuristic for content-hashed assets); when the file does not exist the
199
- * request falls through to setRouteFallbackHandler, where the app decides
200
- * what remains (e.g. render an app shell with res.templateFile).
197
+ * never shadow routes registered after it. Serves files from `source`
198
+ * (default: the publicPath folder; also LambderS3FileSource for S3 and
199
+ * R2, or any LambderPublicFileSource), traversal-safe, mime-typed,
200
+ * memory-cached, with the immutable-cache heuristic for content-hashed
201
+ * assets; when the source has no such file the request falls through to
202
+ * setRouteFallbackHandler, where the app decides what remains (e.g.
203
+ * render an app shell with res.templateFile).
201
204
  */
202
205
  servePublicFiles(options?: LambderPublicFilesOptions): this;
203
206
  /**
@@ -5,7 +5,7 @@ import { compileRouteMatcher } from "./LambderRouting.js";
5
5
  import { applyCorsHeaders } from "./LambderCors.js";
6
6
  import LambderSessionManager from "../session/LambderSessionManager.js";
7
7
  import LambderSessionController from "../session/LambderSessionController.js";
8
- import { LambderPublicFilesHandler } from "./LambderPublicFiles.js";
8
+ import { LambderPublicFilesHandler, LambderLocalFileSource } from "./LambderPublicFiles.js";
9
9
  import { isLambderApiError, LAMBDER_REFUSAL_CODES } from "../shared/LambderApiError.js";
10
10
  import { LambderApiPolicyEngine } from "../policies/LambderApiPolicies.js";
11
11
  import { createContext, isV2HttpEvent } from "./LambderContext.js";
@@ -72,7 +72,8 @@ export default class Lambder {
72
72
  this.finalizeOptions = {
73
73
  compression: options.compression === false
74
74
  ? false
75
- : { minBytes: options.compression?.minBytes ?? DEFAULT_FINALIZE_OPTIONS.compression.minBytes },
75
+ : { minBytes: (typeof options.compression === "object" ? options.compression.minBytes : undefined)
76
+ ?? DEFAULT_FINALIZE_OPTIONS.compression.minBytes },
76
77
  etag: options.etag ?? DEFAULT_FINALIZE_OPTIONS.etag,
77
78
  maxResponseBytes: options.maxResponseBytes ?? DEFAULT_FINALIZE_OPTIONS.maxResponseBytes,
78
79
  };
@@ -128,14 +129,17 @@ export default class Lambder {
128
129
  }
129
130
  /**
130
131
  * Terminal public-file layer. Runs only when no route matched, so it can
131
- * never shadow routes registered after it. Serves real files under
132
- * publicPath (traversal-safe, mime-typed, memory-cached, immutable-cache
133
- * heuristic for content-hashed assets); when the file does not exist the
134
- * request falls through to setRouteFallbackHandler, where the app decides
135
- * what remains (e.g. render an app shell with res.templateFile).
132
+ * never shadow routes registered after it. Serves files from `source`
133
+ * (default: the publicPath folder; also LambderS3FileSource for S3 and
134
+ * R2, or any LambderPublicFileSource), traversal-safe, mime-typed,
135
+ * memory-cached, with the immutable-cache heuristic for content-hashed
136
+ * assets; when the source has no such file the request falls through to
137
+ * setRouteFallbackHandler, where the app decides what remains (e.g.
138
+ * render an app shell with res.templateFile).
136
139
  */
137
140
  servePublicFiles(options = {}) {
138
- this.publicFilesHandler = new LambderPublicFilesHandler(this.publicPath, options);
141
+ const source = options.source ?? new LambderLocalFileSource({ root: this.publicPath });
142
+ this.publicFilesHandler = new LambderPublicFilesHandler(source, options);
139
143
  return this;
140
144
  }
141
145
  /**
@@ -1,14 +1,36 @@
1
1
  import type { LambderRenderContext } from "./LambderContext.js";
2
2
  import { LambderResponse } from "./LambderResponse.js";
3
+ /** A file a source serves: its bytes, and its mime type when the source knows it (otherwise resolved from the extension). */
4
+ export type LambderPublicFile = {
5
+ body: Buffer;
6
+ mimeType?: string;
7
+ };
8
+ /**
9
+ * Where servePublicFiles gets its files. Implement `read` over any backing
10
+ * store: LambderLocalFileSource (a folder, the default), LambderS3FileSource
11
+ * (S3, or R2 and other S3-compatible stores), or your own. The handler does
12
+ * the rest for every source: traversal check, memory cache, mime fallback
13
+ * from the extension, Cache-Control, ETag and compression.
14
+ */
15
+ export interface LambderPublicFileSource {
16
+ /**
17
+ * The file at a relative path (no leading slash, no ".." segments: the
18
+ * handler rejects those before calling), or null when there is no such
19
+ * file, which lets the request fall through to the route fallback.
20
+ */
21
+ read(relativePath: string): Promise<LambderPublicFile | null>;
22
+ }
3
23
  export type LambderPublicFilesOptions = {
24
+ /** Where files come from. Default: LambderLocalFileSource over publicPath. */
25
+ source?: LambderPublicFileSource;
4
26
  /**
5
- * Map the request to a file path under publicPath (app-owned logic, e.g.
6
- * per-tenant roots: (ctx) => `${brand(ctx.host)}${ctx.path}`). Return
27
+ * Map the request to a file path (app-owned logic, e.g. per-tenant
28
+ * roots: (ctx) => `${brand(ctx.host)}${ctx.path}`). Return
7
29
  * null/undefined to skip. Default: (ctx) => ctx.path.
8
30
  */
9
31
  path?: (ctx: LambderRenderContext) => string | null | undefined;
10
- /** Cache-Control for served files. Default: "public, max-age=3600". */
11
- cacheControl?: string | ((ctx: LambderRenderContext, filePath: string) => string);
32
+ /** Cache-Control for served files; the function receives the relative file path. Default: "public, max-age=3600". */
33
+ cacheControl?: string | ((ctx: LambderRenderContext, relativePath: string) => string);
12
34
  /** Filenames matching this get immutableCacheControl. Default: content-hash heuristic. Set false to disable. */
13
35
  immutablePattern?: RegExp | false;
14
36
  /** Default: "public, max-age=31536000, immutable". */
@@ -24,24 +46,34 @@ export type LambderPublicFilesOptions = {
24
46
  */
25
47
  compress?: boolean | "auto" | ((ctx: LambderRenderContext) => boolean | "auto");
26
48
  };
49
+ /**
50
+ * Files from a folder on the Lambda's filesystem, typically the build output
51
+ * bundled into the deployment package. Reads stay under root. The default
52
+ * source of servePublicFiles, over publicPath.
53
+ */
54
+ export declare class LambderLocalFileSource implements LambderPublicFileSource {
55
+ private root;
56
+ constructor({ root }: {
57
+ root: string;
58
+ });
59
+ read(relativePath: string): Promise<LambderPublicFile | null>;
60
+ }
27
61
  /**
28
62
  * Terminal public-file handler registered via lambder.servePublicFiles().
29
63
  * Runs only when no route matched, so it can never shadow routes registered
30
- * after it. Serves real files under publicPath (traversal-safe, mime-typed,
64
+ * after it. Serves files from its source (traversal-safe, mime-typed,
31
65
  * memory-cached, immutable-cache heuristic for content-hashed assets) and
32
- * falls through to the route fallback when the file does not exist.
66
+ * falls through to the route fallback when the source has no such file.
33
67
  */
34
68
  export declare class LambderPublicFilesHandler {
35
- private publicPath;
69
+ private source;
36
70
  private options;
37
71
  private fileCache;
38
72
  private fileCacheBytes;
39
- constructor(publicPath: string, options: LambderPublicFilesOptions);
73
+ constructor(source: LambderPublicFileSource, options: LambderPublicFilesOptions);
40
74
  /** Serve the mapped file, or return null to fall through. */
41
75
  handle(ctx: LambderRenderContext): Promise<LambderResponse | null>;
42
- /** Join base+target and require the result to stay under base. */
43
- private resolveSafe;
44
- /** Read a file, caching small files in memory for warm invocations. */
45
- private readFileCached;
76
+ /** Read from the source, caching small files in memory for warm invocations. */
77
+ private readCached;
46
78
  private cacheControlFor;
47
79
  }