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.
@@ -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 under publicPath (app-owned logic, e.g.
6
- * per-tenant roots: (ctx) => `${brand(ctx.host)}${ctx.path}`). Return
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, filePath: string) => string);
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 real files under publicPath (traversal-safe, mime-typed,
31
- * memory-cached, immutable-cache heuristic for content-hashed assets) and
32
- * falls through to the route fallback when the file does not exist.
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 publicPath;
33
+ private files;
36
34
  private options;
37
- private fileCache;
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 real files under publicPath (traversal-safe, mime-typed,
15
- * memory-cached, immutable-cache heuristic for content-hashed assets) and
16
- * falls through to the route fallback when the file does not exist.
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
- publicPath;
16
+ files;
20
17
  options;
21
- fileCache = new Map();
22
- fileCacheBytes = 0;
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 publicRoot = path.resolve(this.publicPath);
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, filePath),
36
+ "Cache-Control": this.cacheControlFor(ctx, file.relativePath),
50
37
  },
51
38
  body: file.body,
52
39
  compress,
53
40
  });
54
41
  }
55
- /** Join base+target and require the result to stay under base. */
56
- resolveSafe(path, base, target) {
57
- if (target.split("/").some((segment) => segment === ".."))
58
- return null;
59
- const normalizedTarget = target.startsWith("/") ? target.slice(1) : target;
60
- const absolute = path.resolve(base, normalizedTarget);
61
- if (absolute !== base && !absolute.startsWith(base + path.sep))
62
- return null;
63
- return absolute;
64
- }
65
- /** Read a file, caching small files in memory for warm invocations. */
66
- async readFileCached(fs, filePath) {
67
- const cached = this.fileCache.get(filePath);
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, filePath);
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(filePath)) {
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 { type LambderTemplateData } from "./LambderTemplatingEngine.js";
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 publicPath: string;
28
+ protected files: LambderFiles | null;
28
29
  protected apiVersion: string | null;
29
30
  protected ctx?: LambderRenderContext;
30
- constructor({ publicPath, apiVersion, ctx }: {
31
- publicPath: string;
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
- private resolvePublicFilePath;
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(filePath: string, options?: LambderResponseOptions & {
51
- fallback?: string;
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 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.
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
- publicPath;
3
+ files;
9
4
  apiVersion;
10
5
  ctx;
11
- constructor({ publicPath, apiVersion, ctx }) {
12
- this.publicPath = publicPath;
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
- 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
- }
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
- 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) {
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 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.
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 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
- }
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 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
@@ -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
- ExpressionAttributeNames: { "#pk": this.partitionKey },
116
+ ProjectionExpression: "#sk",
117
+ ExpressionAttributeNames: { "#pk": this.partitionKey, "#sk": this.sortKey },
116
118
  ExpressionAttributeValues: { ":pv": partitionValue },
117
119
  };
118
120
  const queryResults = [];
@@ -341,6 +343,40 @@ export default class LambderSessionManager {
341
343
  return true;
342
344
  }
343
345
  ;
346
+ /**
347
+ * Marks the data of every session of the given sessionKey stale, so each
348
+ * renews via dataRefresh on its next read: "this subject's roles or
349
+ * permissions changed, apply it now", without logging the subject out
350
+ * (deleteSessionAllByKey) and without waiting for the data TTL. Stamps
351
+ * dataExpiresAt only, conditionally on the record still existing, so it
352
+ * neither resurrects a session deleted in between nor overwrites a
353
+ * concurrent write. Requires dataRefresh to be configured.
354
+ */
355
+ async expireSessionDataAllByKey(sessionKey) {
356
+ if (!this.dataRefresh)
357
+ throw new Error("dataRefresh is not configured. Pass session.dataRefresh at creation to enable.");
358
+ const partitionValue = this.sessionUserKeyHasher(sessionKey);
359
+ const now = Math.floor(Date.now() / 1000);
360
+ for (const item of await this.ddbQueryAllByPartitionKey(partitionValue)) {
361
+ try {
362
+ await this.ddbDocumentClient.send(new UpdateCommand({
363
+ TableName: this.tableName,
364
+ Key: { [this.partitionKey]: partitionValue, [this.sortKey]: item[this.sortKey] },
365
+ UpdateExpression: "SET #dataExpiresAt = :now",
366
+ ConditionExpression: "attribute_exists(#sk)",
367
+ ExpressionAttributeNames: { "#dataExpiresAt": "dataExpiresAt", "#sk": this.sortKey },
368
+ ExpressionAttributeValues: { ":now": now },
369
+ }));
370
+ }
371
+ catch (err) {
372
+ // Deleted between the query and the update: nothing left to expire.
373
+ if (err.name !== "ConditionalCheckFailedException")
374
+ throw err;
375
+ }
376
+ }
377
+ return true;
378
+ }
379
+ ;
344
380
  async regenerateSession(session) {
345
381
  if (!session)
346
382
  throw new Error("Invalid session");
@@ -0,0 +1,35 @@
1
+ import type { S3Client, S3ClientConfig } from "@aws-sdk/client-s3";
2
+ import type { 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
+ }