lambder 4.4.1 → 4.6.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,15 @@
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.6:**
6
+
7
+ - **Cookies as a first-class concern**: `res.setCookie(name, value, options)` and `res.clearCookie(name, options)` serialize Set-Cookie headers through the `cookie` package (defaults Path=/, SameSite=Lax, Secure; a function-form `domain` resolves against the request hostname, the same option the session takes), replacing hand-built header strings; `serializeCookie`/`serializeClearCookie` are exported for code holding a response. `ctx.cookieList` keeps every value a cookie name arrived with beside the first-wins `ctx.cookie`.
8
+ - **Session cookie scope changes heal**: a cookie's identity is (name, domain, path), so changing the session's `cookie.domain` or `path` on a live deployment leaves the old copy in every browser beside the new one, and a whole-header parse silently picks whichever the browser lists first. The controller now tries every copy of the session cookie (record and CSRF pairing checked per copy), logs the ambiguity, and evicts the stale host-only twin from the response, so a migrated browser recovers on its first request instead of answering `sessionExpired` until the old cookie expires.
9
+
10
+ **New in 4.5:**
11
+
12
+ - **`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).
13
+
5
14
  **New in 4.4:**
6
15
 
7
16
  - **`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.
@@ -92,7 +101,7 @@ would silently widen the inferred policy types, which is why the curried
92
101
  creator is the canonical entry.
93
102
 
94
103
  ```typescript
95
- import { initLambder } from 'lambder';
104
+ import { initLambder, LambderLocalFileSource } from 'lambder';
96
105
  import { z } from 'zod';
97
106
  import * as path from 'path';
98
107
 
@@ -100,7 +109,7 @@ interface SessionData { userId: string; }
100
109
 
101
110
  const lambder = initLambder<SessionData>().create({
102
111
  apiPath: "/api",
103
- publicPath: path.resolve(`./public`),
112
+ files: new LambderLocalFileSource({ root: path.resolve(`./public`) }),
104
113
  session: {
105
114
  tableName: "website-session",
106
115
  tableRegion: "us-east-1",
@@ -166,9 +175,9 @@ lambder
166
175
  .addRoute({ path: "/stripe-webhook", method: "POST" }, (ctx, res) => {
167
176
  return res.json({ received: true });
168
177
  })
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.
178
+ // Serve real files from the files source (see "Public file sources"
179
+ // below). This is a terminal fallback slot, NOT a catch-all route, so it
180
+ // can never shadow routes registered after it.
172
181
  .servePublicFiles()
173
182
  // Serve the app shell for GET/HEAD page requests nothing else handled
174
183
  // (see "Hosting a frontend build" below).
@@ -216,7 +225,7 @@ For larger applications, split your APIs into separate modules:
216
225
  ```typescript
217
226
  // user-api.ts
218
227
  import { z } from "zod";
219
- import Lambder from "lambder";
228
+ import Lambder, { LambderLocalFileSource } from "lambder";
220
229
 
