lambder 4.8.1 → 5.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.
package/README.md ADDED
@@ -0,0 +1,158 @@
1
+ # Lambder
2
+
3
+ A highly opinionated serverless web framework for TypeScript on AWS Lambda.
4
+ Lambder handles HTTP requests, routes, type-safe APIs, sessions and the
5
+ declarative policy layer around them (rate limits, authorization guards,
6
+ idempotency), so an application is a set of declarations rather than a pile of
7
+ per-handler boilerplate.
8
+
9
+ ```typescript
10
+ import { initLambder, LambderLocalFileSource } from "lambder";
11
+ import { z } from "zod";
12
+
13
+ const lambder = initLambder<SessionData>().create({
14
+ apiPath: "/api",
15
+ files: new LambderLocalFileSource({ root: "./public" }),
16
+ session: { tableName: "app-session", tableRegion: "us-east-1", sessionSalt: process.env.SESSION_SALT! },
17
+ });
18
+
19
+ lambder.addApi("getCompany", {
20
+ input: z.object({ slug: z.string() }),
21
+ output: z.object({ id: z.string(), name: z.string() }),
22
+ }, async ({ apiPayload }, res) => res.api(await loadCompany(apiPayload.slug)));
23
+
24
+ export type ApiContractType = typeof lambder.ApiContract;
25
+ export const handler = lambder.getHandler();
26
+ ```
27
+
28
+ The frontend imports that contract type and gets autocomplete, typed payloads
29
+ and typed results with no hand-written client:
30
+
31
+ ```typescript
32
+ import { LambderCaller } from "lambder/client";
33
+ import type { ApiContractType } from "./backend/handler";
34
+
35
+ const caller = new LambderCaller<ApiContractType>({ apiPath: "/api", isCorsEnabled: false });
36
+ const company = await caller.api("getCompany", { slug: "acme" });
37
+ ```
38
+
39
+ ## Features
40
+
41
+ - **Type-safe APIs with Zod.** Define inputs and outputs with Zod schemas; get
42
+ runtime validation and compile-time inference on both sides of the wire.
43
+ - **One inferred contract.** The API contract is derived from the backend code
44
+ and consumed by the frontend as a type-only import.
45
+ - **Simple route and API declaration.** Paths, regexes, predicates and
46
+ structured matchers, chained fluently.
47
+ - **Sessions.** DynamoDB-backed, with secrets hashed at rest, sliding
48
+ expiration, data refresh and cross-subdomain cookies.
49
+ - **Declarative policies.** Named rate-limit policies, authorization guards and
50
+ idempotency, referenced by name from an API declaration and checked at
51
+ compile time.
52
+ - **A real response pipeline.** Automatic Brotli/gzip, ETag and 304 handling,
53
+ cookies, and a guard against Lambda's response size cap.
54
+ - **Hooks and actions.** Lifecycle hooks, plus `addAction()` for the non-HTTP
55
+ invocations (EventBridge, SQS, custom events) the same function receives.
56
+ - **Frontend hosting.** Serve a build from a folder, S3 or R2, with an app
57
+ shell rendered through a build-pipeline-safe template engine.
58
+ - **Runs anywhere Lambda does.** API Gateway REST APIs (payload v1), HTTP APIs
59
+ (payload v2) and Lambda Function URLs; the payload format is detected per
60
+ event.
61
+
62
+ ## Installation
63
+
64
+ ```bash
65
+ npm install lambder zod
66
+ ```
67
+
68
+ `zod` and the AWS SDK clients are optional peer dependencies, so installing
69
+ lambder never drags them into your tree. Add whatever the code you actually
70
+ import needs:
71
+
72
+ | What you import | What to install alongside |
73
+ | --- | --- |
74
+ | `lambder/client` (browser, shared isomorphic code) | `zod` |
75
+ | `lambder` on AWS Lambda (`nodejs18.x` and later) | `zod`. The runtime already provides the AWS SDK v3, so mark the SDK packages as dev dependencies and keep them out of the deployment package |
76
+ | `lambder` anywhere else (a long-running server, a container, local tests) | `zod`, `@aws-sdk/client-dynamodb`, `@aws-sdk/lib-dynamodb` |
77
+ | `LambderS3FileSource` | `@aws-sdk/client-s3`, loaded on first read |
78
+ | `lambder/testing` | `msw` |
79
+
80
+ The SDK and its `@smithy` tree are roughly 21MB installed, which is why they are
81
+ peers rather than dependencies: a frontend importing only `lambder/client` has
82
+ no use for any of it, and a Lambda deployment package should not ship a second
83
+ copy of what the runtime already loads. The runtime pins its own SDK version,
84
+ so if you need a specific one, install it and bundle it yourself.
85
+
86
+ ## Package entry points
87
+
88
+ The package ships three entry points; pick by where the code runs:
89
+
90
+ | Entry | Runs in | Carries |
91
+ | --- | --- | --- |
92
+ | `lambder` | Server (Lambda) | The full framework: pipeline, sessions, DDB stores, policies, plus everything from `lambder/client` |
93
+ | `lambder/client` | Browser and isomorphic shared code | `LambderCaller`, `LambderApiError`/`refuse`, the API contract and envelope types, `html`/`xml` tagged templates, `createLambderI18n` |
94
+ | `lambder/testing` | Dev and test tooling | `LambderMSW`, the MSW adapter that serves your typed contract from mock handlers |
95
+
96
+ Frontends and shared isomorphic packages should import from `lambder/client`
97
+ only; the entry's module graph contains no AWS SDK, Node built-ins, or server
98
+ pipeline, so the browser boundary is structural rather than left to
99
+ tree-shaking.
100
+
101
+ Source layout mirrors this: `src/core/` (request pipeline), `src/policies/`
102
+ (declarative rate limits, guards, idempotency), `src/session/`, `src/stores/`
103
+ (DynamoDB primitives), `src/client/`, and `src/shared/` (isomorphic modules
104
+ both entries re-export).
105
+
106
+ ## Documentation
107
+
108
+ Start with [Getting started](./docs/getting-started.md), then reach for the
109
+ guide that matches what you are building. The full index lives in
110
+ [docs/](./docs/README.md).
111
+
112
+ | Guide | Covers |
113
+ | --- | --- |
114
+ | [Getting started](./docs/getting-started.md) | The three-step path from a first API to a typed frontend call |
115
+ | [Configuration](./docs/configuration.md) | Every `initLambder().create({...})` option, in one reference |
116
+ | [Routing and actions](./docs/routing.md) | Routes, matchers, hooks, fallbacks, and non-HTTP invocations |
117
+ | [APIs and refusals](./docs/apis.md) | `addApi`/`addSessionApi`, the inferred contract, `refuse()` and `LambderApiError` |
118
+ | [Responses](./docs/responses.md) | The render context, resolver methods, cookies, compression, ETag and the size cap |
119
+ | [Sessions](./docs/sessions.md) | DynamoDB sessions, cookie scope, secrets at rest, `dataRefresh`, the controller API |
120
+ | [API policies](./docs/api-policies.md) | Declarative rate limits, guards and idempotency, and mandatory authorization declarations |
121
+ | [Frontend client](./docs/client.md) | `LambderCaller`: typed calls, failure outcomes, timeouts, guard inputs, request compression |
122
+ | [Frontend hosting](./docs/frontend-hosting.md) | File sources, `servePublicFiles`, `serveIndexHtml`, `res.templateFile` |
123
+ | [Templating](./docs/templating.md) | `html`/`xml` tagged templates and `LambderTemplatingEngine` |
124
+ | [Translations](./docs/i18n.md) | `createLambderI18n`: typed keys, extension, detection, runtime dictionaries |
125
+ | [Testing](./docs/testing.md) | `LambderMSW`: typed MSW mocking of the API contract |
126
+ | [DynamoDB tables](./docs/dynamodb-tables.md) | Table shapes, TTL and IAM for sessions, cache, rate limits and idempotency |
127
+ | [Exports reference](./docs/exports.md) | Every name the three entry points export, grouped by purpose |
128
+
129
+ ## Standalone modules
130
+
131
+ Self-contained tools that ship with the package and work with or without the
132
+ framework:
133
+
134
+ | Module | Guide | Description |
135
+ | --- | --- | --- |
136
+ | `html` / `xml` tags + `LambderTemplatingEngine` | [Templating](./docs/templating.md) | Type-safe tagged templates and a comment-only HTML template engine (build-pipeline-safe) |
137
+ | `createLambderI18n` | [Translations](./docs/i18n.md) | Typed translations with enforced/optional languages, component-level extension and auto language detection (isomorphic) |
138
+ | `LambderDdbCache` | [DynamoDB cache](./docs/ddb-cache.md) | DynamoDB-backed compressed JSON cache with lease-based single-fill and grouped keys (server-only) |
139
+ | `LambderDdbRateLimiter` | [Rate limiter](./docs/ddb-rate-limiter.md) | DynamoDB fixed-window rate limiter, atomic per window (server-only) |
140
+ | `LambderDdbIdempotency` | [Idempotency store](./docs/ddb-idempotency.md) | DynamoDB idempotency records with owner-checked claims and compressed replays (server-only) |
141
+ | `LambderMSW` | [Testing](./docs/testing.md) | Typed MSW mocking of the API contract for frontend development |
142
+
143
+ ## Versioning and changes
144
+
145
+ Released versions and what each one changed are in
146
+ [CHANGELOG.md](./CHANGELOG.md). The current major is v5, which is v4's API
147
+ plus this documentation set: upgrading from 4.x needs no code changes.
148
+ Upgrading from 3.x is covered by the breaking-changes section of the 4.0.1
149
+ entry.
150
+
151
+ ## Contributing
152
+
153
+ Contributions are welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md) for how to
154
+ run the tests and what a good change looks like.
155
+
156
+ ## License
157
+
158
+ MIT. See [LICENSE](./LICENSE).
@@ -21,7 +21,7 @@ type MaybePromise<T> = T | Promise<T>;
21
21
  type Path = `/${string}`;
