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 +158 -0
- package/dist/core/Lambder.d.ts +33 -14
- package/dist/core/Lambder.js +13 -5
- package/dist/policies/LambderApiGuards.d.ts +30 -6
- package/dist/policies/LambderApiGuards.js +12 -1
- package/dist/shared/LambderI18n.d.ts +1 -1
- package/dist/shared/LambderI18n.js +1 -1
- package/package.json +36 -9
- package/Readme.md +0 -1005
- /package/{License.md → LICENSE} +0 -0
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).
|
package/dist/core/Lambder.d.ts
CHANGED
|
@@ -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
|
|
165
|
-
*
|
|
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
|
|
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
|
|
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
|
-
} &
|
|
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
|
};
|
package/dist/core/Lambder.js
CHANGED
|
@@ -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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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/
|
|
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
|
-
"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",
|