221
230
  export const userApi = <T>(l: Lambder<T>) => {
222
231
  return l
@@ -237,7 +246,7 @@ export const userApi = <T>(l: Lambder<T>) => {
237
246
  // index.ts
238
247
  import { userApi } from "./user-api";
239
248
 
240
- const lambder = new Lambder({ publicPath: './public' })
249
+ const lambder = new Lambder({ files: new LambderLocalFileSource({ root: './public' }) })
241
250
  .use(userApi);
242
251
 
243
252
  export type ApiContractType = typeof lambder.ApiContract;
@@ -331,6 +340,12 @@ const lambder = initLambder<SessionData>().create({
331
340
 
332
341
  See [docs/DYNAMODB_SETUP.md](docs/DYNAMODB_SETUP.md) for detailed setup instructions.
333
342
 
343
+ #### Cookie scope
344
+
345
+ `session.cookie` sets the scope of the two session cookies: `{ domain: ".example.com" }` shares a login across subdomains, and `domain` may be a `(hostname) => string | undefined` function when one deployment serves several apex domains (return undefined for a host-only cookie); `path`, `sameSite` (default `Lax`) and `secure` (default true) complete it. `LambderCaller` takes the same `sessionCookieDomain` so it can clear the CSRF cookie where the server set it.
346
+
347
+ Changing `domain` or `path` on a live deployment is a migration, because a browser identifies a cookie by (name, domain, path): the old copy stays beside the new one, both arrive on every request, and the browser's order says nothing about which is current. The controller handles the overlap: when the session cookie name arrives more than once it tries every copy (record lookup and CSRF pairing per copy), takes the live one, logs the ambiguity, and evicts the stale host-only twin from the response when a domain is configured. The reverse move, from a domain cookie back to host-only, cannot be evicted (this host cannot name the parent domain), so that copy is tolerated on every request until its own expiry. Renaming the cookies (`tokenCookieKey`, `csrfCookieKey`) alongside the scope change avoids the overlap entirely.
348
+
334
349
  #### How the secrets are stored
335
350
 
336
351
  The session cookie is `pkHash:secret`: `pkHash = sha256(sessionKey + sessionSalt)` and `secret` is 256 random bits. At rest the record stores only HASHES of the bearer secrets: the range key is `sha256(secret)` (so the lookup itself proves possession of the raw secret) and the CSRF token is stored as `csrfTokenHash`. The raw values exist only in the client's cookies and, transiently, on the `LambderCreatedSession` result the manager returns at creation; a read of the session table (backup leak, over-broad IAM, insider) therefore yields no usable cookies. Fast sha256 is the correct construction here rather than a password KDF: the secrets are 256-bit random, so there is nothing to brute-force, while `sessionSalt` peppers the identity-to-partition-key mapping so partition keys and cookie prefixes cannot be derived from (or linked to) known user ids.
@@ -443,27 +458,31 @@ Lambder has no SPA-specific machinery; hosting a frontend build is a recipe buil
443
458
 
444
459
  #### Public file sources
445
460
 
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:
461
+ 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:
447
462
 
448
463
  ```typescript
449
- // Default: the publicPath folder bundled with the deployment.
450
- lambder.servePublicFiles();
464
+ // A folder, typically the build output bundled with the deployment.
465
+ initLambder().create({ files: new LambderLocalFileSource({ root: path.resolve("./public") }) });
451
466
 
452
467
  // 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" } }),
468
+ initLambder().create({
469
+ files: new LambderS3FileSource({ bucket: "myapp-web", prefix: "v42/", clientConfig: { region: "eu-central-1" } }),
455
470
  });
456
471
 
457
472
  // Cloudflare R2, or any S3-compatible store: point the client at its endpoint.
458
- lambder.servePublicFiles({
459
- source: new LambderS3FileSource({
473
+ initLambder().create({
474
+ files: new LambderS3FileSource({
460
475
  bucket: "myapp-web",
461
476
  clientConfig: { region: "auto", endpoint: "https://<account>.r2.cloudflarestorage.com", credentials: { accessKeyId, secretAccessKey } },
462
477
  }),
463
478
  });
464
479
 
465
480
  // Anything else: implement read().
466
- lambder.servePublicFiles({ source: { read: async (relativePath) => myStore.get(relativePath) } });
481
+ initLambder().create({ files: { read: async (relativePath) => myStore.get(relativePath) } });
482
+
483
+ // The in-memory file cache, tuned or off, beside any source.
484
+ initLambder().create({ files: { source: new LambderS3FileSource({ bucket: "myapp-web" }), memoryCache: { maxBytes: 64_000_000, maxFileBytes: 4_000_000 } } });
485
+ initLambder().create({ files: { source: new LambderLocalFileSource({ root }), memoryCache: false } });
467
486
  ```
468
487
 
469
488
  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.
@@ -496,7 +515,8 @@ The `ctx` object provides access to request data:
496
515
  | `rawBody` | Decoded request body as received (webhook signatures) | `'{"a":1}'` |
497
516
  | `ip` | Client IP (CF-Connecting-IP / X-Forwarded-For / source IP) | `"1.2.3.4"` |
498
517
  | `header(name)` | Case-insensitive request header lookup | `ctx.header("accept-language")` |
499
- | `cookie` | Cookies | `{ rememberMe: "true" }` |
518
+ | `cookie` | Cookies (the first value when a name arrived more than once) | `{ rememberMe: "true" }` |
519
+ | `cookieList` | Every value per cookie name, in header order (a name held at several scopes arrives several times) | `{ rememberMe: ["true"] }` |
500
520
  | `headers` | Request headers | `{ "Content-Type": "..." }` |
501
521
  | `event` | Raw Lambda event (APIGatewayProxyEvent or APIGatewayProxyEventV2) | - |
502
522
  | `lambdaContext` | AWS Lambda Context | - |
@@ -509,6 +529,8 @@ The `ctx` object provides access to request data:
509
529
  **Header Manipulation** (call before returning response):
510
530
  - `res.addHeader(key, value)` - Adds a header value (can be called multiple times for same key)
511
531
  - `res.setHeader(key, value)` - Sets a header (replaces existing values)
532
+ - `res.setCookie(name, value, options?)` - Adds a Set-Cookie header. Options: `domain` (a string, or a `(hostname) => string | undefined` function resolved against the request host), `path` (default `/`), `sameSite` (default `Lax`), `secure` (default true), `httpOnly`, `maxAge` (seconds), `expires` (Date), `encode` (default encodeURIComponent, which `ctx.cookie` reverses)
533
+ - `res.clearCookie(name, options?)` - Adds a Set-Cookie header that deletes the cookie. Pass the `domain` and `path` it was set with: a cookie's identity is (name, domain, path), so a deletion under another scope deletes nothing
512
534
  - `res.logToApiResponse(data)` - Adds data to logList in API responses (debugging)
513
535
 
514
536
  **Response Methods** (all accept an options object: `{ statusCode?, headers?, cacheControl?, compress?, etag? }`):
@@ -101,6 +101,9 @@ export default class LambderCaller {
101
101
  const resolvedDomain = typeof domainOption === "function" ? domainOption(hostname) : domainOption;
102
102
  for (const key of [this.sessionTokenCookieKey, this.sessionCsrfCookieKey]) {
103
103
  // Host-only and domain-scoped cookies are distinct entries; clear both.
104
+ // Only the CSRF cookie is reachable from here: the token cookie is
105
+ // HttpOnly, so its removal is the server's (a Set-Cookie on the
106
+ // session-expired or logout response).
104
107
  Cookies.remove(key);
105
108
  if (resolvedDomain)
106
109
  Cookies.remove(key, { domain: resolvedDomain, path: "/" });
@@ -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,9 +203,8 @@ 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 files from `source`
198
- * (default: the publicPath folder; also LambderS3FileSource for S3 and
199
- * R2, or any LambderPublicFileSource), traversal-safe, mime-typed,
206
+ * never shadow routes registered after it. Serves files from the `files`
207
+ * source configured at creation, traversal-safe, mime-typed,
200
208
  * memory-cached, with the immutable-cache heuristic for content-hashed
201
209
  * assets; when the source has no such file the request falls through to
202
210
  * setRouteFallbackHandler, where the app decides what remains (e.g.
@@ -209,8 +217,8 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
209
217
  * gone; everything left is an app route (option `skipFilePaths` opts back
210
218
  * into 404ing dotted paths). Only configured methods reach it, default
211
219
  * GET/HEAD. Gated-out requests fall through to setRouteFallbackHandler.
212
- * Without a handler, publicPath/index.html is served via res.templateFile
213
- * (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.
214
222
  */
215
223
  serveIndexHtml(handler?: FallbackHandlerFunction, options?: LambderIndexHtmlOptions): this;
216
224
  /** Apply the serveIndexHtml gates; null means fall through. */
@@ -5,7 +5,8 @@ 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, LambderLocalFileSource } from "./LambderPublicFiles.js";
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,17 +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 files from `source`
133
- * (default: the publicPath folder; also LambderS3FileSource for S3 and
134
- * R2, or any LambderPublicFileSource), traversal-safe, mime-typed,
134
+ * never shadow routes registered after it. Serves files from the `files`
135
+ * source configured at creation, traversal-safe, mime-typed,
135
136
  * memory-cached, with the immutable-cache heuristic for content-hashed
136
137
  * assets; when the source has no such file the request falls through to
137
138
  * setRouteFallbackHandler, where the app decides what remains (e.g.
138
139
  * render an app shell with res.templateFile).
139
140
  */
140
141
  servePublicFiles(options = {}) {
141
- const source = options.source ?? new LambderLocalFileSource({ root: this.publicPath });
142
- this.publicFilesHandler = new LambderPublicFilesHandler(source, 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);
143
145
  return this;
144
146
  }
145
147
  /**
@@ -148,8 +150,8 @@ export default class Lambder {
148
150
  * gone; everything left is an app route (option `skipFilePaths` opts back
149
151
  * into 404ing dotted paths). Only configured methods reach it, default
150
152
  * GET/HEAD. Gated-out requests fall through to setRouteFallbackHandler.
151
- * Without a handler, publicPath/index.html is served via res.templateFile
152
- * (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.
153
155
  */
154
156
  serveIndexHtml(handler, options = {}) {
155
157
  this.indexHtmlConfig = { handler: handler ?? null, options };
@@ -331,7 +333,7 @@ export default class Lambder {
331
333
  }
332
334
  getResponseBuilder(ctx) {
333
335
  return new LambderResponseBuilder({
334
- publicPath: this.publicPath,
336
+ files: this.files,
335
337
  apiVersion: this.apiVersion,
336
338
  ctx,
337
339
  });
@@ -339,7 +341,7 @@ export default class Lambder {
339
341
  ;
340
342
  getResolver(ctx) {
341
343
  return new LambderResolver({
342
- publicPath: this.publicPath,
344
+ files: this.files,
343
345
  apiVersion: this.apiVersion,
344
346
  ctx,
345
347
  });
@@ -11,7 +11,16 @@ export type LambderRenderContext<TApiPayload = any, TPathParams extends Record<s
11
11
  method: string;
12
12
  get: Record<string, string | undefined>;
13
13
  post: Record<string, any>;
14
+ /** Cookies by name (the first value when a name arrived more than once; see cookieList). */
14
15
  cookie: Record<string, string>;
16
+ /**
17
+ * Every value the request carried per cookie name, in header order. A
18
+ * name normally maps to one value; several arrive when the browser holds
19
+ * that name at more than one scope (host-only beside Domain=, or two
20
+ * paths), typically after a cookie's Domain or Path was changed. The
21
+ * browser's order says nothing about which copy is current.
22
+ */
23
+ cookieList: Record<string, string[]>;
15
24
  session: null;
16
25
  apiName: string | null;
17
26
  apiPayload: TApiPayload;
@@ -10,7 +10,7 @@ export const createContext = (event, lambdaContext, apiPath) => {
10
10
  let path;
11
11
  let method;
12
12
  let get;
13
- let cookieHeader;
13
+ let cookiePairs;
14
14
  let sourceIp;
15
15
  const headers = event.headers ?? {};
16
16
  if (isV2HttpEvent(event)) {
@@ -26,7 +26,8 @@ export const createContext = (event, lambdaContext, apiPath) => {
26
26
  for (const [key, value] of new URLSearchParams(event.rawQueryString ?? "").entries()) {
27
27
  get[key] = value;
28
28
  }
29
- cookieHeader = (event.cookies ?? []).join("; ");
29
+ // v2 delivers the Cookie header pre-split into name=value pairs.
30
+ cookiePairs = event.cookies ?? [];
30
31
  sourceIp = event.requestContext.http.sourceIp || "";
31
32
  }
32
33
  else {
@@ -34,10 +35,19 @@ export const createContext = (event, lambdaContext, apiPath) => {
34
35
  path = event.path;
35
36
  method = event.httpMethod;
36
37
  get = event.queryStringParameters || {};
37
- cookieHeader = headers.Cookie || headers.cookie || "";
38
+ cookiePairs = (headers.Cookie || headers.cookie || "").split(";");
38
39
  sourceIp = event.requestContext?.identity?.sourceIp || "";
39
40
  }
40
- const cookie = cookieParser.parse(cookieHeader);
41
+ // Parsed pair by pair so a name that arrived more than once keeps every
42
+ // value; a whole-header parse keeps only the first.
43
+ const cookieList = {};
44
+ for (const pair of cookiePairs) {
45
+ for (const [name, value] of Object.entries(cookieParser.parse(pair))) {
46
+ if (value !== undefined)
47
+ (cookieList[name] ??= []).push(value);
48
+ }
49
+ }
50
+ const cookie = Object.fromEntries(Object.entries(cookieList).map(([name, values]) => [name, values[0]]));
41
51
  const lowercasedHeaders = {};
42
52
  for (const [key, value] of Object.entries(headers)) {
43
53
  if (value !== undefined)
@@ -74,7 +84,7 @@ export const createContext = (event, lambdaContext, apiPath) => {
74
84
  const requestVersion = isApiCall ? (post.version ?? null) : null;
75
85
  return {
76
86
  host, path, pathParams: {}, method,
77
- get, post, cookie, event,
87
+ get, post, cookie, cookieList, event,
78
88
  session: null,
79
89
  apiName, apiPayload,
80
90
  guardData: {},
@@ -0,0 +1,44 @@
1
+ /**
2
+ * A cookie's Domain attribute: a fixed value such as ".example.com", or a
3
+ * function of the request hostname for one deployment serving several apex
4
+ * domains. Return undefined (or null) for a host-only cookie.
5
+ */
6
+ export type LambderCookieDomain = string | ((hostname: string) => string | undefined | null);
7
+ /**
8
+ * Attributes of a Set-Cookie header. A cookie's identity in the browser is
9
+ * (name, domain, path): a write under a different domain or path creates a
10
+ * second cookie beside the first instead of replacing it, and a deletion
11
+ * only reaches the cookie whose domain and path it names.
12
+ */
13
+ export type LambderCookieOptions = {
14
+ domain?: LambderCookieDomain;
15
+ /** Default "/". */
16
+ path?: string;
17
+ /** Default "Lax". */
18
+ sameSite?: "Strict" | "Lax" | "None";
19
+ /** Default true. */
20
+ secure?: boolean;
21
+ /** Default false. */
22
+ httpOnly?: boolean;
23
+ /** Lifetime in seconds (Max-Age). A cookie with neither maxAge nor expires lasts the browser session. */
24
+ maxAge?: number;
25
+ /** Absolute expiry (Expires). */
26
+ expires?: Date;
27
+ /** Value encoder. Default encodeURIComponent, which ctx.cookie reverses on the way back in. */
28
+ encode?: (value: string) => string;
29
+ };
30
+ /** Options of a deleting Set-Cookie: only the scope matters. */
31
+ export type LambderClearCookieOptions = Omit<LambderCookieOptions, "maxAge" | "expires" | "encode">;
32
+ /** The Domain attribute for this request, or undefined for a host-only cookie. */
33
+ export declare const resolveCookieDomain: (domain: LambderCookieDomain | undefined, host?: string) => string | undefined;
34
+ /**
35
+ * One Set-Cookie header value. `host` resolves a function-form domain; a
36
+ * string domain needs none.
37
+ */
38
+ export declare const serializeCookie: (name: string, value: string, options?: LambderCookieOptions, host?: string) => string;
39
+ /**
40
+ * A Set-Cookie header value that deletes the cookie. Domain and path must
41
+ * match the cookie being deleted: a mismatch targets a different cookie and
42
+ * deletes nothing.
43
+ */
44
+ export declare const serializeClearCookie: (name: string, options?: LambderClearCookieOptions, host?: string) => string;
@@ -0,0 +1,28 @@
1
+ import cookieParser from "cookie";
2
+ /** The Domain attribute for this request, or undefined for a host-only cookie. */
3
+ export const resolveCookieDomain = (domain, host = "") => {
4
+ // Host header can carry a port; browsers match the Domain attribute on hostname only.
5
+ const hostname = host.split(":")[0] ?? "";
6
+ const resolved = typeof domain === "function" ? domain(hostname) : domain;
7
+ return resolved || undefined;
8
+ };
9
+ /**
10
+ * One Set-Cookie header value. `host` resolves a function-form domain; a
11
+ * string domain needs none.
12
+ */
13
+ export const serializeCookie = (name, value, options = {}, host) => cookieParser.serialize(name, value, {
14
+ domain: resolveCookieDomain(options.domain, host),
15
+ path: options.path ?? "/",
16
+ sameSite: (options.sameSite ?? "Lax").toLowerCase(),
17
+ secure: options.secure ?? true,
18
+ httpOnly: options.httpOnly ?? false,
19
+ maxAge: options.maxAge,
20
+ expires: options.expires,
21
+ encode: options.encode,
22
+ });
23
+ /**
24
+ * A Set-Cookie header value that deletes the cookie. Domain and path must
25
+ * match the cookie being deleted: a mismatch targets a different cookie and
26
+ * deletes nothing.
27
+ */
28
+ export const serializeClearCookie = (name, options = {}, host) => serializeCookie(name, "", { ...options, maxAge: 0, expires: new Date(0) }, host);
@@ -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
+ }
@@ -0,0 +1,116 @@
1
+ import mimeTypeResolver from "mime-types";
2
+ import { getFS, getPath } from "../shared/node-polyfills.js";
3
+ import { LambderTemplatingEngine } from "./LambderTemplatingEngine.js";
4
+ const DEFAULT_MEMORY_CACHE_MAX_BYTES = 32 * 1024 * 1024;
5
+ const DEFAULT_MEMORY_CACHE_MAX_FILE_BYTES = 2 * 1024 * 1024;
6
+ /**
7
+ * Files from a folder on the Lambda's filesystem, typically the build output
8
+ * bundled into the deployment package. Reads stay under root.
9
+ */
10
+ export class LambderLocalFileSource {
11
+ root;
12
+ constructor({ root }) {
13
+ this.root = root;
14
+ }
15
+ async read(relativePath) {
16
+ const fs = await getFS();
17
+ const path = await getPath();
18
+ if (!fs || !path)
19
+ throw new Error("LambderLocalFileSource requires a Node.js environment.");
20
+ const base = path.resolve(this.root);
21
+ const absolute = path.resolve(base, relativePath);
22
+ if (absolute !== base && !absolute.startsWith(base + path.sep))
23
+ return null;
24
+ const stat = await fs.promises.stat(absolute).catch(() => null);
25
+ if (!stat?.isFile())
26
+ return null;
27
+ return { body: await fs.promises.readFile(absolute) };
28
+ }
29
+ }
30
+ /**
31
+ * The path a source is asked for: leading slash stripped, traversal
32
+ * rejected; null for a path that names no file (empty, or a directory).
33
+ */
34
+ export const toRelativePath = (target) => {
35
+ if (target.split("/").some((segment) => segment === ".."))
36
+ return null;
37
+ const relative = target.startsWith("/") ? target.slice(1) : target;
38
+ if (relative === "" || relative.endsWith("/"))
39
+ return null;
40
+ return relative;
41
+ };
42
+ /**
43
+ * The app's file reader, owned by the Lambder instance: one source, one
44
+ * path rule, one memory cache and one compiled-template cache, shared by
45
+ * every feature that reads files. Both caches live as long as the instance,
46
+ * i.e. across warm invocations.
47
+ */
48
+ export class LambderFiles {
49
+ source;
50
+ cache;
51
+ cacheBytes = 0;
52
+ maxBytes;
53
+ maxFileBytes;
54
+ templates = new Map();
55
+ constructor(option) {
56
+ const { source, memoryCache } = "source" in option ? option : { source: option, memoryCache: undefined };
57
+ this.source = source;
58
+ this.cache = memoryCache === false ? null : new Map();
59
+ this.maxBytes = memoryCache === false ? 0 : (memoryCache?.maxBytes ?? DEFAULT_MEMORY_CACHE_MAX_BYTES);
60
+ this.maxFileBytes = memoryCache === false ? 0 : (memoryCache?.maxFileBytes ?? DEFAULT_MEMORY_CACHE_MAX_FILE_BYTES);
61
+ }
62
+ /**
63
+ * The file at a request or handler path (leading slash optional), mime
64
+ * type resolved; null when the path is invalid or the source has none.
65
+ */
66
+ async read(path) {
67
+ const relativePath = toRelativePath(path);
68
+ if (relativePath === null)
69
+ return null;
70
+ const cached = this.cache?.get(relativePath);
71
+ if (cached)
72
+ return cached;
73
+ const file = await this.source.read(relativePath);
74
+ if (!file)
75
+ return null;
76
+ const entry = {
77
+ body: file.body,
78
+ mimeType: file.mimeType || mimeTypeResolver.lookup(relativePath) || "application/octet-stream",
79
+ relativePath,
80
+ };
81
+ this.remember(entry);
82
+ return entry;
83
+ }
84
+ /**
85
+ * The compiled template for an HTML file, compiled once per instance.
86
+ * A missing file throws: it is a server-side configuration error, not a
87
+ * client 404.
88
+ */
89
+ async template(path, options = {}) {
90
+ const key = `${toRelativePath(path)}|${options.htmlVirtualSlots ? "v" : ""}`;
91
+ const cached = this.templates.get(key);
92
+ if (cached)
93
+ return cached;
94
+ const file = await this.read(path);
95
+ if (!file)
96
+ throw new Error(`templateFile: file not found in the files source: ${path}`);
97
+ const template = new LambderTemplatingEngine(file.body.toString("utf8"), { htmlVirtualSlots: options.htmlVirtualSlots });
98
+ this.templates.set(key, template);
99
+ return template;
100
+ }
101
+ /** Cache small files within the byte budget, evicting the oldest entries first. */
102
+ remember(entry) {
103
+ if (!this.cache || entry.body.length > this.maxFileBytes)
104
+ return;
105
+ for (const [key, value] of this.cache) {
106
+ if (this.cacheBytes + entry.body.length <= this.maxBytes)
107
+ break;
108
+ this.cache.delete(key);
109
+ this.cacheBytes -= value.body.length;
110
+ }
111
+ if (this.cacheBytes + entry.body.length <= this.maxBytes) {
112
+ this.cache.set(entry.relativePath, entry);
113
+ this.cacheBytes += entry.body.length;
114
+ }
115
+ }
116
+ }