lambder 3.8.1 → 4.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/Readme.md +139 -50
  2. package/dist/{LambderCaller.d.ts → client/LambderCaller.d.ts} +30 -12
  3. package/dist/{LambderCaller.js → client/LambderCaller.js} +21 -12
  4. package/dist/{LambderMSW.d.ts → client/LambderMSW.d.ts} +1 -1
  5. package/dist/client.d.ts +16 -0
  6. package/dist/client.js +17 -0
  7. package/dist/{Lambder.d.ts → core/Lambder.d.ts} +107 -75
  8. package/dist/{Lambder.js → core/Lambder.js} +96 -76
  9. package/dist/{LambderContext.d.ts → core/LambderContext.d.ts} +9 -3
  10. package/dist/{LambderContext.js → core/LambderContext.js} +1 -0
  11. package/dist/{LambderPublicFiles.js → core/LambderPublicFiles.js} +1 -1
  12. package/dist/{LambderResponse.js → core/LambderResponse.js} +1 -1
  13. package/dist/{LambderResponseBuilder.d.ts → core/LambderResponseBuilder.d.ts} +3 -15
  14. package/dist/{LambderResponseBuilder.js → core/LambderResponseBuilder.js} +2 -2
  15. package/dist/{LambderTemplatingEngine.d.ts → core/LambderTemplatingEngine.d.ts} +1 -1
  16. package/dist/{LambderTemplatingEngine.js → core/LambderTemplatingEngine.js} +2 -2
  17. package/dist/index.d.ts +37 -35
  18. package/dist/index.js +22 -20
  19. package/dist/policies/LambderApiGuards.d.ts +221 -0
  20. package/dist/policies/LambderApiGuards.js +79 -0
  21. package/dist/policies/LambderApiIdempotency.d.ts +58 -0
  22. package/dist/policies/LambderApiIdempotency.js +215 -0
  23. package/dist/policies/LambderApiPolicies.d.ts +40 -0
  24. package/dist/policies/LambderApiPolicies.js +47 -0
  25. package/dist/policies/LambderApiRateLimits.d.ts +90 -0
  26. package/dist/policies/LambderApiRateLimits.js +77 -0
  27. package/dist/{LambderSessionController.d.ts → session/LambderSessionController.d.ts} +3 -2
  28. package/dist/{LambderSessionController.js → session/LambderSessionController.js} +17 -12
  29. package/dist/{LambderSessionManager.d.ts → session/LambderSessionManager.d.ts} +38 -4
  30. package/dist/{LambderSessionManager.js → session/LambderSessionManager.js} +50 -17
  31. package/dist/{LambderApiContract.d.ts → shared/LambderApiContract.d.ts} +14 -0
  32. package/dist/{LambderApiError.d.ts → shared/LambderApiError.d.ts} +1 -1
  33. package/dist/{LambderDdbCache.js → stores/LambderDdbCache.js} +7 -38
  34. package/dist/stores/LambderDdbCompression.d.ts +3 -0
  35. package/dist/stores/LambderDdbCompression.js +39 -0
  36. package/dist/{LambderDdbIdempotency.d.ts → stores/LambderDdbIdempotency.d.ts} +41 -12
  37. package/dist/{LambderDdbIdempotency.js → stores/LambderDdbIdempotency.js} +100 -11
  38. package/dist/{LambderDdbRateLimiter.js → stores/LambderDdbRateLimiter.js} +5 -1
  39. package/dist/testing.d.ts +9 -0
  40. package/dist/testing.js +8 -0
  41. package/package.json +19 -1
  42. package/dist/LambderApiPolicies.d.ts +0 -212
  43. package/dist/LambderApiPolicies.js +0 -228
  44. /package/dist/{LambderMSW.js → client/LambderMSW.js} +0 -0
  45. /package/dist/{LambderCors.d.ts → core/LambderCors.d.ts} +0 -0
  46. /package/dist/{LambderCors.js → core/LambderCors.js} +0 -0
  47. /package/dist/{LambderPublicFiles.d.ts → core/LambderPublicFiles.d.ts} +0 -0
  48. /package/dist/{LambderResolver.d.ts → core/LambderResolver.d.ts} +0 -0
  49. /package/dist/{LambderResolver.js → core/LambderResolver.js} +0 -0
  50. /package/dist/{LambderResponse.d.ts → core/LambderResponse.d.ts} +0 -0
  51. /package/dist/{LambderRouting.d.ts → core/LambderRouting.d.ts} +0 -0
  52. /package/dist/{LambderRouting.js → core/LambderRouting.js} +0 -0
  53. /package/dist/{LambderApiContract.js → shared/LambderApiContract.js} +0 -0
  54. /package/dist/{LambderApiError.js → shared/LambderApiError.js} +0 -0
  55. /package/dist/{LambderHtml.d.ts → shared/LambderHtml.d.ts} +0 -0
  56. /package/dist/{LambderHtml.js → shared/LambderHtml.js} +0 -0
  57. /package/dist/{LambderI18n.d.ts → shared/LambderI18n.d.ts} +0 -0
  58. /package/dist/{LambderI18n.js → shared/LambderI18n.js} +0 -0
  59. /package/dist/{node-polyfills.d.ts → shared/node-polyfills.d.ts} +0 -0
  60. /package/dist/{node-polyfills.js → shared/node-polyfills.js} +0 -0
  61. /package/dist/{LambderDdbCache.d.ts → stores/LambderDdbCache.d.ts} +0 -0
  62. /package/dist/{LambderDdbRateLimiter.d.ts → stores/LambderDdbRateLimiter.d.ts} +0 -0
