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 +38 -16
- package/dist/client/LambderCaller.js +3 -0
- package/dist/core/Lambder.d.ts +15 -7
- package/dist/core/Lambder.js +14 -12
- package/dist/core/LambderContext.d.ts +9 -0
- package/dist/core/LambderContext.js +15 -5
- package/dist/core/LambderCookie.d.ts +44 -0
- package/dist/core/LambderCookie.js +28 -0
- package/dist/core/LambderFiles.d.ts +85 -0
- package/dist/core/LambderFiles.js +116 -0
- package/dist/core/LambderPublicFiles.d.ts +8 -48
- package/dist/core/LambderPublicFiles.js +9 -83
- package/dist/core/LambderResponseBuilder.d.ts +29 -13
- package/dist/core/LambderResponseBuilder.js +43 -54
- package/dist/core/LambderTemplatingEngine.d.ts +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.js +4 -1
- package/dist/session/LambderSessionController.d.ts +22 -13
- package/dist/session/LambderSessionController.js +44 -37
- package/dist/stores/LambderS3FileSource.d.ts +3 -3
- package/package.json +1 -1
|
@@ -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
|
-
/**
|
|
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
|
|
65
|
-
* memory-cached
|
|
66
|
-
* falls through to the route fallback when the
|
|
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
|
|
33
|
+
private files;
|
|
70
34
|
private options;
|
|
71
|
-
|
|
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
|
|
49
|
-
* memory-cached
|
|
50
|
-
* falls through to the route fallback when the
|
|
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
|
-
|
|
16
|
+
files;
|
|
54
17
|
options;
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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 {
|
|
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
|
|
29
|
+
protected files: LambderFiles | null;
|
|
28
30
|
protected apiVersion: string | null;
|
|
29
31
|
protected ctx?: LambderRenderContext;
|
|
30
|
-
constructor({
|
|
31
|
-
|
|
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
|
-
|
|
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
|
|
51
|
-
|
|
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
|
|
55
|
-
* (comment-based slots/conditionals) and return
|
|
56
|
-
* The compiled template is cached
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
|
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
|
-
|
|
4
|
+
files;
|
|
9
5
|
apiVersion;
|
|
10
6
|
ctx;
|
|
11
|
-
constructor({
|
|
12
|
-
this.
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
|
153
|
-
* (comment-based slots/conditionals) and return
|
|
154
|
-
* The compiled template is cached
|
|
155
|
-
*
|
|
156
|
-
*
|
|
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
|
|
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
|
|
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
|
|
19
|
-
export type { LambderPublicFilesOptions
|
|
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
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
36
|
-
|
|
37
|
-
this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value:
|
|
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
|
|
42
|
-
this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value:
|
|
43
|
-
this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value:
|
|
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
|
|
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
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
}
|