lambder 3.8.1 → 4.1.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.
Files changed (62) hide show
  1. package/Readme.md +139 -50
  2. package/dist/{LambderCaller.d.ts → client/LambderCaller.d.ts} +30 -12
  3. package/dist/{LambderCaller.js → client/LambderCaller.js} +21 -12
  4. package/dist/{LambderMSW.d.ts → client/LambderMSW.d.ts} +1 -1
  5. package/dist/client.d.ts +16 -0
  6. package/dist/client.js +17 -0
  7. package/dist/{Lambder.d.ts → core/Lambder.d.ts} +107 -75
  8. package/dist/{Lambder.js → core/Lambder.js} +96 -76
  9. package/dist/{LambderContext.d.ts → core/LambderContext.d.ts} +9 -3
  10. package/dist/{LambderContext.js → core/LambderContext.js} +1 -0
  11. package/dist/{LambderPublicFiles.js → core/LambderPublicFiles.js} +1 -1
  12. package/dist/{LambderResponse.js → core/LambderResponse.js} +1 -1
  13. package/dist/{LambderResponseBuilder.d.ts → core/LambderResponseBuilder.d.ts} +3 -15
  14. package/dist/{LambderResponseBuilder.js → core/LambderResponseBuilder.js} +2 -2
  15. package/dist/{LambderTemplatingEngine.d.ts → core/LambderTemplatingEngine.d.ts} +1 -1
  16. package/dist/{LambderTemplatingEngine.js → core/LambderTemplatingEngine.js} +2 -2
  17. package/dist/index.d.ts +37 -35
  18. package/dist/index.js +22 -20
  19. package/dist/policies/LambderApiGuards.d.ts +221 -0
  20. package/dist/policies/LambderApiGuards.js +79 -0
  21. package/dist/policies/LambderApiIdempotency.d.ts +58 -0
  22. package/dist/policies/LambderApiIdempotency.js +215 -0
  23. package/dist/policies/LambderApiPolicies.d.ts +40 -0
  24. package/dist/policies/LambderApiPolicies.js +47 -0
  25. package/dist/policies/LambderApiRateLimits.d.ts +90 -0
  26. package/dist/policies/LambderApiRateLimits.js +77 -0
  27. package/dist/{LambderSessionController.d.ts → session/LambderSessionController.d.ts} +3 -2
  28. package/dist/{LambderSessionController.js → session/LambderSessionController.js} +17 -12
  29. package/dist/{LambderSessionManager.d.ts → session/LambderSessionManager.d.ts} +38 -4
  30. package/dist/{LambderSessionManager.js → session/LambderSessionManager.js} +50 -17
  31. package/dist/{LambderApiContract.d.ts → shared/LambderApiContract.d.ts} +14 -0
  32. package/dist/{LambderApiError.d.ts → shared/LambderApiError.d.ts} +1 -1
  33. package/dist/{LambderDdbCache.js → stores/LambderDdbCache.js} +7 -38
  34. package/dist/stores/LambderDdbCompression.d.ts +3 -0
  35. package/dist/stores/LambderDdbCompression.js +39 -0
  36. package/dist/{LambderDdbIdempotency.d.ts → stores/LambderDdbIdempotency.d.ts} +41 -12
  37. package/dist/{LambderDdbIdempotency.js → stores/LambderDdbIdempotency.js} +100 -11
  38. package/dist/{LambderDdbRateLimiter.js → stores/LambderDdbRateLimiter.js} +5 -1
  39. package/dist/testing.d.ts +9 -0
  40. package/dist/testing.js +8 -0
  41. package/package.json +19 -1
  42. package/dist/LambderApiPolicies.d.ts +0 -212
  43. package/dist/LambderApiPolicies.js +0 -228
  44. /package/dist/{LambderMSW.js → client/LambderMSW.js} +0 -0
  45. /package/dist/{LambderCors.d.ts → core/LambderCors.d.ts} +0 -0
  46. /package/dist/{LambderCors.js → core/LambderCors.js} +0 -0
  47. /package/dist/{LambderPublicFiles.d.ts → core/LambderPublicFiles.d.ts} +0 -0
  48. /package/dist/{LambderResolver.d.ts → core/LambderResolver.d.ts} +0 -0
  49. /package/dist/{LambderResolver.js → core/LambderResolver.js} +0 -0
  50. /package/dist/{LambderResponse.d.ts → core/LambderResponse.d.ts} +0 -0
  51. /package/dist/{LambderRouting.d.ts → core/LambderRouting.d.ts} +0 -0
  52. /package/dist/{LambderRouting.js → core/LambderRouting.js} +0 -0
  53. /package/dist/{LambderApiContract.js → shared/LambderApiContract.js} +0 -0
  54. /package/dist/{LambderApiError.js → shared/LambderApiError.js} +0 -0
  55. /package/dist/{LambderHtml.d.ts → shared/LambderHtml.d.ts} +0 -0
  56. /package/dist/{LambderHtml.js → shared/LambderHtml.js} +0 -0
  57. /package/dist/{LambderI18n.d.ts → shared/LambderI18n.d.ts} +0 -0
  58. /package/dist/{LambderI18n.js → shared/LambderI18n.js} +0 -0
  59. /package/dist/{node-polyfills.d.ts → shared/node-polyfills.d.ts} +0 -0
  60. /package/dist/{node-polyfills.js → shared/node-polyfills.js} +0 -0
  61. /package/dist/{LambderDdbCache.d.ts → stores/LambderDdbCache.d.ts} +0 -0
  62. /package/dist/{LambderDdbRateLimiter.d.ts → stores/LambderDdbRateLimiter.d.ts} +0 -0