@@ -5,11 +5,13 @@ 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";
9
- import LambderSessionController, { type LambderSessionCookieOptions } from "./LambderSessionController.js";
8
+ import { type LambderSessionDataRefreshConfig } from "../session/LambderSessionManager.js";
9
+ import LambderSessionController, { type LambderSessionCookieOptions } from "../session/LambderSessionController.js";
10
10
  import { type LambderPublicFilesOptions } from "./LambderPublicFiles.js";
11
- import { type LambderApiRateLimitPolicyConfig, type LambderApiRateLimitsConfig, type LambderApiIdempotencyConfig, type LambderApiGuard, type LambderGuardMetaMap, type LambderAllowedGuardNames, type LambderAllowedPolicyNames, type LambderGuardInputsOf } from "./LambderApiPolicies.js";
12
- import type { MergeContract } from "./LambderApiContract.js";
11
+ import type { LambderApiGuard, LambderGuardMetaMap, LambderGuardsOption, LambderGuardDataOf, LambderGuardInputsOf } from "../policies/LambderApiGuards.js";
12
+ import type { LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig, LambderAllowedPolicyNames } from "../policies/LambderApiRateLimits.js";
13
+ import type { LambderApiIdempotencyConfig } from "../policies/LambderApiIdempotency.js";
14
+ import type { MergeContract } from "../shared/LambderApiContract.js";
13
15
  import { type LambderHttpEvent, type LambderRenderContext, type LambderSessionRenderContext } from "./LambderContext.js";
14
16
  export type { PathParamsOf, RouteCondition, ConditionFunction, LambderRouteMatcher } from "./LambderRouting.js";
15
17
  export type { LambderCorsConfig } from "./LambderCors.js";
@@ -62,7 +64,40 @@ export type LambderHandler = {
62
64
  (event: LambderHttpEvent, context: Context): Promise<LambderHttpResponse>;
63
65
  (event: unknown, context: Context): Promise<unknown>;
64
66
  };
