lambder 4.3.2 → 4.5.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,6 +2,16 @@
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.5:**
6
+
7
+ - **`files` at creation replaces `publicPath`** (and `servePublicFiles({ source })`): one `LambderFileSource` configured once, `files: new LambderLocalFileSource({ root: path.resolve("./public") })` for the folder bundled with the deployment, `new LambderS3FileSource({...})` for S3 or R2, or your own `{ read(relativePath) }`. The instance owns one reader over it (`lambder.files`): path rule, in-memory file cache and compiled-template cache in one place, shared by `servePublicFiles`, `serveIndexHtml`, `res.file` and `res.templateFile`, so a build hosted from a bucket serves its index.html and templates from the bucket too, cached the same way as its assets. The cache is tuned or disabled beside the source, `files: { source, memoryCache }`, and `memoryCache` leaves `servePublicFiles`; `res.file` loses its SPA-era `fallback` option (the fallback chain replaced it).
8
+
9
+ **New in 4.4:**
10
+
11
+ - **`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.
12
+ - **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.
13
+ - **`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.
14
+
5
15
  **New in 4.3:**
6
16
 
7
17
  - **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.
@@ -86,7 +96,7 @@ would silently widen the inferred policy types, which is why the curried
86
96
  creator is the canonical entry.
87
97
 
88
98
  ```typescript
89
- import { initLambder } from 'lambder';
99
+ import { initLambder, LambderLocalFileSource } from 'lambder';
90
100
  import { z } from 'zod';
91
101
  import * as path from 'path';
92
102
 
@@ -94,7 +104,7 @@ interface SessionData { userId: string; }
94
104
 
95
105
  const lambder = initLambder<SessionData>().create({
96
106
  apiPath: "/api",
97
- publicPath: path.resolve(`./public`),
107
+ files: new LambderLocalFileSource({ root: path.resolve(`./public`) }),
98
108
  session: {
99
109
  tableName: "website-session",
100
110
  tableRegion: "us-east-1",
@@ -160,8 +170,9 @@ lambder
160
170
  .addRoute({ path: "/stripe-webhook", method: "POST" }, (ctx, res) => {
161
171
  return res.json({ received: true });
162
172
  })
163
- // Serve real files from publicPath. This is a terminal fallback slot, NOT
164
- // a catch-all route, so it can never shadow routes registered after it.
173
+ // Serve real files from the files source (see "Public file sources"
174
+ // below). This is a terminal fallback slot, NOT a catch-all route, so it
175
+ // can never shadow routes registered after it.
165
176
  .servePublicFiles()
166
177
  // Serve the app shell for GET/HEAD page requests nothing else handled
167
178
  // (see "Hosting a frontend build" below).
@@ -209,7 +220,7 @@ For larger applications, split your APIs into separate modules:
209
220
  ```typescript
210
221
  // user-api.ts
211
222
  import { z } from "zod";
212
- import Lambder from "lambder";
223
+ import Lambder, { LambderLocalFileSource } from "lambder";
213
224
 
