lambder 2.0.16 → 3.0.0

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.
Files changed (67) hide show
  1. package/Readme.md +162 -41
  2. package/dist/Lambder.d.ts +154 -46
  3. package/dist/Lambder.js +312 -166
  4. package/dist/LambderCaller.js +6 -3
  5. package/dist/LambderContext.d.ts +20 -9
  6. package/dist/LambderContext.js +57 -17
  7. package/dist/LambderCors.d.ts +12 -0
  8. package/dist/LambderCors.js +30 -0
  9. package/dist/LambderHtml.d.ts +33 -0
  10. package/dist/LambderHtml.js +62 -0
  11. package/dist/LambderMSW.d.ts +16 -1
  12. package/dist/LambderMSW.js +5 -9
  13. package/dist/LambderPublicFiles.d.ts +47 -0
  14. package/dist/LambderPublicFiles.js +108 -0
  15. package/dist/LambderResolver.d.ts +30 -31
  16. package/dist/LambderResolver.js +29 -43
  17. package/dist/LambderResponse.d.ts +71 -0
  18. package/dist/LambderResponse.js +196 -0
  19. package/dist/LambderResponseBuilder.d.ts +58 -33
  20. package/dist/LambderResponseBuilder.js +114 -167
  21. package/dist/LambderRouting.d.ts +23 -0
  22. package/dist/LambderRouting.js +67 -0
  23. package/dist/LambderSessionController.d.ts +13 -1
  24. package/dist/LambderSessionController.js +33 -10
  25. package/dist/LambderSessionManager.d.ts +3 -1
  26. package/dist/LambderSessionManager.js +15 -6
  27. package/dist/LambderTemplatingEngine.d.ts +87 -0
  28. package/dist/LambderTemplatingEngine.js +156 -0
  29. package/dist/index.d.ts +14 -2
  30. package/dist/index.js +10 -1
  31. package/dist/node-polyfills.d.ts +4 -2
  32. package/dist/node-polyfills.js +28 -0
  33. package/package.json +7 -5
  34. package/.eslintrc.cjs +0 -26
  35. package/.vscode/settings.json +0 -26
  36. package/deploy +0 -22
  37. package/dist/LambderUtils.d.ts +0 -10
  38. package/dist/LambderUtils.js +0 -70
  39. package/docs/DYNAMODB_SETUP.md +0 -96
  40. package/docs/LAMBDER_MSW.md +0 -409
  41. package/docs/TYPE_SAFE_QUICK_START.md +0 -77
  42. package/examples/msw-testing-example.ts +0 -280
  43. package/examples/secure-session-example.ts +0 -207
  44. package/examples/zod-chained-api-example.ts +0 -63
  45. package/src/Lambder.ts +0 -430
  46. package/src/LambderApiContract.ts +0 -20
  47. package/src/LambderCaller.ts +0 -238
  48. package/src/LambderContext.ts +0 -78
  49. package/src/LambderMSW.ts +0 -180
  50. package/src/LambderResolver.ts +0 -101
  51. package/src/LambderResponseBuilder.ts +0 -332
  52. package/src/LambderSessionController.ts +0 -114
  53. package/src/LambderSessionManager.ts +0 -217
  54. package/src/LambderUtils.ts +0 -75
  55. package/src/index.ts +0 -17
  56. package/src/node-polyfills.ts +0 -27
  57. package/tests/error-handling.test.ts +0 -585
  58. package/tests/file-serving.test.ts +0 -194
  59. package/tests/fixtures/public/index.html +0 -1
  60. package/tests/fixtures/public/main.css +0 -1
  61. package/tests/hooks.test.ts +0 -561
  62. package/tests/output-type-runtime.test.ts +0 -381
  63. package/tests/redirect.test.ts +0 -88
  64. package/tests/routes.test.ts +0 -543
  65. package/tests/session.test.ts +0 -1083
  66. package/tests/use-plugin.test.ts +0 -460
  67. package/tsconfig.json +0 -24