65
- export type LambderConstructorOptions = {
67
+ /** DynamoDB session configuration (the `session` option of create/new). */
68
+ export type LambderSessionOptions<TSessionData = any> = {
69
+ tableName: string;
70
+ tableRegion: string;
71
+ sessionSalt: string;
72
+ enableSlidingExpiration?: boolean;
73
+ /** Min seconds between sliding-expiration writes. Default: max(60, 5% of TTL). */
74
+ slidingWriteIntervalSeconds?: number;
75
+ /** Session cookie attributes, e.g. { domain: ".example.com" } for cross-subdomain sessions. `domain` may be a (hostname) => string function for multi-domain deployments. */
76
+ cookie?: LambderSessionCookieOptions;
77
+ /** Session cookie names. Defaults: LMDRSESSIONTKID / LMDRSESSIONCSTK. */
78
+ tokenCookieKey?: string;
79
+ csrfCookieKey?: string;
80
+ partitionKey?: string;
81
+ sortKey?: string;
82
+ /**
83
+ * Opt-in freshness for session.data derived from external state (roles,
84
+ * permissions, feature flags...). Every session read renews data past
85
+ * its ttlSeconds via your refresh callback, persisting in place on the
86
+ * same record: same tokens, same cookies. Return null from refresh to
87
+ * end the session. See LambderSessionDataRefreshConfig for the exact
88
+ * semantics.
89
+ */
90
+ dataRefresh?: LambderSessionDataRefreshConfig<TSessionData>;
91
+ };
92
+ /**
93
+ * Everything an instance is configured with, in ONE declaration: base
94
+ * serving options plus the type-affecting policy layer (rate limits,
95
+ * guards, idempotency) and session/CORS config. There are no enable/define
96
+ * chain methods; the instance is born fully configured and fully typed
97
+ * (via initLambder), so no ordering rules exist and no partially-configured
98
+ * instance type ever needs a name.
99
+ */
100
+ export type LambderCreateOptions<TSessionData = any> = {
66
101
  publicPath?: string;
67
102
  apiPath?: string;
68
103
  apiVersion?: string;
@@ -74,21 +109,34 @@ export type LambderConstructorOptions = {
74
109
  etag?: boolean;
75
110
  /** Guard threshold for Lambda's ~6MB response cap. Default: 5,500,000. */
76
111
  maxResponseBytes?: number;
112
+ /** CORS: true allows any origin; or pass a LambderCorsConfig. Default: off. */
113
+ cors?: boolean | LambderCorsConfig;
114
+ /** DynamoDB-backed sessions; required for addSessionApi/addSessionRoute. */
115
+ session?: LambderSessionOptions<TSessionData>;
116
+ /** Declarative per-API rate limiting: your limiter plus named policies APIs reference (typed) via the `rateLimit` option. */
117
+ rateLimits?: LambderApiRateLimitsConfig<Record<string, LambderApiRateLimitPolicyConfig>>;
118
+ /** Named guards APIs reference (typed) via the `guards` option; build each with lambderGuard(). */
119
+ guards?: Record<string, LambderApiGuard<any, any, any>>;
120
+ /** Declarative idempotency: your store plus replay defaults; APIs opt in via `idempotency: true | { ttlSeconds }`. */
121
+ idempotency?: LambderApiIdempotencyConfig;
77
122
  };
78
123
  /**
79
- * Main Lambder class for building type-safe serverless APIs
124
+ * Main Lambder class for building type-safe serverless APIs. Create
125
+ * instances with initLambder<SessionData>().create({...}) (see below): the
126
+ * whole configuration, including the typed policy layer, is given at
127
+ * construction, and only registration (routes, apis, hooks, use) chains.
80
128
  *
81
129
  * @typeParam TSessionData - Type of session data stored in DynamoDB
82
130
  * @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 _TGuards - @internal Guard name to required-payload map, accumulated by defineApiGuards (do not pass manually)
85
- * @typeParam _TIdempotencyEnabled - @internal Flipped by enableApiIdempotency (do not pass manually)
131
+ * @typeParam _TRateLimitPolicies - @internal Inferred from create()'s rateLimits.policies (do not pass manually)
132
+ * @typeParam _TGuards - @internal Guard metadata map inferred from create()'s guards (do not pass manually)
133
+ * @typeParam _TIdempotencyEnabled - @internal True when create() received idempotency (do not pass manually)
86
134
  *
87
135
  * @example
88
136
  * ```typescript
89
137
  * interface SessionData { userId: string; role: string; }
90
138
  *
91
- * const lambder = new Lambder<SessionData>({ apiPath: '/api' })
139
+ * const lambder = initLambder<SessionData>().create({ apiPath: '/api' })
92
140
  * .addApi('getUser', { input: z.object({...}), output: z.object({...}) }, handler)
93
141
  * .addApi('createUser', { input: z.object({...}), output: z.object({...}) }, handler);
94
142
  * ```
@@ -128,30 +176,7 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
128
176
  private sessionCookieOptions;
129
177
  private sessionTokenCookieKey;
130
178
  private sessionCsrfCookieKey;
131
- constructor(options?: LambderConstructorOptions);
132
- enableCors(config: boolean | LambderCorsConfig): this;
133
- enableDdbSession({ tableName, tableRegion, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, cookie, partitionKey, sortKey, dataRefresh, }: {
134
- tableName: string;
135
- tableRegion: string;
136
- sessionSalt: string;
137
- enableSlidingExpiration?: boolean;
138
- /** Min seconds between sliding-expiration writes. Default: max(60, 5% of TTL). */
139
- slidingWriteIntervalSeconds?: number;
140
- /** Session cookie attributes, e.g. { domain: ".example.com" } for cross-subdomain sessions. `domain` may be a (hostname) => string function for multi-domain deployments. */
141
- cookie?: LambderSessionCookieOptions;
142
- partitionKey?: string;
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>;
153
- }): this;
154
- setSessionCookieKey(sessionTokenCookieKey: string, sessionCsrfCookieKey: string): this;
179
+ constructor(options?: LambderCreateOptions<TSessionData>);
155
180
  setRouteFallbackHandler(routeFallbackHandler: FallbackHandlerFunction): this;
156
181
  setApiFallbackHandler(apiFallbackHandler: FallbackHandlerFunction): this;
157
182
  setApiInputValidationErrorHandler(apiInputValidationErrorHandler: ApiInputValidationErrorHandlerFunction): this;
@@ -179,39 +204,6 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
179
204
  serveIndexHtml(handler?: FallbackHandlerFunction, options?: LambderIndexHtmlOptions): this;
180
205
  /** Apply the serveIndexHtml gates; null means fall through. */
181
206
  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, _TGuards, _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, _TGuards, true>;