214
225
  export const userApi = <T>(l: Lambder<T>) => {
215
226
  return l
@@ -230,7 +241,7 @@ export const userApi = <T>(l: Lambder<T>) => {
230
241
  // index.ts
231
242
  import { userApi } from "./user-api";
232
243
 
233
- const lambder = new Lambder({ publicPath: './public' })
244
+ const lambder = new Lambder({ files: new LambderLocalFileSource({ root: './public' }) })
234
245
  .use(userApi);
235
246
 
236
247
  export type ApiContractType = typeof lambder.ApiContract;
@@ -359,6 +370,7 @@ Semantics:
359
370
  - The renewal write and the sliding-expiration write share a single DynamoDB put when both are due.
360
371
  - Records created before `dataRefresh` was enabled renew on their first read.
361
372
  - `updateSessionData()` marks data fresh (it was just written deliberately); `regenerateSession()` carries the old freshness stamp over.
373
+ - `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.
362
374
 
363
375
  #### Session data at rest (`compression`)
364
376
 
@@ -389,6 +401,7 @@ Access the session controller with `lambder.getSessionController(ctx)`:
389
401
  | `endSession()` | End session, delete from DDB |
390
402
  | `endSessionAll()` | End all sessions for this sessionKey (all devices) |
391
403
  | `deleteSessionAllByKey(sessionKey)` | Delete all sessions of any sessionKey (e.g. "log user X out everywhere") |
404
+ | `expireSessionDataAllByKey(sessionKey)` | Mark the data of all sessions of a sessionKey stale, so each renews via `dataRefresh` on its next read (no logout) |
392
405
  | `regenerateSession()` | Regenerate token (use after password change) |
393
406
 
394
407
  ### Type-Safe Templating (html / xml)
@@ -432,6 +445,37 @@ const output = template.render({
432
445
 
433
446
  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).**
434
447
 
448
+ #### Public file sources
449
+
450
+ The `files` option at creation is a `LambderFileSource`, an object with one method, `read(relativePath)`, returning `{ body, mimeType? }` or `null`. The instance owns one reader over it, `lambder.files`, and `servePublicFiles`, `serveIndexHtml`, `res.file` and `res.templateFile` all go through that reader, which does everything else for every source: traversal check, in-memory file cache for warm invocations (default 32MB, 2MB per file), compiled-template cache, mime fallback from the extension. Cache-Control (immutable for content-hashed names), ETag and compression are applied by the serving slot and the response pipeline. Built in:
451
+
452
+ ```typescript
453
+ // A folder, typically the build output bundled with the deployment.
454
+ initLambder().create({ files: new LambderLocalFileSource({ root: path.resolve("./public") }) });
455
+
456
+ // S3. @aws-sdk/client-s3 is an optional peer dependency, loaded on first read.
457
+ initLambder().create({
458
+ files: new LambderS3FileSource({ bucket: "myapp-web", prefix: "v42/", clientConfig: { region: "eu-central-1" } }),
459
+ });
460
+
461
+ // Cloudflare R2, or any S3-compatible store: point the client at its endpoint.
462
+ initLambder().create({
463
+ files: new LambderS3FileSource({
464
+ bucket: "myapp-web",
465
+ clientConfig: { region: "auto", endpoint: "https://<account>.r2.cloudflarestorage.com", credentials: { accessKeyId, secretAccessKey } },
466
+ }),
467
+ });
468
+
469
+ // Anything else: implement read().
470
+ initLambder().create({ files: { read: async (relativePath) => myStore.get(relativePath) } });
471
+
472
+ // The in-memory file cache, tuned or off, beside any source.
473
+ initLambder().create({ files: { source: new LambderS3FileSource({ bucket: "myapp-web" }), memoryCache: { maxBytes: 64_000_000, maxFileBytes: 4_000_000 } } });
474
+ initLambder().create({ files: { source: new LambderLocalFileSource({ root }), memoryCache: false } });
475
+ ```
476
+
477
+ 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.
478
+
435
479
  ```typescript
436
480
  // Zero-config single-tenant hosting:
437
481
  lambder.servePublicFiles().serveIndexHtml();
@@ -772,7 +816,7 @@ Also available:
772
816
 
773
817
  - **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.
774
818
  - **Per-call handler overrides**: every constructor handler (`errorHandler`, `sessionExpiredHandler`, `errorMessageHandler`, ...) can be overridden in the options of a single `api`/`apiOutcome` call.
775
- - **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.
819
+ - **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.
776
820
  - **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.
777
821
 
778
822
  ### 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";
@@ -9,6 +9,7 @@ import { type LambderSessionDataRefreshConfig } from "../session/LambderSessionM
9
9
  import type { LambderCompressionOption } from "../stores/LambderDdbCompression.js";
10
10
  import LambderSessionController, { type LambderSessionCookieOptions } from "../session/LambderSessionController.js";
11
11
  import { type LambderPublicFilesOptions } from "./LambderPublicFiles.js";
12
+ import { LambderFiles, type LambderFilesOption } from "./LambderFiles.js";
12
13
  import type { LambderApiGuard, LambderGuardMetaMap, LambderGuardsOption, LambderGuardDataOf, LambderGuardInputsOf } from "../policies/LambderApiGuards.js";
13
14
  import type { LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig, LambderRateLimitOption } from "../policies/LambderApiRateLimits.js";
14
15
  import type { LambderApiIdempotencyConfig } from "../policies/LambderApiIdempotency.js";
@@ -107,7 +108,14 @@ export type LambderSessionOptions<TSessionData = any> = {
107
108
  * instance type ever needs a name.
108
109
  */
109
110
  export type LambderCreateOptions<TSessionData = any> = {
110
- publicPath?: string;
111
+ /**
112
+ * Where the app's files come from, for servePublicFiles, serveIndexHtml,
113
+ * res.file and res.templateFile: a LambderLocalFileSource over a folder
114
+ * (the build output bundled with the deployment), a LambderS3FileSource
115
+ * (S3, R2), or any LambderFileSource; or `{ source, memoryCache }` to
116
+ * tune or disable the in-memory file cache. Required by those features.
117
+ */
118
+ files?: LambderFilesOption;
111
119
  apiPath?: string;
112
120
  apiVersion?: string;
113
121
  /** Automatic gzip for compressible responses. `true` (the default) is `{ minBytes: 860 }`; `false` disables it. */
@@ -153,7 +161,8 @@ export type LambderCreateOptions<TSessionData = any> = {
153
161
  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> {
154
162
  apiPath: string;
155
163
  apiVersion: null | string;
156
- publicPath: string;
164
+ /** The instance's file reader (source + caches), or null without the files option. */
165
+ files: LambderFiles | null;
157
166
  /**
158
167
  * Type property for extracting the API contract
159
168
  * Use this to export your API types to the frontend
@@ -194,11 +203,12 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
194
203
  setSessionExpiredRouteHandler(handler: FallbackHandlerFunction): this;
195
204
  /**
196
205
  * Terminal public-file layer. Runs only when no route matched, so it can
197
- * never shadow routes registered after it. Serves real files under
198
- * publicPath (traversal-safe, mime-typed, memory-cached, immutable-cache
199
- * heuristic for content-hashed assets); when the file does not exist the
200
- * request falls through to setRouteFallbackHandler, where the app decides
201
- * what remains (e.g. render an app shell with res.templateFile).
206
+ * never shadow routes registered after it. Serves files from the `files`
207
+ * source configured at creation, traversal-safe, mime-typed,
208
+ * memory-cached, with the immutable-cache heuristic for content-hashed
209
+ * assets; when the source has no such file the request falls through to
210
+ * setRouteFallbackHandler, where the app decides what remains (e.g.
211
+ * render an app shell with res.templateFile).
202
212
  */
203
213
  servePublicFiles(options?: LambderPublicFilesOptions): this;
204
214
  /**
@@ -207,8 +217,8 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
207
217
  * gone; everything left is an app route (option `skipFilePaths` opts back
208
218
  * into 404ing dotted paths). Only configured methods reach it, default
209
219
  * GET/HEAD. Gated-out requests fall through to setRouteFallbackHandler.
210
- * Without a handler, publicPath/index.html is served via res.templateFile
211
- * (markers optional) with no-cache.
220
+ * Without a handler, index.html from the files source is served via
221
+ * res.templateFile (markers optional) with no-cache.
212
222
  */
213
223
  serveIndexHtml(handler?: FallbackHandlerFunction, options?: LambderIndexHtmlOptions): this;
214
224
  /** Apply the serveIndexHtml gates; null means fall through. */
@@ -6,6 +6,7 @@ import { applyCorsHeaders } from "./LambderCors.js";
6
6
  import LambderSessionManager from "../session/LambderSessionManager.js";
7
7
  import LambderSessionController from "../session/LambderSessionController.js";
8
8
  import { LambderPublicFilesHandler } from "./LambderPublicFiles.js";
9
+ import { LambderFiles } from "./LambderFiles.js";
9
10
  import { isLambderApiError, LAMBDER_REFUSAL_CODES } from "../shared/LambderApiError.js";
10
11
  import { LambderApiPolicyEngine } from "../policies/LambderApiPolicies.js";
11
12
  import { createContext, isV2HttpEvent } from "./LambderContext.js";
@@ -33,7 +34,8 @@ import { createContext, isV2HttpEvent } from "./LambderContext.js";
33
34
  export default class Lambder {
34
35
  apiPath;
35
36
  apiVersion;
36
- publicPath;
37
+ /** The instance's file reader (source + caches), or null without the files option. */
38
+ files;
37
39
  /**
38
40
  * Type property for extracting the API contract
39
41
  * Use this to export your API types to the frontend
@@ -66,7 +68,7 @@ export default class Lambder {
66
68
  sessionTokenCookieKey = "LMDRSESSIONTKID";
67
69
  sessionCsrfCookieKey = "LMDRSESSIONCSTK";
68
70
  constructor(options = {}) {
69
- this.publicPath = options.publicPath || "/incorrect-path-not-found";
71
+ this.files = options.files ? new LambderFiles(options.files) : null;
70
72
  this.apiPath = options.apiPath ?? "/api";
71
73
  this.apiVersion = options.apiVersion ?? null;
72
74
  this.finalizeOptions = {
@@ -129,14 +131,17 @@ export default class Lambder {
129
131
  }
130
132
  /**
131
133
  * Terminal public-file layer. Runs only when no route matched, so it can
132
- * never shadow routes registered after it. Serves real files under
133
- * publicPath (traversal-safe, mime-typed, memory-cached, immutable-cache
134
- * heuristic for content-hashed assets); when the file does not exist the
135
- * request falls through to setRouteFallbackHandler, where the app decides
136
- * what remains (e.g. render an app shell with res.templateFile).
134
+ * never shadow routes registered after it. Serves files from the `files`
135
+ * source configured at creation, traversal-safe, mime-typed,
136
+ * memory-cached, with the immutable-cache heuristic for content-hashed
137
+ * assets; when the source has no such file the request falls through to
138
+ * setRouteFallbackHandler, where the app decides what remains (e.g.
139
+ * render an app shell with res.templateFile).
137
140
  */
138
141
  servePublicFiles(options = {}) {
139
- this.publicFilesHandler = new LambderPublicFilesHandler(this.publicPath, options);
142
+ if (!this.files)
143
+ throw new Error("servePublicFiles requires the files option at creation (e.g. files: new LambderLocalFileSource({ root }))");
144
+ this.publicFilesHandler = new LambderPublicFilesHandler(this.files, options);
140
145
  return this;
141
146
  }
142
147
  /**
@@ -145,8 +150,8 @@ export default class Lambder {
145
150
  * gone; everything left is an app route (option `skipFilePaths` opts back
146
151
  * into 404ing dotted paths). Only configured methods reach it, default
147
152
  * GET/HEAD. Gated-out requests fall through to setRouteFallbackHandler.
148
- * Without a handler, publicPath/index.html is served via res.templateFile
149
- * (markers optional) with no-cache.
153
+ * Without a handler, index.html from the files source is served via
154
+ * res.templateFile (markers optional) with no-cache.
150
155
  */
151
156
  serveIndexHtml(handler, options = {}) {
152
157
  this.indexHtmlConfig = { handler: handler ?? null, options };
@@ -328,7 +333,7 @@ export default class Lambder {
328
333
  }
329
334
  getResponseBuilder(ctx) {
330
335
  return new LambderResponseBuilder({
331
- publicPath: this.publicPath,
336
+ files: this.files,
332
337
  apiVersion: this.apiVersion,
333
338
  ctx,
334
339
  });
@@ -336,7 +341,7 @@ export default class Lambder {
336
341
  ;
337
342
  getResolver(ctx) {
338
343
  return new LambderResolver({
339
- publicPath: this.publicPath,
344
+ files: this.files,
340
345
  apiVersion: this.apiVersion,
341
346
  ctx,
342
347
  });
@@ -0,0 +1,85 @@
1
+ import { LambderTemplatingEngine } from "./LambderTemplatingEngine.js";
2
+ /** A file a source serves: its bytes, and its mime type when the source knows it (otherwise resolved from the extension). */
3
+ export type LambderFile = {
4
+ body: Buffer;
5
+ mimeType?: string;
6
+ };
7
+ /**
8
+ * Where an app's files come from: the `files` option at creation, read by
9
+ * servePublicFiles, serveIndexHtml, res.file and res.templateFile alike,
10
+ * through the instance's one reader (LambderFiles). Implement `read` over
11
+ * any backing store: LambderLocalFileSource (a folder), LambderS3FileSource
12
+ * (S3, or R2 and other S3-compatible stores), or your own. The reader does
13
+ * the rest for every source: traversal check, memory cache, mime fallback
14
+ * from the extension.
15
+ */
16
+ export interface LambderFileSource {
17
+ /**
18
+ * The file at a relative path (no leading slash, no ".." segments: the
19
+ * reader rejects those before calling), or null when there is no such
20
+ * file, which lets a request fall through to the route fallback.
21
+ */
22
+ read(relativePath: string): Promise<LambderFile | null>;
23
+ }
24
+ /** In-memory cache of files for warm invocations. Default: { maxBytes: 32MB, maxFileBytes: 2MB }. false disables it. */
25
+ export type LambderFileMemoryCacheOption = false | {
26
+ maxBytes?: number;
27
+ maxFileBytes?: number;
28
+ };
29
+ /** The `files` option at creation: a source, or a source with its memory cache tuned or off. */
30
+ export type LambderFilesOption = LambderFileSource | {
31
+ source: LambderFileSource;
32
+ memoryCache?: LambderFileMemoryCacheOption;
33
+ };
34
+ /** A file as the reader hands it out: path normalized, mime type resolved. */
35
+ export type LambderReadFile = {
36
+ body: Buffer;
37
+ mimeType: string;
38
+ relativePath: string;
39
+ };
40
+ /**
41
+ * Files from a folder on the Lambda's filesystem, typically the build output
42
+ * bundled into the deployment package. Reads stay under root.
43
+ */
44
+ export declare class LambderLocalFileSource implements LambderFileSource {
45
+ private root;
46
+ constructor({ root }: {
47
+ root: string;
48
+ });
49
+ read(relativePath: string): Promise<LambderFile | null>;
50
+ }
51
+ /**
52
+ * The path a source is asked for: leading slash stripped, traversal
53
+ * rejected; null for a path that names no file (empty, or a directory).
54
+ */
55
+ export declare const toRelativePath: (target: string) => string | null;
56
+ /**
57
+ * The app's file reader, owned by the Lambder instance: one source, one
58
+ * path rule, one memory cache and one compiled-template cache, shared by
59
+ * every feature that reads files. Both caches live as long as the instance,
60
+ * i.e. across warm invocations.
61
+ */
62
+ export declare class LambderFiles {
63
+ private source;
64
+ private cache;
65
+ private cacheBytes;
66
+ private maxBytes;
67
+ private maxFileBytes;
68
+ private templates;
69
+ constructor(option: LambderFilesOption);
70
+ /**
71
+ * The file at a request or handler path (leading slash optional), mime
72
+ * type resolved; null when the path is invalid or the source has none.
73
+ */
74
+ read(path: string): Promise<LambderReadFile | null>;
75
+ /**
76
+ * The compiled template for an HTML file, compiled once per instance.
77
+ * A missing file throws: it is a server-side configuration error, not a
78
+ * client 404.
79
+ */
80
+ template(path: string, options?: {
81
+ htmlVirtualSlots?: boolean;
82
+ }): Promise<LambderTemplatingEngine>;
83
+ /** Cache small files within the byte budget, evicting the oldest entries first. */
84
+ private remember;
85
+ }