lambder 3.3.3 → 3.5.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.
- package/Readme.md +139 -2
- package/dist/Lambder.d.ts +55 -6
- package/dist/Lambder.js +110 -3
- package/dist/LambderApiError.d.ts +54 -0
- package/dist/LambderApiError.js +38 -0
- package/dist/LambderApiPolicies.d.ts +95 -0
- package/dist/LambderApiPolicies.js +196 -0
- package/dist/LambderCaller.d.ts +77 -24
- package/dist/LambderCaller.js +212 -74
- package/dist/LambderDdbCache.d.ts +8 -0
- package/dist/LambderDdbCache.js +8 -1
- package/dist/LambderDdbIdempotency.d.ts +72 -0
- package/dist/LambderDdbIdempotency.js +132 -0
- package/dist/LambderDdbRateLimiter.d.ts +4 -1
- package/dist/LambderDdbRateLimiter.js +3 -0
- package/dist/LambderSessionController.d.ts +15 -2
- package/dist/LambderSessionController.js +33 -0
- package/dist/LambderSessionManager.d.ts +56 -2
- package/dist/LambderSessionManager.js +125 -32
- package/dist/index.d.ts +8 -1
- package/dist/index.js +5 -0
- package/package.json +1 -1
package/Readme.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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)
|
|
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.
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
@@ -275,6 +275,35 @@ lambder
|
|
|
275
275
|
|
|
276
276
|
See [docs/DYNAMODB_SETUP.md](docs/DYNAMODB_SETUP.md) for detailed setup instructions.
|
|
277
277
|
|
|
278
|
+
#### Keeping session data fresh (`dataRefresh`)
|
|
279
|
+
|
|
280
|
+
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
|
+
|
|
282
|
+
```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);
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Semantics:
|
|
299
|
+
|
|
300
|
+
- The callback must be a pure derivation of external state: concurrent reads may run it in parallel, last write wins.
|
|
301
|
+
- Returning `null` deletes the session; the request is answered as session-expired.
|
|
302
|
+
- 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.
|
|
303
|
+
- The renewal write and the sliding-expiration write share a single DynamoDB put when both are due.
|
|
304
|
+
- Records created before `dataRefresh` was enabled renew on their first read.
|
|
305
|
+
- `updateSessionData()` marks data fresh (it was just written deliberately); `regenerateSession()` carries the old freshness stamp over.
|
|
306
|
+
|
|
278
307
|
#### Session Controller
|
|
279
308
|
|
|
280
309
|
Access the session controller with `lambder.getSessionController(ctx)`:
|
|
@@ -285,8 +314,10 @@ Access the session controller with `lambder.getSessionController(ctx)`:
|
|
|
285
314
|
| `fetchSession()` | Fetch & validate existing session (throws if not found) |
|
|
286
315
|
| `fetchSessionIfExists()` | Returns session or null |
|
|
287
316
|
| `updateSessionData(newData)` | Update session data in DDB |
|
|
317
|
+
| `refreshSessionData()` | Run the `dataRefresh` callback now, regardless of TTL |
|
|
288
318
|
| `endSession()` | End session, delete from DDB |
|
|
289
319
|
| `endSessionAll()` | End all sessions for this sessionKey (all devices) |
|
|
320
|
+
| `deleteSessionAllByKey(sessionKey)` | Delete all sessions of any sessionKey (e.g. "log user X out everywhere") |
|
|
290
321
|
| `regenerateSession()` | Regenerate token (use after password change) |
|
|
291
322
|
|
|
292
323
|
### Type-Safe Templating (html / xml)
|
|
@@ -397,9 +428,88 @@ Responses are finalized once at the end of the request: automatic gzip (when the
|
|
|
397
428
|
|
|
398
429
|
**Die Methods**: `res.die.*` - Builds the response and throws it, immediately halting the request at any call depth (handlers, hooks, nested helper functions). Plain `throw res.html(...)` works the same way.
|
|
399
430
|
|
|
431
|
+
### Typed API Refusals (LambderApiError)
|
|
432
|
+
|
|
433
|
+
A refusal ("you are not allowed", "quota exceeded") is not a crash. `res.die.*` covers refusals where you hold the resolver, but shared helpers (permission checks, validators) usually don't. Throw `LambderApiError` from anywhere in an API call's stack and the pipeline maps it onto the structured envelope instead of the global error handler, so refusals never pollute crash logging and clients get a parseable response:
|
|
434
|
+
|
|
435
|
+
```typescript
|
|
436
|
+
import { LambderApiError } from "lambder";
|
|
437
|
+
|
|
438
|
+
// In any helper, no resolver needed:
|
|
439
|
+
export const requirePermission = (granted: boolean) => {
|
|
440
|
+
if (!granted) throw new LambderApiError("Permission denied.", {
|
|
441
|
+
notAuthorized: true, // envelope flag -> caller's notAuthorizedHandler
|
|
442
|
+
errorMessage: { type: "warning", content: "Not allowed." }, // any shape your errorMessageHandler expects
|
|
443
|
+
// sessionExpired: true, // optional envelope flag
|
|
444
|
+
// statusCode: 403, // optional; default 200 (avoid 5xx and 422)
|
|
445
|
+
});
|
|
446
|
+
};
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
`errorMessage` defaults to the error's message string, so `throw new LambderApiError("Nope.")` alone is already visible to the client. Thrown outside an API call (e.g. in a route handler) it behaves like a normal error. The class is isomorphic and dependency-free, so shared server/browser packages can import it safely. Detection is brand-based (`isLambderApiError`), so it works even when two copies of lambder end up in one bundle.
|
|
450
|
+
|
|
451
|
+
Related: when an API call crashes with no `setGlobalErrorHandler` (or the handler itself fails), the last-resort 500 is now a JSON envelope (`{ payload: null, errorMessage: "Internal server error." }`) instead of a plain-text page; routes keep the plain-text 500.
|
|
452
|
+
|
|
453
|
+
### Declarative API Policies (rate limits, guards, idempotency)
|
|
454
|
+
|
|
455
|
+
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.
|
|
456
|
+
|
|
457
|
+
```typescript
|
|
458
|
+
import Lambder, { LambderDdbRateLimiter, LambderDdbIdempotency, LambderApiError } from "lambder";
|
|
459
|
+
|
|
460
|
+
const lambder = new Lambder<SessionData>({ apiPath: "/api" })
|
|
461
|
+
// 1. Rate limiting: your limiter instance + named policies. Each policy
|
|
462
|
+
// declares its windows AND what one counter tracks ("per").
|
|
463
|
+
.enableApiRateLimits({
|
|
464
|
+
limiter: new LambderDdbRateLimiter({ tableName: "app-rate-limiter", region: "us-east-1", failOpen: true }),
|
|
465
|
+
policies: {
|
|
466
|
+
authPerIp: { perMin: 5, perHour: 30, per: "ip" },
|
|
467
|
+
writePerUser: { perMin: 30, per: "session" }, // only referable from addSessionApi (also enforced at compile time)
|
|
468
|
+
codePerEmail: { perMin: 3, per: (ctx) => String(ctx.post?.payload?.email ?? "").toLowerCase(),
|
|
469
|
+
errorMessage: { type: "warning", content: "Too many attempts for this address." } },
|
|
470
|
+
},
|
|
471
|
+
})
|
|
472
|
+
// 2. Idempotency: a store instance + replay defaults. May share the rate
|
|
473
|
+
// limiter's table (records use an IDEM# key prefix).
|
|
474
|
+
.enableApiIdempotency({
|
|
475
|
+
store: new LambderDdbIdempotency({ tableName: "app-rate-limiter", region: "us-east-1" }),
|
|
476
|
+
defaultTtlSeconds: 24 * 3600,
|
|
477
|
+
failOpen: true, // DynamoDB down => execute without dedupe instead of failing
|
|
478
|
+
})
|
|
479
|
+
// 3. Named guards: run before input validation, refuse by throwing.
|
|
480
|
+
// Callable multiple times; domain modules can contribute their own.
|
|
481
|
+
.defineApiGuards({
|
|
482
|
+
captcha: async (ctx) => {
|
|
483
|
+
if (!await verifyCaptcha(ctx.post?.payload?.captchaToken, ctx.ip)) {
|
|
484
|
+
throw new LambderApiError("Captcha failed", { errorMessage: "Verification failed, please retry." });
|
|
485
|
+
}
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
lambder.addApi("public.resetPassword", {
|
|
490
|
+
input: z.object({ email: z.string().email(), captchaToken: z.string() }),
|
|
491
|
+
output: z.object({ ok: z.boolean() }),
|
|
492
|
+
rateLimit: ["authPerIp", "codePerEmail"], // stacked: checked in order, first exceeded refuses (429 envelope)
|
|
493
|
+
guards: "captcha",
|
|
494
|
+
}, handler);
|
|
495
|
+
|
|
496
|
+
lambder.addSessionApi("secure.order.create", {
|
|
497
|
+
input: OrderSchema,
|
|
498
|
+
output: OrderResultSchema,
|
|
499
|
+
rateLimit: "writePerUser",
|
|
500
|
+
idempotency: true, // or { ttlSeconds: 3600 }; type error until enableApiIdempotency()
|
|
501
|
+
}, handler);
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
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.
|
|
505
|
+
|
|
506
|
+
**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.
|
|
507
|
+
|
|
508
|
+
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).
|
|
509
|
+
|
|
400
510
|
### DynamoDB Cache (LambderDdbCache)
|
|
401
511
|
|
|
402
|
-
Standalone, persistent JSON cache backed by a DynamoDB table (`pk`/`sk` keys + `expiresAt` TTL attribute, same shape as the session table). Brotli-compressed values, in-memory LRU layer, single-flight deduplication, a DynamoDB lease so only one Lambda fills a missing key, and fail-open semantics. Server-only. **Full guide with table setup: [docs/DDB_CACHE.md](./docs/DDB_CACHE.md).**
|
|
512
|
+
Standalone, persistent JSON cache backed by a DynamoDB table (`pk`/`sk` keys + `expiresAt` TTL attribute, same shape as the session table). Items are prefixed `CACHE#<namespace>#`, and the rate limiter (`RL#`) and idempotency store (`IDEM#`) prefix theirs too, so all three non-session systems can share one table without collisions; keep sessions in their own table for IAM scoping. Brotli-compressed values, in-memory LRU layer, single-flight deduplication, a DynamoDB lease so only one Lambda fills a missing key, and fail-open semantics. Server-only. **Full guide with table setup: [docs/DDB_CACHE.md](./docs/DDB_CACHE.md).**
|
|
403
513
|
|
|
404
514
|
```typescript
|
|
405
515
|
import { LambderDdbCache } from "lambder";
|
|
@@ -474,6 +584,33 @@ const user = await lambderCaller.api("getCompanyPage", { companyName: "Acme" });
|
|
|
474
584
|
// - Expected output type
|
|
475
585
|
```
|
|
476
586
|
|
|
587
|
+
### Failure Semantics (apiOutcome, timeouts, per-call overrides)
|
|
588
|
+
|
|
589
|
+
`api()` collapses every failure to `null`, which is indistinguishable from a legitimately-null payload. When the call site needs to know why, use `apiOutcome()`; it never throws and resolves to a discriminated union:
|
|
590
|
+
|
|
591
|
+
```typescript
|
|
592
|
+
const outcome = await lambderCaller.apiOutcome("getCompanyPage", { companyName: "Acme" });
|
|
593
|
+
if (outcome.ok) {
|
|
594
|
+
render(outcome.payload);
|
|
595
|
+
} else if (outcome.reason === "network" || outcome.reason === "timeout") {
|
|
596
|
+
showOfflineScreen();
|
|
597
|
+
} else if (outcome.reason === "sessionExpired") {
|
|
598
|
+
redirectToLogin();
|
|
599
|
+
} else {
|
|
600
|
+
// 'server' (5xx / non-envelope body), 'validation' (422), 'versionExpired',
|
|
601
|
+
// 'notAuthorized', 'errorMessage' (structured refusal), 'unknown'
|
|
602
|
+
showError(outcome.errorMessage);
|
|
603
|
+
}
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
Every configured handler still fires on the matching failure, so global UX (toasts, re-login prompts) lives in the constructor while individual call sites branch on the outcome.
|
|
607
|
+
|
|
608
|
+
Also available:
|
|
609
|
+
|
|
610
|
+
- **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.
|
|
611
|
+
- **Per-call handler overrides**: every constructor handler (`errorHandler`, `sessionExpiredHandler`, `errorMessageHandler`, ...) can be overridden in the options of a single `api`/`apiRaw`/`apiOutcome` call.
|
|
612
|
+
- **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.
|
|
613
|
+
|
|
477
614
|
### Benefits
|
|
478
615
|
|
|
479
616
|
✅ **No Manual Type Definitions** - Types are inferred from your Zod schemas
|
package/dist/Lambder.d.ts
CHANGED
|
@@ -5,8 +5,10 @@ import LambderResponseBuilder from "./LambderResponseBuilder.js";
|
|
|
5
5
|
import { LambderResponse, type LambderHttpResponse } from "./LambderResponse.js";
|
|
6
6
|
import { type ConditionFunction, type LambderRouteMatcher, type PathParamsOf } from "./LambderRouting.js";
|
|
7
7
|
import { type LambderCorsConfig } from "./LambderCors.js";
|
|
8
|
+
import { type LambderSessionDataRefreshConfig } from "./LambderSessionManager.js";
|
|
8
9
|
import LambderSessionController, { type LambderSessionCookieOptions } from "./LambderSessionController.js";
|
|
9
10
|
import { type LambderPublicFilesOptions } from "./LambderPublicFiles.js";
|
|
11
|
+
import { type LambderApiRateLimitPolicyConfig, type LambderApiRateLimitsConfig, type LambderApiIdempotencyConfig, type LambderApiGuardFunction, type LambderApiRegistrationOptions, type LambderPublicRateLimitNames } from "./LambderApiPolicies.js";
|
|
10
12
|
import type { MergeContract } from "./LambderApiContract.js";
|
|
11
13
|
import { type LambderHttpEvent, type LambderRenderContext, type LambderSessionRenderContext } from "./LambderContext.js";
|
|
12
14
|
export type { PathParamsOf, RouteCondition, ConditionFunction, LambderRouteMatcher } from "./LambderRouting.js";
|
|
@@ -15,7 +17,7 @@ type MaybePromise<T> = T | Promise<T>;
|
|
|
15
17
|
type Path = `/${string}`;
|
|
16
18
|
type ActionFunction = (ctx: LambderRenderContext, resolver: LambderResolver) => MaybePromise<LambderResponse>;
|
|
17
19
|
type SessionActionFunction<SessionData = any> = (ctx: LambderSessionRenderContext<any, SessionData>, resolver: LambderResolver) => MaybePromise<LambderResponse>;
|
|
18
|
-
type HookCreatedFunction = (lambderInstance: Lambder<any, any>) => void | Promise<void>;
|
|
20
|
+
type HookCreatedFunction = (lambderInstance: Lambder<any, any, any, any, any>) => void | Promise<void>;
|
|
19
21
|
/** Return the (possibly replaced) ctx to continue, a LambderResponse to short-circuit, or an Error to fail. */
|
|
20
22
|
type HookBeforeRenderFunction = (ctx: LambderRenderContext, resolver: LambderResolver) => MaybePromise<LambderRenderContext | LambderResponse | Error>;
|
|
21
23
|
type HookAfterRenderFunction = (ctx: LambderRenderContext, resolver: LambderResolver, response: LambderResponse) => MaybePromise<LambderResponse | Error>;
|
|
@@ -78,6 +80,9 @@ export type LambderConstructorOptions = {
|
|
|
78
80
|
*
|
|
79
81
|
* @typeParam TSessionData - Type of session data stored in DynamoDB
|
|
80
82
|
* @typeParam _TContract - @internal Accumulates API contract during chaining (do not pass manually)
|
|
83
|
+
* @typeParam _TRateLimitPolicies - @internal Accumulated by enableApiRateLimits (do not pass manually)
|
|
84
|
+
* @typeParam _TGuardName - @internal Accumulated by defineApiGuards (do not pass manually)
|
|
85
|
+
* @typeParam _TIdempotencyEnabled - @internal Flipped by enableApiIdempotency (do not pass manually)
|
|
81
86
|
*
|
|
82
87
|
* @example
|
|
83
88
|
* ```typescript
|
|
@@ -88,7 +93,7 @@ export type LambderConstructorOptions = {
|
|
|
88
93
|
* .addApi('createUser', { input: z.object({...}), output: z.object({...}) }, handler);
|
|
89
94
|
* ```
|
|
90
95
|
*/
|
|
91
|
-
export default class Lambder<TSessionData = any, _TContract extends Record<string, any> = {}> {
|
|
96
|
+
export default class Lambder<TSessionData = any, _TContract extends Record<string, any> = {}, _TRateLimitPolicies extends Record<string, LambderApiRateLimitPolicyConfig> = {}, _TGuardName extends string = never, _TIdempotencyEnabled extends boolean = false> {
|
|
92
97
|
apiPath: string;
|
|
93
98
|
apiVersion: null | string;
|
|
94
99
|
publicPath: string;
|
|
@@ -104,6 +109,8 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
104
109
|
*/
|
|
105
110
|
readonly ApiContract: _TContract;
|
|
106
111
|
private actionList;
|
|
112
|
+
private apiPolicyEngine;
|
|
113
|
+
private registeredApiNames;
|
|
107
114
|
private hookList;
|
|
108
115
|
private createdHooks;
|
|
109
116
|
private initPromise;
|
|
@@ -123,7 +130,7 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
123
130
|
private sessionCsrfCookieKey;
|
|
124
131
|
constructor(options?: LambderConstructorOptions);
|
|
125
132
|
enableCors(config: boolean | LambderCorsConfig): this;
|
|
126
|
-
enableDdbSession({ tableName, tableRegion, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, cookie, partitionKey, sortKey, }: {
|
|
133
|
+
enableDdbSession({ tableName, tableRegion, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, cookie, partitionKey, sortKey, dataRefresh, }: {
|
|
127
134
|
tableName: string;
|
|
128
135
|
tableRegion: string;
|
|
129
136
|
sessionSalt: string;
|
|
@@ -134,6 +141,15 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
134
141
|
cookie?: LambderSessionCookieOptions;
|
|
135
142
|
partitionKey?: string;
|
|
136
143
|
sortKey?: string;
|
|
144
|
+
/**
|
|
145
|
+
* Opt-in freshness for session.data derived from external state
|
|
146
|
+
* (roles, permissions, feature flags...). Every session read
|
|
147
|
+
* renews data past its ttlSeconds via your refresh callback,
|
|
148
|
+
* persisting in place on the same record: same tokens, same
|
|
149
|
+
* cookies. Return null from refresh to end the session. See
|
|
150
|
+
* LambderSessionDataRefreshConfig for the exact semantics.
|
|
151
|
+
*/
|
|
152
|
+
dataRefresh?: LambderSessionDataRefreshConfig<TSessionData>;
|
|
137
153
|
}): this;
|
|
138
154
|
setSessionCookieKey(sessionTokenCookieKey: string, sessionCsrfCookieKey: string): this;
|
|
139
155
|
setRouteFallbackHandler(routeFallbackHandler: FallbackHandlerFunction): this;
|
|
@@ -163,19 +179,50 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
163
179
|
serveIndexHtml(handler?: FallbackHandlerFunction, options?: LambderIndexHtmlOptions): this;
|
|
164
180
|
/** Apply the serveIndexHtml gates; null means fall through. */
|
|
165
181
|
private tryServeIndexHtml;
|
|
182
|
+
/**
|
|
183
|
+
* Wire declarative per-API rate limiting: your LambderDdbRateLimiter
|
|
184
|
+
* instance plus named policies, each declaring its windows and what one
|
|
185
|
+
* counter tracks (`per`: "ip", "session", or a custom key function).
|
|
186
|
+
* APIs then reference policies by name via the `rateLimit` option; the
|
|
187
|
+
* returned type narrows so only declared names are accepted, and
|
|
188
|
+
* policies keyed per "session" are only referable from addSessionApi.
|
|
189
|
+
* Callable once; call it before the API registrations that use it.
|
|
190
|
+
*/
|
|
191
|
+
enableApiRateLimits<const TPolicies extends Record<string, LambderApiRateLimitPolicyConfig>>(config: LambderApiRateLimitsConfig<TPolicies>): Lambder<TSessionData, _TContract, TPolicies, _TGuardName, _TIdempotencyEnabled>;
|
|
192
|
+
/**
|
|
193
|
+
* Wire declarative idempotency: your LambderDdbIdempotency instance plus
|
|
194
|
+
* replay defaults. APIs opt in via `idempotency: true | { ttlSeconds }`;
|
|
195
|
+
* the option is a type error until this is called. Requests carrying a
|
|
196
|
+
* client `idempotencyKey` (sent by LambderCaller) claim an
|
|
197
|
+
* identity+api+key scope atomically: concurrent duplicates refuse with
|
|
198
|
+
* 409, replays of a completed request return the stored response, and a
|
|
199
|
+
* crashed original releases its claim. Callable once.
|
|
200
|
+
*/
|
|
201
|
+
enableApiIdempotency(config: LambderApiIdempotencyConfig): Lambder<TSessionData, _TContract, _TRateLimitPolicies, _TGuardName, true>;
|
|
202
|
+
/**
|
|
203
|
+
* Define named guards that APIs reference (typed) via the `guards`
|
|
204
|
+
* option. Guards run before input validation, in the order the API
|
|
205
|
+
* declares them; a guard refuses by throwing (typically LambderApiError).
|
|
206
|
+
* Callable multiple times so domain modules can contribute their own;
|
|
207
|
+
* names must not collide.
|
|
208
|
+
*/
|
|
209
|
+
defineApiGuards<TGuards extends Record<string, LambderApiGuardFunction>>(guards: TGuards): Lambder<TSessionData, _TContract, _TRateLimitPolicies, _TGuardName | Extract<keyof TGuards, string>, _TIdempotencyEnabled>;
|
|
210
|
+
private getOrCreatePolicyEngine;
|
|
211
|
+
/** Registration-time checks shared by addApi/addSessionApi. */
|
|
212
|
+
private assertApiRegistration;
|
|
166
213
|
addRoute<TPath extends Path>(condition: TPath, actionFn: (ctx: LambderRenderContext<any, PathParamsOf<TPath>>, resolver: LambderResolver) => MaybePromise<LambderResponse>): this;
|
|
167
214
|
addRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: ActionFunction): this;
|
|
168
215
|
addSessionRoute<TPath extends Path>(condition: TPath, actionFn: (ctx: LambderSessionRenderContext<any, TSessionData, PathParamsOf<TPath>>, resolver: LambderResolver) => MaybePromise<LambderResponse>): this;
|
|
169
216
|
addSessionRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: SessionActionFunction<TSessionData>): this;
|
|
170
|
-
use<_TNewContract extends Record<string, any>>(plugin: (lambder: Lambder<TSessionData, _TContract>) => Lambder<TSessionData, _TNewContract>): Lambder<TSessionData, _TNewContract extends _TContract ? _TNewContract : (_TContract & _TNewContract)>;
|
|
217
|
+
use<_TNewContract extends Record<string, any>>(plugin: (lambder: Lambder<TSessionData, _TContract, any, any, any>) => Lambder<TSessionData, _TNewContract, any, any, any>): Lambder<TSessionData, _TNewContract extends _TContract ? _TNewContract : (_TContract & _TNewContract), _TRateLimitPolicies, _TGuardName, _TIdempotencyEnabled>;
|
|
171
218
|
addApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny>(name: TName, schema: {
|
|
172
219
|
input: TInput;
|
|
173
220
|
output: TOutput;
|
|
174
|
-
}, handler: (ctx: LambderRenderContext<z.infer<TInput>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput
|
|
221
|
+
} & LambderApiRegistrationOptions<LambderPublicRateLimitNames<_TRateLimitPolicies>, _TGuardName, _TIdempotencyEnabled>, handler: (ctx: LambderRenderContext<z.infer<TInput>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>>, _TRateLimitPolicies, _TGuardName, _TIdempotencyEnabled>;
|
|
175
222
|
addSessionApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny>(name: TName, schema: {
|
|
176
223
|
input: TInput;
|
|
177
224
|
output: TOutput;
|
|
178
|
-
}, handler: (ctx: LambderSessionRenderContext<z.infer<TInput>, TSessionData>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput
|
|
225
|
+
} & LambderApiRegistrationOptions<Extract<keyof _TRateLimitPolicies, string>, _TGuardName, _TIdempotencyEnabled>, handler: (ctx: LambderSessionRenderContext<z.infer<TInput>, TSessionData>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>>, _TRateLimitPolicies, _TGuardName, _TIdempotencyEnabled>;
|
|
179
226
|
/**
|
|
180
227
|
* Fetch the session or short-circuit the request: API calls get the
|
|
181
228
|
* protocol's { sessionExpired: true } response (handled by LambderCaller),
|
|
@@ -189,6 +236,8 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
189
236
|
getSessionController(ctx: LambderRenderContext | LambderSessionRenderContext<any, TSessionData>): LambderSessionController<TSessionData>;
|
|
190
237
|
getResponseBuilder(ctx?: LambderRenderContext): LambderResponseBuilder<any>;
|
|
191
238
|
private getResolver;
|
|
239
|
+
/** Map a thrown LambderApiError onto the structured API envelope. */
|
|
240
|
+
private apiErrorResponse;
|
|
192
241
|
getHandler(): LambderHandler;
|
|
193
242
|
/** True when the Lambda event is an API Gateway HTTP event (REST API v1 or HTTP API / Function URL v2). */
|
|
194
243
|
static isHttpEvent(event: unknown): event is LambderHttpEvent;
|
package/dist/Lambder.js
CHANGED
|
@@ -6,12 +6,17 @@ import { applyCorsHeaders } from "./LambderCors.js";
|
|
|
6
6
|
import LambderSessionManager from "./LambderSessionManager.js";
|
|
7
7
|
import LambderSessionController from "./LambderSessionController.js";
|
|
8
8
|
import { LambderPublicFilesHandler } from "./LambderPublicFiles.js";
|
|
9
|
+
import { isLambderApiError } from "./LambderApiError.js";
|
|
10
|
+
import { LambderApiPolicyEngine, } from "./LambderApiPolicies.js";
|
|
9
11
|
import { createContext, isV2HttpEvent } from "./LambderContext.js";
|
|
10
12
|
/**
|
|
11
13
|
* Main Lambder class for building type-safe serverless APIs
|
|
12
14
|
*
|
|
13
15
|
* @typeParam TSessionData - Type of session data stored in DynamoDB
|
|
14
16
|
* @typeParam _TContract - @internal Accumulates API contract during chaining (do not pass manually)
|
|
17
|
+
* @typeParam _TRateLimitPolicies - @internal Accumulated by enableApiRateLimits (do not pass manually)
|
|
18
|
+
* @typeParam _TGuardName - @internal Accumulated by defineApiGuards (do not pass manually)
|
|
19
|
+
* @typeParam _TIdempotencyEnabled - @internal Flipped by enableApiIdempotency (do not pass manually)
|
|
15
20
|
*
|
|
16
21
|
* @example
|
|
17
22
|
* ```typescript
|
|
@@ -38,6 +43,8 @@ export default class Lambder {
|
|
|
38
43
|
*/
|
|
39
44
|
ApiContract;
|
|
40
45
|
actionList = [];
|
|
46
|
+
apiPolicyEngine = null;
|
|
47
|
+
registeredApiNames = new Set();
|
|
41
48
|
hookList = { "beforeRender": [], "afterRender": [], "fallback": [] };
|
|
42
49
|
createdHooks = [];
|
|
43
50
|
initPromise = null;
|
|
@@ -71,12 +78,13 @@ export default class Lambder {
|
|
|
71
78
|
this.corsConfig = config === true ? {} : (config === false ? null : config);
|
|
72
79
|
return this;
|
|
73
80
|
}
|
|
74
|
-
enableDdbSession({ tableName, tableRegion, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, cookie, partitionKey, sortKey, }) {
|
|
81
|
+
enableDdbSession({ tableName, tableRegion, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, cookie, partitionKey, sortKey, dataRefresh, }) {
|
|
75
82
|
this.lambderSessionManager = new LambderSessionManager({
|
|
76
83
|
tableName, tableRegion,
|
|
77
84
|
partitionKey: partitionKey ?? "pk",
|
|
78
85
|
sortKey: sortKey ?? "sk",
|
|
79
86
|
sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds,
|
|
87
|
+
dataRefresh,
|
|
80
88
|
});
|
|
81
89
|
this.sessionCookieOptions = cookie ?? {};
|
|
82
90
|
return this;
|
|
@@ -154,6 +162,65 @@ export default class Lambder {
|
|
|
154
162
|
}
|
|
155
163
|
return response;
|
|
156
164
|
}
|
|
165
|
+
// ---------------------------------------------------------------------
|
|
166
|
+
// Declarative API policies (rate limits, guards, idempotency)
|
|
167
|
+
// ---------------------------------------------------------------------
|
|
168
|
+
/**
|
|
169
|
+
* Wire declarative per-API rate limiting: your LambderDdbRateLimiter
|
|
170
|
+
* instance plus named policies, each declaring its windows and what one
|
|
171
|
+
* counter tracks (`per`: "ip", "session", or a custom key function).
|
|
172
|
+
* APIs then reference policies by name via the `rateLimit` option; the
|
|
173
|
+
* returned type narrows so only declared names are accepted, and
|
|
174
|
+
* policies keyed per "session" are only referable from addSessionApi.
|
|
175
|
+
* Callable once; call it before the API registrations that use it.
|
|
176
|
+
*/
|
|
177
|
+
enableApiRateLimits(config) {
|
|
178
|
+
this.getOrCreatePolicyEngine().setRateLimits(config);
|
|
179
|
+
return this;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Wire declarative idempotency: your LambderDdbIdempotency instance plus
|
|
183
|
+
* replay defaults. APIs opt in via `idempotency: true | { ttlSeconds }`;
|
|
184
|
+
* the option is a type error until this is called. Requests carrying a
|
|
185
|
+
* client `idempotencyKey` (sent by LambderCaller) claim an
|
|
186
|
+
* identity+api+key scope atomically: concurrent duplicates refuse with
|
|
187
|
+
* 409, replays of a completed request return the stored response, and a
|
|
188
|
+
* crashed original releases its claim. Callable once.
|
|
189
|
+
*/
|
|
190
|
+
enableApiIdempotency(config) {
|
|
191
|
+
this.getOrCreatePolicyEngine().setIdempotency(config);
|
|
192
|
+
return this;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Define named guards that APIs reference (typed) via the `guards`
|
|
196
|
+
* option. Guards run before input validation, in the order the API
|
|
197
|
+
* declares them; a guard refuses by throwing (typically LambderApiError).
|
|
198
|
+
* Callable multiple times so domain modules can contribute their own;
|
|
199
|
+
* names must not collide.
|
|
200
|
+
*/
|
|
201
|
+
defineApiGuards(guards) {
|
|
202
|
+
this.getOrCreatePolicyEngine().addGuards(guards);
|
|
203
|
+
return this;
|
|
204
|
+
}
|
|
205
|
+
getOrCreatePolicyEngine() {
|
|
206
|
+
if (!this.apiPolicyEngine)
|
|
207
|
+
this.apiPolicyEngine = new LambderApiPolicyEngine();
|
|
208
|
+
return this.apiPolicyEngine;
|
|
209
|
+
}
|
|
210
|
+
/** Registration-time checks shared by addApi/addSessionApi. */
|
|
211
|
+
assertApiRegistration(name, mode, options) {
|
|
212
|
+
if (this.registeredApiNames.has(name)) {
|
|
213
|
+
throw new Error(`Lambder: duplicate API name "${name}". Dispatch is first-match, so the second registration would be silently dead code.`);
|
|
214
|
+
}
|
|
215
|
+
this.registeredApiNames.add(name);
|
|
216
|
+
const usesPolicies = options.rateLimit !== undefined || options.guards !== undefined || options.idempotency !== undefined;
|
|
217
|
+
if (!usesPolicies)
|
|
218
|
+
return;
|
|
219
|
+
if (!this.apiPolicyEngine) {
|
|
220
|
+
throw new Error(`Lambder: API "${name}" declares rateLimit/guards/idempotency, but none of enableApiRateLimits()/defineApiGuards()/enableApiIdempotency() was called first.`);
|
|
221
|
+
}
|
|
222
|
+
this.apiPolicyEngine.assertRegistration(name, mode, options);
|
|
223
|
+
}
|
|
157
224
|
addRoute(condition, actionFn) {
|
|
158
225
|
this.actionList.push({
|
|
159
226
|
match: compileRouteMatcher(condition),
|
|
@@ -172,14 +239,21 @@ export default class Lambder {
|
|
|
172
239
|
return this;
|
|
173
240
|
}
|
|
174
241
|
// Plugin system
|
|
242
|
+
// The policy generics are `any` in the plugin signature on purpose: a
|
|
243
|
+
// module may annotate its parameter as the bare Lambder<SessionData> or
|
|
244
|
+
// as the app's narrowed alias, and both must chain. Registration-time
|
|
245
|
+
// assertions still verify every referenced policy/guard name at runtime.
|
|
175
246
|
use(plugin) {
|
|
176
247
|
return plugin(this);
|
|
177
248
|
}
|
|
178
249
|
// Typed API with Zod
|
|
179
250
|
addApi(name, schema, handler) {
|
|
251
|
+
this.assertApiRegistration(name, "public", schema);
|
|
180
252
|
this.actionList.push({
|
|
181
253
|
match: (ctx) => ctx.apiName === name ? {} : false,
|
|
182
254
|
actionFn: async (ctx, resolver) => {
|
|
255
|
+
if (this.apiPolicyEngine)
|
|
256
|
+
await this.apiPolicyEngine.runPreflight(name, ctx, resolver, schema);
|
|
183
257
|
const inputResult = schema.input.safeParse(ctx.apiPayload);
|
|
184
258
|
if (!inputResult.success) {
|
|
185
259
|
if (this.apiInputValidationErrorHandler) {
|
|
@@ -188,17 +262,23 @@ export default class Lambder {
|
|
|
188
262
|
return resolver.json({ error: "Input validation failed", zodError: inputResult.error }, { statusCode: 422 });
|
|
189
263
|
}
|
|
190
264
|
ctx.apiPayload = inputResult.data;
|
|
191
|
-
|
|
265
|
+
const run = async () => await handler(ctx, resolver);
|
|
266
|
+
if (this.apiPolicyEngine && schema.idempotency)
|
|
267
|
+
return await this.apiPolicyEngine.withIdempotency(name, ctx, schema.idempotency, run);
|
|
268
|
+
return await run();
|
|
192
269
|
},
|
|
193
270
|
});
|
|
194
271
|
return this;
|
|
195
272
|
}
|
|
196
273
|
// Typed Session API with Zod
|
|
197
274
|
addSessionApi(name, schema, handler) {
|
|
275
|
+
this.assertApiRegistration(name, "session", schema);
|
|
198
276
|
this.actionList.push({
|
|
199
277
|
match: (ctx) => ctx.apiName === name ? {} : false,
|
|
200
278
|
actionFn: async (ctx, resolver) => {
|
|
201
279
|
await this.requireSession(ctx, resolver);
|
|
280
|
+
if (this.apiPolicyEngine)
|
|
281
|
+
await this.apiPolicyEngine.runPreflight(name, ctx, resolver, schema);
|
|
202
282
|
const inputResult = schema.input.safeParse(ctx.apiPayload);
|
|
203
283
|
if (!inputResult.success) {
|
|
204
284
|
if (this.apiInputValidationErrorHandler) {
|
|
@@ -207,7 +287,10 @@ export default class Lambder {
|
|
|
207
287
|
return resolver.json({ error: "Input validation failed", zodError: inputResult.error }, { statusCode: 422 });
|
|
208
288
|
}
|
|
209
289
|
ctx.apiPayload = inputResult.data;
|
|
210
|
-
|
|
290
|
+
const run = async () => await handler(ctx, resolver);
|
|
291
|
+
if (this.apiPolicyEngine && schema.idempotency)
|
|
292
|
+
return await this.apiPolicyEngine.withIdempotency(name, ctx, schema.idempotency, run);
|
|
293
|
+
return await run();
|
|
211
294
|
}
|
|
212
295
|
});
|
|
213
296
|
return this;
|
|
@@ -267,6 +350,14 @@ export default class Lambder {
|
|
|
267
350
|
});
|
|
268
351
|
}
|
|
269
352
|
;
|
|
353
|
+
/** Map a thrown LambderApiError onto the structured API envelope. */
|
|
354
|
+
apiErrorResponse(err, resolver) {
|
|
355
|
+
return resolver.api(null, {
|
|
356
|
+
...(err.errorMessage !== undefined ? { errorMessage: err.errorMessage } : {}),
|
|
357
|
+
...(err.notAuthorized ? { notAuthorized: true } : {}),
|
|
358
|
+
...(err.sessionExpired ? { sessionExpired: true } : {}),
|
|
359
|
+
}, err.statusCode !== undefined ? { statusCode: err.statusCode } : undefined);
|
|
360
|
+
}
|
|
270
361
|
getHandler() {
|
|
271
362
|
return ((event, context) => Lambder.isHttpEvent(event)
|
|
272
363
|
? this.render(event, context)
|
|
@@ -400,6 +491,11 @@ export default class Lambder {
|
|
|
400
491
|
if (err instanceof LambderResponse) {
|
|
401
492
|
response = err;
|
|
402
493
|
}
|
|
494
|
+
// A thrown LambderApiError on an API call IS a structured refusal
|
|
495
|
+
// (brand-checked, not instanceof, to survive duplicate installs).
|
|
496
|
+
else if (isLambderApiError(err) && ctx._otherInternal.isApiCall) {
|
|
497
|
+
response = this.apiErrorResponse(err, resolver);
|
|
498
|
+
}
|
|
403
499
|
else {
|
|
404
500
|
throw err;
|
|
405
501
|
}
|
|
@@ -416,6 +512,9 @@ export default class Lambder {
|
|
|
416
512
|
if (err instanceof LambderResponse) {
|
|
417
513
|
response = err;
|
|
418
514
|
}
|
|
515
|
+
else if (isLambderApiError(err) && ctx._otherInternal.isApiCall) {
|
|
516
|
+
response = this.apiErrorResponse(err, resolver);
|
|
517
|
+
}
|
|
419
518
|
else {
|
|
420
519
|
throw err;
|
|
421
520
|
}
|
|
@@ -449,6 +548,14 @@ export default class Lambder {
|
|
|
449
548
|
catch { /* fall through */ }
|
|
450
549
|
}
|
|
451
550
|
}
|
|
551
|
+
// Last-resort 500. API calls get the JSON envelope so clients can
|
|
552
|
+
// parse a structured failure; everything else keeps plain text.
|
|
553
|
+
if (ctx?._otherInternal.isApiCall) {
|
|
554
|
+
const apiBody = JSON.stringify({ apiVersion: this.apiVersion, payload: null, errorMessage: "Internal server error." });
|
|
555
|
+
return eventFormat === "v2"
|
|
556
|
+
? { statusCode: 500, headers: { "Content-Type": "application/json; charset=utf-8" }, body: apiBody, isBase64Encoded: false }
|
|
557
|
+
: { statusCode: 500, multiValueHeaders: { "Content-Type": ["application/json; charset=utf-8"] }, body: apiBody, isBase64Encoded: false };
|
|
558
|
+
}
|
|
452
559
|
return eventFormat === "v2"
|
|
453
560
|
? { statusCode: 500, headers: {}, body: "Internal Server Error.", isBase64Encoded: false }
|
|
454
561
|
: { statusCode: 500, multiValueHeaders: {}, body: "Internal Server Error.", isBase64Encoded: false };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { HttpStatusCode } from "./LambderResponse.js";
|
|
2
|
+
export type LambderApiErrorOptions = {
|
|
3
|
+
/**
|
|
4
|
+
* Structured, user-facing failure detail placed on the API envelope's
|
|
5
|
+
* `errorMessage` field. Any shape the app's errorMessageHandler expects
|
|
6
|
+
* (e.g. `{ type: "warning", content: "..." }`). Defaults to the error
|
|
7
|
+
* message string, so a bare `throw new LambderApiError("...")` is still
|
|
8
|
+
* visible to the client.
|
|
9
|
+
*/
|
|
10
|
+
errorMessage?: any;
|
|
11
|
+
/** Sets the envelope's `notAuthorized` flag (routed to the caller's notAuthorizedHandler). */
|
|
12
|
+
notAuthorized?: boolean;
|
|
13
|
+
/** Sets the envelope's `sessionExpired` flag (the caller clears session cookies and calls sessionExpiredHandler). */
|
|
14
|
+
sessionExpired?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* HTTP status of the refusal response. Default 200: the envelope is the
|
|
17
|
+
* semantic channel. Avoid 5xx (LambderCaller treats those as crashes) and
|
|
18
|
+
* 422 (reserved for input validation).
|
|
19
|
+
*/
|
|
20
|
+
statusCode?: HttpStatusCode;
|
|
21
|
+
/** Underlying cause, preserved on the standard Error `cause` property. */
|
|
22
|
+
cause?: unknown;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* A typed refusal: "this request is denied/invalid" as opposed to "the server
|
|
26
|
+
* crashed". Throw it from anywhere in an API call's call stack — handlers,
|
|
27
|
+
* hooks, or nested helpers that have no access to the per-request resolver —
|
|
28
|
+
* and the render pipeline maps it onto the structured API envelope
|
|
29
|
+
* (`res.api(null, { errorMessage, notAuthorized, sessionExpired })`) instead
|
|
30
|
+
* of routing it through setGlobalErrorHandler. Refusals therefore never reach
|
|
31
|
+
* crash logging, and clients receive a parseable response they can surface.
|
|
32
|
+
*
|
|
33
|
+
* Thrown outside an API call (e.g. in a route handler) it behaves like any
|
|
34
|
+
* other error: global error handler, then the default 500.
|
|
35
|
+
*
|
|
36
|
+
* Isomorphic and dependency-free, so shared code (validators, permission
|
|
37
|
+
* checks) may import and throw it from packages used by both server and
|
|
38
|
+
* browser builds; in the browser it is just an Error.
|
|
39
|
+
*/
|
|
40
|
+
export declare class LambderApiError extends Error {
|
|
41
|
+
/**
|
|
42
|
+
* Brand for detection across duplicate lambder installs: when two copies
|
|
43
|
+
* of the package coexist in one bundle, `instanceof LambderApiError` fails
|
|
44
|
+
* across them while this marker does not. The pipeline checks the brand.
|
|
45
|
+
*/
|
|
46
|
+
readonly isLambderApiError = true;
|
|
47
|
+
readonly errorMessage?: any;
|
|
48
|
+
readonly notAuthorized?: boolean;
|
|
49
|
+
readonly sessionExpired?: boolean;
|
|
50
|
+
readonly statusCode?: HttpStatusCode;
|
|
51
|
+
constructor(message: string, options?: LambderApiErrorOptions);
|
|
52
|
+
}
|
|
53
|
+
/** Brand-based type guard (see LambderApiError.isLambderApiError). */
|
|
54
|
+
export declare const isLambderApiError: (err: unknown) => err is LambderApiError;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A typed refusal: "this request is denied/invalid" as opposed to "the server
|
|
3
|
+
* crashed". Throw it from anywhere in an API call's call stack — handlers,
|
|
4
|
+
* hooks, or nested helpers that have no access to the per-request resolver —
|
|
5
|
+
* and the render pipeline maps it onto the structured API envelope
|
|
6
|
+
* (`res.api(null, { errorMessage, notAuthorized, sessionExpired })`) instead
|
|
7
|
+
* of routing it through setGlobalErrorHandler. Refusals therefore never reach
|
|
8
|
+
* crash logging, and clients receive a parseable response they can surface.
|
|
9
|
+
*
|
|
10
|
+
* Thrown outside an API call (e.g. in a route handler) it behaves like any
|
|
11
|
+
* other error: global error handler, then the default 500.
|
|
12
|
+
*
|
|
13
|
+
* Isomorphic and dependency-free, so shared code (validators, permission
|
|
14
|
+
* checks) may import and throw it from packages used by both server and
|
|
15
|
+
* browser builds; in the browser it is just an Error.
|
|
16
|
+
*/
|
|
17
|
+
export class LambderApiError extends Error {
|
|
18
|
+
/**
|
|
19
|
+
* Brand for detection across duplicate lambder installs: when two copies
|
|
20
|
+
* of the package coexist in one bundle, `instanceof LambderApiError` fails
|
|
21
|
+
* across them while this marker does not. The pipeline checks the brand.
|
|
22
|
+
*/
|
|
23
|
+
isLambderApiError = true;
|
|
24
|
+
errorMessage;
|
|
25
|
+
notAuthorized;
|
|
26
|
+
sessionExpired;
|
|
27
|
+
statusCode;
|
|
28
|
+
constructor(message, options = {}) {
|
|
29
|
+
super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
|
|
30
|
+
this.name = "LambderApiError";
|
|
31
|
+
this.errorMessage = options.errorMessage ?? message;
|
|
32
|
+
this.notAuthorized = options.notAuthorized;
|
|
33
|
+
this.sessionExpired = options.sessionExpired;
|
|
34
|
+
this.statusCode = options.statusCode;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Brand-based type guard (see LambderApiError.isLambderApiError). */
|
|
38
|
+
export const isLambderApiError = (err) => err instanceof Error && err.isLambderApiError === true;
|