202
- /**
203
- * Define named guards that APIs reference (typed) via the `guards`
204
- * option. Each guard is built with lambderGuard() in one of two modes:
205
- * apiInput (checks a slice of the API's own payload; declarable only on
206
- * APIs whose input schema carries those fields, so the payload type
207
- * passes both the API input and the guard's apiInput) or guardInput (the
208
- * client sends the guard's value separately via options.guardInputs, and
209
- * the contract forces it at the call site). Guards run before input
210
- * validation, in the order the API declares them; a handler refuses by
211
- * throwing (typically refuse()). Callable multiple times so domain
212
- * modules can contribute their own; names must not collide.
213
- */
214
- defineApiGuards<TGuards extends Record<string, LambderApiGuard<any>>>(guards: TGuards): Lambder<TSessionData, _TContract, _TRateLimitPolicies, _TGuards & LambderGuardMetaMap<TGuards>, _TIdempotencyEnabled>;
215
207
  private getOrCreatePolicyEngine;
216
208
  /** Registration-time checks shared by addApi/addSessionApi. */
217
209
  private assertApiRegistration;
@@ -220,32 +212,32 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
220
212
  addSessionRoute<TPath extends Path>(condition: TPath, actionFn: (ctx: LambderSessionRenderContext<any, TSessionData, PathParamsOf<TPath>>, resolver: LambderResolver) => MaybePromise<LambderResponse>): this;
221
213
  addSessionRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: SessionActionFunction<TSessionData>): this;
222
214
  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, _TGuards, _TIdempotencyEnabled>;
