lambder 4.0.1 → 4.2.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 +99 -55
- package/dist/client/LambderCaller.d.ts +2 -0
- package/dist/client/LambderCaller.js +8 -4
- package/dist/core/Lambder.d.ts +100 -86
- package/dist/core/Lambder.js +100 -87
- package/dist/core/LambderCors.d.ts +6 -0
- package/dist/core/LambderCors.js +5 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +1 -0
- package/dist/policies/LambderApiGuards.d.ts +17 -6
- package/dist/policies/LambderApiGuards.js +13 -9
- package/dist/policies/LambderApiIdempotency.d.ts +1 -1
- package/dist/policies/LambderApiIdempotency.js +8 -4
- package/dist/policies/LambderApiPolicies.d.ts +6 -4
- package/dist/policies/LambderApiPolicies.js +9 -4
- package/dist/policies/LambderApiRateLimits.d.ts +63 -20
- package/dist/policies/LambderApiRateLimits.js +54 -15
- package/dist/session/LambderSessionController.d.ts +1 -1
- package/dist/session/LambderSessionController.js +1 -1
- package/dist/session/LambderSessionManager.js +1 -1
- package/dist/shared/LambderApiError.d.ts +5 -0
- package/dist/shared/LambderApiError.js +3 -0
- package/dist/stores/LambderDdbRateLimiter.d.ts +45 -14
- package/dist/stores/LambderDdbRateLimiter.js +19 -10
- package/package.json +1 -1
package/dist/core/Lambder.js
CHANGED
|
@@ -10,19 +10,22 @@ import { isLambderApiError } from "../shared/LambderApiError.js";
|
|
|
10
10
|
import { LambderApiPolicyEngine } from "../policies/LambderApiPolicies.js";
|
|
11
11
|
import { createContext, isV2HttpEvent } from "./LambderContext.js";
|
|
12
12
|
/**
|
|
13
|
-
* Main Lambder class for building type-safe serverless APIs
|
|
13
|
+
* Main Lambder class for building type-safe serverless APIs. Create
|
|
14
|
+
* instances with initLambder<SessionData>().create({...}) (see below): the
|
|
15
|
+
* whole configuration, including the typed policy layer, is given at
|
|
16
|
+
* construction, and only registration (routes, apis, hooks, use) chains.
|
|
14
17
|
*
|
|
15
18
|
* @typeParam TSessionData - Type of session data stored in DynamoDB
|
|
16
19
|
* @typeParam _TContract - @internal Accumulates API contract during chaining (do not pass manually)
|
|
17
|
-
* @typeParam _TRateLimitPolicies - @internal
|
|
18
|
-
* @typeParam _TGuards - @internal Guard
|
|
19
|
-
* @typeParam _TIdempotencyEnabled - @internal
|
|
20
|
+
* @typeParam _TRateLimitPolicies - @internal Inferred from create()'s rateLimits.policies (do not pass manually)
|
|
21
|
+
* @typeParam _TGuards - @internal Guard metadata map inferred from create()'s guards (do not pass manually)
|
|
22
|
+
* @typeParam _TIdempotencyEnabled - @internal True when create() received idempotency (do not pass manually)
|
|
20
23
|
*
|
|
21
24
|
* @example
|
|
22
25
|
* ```typescript
|
|
23
26
|
* interface SessionData { userId: string; role: string; }
|
|
24
27
|
*
|
|
25
|
-
* const lambder =
|
|
28
|
+
* const lambder = initLambder<SessionData>().create({ apiPath: '/api' })
|
|
26
29
|
* .addApi('getUser', { input: z.object({...}), output: z.object({...}) }, handler)
|
|
27
30
|
* .addApi('createUser', { input: z.object({...}), output: z.object({...}) }, handler);
|
|
28
31
|
* ```
|
|
@@ -73,26 +76,33 @@ export default class Lambder {
|
|
|
73
76
|
etag: options.etag ?? DEFAULT_FINALIZE_OPTIONS.etag,
|
|
74
77
|
maxResponseBytes: options.maxResponseBytes ?? DEFAULT_FINALIZE_OPTIONS.maxResponseBytes,
|
|
75
78
|
};
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
79
|
+
if (options.cors !== undefined && options.cors !== false) {
|
|
80
|
+
this.corsConfig = options.cors === true ? {} : options.cors;
|
|
81
|
+
}
|
|
82
|
+
if (options.session) {
|
|
83
|
+
const session = options.session;
|
|
84
|
+
this.lambderSessionManager = new LambderSessionManager({
|
|
85
|
+
tableName: session.tableName,
|
|
86
|
+
tableRegion: session.tableRegion,
|
|
87
|
+
partitionKey: session.partitionKey ?? "pk",
|
|
88
|
+
sortKey: session.sortKey ?? "sk",
|
|
89
|
+
sessionSalt: session.sessionSalt,
|
|
90
|
+
enableSlidingExpiration: session.enableSlidingExpiration,
|
|
91
|
+
slidingWriteIntervalSeconds: session.slidingWriteIntervalSeconds,
|
|
92
|
+
dataRefresh: session.dataRefresh,
|
|
93
|
+
});
|
|
94
|
+
this.sessionCookieOptions = session.cookie ?? {};
|
|
95
|
+
if (session.tokenCookieKey)
|
|
96
|
+
this.sessionTokenCookieKey = session.tokenCookieKey;
|
|
97
|
+
if (session.csrfCookieKey)
|
|
98
|
+
this.sessionCsrfCookieKey = session.csrfCookieKey;
|
|
99
|
+
}
|
|
100
|
+
if (options.rateLimits)
|
|
101
|
+
this.getOrCreatePolicyEngine().setRateLimits(options.rateLimits);
|
|
102
|
+
if (options.guards)
|
|
103
|
+
this.getOrCreatePolicyEngine().addGuards(options.guards);
|
|
104
|
+
if (options.idempotency)
|
|
105
|
+
this.getOrCreatePolicyEngine().setIdempotency(options.idempotency);
|
|
96
106
|
}
|
|
97
107
|
setRouteFallbackHandler(routeFallbackHandler) {
|
|
98
108
|
this.routeFallbackHandler = routeFallbackHandler;
|
|
@@ -162,56 +172,24 @@ export default class Lambder {
|
|
|
162
172
|
}
|
|
163
173
|
return response;
|
|
164
174
|
}
|
|
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. Each guard is built with lambderGuard(): its input mode
|
|
197
|
-
* (apiInput slice of the API's own payload, a separate client-sent
|
|
198
|
-
* guardInput, or none), an optional `session: true` requirement, an
|
|
199
|
-
* optional parameter APIs pass in their declaration (`guards: { name:
|
|
200
|
-
* param }`), and an optional return value that lands typed on the
|
|
201
|
-
* handler's ctx.guardData[name]. Guards run before input validation, in
|
|
202
|
-
* the order the API declares them; a handler refuses by throwing
|
|
203
|
-
* (typically refuse()). Callable multiple times so domain modules can
|
|
204
|
-
* contribute their own; names must not collide.
|
|
205
|
-
*/
|
|
206
|
-
defineApiGuards(guards) {
|
|
207
|
-
this.getOrCreatePolicyEngine().addGuards(guards);
|
|
208
|
-
return this;
|
|
209
|
-
}
|
|
210
175
|
getOrCreatePolicyEngine() {
|
|
176
|
+
// Late-bound: the handler may be set after creation, so the engine
|
|
177
|
+
// asks at request time rather than capturing it here.
|
|
211
178
|
if (!this.apiPolicyEngine)
|
|
212
|
-
this.apiPolicyEngine = new LambderApiPolicyEngine();
|
|
179
|
+
this.apiPolicyEngine = new LambderApiPolicyEngine((ctx, resolver, zodError) => this.inputValidationRefusal(ctx, resolver, zodError));
|
|
213
180
|
return this.apiPolicyEngine;
|
|
214
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* The response for a rejected input: the app's
|
|
184
|
+
* setApiInputValidationErrorHandler when set, otherwise the standard 422
|
|
185
|
+
* body. The API's own schema and every preflight slice (guard inputs,
|
|
186
|
+
* rate-limit keys) answer through here, so one failure has one shape.
|
|
187
|
+
*/
|
|
188
|
+
async inputValidationRefusal(ctx, resolver, zodError) {
|
|
189
|
+
if (this.apiInputValidationErrorHandler)
|
|
190
|
+
return await this.apiInputValidationErrorHandler(ctx, resolver, zodError);
|
|
191
|
+
return resolver.json({ error: "Input validation failed", zodError }, { statusCode: 422 });
|
|
192
|
+
}
|
|
215
193
|
/** Registration-time checks shared by addApi/addSessionApi. */
|
|
216
194
|
assertApiRegistration(name, mode, options) {
|
|
217
195
|
if (this.registeredApiNames.has(name)) {
|
|
@@ -222,7 +200,7 @@ export default class Lambder {
|
|
|
222
200
|
if (!usesPolicies)
|
|
223
201
|
return;
|
|
224
202
|
if (!this.apiPolicyEngine) {
|
|
225
|
-
throw new Error(`Lambder: API "${name}" declares rateLimit/guards/idempotency, but none of
|
|
203
|
+
throw new Error(`Lambder: API "${name}" declares rateLimit/guards/idempotency, but none of rateLimits/guards/idempotency was configured at creation.`);
|
|
226
204
|
}
|
|
227
205
|
this.apiPolicyEngine.assertRegistration(name, mode, options);
|
|
228
206
|
}
|
|
@@ -268,12 +246,8 @@ export default class Lambder {
|
|
|
268
246
|
if (this.apiPolicyEngine)
|
|
269
247
|
await this.apiPolicyEngine.runPreflight(name, ctx, resolver, schema);
|
|
270
248
|
const inputResult = schema.input.safeParse(ctx.apiPayload);
|
|
271
|
-
if (!inputResult.success)
|
|
272
|
-
|
|
273
|
-
return await this.apiInputValidationErrorHandler(ctx, resolver, inputResult.error);
|
|
274
|
-
}
|
|
275
|
-
return resolver.json({ error: "Input validation failed", zodError: inputResult.error }, { statusCode: 422 });
|
|
276
|
-
}
|
|
249
|
+
if (!inputResult.success)
|
|
250
|
+
return await this.inputValidationRefusal(ctx, resolver, inputResult.error);
|
|
277
251
|
ctx.apiPayload = inputResult.data;
|
|
278
252
|
const run = async () => await handler(ctx, resolver);
|
|
279
253
|
if (this.apiPolicyEngine && schema.idempotency)
|
|
@@ -300,12 +274,8 @@ export default class Lambder {
|
|
|
300
274
|
if (this.apiPolicyEngine)
|
|
301
275
|
await this.apiPolicyEngine.runPreflight(name, ctx, resolver, schema);
|
|
302
276
|
const inputResult = schema.input.safeParse(ctx.apiPayload);
|
|
303
|
-
if (!inputResult.success)
|
|
304
|
-
|
|
305
|
-
return await this.apiInputValidationErrorHandler(ctx, resolver, inputResult.error);
|
|
306
|
-
}
|
|
307
|
-
return resolver.json({ error: "Input validation failed", zodError: inputResult.error }, { statusCode: 422 });
|
|
308
|
-
}
|
|
277
|
+
if (!inputResult.success)
|
|
278
|
+
return await this.inputValidationRefusal(ctx, resolver, inputResult.error);
|
|
309
279
|
ctx.apiPayload = inputResult.data;
|
|
310
280
|
const run = async () => await handler(ctx, resolver);
|
|
311
281
|
if (this.apiPolicyEngine && schema.idempotency)
|
|
@@ -345,7 +315,7 @@ export default class Lambder {
|
|
|
345
315
|
}
|
|
346
316
|
getSessionController(ctx) {
|
|
347
317
|
if (!this.lambderSessionManager)
|
|
348
|
-
throw new Error("Session is not enabled.
|
|
318
|
+
throw new Error("Session is not enabled. Configure the session option at creation.");
|
|
349
319
|
return new LambderSessionController({
|
|
350
320
|
lambderSessionManager: this.lambderSessionManager,
|
|
351
321
|
sessionTokenCookieKey: this.sessionTokenCookieKey,
|
|
@@ -376,7 +346,10 @@ export default class Lambder {
|
|
|
376
346
|
...(err.errorMessage !== undefined ? { errorMessage: err.errorMessage } : {}),
|
|
377
347
|
...(err.notAuthorized ? { notAuthorized: true } : {}),
|
|
378
348
|
...(err.sessionExpired ? { sessionExpired: true } : {}),
|
|
379
|
-
},
|
|
349
|
+
}, {
|
|
350
|
+
...(err.statusCode !== undefined ? { statusCode: err.statusCode } : {}),
|
|
351
|
+
...(err.headers ? { headers: err.headers } : {}),
|
|
352
|
+
});
|
|
380
353
|
}
|
|
381
354
|
getHandler() {
|
|
382
355
|
return ((event, context) => Lambder.isHttpEvent(event)
|
|
@@ -450,7 +423,7 @@ export default class Lambder {
|
|
|
450
423
|
if (isAPI) {
|
|
451
424
|
if (this.apiFallbackHandler)
|
|
452
425
|
return await this.apiFallbackHandler(ctx, resolver);
|
|
453
|
-
return resolver.api(null, { errorMessage: "API not found." });
|
|
426
|
+
return resolver.api(null, { errorMessage: { type: "warning", content: "API not found." } });
|
|
454
427
|
}
|
|
455
428
|
if (this.publicFilesHandler) {
|
|
456
429
|
const fileResponse = await this.publicFilesHandler.handle(ctx);
|
|
@@ -582,6 +555,46 @@ export default class Lambder {
|
|
|
582
555
|
}
|
|
583
556
|
}
|
|
584
557
|
}
|
|
558
|
+
/**
|
|
559
|
+
* The canonical way to create an instance: fix the session data type first,
|
|
560
|
+
* then create with the full configuration in one declaration; the policy,
|
|
561
|
+
* guard, and idempotency types are INFERRED from the options, so the
|
|
562
|
+
* instance is born fully typed and `typeof lambderApp` is the annotation
|
|
563
|
+
* type for api modules. No enable/define chain exists, so there are no
|
|
564
|
+
* ordering rules and nothing can be half-configured.
|
|
565
|
+
*
|
|
566
|
+
* ```typescript
|
|
567
|
+
* // app.ts (imports no api modules, so modules can import the type back)
|
|
568
|
+
* export const lambderApp = initLambder<SessionData>().create({
|
|
569
|
+
* apiPath: "/api",
|
|
570
|
+
* session: { tableName: "app-session", tableRegion: "us-east-1", sessionSalt: "..." },
|
|
571
|
+
* rateLimits: { limiter, policies },
|
|
572
|
+
* guards,
|
|
573
|
+
* idempotency: { store },
|
|
574
|
+
* });
|
|
575
|
+
* export type AppLambder = typeof lambderApp;
|
|
576
|
+
*
|
|
577
|
+
* // orders.ts
|
|
578
|
+
* export const orderApi = (lambder: AppLambder) => lambder.addSessionApi(...);
|
|
579
|
+
*
|
|
580
|
+
* // index.ts: registration only
|
|
581
|
+
* const lambder = lambderApp.addHook(...).use(orderApi)...;
|
|
582
|
+
* export const handler = lambder.getHandler();
|
|
583
|
+
* ```
|
|
584
|
+
*
|
|
585
|
+
* Why curried (`initLambder<S>().create(...)` rather than
|
|
586
|
+
* `new Lambder<S>(...)`): TypeScript type arguments are all-or-nothing per
|
|
587
|
+
* call, so explicitly passing the session data type to the constructor
|
|
588
|
+
* would silently WIDEN the inferred policy and guard types to their {}
|
|
589
|
+
* defaults. Fixing the session type in the first call lets the second call
|
|
590
|
+
* infer everything else from the options. `new Lambder(options)` remains
|
|
591
|
+
* for untyped or session-data-free instances.
|
|
592
|
+
*/
|
|
593
|
+
export const initLambder = () => ({
|
|
594
|
+
create(options) {
|
|
595
|
+
return new Lambder(options);
|
|
596
|
+
},
|
|
597
|
+
});
|
|
585
598
|
/** Rebuild the query string from the API Gateway event for redirects. */
|
|
586
599
|
const buildQueryString = (ctx) => {
|
|
587
600
|
if (isV2HttpEvent(ctx.event)) {
|
|
@@ -6,6 +6,12 @@ export type LambderCorsConfig = {
|
|
|
6
6
|
credentials?: boolean;
|
|
7
7
|
methods?: string[];
|
|
8
8
|
allowHeaders?: string[];
|
|
9
|
+
/**
|
|
10
|
+
* Response headers a cross-origin browser caller may read. Default:
|
|
11
|
+
* ["Retry-After"], so rate-limit refusals stay readable (it is not on the
|
|
12
|
+
* CORS safelist, and a hidden header reads as null, not as an error).
|
|
13
|
+
*/
|
|
14
|
+
exposeHeaders?: string[];
|
|
9
15
|
maxAge?: number;
|
|
10
16
|
};
|
|
11
17
|
/** Mutate the response with the CORS headers the config allows for this request. */
|
package/dist/core/LambderCors.js
CHANGED
|
@@ -27,4 +27,9 @@ export const applyCorsHeaders = (config, ctx, response, isPreflight) => {
|
|
|
27
27
|
if (config.maxAge !== undefined)
|
|
28
28
|
response.setHeader("Access-Control-Max-Age", String(config.maxAge));
|
|
29
29
|
}
|
|
30
|
+
else {
|
|
31
|
+
const exposeHeaders = config.exposeHeaders ?? ["Retry-After"];
|
|
32
|
+
if (exposeHeaders.length)
|
|
33
|
+
response.setHeader("Access-Control-Expose-Headers", exposeHeaders.join(", "));
|
|
34
|
+
}
|
|
30
35
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import Lambder from './core/Lambder.js';
|
|
2
2
|
export default Lambder;
|
|
3
|
+
export { initLambder } from './core/Lambder.js';
|
|
3
4
|
export { default as LambderCaller } from "./client/LambderCaller.js";
|
|
4
5
|
export type { LambderApiOutcome, LambderApiFailureReason, LambderCallOptions, LambderIdempotencyKeyScope } from "./client/LambderCaller.js";
|
|
5
6
|
export { LambderApiError, isLambderApiError, refuse } from "./shared/LambderApiError.js";
|
|
@@ -13,7 +14,7 @@ export { html, xml, raw, jsonScript, escapeHtml, renderHtmlValue, LambderSafeHtm
|
|
|
13
14
|
export { LambderTemplatingEngine } from "./core/LambderTemplatingEngine.js";
|
|
14
15
|
export type { LambderTemplateData, LambderTemplatingEngineOptions } from "./core/LambderTemplatingEngine.js";
|
|
15
16
|
export type { LambderResponseOptions, LambderRawResponseInit, } from "./core/LambderResponseBuilder.js";
|
|
16
|
-
export type { LambderRouteMatcher, LambderCorsConfig,
|
|
17
|
+
export type { LambderRouteMatcher, LambderCorsConfig, LambderCreateOptions, LambderSessionOptions, ConditionFunction, RouteCondition, PathParamsOf, LambderActionTools, LambderHandler, LambderIndexHtmlOptions, } from "./core/Lambder.js";
|
|
17
18
|
export { LambderPublicFilesHandler } from "./core/LambderPublicFiles.js";
|
|
18
19
|
export type { LambderPublicFilesOptions } from "./core/LambderPublicFiles.js";
|
|
19
20
|
export type { LambderSessionCookieOptions } from "./session/LambderSessionController.js";
|
|
@@ -22,13 +23,13 @@ export { LambderSessionDataRefreshError, LambderSessionReadError } from "./sessi
|
|
|
22
23
|
export { LambderDdbCache } from "./stores/LambderDdbCache.js";
|
|
23
24
|
export type { LambderDdbCacheOptions, LambderDdbCacheSetOptions, LambderDdbCacheGetOrSetOptions, } from "./stores/LambderDdbCache.js";
|
|
24
25
|
export { LambderDdbRateLimiter } from "./stores/LambderDdbRateLimiter.js";
|
|
25
|
-
export type { LambderDdbRateLimiterOptions, LambderRateLimitPolicy,
|
|
26
|
+
export type { LambderDdbRateLimiterOptions, LambderRateLimitWindow, LambderRateLimitPolicy, LambderRateLimitExceeded, LambderRateLimitResult, } from "./stores/LambderDdbRateLimiter.js";
|
|
26
27
|
export { LambderDdbIdempotency } from "./stores/LambderDdbIdempotency.js";
|
|
27
28
|
export type { LambderDdbIdempotencyOptions, LambderIdempotencyBeginResult, LambderIdempotencyDoneRecord, } from "./stores/LambderDdbIdempotency.js";
|
|
28
29
|
export { lambderGuard } from "./policies/LambderApiGuards.js";
|
|
29
30
|
export type { LambderApiGuard, LambderGuardMeta, LambderGuardMetaMap, LambderAllowedGuardNames, LambderParamlessGuardNames, LambderGuardsOption, LambderGuardsOptionValue, LambderGuardDataOf, LambderGuardInputsOf, } from "./policies/LambderApiGuards.js";
|
|
30
31
|
export { lambderRateLimitKey } from "./policies/LambderApiRateLimits.js";
|
|
31
|
-
export type { LambderRateLimitKeyFn, LambderRateLimitPer, LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig, LambderAllowedPolicyNames, } from "./policies/LambderApiRateLimits.js";
|
|
32
|
+
export type { LambderRateLimitKeyFn, LambderRateLimitPer, LambderRateLimitBudget, LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig, LambderAllowedPolicyNames, LambderRateLimitOverride, LambderRateLimitOption, LambderRateLimitOptionValue, } from "./policies/LambderApiRateLimits.js";
|
|
32
33
|
export type { LambderApiIdempotencyConfig } from "./policies/LambderApiIdempotency.js";
|
|
33
34
|
export { createLambderI18n } from "./shared/LambderI18n.js";
|
|
34
35
|
export type { LambderLanguageMeta, LambderI18nConfig, LambderI18nInstance, LambderI18nTranslator, LambderI18nExtractParams, LambderI18nCodes, LambderI18nKeys, LambderI18nTranslatorFor, } from "./shared/LambderI18n.js";
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import Lambder from './core/Lambder.js';
|
|
2
2
|
export default Lambder;
|
|
3
|
+
export { initLambder } from './core/Lambder.js';
|
|
3
4
|
export { default as LambderCaller } from "./client/LambderCaller.js";
|
|
4
5
|
// Typed API refusals (isomorphic: shared code may throw them from anywhere)
|
|
5
6
|
export { LambderApiError, isLambderApiError, refuse } from "./shared/LambderApiError.js";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { z } from "zod";
|
|
2
2
|
import type { LambderRenderContext, LambderSessionRenderContext } from "../core/LambderContext.js";
|
|
3
3
|
import type LambderResolver from "../core/LambderResolver.js";
|
|
4
|
+
import type { LambderResponse } from "../core/LambderResponse.js";
|
|
4
5
|
/**
|
|
5
6
|
* A named guard, run before the API's own input validation. Three input
|
|
6
7
|
* modes:
|
|
@@ -30,9 +31,10 @@ import type LambderResolver from "../core/LambderResolver.js";
|
|
|
30
31
|
* the API handler's context as `ctx.guardData[guardName]`, fully typed.
|
|
31
32
|
* Guards that return nothing never appear in guardData.
|
|
32
33
|
*
|
|
33
|
-
* A validation failure answers the
|
|
34
|
-
*
|
|
35
|
-
*
|
|
34
|
+
* A validation failure answers like the API's own input validation (the
|
|
35
|
+
* app's setApiInputValidationErrorHandler when set, else the standard 422),
|
|
36
|
+
* and the handler refuses by throwing (typically refuse()/LambderApiError).
|
|
37
|
+
* Build with lambderGuard() so the handler's payload/ctx/param types line up.
|
|
36
38
|
*/
|
|
37
39
|
export type LambderApiGuard<TInput extends z.ZodTypeAny = z.ZodTypeAny, TParam = any, TOutput = any> = {
|
|
38
40
|
apiInput: TInput;
|
|
@@ -198,20 +200,29 @@ type GuardInputsEntries<TGuards, TOpt> = {
|
|
|
198
200
|
export type LambderGuardInputsOf<TGuards, TOpt> = keyof GuardInputsEntries<TGuards, TOpt> extends never ? never : GuardInputsEntries<TGuards, TOpt>;
|
|
199
201
|
/** The guards option's runtime shape: a name, ordered names, or a name-to-param map. */
|
|
200
202
|
export type LambderGuardsOptionValue = string | readonly string[] | Readonly<Record<string, unknown>>;
|
|
203
|
+
/**
|
|
204
|
+
* How a rejected input answers. Lambder binds this to its own decision
|
|
205
|
+
* (setApiInputValidationErrorHandler when set, else the standard 422 body),
|
|
206
|
+
* so the API's schema and every preflight slice refuse with one shape.
|
|
207
|
+
*/
|
|
208
|
+
export type LambderInputValidationRefusal = (ctx: LambderRenderContext, resolver: LambderResolver, zodError: z.ZodError) => Promise<LambderResponse>;
|
|
201
209
|
/**
|
|
202
210
|
* Validate a preflight input slice (an apiInput slice of the raw payload, or
|
|
203
211
|
* a guardInput value from the raw guardInputs map). Runs before the API's
|
|
204
|
-
* own validation;
|
|
205
|
-
* validation. Shared with the rate-limit
|
|
212
|
+
* own validation; a failure throws the response `onInvalid` decides, the
|
|
213
|
+
* same one regular input validation answers. Shared with the rate-limit
|
|
214
|
+
* engine's apiInput-keyed policies.
|
|
206
215
|
*/
|
|
207
|
-
export declare const parsePreflightSlice: (input: z.ZodTypeAny, value: unknown, resolver: LambderResolver) => unknown
|
|
216
|
+
export declare const parsePreflightSlice: (input: z.ZodTypeAny, value: unknown, ctx: LambderRenderContext, resolver: LambderResolver, onInvalid: LambderInputValidationRefusal) => Promise<unknown>;
|
|
208
217
|
/**
|
|
209
218
|
* Runtime side of the guards subsystem: holds the defined guards, asserts
|
|
210
219
|
* API registrations against them at startup, and executes an API's declared
|
|
211
220
|
* guards during preflight. Composed into LambderApiPolicyEngine.
|
|
212
221
|
*/
|
|
213
222
|
export declare class LambderApiGuardsEngine {
|
|
223
|
+
private readonly onInvalidInput;
|
|
214
224
|
private guards;
|
|
225
|
+
constructor(onInvalidInput: LambderInputValidationRefusal);
|
|
215
226
|
addGuards(guards: Record<string, LambderApiGuard<any, any, any>>): void;
|
|
216
227
|
/** Startup validation of one API registration's guards option. */
|
|
217
228
|
assertRegistration(apiName: string, mode: "public" | "session", guardsOption?: LambderGuardsOptionValue): void;
|
|
@@ -14,14 +14,14 @@ const toGuardEntries = (value) => {
|
|
|
14
14
|
/**
|
|
15
15
|
* Validate a preflight input slice (an apiInput slice of the raw payload, or
|
|
16
16
|
* a guardInput value from the raw guardInputs map). Runs before the API's
|
|
17
|
-
* own validation;
|
|
18
|
-
* validation. Shared with the rate-limit
|
|
17
|
+
* own validation; a failure throws the response `onInvalid` decides, the
|
|
18
|
+
* same one regular input validation answers. Shared with the rate-limit
|
|
19
|
+
* engine's apiInput-keyed policies.
|
|
19
20
|
*/
|
|
20
|
-
export const parsePreflightSlice = (input, value, resolver) => {
|
|
21
|
+
export const parsePreflightSlice = async (input, value, ctx, resolver, onInvalid) => {
|
|
21
22
|
const parsed = input.safeParse(value);
|
|
22
|
-
if (!parsed.success)
|
|
23
|
-
throw
|
|
24
|
-
}
|
|
23
|
+
if (!parsed.success)
|
|
24
|
+
throw await onInvalid(ctx, resolver, parsed.error);
|
|
25
25
|
return parsed.data;
|
|
26
26
|
};
|
|
27
27
|
/**
|
|
@@ -30,7 +30,11 @@ export const parsePreflightSlice = (input, value, resolver) => {
|
|
|
30
30
|
* guards during preflight. Composed into LambderApiPolicyEngine.
|
|
31
31
|
*/
|
|
32
32
|
export class LambderApiGuardsEngine {
|
|
33
|
+
onInvalidInput;
|
|
33
34
|
guards = {};
|
|
35
|
+
constructor(onInvalidInput) {
|
|
36
|
+
this.onInvalidInput = onInvalidInput;
|
|
37
|
+
}
|
|
34
38
|
addGuards(guards) {
|
|
35
39
|
for (const [name, guardDef] of Object.entries(guards)) {
|
|
36
40
|
if (this.guards[name])
|
|
@@ -47,7 +51,7 @@ export class LambderApiGuardsEngine {
|
|
|
47
51
|
for (const { name } of toGuardEntries(guardsOption)) {
|
|
48
52
|
const guardDef = this.guards[name];
|
|
49
53
|
if (!guardDef) {
|
|
50
|
-
throw new Error(`Lambder: API "${apiName}" references unknown guard "${name}".
|
|
54
|
+
throw new Error(`Lambder: API "${apiName}" references unknown guard "${name}". Declare it in the guards option at creation.`);
|
|
51
55
|
}
|
|
52
56
|
if (guardDef.session && mode !== "session") {
|
|
53
57
|
throw new Error(`Lambder: API "${apiName}" uses guard "${name}" (session: true), which requires addSessionApi.`);
|
|
@@ -63,10 +67,10 @@ export class LambderApiGuardsEngine {
|
|
|
63
67
|
const post = ctx.post;
|
|
64
68
|
let payload;
|
|
65
69
|
if (guardDef.apiInput) {
|
|
66
|
-
payload = parsePreflightSlice(guardDef.apiInput, post?.payload, resolver);
|
|
70
|
+
payload = await parsePreflightSlice(guardDef.apiInput, post?.payload, ctx, resolver, this.onInvalidInput);
|
|
67
71
|
}
|
|
68
72
|
else if (guardDef.guardInput) {
|
|
69
|
-
payload = parsePreflightSlice(guardDef.guardInput, post?.guardInputs?.[name], resolver);
|
|
73
|
+
payload = await parsePreflightSlice(guardDef.guardInput, post?.guardInputs?.[name], ctx, resolver, this.onInvalidInput);
|
|
70
74
|
}
|
|
71
75
|
// A guard's return value becomes the handler's typed
|
|
72
76
|
// ctx.guardData[name]; check-only guards return undefined.
|
|
@@ -19,7 +19,7 @@ export declare class LambderApiIdempotencyEngine {
|
|
|
19
19
|
private defaultTtlSeconds;
|
|
20
20
|
private failOpen;
|
|
21
21
|
configure(config: LambderApiIdempotencyConfig): void;
|
|
22
|
-
/** True once
|
|
22
|
+
/** True once the idempotency option was configured; registration asserts check it. */
|
|
23
23
|
get isConfigured(): boolean;
|
|
24
24
|
/**
|
|
25
25
|
* The request's idempotencyKey: null when absent, the key when valid, a
|
|
@@ -20,12 +20,12 @@ export class LambderApiIdempotencyEngine {
|
|
|
20
20
|
failOpen = true;
|
|
21
21
|
configure(config) {
|
|
22
22
|
if (this.store)
|
|
23
|
-
throw new Error("Lambder:
|
|
23
|
+
throw new Error("Lambder: idempotency was already configured.");
|
|
24
24
|
this.store = config.store;
|
|
25
25
|
this.defaultTtlSeconds = config.defaultTtlSeconds ?? 24 * 3600;
|
|
26
26
|
this.failOpen = config.failOpen ?? true;
|
|
27
27
|
}
|
|
28
|
-
/** True once
|
|
28
|
+
/** True once the idempotency option was configured; registration asserts check it. */
|
|
29
29
|
get isConfigured() { return this.store !== null; }
|
|
30
30
|
/**
|
|
31
31
|
* The request's idempotencyKey: null when absent, the key when valid, a
|
|
@@ -37,7 +37,11 @@ export class LambderApiIdempotencyEngine {
|
|
|
37
37
|
if (rawKey === undefined || rawKey === null)
|
|
38
38
|
return null;
|
|
39
39
|
if (typeof rawKey !== "string" || rawKey.length < IDEMPOTENCY_MIN_KEY_LENGTH || rawKey.length > IDEMPOTENCY_MAX_KEY_LENGTH) {
|
|
40
|
-
|
|
40
|
+
const content = `Invalid idempotency key: must be a string of ${IDEMPOTENCY_MIN_KEY_LENGTH}-${IDEMPOTENCY_MAX_KEY_LENGTH} characters.`;
|
|
41
|
+
throw new LambderApiError(content, {
|
|
42
|
+
statusCode: 400,
|
|
43
|
+
errorMessage: { type: "error", content },
|
|
44
|
+
});
|
|
41
45
|
}
|
|
42
46
|
return rawKey;
|
|
43
47
|
}
|
|
@@ -112,7 +116,7 @@ export class LambderApiIdempotencyEngine {
|
|
|
112
116
|
if (begun.state === "pending") {
|
|
113
117
|
throw new LambderApiError(`Duplicate request for "${apiName}": the original is still processing.`, {
|
|
114
118
|
statusCode: 409,
|
|
115
|
-
errorMessage: "This request is already being processed.",
|
|
119
|
+
errorMessage: { type: "warning", content: "This request is already being processed." },
|
|
116
120
|
});
|
|
117
121
|
}
|
|
118
122
|
if (begun.state === "done") {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { LambderRenderContext } from "../core/LambderContext.js";
|
|
2
2
|
import type LambderResolver from "../core/LambderResolver.js";
|
|
3
3
|
import type { LambderResponse } from "../core/LambderResponse.js";
|
|
4
|
-
import { type LambderApiGuard, type LambderGuardsOptionValue } from "./LambderApiGuards.js";
|
|
5
|
-
import { type LambderApiRateLimitPolicyConfig, type LambderApiRateLimitsConfig } from "./LambderApiRateLimits.js";
|
|
4
|
+
import { type LambderApiGuard, type LambderGuardsOptionValue, type LambderInputValidationRefusal } from "./LambderApiGuards.js";
|
|
5
|
+
import { type LambderApiRateLimitPolicyConfig, type LambderApiRateLimitsConfig, type LambderRateLimitOptionValue } from "./LambderApiRateLimits.js";
|
|
6
6
|
import { type LambderApiIdempotencyConfig } from "./LambderApiIdempotency.js";
|
|
7
7
|
/** The declarative options one API registration may carry. */
|
|
8
8
|
type LambderApiPolicyOptions = {
|
|
9
|
-
rateLimit?:
|
|
9
|
+
rateLimit?: LambderRateLimitOptionValue;
|
|
10
10
|
guards?: LambderGuardsOptionValue;
|
|
11
11
|
idempotency?: unknown;
|
|
12
12
|
};
|
|
@@ -16,13 +16,15 @@ type LambderApiPolicyOptions = {
|
|
|
16
16
|
* ./LambderApiGuards.ts, idempotency in ./LambderApiIdempotency.ts), asserts
|
|
17
17
|
* registrations against them at startup, and executes them around handlers
|
|
18
18
|
* at request time. Internal to Lambder; apps interact through
|
|
19
|
-
*
|
|
19
|
+
* the create() options (rateLimits, guards, idempotency) and the
|
|
20
20
|
* per-API options.
|
|
21
21
|
*/
|
|
22
22
|
export declare class LambderApiPolicyEngine {
|
|
23
23
|
private rateLimits;
|
|
24
24
|
private guards;
|
|
25
25
|
private idempotency;
|
|
26
|
+
/** `onInvalidInput` is Lambder's input-validation refusal, so preflight slices answer exactly like the API's own schema. */
|
|
27
|
+
constructor(onInvalidInput: LambderInputValidationRefusal);
|
|
26
28
|
setRateLimits(config: LambderApiRateLimitsConfig<Record<string, LambderApiRateLimitPolicyConfig>>): void;
|
|
27
29
|
addGuards(guards: Record<string, LambderApiGuard<any, any, any>>): void;
|
|
28
30
|
setIdempotency(config: LambderApiIdempotencyConfig): void;
|
|
@@ -7,13 +7,18 @@ import { LambderApiIdempotencyEngine } from "./LambderApiIdempotency.js";
|
|
|
7
7
|
* ./LambderApiGuards.ts, idempotency in ./LambderApiIdempotency.ts), asserts
|
|
8
8
|
* registrations against them at startup, and executes them around handlers
|
|
9
9
|
* at request time. Internal to Lambder; apps interact through
|
|
10
|
-
*
|
|
10
|
+
* the create() options (rateLimits, guards, idempotency) and the
|
|
11
11
|
* per-API options.
|
|
12
12
|
*/
|
|
13
13
|
export class LambderApiPolicyEngine {
|
|
14
|
-
rateLimits
|
|
15
|
-
guards
|
|
14
|
+
rateLimits;
|
|
15
|
+
guards;
|
|
16
16
|
idempotency = new LambderApiIdempotencyEngine();
|
|
17
|
+
/** `onInvalidInput` is Lambder's input-validation refusal, so preflight slices answer exactly like the API's own schema. */
|
|
18
|
+
constructor(onInvalidInput) {
|
|
19
|
+
this.rateLimits = new LambderApiRateLimitsEngine(onInvalidInput);
|
|
20
|
+
this.guards = new LambderApiGuardsEngine(onInvalidInput);
|
|
21
|
+
}
|
|
17
22
|
setRateLimits(config) {
|
|
18
23
|
this.rateLimits.configure(config);
|
|
19
24
|
}
|
|
@@ -28,7 +33,7 @@ export class LambderApiPolicyEngine {
|
|
|
28
33
|
this.rateLimits.assertRegistration(apiName, mode, options.rateLimit);
|
|
29
34
|
this.guards.assertRegistration(apiName, mode, options.guards);
|
|
30
35
|
if (options.idempotency !== undefined && !this.idempotency.isConfigured) {
|
|
31
|
-
throw new Error(`Lambder: API "${apiName}" declares idempotency but
|
|
36
|
+
throw new Error(`Lambder: API "${apiName}" declares idempotency but no idempotency store was configured at creation.`);
|
|
32
37
|
}
|
|
33
38
|
}
|
|
34
39
|
/** Rate limits then guards, in declared order. Refusals throw (LambderApiError or a guard's own throw). */
|