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
package/Readme.md CHANGED
@@ -1,8 +1,8 @@
1
- # Lambder - Serverless NodeJS Web Framework (v2.0)
1
+ # Lambder - Serverless NodeJS Web Framework (v3)
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 v2.0:** Full type safety with Zod schemas and runtime validation!
5
+ **New in v3:** Public file serving with `servePublicFiles()` + `serveIndexHtml()`, unified `addAction()` for non-HTTP triggers, automatic gzip + ETag, thrown responses with a real `die`, the comment-based `LambderTemplatingEngine`, type-safe `html`/`xml` tagged templates, and API Gateway HTTP API (payload v2) / Lambda Function URL support.
6
6
 
7
7
  ## Features
8
8
 
@@ -12,7 +12,7 @@ Lambder is a highly opinionated dynamic serverless framework designed to facilit
12
12
  - **Session Management**: Built-in session management to secure and personalize user experiences.
13
13
  - **Flexible Hooks System**: Employ hooks to execute code at different stages of the request lifecycle.
14
14
  - **Error Handling**: Comprehensive error handling capabilities, including global error handlers and route-specific fallbacks.
15
- - **Seamless Integration**: Designed to work effortlessly with AWS Lambda and API Gateway.
15
+ - **Seamless Integration**: Works with API Gateway REST APIs (payload v1), HTTP APIs (payload v2) and Lambda Function URLs; the payload format is detected per event.
16
16
 
17
17
  ## Installation
18
18
 
@@ -43,6 +43,7 @@ lambder
43
43
  tableRegion: "us-east-1",
44
44
  sessionSalt: "CHANGE-THIS-TO-A-SECURE-RANDOM-STRING"
45
45
  })
46
+ // true allows any origin; or configure: { origins: ["https://app.example.com"], credentials: true }
46
47
  .enableCors(true);
47
48
 
48
49
  // Define type-safe APIs with Zod schemas
@@ -97,11 +98,17 @@ lambder
97
98
  .addRoute((ctx)=>ctx.path === '/hello-fn-route', (ctx, res) => {
98
99
  return res.html("Hello from a function route");
99
100
  })
100
- // Match all other paths and serve static files from publicPath
101
- .addRoute("/(.*)", (ctx, res)=>{
102
- return res.file(ctx.path, {}, "index.html");
101
+ // Match on method/host with a structured matcher
102
+ .addRoute({ path: "/stripe-webhook", method: "POST" }, (ctx, res) => {
103
+ return res.json({ received: true });
103
104
  })
104
- // Set a fallback handler for unmatched routes
105
+ // Serve real files from publicPath. This is a terminal fallback slot, NOT
106
+ // a catch-all route, so it can never shadow routes registered after it.
107
+ .servePublicFiles()
108
+ // Serve the app shell for GET/HEAD page requests nothing else handled
109
+ // (see "Hosting a frontend build" below).
110
+ .serveIndexHtml()
111
+ // Set a fallback handler for whatever remains
105
112
  .setRouteFallbackHandler((ctx, res) => {
106
113
  return res.status404("Not Found");
107
114
  })
@@ -111,7 +118,7 @@ lambder
111
118
  })
112
119
  // Handle Zod validation errors for API inputs
113
120
  .setApiInputValidationErrorHandler((ctx, res, zodError) => {
114
- return res.api(null, { errorMessage: zodError.errors });
121
+ return res.api(null, { errorMessage: zodError.issues });
115
122
  })
116
123
  // Global error handler