223
- addApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends LambderAllowedPolicyNames<_TRateLimitPolicies, z.infer<TInput>, false> | readonly LambderAllowedPolicyNames<_TRateLimitPolicies, z.infer<TInput>, false>[] = never, const TGuardsOpt extends LambderAllowedGuardNames<_TGuards, z.infer<TInput>> | readonly LambderAllowedGuardNames<_TGuards, z.infer<TInput>>[] = never>(name: TName, schema: {
215
+ addApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends LambderAllowedPolicyNames<_TRateLimitPolicies, z.infer<TInput>, false> | readonly LambderAllowedPolicyNames<_TRateLimitPolicies, z.infer<TInput>, false>[] = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, z.infer<TInput>, false> = never>(name: TName, schema: {
224
216
  input: TInput;
225
217
  output: TOutput;
226
218
  } & {
227
219
  /** Named rate limits, checked in declared order before guards and validation; the first exceeded one refuses (429 envelope). */
228
220
  rateLimit?: TRateOpt;
229
- /** Named guards, run in declared order before input validation; their input requirements merge into this API's contract input. */
221
+ /** 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. */
230
222
  guards?: TGuardsOpt;
231
- /** Replay-protect this API per client idempotencyKey. Requires enableApiIdempotency() first. */
223
+ /** Replay-protect this API per client idempotencyKey. Requires the idempotency option at creation. */
232
224
  idempotency?: _TIdempotencyEnabled extends true ? (boolean | {
233
225
  ttlSeconds?: number;
234
226
  }) : never;
235
- }, handler: (ctx: LambderRenderContext<z.infer<TInput>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>, LambderGuardInputsOf<_TGuards, TGuardsOpt>>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled>;
236
- addSessionApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends LambderAllowedPolicyNames<_TRateLimitPolicies, z.infer<TInput>, true> | readonly LambderAllowedPolicyNames<_TRateLimitPolicies, z.infer<TInput>, true>[] = never, const TGuardsOpt extends LambderAllowedGuardNames<_TGuards, z.infer<TInput>> | readonly LambderAllowedGuardNames<_TGuards, z.infer<TInput>>[] = never>(name: TName, schema: {
227
+ }, 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>>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled>;
228
+ addSessionApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends LambderAllowedPolicyNames<_TRateLimitPolicies, z.infer<TInput>, true> | readonly LambderAllowedPolicyNames<_TRateLimitPolicies, z.infer<TInput>, true>[] = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, z.infer<TInput>, true> = never>(name: TName, schema: {
237
229
  input: TInput;
238
230
  output: TOutput;
239
231
  } & {
240
232
  /** Named rate limits, checked in declared order before guards and validation; the first exceeded one refuses (429 envelope). */
241
233
  rateLimit?: TRateOpt;
242
- /** Named guards, run in declared order before input validation; their input requirements merge into this API's contract input. */
234
+ /** 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. */
243
235
  guards?: TGuardsOpt;
244
- /** Replay-protect this API per client idempotencyKey. Requires enableApiIdempotency() first. */
236
+ /** Replay-protect this API per client idempotencyKey. Requires the idempotency option at creation. */
245
237
  idempotency?: _TIdempotencyEnabled extends true ? (boolean | {
246
238
  ttlSeconds?: number;
247
239
  }) : never;
248
- }, 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>, LambderGuardInputsOf<_TGuards, TGuardsOpt>>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled>;
240
+ }, 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>>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled>;
249
241
  /**
250
242
  * Fetch the session or short-circuit the request: API calls get the
251
243
  * protocol's { sessionExpired: true } response (handled by LambderCaller),
@@ -290,3 +282,43 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
290
282
  private resolveRequest;
291
283
  render(event: LambderHttpEvent, lambdaContext: Context): Promise<LambderHttpResponse>;
292
284
  }
285
+ /**
286
+ * The canonical way to create an instance: fix the session data type first,
287
+ * then create with the full configuration in one declaration; the policy,
288
+ * guard, and idempotency types are INFERRED from the options, so the
289
+ * instance is born fully typed and `typeof lambderApp` is the annotation
290
+ * type for api modules. No enable/define chain exists, so there are no
291
+ * ordering rules and nothing can be half-configured.
292
+ *
293
+ * ```typescript
294
+ * // app.ts (imports no api modules, so modules can import the type back)
295
+ * export const lambderApp = initLambder<SessionData>().create({
296
+ * apiPath: "/api",
297
+ * session: { tableName: "app-session", tableRegion: "us-east-1", sessionSalt: "..." },
298
+ * rateLimits: { limiter, policies },
299
+ * guards,
300
+ * idempotency: { store },
301
+ * });
302
+ * export type AppLambder = typeof lambderApp;
303
+ *
304
+ * // orders.ts
305
+ * export const orderApi = (lambder: AppLambder) => lambder.addSessionApi(...);
306
+ *
307
+ * // index.ts: registration only
308
+ * const lambder = lambderApp.addHook(...).use(orderApi)...;
309
+ * export const handler = lambder.getHandler();
310
+ * ```
311
+ *
312
+ * Why curried (`initLambder<S>().create(...)` rather than
313
+ * `new Lambder<S>(...)`): TypeScript type arguments are all-or-nothing per
314
+ * call, so explicitly passing the session data type to the constructor
315
+ * would silently WIDEN the inferred policy and guard types to their {}
316
+ * defaults. Fixing the session type in the first call lets the second call
317
+ * infer everything else from the options. `new Lambder(options)` remains
318
+ * for untyped or session-data-free instances.
319
+ */
320
+ export declare const initLambder: <TSessionData = any>() => {
321
+ create<const TOptions extends LambderCreateOptions<TSessionData>>(options: TOptions): Lambder<TSessionData, {}, TOptions["rateLimits"] extends {
322
+ policies: infer TPolicies extends Record<string, LambderApiRateLimitPolicyConfig>;
323
+ } ? TPolicies : {}, TOptions["guards"] extends Record<string, LambderApiGuard<any, any, any>> ? LambderGuardMetaMap<TOptions["guards"]> : {}, TOptions["idempotency"] extends LambderApiIdempotencyConfig ? true : false>;
324
+ };
@@ -3,26 +3,29 @@ import LambderResponseBuilder from "./LambderResponseBuilder.js";
3
3
  import { LambderResponse, finalizeResponse, DEFAULT_FINALIZE_OPTIONS, } from "./LambderResponse.js";
4
4
  import { compileRouteMatcher } from "./LambderRouting.js";
5
5
  import { applyCorsHeaders } from "./LambderCors.js";
6
- import LambderSessionManager from "./LambderSessionManager.js";
7
- import LambderSessionController from "./LambderSessionController.js";
6
+ import LambderSessionManager from "../session/LambderSessionManager.js";
7
+ import LambderSessionController from "../session/LambderSessionController.js";
8
8
  import { LambderPublicFilesHandler } from "./LambderPublicFiles.js";
9
- import { isLambderApiError } from "./LambderApiError.js";
10
- import { LambderApiPolicyEngine, } from "./LambderApiPolicies.js";
9
+ import { isLambderApiError } from "../shared/LambderApiError.js";
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 Accumulated by enableApiRateLimits (do not pass manually)
18
- * @typeParam _TGuards - @internal Guard name to required-payload map, accumulated by defineApiGuards (do not pass manually)
19
- * @typeParam _TIdempotencyEnabled - @internal Flipped by enableApiIdempotency (do not pass manually)
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 = new Lambder<SessionData>({ apiPath: '/api' })
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
- enableCors(config) {
78
- this.corsConfig = config === true ? {} : (config === false ? null : config);
79
- return this;
80
- }
81
- enableDdbSession({ tableName, tableRegion, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, cookie, partitionKey, sortKey, dataRefresh, }) {
82
- this.lambderSessionManager = new LambderSessionManager({
83
- tableName, tableRegion,
84
- partitionKey: partitionKey ?? "pk",
85
- sortKey: sortKey ?? "sk",
86
- sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds,
87
- dataRefresh,
88
- });
89
- this.sessionCookieOptions = cookie ?? {};
90
- return this;
91
- }
92
- setSessionCookieKey(sessionTokenCookieKey, sessionCsrfCookieKey) {
93
- this.sessionTokenCookieKey = sessionTokenCookieKey;
94
- this.sessionCsrfCookieKey = sessionCsrfCookieKey;
95
- return this;
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,51 +172,6 @@ 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() in one of two modes:
197
- * apiInput (checks a slice of the API's own payload; declarable only on
198
- * APIs whose input schema carries those fields, so the payload type
199
- * passes both the API input and the guard's apiInput) or guardInput (the
200
- * client sends the guard's value separately via options.guardInputs, and
201
- * the contract forces it at the call site). Guards run before input
202
- * validation, in the order the API declares them; a handler refuses by
203
- * throwing (typically refuse()). Callable multiple times so domain
204
- * modules can contribute their own; names must not collide.
205
- */
206
- defineApiGuards(guards) {
207
- this.getOrCreatePolicyEngine().addGuards(guards);
208
- return this;
209
- }
210
175
  getOrCreatePolicyEngine() {
211
176
  if (!this.apiPolicyEngine)
212
177
  this.apiPolicyEngine = new LambderApiPolicyEngine();
@@ -222,7 +187,7 @@ export default class Lambder {
222
187
  if (!usesPolicies)
223
188
  return;
224
189
  if (!this.apiPolicyEngine) {
225
- throw new Error(`Lambder: API "${name}" declares rateLimit/guards/idempotency, but none of enableApiRateLimits()/defineApiGuards()/enableApiIdempotency() was called first.`);
190
+ throw new Error(`Lambder: API "${name}" declares rateLimit/guards/idempotency, but none of rateLimits/guards/idempotency was configured at creation.`);
226
191
  }
227
192
  this.apiPolicyEngine.assertRegistration(name, mode, options);
228
193
  }
@@ -257,6 +222,14 @@ export default class Lambder {
257
222
  this.actionList.push({
258
223
  match: (ctx) => ctx.apiName === name ? {} : false,
259
224
  actionFn: async (ctx, resolver) => {
225
+ // Replay fast path first: a completed idempotent request must
226
+ // answer its stored response without burning rate-limit quota
227
+ // or re-running guards (no handler executes either way).
228
+ if (this.apiPolicyEngine && schema.idempotency) {
229
+ const replay = await this.apiPolicyEngine.findReplay(name, ctx);
230
+ if (replay)
231
+ return replay;
232
+ }
260
233
  if (this.apiPolicyEngine)
261
234
  await this.apiPolicyEngine.runPreflight(name, ctx, resolver, schema);
262
235
  const inputResult = schema.input.safeParse(ctx.apiPayload);
@@ -282,6 +255,13 @@ export default class Lambder {
282
255
  match: (ctx) => ctx.apiName === name ? {} : false,
283
256
  actionFn: async (ctx, resolver) => {
284
257
  await this.requireSession(ctx, resolver);
258
+ // Replay fast path (after the session fetch: the replay scope
259
+ // is keyed per session): see addApi.
260
+ if (this.apiPolicyEngine && schema.idempotency) {
261
+ const replay = await this.apiPolicyEngine.findReplay(name, ctx);
262
+ if (replay)
263
+ return replay;
264
+ }
285
265
  if (this.apiPolicyEngine)
286
266
  await this.apiPolicyEngine.runPreflight(name, ctx, resolver, schema);
287
267
  const inputResult = schema.input.safeParse(ctx.apiPayload);
@@ -330,7 +310,7 @@ export default class Lambder {
330
310
  }
331
311
  getSessionController(ctx) {
332
312
  if (!this.lambderSessionManager)
333
- throw new Error("Session is not enabled. Use lambder.enableDdbSession(...) to enable.");
313
+ throw new Error("Session is not enabled. Configure the session option at creation.");
334
314
  return new LambderSessionController({
335
315
  lambderSessionManager: this.lambderSessionManager,
336
316
  sessionTokenCookieKey: this.sessionTokenCookieKey,
@@ -567,6 +547,46 @@ export default class Lambder {
567
547
  }
568
548
  }
569
549
  }
550
+ /**
551
+ * The canonical way to create an instance: fix the session data type first,
552
+ * then create with the full configuration in one declaration; the policy,
553
+ * guard, and idempotency types are INFERRED from the options, so the
554
+ * instance is born fully typed and `typeof lambderApp` is the annotation
555
+ * type for api modules. No enable/define chain exists, so there are no
556
+ * ordering rules and nothing can be half-configured.
557
+ *
558
+ * ```typescript
559
+ * // app.ts (imports no api modules, so modules can import the type back)
560
+ * export const lambderApp = initLambder<SessionData>().create({
561
+ * apiPath: "/api",
562
+ * session: { tableName: "app-session", tableRegion: "us-east-1", sessionSalt: "..." },
563
+ * rateLimits: { limiter, policies },
564
+ * guards,
565
+ * idempotency: { store },
566
+ * });
567
+ * export type AppLambder = typeof lambderApp;
568
+ *
569
+ * // orders.ts
570
+ * export const orderApi = (lambder: AppLambder) => lambder.addSessionApi(...);
571
+ *
572
+ * // index.ts: registration only
573
+ * const lambder = lambderApp.addHook(...).use(orderApi)...;
574
+ * export const handler = lambder.getHandler();
575
+ * ```
576
+ *
577
+ * Why curried (`initLambder<S>().create(...)` rather than
578
+ * `new Lambder<S>(...)`): TypeScript type arguments are all-or-nothing per
579
+ * call, so explicitly passing the session data type to the constructor
580
+ * would silently WIDEN the inferred policy and guard types to their {}
581
+ * defaults. Fixing the session type in the first call lets the second call
582
+ * infer everything else from the options. `new Lambder(options)` remains
583
+ * for untyped or session-data-free instances.
584
+ */
585
+ export const initLambder = () => ({
586
+ create(options) {
587
+ return new Lambder(options);
588
+ },
589
+ });
570
590
  /** Rebuild the query string from the API Gateway event for redirects. */
571
591
  const buildQueryString = (ctx) => {
572
592
  if (isV2HttpEvent(ctx.event)) {
@@ -1,10 +1,10 @@
1
1
  import type { APIGatewayProxyEvent, APIGatewayProxyEventV2, APIGatewayProxyEventHeaders, Context } from "aws-lambda";
2
- import type { LambderSessionContext } from "./LambderSessionManager.js";
2
+ import type { LambderSessionContext } from "../session/LambderSessionManager.js";
3
3
  import type { LambderHttpEventFormat } from "./LambderResponse.js";
4
4
  export type LambderHttpEvent = APIGatewayProxyEvent | APIGatewayProxyEventV2;
5
5
  /** True for API Gateway HTTP API / Lambda Function URL (payload v2) events. */
6
6
  export declare const isV2HttpEvent: (event: unknown) => event is APIGatewayProxyEventV2;
7
- export type LambderRenderContext<TApiPayload = any, TPathParams extends Record<string, string> = Record<string, string>> = {
7
+ export type LambderRenderContext<TApiPayload = any, TPathParams extends Record<string, string> = Record<string, string>, TGuardData = {}> = {
8
8
  host: string;
9
9
  path: string;
10
10
  pathParams: TPathParams;
@@ -15,6 +15,12 @@ export type LambderRenderContext<TApiPayload = any, TPathParams extends Record<s
15
15
  session: null;
16
16
  apiName: string | null;
17
17
  apiPayload: TApiPayload;
18
+ /**
19
+ * Outputs of this API's guards, keyed by guard name. Only guards the API
20
+ * declares AND that return a value appear (typed via the declarative
21
+ * guards option); void guards never do.
22
+ */
23
+ guardData: TGuardData;
18
24
  headers: APIGatewayProxyEventHeaders;
19
25
  /** Decoded request body, exactly as received (e.g. for webhook signature verification). */
20
26
  rawBody: string;
@@ -39,7 +45,7 @@ export type LambderRenderContext<TApiPayload = any, TPathParams extends Record<s
39
45
  logToApiResponseAccumulator: any[];
40
46
  };
41
47
  };
42
- export type LambderSessionRenderContext<TApiPayload = any, SessionData = any, TPathParams extends Record<string, string> = Record<string, string>> = Omit<LambderRenderContext<TApiPayload, TPathParams>, 'session'> & {
48
+ export type LambderSessionRenderContext<TApiPayload = any, SessionData = any, TPathParams extends Record<string, string> = Record<string, string>, TGuardData = {}> = Omit<LambderRenderContext<TApiPayload, TPathParams, TGuardData>, 'session'> & {
43
49
  session: LambderSessionContext<SessionData>;
44
50
  };
45
51
  export declare const createContext: (event: LambderHttpEvent, lambdaContext: Context, apiPath: string) => LambderRenderContext;
@@ -77,6 +77,7 @@ export const createContext = (event, lambdaContext, apiPath) => {
77
77
  get, post, cookie, event,
78
78
  session: null,
79
79
  apiName, apiPayload,
80
+ guardData: {},
80
81
  headers, rawBody, ip, header,
81
82
  lambdaContext,
82
83
  _otherInternal: {
@@ -1,5 +1,5 @@
1
1
  import mimeTypeResolver from "mime-types";
2
- import { getFS, getPath } from "./node-polyfills.js";
2
+ import { getFS, getPath } from "../shared/node-polyfills.js";
3
3
  import { LambderResponse } from "./LambderResponse.js";
4
4
  // Content-hashed build outputs (Vite/webpack/Rollup): a [-.] separated run of
5
5
  // 8+ hash chars containing at least one digit, before the extension.
@@ -1,4 +1,4 @@
1
- import { getZlib, getCrypto } from "./node-polyfills.js";
1
+ import { getZlib, getCrypto } from "../shared/node-polyfills.js";
2
2
  export const normalizeHeaders = (headers) => Object.fromEntries(Object.entries(headers ?? {}).map(([k, v]) => [k, Array.isArray(v) ? [...v] : [v]]));
3
3
  /**
4
4
  * Intermediate response object returned by all response builder methods and by
@@ -1,7 +1,9 @@
1
1
  import type { LambderRenderContext } from "./LambderContext.js";
2
2
  import { LambderResponse, type HttpStatusCode, type LambderHeadersInput } from "./LambderResponse.js";
3
- import { LambderSafeHtml } from "./LambderHtml.js";
3
+ import { LambderSafeHtml } from "../shared/LambderHtml.js";
4
4
  import { type LambderTemplateData } from "./LambderTemplatingEngine.js";
5
+ import type { LambderApiResponseConfig } from "../shared/LambderApiContract.js";
6
+ export type { LambderApiResponse, LambderApiResponseConfig } from "../shared/LambderApiContract.js";
5
7
  export type LambderResponseOptions = {
6
8
  statusCode?: HttpStatusCode;
7
9
  headers?: LambderHeadersInput;
@@ -12,23 +14,9 @@ export type LambderResponseOptions = {
12
14
  /** "auto" (default): ETag on GET/HEAD 200 when globally enabled. true: force. false: never. */
13
15
  etag?: boolean | "auto";
14
16
  };
15
- export type LambderApiResponseConfig = {
16
- versionExpired?: boolean;
17
- sessionExpired?: boolean;
18
- notAuthorized?: boolean;
19
- message?: any;
20
- errorMessage?: any;
21
- logList?: any[];
22
- };
23
- export type LambderApiResponse<T> = LambderApiResponseConfig & {
24
- apiVersion?: string | null;
25
- payload?: T | null;
26
- };
27
17
  export type LambderRawResponseInit = {
28
18
  statusCode: HttpStatusCode;
29
19
  headers?: LambderHeadersInput;
30
- /** Legacy alias for headers (API Gateway naming). */
31
- multiValueHeaders?: Record<string, string[]>;
32
20
  body: string | Buffer | null;
33
21
  /** True when body is already a base64-encoded string. */
34
22
  isBase64Encoded?: boolean;
@@ -1,5 +1,5 @@
1
1
  import mimeTypeResolver from "mime-types";
2
- import { getFS, getPath } from "./node-polyfills.js";
2
+ import { getFS, getPath } from "../shared/node-polyfills.js";
3
3
  import { LambderResponse } from "./LambderResponse.js";
4
4
  import { LambderTemplatingEngine } from "./LambderTemplatingEngine.js";
5
5
  // Compiled templates survive across requests (builder instances are per-request).
@@ -72,7 +72,7 @@ export default class LambderResponseBuilder {
72
72
  raw(init) {
73
73
  return new LambderResponse({
74
74
  statusCode: init.statusCode,
75
- headers: init.headers ?? init.multiValueHeaders,
75
+ headers: init.headers,
76
76
  body: init.body,
77
77
  isBodyBase64: init.isBase64Encoded ?? false,
78
78
  compress: init.compress ?? (init.isBase64Encoded ? false : "auto"),
@@ -1,4 +1,4 @@
1
- import { type LambderHtmlValue } from "./LambderHtml.js";
1
+ import { type LambderHtmlValue } from "../shared/LambderHtml.js";
2
2
  /**
3
3
  * LambderTemplatingEngine: a comment-only HTML template engine.
4
4
  *
@@ -1,5 +1,5 @@
1
- import { getFS } from "./node-polyfills.js";
2
- import { renderHtmlValue } from "./LambderHtml.js";
1
+ import { getFS } from "../shared/node-polyfills.js";
2
+ import { renderHtmlValue } from "../shared/LambderHtml.js";
3
3
  const TOKEN_PATTERN = /<!--\s*(?:(slot:([\w-]+)\s*\/)|(slot:([\w-]+))|(\/slot:([\w-]+))|(if:(!?)([\w-]+))|(else)|(\/if:(!?)([\w-]+)))\s*-->/g;
4
4
  /**
5
5
  * Fail loudly on slot positions where HTML escaping cannot protect against