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.
@@ -1,28 +1,8 @@
1
1
  import type { LambderRenderContext } from "./LambderContext.js";
2
+ import type { LambderFiles } from "./LambderFiles.js";
2
3
  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
- }
4
+ /** Per-registration policy of servePublicFiles: how a request maps to a file and how the response is cached. */
23
5
  export type LambderPublicFilesOptions = {
24
- /** Where files come from. Default: LambderLocalFileSource over publicPath. */
25
- source?: LambderPublicFileSource;
26
6
  /**
27
7
  * Map the request to a file path (app-owned logic, e.g. per-tenant
28
8
  * roots: (ctx) => `${brand(ctx.host)}${ctx.path}`). Return
@@ -35,45 +15,25 @@ export type LambderPublicFilesOptions = {
35
15
  immutablePattern?: RegExp | false;
36
16
  /** Default: "public, max-age=31536000, immutable". */
37
17
  immutableCacheControl?: string;
38
- /** In-memory cache of files for warm invocations. Default: { maxBytes: 32MB, maxFileBytes: 2MB }. Set false to disable. */
39
- memoryCache?: false | {
40
- maxBytes?: number;
41
- maxFileBytes?: number;
42
- };
43
18
  /**
44
19
  * Compression per file: "auto" (default: compressible mime + size threshold),
45
20
  * true/false, or a function, e.g. (ctx) => /\.(css|js|svg)$/.test(ctx.path).
46
21
  */
47
22
  compress?: boolean | "auto" | ((ctx: LambderRenderContext) => boolean | "auto");
48
23
  };
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
- }
61
24
  /**
62
25
  * Terminal public-file handler registered via lambder.servePublicFiles().
63
26
  * Runs only when no route matched, so it can never shadow routes registered
64
- * after it. Serves files from its source (traversal-safe, mime-typed,
65
- * memory-cached, immutable-cache heuristic for content-hashed assets) and
66
- * falls through to the route fallback when the source has no such file.
27
+ * after it. Serves files through the instance's reader (traversal-safe,
28
+ * mime-typed, memory-cached) with the immutable-cache heuristic for
29
+ * content-hashed assets, and falls through to the route fallback when the
30
+ * source has no such file.
67
31
  */
68
32
  export declare class LambderPublicFilesHandler {
69
- private source;
33
+ private files;
70
34
  private options;
71
- private fileCache;
72
- private fileCacheBytes;
73
- constructor(source: LambderPublicFileSource, options: LambderPublicFilesOptions);
35
+ constructor(files: LambderFiles, options: LambderPublicFilesOptions);
74
36
  /** Serve the mapped file, or return null to fall through. */
75
37
  handle(ctx: LambderRenderContext): Promise<LambderResponse | null>;
76
- /** Read from the source, caching small files in memory for warm invocations. */
77
- private readCached;
78
38
  private cacheControlFor;
79
39
  }
@@ -1,61 +1,22 @@
1
- import mimeTypeResolver from "mime-types";
2
- import { getFS, getPath } from "../shared/node-polyfills.js";
3
1
  import { LambderResponse } from "./LambderResponse.js";
4
2
  // Content-hashed build outputs (Vite/webpack/Rollup): a [-.] separated run of
5
3
  // 8+ hash chars containing at least one digit, before the extension.
6
4
  const DEFAULT_IMMUTABLE_PATTERN = /[-.](?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+$/;
7
5
  const DEFAULT_IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
8
6
  const DEFAULT_CACHE_CONTROL = "public, max-age=3600";
9
- const DEFAULT_MEMORY_CACHE_MAX_BYTES = 32 * 1024 * 1024;
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
- };
45
7
  /**
46
8
  * Terminal public-file handler registered via lambder.servePublicFiles().
47
9
  * Runs only when no route matched, so it can never shadow routes registered
48
- * after it. Serves files from its source (traversal-safe, mime-typed,
49
- * memory-cached, immutable-cache heuristic for content-hashed assets) and
50
- * falls through to the route fallback when the source has no such file.
10
+ * after it. Serves files through the instance's reader (traversal-safe,
11
+ * mime-typed, memory-cached) with the immutable-cache heuristic for
12
+ * content-hashed assets, and falls through to the route fallback when the
13
+ * source has no such file.
51
14
  */