package/Readme.md CHANGED
@@ -1,8 +1,18 @@
1
- # Lambder - Serverless NodeJS Web Framework (v3)
1
+ # Lambder - Serverless NodeJS Web Framework (v4)
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 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, API Gateway HTTP API (payload v2) / Lambda Function URL support, the `LambderDdbCache` DynamoDB cache (3.1), typed translations with `createLambderI18n` (3.2), and in 3.5: typed API refusals with `LambderApiError`, caller outcomes/timeouts with `apiOutcome()`, plus declarative per-API rate limits, guards, and idempotency; 3.8 gives guards and custom rate-limit keys two typed modes: apiInput (checks a slice of the API's own payload; declarable only where the schema carries those fields) and guardInput (a separate client-sent guardInputs channel the contract makes mandatory at the call site).
5
+ **New in v4:**
6
+
7
+ - **Declarative auth as guards**: guards take per-API params (`guards: { orgPermission: "SOME.PERMISSION" }`), can require a session (`session: true`, compile-checked), and RETURN typed values that land on the handler's `ctx.guardData[name]`. Together with the apiInput/guardInput input modes, permission checks and device auth become registration-time declarations instead of per-handler boilerplate.
8
+ - **Hardened policy layer**: rate-limit policies can share one counter across APIs (`scope: "policy"`); idempotency replays answer before rate limits, survive client IP changes (key-scoped for public APIs, 16-char minimum keys), store full response headers, refuse to store Set-Cookie responses, and Brotli-compress stored bodies of 1KB+ so the ~350KB replay budget applies to compressed bytes.
9
+ - **Secrets hashed at rest**: session records store only sha256 hashes of the bearer secrets, so a session-table read yields no usable cookies; `LambderSessionReadError` keeps a DynamoDB blip from reading as a logout.
10
+ - **Three package entry points**: `lambder` (server), `lambder/client` (browser-safe by construction: no AWS SDK, no Node built-ins), `lambder/testing` (`LambderMSW`); sources organized into core/policies/session/stores/client/shared.
11
+ - **Configuration at creation**: `initLambder<SessionData>().create({...})` takes the WHOLE configuration (serving options, session, cors, rate limits, guards, idempotency) in one declaration; the enable/define chain methods are gone, so nothing can be half-configured or wired in the wrong order, and api modules annotate with `typeof lambderApp` derived from the real instance. Plus `LambderCaller.createIdempotencyKeyScope()` for one self-rotating key per logical operation, and fail-open rate limiting logs its passes.
12
+
13
+ **Breaking in v4** (from 3.x): configuration moved entirely to creation, removing `enableCors`, `enableDdbSession`, `setSessionCookieKey`, `enableApiRateLimits`, `enableApiIdempotency`, and `defineApiGuards` in favor of the `cors`/`session`/`rateLimits`/`guards`/`idempotency` options of `initLambder().create({...})`; session records are reshaped (hashes at rest; live sessions invalidate once on upgrade, clients just re-login) and the manager-level `createSession`/`regenerateSession` return `LambderCreatedSession` (`{ session, sessionToken, csrfToken }`; the controller API is unchanged); `LambderMSW` moved from the root entry to `lambder/testing`; `LambderCaller.apiRaw()` is removed (use `apiOutcome()`, whose failure outcomes carry the envelope on `response`); the `multiValueHeaders` alias on `res.raw()` is removed (use `headers`); `LambderDdbIdempotency.complete()` answers `"stored" | "too-large" | "lost"`; idempotency keys must be 16-200 chars.
14
+
15
+ v3 (public file serving, `addAction()`, gzip + ETag, thrown responses, `LambderTemplatingEngine`, `html`/`xml` tags, payload v2 support, `LambderDdbCache`, `createLambderI18n`, `LambderApiError`/`refuse()`, `apiOutcome()`, the declarative policy foundations) is documented in the git history.
6
16
 
7
17
  ## Features
8
18
 
@@ -35,29 +45,50 @@ npm install lambder zod
35
45
  yarn add lambder zod
36
46
  ```
37
47
 
48
+ ## Package Entry Points
49
+
50
+ The package ships three entry points; pick by where the code runs:
51
+
52
+ | Entry | Runs in | Carries |
53
+ |-------|---------|---------|
54
+ | `lambder` | Server (Lambda) | The full framework: pipeline, sessions, DDB stores, policies, plus everything from `lambder/client` |
55
+ | `lambder/client` | Browser and isomorphic shared code | `LambderCaller`, `LambderApiError`/`refuse`, the API contract and envelope types, `html`/`xml` tagged templates, `createLambderI18n` |
56
+ | `lambder/testing` | Dev and test tooling | `LambderMSW`, the MSW adapter that serves your typed contract from mock handlers |
57
+
58
+ Frontends and shared isomorphic packages should import from `lambder/client` only; the entry's module graph contains no AWS SDK, Node built-ins, or server pipeline, so the browser boundary is structural rather than left to tree-shaking.
59
+
60
+ Source layout mirrors this: `src/core/` (request pipeline), `src/policies/` (declarative rate limits, guards, idempotency), `src/session/`, `src/stores/` (DynamoDB primitives), `src/client/`, and `src/shared/` (isomorphic modules both entries re-export).
61
+
38
62
  ## Backend Usage
39
63
 
40
64
  ### Basic Setup
41
65
 
66
+ The whole configuration is given at creation, in one declaration; only
67
+ registration (routes, apis, hooks, `use()`) chains afterwards. `initLambder`
68
+ is curried so the session data type is fixed first and everything else
69
+ (policy names, guard metadata) is INFERRED from the options; TypeScript type
70
+ arguments are all-or-nothing per call, so a plain `new Lambder<SessionData>({...})`
71
+ would silently widen the inferred policy types, which is why the curried
72
+ creator is the canonical entry.
73
+
42
74
  ```typescript
43
- import Lambder from 'lambder';
75
+ import { initLambder } from 'lambder';
44
76
  import { z } from 'zod';
45
77
  import * as path from 'path';
46
78
 
47
- const lambder = new Lambder({
79
+ interface SessionData { userId: string; }
80
+
81
+ const lambder = initLambder<SessionData>().create({
48
82
  apiPath: "/api",
49
83
  publicPath: path.resolve(`./public`),
50
- });
51
-
52
- // Enable session and CORS
53
- lambder
54
- .enableDdbSession({
84
+ session: {
55
85
  tableName: "website-session",
56
86
  tableRegion: "us-east-1",
57
- sessionSalt: "CHANGE-THIS-TO-A-SECURE-RANDOM-STRING"
58
- })
87
+ sessionSalt: "CHANGE-THIS-TO-A-SECURE-RANDOM-STRING",
88
+ },
59
89
  // true allows any origin; or configure: { origins: ["https://app.example.com"], credentials: true }
60
- .enableCors(true);
90
+ cors: true,
91
+ });
61
92
 
62
93
  // Define type-safe APIs with Zod schemas
63
94
  lambder
@@ -253,18 +284,21 @@ lambder
253
284
 
254
285
  ### Session Management
255
286
 
256
- Enable DynamoDB-based sessions with `enableDdbSession()`. Optional configuration:
287
+ Enable DynamoDB-based sessions with the `session` option at creation:
257
288
 
258
289
  ```typescript
259
- lambder
260
- .enableDdbSession({
290
+ const lambder = initLambder<SessionData>().create({
291
+ apiPath: "/api",
292
+ session: {
261
293
  tableName: "website-session",
262
294
  tableRegion: "us-east-1",
263
295
  sessionSalt: "CHANGE-THIS-TO-A-SECURE-RANDOM-STRING",
264
- enableSlidingExpiration: true // Optional: extend session on each access
265
- })
266
- // Optionally customize session cookie names (defaults: LMDRSESSIONTKID, LMDRSESSIONCSTK)
267
- .setSessionCookieKey("MY_SESSION_TOKEN", "MY_CSRF_TOKEN");
296
+ enableSlidingExpiration: true, // Optional: extend session on each access
297
+ // Optionally customize cookie names (defaults: LMDRSESSIONTKID, LMDRSESSIONCSTK)
298
+ tokenCookieKey: "MY_SESSION_TOKEN",
299
+ csrfCookieKey: "MY_CSRF_TOKEN",
300
+ },
301
+ });
268
302
  ```
269
303
 
270
304
  #### DynamoDB Session Table Structure
@@ -275,21 +309,27 @@ lambder
275
309
 
276
310
  See [docs/DYNAMODB_SETUP.md](docs/DYNAMODB_SETUP.md) for detailed setup instructions.
277
311
 
312
+ #### How the secrets are stored
313
+
314
+ The session cookie is `pkHash:secret`: `pkHash = sha256(sessionKey + sessionSalt)` and `secret` is 256 random bits. At rest the record stores only HASHES of the bearer secrets: the range key is `sha256(secret)` (so the lookup itself proves possession of the raw secret) and the CSRF token is stored as `csrfTokenHash`. The raw values exist only in the client's cookies and, transiently, on the `LambderCreatedSession` result the manager returns at creation; a read of the session table (backup leak, over-broad IAM, insider) therefore yields no usable cookies. Fast sha256 is the correct construction here rather than a password KDF: the secrets are 256-bit random, so there is nothing to brute-force, while `sessionSalt` peppers the identity-to-partition-key mapping so partition keys and cookie prefixes cannot be derived from (or linked to) known user ids.
315
+
278
316
  #### Keeping session data fresh (`dataRefresh`)
279
317
 
280
318
  Session data often caches values derived from external state: roles, permissions, feature flags. Opt in to `dataRefresh` to give that data a shelf life. Every session read checks it, and once `ttlSeconds` have passed your `refresh` callback rebuilds the data, which is persisted onto the same session record: same tokens, same cookies, the session itself is untouched. Changes to the source of truth then reach every live session within `ttlSeconds`, with no mass session invalidation.
281
319
 
282
320
  ```typescript
283
- lambder.enableDdbSession({
284
- tableName: "website-session",
285
- tableRegion: "us-east-1",
286
- sessionSalt: "CHANGE-THIS-TO-A-SECURE-RANDOM-STRING",
287
- dataRefresh: {
288
- ttlSeconds: 600, // data is renewed at most every 10 minutes
289
- refresh: async (session) => {
290
- const user = await loadUser(session.data.userId);
291
- if (!user || user.disabled) return null; // null ends the session
292
- return buildSessionData(user);
321
+ const lambder = initLambder<SessionData>().create({
322
+ session: {
323
+ tableName: "website-session",
324
+ tableRegion: "us-east-1",
325
+ sessionSalt: "CHANGE-THIS-TO-A-SECURE-RANDOM-STRING",
326
+ dataRefresh: {
327
+ ttlSeconds: 600, // data is renewed at most every 10 minutes
328
+ refresh: async (session) => {
329
+ const user = await loadUser(session.data.userId);
330
+ if (!user || user.disabled) return null; // null ends the session
331
+ return buildSessionData(user);
332
+ },
293
333
  },
294
334
  },
295
335
  });
@@ -300,6 +340,7 @@ Semantics:
300
340
  - The callback must be a pure derivation of external state: concurrent reads may run it in parallel, last write wins.
301
341
  - Returning `null` deletes the session; the request is answered as session-expired.
302
342
  - Thrown errors fail the request as a `LambderSessionDataRefreshError` and leave the session untouched (they are never mistaken for a logout). Catch inside and return `session.data` to explicitly serve stale instead.
343
+ - Similarly, a DynamoDB failure while READING a session fails the request as a `LambderSessionReadError` instead of reading as "no session": answering session-expired there would make LambderCaller clear the client's cookies, turning an infra blip into a forced logout.
303
344
  - The renewal write and the sliding-expiration write share a single DynamoDB put when both are due.
304
345
  - Records created before `dataRefresh` was enabled renew on their first read.
305
346
  - `updateSessionData()` marks data fresh (it was just written deliberately); `regenerateSession()` carries the old freshness stamp over.
@@ -466,18 +507,23 @@ Related: when an API call crashes with no `setGlobalErrorHandler` (or the handle
466
507
  Declare named building blocks once; reference them from API definitions with full type inference (unknown names are compile errors, and everything is re-asserted at registration time for plain-JS safety). Each piece is independent and optional.
467
508
 
468
509
  ```typescript
469
- import Lambder, { LambderDdbRateLimiter, LambderDdbIdempotency, lambderGuard, lambderRateLimitKey, refuse } from "lambder";
510
+ import { initLambder, LambderDdbRateLimiter, LambderDdbIdempotency, lambderGuard, lambderRateLimitKey, refuse } from "lambder";
470
511
 
471
- const lambder = new Lambder<SessionData>({ apiPath: "/api" })
512
+ const lambder = initLambder<SessionData>().create({
513
+ apiPath: "/api",
472
514
  // 1. Rate limiting: your limiter instance + named policies. Each policy
473
515
  // declares its windows AND what one counter tracks ("per").
474
- .enableApiRateLimits({
516
+ rateLimits: {
475
517
  limiter: new LambderDdbRateLimiter({ tableName: "app-rate-limiter", region: "us-east-1", failOpen: true }),
476
518
  policies: {
477
519
  authPerIp: { perMin: 5, perHour: 30, per: "ip" },
478
520
  writePerUser: { perMin: 30, per: "session" }, // only referable from addSessionApi (also enforced at compile time)
479
521
  codePerEmail: {
480
522
  perMin: 3,
523
+ // scope "policy": ONE combined budget across every API that
524
+ // references this policy (send + register + reset share the
525
+ // 3/min). Default scope "api" gives each API its own counter.
526
+ scope: "policy",
481
527
  // apiInput key: derives from the API's OWN payload. Validated
482
528
  // before it runs, typed in the handler, and the policy is only
483
529
  // referable from APIs whose input schema carries `email`.
@@ -488,28 +534,44 @@ const lambder = new Lambder<SessionData>({ apiPath: "/api" })
488
534
  errorMessage: { type: "warning", content: "Too many attempts for this address." },
489
535
  },
490
536
  },
491
- })
537
+ },
492
538
  // 2. Idempotency: a store instance + replay defaults. May share the rate
493
539
  // limiter's table (records use an IDEM# key prefix).
494
- .enableApiIdempotency({
540
+ idempotency: {
495
541
  store: new LambderDdbIdempotency({ tableName: "app-rate-limiter", region: "us-east-1" }),
496
542
  defaultTtlSeconds: 24 * 3600,
497
543
  failOpen: true, // DynamoDB down => execute without dedupe instead of failing
498
- })
499
- // 3. Named guards, two modes. apiInput checks a slice of the API's own
544
+ },
545
+ // 3. Named guards. Input modes: apiInput checks a slice of the API's own
500
546
  // payload (the schema keeps the field; the guard is declarable only
501
- // where the payload type passes both). guardInput is the guard's OWN
547
+ // where the payload type passes both); guardInput is the guard's OWN
502
548
  // value, sent separately by the caller via options.guardInputs and
503
549
  // made mandatory by the contract, so forgetting it is a compile error
504
- // at the call site. Both are validated pre-run and typed in the handler.
505
- .defineApiGuards({
550
+ // at the call site; or neither. Both are validated pre-run and typed
551
+ // in the handler. On top of that a guard may require a session
552
+ // (session: true, declarable only on addSessionApi), take a per-API
553
+ // PARAM (annotate a 4th handler argument), and RETURN a value that
554
+ // lands typed on the API handler's ctx.guardData[name].
555
+ guards: {
506
556
  captcha: lambderGuard({
507
557
  guardInput: z.object({ captchaToken: z.string() }),
508
558
  handler: async (ctx, { captchaToken }) => {
509
559
  if (!await verifyCaptcha(captchaToken, ctx.ip)) refuse("Verification failed, please retry.");
510
560
  },
511
561
  }),
512
- });
562
+ deviceAuth: lambderGuard({
563
+ apiInput: z.object({ deviceToken: z.string() }),
564
+ // Returns a value: the API handler reads ctx.guardData.deviceAuth.
565
+ handler: async (_ctx, { deviceToken }) => await resolveDeviceOrRefuse(deviceToken),
566
+ }),
567
+ orgPermission: lambderGuard({
568
+ session: true,
569
+ // Parameterized: APIs declare guards: { orgPermission: "SOME.PERMISSION" }.
570
+ handler: (ctx, _payload, _res, permission: PermissionString) =>
571
+ requirePermissionOrRefuse(ctx.session, permission), // return value → ctx.guardData.orgPermission
572
+ }),
573
+ },
574
+ });
513
575
 
514
576
  lambder.addApi("public.resetPassword", {
515
577
  // captchaToken is NOT declared here: it travels in the separate
@@ -519,20 +581,47 @@ lambder.addApi("public.resetPassword", {
519
581
  input: z.object({ email: z.string().email() }),
520
582
  output: z.object({ ok: z.boolean() }),
521
583
  rateLimit: ["authPerIp", "codePerEmail"], // stacked: checked in order, first exceeded refuses (429 envelope)
522
- guards: "captcha",
584
+ guards: "captcha", // one name, a list of names, or a { name: param } map
523
585
  }, handler);
524
586
 
525
587
  lambder.addSessionApi("secure.order.create", {
526
588
  input: OrderSchema,
527
589
  output: OrderResultSchema,
528
590
  rateLimit: "writePerUser",
529
- idempotency: true, // or { ttlSeconds: 3600 }; type error until enableApiIdempotency()
530
- }, handler);
591
+ guards: { orgPermission: "ORDERS.CREATE" }, // param typed per guard; entries run in insertion order
592
+ idempotency: true, // or { ttlSeconds: 3600 }; type error unless created with idempotency
593
+ }, async (ctx, res) => {
594
+ const { organizationId } = ctx.guardData.orgPermission; // typed guard output
595
+ // ...
596
+ });
597
+ ```
598
+
599
+ Guard results are typed end to end: the handler's `ctx.guardData` carries exactly the declared guards that return a value, a session guard on a public API is a compile error (and a startup assert), an apiInput guard is declarable only where the API's schema carries its fields, and a parameterized guard's param is typechecked in the declaration.
600
+
601
+ For api modules split across files, DERIVE the annotation type from the real instance instead of writing it by hand: create the instance next to the policy declarations and export `typeof` it. The type can never drift from what actually runs, and modules import it without a cycle (the app file imports no modules):
602
+
603
+ ```typescript
604
+ // app.ts: declarations + the fully configured instance
605
+ export const lambderApp = initLambder<SessionData>().create({
606
+ apiPath: "/api",
607
+ session: { tableName: "app-session", tableRegion: "us-east-1", sessionSalt: "..." },
608
+ rateLimits: { limiter, policies: apiRateLimitPolicies },
609
+ idempotency: { store: idempotencyStore },
610
+ guards: apiGuards,
611
+ });
612
+ export type AppLambder = typeof lambderApp;
613
+
614
+ // orders.ts: an api module
615
+ export const orderApi = (lambder: AppLambder) => lambder.addSessionApi(...);
616
+
617
+ // index.ts: registration only (hooks, routes, modules)
618
+ const lambder = lambderApp.addHook(...).use(orderApi)...;
619
+ export const handler = lambder.getHandler();
531
620
  ```
532
621
 
533
- Request flow per API: session (session APIs) → rate limits → guards → zod validation → idempotency claim → handler → idempotency store. Refusals ride the envelope via `LambderApiError` (429 rate limited, 409 duplicate in flight), so the caller's `errorMessageHandler` surfaces them with zero client code.
622
+ Request flow per API: session (session APIs) → idempotency replay lookup → rate limits → guards → zod validation → idempotency claim → handler → idempotency store. The replay lookup runs first on purpose: a completed idempotent request answers its stored response without burning rate-limit quota or re-running guards (the original already passed them, and no handler executes either way). Refusals ride the envelope via `LambderApiError` (429 rate limited, 409 duplicate in flight), so the caller's `errorMessageHandler` surfaces them with zero client code.
534
623
 
535
- **Idempotency semantics**: the client sends an `idempotencyKey` per call (see LambderCaller below); generate it once per logical operation and reuse it on retries. The scope is identity (session key, or IP for public APIs) + API name + key: concurrent duplicates of an in-flight request refuse with 409, repeats of a completed one replay the stored response verbatim until the TTL, and a crashed original releases its claim so a retry actually retries. A response delivered by throwing (`res.die.*`, `throw res.api(...)`) counts as a completion and is stored like a returned one; thrown `LambderApiError` refusals release the claim instead. Responses with status ≥ 500 are never stored. Claims are owner-checked, so an original that stalls past the pending window can no longer overwrite or delete the claim a retry has since taken. Requests without a key execute normally.
624
+ **Idempotency semantics**: the client sends an `idempotencyKey` per call (see LambderCaller below); generate it once per logical operation with `LambderCaller.createIdempotencyKey()` and reuse it on retries. Keys must be 16-200 characters and UNGUESSABLE random (shorter keys refuse with 400): on session APIs the scope is session + API name + key, and on public APIs it is the key itself + API name, deliberately NOT the client IP, because the retry idempotency exists for (a timeout followed by a network switch) frequently arrives from a new IP. Concurrent duplicates of an in-flight request refuse with 409, repeats of a completed one replay the stored response verbatim until the TTL (response headers included, so headers set via `res.setHeader`/`res.addHeader` replay too), and a crashed original releases its claim so a retry actually retries. The replay rule for failures: RESPONSES are stored and replayed, refusals returned as envelopes (`res.api(null, { errorMessage })`) and thrown responses (`res.die.*`) included; EXCEPTIONS are not, so a thrown `LambderApiError`/`refuse()` releases the claim and a retry re-executes and decides afresh. Stored bodies of 1KB or more are Brotli-compressed (the same scheme as LambderDdbCache; `compressionQuality` on the store, default 5): JSON envelopes typically shrink 5-10x, which cuts DynamoDB write cost, and the ~350KB item budget applies to the COMPRESSED bytes, so even large responses usually stay replayable. Responses with status ≥ 500, bodies over the budget even compressed, and responses that set cookies are never stored (replaying one request's Set-Cookie, e.g. session tokens, into another would be wrong; such APIs still get in-flight 409 dedupe, just not replays). Claims are owner-checked, so an original that stalls past the pending window can no longer overwrite or delete the claim a retry has since taken. Requests without a key execute normally.
536
625
 
537
626
  Also enforced at registration: **duplicate API names throw** (dispatch is first-match, so a second registration of the same name would be silently dead code).
538
627
 
@@ -583,12 +672,12 @@ cI18n.forLanguage("tr")("compute"); // explicit (per-request backend use)
583
672
 
584
673
  ## Frontend Usage with LambderCaller
585
674
 
586
- LambderCaller is a frontend companion library for Lambder (only 2kb compressed) designed to simplify making type-safe API requests to your Lambder backend.
675
+ LambderCaller is a frontend companion library for Lambder (only 2kb compressed) designed to simplify making type-safe API requests to your Lambder backend. Import it from the `lambder/client` entry: everything reachable from there is browser-safe by construction (no AWS SDK, no Node built-ins, no server pipeline), so your bundle can never pick up server code.
587
676
 
588
677
  ### Basic Setup with Type Safety
589
678
 
590
679
  ```typescript
591
- import { LambderCaller } from "lambder";
680
+ import { LambderCaller } from "lambder/client";
592
681
  import type { ApiContractType } from "./backend/handler"; // Import the inferred contract type
593
682
 
594
683
  const lambderCaller = new LambderCaller<ApiContractType>({
@@ -637,9 +726,9 @@ Every configured handler still fires on the matching failure, so global UX (toas
637
726
  Also available:
638
727
 
639
728
  - **Timeouts**: pass `timeoutMs` in the constructor for a default (API Gateway caps around 29s, so ~30000 is sensible) and/or per call; timed-out calls abort the fetch and report `reason: 'timeout'`. A per-call `signal` combines with the timeout.
640
- - **Per-call handler overrides**: every constructor handler (`errorHandler`, `sessionExpiredHandler`, `errorMessageHandler`, ...) can be overridden in the options of a single `api`/`apiRaw`/`apiOutcome` call.
729
+ - **Per-call handler overrides**: every constructor handler (`errorHandler`, `sessionExpiredHandler`, `errorMessageHandler`, ...) can be overridden in the options of a single `api`/`apiOutcome` call.
641
730
  - **Guard inputs**: for APIs whose guards run in guardInput mode, pass their values per call as `guardInputs: { <guardName>: value }`; the typed contract makes the options argument (and the correct value shape) mandatory for those APIs.
642
- - **Idempotency keys**: pass `idempotencyKey` per call for APIs declared idempotent on the server (see Declarative API Policies). Generate it once per logical operation with `LambderCaller.createIdempotencyKey()` (safe in insecure contexts where `crypto.randomUUID` is missing) and send the same key on retries; rotate after a confirmed success.
731
+ - **Idempotency keys**: pass `idempotencyKey` per call for APIs declared idempotent on the server (see Declarative API Policies). Generate it once per logical operation with `LambderCaller.createIdempotencyKey()` (safe in insecure contexts where `crypto.randomUUID` is missing) and send the same key on retries; rotate after a confirmed success. `LambderCaller.createIdempotencyKeyScope()` packages that pattern for a component performing one operation repeatedly: read `scope.current` on every attempt, call `scope.rotate()` after a confirmed success. Keys must be unguessable random and 16-200 characters (they scope the replay record for logged-out clients); the server refuses shorter keys with a 400.
643
732
 
644
733
  ### Benefits
645
734
 
@@ -657,7 +746,7 @@ Also available:
657
746
  LambderMSW provides seamless integration with [MSW (Mock Service Worker)](https://mswjs.io/) for testing your APIs with full type safety.
658
747
 
659
748
  ```typescript
660
- import { LambderMSW } from 'lambder';
749
+ import { LambderMSW } from 'lambder/testing';
661
750
  import { setupServer } from 'msw/node';
662
751
  import type { ApiContractType } from './backend/handler';
663
752
 
@@ -1,5 +1,5 @@
1
- import { LambderApiResponse } from './LambderResponseBuilder';
2
- import type { ApiContractShape } from './LambderApiContract';
1
+ import type { LambderApiResponse } from '../shared/LambderApiContract.js';
2
+ import type { ApiContractShape } from '../shared/LambderApiContract.js';
3
3
  import type { z } from "zod";
4
4
  type IsAny<T> = 0 extends (1 & T) ? true : false;
5
5
  type GuardInputsOf<TEntry> = TEntry extends {
@@ -36,6 +36,13 @@ type FetchEndEventHandler = (params: {
36
36
  type ErrorHandler = (err: Error) => void | Promise<void>;
37
37
  type ValidationErrorHandler = (zodError: z.ZodError) => (void | false) | Promise<(void | false)>;
38
38
  type MessageHandler = (message: any) => void | Promise<void>;
39
+ /** One logical operation's rotating idempotency key: see LambderCaller.createIdempotencyKeyScope(). */
40
+ export type LambderIdempotencyKeyScope = {
41
+ /** The key for the operation currently in progress. */
42
+ readonly current: string;
43
+ /** Call after a confirmed success: the next operation is a new intent. Returns the new key. */
44
+ rotate(): string;
45
+ };
39
46
  export type LambderApiFailureReason = 'network' | 'timeout' | 'server' | 'validation' | 'versionExpired' | 'sessionExpired' | 'notAuthorized' | 'errorMessage' | 'unknown';
40
47
  /**
41
48
  * Discriminated result of an API call: `ok: true` carries the payload, every
@@ -75,10 +82,12 @@ export type LambderCallOptions = {
75
82
  guardInputs?: Record<string, unknown>;
76
83
  /**
77
84
  * Replay-protection key for APIs declared idempotent on the server.
78
- * Generate once per logical operation (e.g. crypto.randomUUID() when the
79
- * form opens) and send the same key on retries: duplicates of an
80
- * in-flight request refuse, and repeats of a completed one replay its
81
- * stored response instead of re-executing.
85
+ * Generate once per logical operation with createIdempotencyKey() and
86
+ * send the same key on retries: duplicates of an in-flight request
87
+ * refuse, and repeats of a completed one replay its stored response
88
+ * instead of re-executing. Must be UNGUESSABLE random (it scopes the
89
+ * replay record for logged-out clients) and at least 16 characters; the
90
+ * server refuses shorter keys with a 400.
82
91
  */
83
92
  idempotencyKey?: string;
84
93
  versionExpiredHandler?: VoidFunction;
@@ -129,6 +138,21 @@ export default class LambderCaller<TContract extends ApiContractShape = any> {
129
138
  sessionCookieDomain?: string | ((hostname: string) => string | undefined | null);
130
139
  });
131
140
  setSessionCookieKey(sessionTokenCookieKey: string, sessionCsrfCookieKey: string): void;
141
+ /**
142
+ * A self-rotating idempotency key for a component or form that performs
143
+ * the same logical operation repeatedly. `current` is the key for the
144
+ * operation in progress: send it with every attempt (first try, retry
145
+ * after a failure, double-tap) so the server collapses them. Call
146
+ * `rotate()` after a confirmed success so the next operation is a new
147
+ * intent with its own key.
148
+ *
149
+ * ```typescript
150
+ * const submitKey = LambderCaller.createIdempotencyKeyScope();
151
+ * await caller.api("order.create", payload, { idempotencyKey: submitKey.current });
152
+ * submitKey.rotate();
153
+ * ```
154
+ */
155
+ static createIdempotencyKeyScope(): LambderIdempotencyKeyScope;
132
156
  /**
133
157
  * Generate an idempotency key for one logical operation. Create it when
134
158
  * the operation begins (a form opens, a draft starts), send the same key
@@ -146,12 +170,6 @@ export default class LambderCaller<TContract extends ApiContractShape = any> {
146
170
  * instead of collapsing every failure to null. Never throws.
147
171
  */
148
172
  apiOutcome<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, ...rest: CallOptionsArg<TContract, TApiName>): Promise<LambderApiOutcome<TOutput>>;
149
- /**
150
- * Legacy shape: the parsed envelope on success (and on structured
151
- * errorMessage refusals, which carry an envelope), null on every other
152
- * failure. Prefer apiOutcome() when the call site needs to know why.
153
- */
154
- apiRaw<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, ...rest: CallOptionsArg<TContract, TApiName>): Promise<LambderApiResponse<TOutput> | null | undefined>;
155
173
  /** Payload on success, null/undefined otherwise (indistinguishable from a null payload; prefer apiOutcome() when that matters). */
156
174
  api<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, ...rest: CallOptionsArg<TContract, TApiName>): Promise<TOutput | null | undefined>;
157
175
  }
@@ -39,6 +39,27 @@ export default class LambderCaller {
39
39
  this.sessionTokenCookieKey = sessionTokenCookieKey;
40
40
  this.sessionCsrfCookieKey = sessionCsrfCookieKey;
41
41
  }
42
+ /**
43
+ * A self-rotating idempotency key for a component or form that performs
44
+ * the same logical operation repeatedly. `current` is the key for the
45
+ * operation in progress: send it with every attempt (first try, retry
46
+ * after a failure, double-tap) so the server collapses them. Call
47
+ * `rotate()` after a confirmed success so the next operation is a new
48
+ * intent with its own key.
49
+ *
50
+ * ```typescript
51
+ * const submitKey = LambderCaller.createIdempotencyKeyScope();
52
+ * await caller.api("order.create", payload, { idempotencyKey: submitKey.current });
53
+ * submitKey.rotate();
54
+ * ```
55
+ */
56
+ static createIdempotencyKeyScope() {
57
+ let key = LambderCaller.createIdempotencyKey();
58
+ return {
59
+ get current() { return key; },
60
+ rotate() { key = LambderCaller.createIdempotencyKey(); return key; },
61
+ };
62
+ }
42
63
  /**
43
64
  * Generate an idempotency key for one logical operation. Create it when
44
65
  * the operation begins (a form opens, a draft starts), send the same key
@@ -285,18 +306,6 @@ export default class LambderCaller {
285
306
  return await this.dispatch(apiName, payload, rest[0]);
286
307
  }
287
308
  ;
288
- /**
289
- * Legacy shape: the parsed envelope on success (and on structured
290
- * errorMessage refusals, which carry an envelope), null on every other
291
- * failure. Prefer apiOutcome() when the call site needs to know why.
292
- */
293
- async apiRaw(apiName, payload, ...rest) {
294
- const outcome = await this.dispatch(apiName, payload, rest[0]);
295
- if (outcome.ok)
296
- return outcome.response;
297
- return outcome.reason === 'errorMessage' ? outcome.response : null;
298
- }
299
- ;
300
309
  /** Payload on success, null/undefined otherwise (indistinguishable from a null payload; prefer apiOutcome() when that matters). */
301
310
  async api(apiName, payload, ...rest) {
302
311
  const outcome = await this.dispatch(apiName, payload, rest[0]);
@@ -1,4 +1,4 @@
1
- import type { ApiContractShape } from './LambderApiContract';
1
+ import type { ApiContractShape } from '../shared/LambderApiContract.js';
2
2
  type RequestHandler = any;
3
3
  /** The parts of the msw module LambderMSW uses: `import { http, HttpResponse } from "msw"`. */
4
4
  export type LambderMswModule = {
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Browser entry point (`import ... from "lambder/client"`).
3
+ *
4
+ * Everything reachable from here is browser-safe: no AWS SDK, no Node
5
+ * built-ins, no server pipeline. Frontends and isomorphic shared packages
6
+ * import from this entry so their bundles can never pick up server code;
7
+ * the root entry (`"lambder"`) is the server surface.
8
+ */
9
+ export { default as LambderCaller } from "./client/LambderCaller.js";
10
+ export type { LambderApiOutcome, LambderApiFailureReason, LambderCallOptions, LambderIdempotencyKeyScope, } from "./client/LambderCaller.js";
11
+ export { LambderApiError, isLambderApiError, refuse } from "./shared/LambderApiError.js";
12
+ export type { LambderApiErrorOptions, LambderRefusalMessage, LambderRefuseOptions } from "./shared/LambderApiError.js";
13
+ export type { ApiContractShape, LambderApiResponse, LambderApiResponseConfig } from "./shared/LambderApiContract.js";
14
+ export { html, xml, raw, jsonScript, escapeHtml, renderHtmlValue, LambderSafeHtml, type LambderHtmlValue } from "./shared/LambderHtml.js";
15
+ export { createLambderI18n } from "./shared/LambderI18n.js";
16
+ export type { LambderLanguageMeta, LambderI18nConfig, LambderI18nInstance, LambderI18nTranslator, LambderI18nExtractParams, LambderI18nCodes, LambderI18nKeys, LambderI18nTranslatorFor, } from "./shared/LambderI18n.js";
package/dist/client.js ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Browser entry point (`import ... from "lambder/client"`).
3
+ *
4
+ * Everything reachable from here is browser-safe: no AWS SDK, no Node
5
+ * built-ins, no server pipeline. Frontends and isomorphic shared packages
6
+ * import from this entry so their bundles can never pick up server code;
7
+ * the root entry (`"lambder"`) is the server surface.
8
+ */
9
+ // The typed API caller.
10
+ export { default as LambderCaller } from "./client/LambderCaller.js";
11
+ // Typed API refusals (isomorphic: shared code may throw them from anywhere;
12
+ // in the browser they are plain Errors).
13
+ export { LambderApiError, isLambderApiError, refuse } from "./shared/LambderApiError.js";
14
+ // Type-safe templating (tagged templates with auto-escaping)
15
+ export { html, xml, raw, jsonScript, escapeHtml, renderHtmlValue, LambderSafeHtml } from "./shared/LambderHtml.js";
16
+ // Typed translations (standalone, isomorphic)
17
+ export { createLambderI18n } from "./shared/LambderI18n.js";