22
22
  type ActionFunction = (ctx: LambderRenderContext, resolver: LambderResolver) => MaybePromise<LambderResponse>;
23
23
  type SessionActionFunction<SessionData = any> = (ctx: LambderSessionRenderContext<any, SessionData>, resolver: LambderResolver) => MaybePromise<LambderResponse>;
24
- type HookCreatedFunction = (lambderInstance: Lambder<any, any, any, any, any, any>) => void | Promise<void>;
24
+ type HookCreatedFunction = (lambderInstance: Lambder<any, any, any, any, any, any, any>) => void | Promise<void>;
25
25
  /** Return the (possibly replaced) ctx to continue, a LambderResponse to short-circuit, or an Error to fail. */
26
26
  type HookBeforeRenderFunction = (ctx: LambderRenderContext, resolver: LambderResolver) => MaybePromise<LambderRenderContext | LambderResponse | Error>;
27
27
  type HookAfterRenderFunction = (ctx: LambderRenderContext, resolver: LambderResolver, response: LambderResponse) => MaybePromise<LambderResponse | Error>;
@@ -157,19 +157,38 @@ export type LambderCreateOptions<TSessionData = any> = {
157
157
  * from. Default: false.
158
158
  */
159
159
  requireSessionApiGuards?: boolean;
160
+ /**
161
+ * The same for public APIs: every addApi must declare `guards`, at the
162
+ * type level and at registration.
163
+ *
164
+ * Public APIs are open by default and that is the right default, so this
165
+ * is off unless an app decides otherwise. What it buys an app that turns
166
+ * it on is that a public endpoint's openness becomes a written decision
167
+ * rather than an omission: the ones anybody may call declare a named no-op
168
+ * guard carrying the reason, and the ones that authorize their caller some
169
+ * other way (a signature, a device secret, a one-shot token) name where
170
+ * that happens. One grep over the guard names then lists every public
171
+ * door and why it is open, which is the review question a growing public
172
+ * surface makes expensive to answer any other way. Needs a guards map to
173
+ * pick from. Default: false.
174
+ */
175
+ requirePublicApiGuards?: boolean;
160
176
  /** Declarative idempotency: your store plus replay defaults; APIs opt in via `idempotency: true | { ttlSeconds }`. */
161
177
  idempotency?: LambderApiIdempotencyConfig;
162
178
  };
