lambder 4.4.1 → 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 CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  Lambder is a highly opinionated dynamic serverless framework designed to facilitate the management and implementation of routes and APIs within AWS Lambda functions, specifically tailored for TypeScript projects. It provides a streamlined approach to handling HTTP requests, managing sessions, and defining API routes, making serverless application development more intuitive and structured.
4
4
 
5
+ **New in 4.5:**
6
+
7
+ - **`files` at creation replaces `publicPath`** (and `servePublicFiles({ source })`): one `LambderFileSource` configured once, `files: new LambderLocalFileSource({ root: path.resolve("./public") })` for the folder bundled with the deployment, `new LambderS3FileSource({...})` for S3 or R2, or your own `{ read(relativePath) }`. The instance owns one reader over it (`lambder.files`): path rule, in-memory file cache and compiled-template cache in one place, shared by `servePublicFiles`, `serveIndexHtml`, `res.file` and `res.templateFile`, so a build hosted from a bucket serves its index.html and templates from the bucket too, cached the same way as its assets. The cache is tuned or disabled beside the source, `files: { source, memoryCache }`, and `memoryCache` leaves `servePublicFiles`; `res.file` loses its SPA-era `fallback` option (the fallback chain replaced it).
8
+
5
9
  **New in 4.4:**
6
10
 
7
11
  - **`guardInputsProvider`** on `LambderCaller`: supply guardInput-mode guard values for every call from one place (the organization the UI is on, a device token) instead of at each call site; per-call `guardInputs` merge on top. Name the covered guards in the caller's second type parameter, `new LambderCaller<Contract, "orgPermission">({ guardInputsProvider, ... })`: calls to APIs whose guardInput guards are all covered no longer require the options argument, uncovered ones (a Turnstile token) still do, and naming guards makes the provider itself mandatory.
@@ -92,7 +96,7 @@ would silently widen the inferred policy types, which is why the curried
92
96
  creator is the canonical entry.
93
97
 
94
98
  ```typescript
95
- import { initLambder } from 'lambder';
99
+ import { initLambder, LambderLocalFileSource } from 'lambder';
96
100
  import { z } from 'zod';
97
101
  import * as path from 'path';
98
102
 
@@ -100,7 +104,7 @@ interface SessionData { userId: string; }
100
104
 
101
105
  const lambder = initLambder<SessionData>().create({
102
106
  apiPath: "/api",
103
- publicPath: path.resolve(`./public`),
107
+ files: new LambderLocalFileSource({ root: path.resolve(`./public`) }),
104
108
  session: {
105
109
  tableName: "website-session",
106
110
  tableRegion: "us-east-1",
@@ -166,9 +170,9 @@ lambder
166
170
  .addRoute({ path: "/stripe-webhook", method: "POST" }, (ctx, res) => {
167
171
  return res.json({ received: true });
168
172
  })
169
- // Serve real files (from publicPath by default; see "Public file sources"
170
- // below for S3/R2). This is a terminal fallback slot, NOT a catch-all
171
- // route, so it can never shadow routes registered after it.
173
+ // Serve real files from the files source (see "Public file sources"
174
+ // below). This is a terminal fallback slot, NOT a catch-all route, so it
175
+ // can never shadow routes registered after it.
172
176
  .servePublicFiles()
173
177
  // Serve the app shell for GET/HEAD page requests nothing else handled
174
178
  // (see "Hosting a frontend build" below).
@@ -216,7 +220,7 @@ For larger applications, split your APIs into separate modules:
216
220
  ```typescript
217
221
  // user-api.ts
218
222
  import { z } from "zod";
219
- import Lambder from "lambder";
223
+ import Lambder, { LambderLocalFileSource } from "lambder";
220
224
 