117
124
  .setGlobalErrorHandler((err, ctx, res) => {
@@ -172,6 +179,40 @@ export type ApiContractType = typeof lambder.ApiContract;
172
179
  ```
173
180
 
174
181
 
182
+ ### Actions (addAction)
183
+
184
+ The same Lambda often also receives non-HTTP invocations: EventBridge/CloudWatch schedules, custom events, SQS batches. `addAction(filter, action)` registers a handler whose filter sees the **raw Lambda event** (always) and the **HTTP context** (`ctx`, or `null` for non-HTTP invocations). `getHandler()` dispatches everything.
185
+
186
+ ```typescript
187
+ lambder
188
+ // Non-HTTP trigger: filter on the raw event (one plain function, no DSL)
189
+ .addAction(
190
+ (event) => (event as { source?: string })?.source === "app.reconciliation",
191
+ async (event, { lambdaContext }) => {
192
+ await reconcileEverything();
193
+ return { reconciled: true };
194
+ },
195
+ )
196
+ // Type-guard filters give a typed event in the handler
197
+ .addAction(
198
+ (event): event is ScheduledEvent => isScheduledEvent(event),
199
+ async (event) => runMaintenance(),
200
+ )
201
+ // HTTP interception: ctx is present, and the action must return a response via tools.res
202
+ .addAction(
203
+ (event, ctx) => ctx !== null && ctx.host.endsWith("dev.example.com") && ctx.cookie.dev !== "atlas",
204
+ async (event, { res }) => res!.status404("Not found"),
205
+ );
206
+
207
+ export const handler = lambder.getHandler();
208
+ ```
209
+
210
+ Semantics:
211
+ - The handler's second argument is `{ ctx, res, lambdaContext }`, discriminated on `ctx`: both `ctx` and `res` are non-null for HTTP invocations and `null` otherwise, so `if (tools.ctx)` narrows both
212
+ - **HTTP invocations**: actions join the same first-match chain as routes/APIs (registration order) and must return a response built with `tools.res`
213
+ - **Non-HTTP invocations**: actions are the only handlers; return values pass through to Lambda untouched (e.g. `{ batchItemFailures }` for SQS) and errors **rethrow** (never routed to `setGlobalErrorHandler`), preserving Lambda-native retry/DLQ semantics
214
+ - A trailing `.addAction(() => true, handler)` acts as the fallback for unmatched non-HTTP events; with no match at all, a descriptive error is thrown
215
+
175
216
  ### Hooks
176
217
 
177
218
  Lambder provides hooks to execute code at different stages of the request lifecycle.
@@ -182,7 +223,7 @@ lambder
182
223
  .addHook("beforeRender", async (ctx, res) => {
183
224
  // Perform actions before rendering
184
225
  console.log("Request received:", ctx.path);
185
- return ctx; // Return modified context or throw an Error
226
+ return ctx; // Return the (modified) ctx to continue, a response to short-circuit, or throw an Error
186
227
  })
187
228
  // After render hook
188
229
  .addHook("afterRender", async (ctx, res, response) => {
@@ -235,30 +276,104 @@ Access the session controller with `lambder.getSessionController(ctx)`:
235
276
  | `endSessionAll()` | End all sessions for this sessionKey (all devices) |
236
277
  | `regenerateSession()` | Regenerate token (use after password change) |
237
278
 
238
- ### EJS Templates
279
+ ### Type-Safe Templating (html / xml)
280
+
281
+ Lambder ships zero-dependency tagged template literals instead of a template engine. Interpolated values are HTML-escaped automatically, and everything is plain TypeScript, so templates are fully type-checked and refactorable.
282
+
283
+ ```typescript
284
+ import { html, xml, raw } from "lambder";
285
+
286
+ // Values are escaped by default (XSS-safe):
287
+ const page = html`<h1>Hello ${user.name}</h1>`;
288
+
289
+ // Arrays flatten; nested fragments are not double-escaped:
290
+ const list = html`<ul>${items.map((item) => html`<li>${item.label}</li>`)}</ul>`;
291
+
292
+ // Conditionals: null/undefined/false render as empty string:
293
+ const nav = html`${isLoggedIn && html`<a href="/logout">Log out</a>`}`;
294
+
295
+ // raw() inserts trusted markup verbatim (never pass user input):
296
+ const head = html`${raw('<meta charset="utf-8">')}`;
297
+
298
+ // Works for XML too (xml is an alias of html):
299
+ return res.xml(xml`<?xml version="1.0" encoding="UTF-8"?>
300
+ <urlset>${urls.map((loc) => xml`<url><loc>${loc}</loc></url>`)}</urlset>`);
301
+ ```
302
+
303
+ ### Templating with LambderTemplatingEngine
239
304
 
240
- EJS templates have the variables `page` and `partial` available:
305
+ `LambderTemplatingEngine` is a standalone, comment-only HTML template engine. Every construct is an HTML comment, so templates survive HTML build pipelines (e.g. Vite) untouched, and during frontend development the browser simply renders the default content because the markers are invisible. It can template anything: SPA shells, emails, error pages.
241
306
 
242
- - **Template**: The main file called with `await res.ejsFile('template-file')`. Has `page` variable.
243
- - **Partial**: Included from a template with `<%- await include('partial/header.html.ejs', partialData) -%>`. Has both `page` and `partial` variables.
307
+ **Syntax** (everything is an HTML comment):
244
308
 
245
- Example template:
246
309
  ```html
247
- <div>
248
- <%- await include('partial/header.html.ejs', partialData) -%>
249
- <div>Page Variable: <pre><%~ JSON.stringify(page, null, 2) %></pre></div>
250
- <%- await include('partial/footer.html.ejs', partialData) -%>
251
- </div>
310
+ <title><!--slot:title-->Default Title<!--/slot:title--></title> <!-- replaceable region -->
311
+ <!--slot:head/--> <!-- insert-only point -->
312
+ <!--if:isRtl--><body dir="rtl"><!--else--><body><!--/if:isRtl--> <!-- conditional -->
313
+ <!--if:!minimal--><nav>...</nav><!--/if:!minimal--> <!-- negated conditional -->
252
314
  ```
253
315
 
254
- Example partial:
316
+ **Usage** (standalone, importable directly from `lambder`):
317
+
318
+ ```typescript
319
+ import { LambderTemplatingEngine, html, jsonScript } from "lambder";
320
+
321
+ // Compile once (throws early on unclosed/mismatched blocks) ...
322
+ const template = await LambderTemplatingEngine.fromFile("./templates/page.html");
323
+ // ... render many times, per request:
324
+ const output = template.render({
325
+ title: userInput, // plain values are escaped (XSS-safe)
326
+ head: html`<link rel="canonical" href="${canonicalUrl}" />
327
+ ${jsonScript("app-data", preloadedState)}`, // html`...`/raw()/jsonScript() inserted verbatim
328
+ isRtl: lang === "ar", // condition names use truthiness
329
+ });
330
+
331
+ // Runtime introspection (dynamically typed by design):
332
+ template.slotNames; // e.g. ["title", "head"]
333
+ template.conditionNames; // e.g. ["isRtl", "minimal"]
334
+ template.has("title"); // true
335
+ ```
336
+
337
+ Rules:
338
+ - Slot values: strings/numbers escaped; `html`/`raw()`/`jsonScript()` verbatim; arrays flattened; `null`/`undefined`/`false` keep the slot's default content
339
+ - Unknown data keys are ignored, so one data object can serve several templates with different slots
340
+ - Blocks nest freely; there are intentionally no loops or inline expressions: build dynamic lists server-side with `html` and pass them into a slot
341
+ - Attribute-position values (e.g. `<html lang="...">`) are handled with if/else around whole-tag variants
342
+
343
+ ### Hosting a frontend build (servePublicFiles + templateFile)
344
+
345
+ Lambder has no SPA-specific machinery; hosting a frontend build is a recipe built from three generic primitives:
346
+
347
+ 1. **`servePublicFiles(options?)`**: a terminal slot that serves real files under `publicPath`. It runs only when no route or API matched, so unlike a `"/(.*)"` catch-all route it can never shadow routes registered after it. Traversal-safe, mime-typed, memory-cached for warm invocations, immutable Cache-Control for content-hashed assets (`app-4f8a1b2c.js`), automatic ETag/gzip. When the file does not exist, the request **falls through**.
348
+ 2. **`serveIndexHtml(handler?, options?)`**: the next slot in the fallback chain, gated by a built-in filter: only `GET`/`HEAD` (option `methods`) and, by default, only paths that do not look like files (`skipFilePaths: true`, so a missing `/logo.png` is a 404, not a soft-404 HTML shell). Optional `redirectTrailingSlash` (default false) 301s `/about/` to `/about`. Gated-out requests fall through to `setRouteFallbackHandler`. Without a handler it serves `publicPath/index.html` (option `indexFile`) via `res.templateFile` with `no-cache`, so plain hosting is zero-config and templating is opt-in.
349
+ 3. **`res.templateFile(path, data?, options?)`**: render any HTML file under `publicPath` through `LambderTemplatingEngine` (compiled once, cached across warm invocations) and return it as an HTML response.
350
+
255
351
  ```html
256
- <div>
257
- <div>Page Variable: <pre><%~ JSON.stringify(page, null, 2) %></pre></div>
258
- <div>Partial Variable: <pre><%~ JSON.stringify(partial, null, 2) %></pre></div>
259
- </div>
352
+ <!-- frontend index.html (markers survive the Vite build; defaults show in vite dev) -->
353
+ <title><!--slot:title-->My App<!--/slot:title--></title>
354
+ <!--slot:head/-->
260
355
  ```
261
356
 
357
+ ```typescript
358
+ lambder
359
+ // Multi-tenant roots are just app logic in the path mapper:
360
+ .servePublicFiles({ path: (ctx) => `${getBrandFromHost(ctx.host)}${ctx.path}` })
361
+ // Only GET/HEAD page requests reach this handler:
362
+ .serveIndexHtml(async (ctx, res) => {
363
+ return res.templateFile(`${getBrandFromHost(ctx.host)}/index.html`, {
364
+ title: pageTitle(ctx), // escaped automatically
365
+ head: html`<link rel="canonical" href="${canonicalUrl(ctx)}" />
366
+ ${jsonScript("app-data", preloadedState(ctx))}`,
367
+ isRtl: activeLang(ctx) === "ar",
368
+ }, { cacheControl: "no-cache" });
369
+ });
370
+
371
+ // Or, zero-config for a single-tenant app without templating:
372
+ lambder.servePublicFiles().serveIndexHtml();
373
+ ```
374
+
375
+ Files without template markers can opt into virtual slots (`title` = the `<title>` element, `head` = before `</head>`) with `res.templateFile(path, data, { htmlVirtualSlots: true })`. File cache policies (`cacheControl`, `immutablePattern`, `memoryCache`) are configurable via `LambderPublicFilesOptions`, and both slots take an explicit compression policy: `servePublicFiles({ compress: (ctx) => /\.(css|js|svg)$/.test(ctx.path) })` and `serveIndexHtml(handler, { compress: true })` (default "auto").
376
+
262
377
  ### Render Context (ctx) Variables
263
378
 
264
379
  The `ctx` object provides access to request data:
@@ -271,9 +386,12 @@ The `ctx` object provides access to request data:
271
386
  | `method` | HTTP method | `"GET"`, `"POST"` |
272
387
  | `get` | Query parameters | `{ page: "1" }` |
273
388
  | `post` | POST body (parsed) | `{ name: "John" }` |
389
+ | `rawBody` | Decoded request body as received (webhook signatures) | `'{"a":1}'` |
390
+ | `ip` | Client IP (CF-Connecting-IP / X-Forwarded-For / source IP) | `"1.2.3.4"` |
391
+ | `header(name)` | Case-insensitive request header lookup | `ctx.header("accept-language")` |
274
392
  | `cookie` | Cookies | `{ rememberMe: "true" }` |
275
393
  | `headers` | Request headers | `{ "Content-Type": "..." }` |
276
- | `event` | Raw APIGatewayProxyEvent | - |
394
+ | `event` | Raw Lambda event (APIGatewayProxyEvent or APIGatewayProxyEventV2) | - |
277
395
  | `lambdaContext` | AWS Lambda Context | - |
278
396
  | `apiName` | API name (for API calls) | `"getUser"` |
279
397
  | `apiPayload` | Validated input | `{ userId: "123" }` |
@@ -286,27 +404,29 @@ The `ctx` object provides access to request data:
286
404
  - `res.setHeader(key, value)` - Sets a header (replaces existing values)
287
405
  - `res.logToApiResponse(data)` - Adds data to logList in API responses (debugging)
288
406
 
289
- **Response Methods**:
407
+ **Response Methods** (all accept an options object: `{ statusCode?, headers?, cacheControl?, compress?, etag? }`):
290
408
 
291
409
  | Method | Description |
292
410
  |--------|-------------|
293
- | `res.raw(param)` | Custom HTTP response |
294
- | `res.json(data, headers?)` | JSON response |
295
- | `res.xml(data)` | XML response (base64 encoded) |
296
- | `res.html(data, headers?)` | HTML response (base64 encoded) |
297
- | `res.redirect(url, statusCode?, headers?)` | Redirect (default: 302) |
298
- | `res.status404(data, headers?)` | 404 Not Found response |
299
- | `res.cors()` | 200 OK with CORS headers (preflight) |
300
- | `res.fileBase64(base64, mimeType, headers?)` | File from base64 content |
301
- | `res.file(path, headers?, fallbackPath?)` | Serve file from public directory |
302
- | `await res.ejsFile(path, pageData, headers?)` | Render EJS file |
303
- | `await res.ejsTemplate(template, pageData, headers?)` | Render EJS template string |
304
- | `res.api(payload, config?, headers?)` | Standardized API response |
305
- | `res.apiBinary(payload, config?, headers?)` | Gzip-compressed API response |
411
+ | `res.raw(init)` | Custom HTTP response |
412
+ | `res.json(data, options?)` | JSON response |
413
+ | `res.text(data, options?)` | Plain text response |
414
+ | `res.xml(data, options?)` | XML response (accepts xml\`...\` templates) |
415
+ | `res.html(data, options?)` | HTML response (accepts html\`...\` templates) |
416
+ | `res.status(code, body?, options?)` | Response with any status code |
417
+ | `res.redirect(url, statusCode?, options?)` | Redirect (default: 302) |
418
+ | `res.status404(data, options?)` | 404 Not Found response |
419
+ | `res.fileBase64(base64, mimeType, options?)` | File from base64 content |
420
+ | `await res.file(path, options? & { fallback? })` | Serve file from public directory (404 when missing) |
421
+ | `await res.templateFile(path, data?, options?)` | Render an HTML file via LambderTemplatingEngine (cached; throws when missing) |
422
+ | `res.api(payload, config?, options?)` | Standardized API response |
423
+ | `res.apiBinary(payload, config?, options?)` | API response with forced gzip |
424
+
425
+ Responses are finalized once at the end of the request: automatic gzip (when the client accepts it, the body is compressible and large enough), automatic ETag + `If-None-Match` 304 handling on GET/HEAD, and a clear error if the body would exceed Lambda's ~6MB cap. Override per response with `compress: true | false` and `etag: false`.
306
426
 
307
427
  **API Config Options**: `{ notAuthorized, message, errorMessage, versionExpired, sessionExpired, logList }`
308
428
 
309
- **Die Methods**: `res.die.*` - Same as above but immediately returns, skipping `afterRender` hooks.
429
+ **Die Methods**: `res.die.*` - Builds the response and throws it, immediately halting the request at any call depth (handlers, hooks, nested helper functions). Plain `throw res.html(...)` works the same way.
310
430
 
311
431
  ## Frontend Usage with LambderCaller
312
432
 
@@ -362,6 +482,7 @@ import type { ApiContractType } from './backend/handler';
362
482
 
363
483
  const lambderMSW = new LambderMSW<ApiContractType>({
364
484
  apiPath: '/api',
485
+ msw: await import('msw'),
365
486
  });
366
487
 
367
488
  const handlers = [
@@ -398,4 +519,4 @@ Contributions are welcome! Especially for documentation. If you have an idea for
398
519
 
399
520
  ## License
400
521
 
401
- This project is licensed under the [MIT License](LICENSE).
522
+ This project is licensed under the [MIT License](License.md).
package/dist/Lambder.d.ts CHANGED
@@ -1,23 +1,75 @@
1
- import { z } from "zod";
2
- import type { APIGatewayProxyEvent, Context } from "aws-lambda";
1
+ import type { z } from "zod";
2
+ import type { Context } from "aws-lambda";
3
3
  import LambderResolver from "./LambderResolver.js";
4
- import LambderResponseBuilder, { LambderResolverResponse } from "./LambderResponseBuilder.js";
5
- import LambderUtils from "./LambderUtils.js";
6
- import LambderSessionController from "./LambderSessionController.js";
4
+ import LambderResponseBuilder from "./LambderResponseBuilder.js";
5
+ import { LambderResponse, type LambderHttpResponse } from "./LambderResponse.js";
6
+ import { type ConditionFunction, type LambderRouteMatcher, type PathParamsOf } from "./LambderRouting.js";
7
+ import { type LambderCorsConfig } from "./LambderCors.js";
8
+ import LambderSessionController, { type LambderSessionCookieOptions } from "./LambderSessionController.js";
9
+ import { type LambderPublicFilesOptions } from "./LambderPublicFiles.js";
7
10
  import type { MergeContract } from "./LambderApiContract.js";
8
- import { type LambderRenderContext, type LambderSessionRenderContext } from "./LambderContext.js";
11
+ import { type LambderHttpEvent, type LambderRenderContext, type LambderSessionRenderContext } from "./LambderContext.js";
12
+ export type { PathParamsOf, RouteCondition, ConditionFunction, LambderRouteMatcher } from "./LambderRouting.js";
13
+ export type { LambderCorsConfig } from "./LambderCors.js";
14
+ type MaybePromise<T> = T | Promise<T>;
9
15
  type Path = `/${string}`;
10
- type ConditionFunction = (ctx: LambderRenderContext<any>) => boolean;
11
- type ActionFunction = (ctx: LambderRenderContext<any>, resolver: LambderResolver) => LambderResolverResponse | Promise<LambderResolverResponse>;
12
- type SessionActionFunction<SessionData = any> = (ctx: LambderSessionRenderContext<any, SessionData>, resolver: LambderResolver) => LambderResolverResponse | Promise<LambderResolverResponse>;
13
- type HookCreatedFunction = (lambderInstance: Lambder) => Promise<void>;
14
- type HookBeforeRenderFunction = (ctx: LambderRenderContext<any>, resolver: LambderResolver) => LambderRenderContext<any> | Error | Promise<LambderRenderContext<any> | Error>;
15
- type HookAfterRenderFunction = (ctx: LambderRenderContext<any>, resolver: LambderResolver, response: LambderResolverResponse) => LambderResolverResponse | Error | Promise<LambderResolverResponse | Error>;
16
- type HookFallbackFunction = (ctx: LambderRenderContext<any>, resolver: LambderResolver) => void | Promise<void>;
17
- type GlobalErrorHandlerFunction = (err: Error, ctx: LambderRenderContext<any> | null, response: LambderResponseBuilder, logListToApiResponse?: any[]) => LambderResolverResponse | Promise<LambderResolverResponse>;
18
- type RouteFallbackHandlerFunction = (ctx: LambderRenderContext<any>, resolver: LambderResolver) => LambderResolverResponse;
19
- type ApiFallbackHandlerFunction = (ctx: LambderRenderContext<any>, resolver: LambderResolver) => LambderResolverResponse;
20
- type ApiInputValidationErrorHandlerFunction = (ctx: LambderRenderContext<any>, resolver: LambderResolver, zodError: z.ZodError) => LambderResolverResponse | Promise<LambderResolverResponse>;
16
+ type ActionFunction = (ctx: LambderRenderContext, resolver: LambderResolver) => MaybePromise<LambderResponse>;
17
+ type SessionActionFunction<SessionData = any> = (ctx: LambderSessionRenderContext<any, SessionData>, resolver: LambderResolver) => MaybePromise<LambderResponse>;
18
+ type HookCreatedFunction = (lambderInstance: Lambder<any, any>) => void | Promise<void>;
19
+ /** Return the (possibly replaced) ctx to continue, a LambderResponse to short-circuit, or an Error to fail. */
20
+ type HookBeforeRenderFunction = (ctx: LambderRenderContext, resolver: LambderResolver) => MaybePromise<LambderRenderContext | LambderResponse | Error>;
21
+ type HookAfterRenderFunction = (ctx: LambderRenderContext, resolver: LambderResolver, response: LambderResponse) => MaybePromise<LambderResponse | Error>;
22
+ type HookFallbackFunction = (ctx: LambderRenderContext, resolver: LambderResolver) => void | Promise<void>;
23
+ type GlobalErrorHandlerFunction = (err: Error, ctx: LambderRenderContext | null, response: LambderResponseBuilder, logListToApiResponse?: any[]) => MaybePromise<LambderResponse>;
24
+ type FallbackHandlerFunction = (ctx: LambderRenderContext, resolver: LambderResolver) => MaybePromise<LambderResponse>;
25
+ type ApiInputValidationErrorHandlerFunction = (ctx: LambderRenderContext, resolver: LambderResolver, zodError: z.ZodError) => MaybePromise<LambderResponse>;
26
+ export type LambderIndexHtmlOptions = {
27
+ /** Methods that reach the index handler. Default: ["GET", "HEAD"]. */
28
+ methods?: string[];
29
+ /**
30
+ * Skip paths whose last segment has an extension (missing assets by this
31
+ * point; a 200 HTML shell would be a soft-404). Default: true.
32
+ */
33
+ skipFilePaths?: boolean;
34
+ /** 301-redirect trailing-slash paths to the canonical no-slash URL. Default: false. */
35
+ redirectTrailingSlash?: boolean;
36
+ /** Shell served by the default handler. Default: "index.html". */
37
+ indexFile?: string | ((ctx: LambderRenderContext) => string);
38
+ /** Compression override, like servePublicFiles: "auto" (default), true/false, or (ctx) => boolean | "auto". */
39
+ compress?: boolean | "auto" | ((ctx: LambderRenderContext) => boolean | "auto");
40
+ };
41
+ /**
42
+ * Second argument of an addAction handler. Discriminated on `ctx`: HTTP
43
+ * invocations get the full context and a resolver, non-HTTP invocations get
44
+ * null for both.
45
+ */
46
+ export type LambderActionTools = {
47
+ ctx: LambderRenderContext;
48
+ res: LambderResolver;
49
+ lambdaContext: Context;
50
+ } | {
51
+ ctx: null;
52
+ res: null;
53
+ lambdaContext: Context;
54
+ };
55
+ /** Overloaded handler type returned by getHandler(): HTTP events get a typed response, others dispatch to actions. */
56
+ export type LambderHandler = {
57
+ (event: LambderHttpEvent, context: Context): Promise<LambderHttpResponse>;
58
+ (event: unknown, context: Context): Promise<unknown>;
59
+ };
60
+ export type LambderConstructorOptions = {
61
+ publicPath?: string;
62
+ apiPath?: string;
63
+ apiVersion?: string;
64
+ /** Automatic gzip for compressible responses. Default: { minBytes: 860 }. Set false to disable. */
65
+ compression?: false | {
66
+ minBytes?: number;
67
+ };
68
+ /** Automatic ETag + If-None-Match 304 on GET/HEAD 200 responses. Default: true. */
69
+ etag?: boolean;
70
+ /** Guard threshold for Lambda's ~6MB response cap. Default: 5,500,000. */
71
+ maxResponseBytes?: number;
72
+ };
21
73
  /**
22
74
  * Main Lambder class for building type-safe serverless APIs
23
75
  *
@@ -36,9 +88,7 @@ type ApiInputValidationErrorHandlerFunction = (ctx: LambderRenderContext<any>, r
36
88
  export default class Lambder<TSessionData = any, _TContract extends Record<string, any> = {}> {
37
89
  apiPath: string;
38
90
  apiVersion: null | string;
39
- isCorsEnabled: boolean;
40
91
  publicPath: string;
41
- ejsPath: string;
42
92
  /**
43
93
  * Type property for extracting the API contract
44
94
  * Use this to export your API types to the frontend
@@ -52,57 +102,115 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
52
102
  readonly ApiContract: _TContract;
53
103
  private actionList;
54
104
  private hookList;
105
+ private createdHooks;
106
+ private initPromise;
55
107
  private globalErrorHandler;
56
108
  private routeFallbackHandler;
57
109
  private apiFallbackHandler;
58
110
  private apiInputValidationErrorHandler;
59
- utils: LambderUtils;
111
+ private sessionExpiredRouteHandler;
112
+ private publicFilesHandler;
113
+ private indexHtmlConfig;
114
+ private eventActionList;
115
+ private corsConfig;
116
+ private finalizeOptions;
60
117
  private lambderSessionManager?;
118
+ private sessionCookieOptions;
61
119
  private sessionTokenCookieKey;
62
120
  private sessionCsrfCookieKey;
63
- constructor({ publicPath, apiPath, ejsPath, apiVersion }: {
64
- publicPath: string;
65
- apiPath?: string;
66
- ejsPath?: string;
67
- apiVersion?: string;
68
- });
69
- enableCors(isCorsEnabled: boolean): this;
70
- enableDdbSession({ tableName, tableRegion, sessionSalt, enableSlidingExpiration }: {
121
+ constructor(options?: LambderConstructorOptions);
122
+ enableCors(config: boolean | LambderCorsConfig): this;
123
+ enableDdbSession({ tableName, tableRegion, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, cookie, partitionKey, sortKey, }: {
71
124
  tableName: string;
72
125
  tableRegion: string;
73
126
  sessionSalt: string;
74
127
  enableSlidingExpiration?: boolean;
75
- }, { partitionKey, sortKey }?: {
76
- partitionKey: string;
77
- sortKey: string;
128
+ /** Min seconds between sliding-expiration writes. Default: max(60, 5% of TTL). */
129
+ slidingWriteIntervalSeconds?: number;
130
+ /** Session cookie attributes, e.g. { domain: ".example.com" } for cross-subdomain sessions. */
131
+ cookie?: LambderSessionCookieOptions;
132
+ partitionKey?: string;
133
+ sortKey?: string;
78
134
  }): this;
79
135
  setSessionCookieKey(sessionTokenCookieKey: string, sessionCsrfCookieKey: string): this;
80
- setRouteFallbackHandler(routeFallbackHandler: RouteFallbackHandlerFunction): this;
81
- setApiFallbackHandler(apiFallbackHandler: ApiFallbackHandlerFunction): this;
136
+ setRouteFallbackHandler(routeFallbackHandler: FallbackHandlerFunction): this;
137
+ setApiFallbackHandler(apiFallbackHandler: FallbackHandlerFunction): this;
82
138
  setApiInputValidationErrorHandler(apiInputValidationErrorHandler: ApiInputValidationErrorHandlerFunction): this;
83
139
  setGlobalErrorHandler(globalErrorHandler: GlobalErrorHandlerFunction): this;
84
- private getPatternMatch;
85
- private testPatternMatch;
86
- private handleNoMatchedAction;
87
- addRoute(condition: Path | ConditionFunction | RegExp, actionFn: ActionFunction): this;
88
- addSessionRoute(condition: Path | ConditionFunction | RegExp, actionFn: SessionActionFunction<TSessionData>): this;
140
+ /** Response for session routes when the session is missing/expired (non-API). Default: 401. */
141
+ setSessionExpiredRouteHandler(handler: FallbackHandlerFunction): this;
142
+ /**
143
+ * Terminal public-file layer. Runs only when no route matched, so it can
144
+ * never shadow routes registered after it. Serves real files under
145
+ * publicPath (traversal-safe, mime-typed, memory-cached, immutable-cache
146
+ * heuristic for content-hashed assets); when the file does not exist the
147
+ * request falls through to setRouteFallbackHandler, where the app decides
148
+ * what remains (e.g. render an app shell with res.templateFile).
149
+ */
150
+ servePublicFiles(options?: LambderPublicFilesOptions): this;
151
+ /**
152
+ * Serve the app shell for page requests that nothing else handled. Runs
153
+ * after servePublicFiles in the fallback chain, gated by a built-in
154
+ * filter: only configured methods (default GET/HEAD) and, by default, only
155
+ * paths that do not look like files. Gated-out requests fall through to
156
+ * setRouteFallbackHandler. Without a handler, publicPath/index.html is
157
+ * served via res.templateFile (markers optional) with no-cache.
158
+ */
159
+ serveIndexHtml(handler?: FallbackHandlerFunction, options?: LambderIndexHtmlOptions): this;
160
+ /** Apply the serveIndexHtml gates; null means fall through. */
161
+ private tryServeIndexHtml;
162
+ addRoute<TPath extends Path>(condition: TPath, actionFn: (ctx: LambderRenderContext<any, PathParamsOf<TPath>>, resolver: LambderResolver) => MaybePromise<LambderResponse>): this;
163
+ addRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: ActionFunction): this;
164
+ addSessionRoute<TPath extends Path>(condition: TPath, actionFn: (ctx: LambderSessionRenderContext<any, TSessionData, PathParamsOf<TPath>>, resolver: LambderResolver) => MaybePromise<LambderResponse>): this;
165
+ addSessionRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: SessionActionFunction<TSessionData>): this;
89
166
  use<_TNewContract extends Record<string, any>>(plugin: (lambder: Lambder<TSessionData, _TContract>) => Lambder<TSessionData, _TNewContract>): Lambder<TSessionData, _TNewContract extends _TContract ? _TNewContract : (_TContract & _TNewContract)>;
90
167
  addApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny>(name: TName, schema: {
91
168
  input: TInput;
92
169
  output: TOutput;
93
- }, handler: (ctx: LambderRenderContext<z.infer<TInput>>, resolver: LambderResolver<z.infer<TOutput>>) => LambderResolverResponse | Promise<LambderResolverResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>>>;
170
+ }, handler: (ctx: LambderRenderContext<z.infer<TInput>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>>>;
94
171
  addSessionApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny>(name: TName, schema: {
95
172
  input: TInput;
96
173
  output: TOutput;
97
- }, handler: (ctx: LambderSessionRenderContext<z.infer<TInput>, TSessionData>, resolver: LambderResolver<z.infer<TOutput>>) => LambderResolverResponse | Promise<LambderResolverResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>>>;
98
- addHook(hookEvent: 'created', hookFn: HookCreatedFunction, priority?: number): Promise<this>;
174
+ }, handler: (ctx: LambderSessionRenderContext<z.infer<TInput>, TSessionData>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>>>;
175
+ /**
176
+ * Fetch the session or short-circuit the request: API calls get the
177
+ * protocol's { sessionExpired: true } response (handled by LambderCaller),
178
+ * routes get the sessionExpiredRouteHandler response (default 401).
179
+ */
180
+ private requireSession;
181
+ addHook(hookEvent: 'created', hookFn: HookCreatedFunction, priority?: number): this;
99
182
  addHook(hookEvent: 'beforeRender', hookFn: HookBeforeRenderFunction, priority?: number): this;
100
183
  addHook(hookEvent: 'afterRender', hookFn: HookAfterRenderFunction, priority?: number): this;
101
184
  addHook(hookEvent: 'fallback', hookFn: HookFallbackFunction, priority?: number): this;
102
- getSessionController(ctx: LambderRenderContext<any> | LambderSessionRenderContext<any, TSessionData>): LambderSessionController<TSessionData>;
103
- getResponseBuilder(): LambderResponseBuilder<any>;
185
+ getSessionController(ctx: LambderRenderContext | LambderSessionRenderContext<any, TSessionData>): LambderSessionController<TSessionData>;
186
+ getResponseBuilder(ctx?: LambderRenderContext): LambderResponseBuilder<any>;
104
187
  private getResolver;
105
- getHandler(): (event: APIGatewayProxyEvent, context: Context) => Promise<LambderResolverResponse>;
106
- render(event: APIGatewayProxyEvent, lambdaContext: Context): Promise<LambderResolverResponse>;
188
+ getHandler(): LambderHandler;
189
+ /** True when the Lambda event is an API Gateway HTTP event (REST API v1 or HTTP API / Function URL v2). */
190
+ static isHttpEvent(event: unknown): event is LambderHttpEvent;
191
+ /**
192
+ * Register an action that filters on the raw Lambda event and, for HTTP
193
+ * invocations, the context (ctx is null otherwise).
194
+ *
195
+ * - Non-HTTP invocations (EventBridge/CloudWatch schedules, SQS, ...):
196
+ * actions are the only handlers. Return values pass through to Lambda
197
+ * untouched and errors rethrow, so retry/DLQ semantics keep working.
198
+ * A trailing `.addAction(() => true, handler)` acts as the fallback;
199
+ * with no match, a descriptive error is thrown.
200
+ * - HTTP invocations: the action joins the same first-match chain as
201
+ * routes/APIs (registration order) and must return a response built
202
+ * with tools.res.
203
+ *
204
+ * Use a type-guard filter to get a typed event:
205
+ * `(event): event is ScheduledEvent => ...`
206
+ */
207
+ addAction<TEvent>(filter: (event: unknown, ctx: LambderRenderContext | null) => event is TEvent, actionFn: (event: TEvent, tools: LambderActionTools) => MaybePromise<unknown>): this;
208
+ addAction(filter: (event: unknown, ctx: LambderRenderContext | null) => boolean, actionFn: (event: unknown, tools: LambderActionTools) => MaybePromise<unknown>): this;
209
+ /** Dispatch a non-HTTP Lambda event to the registered actions. */
210
+ renderEvent(event: unknown, lambdaContext: Context): Promise<unknown>;
211
+ private ensureInitialized;
212
+ private applyCors;
213
+ private handleNoMatchedAction;
214
+ private resolveRequest;
215
+ render(event: LambderHttpEvent, lambdaContext: Context): Promise<LambderHttpResponse>;
107
216
  }
108
- export {};