163
179
  /**
164
- * The `guards` field of a session API's options: optional by default,
165
- * required once create() received requireSessionApiGuards, so that an
166
- * authorization declaration cannot be forgotten at the type level.
180
+ * The `guards` field of an API's options: optional by default, required once
181
+ * create() received the require*ApiGuards flag for that kind of API, so that
182
+ * an authorization declaration cannot be forgotten at the type level.
183
+ *
184
+ * One type for both kinds: the requirement is the same shape either way, and
185
+ * only which flag switches it on differs.
167
186
  */
168
- type LambderSessionGuardsField<TRequired extends boolean, TGuardsOpt> = TRequired extends true ? {
169
- /** Named guards, run in declared order before input validation: a name, a list of names, or a { name: param } map for parameterized guards. Required on this instance (requireSessionApiGuards): an API the session alone authorizes declares the named no-op session guard. Their input requirements merge into this API's contract input; their return values land typed on ctx.guardData. */
187
+ type LambderRequirableGuardsField<TRequired extends boolean, TGuardsOpt> = TRequired extends true ? {
188
+ /** Named guards, run in declared order before input validation: a name, a non-empty list of names, or a non-empty { name: param } map for parameterized guards. Required on this instance: an API that needs no authorization declares a named no-op guard, so every opt-out is explicit and one grep lists them all. Their input requirements merge into this API's contract input; their return values land typed on ctx.guardData. */
170
189
  guards: TGuardsOpt;
171
190
  } : {
172
- /** Named guards, run in declared order before input validation: a name, a list of names, or a { name: param } map for parameterized guards. Their input requirements merge into this API's contract input; their return values land typed on ctx.guardData. */
191
+ /** Named guards, run in declared order before input validation: a name, a non-empty list of names, or a non-empty { name: param } map for parameterized guards. Their input requirements merge into this API's contract input; their return values land typed on ctx.guardData. */
173
192
  guards?: TGuardsOpt;
174
193
  };
175
194
  /**
@@ -184,6 +203,7 @@ type LambderSessionGuardsField<TRequired extends boolean, TGuardsOpt> = TRequire
184
203
  * @typeParam _TGuards - @internal Guard metadata map inferred from create()'s guards (do not pass manually)
185
204
  * @typeParam _TIdempotencyEnabled - @internal True when create() received idempotency (do not pass manually)
186
205
  * @typeParam _TSessionGuardsRequired - @internal True when create() received requireSessionApiGuards (do not pass manually)
206
+ * @typeParam _TPublicGuardsRequired - @internal True when create() received requirePublicApiGuards (do not pass manually)
187
207
  *
188
208
  * @example
189
209
  * ```typescript
@@ -194,7 +214,7 @@ type LambderSessionGuardsField<TRequired extends boolean, TGuardsOpt> = TRequire
194
214
  * .addApi('createUser', { input: z.object({...}), output: z.object({...}) }, handler);
195
215
  * ```
196
216
  */
197
- export default class Lambder<TSessionData = any, _TContract extends Record<string, any> = {}, _TRateLimitPolicies extends Record<string, LambderApiRateLimitPolicyConfig> = {}, _TGuards extends Record<string, any> = {}, _TIdempotencyEnabled extends boolean = false, _TSessionGuardsRequired extends boolean = false> {
217
+ export default class Lambder<TSessionData = any, _TContract extends Record<string, any> = {}, _TRateLimitPolicies extends Record<string, LambderApiRateLimitPolicyConfig> = {}, _TGuards extends Record<string, any> = {}, _TIdempotencyEnabled extends boolean = false, _TSessionGuardsRequired extends boolean = false, _TPublicGuardsRequired extends boolean = false> {
198
218
  apiPath: string;
199
219
  apiVersion: null | string;
200
220
  /** The instance's file reader (source + caches), or null without the files option. */
@@ -228,6 +248,7 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
228
248
  private finalizeOptions;
229
249
  private maxRequestPayloadBytes;
230
250
  private requireSessionApiGuards;
251
+ private requirePublicApiGuards;
231
252
  private lambderSessionManager?;
232
253
  private sessionCookieOptions;
233
254
  private sessionTokenCookieKey;
@@ -275,20 +296,18 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
275
296
  addRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: ActionFunction): this;
276
297
  addSessionRoute<TPath extends Path>(condition: TPath, actionFn: (ctx: LambderSessionRenderContext<any, TSessionData, PathParamsOf<TPath>>, resolver: LambderResolver) => MaybePromise<LambderResponse>): this;
277
298
  addSessionRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: SessionActionFunction<TSessionData>): this;
278
- use<_TNewContract extends Record<string, any>>(plugin: (lambder: Lambder<TSessionData, _TContract, any, any, any, any>) => Lambder<TSessionData, _TNewContract, any, any, any, any>): Lambder<TSessionData, _TNewContract extends _TContract ? _TNewContract : (_TContract & _TNewContract), _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired>;
299
+ use<_TNewContract extends Record<string, any>>(plugin: (lambder: Lambder<TSessionData, _TContract, any, any, any, any, any>) => Lambder<TSessionData, _TNewContract, any, any, any, any, any>): Lambder<TSessionData, _TNewContract extends _TContract ? _TNewContract : (_TContract & _TNewContract), _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired, _TPublicGuardsRequired>;
279
300
  addApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends LambderRateLimitOption<_TRateLimitPolicies, z.infer<TInput>, false> = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, z.infer<TInput>, false> = never>(name: TName, schema: {
280
301
  input: TInput;
281
302
  output: TOutput;
282
303
  } & {
283
304
  /** Named rate limits, checked in declared order before guards and validation: a name, a list of names, or a { name: true | override } map (windows overridable on perApi budgets, errorMessage on any). The first exceeded one refuses (429 envelope + Retry-After); attempts count on every counter checked before it. */
284
305
  rateLimit?: TRateOpt;
285
- /** Named guards, run in declared order before input validation: a name, a list of names, or a { name: param } map for parameterized guards. Their input requirements merge into this API's contract input; their return values land typed on ctx.guardData. */
286
- guards?: TGuardsOpt;
287
306
  /** Replay-protect this API per client idempotencyKey. Requires the idempotency option at creation. */
288
307
  idempotency?: _TIdempotencyEnabled extends true ? (boolean | {
289
308
  ttlSeconds?: number;
290
309
  }) : never;
291
- }, handler: (ctx: LambderRenderContext<z.infer<TInput>, Record<string, string>, LambderGuardDataOf<_TGuards, TGuardsOpt>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>, LambderGuardInputsOf<_TGuards, TGuardsOpt>, TGuardsOpt>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired>;
310
+ } & LambderRequirableGuardsField<_TPublicGuardsRequired, TGuardsOpt>, handler: (ctx: LambderRenderContext<z.infer<TInput>, Record<string, string>, LambderGuardDataOf<_TGuards, TGuardsOpt>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>, LambderGuardInputsOf<_TGuards, TGuardsOpt>, TGuardsOpt>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired, _TPublicGuardsRequired>;
292
311
  addSessionApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends LambderRateLimitOption<_TRateLimitPolicies, z.infer<TInput>, true> = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, z.infer<TInput>, true> = never>(name: TName, schema: {
293
312
  input: TInput;
294
313
  output: TOutput;
@@ -299,7 +318,7 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
299
318
  idempotency?: _TIdempotencyEnabled extends true ? (boolean | {
300
319
  ttlSeconds?: number;
301
320
  }) : never;
302
- } & LambderSessionGuardsField<_TSessionGuardsRequired, TGuardsOpt>, handler: (ctx: LambderSessionRenderContext<z.infer<TInput>, TSessionData, Record<string, string>, LambderGuardDataOf<_TGuards, TGuardsOpt>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>, LambderGuardInputsOf<_TGuards, TGuardsOpt>, TGuardsOpt>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired>;
321
+ } & LambderRequirableGuardsField<_TSessionGuardsRequired, TGuardsOpt>, handler: (ctx: LambderSessionRenderContext<z.infer<TInput>, TSessionData, Record<string, string>, LambderGuardDataOf<_TGuards, TGuardsOpt>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>, LambderGuardInputsOf<_TGuards, TGuardsOpt>, TGuardsOpt>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired, _TPublicGuardsRequired>;
303
322
  /**
304
323
  * Fetch the session or short-circuit the request: API calls get the
305
324
  * protocol's { sessionExpired: true } response (handled by LambderCaller),
@@ -382,5 +401,5 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
382
401
  export declare const initLambder: <TSessionData = any>() => {
383
402
  create<const TOptions extends LambderCreateOptions<TSessionData>>(options: TOptions): Lambder<TSessionData, {}, TOptions["rateLimits"] extends {
384
403
  policies: infer TPolicies extends Record<string, LambderApiRateLimitPolicyConfig>;
385
- } ? TPolicies : {}, TOptions["guards"] extends Record<string, LambderApiGuard<any, any, any>> ? LambderGuardMetaMap<TOptions["guards"]> : {}, TOptions["idempotency"] extends LambderApiIdempotencyConfig ? true : false, TOptions["requireSessionApiGuards"] extends true ? true : false>;
404
+ } ? TPolicies : {}, TOptions["guards"] extends Record<string, LambderApiGuard<any, any, any>> ? LambderGuardMetaMap<TOptions["guards"]> : {}, TOptions["idempotency"] extends LambderApiIdempotencyConfig ? true : false, TOptions["requireSessionApiGuards"] extends true ? true : false, TOptions["requirePublicApiGuards"] extends true ? true : false>;
386
405
  };
@@ -24,6 +24,7 @@ import { DEFAULT_MAX_REQUEST_PAYLOAD_BYTES } from "../shared/LambderRequestPaylo
24
24
  * @typeParam _TGuards - @internal Guard metadata map inferred from create()'s guards (do not pass manually)
25
25
  * @typeParam _TIdempotencyEnabled - @internal True when create() received idempotency (do not pass manually)
26
26
  * @typeParam _TSessionGuardsRequired - @internal True when create() received requireSessionApiGuards (do not pass manually)
27
+ * @typeParam _TPublicGuardsRequired - @internal True when create() received requirePublicApiGuards (do not pass manually)
27
28
  *
28
29
  * @example
29
30
  * ```typescript
@@ -68,6 +69,7 @@ export default class Lambder {
68
69
  finalizeOptions;
69
70
  maxRequestPayloadBytes;
70
71
  requireSessionApiGuards;
72
+ requirePublicApiGuards;
71
73
  lambderSessionManager;
72
74
  sessionCookieOptions = {};
73
75
  sessionTokenCookieKey = "LMDRSESSIONTKID";
@@ -116,8 +118,10 @@ export default class Lambder {
116
118
  if (options.idempotency)
117
119
  this.getOrCreatePolicyEngine().setIdempotency(options.idempotency);
118
120
  this.requireSessionApiGuards = options.requireSessionApiGuards ?? false;
119
- if (this.requireSessionApiGuards && !options.guards) {
120
- throw new Error("Lambder: requireSessionApiGuards needs a guards map at creation for session APIs to declare from.");
121
+ this.requirePublicApiGuards = options.requirePublicApiGuards ?? false;
122
+ const requireFlag = this.requireSessionApiGuards ? "requireSessionApiGuards" : "requirePublicApiGuards";
123
+ if ((this.requireSessionApiGuards || this.requirePublicApiGuards) && !options.guards) {
124
+ throw new Error(`Lambder: ${requireFlag} needs a guards map at creation for APIs to declare from.`);
121
125
  }
122
126
  }
123
127
  setRouteFallbackHandler(routeFallbackHandler) {
@@ -215,9 +219,13 @@ export default class Lambder {
215
219
  throw new Error(`Lambder: duplicate API name "${name}". Dispatch is first-match, so the second registration would be silently dead code.`);
216
220
  }
217
221
  this.registeredApiNames.add(name);
218
- if (mode === "session" && this.requireSessionApiGuards && options.guards === undefined) {
219
- throw new Error(`Lambder: session API "${name}" declares no guards, and requireSessionApiGuards is on. ` +
220
- `Declare the guard that authorizes it, or the named no-op guard that marks the session itself as the whole authorization.`);
222
+ const guardsRequired = mode === "session" ? this.requireSessionApiGuards : this.requirePublicApiGuards;
223
+ if (guardsRequired && options.guards === undefined) {
224
+ const optOut = mode === "session"
225
+ ? "the named no-op guard that marks the session itself as the whole authorization"
226
+ : "the named no-op guard that records why anyone may call it";
227
+ throw new Error(`Lambder: ${mode} API "${name}" declares no guards, and require${mode === "session" ? "Session" : "Public"}ApiGuards is on. ` +
228
+ `Declare the guard that authorizes it, or ${optOut}.`);
221
229
  }
222
230
  const usesPolicies = options.rateLimit !== undefined || options.guards !== undefined || options.idempotency !== undefined;
223
231
  if (!usesPolicies)
@@ -164,18 +164,42 @@ export type LambderParamlessGuardNames<TGuards, TPayload, TIncludeSession extend
164
164
  param: undefined;
165
165
  } ? K & string : never;
166
166
  }[LambderAllowedGuardNames<TGuards, TPayload, TIncludeSession> & keyof TGuards];
167
- /**
168
- * The per-API `guards` option: one paramless guard name, an ordered list of
169
- * paramless names, or an object map that can carry each guard's param
170
- * (`true` enables a paramless guard). Map entries run in insertion order.
171
- */
172
- export type LambderGuardsOption<TGuards, TPayload, TIncludeSession extends boolean> = LambderParamlessGuardNames<TGuards, TPayload, TIncludeSession> | readonly LambderParamlessGuardNames<TGuards, TPayload, TIncludeSession>[] | {
167
+ /** The map form's full shape: every declarable guard name, each carrying its own param type. */
168
+ type LambderGuardsMap<TGuards, TPayload, TIncludeSession extends boolean> = {
173
169
  readonly [K in LambderAllowedGuardNames<TGuards, TPayload, TIncludeSession> & keyof TGuards]?: TGuards[K] extends {
174
170
  param: undefined;
175
171
  } ? true : TGuards[K] extends {
176
172
  param: infer P;
177
173
  } ? P : true;
178
174
  };
175
+ /**
176
+ * The map form with AT LEAST ONE entry: the union, over every declarable
177
+ * name, of "this one required and the rest optional".
178
+ *
179
+ * An all-optional map is inhabited by `{}`, which would let `guards: {}`
180
+ * satisfy requireSessionApiGuards / requirePublicApiGuards at the type level
181
+ * while declaring no guard at all: the option is present, so the required-field
182
+ * check passes, and it normalizes to zero entries, so nothing runs. Requiring
183
+ * the chosen key also rejects `{ theGuard: undefined }`, which an optional
184
+ * property accepts and which would otherwise reach the guard's handler with an
185
+ * undefined param.
186
+ */
187
+ type LambderNonEmptyGuardsMap<TGuards, TPayload, TIncludeSession extends boolean, TMap = LambderGuardsMap<TGuards, TPayload, TIncludeSession>> = {
188
+ [K in keyof TMap]-?: Required<Pick<TMap, K>> & Omit<TMap, K>;
189
+ }[keyof TMap];
190
+ /**
191
+ * The per-API `guards` option: one paramless guard name, a non-empty ordered
192
+ * list of paramless names, or a non-empty object map that can carry each
193
+ * guard's param (`true` enables a paramless guard). Map entries run in
194
+ * insertion order.
195
+ *
196
+ * Every form is non-empty by construction, so declaring the option is always
197
+ * declaring a guard. See LambderNonEmptyGuardsMap.
198
+ */
199
+ export type LambderGuardsOption<TGuards, TPayload, TIncludeSession extends boolean> = LambderParamlessGuardNames<TGuards, TPayload, TIncludeSession> | readonly [
200
+ LambderParamlessGuardNames<TGuards, TPayload, TIncludeSession>,
201
+ ...LambderParamlessGuardNames<TGuards, TPayload, TIncludeSession>[]
202
+ ] | LambderNonEmptyGuardsMap<TGuards, TPayload, TIncludeSession>;
179
203
  /**
180
204
  * The typed ctx.guardData an API's handler sees: declared guards that return
181
205
  * a value, keyed by name. Check-only (void) guards never appear.
@@ -48,7 +48,18 @@ export class LambderApiGuardsEngine {
48
48
  }
49
49
  /** Startup validation of one API registration's guards option. */
50
50
  assertRegistration(apiName, mode, guardsOption) {
51
- for (const { name } of toGuardEntries(guardsOption)) {
51
+ const entries = toGuardEntries(guardsOption);
52
+ // The runtime half of LambderNonEmptyGuardsMap. `guards: {}` and
53
+ // `guards: []` are present-but-empty: they satisfy the require*ApiGuards
54
+ // field check while running nothing, which is the one shape that turns a
55
+ // mandatory authorization declaration back into an optional one. The type
56
+ // rejects both; a plain-JS caller, a cast, or a spread that happened to
57
+ // produce an empty object lands here instead.
58
+ if (guardsOption !== undefined && entries.length === 0) {
59
+ throw new Error(`Lambder: API "${apiName}" declares an empty guards option, which authorizes nothing. ` +
60
+ `Name the guard that authorizes it, or omit the option entirely.`);
61
+ }
62
+ for (const { name } of entries) {
52
63
  const guardDef = this.guards[name];
53
64
  if (!guardDef) {
54
65
  throw new Error(`Lambder: API "${apiName}" references unknown guard "${name}". Declare it in the guards option at creation.`);
@@ -4,7 +4,7 @@
4
4
  * Zero dependencies, no Node/DOM requirements (browser detection is feature-gated),
5
5
  * safe to import in both lambda backends and frontend bundles.
6
6
  *
7
- * See docs/I18N.md for the full guide.
7
+ * See docs/i18n.md for the full guide.
8
8
  */
9
9
  export interface LambderLanguageMeta {
10
10
  /** Native language name (shown in language switchers). */
@@ -4,7 +4,7 @@
4
4
  * Zero dependencies, no Node/DOM requirements (browser detection is feature-gated),
5
5
  * safe to import in both lambda backends and frontend bundles.
6
6
  *
7
- * See docs/I18N.md for the full guide.
7
+ * See docs/i18n.md for the full guide.
8
8
  */
9
9
  // ---------------------------------------------------------------------------
10
10
  // Implementation
package/package.json CHANGED
@@ -1,10 +1,40 @@
1
1
  {
2
2
  "name": "lambder",
3
- "version": "4.8.1",
4
- "description": "",
3
+ "version": "5.0.0",
4
+ "description": "Opinionated serverless web framework for TypeScript on AWS Lambda: type-safe APIs from Zod schemas, DynamoDB sessions, and declarative rate limits, authorization guards and idempotency.",
5
+ "keywords": [
6
+ "lambda",
7
+ "aws-lambda",
8
+ "serverless",
9
+ "framework",
10
+ "typescript",
11
+ "api",
12
+ "zod",
13
+ "type-safe",
14
+ "rest",
15
+ "api-gateway",
16
+ "dynamodb",
17
+ "session",
18
+ "rate-limit",
19
+ "idempotency",
20
+ "guards",
21
+ "msw",
22
+ "i18n",
23
+ "templating"
24
+ ],
25
+ "homepage": "https://github.com/nesovera/lambder#readme",
26
+ "bugs": {
27
+ "url": "https://github.com/nesovera/lambder/issues"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/nesovera/lambder.git"
32
+ },
33
+ "license": "MIT",
34
+ "author": "NesoVera",
35
+ "type": "module",
5
36
  "main": "dist/index.js",
6
37
  "types": "dist/index.d.ts",
7
- "type": "module",
8
38
  "exports": {
9
39
  ".": {
10
40
  "types": "./dist/index.d.ts",
@@ -38,6 +68,9 @@
38
68
  "files": [
39
69
  "dist"
40
70
  ],
71
+ "engines": {
72
+ "node": ">=18"
73
+ },
41
74
  "scripts": {
42
75
  "typecheck": "tsc -p tsconfig.tests.json",
43
76
  "test": "npm run typecheck && vitest run",
@@ -45,12 +78,6 @@
45
78
  "build": "tsc",
46
79
  "lint": "eslint . --ext .ts,.tsx --fix"
47
80
  },
48
- "author": "",
49
- "license": "MIT",
50
- "repository": {
51
- "type": "git",
52
- "url": "https://github.com/nesovera/lambder.git"
53
- },
54
81
  "dependencies": {
55
82
  "cookie": "^1.0.2",
56
83
  "js-cookie": "^3.0.5",