221
225
  export const userApi = <T>(l: Lambder<T>) => {
222
226
  return l
@@ -237,7 +241,7 @@ export const userApi = <T>(l: Lambder<T>) => {
237
241
  // index.ts
238
242
  import { userApi } from "./user-api";
239
243
 
240
- const lambder = new Lambder({ publicPath: './public' })
244
+ const lambder = new Lambder({ files: new LambderLocalFileSource({ root: './public' }) })
241
245
  .use(userApi);
242
246
 
243
247
  export type ApiContractType = typeof lambder.ApiContract;
@@ -443,27 +447,31 @@ Lambder has no SPA-specific machinery; hosting a frontend build is a recipe buil
443
447
 
444
448
  #### Public file sources
445
449
 
446
- `servePublicFiles` reads through a `LambderPublicFileSource`, an object with one method, `read(relativePath)`, returning `{ body, mimeType? }` or `null` (the request then falls through). The handler does everything else for every source: traversal check, memory cache for warm invocations, mime fallback from the extension, Cache-Control (immutable for content-hashed names), ETag and compression. Built in:
450
+ The `files` option at creation is a `LambderFileSource`, an object with one method, `read(relativePath)`, returning `{ body, mimeType? }` or `null`. The instance owns one reader over it, `lambder.files`, and `servePublicFiles`, `serveIndexHtml`, `res.file` and `res.templateFile` all go through that reader, which does everything else for every source: traversal check, in-memory file cache for warm invocations (default 32MB, 2MB per file), compiled-template cache, mime fallback from the extension. Cache-Control (immutable for content-hashed names), ETag and compression are applied by the serving slot and the response pipeline. Built in:
447
451
 
448
452
  ```typescript
449
- // Default: the publicPath folder bundled with the deployment.
450
- lambder.servePublicFiles();
453
+ // A folder, typically the build output bundled with the deployment.
454
+ initLambder().create({ files: new LambderLocalFileSource({ root: path.resolve("./public") }) });
451
455
 
452
456
  // S3. @aws-sdk/client-s3 is an optional peer dependency, loaded on first read.
453
- lambder.servePublicFiles({
454
- source: new LambderS3FileSource({ bucket: "myapp-web", prefix: "v42/", clientConfig: { region: "eu-central-1" } }),
457
+ initLambder().create({
458
+ files: new LambderS3FileSource({ bucket: "myapp-web", prefix: "v42/", clientConfig: { region: "eu-central-1" } }),
455
459
  });
456
460
 
457
461
  // Cloudflare R2, or any S3-compatible store: point the client at its endpoint.
458
- lambder.servePublicFiles({
459
- source: new LambderS3FileSource({
462
+ initLambder().create({
463
+ files: new LambderS3FileSource({
460
464
  bucket: "myapp-web",
461
465
  clientConfig: { region: "auto", endpoint: "https://<account>.r2.cloudflarestorage.com", credentials: { accessKeyId, secretAccessKey } },
462
466
  }),
463
467
  });
464
468
 
465
469
  // Anything else: implement read().
466
- lambder.servePublicFiles({ source: { read: async (relativePath) => myStore.get(relativePath) } });
470
+ initLambder().create({ files: { read: async (relativePath) => myStore.get(relativePath) } });
471
+
472
+ // The in-memory file cache, tuned or off, beside any source.
473
+ initLambder().create({ files: { source: new LambderS3FileSource({ bucket: "myapp-web" }), memoryCache: { maxBytes: 64_000_000, maxFileBytes: 4_000_000 } } });
474
+ initLambder().create({ files: { source: new LambderLocalFileSource({ root }), memoryCache: false } });
467
475
  ```
468
476
 
469
477
  A missing S3 object reads as null; grant `s3:ListBucket` besides `s3:GetObject`, otherwise S3 answers a missing key with AccessDenied, which propagates as an error instead of falling through. The object's Content-Type is used unless it is a generic octet-stream, in which case the extension decides. Lambda's ~6MB response cap still applies to anything proxied this way: redirect large downloads to the bucket or CDN URL instead of serving them.
@@ -9,6 +9,7 @@ import { type LambderSessionDataRefreshConfig } from "../session/LambderSessionM
9
9
  import type { LambderCompressionOption } from "../stores/LambderDdbCompression.js";
10
10
  import LambderSessionController, { type LambderSessionCookieOptions } from "../session/LambderSessionController.js";
11
11
  import { type LambderPublicFilesOptions } from "./LambderPublicFiles.js";
12
+ import { LambderFiles, type LambderFilesOption } from "./LambderFiles.js";
12
13
  import type { LambderApiGuard, LambderGuardMetaMap, LambderGuardsOption, LambderGuardDataOf, LambderGuardInputsOf } from "../policies/LambderApiGuards.js";
13
14
  import type { LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig, LambderRateLimitOption } from "../policies/LambderApiRateLimits.js";
14
15
  import type { LambderApiIdempotencyConfig } from "../policies/LambderApiIdempotency.js";
@@ -107,7 +108,14 @@ export type LambderSessionOptions<TSessionData = any> = {
107
108
  * instance type ever needs a name.
108
109
  */
109
110
  export type LambderCreateOptions<TSessionData = any> = {
110
- publicPath?: string;
111
+ /**
112
+ * Where the app's files come from, for servePublicFiles, serveIndexHtml,
113
+ * res.file and res.templateFile: a LambderLocalFileSource over a folder
114
+ * (the build output bundled with the deployment), a LambderS3FileSource
115
+ * (S3, R2), or any LambderFileSource; or `{ source, memoryCache }` to
116
+ * tune or disable the in-memory file cache. Required by those features.
117
+ */
118
+ files?: LambderFilesOption;
111
119
  apiPath?: string;
112
120
  apiVersion?: string;
113
121
  /** Automatic gzip for compressible responses. `true` (the default) is `{ minBytes: 860 }`; `false` disables it. */
@@ -153,7 +161,8 @@ export type LambderCreateOptions<TSessionData = any> = {
153
161
  export default class Lambder<TSessionData = any, _TContract extends Record<string, any> = {}, _TRateLimitPolicies extends Record<string, LambderApiRateLimitPolicyConfig> = {}, _TGuards extends Record<string, any> = {}, _TIdempotencyEnabled extends boolean = false> {
154
162
  apiPath: string;
155
163
  apiVersion: null | string;
156
- publicPath: string;
164
+ /** The instance's file reader (source + caches), or null without the files option. */
165
+ files: LambderFiles | null;
157
166
  /**
158
167
  * Type property for extracting the API contract
159
168
  * Use this to export your API types to the frontend
@@ -194,9 +203,8 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
194
203
  setSessionExpiredRouteHandler(handler: FallbackHandlerFunction): this;
195
204
  /**
196
205
  * Terminal public-file layer. Runs only when no route matched, so it can
197
- * never shadow routes registered after it. Serves files from `source`
198
- * (default: the publicPath folder; also LambderS3FileSource for S3 and
199
- * R2, or any LambderPublicFileSource), traversal-safe, mime-typed,
206
+ * never shadow routes registered after it. Serves files from the `files`
207
+ * source configured at creation, traversal-safe, mime-typed,
200
208
  * memory-cached, with the immutable-cache heuristic for content-hashed
201
209
  * assets; when the source has no such file the request falls through to
202
210
  * setRouteFallbackHandler, where the app decides what remains (e.g.
@@ -209,8 +217,8 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
209
217
  * gone; everything left is an app route (option `skipFilePaths` opts back
210
218
  * into 404ing dotted paths). Only configured methods reach it, default
211
219
  * GET/HEAD. Gated-out requests fall through to setRouteFallbackHandler.
212
- * Without a handler, publicPath/index.html is served via res.templateFile
213
- * (markers optional) with no-cache.
220
+ * Without a handler, index.html from the files source is served via
221
+ * res.templateFile (markers optional) with no-cache.
214
222
  */
215
223
  serveIndexHtml(handler?: FallbackHandlerFunction, options?: LambderIndexHtmlOptions): this;
216
224
  /** Apply the serveIndexHtml gates; null means fall through. */
@@ -5,7 +5,8 @@ import { compileRouteMatcher } from "./LambderRouting.js";
5
5
  import { applyCorsHeaders } from "./LambderCors.js";
6
6
  import LambderSessionManager from "../session/LambderSessionManager.js";
7
7
  import LambderSessionController from "../session/LambderSessionController.js";
8
- import { LambderPublicFilesHandler, LambderLocalFileSource } from "./LambderPublicFiles.js";
8
+ import { LambderPublicFilesHandler } from "./LambderPublicFiles.js";
9
+ import { LambderFiles } from "./LambderFiles.js";
9
10
  import { isLambderApiError, LAMBDER_REFUSAL_CODES } from "../shared/LambderApiError.js";
10
11
  import { LambderApiPolicyEngine } from "../policies/LambderApiPolicies.js";
11
12
  import { createContext, isV2HttpEvent } from "./LambderContext.js";
@@ -33,7 +34,8 @@ import { createContext, isV2HttpEvent } from "./LambderContext.js";
33
34
  export default class Lambder {
34
35
  apiPath;
35
36
  apiVersion;
36
- publicPath;
37
+ /** The instance's file reader (source + caches), or null without the files option. */
38
+ files;
37
39
  /**
38
40
  * Type property for extracting the API contract
39
41
  * Use this to export your API types to the frontend
@@ -66,7 +68,7 @@ export default class Lambder {
66
68
  sessionTokenCookieKey = "LMDRSESSIONTKID";
67
69
  sessionCsrfCookieKey = "LMDRSESSIONCSTK";
68
70
  constructor(options = {}) {
69
- this.publicPath = options.publicPath || "/incorrect-path-not-found";
71
+ this.files = options.files ? new LambderFiles(options.files) : null;
70
72
  this.apiPath = options.apiPath ?? "/api";
71
73
  this.apiVersion = options.apiVersion ?? null;
72
74
  this.finalizeOptions = {
@@ -129,17 +131,17 @@ export default class Lambder {
129
131
  }
130
132
  /**
131
133
  * Terminal public-file layer. Runs only when no route matched, so it can
132
- * never shadow routes registered after it. Serves files from `source`
133
- * (default: the publicPath folder; also LambderS3FileSource for S3 and
134
- * R2, or any LambderPublicFileSource), traversal-safe, mime-typed,
134
+ * never shadow routes registered after it. Serves files from the `files`
135
+ * source configured at creation, traversal-safe, mime-typed,
135
136
  * memory-cached, with the immutable-cache heuristic for content-hashed
136
137
  * assets; when the source has no such file the request falls through to
137
138
  * setRouteFallbackHandler, where the app decides what remains (e.g.
138
139
  * render an app shell with res.templateFile).
139
140
  */
140
141
  servePublicFiles(options = {}) {
141
- const source = options.source ?? new LambderLocalFileSource({ root: this.publicPath });
142
- this.publicFilesHandler = new LambderPublicFilesHandler(source, options);
142
+ if (!this.files)
143
+ throw new Error("servePublicFiles requires the files option at creation (e.g. files: new LambderLocalFileSource({ root }))");
144
+ this.publicFilesHandler = new LambderPublicFilesHandler(this.files, options);
143
145
  return this;
144
146
  }
145
147
  /**
@@ -148,8 +150,8 @@ export default class Lambder {
148
150
  * gone; everything left is an app route (option `skipFilePaths` opts back
149
151
  * into 404ing dotted paths). Only configured methods reach it, default
150
152
  * GET/HEAD. Gated-out requests fall through to setRouteFallbackHandler.
151
- * Without a handler, publicPath/index.html is served via res.templateFile
152
- * (markers optional) with no-cache.
153
+ * Without a handler, index.html from the files source is served via
154
+ * res.templateFile (markers optional) with no-cache.
153
155
  */
154
156
  serveIndexHtml(handler, options = {}) {
155
157
  this.indexHtmlConfig = { handler: handler ?? null, options };
@@ -331,7 +333,7 @@ export default class Lambder {
331
333
  }
332
334
  getResponseBuilder(ctx) {
333
335
  return new LambderResponseBuilder({
334
- publicPath: this.publicPath,
336
+ files: this.files,
335
337
  apiVersion: this.apiVersion,
336
338
  ctx,
337
339
  });
@@ -339,7 +341,7 @@ export default class Lambder {
339
341
  ;
340
342
  getResolver(ctx) {
341
343
  return new LambderResolver({
342
- publicPath: this.publicPath,
344
+ files: this.files,
343
345
  apiVersion: this.apiVersion,
344
346
  ctx,
345
347
  });
@@ -0,0 +1,85 @@
1
+ import { LambderTemplatingEngine } from "./LambderTemplatingEngine.js";
2
+ /** A file a source serves: its bytes, and its mime type when the source knows it (otherwise resolved from the extension). */
3
+ export type LambderFile = {
4
+ body: Buffer;
5
+ mimeType?: string;
6
+ };
7
+ /**
8
+ * Where an app's files come from: the `files` option at creation, read by
9
+ * servePublicFiles, serveIndexHtml, res.file and res.templateFile alike,
10
+ * through the instance's one reader (LambderFiles). Implement `read` over
11
+ * any backing store: LambderLocalFileSource (a folder), LambderS3FileSource
12
+ * (S3, or R2 and other S3-compatible stores), or your own. The reader does
13
+ * the rest for every source: traversal check, memory cache, mime fallback
14
+ * from the extension.
15
+ */
16
+ export interface LambderFileSource {
17
+ /**
18
+ * The file at a relative path (no leading slash, no ".." segments: the
19
+ * reader rejects those before calling), or null when there is no such
20
+ * file, which lets a request fall through to the route fallback.
21
+ */
22
+ read(relativePath: string): Promise<LambderFile | null>;
23
+ }
24
+ /** In-memory cache of files for warm invocations. Default: { maxBytes: 32MB, maxFileBytes: 2MB }. false disables it. */
25
+ export type LambderFileMemoryCacheOption = false | {
26
+ maxBytes?: number;
27
+ maxFileBytes?: number;
28
+ };
29
+ /** The `files` option at creation: a source, or a source with its memory cache tuned or off. */
30
+ export type LambderFilesOption = LambderFileSource | {
31
+ source: LambderFileSource;
32
+ memoryCache?: LambderFileMemoryCacheOption;
33
+ };
34
+ /** A file as the reader hands it out: path normalized, mime type resolved. */
35
+ export type LambderReadFile = {
36
+ body: Buffer;
37
+ mimeType: string;
38
+ relativePath: string;
39
+ };
40
+ /**
41
+ * Files from a folder on the Lambda's filesystem, typically the build output
42
+ * bundled into the deployment package. Reads stay under root.
43
+ */
44
+ export declare class LambderLocalFileSource implements LambderFileSource {
45
+ private root;
46
+ constructor({ root }: {
47
+ root: string;
48
+ });
49
+ read(relativePath: string): Promise<LambderFile | null>;
50
+ }
51
+ /**
52
+ * The path a source is asked for: leading slash stripped, traversal
53
+ * rejected; null for a path that names no file (empty, or a directory).
54
+ */
55
+ export declare const toRelativePath: (target: string) => string | null;
56
+ /**
57
+ * The app's file reader, owned by the Lambder instance: one source, one
58
+ * path rule, one memory cache and one compiled-template cache, shared by
59
+ * every feature that reads files. Both caches live as long as the instance,
60
+ * i.e. across warm invocations.
61
+ */
62
+ export declare class LambderFiles {
63
+ private source;
64
+ private cache;
65
+ private cacheBytes;
66
+ private maxBytes;
67
+ private maxFileBytes;
68
+ private templates;
69
+ constructor(option: LambderFilesOption);
70
+ /**
71
+ * The file at a request or handler path (leading slash optional), mime
72
+ * type resolved; null when the path is invalid or the source has none.
73
+ */
74
+ read(path: string): Promise<LambderReadFile | null>;
75
+ /**
76
+ * The compiled template for an HTML file, compiled once per instance.
77
+ * A missing file throws: it is a server-side configuration error, not a
78
+ * client 404.
79
+ */
80
+ template(path: string, options?: {
81
+ htmlVirtualSlots?: boolean;
82
+ }): Promise<LambderTemplatingEngine>;
83
+ /** Cache small files within the byte budget, evicting the oldest entries first. */
84
+ private remember;
85
+ }
@@ -0,0 +1,116 @@
1
+ import mimeTypeResolver from "mime-types";
2
+ import { getFS, getPath } from "../shared/node-polyfills.js";
3
+ import { LambderTemplatingEngine } from "./LambderTemplatingEngine.js";
4
+ const DEFAULT_MEMORY_CACHE_MAX_BYTES = 32 * 1024 * 1024;
5
+ const DEFAULT_MEMORY_CACHE_MAX_FILE_BYTES = 2 * 1024 * 1024;
6
+ /**
7
+ * Files from a folder on the Lambda's filesystem, typically the build output
8
+ * bundled into the deployment package. Reads stay under root.
9
+ */
10
+ export class LambderLocalFileSource {
11
+ root;
12
+ constructor({ root }) {
13
+ this.root = root;
14
+ }
15
+ async read(relativePath) {
16
+ const fs = await getFS();
17
+ const path = await getPath();
18
+ if (!fs || !path)
19
+ throw new Error("LambderLocalFileSource requires a Node.js environment.");
20
+ const base = path.resolve(this.root);
21
+ const absolute = path.resolve(base, relativePath);
22
+ if (absolute !== base && !absolute.startsWith(base + path.sep))
23
+ return null;
24
+ const stat = await fs.promises.stat(absolute).catch(() => null);
25
+ if (!stat?.isFile())
26
+ return null;
27
+ return { body: await fs.promises.readFile(absolute) };
28
+ }
29
+ }
30
+ /**
31
+ * The path a source is asked for: leading slash stripped, traversal
32
+ * rejected; null for a path that names no file (empty, or a directory).
33
+ */
34
+ export const toRelativePath = (target) => {
35
+ if (target.split("/").some((segment) => segment === ".."))
36
+ return null;
37
+ const relative = target.startsWith("/") ? target.slice(1) : target;
38
+ if (relative === "" || relative.endsWith("/"))
39
+ return null;
40
+ return relative;
41
+ };
42
+ /**
43
+ * The app's file reader, owned by the Lambder instance: one source, one
44
+ * path rule, one memory cache and one compiled-template cache, shared by
45
+ * every feature that reads files. Both caches live as long as the instance,
46
+ * i.e. across warm invocations.
47
+ */
48
+ export class LambderFiles {
49
+ source;
50
+ cache;
51
+ cacheBytes = 0;
52
+ maxBytes;
53
+ maxFileBytes;
54
+ templates = new Map();
55
+ constructor(option) {
56
+ const { source, memoryCache } = "source" in option ? option : { source: option, memoryCache: undefined };
57
+ this.source = source;
58
+ this.cache = memoryCache === false ? null : new Map();
59
+ this.maxBytes = memoryCache === false ? 0 : (memoryCache?.maxBytes ?? DEFAULT_MEMORY_CACHE_MAX_BYTES);
60
+ this.maxFileBytes = memoryCache === false ? 0 : (memoryCache?.maxFileBytes ?? DEFAULT_MEMORY_CACHE_MAX_FILE_BYTES);
61
+ }
62
+ /**
63
+ * The file at a request or handler path (leading slash optional), mime
64
+ * type resolved; null when the path is invalid or the source has none.
65
+ */
66
+ async read(path) {
67
+ const relativePath = toRelativePath(path);
68
+ if (relativePath === null)
69
+ return null;
70
+ const cached = this.cache?.get(relativePath);
71
+ if (cached)
72
+ return cached;
73
+ const file = await this.source.read(relativePath);
74
+ if (!file)
75
+ return null;
76
+ const entry = {
77
+ body: file.body,
78
+ mimeType: file.mimeType || mimeTypeResolver.lookup(relativePath) || "application/octet-stream",
79
+ relativePath,
80
+ };
81
+ this.remember(entry);
82
+ return entry;
83
+ }
84
+ /**
85
+ * The compiled template for an HTML file, compiled once per instance.
86
+ * A missing file throws: it is a server-side configuration error, not a
87
+ * client 404.
88
+ */
89
+ async template(path, options = {}) {
90
+ const key = `${toRelativePath(path)}|${options.htmlVirtualSlots ? "v" : ""}`;
91
+ const cached = this.templates.get(key);
92
+ if (cached)
93
+ return cached;
94
+ const file = await this.read(path);
95
+ if (!file)
96
+ throw new Error(`templateFile: file not found in the files source: ${path}`);
97
+ const template = new LambderTemplatingEngine(file.body.toString("utf8"), { htmlVirtualSlots: options.htmlVirtualSlots });
98
+ this.templates.set(key, template);
99
+ return template;
100
+ }
101
+ /** Cache small files within the byte budget, evicting the oldest entries first. */
102
+ remember(entry) {
103
+ if (!this.cache || entry.body.length > this.maxFileBytes)
104
+ return;
105
+ for (const [key, value] of this.cache) {
106
+ if (this.cacheBytes + entry.body.length <= this.maxBytes)
107
+ break;
108
+ this.cache.delete(key);
109
+ this.cacheBytes -= value.body.length;
110
+ }
111
+ if (this.cacheBytes + entry.body.length <= this.maxBytes) {
112
+ this.cache.set(entry.relativePath, entry);
113
+ this.cacheBytes += entry.body.length;
114
+ }
115
+ }
116
+ }
@@ -1,28 +1,8 @@
1
1
  import type { LambderRenderContext } from "./LambderContext.js";
2
+ import type { LambderFiles } from "./LambderFiles.js";
2
3
  import { LambderResponse } from "./LambderResponse.js";
3
- /** A file a source serves: its bytes, and its mime type when the source knows it (otherwise resolved from the extension). */
4
- export type LambderPublicFile = {
5
- body: Buffer;
6
- mimeType?: string;
7
- };
8
- /**
9
- * Where servePublicFiles gets its files. Implement `read` over any backing
10
- * store: LambderLocalFileSource (a folder, the default), LambderS3FileSource
11
- * (S3, or R2 and other S3-compatible stores), or your own. The handler does
12
- * the rest for every source: traversal check, memory cache, mime fallback
13
- * from the extension, Cache-Control, ETag and compression.
14
- */
15
- export interface LambderPublicFileSource {
16
- /**
17
- * The file at a relative path (no leading slash, no ".." segments: the
18
- * handler rejects those before calling), or null when there is no such
19
- * file, which lets the request fall through to the route fallback.
20
- */
21
- read(relativePath: string): Promise<LambderPublicFile | null>;
22
- }
4
+ /** Per-registration policy of servePublicFiles: how a request maps to a file and how the response is cached. */
23
5
  export type LambderPublicFilesOptions = {
24
- /** Where files come from. Default: LambderLocalFileSource over publicPath. */
25
- source?: LambderPublicFileSource;
26
6
  /**
27
7
  * Map the request to a file path (app-owned logic, e.g. per-tenant
28
8
  * roots: (ctx) => `${brand(ctx.host)}${ctx.path}`). Return
@@ -35,45 +15,25 @@ export type LambderPublicFilesOptions = {
35
15
  immutablePattern?: RegExp | false;
36
16
  /** Default: "public, max-age=31536000, immutable". */
37
17
  immutableCacheControl?: string;
38
- /** In-memory cache of files for warm invocations. Default: { maxBytes: 32MB, maxFileBytes: 2MB }. Set false to disable. */
39
- memoryCache?: false | {
40
- maxBytes?: number;
41
- maxFileBytes?: number;
42
- };
43
18
  /**
44
19
  * Compression per file: "auto" (default: compressible mime + size threshold),
45
20
  * true/false, or a function, e.g. (ctx) => /\.(css|js|svg)$/.test(ctx.path).
46
21
  */
47
22
  compress?: boolean | "auto" | ((ctx: LambderRenderContext) => boolean | "auto");
48
23
  };
49
- /**
50
- * Files from a folder on the Lambda's filesystem, typically the build output
51
- * bundled into the deployment package. Reads stay under root. The default
52
- * source of servePublicFiles, over publicPath.
53
- */
54
- export declare class LambderLocalFileSource implements LambderPublicFileSource {
55
- private root;
56
- constructor({ root }: {
57
- root: string;
58
- });
59
- read(relativePath: string): Promise<LambderPublicFile | null>;
60
- }
61
24
  /**
62
25
  * Terminal public-file handler registered via lambder.servePublicFiles().
63
26
  * Runs only when no route matched, so it can never shadow routes registered
64
- * after it. Serves files from its source (traversal-safe, mime-typed,
65
- * memory-cached, immutable-cache heuristic for content-hashed assets) and
66
- * falls through to the route fallback when the source has no such file.
27
+ * after it. Serves files through the instance's reader (traversal-safe,
28
+ * mime-typed, memory-cached) with the immutable-cache heuristic for
29
+ * content-hashed assets, and falls through to the route fallback when the
30
+ * source has no such file.
67
31
  */
68
32
  export declare class LambderPublicFilesHandler {
69
- private source;
33
+ private files;
70
34
  private options;
71
- private fileCache;
72
- private fileCacheBytes;
73
- constructor(source: LambderPublicFileSource, options: LambderPublicFilesOptions);
35
+ constructor(files: LambderFiles, options: LambderPublicFilesOptions);
74
36
  /** Serve the mapped file, or return null to fall through. */
75
37
  handle(ctx: LambderRenderContext): Promise<LambderResponse | null>;
76
- /** Read from the source, caching small files in memory for warm invocations. */
77
- private readCached;
78
38
  private cacheControlFor;
79
39
  }
@@ -1,61 +1,22 @@
1
- import mimeTypeResolver from "mime-types";
2
- import { getFS, getPath } from "../shared/node-polyfills.js";
3
1
  import { LambderResponse } from "./LambderResponse.js";
4
2
  // Content-hashed build outputs (Vite/webpack/Rollup): a [-.] separated run of
5
3
  // 8+ hash chars containing at least one digit, before the extension.
6
4
  const DEFAULT_IMMUTABLE_PATTERN = /[-.](?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+$/;
7
5
  const DEFAULT_IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
8
6
  const DEFAULT_CACHE_CONTROL = "public, max-age=3600";
9
- const DEFAULT_MEMORY_CACHE_MAX_BYTES = 32 * 1024 * 1024;
10
- const DEFAULT_MEMORY_CACHE_MAX_FILE_BYTES = 2 * 1024 * 1024;
11
- /**
12
- * Files from a folder on the Lambda's filesystem, typically the build output
13
- * bundled into the deployment package. Reads stay under root. The default
14
- * source of servePublicFiles, over publicPath.
15
- */
16
- export class LambderLocalFileSource {
17
- root;
18
- constructor({ root }) {
19
- this.root = root;
20
- }
21
- async read(relativePath) {
22
- const fs = await getFS();
23
- const path = await getPath();
24
- if (!fs || !path)
25
- throw new Error("LambderLocalFileSource requires a Node.js environment.");
26
- const base = path.resolve(this.root);
27
- const absolute = path.resolve(base, relativePath);
28
- if (absolute !== base && !absolute.startsWith(base + path.sep))
29
- return null;
30
- const stat = await fs.promises.stat(absolute).catch(() => null);
31
- if (!stat?.isFile())
32
- return null;
33
- return { body: await fs.promises.readFile(absolute) };
34
- }
35
- }
36
- /** Strip the leading slash and reject traversal; null for a path that names no file (empty, or a directory). */
37
- const toRelativePath = (target) => {
38
- if (target.split("/").some((segment) => segment === ".."))
39
- return null;
40
- const relative = target.startsWith("/") ? target.slice(1) : target;
41
- if (relative === "" || relative.endsWith("/"))
42
- return null;
43
- return relative;
44
- };
45
7
  /**
46
8
  * Terminal public-file handler registered via lambder.servePublicFiles().
47
9
  * Runs only when no route matched, so it can never shadow routes registered
48
- * after it. Serves files from its source (traversal-safe, mime-typed,
49
- * memory-cached, immutable-cache heuristic for content-hashed assets) and
50
- * falls through to the route fallback when the source has no such file.
10
+ * after it. Serves files through the instance's reader (traversal-safe,
11
+ * mime-typed, memory-cached) with the immutable-cache heuristic for
12
+ * content-hashed assets, and falls through to the route fallback when the
13
+ * source has no such file.
51
14
  */
52
15
  export class LambderPublicFilesHandler {
53
- source;
16
+ files;
54
17
  options;
55
- fileCache = new Map();
56
- fileCacheBytes = 0;
57
- constructor(source, options) {
58
- this.source = source;
18
+ constructor(files, options) {
19
+ this.files = files;
59
20
  this.options = options;
60
21
  }
61
22
  /** Serve the mapped file, or return null to fall through. */
@@ -63,10 +24,7 @@ export class LambderPublicFilesHandler {
63
24
  const mappedPath = this.options.path ? this.options.path(ctx) : ctx.path;
64
25
  if (!mappedPath)
65
26
  return null;
66
- const relativePath = toRelativePath(mappedPath);
67
- if (relativePath === null)
68
- return null;
69
- const file = await this.readCached(relativePath);
27
+ const file = await this.files.read(mappedPath);
70
28
  if (!file)
71
29
  return null;
72
30
  const compressOption = this.options.compress;
@@ -75,44 +33,12 @@ export class LambderPublicFilesHandler {
75
33
  statusCode: 200,
76
34
  headers: {
77
35
  "Content-Type": file.mimeType,
78
- "Cache-Control": this.cacheControlFor(ctx, relativePath),
36
+ "Cache-Control": this.cacheControlFor(ctx, file.relativePath),
79
37
  },
80
38
  body: file.body,
81
39
  compress,
82
40
  });
83
41
  }
84
- /** Read from the source, caching small files in memory for warm invocations. */
85
- async readCached(relativePath) {
86
- const cached = this.fileCache.get(relativePath);
87
- if (cached)
88
- return cached;
89
- const file = await this.source.read(relativePath);
90
- if (!file)
91
- return null;
92
- const entry = {
93
- body: file.body,
94
- mimeType: file.mimeType || mimeTypeResolver.lookup(relativePath) || "application/octet-stream",
95
- };
96
- const cacheConfig = this.options.memoryCache;
97
- if (cacheConfig !== false) {
98
- const maxBytes = cacheConfig?.maxBytes ?? DEFAULT_MEMORY_CACHE_MAX_BYTES;
99
- const maxFileBytes = cacheConfig?.maxFileBytes ?? DEFAULT_MEMORY_CACHE_MAX_FILE_BYTES;
100
- if (entry.body.length <= maxFileBytes) {
101
- // Evict oldest entries until the new file fits the budget.
102
- for (const [key, value] of this.fileCache) {
103
- if (this.fileCacheBytes + entry.body.length <= maxBytes)
104
- break;
105
- this.fileCache.delete(key);
106
- this.fileCacheBytes -= value.body.length;
107
- }
108
- if (this.fileCacheBytes + entry.body.length <= maxBytes) {
109
- this.fileCache.set(relativePath, entry);
110
- this.fileCacheBytes += entry.body.length;
111
- }
112
- }
113
- }
114
- return entry;
115
- }
116
42
  cacheControlFor(ctx, relativePath) {
117
43
  const cacheOption = this.options.cacheControl;
118
44
  if (typeof cacheOption === "function")
@@ -1,7 +1,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
@@ -15,8 +15,10 @@ export { LambderTemplatingEngine } from "./core/LambderTemplatingEngine.js";
15
15
  export type { LambderTemplateData, LambderTemplatingEngineOptions } from "./core/LambderTemplatingEngine.js";
16
16
  export type { LambderResponseOptions, LambderRawResponseInit, } from "./core/LambderResponseBuilder.js";
17
17
  export type { LambderRouteMatcher, LambderCorsConfig, LambderCreateOptions, LambderSessionOptions, ConditionFunction, RouteCondition, PathParamsOf, LambderActionTools, LambderHandler, LambderIndexHtmlOptions, } from "./core/Lambder.js";
18
- export { LambderPublicFilesHandler, LambderLocalFileSource } from "./core/LambderPublicFiles.js";
19
- export type { LambderPublicFilesOptions, LambderPublicFileSource, LambderPublicFile } from "./core/LambderPublicFiles.js";
18
+ export { LambderPublicFilesHandler } from "./core/LambderPublicFiles.js";
19
+ export type { LambderPublicFilesOptions } from "./core/LambderPublicFiles.js";
20
+ export { LambderFiles, LambderLocalFileSource } from "./core/LambderFiles.js";
21
+ export type { LambderFileSource, LambderFile, LambderFilesOption, LambderFileMemoryCacheOption, LambderReadFile } from "./core/LambderFiles.js";
20
22
  export { LambderS3FileSource } from "./stores/LambderS3FileSource.js";
21
23
  export type { LambderS3FileSourceOptions } from "./stores/LambderS3FileSource.js";
22
24
  export type { LambderSessionCookieOptions } from "./session/LambderSessionController.js";
package/dist/index.js CHANGED
@@ -15,7 +15,8 @@ export { html, xml, raw, jsonScript, escapeHtml, renderHtmlValue, LambderSafeHtm
15
15
  // Comment-based HTML templating engine (build-pipeline-safe slots and conditionals, standalone)
16
16
  export { LambderTemplatingEngine } from "./core/LambderTemplatingEngine.js";
17
17
  // Public file serving
18
- export { LambderPublicFilesHandler, LambderLocalFileSource } from "./core/LambderPublicFiles.js";
18
+ export { LambderPublicFilesHandler } from "./core/LambderPublicFiles.js";
19
+ export { LambderFiles, LambderLocalFileSource } from "./core/LambderFiles.js";
19
20
  export { LambderS3FileSource } from "./stores/LambderS3FileSource.js";
20
21
  export { LambderSessionDataRefreshError, LambderSessionReadError } from "./session/LambderSessionManager.js";
21
22
  // DynamoDB-backed compressed cache (standalone, server-only)
@@ -1,5 +1,5 @@
1
1
  import type { S3Client, S3ClientConfig } from "@aws-sdk/client-s3";
2
- import type { LambderPublicFile, LambderPublicFileSource } from "../core/LambderPublicFiles.js";
2
+ import type { LambderFile, LambderFileSource } from "../core/LambderFiles.js";
3
3
  export type LambderS3FileSourceOptions = {
4
4
  bucket: string;
5
5
  /** Literal key prefix the relative path is appended to, so include the trailing slash: "web/v42/". Default: none. */
@@ -23,7 +23,7 @@ export type LambderS3FileSourceOptions = {
23
23
  * An object's Content-Type is used unless it is a generic octet-stream, in
24
24
  * which case the extension decides, as for local files.
25
25
  */
26
- export declare class LambderS3FileSource implements LambderPublicFileSource {
26
+ export declare class LambderS3FileSource implements LambderFileSource {
27
27
  private readonly bucket;
28
28
  private readonly prefix;
29
29
  private readonly clientConfig;
@@ -31,5 +31,5 @@ export declare class LambderS3FileSource implements LambderPublicFileSource {
31
31
  private sdk;
32
32
  constructor({ bucket, prefix, client, clientConfig }: LambderS3FileSourceOptions);
33
33
  private loadSdk;
34
- read(relativePath: string): Promise<LambderPublicFile | null>;
34
+ read(relativePath: string): Promise<LambderFile | null>;
35
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lambder",
3
- "version": "4.4.1",
3
+ "version": "4.5.1",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",