lambder 4.3.2 → 4.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Readme.md +51 -7
- package/dist/client/LambderCaller.d.ts +75 -27
- package/dist/client/LambderCaller.js +18 -2
- package/dist/client.d.ts +1 -1
- package/dist/core/Lambder.d.ts +19 -9
- package/dist/core/Lambder.js +17 -12
- package/dist/core/LambderFiles.d.ts +85 -0
- package/dist/core/LambderFiles.js +116 -0
- package/dist/core/LambderPublicFiles.d.ts +12 -20
- package/dist/core/LambderPublicFiles.js +12 -66
- package/dist/core/LambderResponseBuilder.d.ts +15 -13
- package/dist/core/LambderResponseBuilder.js +19 -54
- package/dist/core/LambderTemplatingEngine.d.ts +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +2 -0
- package/dist/session/LambderSessionController.d.ts +7 -0
- package/dist/session/LambderSessionController.js +10 -0
- package/dist/session/LambderSessionManager.d.ts +11 -0
- package/dist/session/LambderSessionManager.js +38 -2
- package/dist/stores/LambderS3FileSource.d.ts +35 -0
- package/dist/stores/LambderS3FileSource.js +53 -0
- package/package.json +3 -1
|
@@ -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
|
+
}
|
|
@@ -1,23 +1,20 @@
|
|
|
1
1
|
import type { LambderRenderContext } from "./LambderContext.js";
|
|
2
|
+
import type { LambderFiles } from "./LambderFiles.js";
|
|
2
3
|
import { LambderResponse } from "./LambderResponse.js";
|
|
4
|
+
/** Per-registration policy of servePublicFiles: how a request maps to a file and how the response is cached. */
|
|
3
5
|
export type LambderPublicFilesOptions = {
|
|
4
6
|
/**
|
|
5
|
-
* Map the request to a file path
|
|
6
|
-
*
|
|
7
|
+
* Map the request to a file path (app-owned logic, e.g. per-tenant
|
|
8
|
+
* roots: (ctx) => `${brand(ctx.host)}${ctx.path}`). Return
|
|
7
9
|
* null/undefined to skip. Default: (ctx) => ctx.path.
|
|
8
10
|
*/
|
|
9
11
|
path?: (ctx: LambderRenderContext) => string | null | undefined;
|
|
10
|
-
/** Cache-Control for served files. Default: "public, max-age=3600". */
|
|
11
|
-
cacheControl?: string | ((ctx: LambderRenderContext,
|
|
12
|
+
/** Cache-Control for served files; the function receives the relative file path. Default: "public, max-age=3600". */
|
|
13
|
+
cacheControl?: string | ((ctx: LambderRenderContext, relativePath: string) => string);
|
|
12
14
|
/** Filenames matching this get immutableCacheControl. Default: content-hash heuristic. Set false to disable. */
|
|
13
15
|
immutablePattern?: RegExp | false;
|
|
14
16
|
/** Default: "public, max-age=31536000, immutable". */
|
|
15
17
|
immutableCacheControl?: string;
|
|
16
|
-
/** In-memory cache of files for warm invocations. Default: { maxBytes: 32MB, maxFileBytes: 2MB }. Set false to disable. */
|
|
17
|
-
memoryCache?: false | {
|
|
18
|
-
maxBytes?: number;
|
|
19
|
-
maxFileBytes?: number;
|
|
20
|
-
};
|
|
21
18
|
/**
|
|
22
19
|
* Compression per file: "auto" (default: compressible mime + size threshold),
|
|
23
20
|
* true/false, or a function, e.g. (ctx) => /\.(css|js|svg)$/.test(ctx.path).
|
|
@@ -27,21 +24,16 @@ export type LambderPublicFilesOptions = {
|
|
|
27
24
|
/**
|
|
28
25
|
* Terminal public-file handler registered via lambder.servePublicFiles().
|
|
29
26
|
* Runs only when no route matched, so it can never shadow routes registered
|
|
30
|
-
* after it. Serves
|
|
31
|
-
* memory-cached
|
|
32
|
-
* 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.
|
|
33
31
|
*/
|
|
34
32
|
export declare class LambderPublicFilesHandler {
|
|
35
|
-
private
|
|
33
|
+
private files;
|
|
36
34
|
private options;
|
|
37
|
-
|
|
38
|
-
private fileCacheBytes;
|
|
39
|
-
constructor(publicPath: string, options: LambderPublicFilesOptions);
|
|
35
|
+
constructor(files: LambderFiles, options: LambderPublicFilesOptions);
|
|
40
36
|
/** Serve the mapped file, or return null to fall through. */
|
|
41
37
|
handle(ctx: LambderRenderContext): Promise<LambderResponse | null>;
|
|
42
|
-
/** Join base+target and require the result to stay under base. */
|
|
43
|
-
private resolveSafe;
|
|
44
|
-
/** Read a file, caching small files in memory for warm invocations. */
|
|
45
|
-
private readFileCached;
|
|
46
38
|
private cacheControlFor;
|
|
47
39
|
}
|
|
@@ -1,43 +1,30 @@
|
|
|
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
7
|
/**
|
|
12
8
|
* Terminal public-file handler registered via lambder.servePublicFiles().
|
|
13
9
|
* Runs only when no route matched, so it can never shadow routes registered
|
|
14
|
-
* after it. Serves
|
|
15
|
-
* memory-cached
|
|
16
|
-
* 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.
|
|
17
14
|
*/
|
|
18
15
|
export class LambderPublicFilesHandler {
|
|
19
|
-
|
|
16
|
+
files;
|
|
20
17
|
options;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
constructor(publicPath, options) {
|
|
24
|
-
this.publicPath = publicPath;
|
|
18
|
+
constructor(files, options) {
|
|
19
|
+
this.files = files;
|
|
25
20
|
this.options = options;
|
|
26
21
|
}
|
|
27
22
|
/** Serve the mapped file, or return null to fall through. */
|
|
28
23
|
async handle(ctx) {
|
|
29
|
-
const fs = await getFS();
|
|
30
|
-
const path = await getPath();
|
|
31
|
-
if (!fs || !path)
|
|
32
|
-
throw new Error("servePublicFiles requires a Node.js environment.");
|
|
33
24
|
const mappedPath = this.options.path ? this.options.path(ctx) : ctx.path;
|
|
34
25
|
if (!mappedPath)
|
|
35
26
|
return null;
|
|
36
|
-
const
|
|
37
|
-
const filePath = this.resolveSafe(path, publicRoot, mappedPath);
|
|
38
|
-
if (!filePath)
|
|
39
|
-
return null;
|
|
40
|
-
const file = await this.readFileCached(fs, filePath);
|
|
27
|
+
const file = await this.files.read(mappedPath);
|
|
41
28
|
if (!file)
|
|
42
29
|
return null;
|
|
43
30
|
const compressOption = this.options.compress;
|
|
@@ -46,61 +33,20 @@ export class LambderPublicFilesHandler {
|
|
|
46
33
|
statusCode: 200,
|
|
47
34
|
headers: {
|
|
48
35
|
"Content-Type": file.mimeType,
|
|
49
|
-
"Cache-Control": this.cacheControlFor(ctx,
|
|
36
|
+
"Cache-Control": this.cacheControlFor(ctx, file.relativePath),
|
|
50
37
|
},
|
|
51
38
|
body: file.body,
|
|
52
39
|
compress,
|
|
53
40
|
});
|
|
54
41
|
}
|
|
55
|
-
|
|
56
|
-
resolveSafe(path, base, target) {
|
|
57
|
-
if (target.split("/").some((segment) => segment === ".."))
|
|
58
|
-
return null;
|
|
59
|
-
const normalizedTarget = target.startsWith("/") ? target.slice(1) : target;
|
|
60
|
-
const absolute = path.resolve(base, normalizedTarget);
|
|
61
|
-
if (absolute !== base && !absolute.startsWith(base + path.sep))
|
|
62
|
-
return null;
|
|
63
|
-
return absolute;
|
|
64
|
-
}
|
|
65
|
-
/** Read a file, caching small files in memory for warm invocations. */
|
|
66
|
-
async readFileCached(fs, filePath) {
|
|
67
|
-
const cached = this.fileCache.get(filePath);
|
|
68
|
-
if (cached)
|
|
69
|
-
return cached;
|
|
70
|
-
const stat = await fs.promises.stat(filePath).catch(() => null);
|
|
71
|
-
if (!stat?.isFile())
|
|
72
|
-
return null;
|
|
73
|
-
const body = await fs.promises.readFile(filePath);
|
|
74
|
-
const mimeType = mimeTypeResolver.lookup(filePath) || "application/octet-stream";
|
|
75
|
-
const entry = { body, mimeType };
|
|
76
|
-
const cacheConfig = this.options.memoryCache;
|
|
77
|
-
if (cacheConfig !== false) {
|
|
78
|
-
const maxBytes = cacheConfig?.maxBytes ?? DEFAULT_MEMORY_CACHE_MAX_BYTES;
|
|
79
|
-
const maxFileBytes = cacheConfig?.maxFileBytes ?? DEFAULT_MEMORY_CACHE_MAX_FILE_BYTES;
|
|
80
|
-
if (body.length <= maxFileBytes) {
|
|
81
|
-
// Evict oldest entries until the new file fits the budget.
|
|
82
|
-
for (const [key, value] of this.fileCache) {
|
|
83
|
-
if (this.fileCacheBytes + body.length <= maxBytes)
|
|
84
|
-
break;
|
|
85
|
-
this.fileCache.delete(key);
|
|
86
|
-
this.fileCacheBytes -= value.body.length;
|
|
87
|
-
}
|
|
88
|
-
if (this.fileCacheBytes + body.length <= maxBytes) {
|
|
89
|
-
this.fileCache.set(filePath, entry);
|
|
90
|
-
this.fileCacheBytes += body.length;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
return entry;
|
|
95
|
-
}
|
|
96
|
-
cacheControlFor(ctx, filePath) {
|
|
42
|
+
cacheControlFor(ctx, relativePath) {
|
|
97
43
|
const cacheOption = this.options.cacheControl;
|
|
98
44
|
if (typeof cacheOption === "function")
|
|
99
|
-
return cacheOption(ctx,
|
|
45
|
+
return cacheOption(ctx, relativePath);
|
|
100
46
|
const immutablePattern = this.options.immutablePattern === false
|
|
101
47
|
? null
|
|
102
48
|
: (this.options.immutablePattern ?? DEFAULT_IMMUTABLE_PATTERN);
|
|
103
|
-
if (immutablePattern && immutablePattern.test(
|
|
49
|
+
if (immutablePattern && immutablePattern.test(relativePath)) {
|
|
104
50
|
return this.options.immutableCacheControl ?? DEFAULT_IMMUTABLE_CACHE_CONTROL;
|
|
105
51
|
}
|
|
106
52
|
return cacheOption ?? DEFAULT_CACHE_CONTROL;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { LambderRenderContext } from "./LambderContext.js";
|
|
2
|
+
import type { LambderFiles } from "./LambderFiles.js";
|
|
2
3
|
import { LambderResponse, type HttpStatusCode, type LambderHeadersInput } from "./LambderResponse.js";
|
|
3
4
|
import { LambderSafeHtml } from "../shared/LambderHtml.js";
|
|
4
|
-
import {
|
|
5
|
+
import type { LambderTemplateData } from "./LambderTemplatingEngine.js";
|
|
5
6
|
import type { LambderApiResponseConfig } from "../shared/LambderApiContract.js";
|
|
6
7
|
export type { LambderApiResponse, LambderApiResponseConfig } from "../shared/LambderApiContract.js";
|
|
7
8
|
export type LambderResponseOptions = {
|
|
@@ -24,16 +25,17 @@ export type LambderRawResponseInit = {
|
|
|
24
25
|
etag?: boolean | "auto";
|
|
25
26
|
};
|
|
26
27
|
export default class LambderResponseBuilder<TResponse = any> {
|
|
27
|
-
protected
|
|
28
|
+
protected files: LambderFiles | null;
|
|
28
29
|
protected apiVersion: string | null;
|
|
29
30
|
protected ctx?: LambderRenderContext;
|
|
30
|
-
constructor({
|
|
31
|
-
|
|
31
|
+
constructor({ files, apiVersion, ctx }: {
|
|
32
|
+
files?: LambderFiles | null;
|
|
32
33
|
apiVersion?: string | null;
|
|
33
34
|
ctx?: LambderRenderContext;
|
|
34
35
|
});
|
|
35
36
|
private buildResponse;
|
|
36
|
-
|
|
37
|
+
/** The instance's file reader, which res.file and res.templateFile need. */
|
|
38
|
+
private requireFiles;
|
|
37
39
|
addHeader(key: string, value: string): void;
|
|
38
40
|
setHeader(key: string, value: string | string[]): void;
|
|
39
41
|
logToApiResponse(input: any): void;
|
|
@@ -47,15 +49,15 @@ export default class LambderResponseBuilder<TResponse = any> {
|
|
|
47
49
|
redirect(url: string, statusCode?: HttpStatusCode, options?: LambderResponseOptions): LambderResponse;
|
|
48
50
|
versionExpired(options?: LambderResponseOptions): LambderResponse;
|
|
49
51
|
fileBase64(fileBase64: string, mimeType: string, options?: LambderResponseOptions): LambderResponse;
|
|
50
|
-
file
|
|
51
|
-
|
|
52
|
-
}): Promise<LambderResponse>;
|
|
52
|
+
/** A file from the files source as a response; 404 when there is none. */
|
|
53
|
+
file(filePath: string, options?: LambderResponseOptions): Promise<LambderResponse>;
|
|
53
54
|
/**
|
|
54
|
-
* Render an HTML file
|
|
55
|
-
* (comment-based slots/conditionals) and return
|
|
56
|
-
* The compiled template is cached
|
|
57
|
-
*
|
|
58
|
-
*
|
|
55
|
+
* Render an HTML file from the files source through
|
|
56
|
+
* LambderTemplatingEngine (comment-based slots/conditionals) and return
|
|
57
|
+
* it as an HTML response. The compiled template is cached on the
|
|
58
|
+
* instance across warm invocations; a missing file throws (it is a
|
|
59
|
+
* server-side configuration error, not a client 404). Set
|
|
60
|
+
* htmlVirtualSlots to expose "title"/"head" slots on marker-less files.
|
|
59
61
|
*/
|
|
60
62
|
templateFile(filePath: string, data?: LambderTemplateData, options?: LambderResponseOptions & {
|
|
61
63
|
htmlVirtualSlots?: boolean;
|
|
@@ -1,15 +1,10 @@
|
|
|
1
|
-
import mimeTypeResolver from "mime-types";
|
|
2
|
-
import { getFS, getPath } from "../shared/node-polyfills.js";
|
|
3
1
|
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
2
|
export default class LambderResponseBuilder {
|
|
8
|
-
|
|
3
|
+
files;
|
|
9
4
|
apiVersion;
|
|
10
5
|
ctx;
|
|
11
|
-
constructor({
|
|
12
|
-
this.
|
|
6
|
+
constructor({ files, apiVersion, ctx }) {
|
|
7
|
+
this.files = files ?? null;
|
|
13
8
|
this.apiVersion = apiVersion ?? null;
|
|
14
9
|
this.ctx = ctx;
|
|
15
10
|
}
|
|
@@ -30,25 +25,12 @@ export default class LambderResponseBuilder {
|
|
|
30
25
|
response.setHeader("Cache-Control", options.cacheControl);
|
|
31
26
|
return response;
|
|
32
27
|
}
|
|
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
|
-
}
|
|
28
|
+
/** The instance's file reader, which res.file and res.templateFile need. */
|
|
29
|
+
requireFiles(method) {
|
|
30
|
+
if (!this.files)
|
|
31
|
+
throw new Error(`${method} requires the files option at creation (e.g. files: new LambderLocalFileSource({ root }))`);
|
|
32
|
+
return this.files;
|
|
50
33
|
}
|
|
51
|
-
;
|
|
52
34
|
addHeader(key, value) {
|
|
53
35
|
if (!this.ctx)
|
|
54
36
|
throw new Error(".addHeader function is not available within this hook");
|
|
@@ -130,41 +112,24 @@ export default class LambderResponseBuilder {
|
|
|
130
112
|
return response;
|
|
131
113
|
}
|
|
132
114
|
;
|
|
115
|
+
/** A file from the files source as a response; 404 when there is none. */
|
|
133
116
|
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) {
|
|
117
|
+
const file = await this.requireFiles("res.file").read(filePath);
|
|
118
|
+
if (!file)
|
|
141
119
|
return this.status404("File not found", { etag: false });
|
|
142
|
-
|
|
143
|
-
const fs = await getFS();
|
|
144
|
-
if (!fs)
|
|
145
|
-
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);
|
|
120
|
+
return this.buildResponse(200, file.mimeType, file.body, options);
|
|
149
121
|
}
|
|
150
122
|
;
|
|
151
123
|
/**
|
|
152
|
-
* Render an HTML file
|
|
153
|
-
* (comment-based slots/conditionals) and return
|
|
154
|
-
* The compiled template is cached
|
|
155
|
-
*
|
|
156
|
-
*
|
|
124
|
+
* Render an HTML file from the files source through
|
|
125
|
+
* LambderTemplatingEngine (comment-based slots/conditionals) and return
|
|
126
|
+
* it as an HTML response. The compiled template is cached on the
|
|
127
|
+
* instance across warm invocations; a missing file throws (it is a
|
|
128
|
+
* server-side configuration error, not a client 404). Set
|
|
129
|
+
* htmlVirtualSlots to expose "title"/"head" slots on marker-less files.
|
|
157
130
|
*/
|
|
158
131
|
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
|
-
}
|
|
132
|
+
const template = await this.requireFiles("res.templateFile").template(filePath, { htmlVirtualSlots: options?.htmlVirtualSlots });
|
|
168
133
|
return this.buildResponse(200, "text/html; charset=utf-8", template.render(data), options);
|
|
169
134
|
}
|
|
170
135
|
;
|
|
@@ -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
|
@@ -2,7 +2,7 @@ import Lambder from './core/Lambder.js';
|
|
|
2
2
|
export default Lambder;
|
|
3
3
|
export { initLambder } from './core/Lambder.js';
|
|
4
4
|
export { default as LambderCaller } from "./client/LambderCaller.js";
|
|
5
|
-
export type { LambderApiOutcome, LambderApiFailureReason, LambderCallOptions, LambderIdempotencyKeyScope } from "./client/LambderCaller.js";
|
|
5
|
+
export type { LambderApiOutcome, LambderApiFailureReason, LambderCallOptions, LambderCallerOptions, LambderGuardInputsProvider, LambderProvidedGuardInputs, LambderIdempotencyKeyScope } from "./client/LambderCaller.js";
|
|
6
6
|
export { LambderApiError, isLambderApiError, refuse, LAMBDER_REFUSAL_CODES } from "./shared/LambderApiError.js";
|
|
7
7
|
export type { LambderApiErrorOptions, LambderRefusalMessage, LambderRefusalCode, LambderRefuseOptions } from "./shared/LambderApiError.js";
|
|
8
8
|
export { default as LambderResponseBuilder } from "./core/LambderResponseBuilder.js";
|
|
@@ -17,6 +17,10 @@ export type { LambderResponseOptions, LambderRawResponseInit, } from "./core/Lam
|
|
|
17
17
|
export type { LambderRouteMatcher, LambderCorsConfig, LambderCreateOptions, LambderSessionOptions, ConditionFunction, RouteCondition, PathParamsOf, LambderActionTools, LambderHandler, LambderIndexHtmlOptions, } from "./core/Lambder.js";
|
|
18
18
|
export { LambderPublicFilesHandler } from "./core/LambderPublicFiles.js";
|
|
19
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";
|
|
22
|
+
export { LambderS3FileSource } from "./stores/LambderS3FileSource.js";
|
|
23
|
+
export type { LambderS3FileSourceOptions } from "./stores/LambderS3FileSource.js";
|
|
20
24
|
export type { LambderSessionCookieOptions } from "./session/LambderSessionController.js";
|
|
21
25
|
export type { LambderSessionContext, LambderCreatedSession, LambderSessionDataRefreshConfig } from "./session/LambderSessionManager.js";
|
|
22
26
|
export type { LambderCompressionOption, LambderCompressionConfig } from "./stores/LambderDdbCompression.js";
|
package/dist/index.js
CHANGED
|
@@ -16,6 +16,8 @@ export { html, xml, raw, jsonScript, escapeHtml, renderHtmlValue, LambderSafeHtm
|
|
|
16
16
|
export { LambderTemplatingEngine } from "./core/LambderTemplatingEngine.js";
|
|
17
17
|
// Public file serving
|
|
18
18
|
export { LambderPublicFilesHandler } from "./core/LambderPublicFiles.js";
|
|
19
|
+
export { LambderFiles, LambderLocalFileSource } from "./core/LambderFiles.js";
|
|
20
|
+
export { LambderS3FileSource } from "./stores/LambderS3FileSource.js";
|
|
19
21
|
export { LambderSessionDataRefreshError, LambderSessionReadError } from "./session/LambderSessionManager.js";
|
|
20
22
|
// DynamoDB-backed compressed cache (standalone, server-only)
|
|
21
23
|
export { LambderDdbCache } from "./stores/LambderDdbCache.js";
|
|
@@ -49,6 +49,13 @@ export default class LambderSessionController<TSessionData = any> {
|
|
|
49
49
|
* session and touches no cookies, so it works on any subject.
|
|
50
50
|
*/
|
|
51
51
|
deleteSessionAllByKey(sessionKey: string): Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* Marks the data of every session of the given sessionKey stale, so each
|
|
54
|
+
* renews via dataRefresh on its next read: the way to apply a change to
|
|
55
|
+
* a subject's roles or permissions immediately, without logging them
|
|
56
|
+
* out. Needs no fetched session; requires dataRefresh.
|
|
57
|
+
*/
|
|
58
|
+
expireSessionDataAllByKey(sessionKey: string): Promise<void>;
|
|
52
59
|
endSession(): Promise<void>;
|
|
53
60
|
endSessionAll(): Promise<void>;
|
|
54
61
|
}
|
|
@@ -151,6 +151,16 @@ export default class LambderSessionController {
|
|
|
151
151
|
await this.lambderSessionManager.deleteSessionAllByKey(sessionKey);
|
|
152
152
|
}
|
|
153
153
|
;
|
|
154
|
+
/**
|
|
155
|
+
* Marks the data of every session of the given sessionKey stale, so each
|
|
156
|
+
* renews via dataRefresh on its next read: the way to apply a change to
|
|
157
|
+
* a subject's roles or permissions immediately, without logging them
|
|
158
|
+
* out. Needs no fetched session; requires dataRefresh.
|
|
159
|
+
*/
|
|
160
|
+
async expireSessionDataAllByKey(sessionKey) {
|
|
161
|
+
await this.lambderSessionManager.expireSessionDataAllByKey(sessionKey);
|
|
162
|
+
}
|
|
163
|
+
;
|
|
154
164
|
async endSession() {
|
|
155
165
|
if (!this.ctx.session)
|
|
156
166
|
throw new Error("Session not found.");
|
|
@@ -111,6 +111,7 @@ export default class LambderSessionManager {
|
|
|
111
111
|
*/
|
|
112
112
|
private ddbPutItem;
|
|
113
113
|
private ddbDeleteItem;
|
|
114
|
+
/** Sort keys of every session under a partition (the callers only need the keys). */
|
|
114
115
|
private ddbQueryAllByPartitionKey;
|
|
115
116
|
private ddbDeleteAllByPartitionKey;
|
|
116
117
|
createSession(sessionKey: string, data?: any, ttlInSeconds?: number, options?: {
|
|
@@ -135,5 +136,15 @@ export default class LambderSessionManager {
|
|
|
135
136
|
* session record.
|
|
136
137
|
*/
|
|
137
138
|
deleteSessionAllByKey(sessionKey: string): Promise<boolean>;
|
|
139
|
+
/**
|
|
140
|
+
* Marks the data of every session of the given sessionKey stale, so each
|
|
141
|
+
* renews via dataRefresh on its next read: "this subject's roles or
|
|
142
|
+
* permissions changed, apply it now", without logging the subject out
|
|
143
|
+
* (deleteSessionAllByKey) and without waiting for the data TTL. Stamps
|
|
144
|
+
* dataExpiresAt only, conditionally on the record still existing, so it
|
|
145
|
+
* neither resurrects a session deleted in between nor overwrites a
|
|
146
|
+
* concurrent write. Requires dataRefresh to be configured.
|
|
147
|
+
*/
|
|
148
|
+
expireSessionDataAllByKey(sessionKey: string): Promise<boolean>;
|
|
138
149
|
regenerateSession(session: LambderSessionContext): Promise<LambderCreatedSession>;
|
|
139
150
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import crypto from "crypto";
|
|
2
2
|
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
3
|
-
import { DynamoDBDocumentClient, QueryCommand, DeleteCommand, PutCommand, GetCommand } from "@aws-sdk/lib-dynamodb";
|
|
3
|
+
import { DynamoDBDocumentClient, QueryCommand, DeleteCommand, PutCommand, GetCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb";
|
|
4
4
|
import { brotliCompressText, brotliRestoreText, resolveCompressionOption, } from "../stores/LambderDdbCompression.js";
|
|
5
5
|
/**
|
|
6
6
|
* Session compression defaults: every record compressed (see
|
|
@@ -108,11 +108,13 @@ export default class LambderSessionManager {
|
|
|
108
108
|
return await this.ddbDocumentClient.send(new DeleteCommand({ TableName: this.tableName, Key: key, }));
|
|
109
109
|
}
|
|
110
110
|
;
|
|
111
|
+
/** Sort keys of every session under a partition (the callers only need the keys). */
|
|
111
112
|
async ddbQueryAllByPartitionKey(partitionValue) {
|
|
112
113
|
const params = {
|
|
113
114
|
TableName: this.tableName,
|
|
114
115
|
KeyConditionExpression: "#pk = :pv",
|
|
115
|
-
|
|
116
|
+
ProjectionExpression: "#sk",
|
|
117
|
+
ExpressionAttributeNames: { "#pk": this.partitionKey, "#sk": this.sortKey },
|
|
116
118
|
ExpressionAttributeValues: { ":pv": partitionValue },
|
|
117
119
|
};
|
|
118
120
|
const queryResults = [];
|
|
@@ -341,6 +343,40 @@ export default class LambderSessionManager {
|
|
|
341
343
|
return true;
|
|
342
344
|
}
|
|
343
345
|
;
|
|
346
|
+
/**
|
|
347
|
+
* Marks the data of every session of the given sessionKey stale, so each
|
|
348
|
+
* renews via dataRefresh on its next read: "this subject's roles or
|
|
349
|
+
* permissions changed, apply it now", without logging the subject out
|
|
350
|
+
* (deleteSessionAllByKey) and without waiting for the data TTL. Stamps
|
|
351
|
+
* dataExpiresAt only, conditionally on the record still existing, so it
|
|
352
|
+
* neither resurrects a session deleted in between nor overwrites a
|
|
353
|
+
* concurrent write. Requires dataRefresh to be configured.
|
|
354
|
+
*/
|
|
355
|
+
async expireSessionDataAllByKey(sessionKey) {
|
|
356
|
+
if (!this.dataRefresh)
|
|
357
|
+
throw new Error("dataRefresh is not configured. Pass session.dataRefresh at creation to enable.");
|
|
358
|
+
const partitionValue = this.sessionUserKeyHasher(sessionKey);
|
|
359
|
+
const now = Math.floor(Date.now() / 1000);
|
|
360
|
+
for (const item of await this.ddbQueryAllByPartitionKey(partitionValue)) {
|
|
361
|
+
try {
|
|
362
|
+
await this.ddbDocumentClient.send(new UpdateCommand({
|
|
363
|
+
TableName: this.tableName,
|
|
364
|
+
Key: { [this.partitionKey]: partitionValue, [this.sortKey]: item[this.sortKey] },
|
|
365
|
+
UpdateExpression: "SET #dataExpiresAt = :now",
|
|
366
|
+
ConditionExpression: "attribute_exists(#sk)",
|
|
367
|
+
ExpressionAttributeNames: { "#dataExpiresAt": "dataExpiresAt", "#sk": this.sortKey },
|
|
368
|
+
ExpressionAttributeValues: { ":now": now },
|
|
369
|
+
}));
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
// Deleted between the query and the update: nothing left to expire.
|
|
373
|
+
if (err.name !== "ConditionalCheckFailedException")
|
|
374
|
+
throw err;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
;
|
|
344
380
|
async regenerateSession(session) {
|
|
345
381
|
if (!session)
|
|
346
382
|
throw new Error("Invalid session");
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { S3Client, S3ClientConfig } from "@aws-sdk/client-s3";
|
|
2
|
+
import type { LambderFile, LambderFileSource } from "../core/LambderFiles.js";
|
|
3
|
+
export type LambderS3FileSourceOptions = {
|
|
4
|
+
bucket: string;
|
|
5
|
+
/** Literal key prefix the relative path is appended to, so include the trailing slash: "web/v42/". Default: none. */
|
|
6
|
+
prefix?: string;
|
|
7
|
+
/** A ready client, e.g. one shared with the rest of the app. */
|
|
8
|
+
client?: S3Client;
|
|
9
|
+
/**
|
|
10
|
+
* Otherwise the client is created from this on first read: `{ region }`
|
|
11
|
+
* for S3; for Cloudflare R2 or another S3-compatible store,
|
|
12
|
+
* `{ region: "auto", endpoint, credentials }`.
|
|
13
|
+
*/
|
|
14
|
+
clientConfig?: S3ClientConfig;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Files from an S3 bucket, or any S3-compatible store such as Cloudflare
|
|
18
|
+
* R2 (pass its endpoint in clientConfig). Needs @aws-sdk/client-s3, an
|
|
19
|
+
* optional peer dependency loaded on first read, so apps that serve from a
|
|
20
|
+
* folder never load it. A missing object reads as null and the request
|
|
21
|
+
* falls through; grant s3:ListBucket besides s3:GetObject, otherwise S3
|
|
22
|
+
* answers a missing key with AccessDenied, which propagates as an error.
|
|
23
|
+
* An object's Content-Type is used unless it is a generic octet-stream, in
|
|
24
|
+
* which case the extension decides, as for local files.
|
|
25
|
+
*/
|
|
26
|
+
export declare class LambderS3FileSource implements LambderFileSource {
|
|
27
|
+
private readonly bucket;
|
|
28
|
+
private readonly prefix;
|
|
29
|
+
private readonly clientConfig;
|
|
30
|
+
private client;
|
|
31
|
+
private sdk;
|
|
32
|
+
constructor({ bucket, prefix, client, clientConfig }: LambderS3FileSourceOptions);
|
|
33
|
+
private loadSdk;
|
|
34
|
+
read(relativePath: string): Promise<LambderFile | null>;
|
|
35
|
+
}
|