@@ -1,22 +1,33 @@
1
- import type { APIGatewayProxyEvent, APIGatewayProxyEventHeaders, Context } from "aws-lambda";
1
+ import type { APIGatewayProxyEvent, APIGatewayProxyEventV2, APIGatewayProxyEventHeaders, Context } from "aws-lambda";
2
2
  import type { LambderSessionContext } from "./LambderSessionManager.js";
3
- export type LambderRenderContext<TApiPayload = any> = {
3
+ import type { LambderHttpEventFormat } from "./LambderResponse.js";
4
+ export type LambderHttpEvent = APIGatewayProxyEvent | APIGatewayProxyEventV2;
5
+ /** True for API Gateway HTTP API / Lambda Function URL (payload v2) events. */
6
+ export declare const isV2HttpEvent: (event: unknown) => event is APIGatewayProxyEventV2;
7
+ export type LambderRenderContext<TApiPayload = any, TPathParams extends Record<string, string> = Record<string, string>> = {
4
8
  host: string;
5
9
  path: string;
6
- pathParams: Record<string, any> | null;
10
+ pathParams: TPathParams;
7
11
  method: string;
8
- get: Record<string, any>;
12
+ get: Record<string, string | undefined>;
9
13
  post: Record<string, any>;
10
- cookie: Record<string, any>;
14
+ cookie: Record<string, string>;
11
15
  session: null;
12
- apiName: string;
16
+ apiName: string | null;
13
17
  apiPayload: TApiPayload;
14
18
  headers: APIGatewayProxyEventHeaders;
15
- event: APIGatewayProxyEvent;
19
+ /** Decoded request body, exactly as received (e.g. for webhook signature verification). */
20
+ rawBody: string;
21
+ /** Client IP: CF-Connecting-IP, then X-Forwarded-For, then the API Gateway source IP. */
22
+ ip: string;
23
+ /** Case-insensitive request header lookup. */
24
+ header: (name: string) => string | undefined;
25
+ event: LambderHttpEvent;
16
26
  lambdaContext: Context;
17
27
  _otherInternal: {
18
28
  isApiCall: boolean;
19
29
  requestVersion: string | null;
30
+ eventFormat: LambderHttpEventFormat;
20
31
  setHeaderFnAccumulator: {
21
32
  key: string;
22
33
  value: string | string[];
@@ -28,7 +39,7 @@ export type LambderRenderContext<TApiPayload = any> = {
28
39
  logToApiResponseAccumulator: any[];
29
40
  };
30
41
  };
31
- export type LambderSessionRenderContext<TApiPayload = any, SessionData = any> = Omit<LambderRenderContext<TApiPayload>, 'session'> & {
42
+ export type LambderSessionRenderContext<TApiPayload = any, SessionData = any, TPathParams extends Record<string, string> = Record<string, string>> = Omit<LambderRenderContext<TApiPayload, TPathParams>, 'session'> & {
32
43
  session: LambderSessionContext<SessionData>;
33
44
  };
34
- export declare const createContext: (event: APIGatewayProxyEvent, lambdaContext: Context, apiPath: string) => LambderRenderContext<any>;
45
+ export declare const createContext: (event: LambderHttpEvent, lambdaContext: Context, apiPath: string) => LambderRenderContext;
@@ -1,21 +1,61 @@
1
1
  import cookieParser from "cookie";
2
+ /** True for API Gateway HTTP API / Lambda Function URL (payload v2) events. */
3
+ export const isV2HttpEvent = (event) => !!event && typeof event === "object"
4
+ && event.version === "2.0"
5
+ && !!event.requestContext?.http;
2
6
  export const createContext = (event, lambdaContext, apiPath) => {
3
- const host = event.headers.Host || event.headers.host || "";
4
- const path = event.path;
5
- const pathParams = null;
6
- const get = event.queryStringParameters || {};
7
- const method = event.httpMethod;
8
- const cookie = cookieParser.parse(event.headers.Cookie || event.headers.cookie || "");
9
- const headers = event.headers;
10
- // Decode body for the post
7
+ // Normalize the two API Gateway payload formats into one shape.
8
+ const eventFormat = isV2HttpEvent(event) ? "v2" : "v1";
9
+ let host;
10
+ let path;
11
+ let method;
12
+ let get;
13
+ let cookieHeader;
14
+ let sourceIp;
15
+ const headers = event.headers ?? {};
16
+ if (isV2HttpEvent(event)) {
17
+ host = headers.host || event.requestContext.domainName || "";
18
+ path = event.rawPath;
19
+ method = event.requestContext.http.method;
20
+ get = {};
21
+ for (const [key, value] of new URLSearchParams(event.rawQueryString ?? "").entries()) {
22
+ get[key] = value;
23
+ }
24
+ cookieHeader = (event.cookies ?? []).join("; ");
25
+ sourceIp = event.requestContext.http.sourceIp || "";
26
+ }
27
+ else {
28
+ host = headers.Host || headers.host || "";
29
+ path = event.path;
30
+ method = event.httpMethod;
31
+ get = event.queryStringParameters || {};
32
+ cookieHeader = headers.Cookie || headers.cookie || "";
33
+ sourceIp = event.requestContext?.identity?.sourceIp || "";
34
+ }
35
+ const cookie = cookieParser.parse(cookieHeader);
36
+ const lowercasedHeaders = {};
37
+ for (const [key, value] of Object.entries(headers)) {
38
+ if (value !== undefined)
39
+ lowercasedHeaders[key.toLowerCase()] = value;
40
+ }
41
+ const header = (name) => lowercasedHeaders[name.toLowerCase()];
42
+ const forwardedFor = lowercasedHeaders["x-forwarded-for"];
43
+ const ip = lowercasedHeaders["cf-connecting-ip"]
44
+ || (forwardedFor ? (forwardedFor.split(",")[0] ?? "").trim() : "")
45
+ || sourceIp
46
+ || "";
47
+ // Decode body: keep the raw string, then parse as JSON with urlencoded fallback.
48
+ let rawBody = "";
11
49
  let post = {};
12
50
  try {
13
- const decodedBody = event.isBase64Encoded ? (event.body ? Buffer.from(event.body, "base64").toString() : "{}") : (event.body || "{}");
51
+ rawBody = event.isBase64Encoded
52
+ ? (event.body ? Buffer.from(event.body, "base64").toString() : "")
53
+ : (event.body || "");
14
54
  try {
15
- post = JSON.parse(decodedBody) || {};
55
+ post = JSON.parse(rawBody || "{}") || {};
16
56
  }
17
57
  catch (e) {
18
- const params = new URLSearchParams(decodedBody);
58
+ const params = new URLSearchParams(rawBody);
19
59
  post = {};
20
60
  for (const [key, value] of params.entries()) {
21
61
  post[key] = value;
@@ -23,19 +63,19 @@ export const createContext = (event, lambdaContext, apiPath) => {
23
63
  }
24
64
  }
25
65
  catch (e) { }
26
- // Parse api variables
27
- const isApiCall = method === "POST" && apiPath && path === apiPath && post.apiName;
66
+ const isApiCall = !!(method === "POST" && apiPath && path === apiPath && post.apiName);
28
67
  const apiName = isApiCall ? post.apiName : null;
29
68
  const apiPayload = isApiCall ? post.payload : null;
30
- const requestVersion = isApiCall ? post.version : null;
69
+ const requestVersion = isApiCall ? (post.version ?? null) : null;
31
70
  return {
32
- host, path, pathParams, method,
71
+ host, path, pathParams: {}, method,
33
72
  get, post, cookie, event,
34
73
  session: null,
35
74
  apiName, apiPayload,
36
- headers, lambdaContext,
75
+ headers, rawBody, ip, header,
76
+ lambdaContext,
37
77
  _otherInternal: {
38
- isApiCall, requestVersion,
78
+ isApiCall, requestVersion, eventFormat,
39
79
  setHeaderFnAccumulator: [],
40
80
  addHeaderFnAccumulator: [],
41
81
  logToApiResponseAccumulator: [],
@@ -0,0 +1,12 @@
1
+ import type { LambderRenderContext } from "./LambderContext.js";
2
+ import type { LambderResponse } from "./LambderResponse.js";
3
+ export type LambderCorsConfig = {
4
+ /** "*" (default), an allowlist, or a per-request predicate. With credentials, the origin is echoed (never "*"). */
5
+ origins?: "*" | string[] | ((origin: string, ctx: LambderRenderContext) => boolean);
6
+ credentials?: boolean;
7
+ methods?: string[];
8
+ allowHeaders?: string[];
9
+ maxAge?: number;
10
+ };
11
+ /** Mutate the response with the CORS headers the config allows for this request. */
12
+ export declare const applyCorsHeaders: (config: LambderCorsConfig | null, ctx: LambderRenderContext, response: LambderResponse, isPreflight: boolean) => void;
@@ -0,0 +1,30 @@
1
+ /** Mutate the response with the CORS headers the config allows for this request. */
2
+ export const applyCorsHeaders = (config, ctx, response, isPreflight) => {
3
+ if (!config)
4
+ return;
5
+ const origin = ctx.header("origin") ?? "";
6
+ const origins = config.origins ?? "*";
7
+ let allowOrigin = null;
8
+ if (origins === "*") {
9
+ allowOrigin = config.credentials ? (origin || null) : "*";
10
+ }
11
+ else if (Array.isArray(origins)) {
12
+ allowOrigin = origin && origins.includes(origin) ? origin : null;
13
+ }
14
+ else {
15
+ allowOrigin = origin && origins(origin, ctx) ? origin : null;
16
+ }
17
+ if (!allowOrigin)
18
+ return;
19
+ response.setHeader("Access-Control-Allow-Origin", allowOrigin);
20
+ if (allowOrigin !== "*")
21
+ response.addHeader("Vary", "Origin");
22
+ if (config.credentials)
23
+ response.setHeader("Access-Control-Allow-Credentials", "true");
24
+ if (isPreflight) {
25
+ response.setHeader("Access-Control-Allow-Methods", (config.methods ?? ["GET", "POST", "OPTIONS"]).join(","));
26
+ response.setHeader("Access-Control-Allow-Headers", (config.allowHeaders ?? ["Origin", "X-Requested-With", "Content-Type", "Accept"]).join(", "));
27
+ if (config.maxAge !== undefined)
28
+ response.setHeader("Access-Control-Max-Age", String(config.maxAge));
29
+ }
30
+ };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Type-safe templating via tagged template literals: interpolated values are
3
+ * HTML-escaped by default, so templates are XSS-safe and fully type-checked by
4
+ * TypeScript (no untyped template-locals bag like EJS).
5
+ *
6
+ * - strings/numbers are escaped
7
+ * - null/undefined/booleans render as "" (enables `${cond && html`...`}`)
8
+ * - arrays are flattened (`${items.map((i) => html`<li>${i}</li>`)}`)
9
+ * - nested html`...` fragments are inserted verbatim (no double escaping)
10
+ * - raw(value) marks a trusted string as safe; never pass user input to it
11
+ *
12
+ * The same escaping rules are valid XML, so `xml` is an alias for sitemaps etc.
13
+ */
14
+ export declare class LambderSafeHtml {
15
+ readonly value: string;
16
+ constructor(value: string);
17
+ toString(): string;
18
+ }
19
+ export type LambderHtmlValue = string | number | boolean | null | undefined | LambderSafeHtml | LambderHtmlValue[];
20
+ export declare const escapeHtml: (value: string) => string;
21
+ /** Serialize any LambderHtmlValue to a string (escaped unless marked safe). */
22
+ export declare const renderHtmlValue: (value: LambderHtmlValue) => string;
23
+ export declare const html: (strings: TemplateStringsArray, ...values: LambderHtmlValue[]) => LambderSafeHtml;
24
+ /** Alias of html for XML documents (identical, XML-valid escaping). */
25
+ export declare const xml: (strings: TemplateStringsArray, ...values: LambderHtmlValue[]) => LambderSafeHtml;
26
+ /** Mark a trusted string as safe (inserted without escaping). Never pass user input. */
27
+ export declare const raw: (value: string) => LambderSafeHtml;
28
+ /**
29
+ * Server-preloaded state as <script type="application/json" id="..."> so an SPA
30
+ * can hydrate without a first fetch. Escaped so the payload can't break out of
31
+ * the script element. Read with JSON.parse(document.getElementById(id).textContent).
32
+ */
33
+ export declare const jsonScript: (id: string, data: unknown) => LambderSafeHtml;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Type-safe templating via tagged template literals: interpolated values are
3
+ * HTML-escaped by default, so templates are XSS-safe and fully type-checked by
4
+ * TypeScript (no untyped template-locals bag like EJS).
5
+ *
6
+ * - strings/numbers are escaped
7
+ * - null/undefined/booleans render as "" (enables `${cond && html`...`}`)
8
+ * - arrays are flattened (`${items.map((i) => html`<li>${i}</li>`)}`)
9
+ * - nested html`...` fragments are inserted verbatim (no double escaping)
10
+ * - raw(value) marks a trusted string as safe; never pass user input to it
11
+ *
12
+ * The same escaping rules are valid XML, so `xml` is an alias for sitemaps etc.
13
+ */
14
+ export class LambderSafeHtml {
15
+ value;
16
+ constructor(value) { this.value = value; }
17
+ toString() { return this.value; }
18
+ }
19
+ export const escapeHtml = (value) => value
20
+ .replace(/&/g, "&amp;")
21
+ .replace(/</g, "&lt;")
22
+ .replace(/>/g, "&gt;")
23
+ .replace(/"/g, "&quot;")
24
+ .replace(/'/g, "&#39;")
25
+ .replace(/`/g, "&#96;");
26
+ /** Serialize any LambderHtmlValue to a string (escaped unless marked safe). */
27
+ export const renderHtmlValue = (value) => {
28
+ if (value === null || value === undefined || typeof value === "boolean")
29
+ return "";
30
+ if (value instanceof LambderSafeHtml)
31
+ return value.value;
32
+ if (Array.isArray(value))
33
+ return value.map(renderHtmlValue).join("");
34
+ if (typeof value === "number")
35
+ return String(value);
36
+ return escapeHtml(value);
37
+ };
38
+ export const html = (strings, ...values) => {
39
+ let out = "";
40
+ for (let i = 0; i < strings.length; i++) {
41
+ out += strings[i];
42
+ if (i < values.length)
43
+ out += renderHtmlValue(values[i]);
44
+ }
45
+ return new LambderSafeHtml(out);
46
+ };
47
+ /** Alias of html for XML documents (identical, XML-valid escaping). */
48
+ export const xml = html;
49
+ /** Mark a trusted string as safe (inserted without escaping). Never pass user input. */
50
+ export const raw = (value) => new LambderSafeHtml(value);
51
+ /**
52
+ * Server-preloaded state as <script type="application/json" id="..."> so an SPA
53
+ * can hydrate without a first fetch. Escaped so the payload can't break out of
54
+ * the script element. Read with JSON.parse(document.getElementById(id).textContent).
55
+ */
56
+ export const jsonScript = (id, data) => {
57
+ const json = JSON.stringify(data)
58
+ .replace(/</g, "\\u003c")
59
+ .replace(/\u2028/g, "\\u2028")
60
+ .replace(/\u2029/g, "\\u2029");
61
+ return new LambderSafeHtml(`<script type="application/json" id="${escapeHtml(id)}">${json}</script>`);
62
+ };
@@ -1,5 +1,18 @@
1
1
  import type { ApiContractShape } from './LambderApiContract';
2
2
  type RequestHandler = any;
3
+ /** The parts of the msw module LambderMSW uses: `import { http, HttpResponse } from "msw"`. */
4
+ export type LambderMswModule = {
5
+ http: {
6
+ post: (path: string, resolver: (info: {
7
+ request: Request;
8
+ }) => any) => any;
9
+ };
10
+ HttpResponse: {
11
+ json: (body: any, init?: {
12
+ status?: number;
13
+ }) => any;
14
+ };
15
+ };
3
16
  type MockApiOptions = {
4
17
  versionExpired?: boolean;
5
18
  sessionExpired?: boolean;
@@ -14,9 +27,11 @@ export default class LambderMSW<TContract extends ApiContractShape = any> {
14
27
  private apiVersion?;
15
28
  private http;
16
29
  private HttpResponse;
17
- constructor({ apiPath, apiVersion, }: {
30
+ constructor({ apiPath, apiVersion, msw, }: {
18
31
  apiPath: string;
19
32
  apiVersion?: string;
33
+ /** Pass the msw module: `import * as msw from "msw"` (ESM-safe; no hidden require). */
34
+ msw: LambderMswModule;
20
35
  });
21
36
  /**
22
37
  * Mock an API endpoint with MSW
@@ -3,18 +3,14 @@ export default class LambderMSW {
3
3
  apiVersion;
4
4
  http;
5
5
  HttpResponse;
6
- constructor({ apiPath, apiVersion, }) {
6
+ constructor({ apiPath, apiVersion, msw, }) {
7
7
  this.apiPath = apiPath;
8
8
  this.apiVersion = apiVersion;
9
- // Dynamically import MSW - it needs to be installed by the user
10
- try {
11
- const msw = require('msw');
12
- this.http = msw.http;
13
- this.HttpResponse = msw.HttpResponse;
14
- }
15
- catch (err) {
16
- throw new Error('MSW (Mock Service Worker) is required. Install it with: npm install msw --save-dev');
9
+ if (!msw?.http || !msw?.HttpResponse) {
10
+ throw new Error('LambderMSW requires the msw module: new LambderMSW({ apiPath, msw: await import("msw") }). Install it with: npm install msw --save-dev');
17
11
  }
12
+ this.http = msw.http;
13
+ this.HttpResponse = msw.HttpResponse;
18
14
  }
19
15
  /**
20
16
  * Mock an API endpoint with MSW
@@ -0,0 +1,47 @@
1
+ import type { LambderRenderContext } from "./LambderContext.js";
2
+ import { LambderResponse } from "./LambderResponse.js";
3
+ export type LambderPublicFilesOptions = {
4
+ /**
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
+ * null/undefined to skip. Default: (ctx) => ctx.path.
8
+ */
9
+ 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
+ /** Filenames matching this get immutableCacheControl. Default: content-hash heuristic. Set false to disable. */
13
+ immutablePattern?: RegExp | false;
14
+ /** Default: "public, max-age=31536000, immutable". */
15
+ 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
+ /**
22
+ * Compression per file: "auto" (default: compressible mime + size threshold),
23
+ * true/false, or a function, e.g. (ctx) => /\.(css|js|svg)$/.test(ctx.path).
24
+ */
25
+ compress?: boolean | "auto" | ((ctx: LambderRenderContext) => boolean | "auto");
26
+ };
27
+ /**
28
+ * Terminal public-file handler registered via lambder.servePublicFiles().
29
+ * 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.
33
+ */
34
+ export declare class LambderPublicFilesHandler {
35
+ private publicPath;
36
+ private options;
37
+ private fileCache;
38
+ private fileCacheBytes;
39
+ constructor(publicPath: string, options: LambderPublicFilesOptions);
40
+ /** Serve the mapped file, or return null to fall through. */
41
+ 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
+ private cacheControlFor;
47
+ }
@@ -0,0 +1,108 @@
1
+ import mimeTypeResolver from "mime-types";
2
+ import { getFS, getPath } from "./node-polyfills.js";
3
+ import { LambderResponse } from "./LambderResponse.js";
4
+ // Content-hashed build outputs (Vite/webpack/Rollup): a [-.] separated run of
5
+ // 8+ hash chars containing at least one digit, before the extension.
6
+ const DEFAULT_IMMUTABLE_PATTERN = /[-.](?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+$/;
7
+ const DEFAULT_IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
8
+ 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
+ * Terminal public-file handler registered via lambder.servePublicFiles().
13
+ * 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.
17
+ */
18
+ export class LambderPublicFilesHandler {
19
+ publicPath;
20
+ options;
21
+ fileCache = new Map();
22
+ fileCacheBytes = 0;
23
+ constructor(publicPath, options) {
24
+ this.publicPath = publicPath;
25
+ this.options = options;
26
+ }
27
+ /** Serve the mapped file, or return null to fall through. */
28
+ 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
+ const mappedPath = this.options.path ? this.options.path(ctx) : ctx.path;
34
+ if (!mappedPath)
35
+ 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);
41
+ if (!file)
42
+ return null;
43
+ const compressOption = this.options.compress;
44
+ const compress = typeof compressOption === "function" ? compressOption(ctx) : (compressOption ?? "auto");
45
+ return new LambderResponse({
46
+ statusCode: 200,
47
+ headers: {
48
+ "Content-Type": file.mimeType,
49
+ "Cache-Control": this.cacheControlFor(ctx, filePath),
50
+ },
51
+ body: file.body,
52
+ compress,
53
+ });
54
+ }
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) {
97
+ const cacheOption = this.options.cacheControl;
98
+ if (typeof cacheOption === "function")
99
+ return cacheOption(ctx, filePath);
100
+ const immutablePattern = this.options.immutablePattern === false
101
+ ? null
102
+ : (this.options.immutablePattern ?? DEFAULT_IMMUTABLE_PATTERN);
103
+ if (immutablePattern && immutablePattern.test(filePath)) {
104
+ return this.options.immutableCacheControl ?? DEFAULT_IMMUTABLE_CACHE_CONTROL;
105
+ }
106
+ return cacheOption ?? DEFAULT_CACHE_CONTROL;
107
+ }
108
+ }
@@ -1,36 +1,35 @@
1
- import type { LambderRenderContext } from "./LambderContext.js";
2
- import LambderResponseBuilder, { LambderResolverResponse } from "./LambderResponseBuilder.js";
3
- import LambderUtils from "./LambderUtils.js";
4
- type MethodType<T, M extends keyof T> = T[M] extends (...args: any[]) => any ? T[M] : never;
5
- interface DieResolverMethods<TOutput> {
6
- raw: MethodType<LambderResponseBuilder, 'raw'>;
7
- json: MethodType<LambderResponseBuilder, 'json'>;
8
- xml: MethodType<LambderResponseBuilder, 'xml'>;
9
- html: MethodType<LambderResponseBuilder, 'html'>;
10
- redirect: MethodType<LambderResponseBuilder, 'redirect'>;
11
- status404: MethodType<LambderResponseBuilder, 'status404'>;
12
- cors: MethodType<LambderResponseBuilder, 'cors'>;
13
- fileBase64: MethodType<LambderResponseBuilder, 'fileBase64'>;
14
- file: MethodType<LambderResponseBuilder, 'file'>;
15
- ejsFile: MethodType<LambderResponseBuilder, 'ejsFile'>;
16
- ejsTemplate: MethodType<LambderResponseBuilder, 'ejsTemplate'>;
17
- api: (payload: TOutput | null, config?: Parameters<LambderResponseBuilder['api']>[1], headers?: Parameters<LambderResponseBuilder['api']>[2]) => LambderResolverResponse;
1
+ import LambderResponseBuilder, { type LambderApiResponseConfig, type LambderResponseOptions } from "./LambderResponseBuilder.js";
2
+ import type { LambderResponse } from "./LambderResponse.js";
3
+ type SyncDie<T extends (...args: any[]) => LambderResponse> = (...args: Parameters<T>) => never;
4
+ type AsyncDie<T extends (...args: any[]) => Promise<LambderResponse>> = (...args: Parameters<T>) => Promise<never>;
5
+ export interface DieResolverMethods<TOutput> {
6
+ raw: SyncDie<LambderResponseBuilder["raw"]>;
7
+ json: SyncDie<LambderResponseBuilder["json"]>;
8
+ text: SyncDie<LambderResponseBuilder["text"]>;
9
+ xml: SyncDie<LambderResponseBuilder["xml"]>;
10
+ html: SyncDie<LambderResponseBuilder["html"]>;
11
+ status: SyncDie<LambderResponseBuilder["status"]>;
12
+ status404: SyncDie<LambderResponseBuilder["status404"]>;
13
+ redirect: SyncDie<LambderResponseBuilder["redirect"]>;
14
+ versionExpired: SyncDie<LambderResponseBuilder["versionExpired"]>;
15
+ fileBase64: SyncDie<LambderResponseBuilder["fileBase64"]>;
16
+ api: (payload: TOutput | null, config?: LambderApiResponseConfig, options?: LambderResponseOptions) => never;
17
+ apiBinary: (payload: TOutput | null, config?: LambderApiResponseConfig, options?: LambderResponseOptions) => never;
18
+ file: AsyncDie<LambderResponseBuilder["file"]>;
19
+ templateFile: AsyncDie<LambderResponseBuilder["templateFile"]>;
18
20
  }
21
+ /**
22
+ * Response builder passed to route/api handlers and hooks.
23
+ *
24
+ * `res.die.*` builds the response and THROWS it, immediately halting the
25
+ * request at any call depth (handlers, hooks, nested service functions).
26
+ * Lambder's render pipeline catches thrown LambderResponse instances and uses
27
+ * them as the response. Plain `throw res.html(...)` works the same way.
28
+ */
19
29
  export default class LambderResolver<TOutput = any> extends LambderResponseBuilder<TOutput> {
20
- resolve: (response: LambderResolverResponse) => void;
21
- reject: (err: Error) => void;
22
30
  die: DieResolverMethods<TOutput>;
23
- constructor({ isCorsEnabled, publicPath, apiVersion, lambderUtils, ctx, resolve, reject }: {
24
- isCorsEnabled: boolean;
25
- publicPath: string;
26
- apiVersion?: string | null;
27
- lambderUtils: LambderUtils;
28
- ctx: LambderRenderContext<any>;
29
- resolve: (response: LambderResolverResponse) => void;
30
- reject: (err: Error) => void;
31
- });
32
- api(payload: TOutput | null, config?: Parameters<LambderResponseBuilder['api']>[1], headers?: Parameters<LambderResponseBuilder['api']>[2]): LambderResolverResponse;
33
- private autoResolve;
34
- private autoResolvePromise;
31
+ constructor(...args: ConstructorParameters<typeof LambderResponseBuilder>);
32
+ api(payload: TOutput | null, config?: LambderApiResponseConfig, options?: LambderResponseOptions): LambderResponse;
33
+ apiBinary(payload: TOutput | null, config?: LambderApiResponseConfig, options?: LambderResponseOptions): LambderResponse;
35
34
  }
36
35
  export {};