lambder 4.3.2 → 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,6 +2,12 @@
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.
@@ -160,8 +166,9 @@ lambder
160
166
  .addRoute({ path: "/stripe-webhook", method: "POST" }, (ctx, res) => {
161
167
  return res.json({ received: true });
162
168
  })
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.
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.
165
172
  .servePublicFiles()
166
173
  // Serve the app shell for GET/HEAD page requests nothing else handled
167
174
  // (see "Hosting a frontend build" below).
@@ -359,6 +366,7 @@ Semantics:
359
366
  - The renewal write and the sliding-expiration write share a single DynamoDB put when both are due.
360
367
  - Records created before `dataRefresh` was enabled renew on their first read.
361
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.
362
370
 
363
371
  #### Session data at rest (`compression`)
364
372
 
@@ -389,6 +397,7 @@ Access the session controller with `lambder.getSessionController(ctx)`:
389
397
  | `endSession()` | End session, delete from DDB |
390
398
  | `endSessionAll()` | End all sessions for this sessionKey (all devices) |
391
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) |
392
401
  | `regenerateSession()` | Regenerate token (use after password change) |
393
402
 
394
403
  ### Type-Safe Templating (html / xml)
@@ -432,6 +441,33 @@ const output = template.render({
432
441
 
433
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).**
434
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
+
435
471
  ```typescript
436
472
  // Zero-config single-tenant hosting:
437
473
  lambder.servePublicFiles().serveIndexHtml();
@@ -772,7 +808,7 @@ Also available:
772
808
 
773
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.
774
810
  - **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.
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.
776
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.
777
813
 
778
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";
@@ -194,11 +194,13 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
194
194
  setSessionExpiredRouteHandler(handler: FallbackHandlerFunction): this;
195
195
  /**
196
196
  * 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).
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).
202
204
  */
203
205
  servePublicFiles(options?: LambderPublicFilesOptions): this;
204
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";
@@ -129,14 +129,17 @@ export default class Lambder {
129
129
  }
130
130
  /**
131
131
  * 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).
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).
137
139
  */
138
140
  servePublicFiles(options = {}) {
139
- this.publicFilesHandler = new LambderPublicFilesHandler(this.publicPath, options);
141
+ const source = options.source ?? new LambderLocalFileSource({ root: this.publicPath });
142
+ this.publicFilesHandler = new LambderPublicFilesHandler(source, options);
140
143
  return this;
141
144
  }
142
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
  }
@@ -8,36 +8,65 @@ const DEFAULT_IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
8
8
  const DEFAULT_CACHE_CONTROL = "public, max-age=3600";
9
9
  const DEFAULT_MEMORY_CACHE_MAX_BYTES = 32 * 1024 * 1024;
10
10
  const DEFAULT_MEMORY_CACHE_MAX_FILE_BYTES = 2 * 1024 * 1024;
11
+ /**
12
+ * Files from a folder on the Lambda's filesystem, typically the build output
13
+ * bundled into the deployment package. Reads stay under root. The default
14
+ * source of servePublicFiles, over publicPath.
15
+ */
16
+ export class LambderLocalFileSource {
17
+ root;
18
+ constructor({ root }) {
19
+ this.root = root;
20
+ }
21
+ async read(relativePath) {
22
+ const fs = await getFS();
23
+ const path = await getPath();
24
+ if (!fs || !path)
25
+ throw new Error("LambderLocalFileSource requires a Node.js environment.");
26
+ const base = path.resolve(this.root);
27
+ const absolute = path.resolve(base, relativePath);
28
+ if (absolute !== base && !absolute.startsWith(base + path.sep))
29
+ return null;
30
+ const stat = await fs.promises.stat(absolute).catch(() => null);
31
+ if (!stat?.isFile())
32
+ return null;
33
+ return { body: await fs.promises.readFile(absolute) };
34
+ }
35
+ }
36
+ /** Strip the leading slash and reject traversal; null for a path that names no file (empty, or a directory). */
37
+ const toRelativePath = (target) => {
38
+ if (target.split("/").some((segment) => segment === ".."))
39
+ return null;
40
+ const relative = target.startsWith("/") ? target.slice(1) : target;
41
+ if (relative === "" || relative.endsWith("/"))
42
+ return null;
43
+ return relative;
44
+ };
11
45
  /**
12
46
  * Terminal public-file handler registered via lambder.servePublicFiles().
13
47
  * Runs only when no route matched, so it can never shadow routes registered
14
- * after it. Serves real files under publicPath (traversal-safe, mime-typed,
48
+ * after it. Serves files from its source (traversal-safe, mime-typed,
15
49
  * memory-cached, immutable-cache heuristic for content-hashed assets) and
16
- * falls through to the route fallback when the file does not exist.
50
+ * falls through to the route fallback when the source has no such file.
17
51
  */
18
52
  export class LambderPublicFilesHandler {
19
- publicPath;
53
+ source;
20
54
  options;
21
55
  fileCache = new Map();
22
56
  fileCacheBytes = 0;
23
- constructor(publicPath, options) {
24
- this.publicPath = publicPath;
57
+ constructor(source, options) {
58
+ this.source = source;
25
59
  this.options = options;
26
60
  }
27
61
  /** Serve the mapped file, or return null to fall through. */
28
62
  async handle(ctx) {
29
- const fs = await getFS();
30
- const path = await getPath();
31
- if (!fs || !path)
32
- throw new Error("servePublicFiles requires a Node.js environment.");
33
63
  const mappedPath = this.options.path ? this.options.path(ctx) : ctx.path;
34
64
  if (!mappedPath)
35
65
  return null;
36
- const publicRoot = path.resolve(this.publicPath);
37
- const filePath = this.resolveSafe(path, publicRoot, mappedPath);
38
- if (!filePath)
66
+ const relativePath = toRelativePath(mappedPath);
67
+ if (relativePath === null)
39
68
  return null;
40
- const file = await this.readFileCached(fs, filePath);
69
+ const file = await this.readCached(relativePath);
41
70
  if (!file)
42
71
  return null;
43
72
  const compressOption = this.options.compress;
@@ -46,61 +75,52 @@ export class LambderPublicFilesHandler {
46
75
  statusCode: 200,
47
76
  headers: {
48
77
  "Content-Type": file.mimeType,
49
- "Cache-Control": this.cacheControlFor(ctx, filePath),
78
+ "Cache-Control": this.cacheControlFor(ctx, relativePath),
50
79
  },
51
80
  body: file.body,
52
81
  compress,
53
82
  });
54
83
  }
55
- /** Join base+target and require the result to stay under base. */
56
- resolveSafe(path, base, target) {
57
- if (target.split("/").some((segment) => segment === ".."))
58
- return null;
59
- const normalizedTarget = target.startsWith("/") ? target.slice(1) : target;
60
- const absolute = path.resolve(base, normalizedTarget);
61
- if (absolute !== base && !absolute.startsWith(base + path.sep))
62
- return null;
63
- return absolute;
64
- }
65
- /** Read a file, caching small files in memory for warm invocations. */
66
- async readFileCached(fs, filePath) {
67
- const cached = this.fileCache.get(filePath);
84
+ /** Read from the source, caching small files in memory for warm invocations. */
85
+ async readCached(relativePath) {
86
+ const cached = this.fileCache.get(relativePath);
68
87
  if (cached)
69
88
  return cached;
70
- const stat = await fs.promises.stat(filePath).catch(() => null);
71
- if (!stat?.isFile())
89
+ const file = await this.source.read(relativePath);
90
+ if (!file)
72
91
  return null;
73
- const body = await fs.promises.readFile(filePath);
74
- const mimeType = mimeTypeResolver.lookup(filePath) || "application/octet-stream";
75
- const entry = { body, mimeType };
92
+ const entry = {
93
+ body: file.body,
94
+ mimeType: file.mimeType || mimeTypeResolver.lookup(relativePath) || "application/octet-stream",
95
+ };
76
96
  const cacheConfig = this.options.memoryCache;
77
97
  if (cacheConfig !== false) {
78
98
  const maxBytes = cacheConfig?.maxBytes ?? DEFAULT_MEMORY_CACHE_MAX_BYTES;
79
99
  const maxFileBytes = cacheConfig?.maxFileBytes ?? DEFAULT_MEMORY_CACHE_MAX_FILE_BYTES;
80
- if (body.length <= maxFileBytes) {
100
+ if (entry.body.length <= maxFileBytes) {
81
101
  // Evict oldest entries until the new file fits the budget.
82
102
  for (const [key, value] of this.fileCache) {
83
- if (this.fileCacheBytes + body.length <= maxBytes)
103
+ if (this.fileCacheBytes + entry.body.length <= maxBytes)
84
104
  break;
85
105
  this.fileCache.delete(key);
86
106
  this.fileCacheBytes -= value.body.length;
87
107
  }
88
- if (this.fileCacheBytes + body.length <= maxBytes) {
89
- this.fileCache.set(filePath, entry);
90
- this.fileCacheBytes += body.length;
108
+ if (this.fileCacheBytes + entry.body.length <= maxBytes) {
109
+ this.fileCache.set(relativePath, entry);
110
+ this.fileCacheBytes += entry.body.length;
91
111
  }
92
112
  }
93
113
  }
94
114
  return entry;
95
115
  }
96
- cacheControlFor(ctx, filePath) {
116
+ cacheControlFor(ctx, relativePath) {
97
117
  const cacheOption = this.options.cacheControl;
98
118
  if (typeof cacheOption === "function")
99
- return cacheOption(ctx, filePath);
119
+ return cacheOption(ctx, relativePath);
100
120
  const immutablePattern = this.options.immutablePattern === false
101
121
  ? null
102
122
  : (this.options.immutablePattern ?? DEFAULT_IMMUTABLE_PATTERN);
103
- if (immutablePattern && immutablePattern.test(filePath)) {
123
+ if (immutablePattern && immutablePattern.test(relativePath)) {
104
124
  return this.options.immutableCacheControl ?? DEFAULT_IMMUTABLE_CACHE_CONTROL;
105
125
  }
106
126
  return cacheOption ?? DEFAULT_CACHE_CONTROL;
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ import Lambder from './core/Lambder.js';
2
2
  export default Lambder;
3
3
  export { initLambder } from './core/Lambder.js';
4
4
  export { default as LambderCaller } from "./client/LambderCaller.js";
5
- export type { LambderApiOutcome, LambderApiFailureReason, LambderCallOptions, LambderIdempotencyKeyScope } from "./client/LambderCaller.js";
5
+ export type { LambderApiOutcome, LambderApiFailureReason, LambderCallOptions, LambderCallerOptions, LambderGuardInputsProvider, LambderProvidedGuardInputs, LambderIdempotencyKeyScope } from "./client/LambderCaller.js";
6
6
  export { LambderApiError, isLambderApiError, refuse, LAMBDER_REFUSAL_CODES } from "./shared/LambderApiError.js";
7
7
  export type { LambderApiErrorOptions, LambderRefusalMessage, LambderRefusalCode, LambderRefuseOptions } from "./shared/LambderApiError.js";
8
8
  export { default as LambderResponseBuilder } from "./core/LambderResponseBuilder.js";
@@ -15,8 +15,10 @@ export { LambderTemplatingEngine } from "./core/LambderTemplatingEngine.js";
15
15
  export type { LambderTemplateData, LambderTemplatingEngineOptions } from "./core/LambderTemplatingEngine.js";
16
16
  export type { LambderResponseOptions, LambderRawResponseInit, } from "./core/LambderResponseBuilder.js";
17
17
  export type { LambderRouteMatcher, LambderCorsConfig, LambderCreateOptions, LambderSessionOptions, ConditionFunction, RouteCondition, PathParamsOf, LambderActionTools, LambderHandler, LambderIndexHtmlOptions, } from "./core/Lambder.js";
18
- export { LambderPublicFilesHandler } from "./core/LambderPublicFiles.js";
19
- export type { LambderPublicFilesOptions } from "./core/LambderPublicFiles.js";
18
+ export { LambderPublicFilesHandler, LambderLocalFileSource } from "./core/LambderPublicFiles.js";
19
+ export type { LambderPublicFilesOptions, LambderPublicFileSource, LambderPublicFile } from "./core/LambderPublicFiles.js";
20
+ export { LambderS3FileSource } from "./stores/LambderS3FileSource.js";
21
+ export type { LambderS3FileSourceOptions } from "./stores/LambderS3FileSource.js";
20
22
  export type { LambderSessionCookieOptions } from "./session/LambderSessionController.js";
21
23
  export type { LambderSessionContext, LambderCreatedSession, LambderSessionDataRefreshConfig } from "./session/LambderSessionManager.js";
22
24
  export type { LambderCompressionOption, LambderCompressionConfig } from "./stores/LambderDdbCompression.js";
package/dist/index.js CHANGED
@@ -15,7 +15,8 @@ export { html, xml, raw, jsonScript, escapeHtml, renderHtmlValue, LambderSafeHtm
15
15
  // Comment-based HTML templating engine (build-pipeline-safe slots and conditionals, standalone)
16
16
  export { LambderTemplatingEngine } from "./core/LambderTemplatingEngine.js";
17
17
  // Public file serving
18
- export { LambderPublicFilesHandler } from "./core/LambderPublicFiles.js";
18
+ export { LambderPublicFilesHandler, LambderLocalFileSource } from "./core/LambderPublicFiles.js";
19
+ export { LambderS3FileSource } from "./stores/LambderS3FileSource.js";
19
20
  export { LambderSessionDataRefreshError, LambderSessionReadError } from "./session/LambderSessionManager.js";
20
21
  // DynamoDB-backed compressed cache (standalone, server-only)
21
22
  export { LambderDdbCache } from "./stores/LambderDdbCache.js";
@@ -49,6 +49,13 @@ export default class LambderSessionController<TSessionData = any> {
49
49
  * session and touches no cookies, so it works on any subject.
50
50
  */
51
51
  deleteSessionAllByKey(sessionKey: string): Promise<void>;
52
+ /**
53
+ * Marks the data of every session of the given sessionKey stale, so each
54
+ * renews via dataRefresh on its next read: the way to apply a change to
55
+ * a subject's roles or permissions immediately, without logging them
56
+ * out. Needs no fetched session; requires dataRefresh.
57
+ */
58
+ expireSessionDataAllByKey(sessionKey: string): Promise<void>;
52
59
  endSession(): Promise<void>;
53
60
  endSessionAll(): Promise<void>;
54
61
  }
@@ -151,6 +151,16 @@ export default class LambderSessionController {
151
151
  await this.lambderSessionManager.deleteSessionAllByKey(sessionKey);
152
152
  }
153
153
  ;
154
+ /**
155
+ * Marks the data of every session of the given sessionKey stale, so each
156
+ * renews via dataRefresh on its next read: the way to apply a change to
157
+ * a subject's roles or permissions immediately, without logging them
158
+ * out. Needs no fetched session; requires dataRefresh.
159
+ */
160
+ async expireSessionDataAllByKey(sessionKey) {
161
+ await this.lambderSessionManager.expireSessionDataAllByKey(sessionKey);
162
+ }
163
+ ;
154
164
  async endSession() {
155
165
  if (!this.ctx.session)
156
166
  throw new Error("Session not found.");
@@ -111,6 +111,7 @@ export default class LambderSessionManager {
111
111
  */
112
112
  private ddbPutItem;
113
113
  private ddbDeleteItem;
114
+ /** Sort keys of every session under a partition (the callers only need the keys). */
114
115
  private ddbQueryAllByPartitionKey;
115
116
  private ddbDeleteAllByPartitionKey;
116
117
  createSession(sessionKey: string, data?: any, ttlInSeconds?: number, options?: {
@@ -135,5 +136,15 @@ export default class LambderSessionManager {
135
136
  * session record.
136
137
  */
137
138
  deleteSessionAllByKey(sessionKey: string): Promise<boolean>;
139
+ /**
140
+ * Marks the data of every session of the given sessionKey stale, so each
141
+ * renews via dataRefresh on its next read: "this subject's roles or
142
+ * permissions changed, apply it now", without logging the subject out
143
+ * (deleteSessionAllByKey) and without waiting for the data TTL. Stamps
144
+ * dataExpiresAt only, conditionally on the record still existing, so it
145
+ * neither resurrects a session deleted in between nor overwrites a
146
+ * concurrent write. Requires dataRefresh to be configured.
147
+ */
148
+ expireSessionDataAllByKey(sessionKey: string): Promise<boolean>;
138
149
  regenerateSession(session: LambderSessionContext): Promise<LambderCreatedSession>;
139
150
  }
@@ -1,6 +1,6 @@
1
1
  import crypto from "crypto";
2
2
  import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
3
- import { DynamoDBDocumentClient, QueryCommand, DeleteCommand, PutCommand, GetCommand } from "@aws-sdk/lib-dynamodb";
3
+ import { DynamoDBDocumentClient, QueryCommand, DeleteCommand, PutCommand, GetCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb";
4
4
  import { brotliCompressText, brotliRestoreText, resolveCompressionOption, } from "../stores/LambderDdbCompression.js";
5
5
  /**
6
6
  * Session compression defaults: every record compressed (see
@@ -108,11 +108,13 @@ export default class LambderSessionManager {
108
108
  return await this.ddbDocumentClient.send(new DeleteCommand({ TableName: this.tableName, Key: key, }));
109
109
  }
110
110
  ;
111
+ /** Sort keys of every session under a partition (the callers only need the keys). */
111
112
  async ddbQueryAllByPartitionKey(partitionValue) {
112
113
  const params = {
113
114
  TableName: this.tableName,
114
115
  KeyConditionExpression: "#pk = :pv",
115
- ExpressionAttributeNames: { "#pk": this.partitionKey },
116
+ ProjectionExpression: "#sk",
117
+ ExpressionAttributeNames: { "#pk": this.partitionKey, "#sk": this.sortKey },
116
118
  ExpressionAttributeValues: { ":pv": partitionValue },
117
119
  };
118
120
  const queryResults = [];
@@ -341,6 +343,40 @@ export default class LambderSessionManager {
341
343
  return true;
342
344
  }
343
345
  ;
346
+ /**
347
+ * Marks the data of every session of the given sessionKey stale, so each
348
+ * renews via dataRefresh on its next read: "this subject's roles or
349
+ * permissions changed, apply it now", without logging the subject out
350
+ * (deleteSessionAllByKey) and without waiting for the data TTL. Stamps
351
+ * dataExpiresAt only, conditionally on the record still existing, so it
352
+ * neither resurrects a session deleted in between nor overwrites a
353
+ * concurrent write. Requires dataRefresh to be configured.
354
+ */
355
+ async expireSessionDataAllByKey(sessionKey) {
356
+ if (!this.dataRefresh)
357
+ throw new Error("dataRefresh is not configured. Pass session.dataRefresh at creation to enable.");
358
+ const partitionValue = this.sessionUserKeyHasher(sessionKey);
359
+ const now = Math.floor(Date.now() / 1000);
360
+ for (const item of await this.ddbQueryAllByPartitionKey(partitionValue)) {
361
+ try {
362
+ await this.ddbDocumentClient.send(new UpdateCommand({
363
+ TableName: this.tableName,
364
+ Key: { [this.partitionKey]: partitionValue, [this.sortKey]: item[this.sortKey] },
365
+ UpdateExpression: "SET #dataExpiresAt = :now",
366
+ ConditionExpression: "attribute_exists(#sk)",
367
+ ExpressionAttributeNames: { "#dataExpiresAt": "dataExpiresAt", "#sk": this.sortKey },
368
+ ExpressionAttributeValues: { ":now": now },
369
+ }));
370
+ }
371
+ catch (err) {
372
+ // Deleted between the query and the update: nothing left to expire.
373
+ if (err.name !== "ConditionalCheckFailedException")
374
+ throw err;
375
+ }
376
+ }
377
+ return true;
378
+ }
379
+ ;
344
380
  async regenerateSession(session) {
345
381
  if (!session)
346
382
  throw new Error("Invalid session");
@@ -0,0 +1,35 @@
1
+ import type { S3Client, S3ClientConfig } from "@aws-sdk/client-s3";
2
+ import type { LambderPublicFile, LambderPublicFileSource } from "../core/LambderPublicFiles.js";
3
+ export type LambderS3FileSourceOptions = {
4
+ bucket: string;
5
+ /** Literal key prefix the relative path is appended to, so include the trailing slash: "web/v42/". Default: none. */
6
+ prefix?: string;
7
+ /** A ready client, e.g. one shared with the rest of the app. */
8
+ client?: S3Client;
9
+ /**
10
+ * Otherwise the client is created from this on first read: `{ region }`
11
+ * for S3; for Cloudflare R2 or another S3-compatible store,
12
+ * `{ region: "auto", endpoint, credentials }`.
13
+ */
14
+ clientConfig?: S3ClientConfig;
15
+ };
16
+ /**
17
+ * Files from an S3 bucket, or any S3-compatible store such as Cloudflare
18
+ * R2 (pass its endpoint in clientConfig). Needs @aws-sdk/client-s3, an
19
+ * optional peer dependency loaded on first read, so apps that serve from a
20
+ * folder never load it. A missing object reads as null and the request
21
+ * falls through; grant s3:ListBucket besides s3:GetObject, otherwise S3
22
+ * answers a missing key with AccessDenied, which propagates as an error.
23
+ * An object's Content-Type is used unless it is a generic octet-stream, in
24
+ * which case the extension decides, as for local files.
25
+ */
26
+ export declare class LambderS3FileSource implements LambderPublicFileSource {
27
+ private readonly bucket;
28
+ private readonly prefix;
29
+ private readonly clientConfig;
30
+ private client;
31
+ private sdk;
32
+ constructor({ bucket, prefix, client, clientConfig }: LambderS3FileSourceOptions);
33
+ private loadSdk;
34
+ read(relativePath: string): Promise<LambderPublicFile | null>;
35
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Files from an S3 bucket, or any S3-compatible store such as Cloudflare
3
+ * R2 (pass its endpoint in clientConfig). Needs @aws-sdk/client-s3, an
4
+ * optional peer dependency loaded on first read, so apps that serve from a
5
+ * folder never load it. A missing object reads as null and the request
6
+ * falls through; grant s3:ListBucket besides s3:GetObject, otherwise S3
7
+ * answers a missing key with AccessDenied, which propagates as an error.
8
+ * An object's Content-Type is used unless it is a generic octet-stream, in
9
+ * which case the extension decides, as for local files.
10
+ */
11
+ export class LambderS3FileSource {
12
+ bucket;
13
+ prefix;
14
+ clientConfig;
15
+ client;
16
+ sdk;
17
+ constructor({ bucket, prefix = "", client, clientConfig }) {
18
+ if (!bucket.trim())
19
+ throw new Error("bucket is required");
20
+ this.bucket = bucket;
21
+ this.prefix = prefix;
22
+ this.client = client;
23
+ this.clientConfig = clientConfig;
24
+ }
25
+ loadSdk() {
26
+ if (!this.sdk) {
27
+ this.sdk = import("@aws-sdk/client-s3").catch(() => {
28
+ throw new Error("LambderS3FileSource requires @aws-sdk/client-s3: npm install @aws-sdk/client-s3");
29
+ });
30
+ }
31
+ return this.sdk;
32
+ }
33
+ async read(relativePath) {
34
+ const { S3Client, GetObjectCommand } = await this.loadSdk();
35
+ if (!this.client)
36
+ this.client = new S3Client(this.clientConfig ?? {});
37
+ let output;
38
+ try {
39
+ output = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: `${this.prefix}${relativePath}` }));
40
+ }
41
+ catch (err) {
42
+ const name = err.name;
43
+ if (name === "NoSuchKey" || name === "NotFound")
44
+ return null;
45
+ throw err;
46
+ }
47
+ if (!output.Body)
48
+ return null;
49
+ const body = Buffer.from(await output.Body.transformToByteArray());
50
+ const contentType = output.ContentType;
51
+ return contentType && !contentType.endsWith("octet-stream") ? { body, mimeType: contentType } : { body };
52
+ }
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lambder",
3
- "version": "4.3.2",
3
+ "version": "4.4.1",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -61,6 +61,7 @@
61
61
  "zod": "^4.1.12"
62
62
  },
63
63
  "peerDependencies": {
64
+ "@aws-sdk/client-s3": "^3.574.0",
64
65
  "msw": "^2.0.0"
65
66
  },
66
67
  "peerDependenciesMeta": {
@@ -69,6 +70,7 @@
69
70
  }
70
71
  },
71
72
  "devDependencies": {
73
+ "@aws-sdk/client-s3": "^3.1127.0",
72
74
  "@types/aws-lambda": "^8.10.136",
73
75
  "@types/cookie": "^0.6.0",
74
76
  "@types/js-cookie": "^3.0.6",