52
15
  export class LambderPublicFilesHandler {
53
- source;
16
+ files;
54
17
  options;
55
- fileCache = new Map();
56
- fileCacheBytes = 0;
57
- constructor(source, options) {
58
- this.source = source;
18
+ constructor(files, options) {
19
+ this.files = files;
59
20
  this.options = options;
60
21
  }
61
22
  /** Serve the mapped file, or return null to fall through. */
@@ -63,10 +24,7 @@ export class LambderPublicFilesHandler {
63
24
  const mappedPath = this.options.path ? this.options.path(ctx) : ctx.path;
64
25
  if (!mappedPath)
65
26
  return null;
66
- const relativePath = toRelativePath(mappedPath);
67
- if (relativePath === null)
68
- return null;
69
- const file = await this.readCached(relativePath);
27
+ const file = await this.files.read(mappedPath);
70
28
  if (!file)
71
29
  return null;
72
30
  const compressOption = this.options.compress;
@@ -75,44 +33,12 @@ export class LambderPublicFilesHandler {
75
33
  statusCode: 200,
76
34
  headers: {
77
35
  "Content-Type": file.mimeType,
78
- "Cache-Control": this.cacheControlFor(ctx, relativePath),
36
+ "Cache-Control": this.cacheControlFor(ctx, file.relativePath),
79
37
  },
80
38
  body: file.body,
81
39
  compress,
82
40
  });
83
41
  }
84
- /** Read from the source, caching small files in memory for warm invocations. */
85
- async readCached(relativePath) {
86
- const cached = this.fileCache.get(relativePath);
87
- if (cached)
88
- return cached;
89
- const file = await this.source.read(relativePath);
90
- if (!file)
91
- return null;
92
- const entry = {
93
- body: file.body,
94
- mimeType: file.mimeType || mimeTypeResolver.lookup(relativePath) || "application/octet-stream",
95
- };
96
- const cacheConfig = this.options.memoryCache;
97
- if (cacheConfig !== false) {
98
- const maxBytes = cacheConfig?.maxBytes ?? DEFAULT_MEMORY_CACHE_MAX_BYTES;
99
- const maxFileBytes = cacheConfig?.maxFileBytes ?? DEFAULT_MEMORY_CACHE_MAX_FILE_BYTES;
100
- if (entry.body.length <= maxFileBytes) {
101
- // Evict oldest entries until the new file fits the budget.
102
- for (const [key, value] of this.fileCache) {
103
- if (this.fileCacheBytes + entry.body.length <= maxBytes)
104
- break;
105
- this.fileCache.delete(key);
106
- this.fileCacheBytes -= value.body.length;
107
- }
108
- if (this.fileCacheBytes + entry.body.length <= maxBytes) {
109
- this.fileCache.set(relativePath, entry);
110
- this.fileCacheBytes += entry.body.length;
111
- }
112
- }
113
- }
114
- return entry;
115
- }
116
42
  cacheControlFor(ctx, relativePath) {
117
43
  const cacheOption = this.options.cacheControl;
118
44
  if (typeof cacheOption === "function")
@@ -1,7 +1,9 @@
1
1
  import type { LambderRenderContext } from "./LambderContext.js";
2
+ import { type LambderCookieOptions, type LambderClearCookieOptions } from "./LambderCookie.js";
3
+ import type { LambderFiles } from "./LambderFiles.js";
2
4
  import { LambderResponse, type HttpStatusCode, type LambderHeadersInput } from "./LambderResponse.js";
3
5
  import { LambderSafeHtml } from "../shared/LambderHtml.js";
4
- import { type LambderTemplateData } from "./LambderTemplatingEngine.js";
6
+ import type { LambderTemplateData } from "./LambderTemplatingEngine.js";
5
7
  import type { LambderApiResponseConfig } from "../shared/LambderApiContract.js";
6
8
  export type { LambderApiResponse, LambderApiResponseConfig } from "../shared/LambderApiContract.js";
7
9
  export type LambderResponseOptions = {
@@ -24,18 +26,32 @@ export type LambderRawResponseInit = {
24
26
  etag?: boolean | "auto";
25
27
  };
26
28
  export default class LambderResponseBuilder<TResponse = any> {
27
- protected publicPath: string;
29
+ protected files: LambderFiles | null;
28
30
  protected apiVersion: string | null;
29
31
  protected ctx?: LambderRenderContext;
30
- constructor({ publicPath, apiVersion, ctx }: {
31
- publicPath: string;
32
+ constructor({ files, apiVersion, ctx }: {
33
+ files?: LambderFiles | null;
32
34
  apiVersion?: string | null;
33
35
  ctx?: LambderRenderContext;
34
36
  });
35
37
  private buildResponse;
36
- private resolvePublicFilePath;
38
+ /** The instance's file reader, which res.file and res.templateFile need. */
39
+ private requireFiles;
37
40
  addHeader(key: string, value: string): void;
38
41
  setHeader(key: string, value: string | string[]): void;
42
+ /**
43
+ * Adds a Set-Cookie header. A function-form `domain` is resolved against
44
+ * the request hostname. Defaults: Path=/, SameSite=Lax, Secure, not
45
+ * HttpOnly, browser-session lifetime.
46
+ */
47
+ setCookie(name: string, value: string, options?: LambderCookieOptions): void;
48
+ /**
49
+ * Adds a Set-Cookie header that deletes the cookie. Pass the same
50
+ * `domain` and `path` the cookie was set with: a cookie's identity is
51
+ * (name, domain, path), and a deletion under a different scope targets a
52
+ * different cookie and deletes nothing.
53
+ */
54
+ clearCookie(name: string, options?: LambderClearCookieOptions): void;
39
55
  logToApiResponse(input: any): void;
40
56
  raw(init: LambderRawResponseInit): LambderResponse;
41
57
  json(data: Record<string, any>, options?: LambderResponseOptions): LambderResponse;
@@ -47,15 +63,15 @@ export default class LambderResponseBuilder<TResponse = any> {
47
63
  redirect(url: string, statusCode?: HttpStatusCode, options?: LambderResponseOptions): LambderResponse;
48
64
  versionExpired(options?: LambderResponseOptions): LambderResponse;
49
65
  fileBase64(fileBase64: string, mimeType: string, options?: LambderResponseOptions): LambderResponse;
50
- file(filePath: string, options?: LambderResponseOptions & {
51
- fallback?: string;
52
- }): Promise<LambderResponse>;
66
+ /** A file from the files source as a response; 404 when there is none. */
67
+ file(filePath: string, options?: LambderResponseOptions): Promise<LambderResponse>;
53
68
  /**
54
- * Render an HTML file under publicPath through LambderTemplatingEngine
55
- * (comment-based slots/conditionals) and return it as an HTML response.
56
- * The compiled template is cached across warm invocations; a missing file
57
- * throws (it is a server-side configuration error, not a client 404).
58
- * Set htmlVirtualSlots to expose "title"/"head" slots on marker-less files.
69
+ * Render an HTML file from the files source through
70
+ * LambderTemplatingEngine (comment-based slots/conditionals) and return
71
+ * it as an HTML response. The compiled template is cached on the
72
+ * instance across warm invocations; a missing file throws (it is a
73
+ * server-side configuration error, not a client 404). Set
74
+ * htmlVirtualSlots to expose "title"/"head" slots on marker-less files.
59
75
  */
60
76
  templateFile(filePath: string, data?: LambderTemplateData, options?: LambderResponseOptions & {
61
77
  htmlVirtualSlots?: boolean;
@@ -1,15 +1,11 @@
1
- import mimeTypeResolver from "mime-types";
2
- import { getFS, getPath } from "../shared/node-polyfills.js";
1
+ import { serializeCookie, serializeClearCookie } from "./LambderCookie.js";
3
2
  import { LambderResponse } from "./LambderResponse.js";
4
- import { LambderTemplatingEngine } from "./LambderTemplatingEngine.js";
5
- // Compiled templates survive across requests (builder instances are per-request).
6
- const templateFileCache = new Map();
7
3
  export default class LambderResponseBuilder {
8
- publicPath;
4
+ files;
9
5
  apiVersion;
10
6
  ctx;
11
- constructor({ publicPath, apiVersion, ctx }) {
12
- this.publicPath = publicPath;
7
+ constructor({ files, apiVersion, ctx }) {
8
+ this.files = files ?? null;
13
9
  this.apiVersion = apiVersion ?? null;
14
10
  this.ctx = ctx;
15
11
  }
@@ -30,25 +26,12 @@ export default class LambderResponseBuilder {
30
26
  response.setHeader("Cache-Control", options.cacheControl);
31
27
  return response;
32
28
  }
33
- async resolvePublicFilePath(filePath) {
34
- const fs = await getFS();
35
- const path = await getPath();
36
- if (!fs || !path)
37
- return null;
38
- const publicPath = path.resolve(this.publicPath);
39
- const normalizedFilePath = filePath.startsWith('/') ? filePath.slice(1) : filePath;
40
- const absolutePath = path.resolve(publicPath, normalizedFilePath);
41
- if (absolutePath !== publicPath && !absolutePath.startsWith(publicPath + path.sep))
42
- return null;
43
- try {
44
- const stat = await fs.promises.stat(absolutePath);
45
- return stat.isFile() ? absolutePath : null;
46
- }
47
- catch {
48
- return null;
49
- }
29
+ /** The instance's file reader, which res.file and res.templateFile need. */
30
+ requireFiles(method) {
31
+ if (!this.files)
32
+ throw new Error(`${method} requires the files option at creation (e.g. files: new LambderLocalFileSource({ root }))`);
33
+ return this.files;
50
34
  }
51
- ;
52
35
  addHeader(key, value) {
53
36
  if (!this.ctx)
54
37
  throw new Error(".addHeader function is not available within this hook");
@@ -63,6 +46,29 @@ export default class LambderResponseBuilder {
63
46
  this.ctx._otherInternal.setHeaderFnAccumulator.push({ key, value });
64
47
  }
65
48
  ;
49
+ /**
50
+ * Adds a Set-Cookie header. A function-form `domain` is resolved against
51
+ * the request hostname. Defaults: Path=/, SameSite=Lax, Secure, not
52
+ * HttpOnly, browser-session lifetime.
53
+ */
54
+ setCookie(name, value, options) {
55
+ if (!this.ctx)
56
+ throw new Error(".setCookie function is not available within this hook");
57
+ this.addHeader("Set-Cookie", serializeCookie(name, value, options, this.ctx.host));
58
+ }
59
+ ;
60
+ /**
61
+ * Adds a Set-Cookie header that deletes the cookie. Pass the same
62
+ * `domain` and `path` the cookie was set with: a cookie's identity is
63
+ * (name, domain, path), and a deletion under a different scope targets a
64
+ * different cookie and deletes nothing.
65
+ */
66
+ clearCookie(name, options) {
67
+ if (!this.ctx)
68
+ throw new Error(".clearCookie function is not available within this hook");
69
+ this.addHeader("Set-Cookie", serializeClearCookie(name, options, this.ctx.host));
70
+ }
71
+ ;
66
72
  logToApiResponse(input) {
67
73
  if (!this.ctx)
68
74
  throw new Error(".logToApiResponse function is not available within this hook");
@@ -130,41 +136,24 @@ export default class LambderResponseBuilder {
130
136
  return response;
131
137
  }
132
138
  ;
139
+ /** A file from the files source as a response; 404 when there is none. */
133
140
  async file(filePath, options) {
134
- let resolvedPath = await this.resolvePublicFilePath(filePath);
135
- let effectivePath = filePath;
136
- if (!resolvedPath && options?.fallback) {
137
- resolvedPath = await this.resolvePublicFilePath(options.fallback);
138
- effectivePath = options.fallback;
139
- }
140
- if (!resolvedPath) {
141
- return this.status404("File not found", { etag: false });
142
- }
143
- const fs = await getFS();
144
- if (!fs)
141
+ const file = await this.requireFiles("res.file").read(filePath);
142
+ if (!file)
145
143
  return this.status404("File not found", { etag: false });
146
- const body = await fs.promises.readFile(resolvedPath);
147
- const mimeType = mimeTypeResolver.lookup(effectivePath) || "application/octet-stream";
148
- return this.buildResponse(200, mimeType, body, options);
144
+ return this.buildResponse(200, file.mimeType, file.body, options);
149
145
  }
150
146
  ;
151
147
  /**
152
- * Render an HTML file under publicPath through LambderTemplatingEngine
153
- * (comment-based slots/conditionals) and return it as an HTML response.
154
- * The compiled template is cached across warm invocations; a missing file
155
- * throws (it is a server-side configuration error, not a client 404).
156
- * Set htmlVirtualSlots to expose "title"/"head" slots on marker-less files.
148
+ * Render an HTML file from the files source through
149
+ * LambderTemplatingEngine (comment-based slots/conditionals) and return
150
+ * it as an HTML response. The compiled template is cached on the
151
+ * instance across warm invocations; a missing file throws (it is a
152
+ * server-side configuration error, not a client 404). Set
153
+ * htmlVirtualSlots to expose "title"/"head" slots on marker-less files.
157
154
  */
158
155
  async templateFile(filePath, data, options) {
159
- const resolvedPath = await this.resolvePublicFilePath(filePath);
160
- if (!resolvedPath)
161
- throw new Error(`templateFile: file not found under publicPath: ${filePath}`);
162
- const cacheKey = `${resolvedPath}|${options?.htmlVirtualSlots ? "v" : ""}`;
163
- let template = templateFileCache.get(cacheKey);
164
- if (!template) {
165
- template = await LambderTemplatingEngine.fromFile(resolvedPath, { htmlVirtualSlots: options?.htmlVirtualSlots });
166
- templateFileCache.set(cacheKey, template);
167
- }
156
+ const template = await this.requireFiles("res.templateFile").template(filePath, { htmlVirtualSlots: options?.htmlVirtualSlots });
168
157
  return this.buildResponse(200, "text/html; charset=utf-8", template.render(data), options);
169
158
  }
170
159
  ;
@@ -4,7 +4,7 @@ import { type LambderHtmlValue } from "../shared/LambderHtml.js";
4
4
  *
5
5
  * Fully standalone: it has no dependency on Lambder routing or file serving,
6
6
  * and can template any HTML: app shells, emails, error pages. res.templateFile
7
- * uses it internally to render HTML files from publicPath per request.
7
+ * uses it internally to render HTML files from the files source per request.
8
8
  *
9
9
  * Every construct is an HTML comment. That is the whole point: templates
10
10
  * survive HTML build pipelines (e.g. Vite) untouched, and are invisible in the
package/dist/index.d.ts CHANGED
@@ -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, LambderLocalFileSource } from "./core/LambderPublicFiles.js";
19
- export type { LambderPublicFilesOptions, LambderPublicFileSource, LambderPublicFile } from "./core/LambderPublicFiles.js";
18
+ export { LambderPublicFilesHandler } from "./core/LambderPublicFiles.js";
19
+ export type { LambderPublicFilesOptions } from "./core/LambderPublicFiles.js";
20
+ export { LambderFiles, LambderLocalFileSource } from "./core/LambderFiles.js";
21
+ export type { LambderFileSource, LambderFile, LambderFilesOption, LambderFileMemoryCacheOption, LambderReadFile } from "./core/LambderFiles.js";
20
22
  export { LambderS3FileSource } from "./stores/LambderS3FileSource.js";
21
23
  export type { LambderS3FileSourceOptions } from "./stores/LambderS3FileSource.js";
22
24
  export type { LambderSessionCookieOptions } from "./session/LambderSessionController.js";
@@ -39,3 +41,5 @@ export type { LambderLanguageMeta, LambderI18nConfig, LambderI18nInstance, Lambd
39
41
  export { type ApiContractShape, type LambderApiResponse, type LambderApiResponseConfig, } from "./shared/LambderApiContract.js";
40
42
  export type { LambderRenderContext, LambderSessionRenderContext, LambderHttpEvent } from "./core/LambderContext.js";
41
43
  export { createContext, isV2HttpEvent } from "./core/LambderContext.js";
44
+ export { serializeCookie, serializeClearCookie, resolveCookieDomain } from "./core/LambderCookie.js";
45
+ export type { LambderCookieOptions, LambderClearCookieOptions, LambderCookieDomain } from "./core/LambderCookie.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, LambderLocalFileSource } from "./core/LambderPublicFiles.js";
18
+ export { LambderPublicFilesHandler } from "./core/LambderPublicFiles.js";
19
+ export { LambderFiles, LambderLocalFileSource } from "./core/LambderFiles.js";
19
20
  export { LambderS3FileSource } from "./stores/LambderS3FileSource.js";
20
21
  export { LambderSessionDataRefreshError, LambderSessionReadError } from "./session/LambderSessionManager.js";
21
22
  // DynamoDB-backed compressed cache (standalone, server-only)
@@ -31,3 +32,5 @@ export { lambderRateLimitKey } from "./policies/LambderApiRateLimits.js";
31
32
  // Typed translations (standalone, isomorphic)
32
33
  export { createLambderI18n } from "./shared/LambderI18n.js";
33
34
  export { createContext, isV2HttpEvent } from "./core/LambderContext.js";
35
+ // Cookies (res.setCookie / res.clearCookie build on these; exported for code holding a LambderResponse)
36
+ export { serializeCookie, serializeClearCookie, resolveCookieDomain } from "./core/LambderCookie.js";
@@ -1,17 +1,18 @@
1
1
  import { LambderRenderContext, LambderSessionRenderContext } from "../core/LambderContext.js";
2
+ import { type LambderCookieOptions } from "../core/LambderCookie.js";
2
3
  import type LambderSessionManager from "./LambderSessionManager.js";
3
4
  import { type LambderSessionContext } from "./LambderSessionManager.js";
4
- export type LambderSessionCookieOptions = {
5
- /**
6
- * e.g. ".example.com" to share sessions across subdomains. Pass a function to
7
- * derive it from the request hostname when one deployment serves several
8
- * apex domains; return undefined for a host-only cookie.
9
- */
10
- domain?: string | ((hostname: string) => string | undefined | null);
11
- path?: string;
12
- sameSite?: "Strict" | "Lax" | "None";
13
- secure?: boolean;
14
- };
5
+ /**
6
+ * Scope of the session cookies. `domain` is e.g. ".example.com" to share
7
+ * sessions across subdomains, or a function of the request hostname when
8
+ * one deployment serves several apex domains (return undefined for a
9
+ * host-only cookie). Changing `domain` or `path` on a live deployment is a
10
+ * migration: browsers keep the cookie under the old scope beside the new
11
+ * one, and both arrive on every request. fetchSession tolerates that by
12
+ * trying every copy and evicting the stale host-only twin; a copy at a
13
+ * parent domain this host cannot name outlives its own Expires.
14
+ */
15
+ export type LambderSessionCookieOptions = Pick<LambderCookieOptions, "domain" | "path" | "sameSite" | "secure">;
15
16
  export default class LambderSessionController<TSessionData = any> {
16
17
  lambderSessionManager: LambderSessionManager;
17
18
  sessionTokenCookieKey: string;
@@ -25,16 +26,24 @@ export default class LambderSessionController<TSessionData = any> {
25
26
  cookieOptions?: LambderSessionCookieOptions;
26
27
  ctx: LambderRenderContext<any> | LambderSessionRenderContext<any, TSessionData>;
27
28
  });
28
- private buildCookie;
29
+ /** The configured scope with the domain resolved for this request, or the host-only scope. */
30
+ private cookieScope;
29
31
  /** Raw secrets exist only on the LambderCreatedSession result and in these cookies; the record stores hashes. */
30
32
  private setSessionCookies;
31
33
  private clearSessionCookies;
34
+ /**
35
+ * Every well-formed value the request carried under the session cookie
36
+ * name. More than one means the browser holds the cookie at several
37
+ * scopes, and the order says nothing about which copy is current.
38
+ */
39
+ private sessionTokenCandidates;
32
40
  private areRequestSessionTokensValid;
33
41
  createSession(sessionKey: string, data?: TSessionData, ttlInSeconds?: number): Promise<LambderSessionContext<TSessionData>>;
34
42
  regenerateSession(): Promise<LambderSessionContext<TSessionData>>;
35
43
  fetchSession(): Promise<LambderSessionContext<TSessionData>>;
36
44
  fetchSessionIfExists(): Promise<LambderSessionContext<TSessionData> | null>;
37
- isSessionValid(session: any): boolean;
45
+ /** Checks the record against a presented token (the request's first session cookie by default) and, on API calls, the posted CSRF token. */
46
+ isSessionValid(session: any, sessionToken?: string | undefined): boolean;
38
47
  updateSessionData(newData: any): Promise<LambderSessionContext>;
39
48
  /**
40
49
  * Force-runs the dataRefresh callback now (see the session option of create) and
@@ -1,4 +1,7 @@
1
+ import { resolveCookieDomain, serializeCookie, serializeClearCookie } from "../core/LambderCookie.js";
1
2
  import { LambderSessionDataRefreshError, LambderSessionReadError } from "./LambderSessionManager.js";
3
+ /** The tokens are hex, so the cookie carries them as they are (the format existing browsers hold). */
4
+ const rawValue = (value) => value;
2
5
  export default class LambderSessionController {
3
6
  lambderSessionManager;
4
7
  sessionTokenCookieKey;
@@ -13,39 +16,37 @@ export default class LambderSessionController {
13
16
  this.ctx = ctx;
14
17
  }
15
18
  ;
16
- buildCookie(key, value, expiresAtMs, httpOnly) {
17
- const { domain, path = "/", sameSite = "Lax", secure = true } = this.cookieOptions;
18
- // Host header can carry a port; browsers match the Domain attribute on hostname only.
19
- const hostname = (this.ctx.host || "").split(":")[0];
20
- const resolvedDomain = typeof domain === "function" ? domain(hostname) : domain;
21
- const parts = [
22
- `${key}=${value}`,
23
- `Expires=${new Date(expiresAtMs).toUTCString()}`,
24
- `Path=${path}`,
25
- ...(resolvedDomain ? [`Domain=${resolvedDomain}`] : []),
26
- ...(httpOnly ? ["HttpOnly"] : []),
27
- `SameSite=${sameSite}`,
28
- ...(secure ? ["Secure"] : []),
29
- ];
30
- return parts.join("; ");
19
+ /** The configured scope with the domain resolved for this request, or the host-only scope. */
20
+ cookieScope(hostOnly = false) {
21
+ const { domain, path, sameSite, secure } = this.cookieOptions;
22
+ return { domain: hostOnly ? undefined : resolveCookieDomain(domain, this.ctx.host), path, sameSite, secure };
31
23
  }
32
24
  ;
33
25
  /** Raw secrets exist only on the LambderCreatedSession result and in these cookies; the record stores hashes. */
34
26
  setSessionCookies(created) {
35
- const expiresAtMs = created.session.expiresAt * 1000;
36
- this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: this.buildCookie(this.sessionTokenCookieKey, created.sessionToken, expiresAtMs, true) });
37
- this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: this.buildCookie(this.sessionCsrfCookieKey, created.csrfToken, expiresAtMs, false) });
27
+ const scope = this.cookieScope();
28
+ const expires = new Date(created.session.expiresAt * 1000);
29
+ this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: serializeCookie(this.sessionTokenCookieKey, created.sessionToken, { ...scope, expires, httpOnly: true, encode: rawValue }) });
30
+ this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: serializeCookie(this.sessionCsrfCookieKey, created.csrfToken, { ...scope, expires, encode: rawValue }) });
38
31
  }
39
32
  ;
40
- clearSessionCookies() {
41
- const expired = Date.now() - 100000;
42
- this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: this.buildCookie(this.sessionTokenCookieKey, "0", expired, true) });
43
- this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: this.buildCookie(this.sessionCsrfCookieKey, "0", expired, false) });
33
+ clearSessionCookies(hostOnly = false) {
34
+ const scope = this.cookieScope(hostOnly);
35
+ this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: serializeClearCookie(this.sessionTokenCookieKey, { ...scope, httpOnly: true }) });
36
+ this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: serializeClearCookie(this.sessionCsrfCookieKey, scope) });
37
+ }
38
+ ;
39
+ /**
40
+ * Every well-formed value the request carried under the session cookie
41
+ * name. More than one means the browser holds the cookie at several
42
+ * scopes, and the order says nothing about which copy is current.
43
+ */
44
+ sessionTokenCandidates() {
45
+ return (this.ctx.cookieList?.[this.sessionTokenCookieKey] ?? []).filter((token) => token.split(":").length === 2);
44
46
  }
45
47
  ;
46
48
  areRequestSessionTokensValid() {
47
- const sessionToken = this.ctx.cookie?.[this.sessionTokenCookieKey];
48
- const isSessionTokenValid = !!sessionToken && sessionToken.split(":").length === 2;
49
+ const isSessionTokenValid = this.sessionTokenCandidates().length > 0;
49
50
  if (this.ctx._otherInternal.isApiCall) {
50
51
  const csrfToken = this.ctx.post?.token;
51
52
  const isCsrfTokenValid = typeof csrfToken === "string" && csrfToken.length > 0;
@@ -76,16 +77,23 @@ export default class LambderSessionController {
76
77
  if (!this.areRequestSessionTokensValid()) {
77
78
  throw new Error("Session tokens are invalid");
78
79
  }
79
- const sessionToken = this.ctx.cookie?.[this.sessionTokenCookieKey];
80
- if (!sessionToken)
81
- throw new Error("Session token not found");
82
- const session = await this.lambderSessionManager.getSession(sessionToken);
83
- if (!session)
84
- throw new Error("Session not found");
85
- if (!this.isSessionValid(session))
86
- throw new Error("Invalid session");
87
- this.ctx.session = session;
88
- return session;
80
+ const candidates = this.sessionTokenCandidates();
81
+ if (candidates.length > 1) {
82
+ console.warn(`Lambder session: ${candidates.length} "${this.sessionTokenCookieKey}" cookies arrived from ${this.ctx.host}; the browser holds the cookie at several scopes and a stale copy may shadow the live one. Trying each.`);
83
+ }
84
+ for (const sessionToken of candidates) {
85
+ const session = await this.lambderSessionManager.getSession(sessionToken);
86
+ if (!session || !this.isSessionValid(session, sessionToken))
87
+ continue;
88
+ // The other copies are stale. This response can evict the
89
+ // host-only twin of a Domain= cookie; a copy at a parent domain
90
+ // this host cannot name is out of reach and expires on its own.
91
+ if (candidates.length > 1 && this.cookieScope().domain)
92
+ this.clearSessionCookies(true);
93
+ this.ctx.session = session;
94
+ return session;
95
+ }
96
+ throw new Error("Session not found");
89
97
  }
90
98
  ;
91
99
  async fetchSessionIfExists() {
@@ -104,14 +112,13 @@ export default class LambderSessionController {
104
112
  }
105
113
  }
106
114
  ;
107
- isSessionValid(session) {
115
+ /** Checks the record against a presented token (the request's first session cookie by default) and, on API calls, the posted CSRF token. */
116
+ isSessionValid(session, sessionToken = this.ctx.cookie?.[this.sessionTokenCookieKey]) {
108
117
  if (this.ctx._otherInternal.isApiCall) {
109
- const sessionToken = this.ctx.cookie?.[this.sessionTokenCookieKey];
110
118
  const csrfToken = this.ctx.post?.token;
111
119
  return this.lambderSessionManager.isSessionValid(session, sessionToken, csrfToken);
112
120
  }
113
121
  else {
114
- const sessionToken = this.ctx.cookie?.[this.sessionTokenCookieKey];
115
122
  return this.lambderSessionManager.isSessionValid(session, sessionToken, null, true);
116
123
  }
117
124
  }