lambder 2.0.16 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/Readme.md +162 -41
  2. package/dist/Lambder.d.ts +154 -46
  3. package/dist/Lambder.js +312 -166
  4. package/dist/LambderCaller.js +6 -3
  5. package/dist/LambderContext.d.ts +20 -9
  6. package/dist/LambderContext.js +57 -17
  7. package/dist/LambderCors.d.ts +12 -0
  8. package/dist/LambderCors.js +30 -0
  9. package/dist/LambderHtml.d.ts +33 -0
  10. package/dist/LambderHtml.js +62 -0
  11. package/dist/LambderMSW.d.ts +16 -1
  12. package/dist/LambderMSW.js +5 -9
  13. package/dist/LambderPublicFiles.d.ts +47 -0
  14. package/dist/LambderPublicFiles.js +108 -0
  15. package/dist/LambderResolver.d.ts +30 -31
  16. package/dist/LambderResolver.js +29 -43
  17. package/dist/LambderResponse.d.ts +71 -0
  18. package/dist/LambderResponse.js +196 -0
  19. package/dist/LambderResponseBuilder.d.ts +58 -33
  20. package/dist/LambderResponseBuilder.js +114 -167
  21. package/dist/LambderRouting.d.ts +23 -0
  22. package/dist/LambderRouting.js +67 -0
  23. package/dist/LambderSessionController.d.ts +13 -1
  24. package/dist/LambderSessionController.js +33 -10
  25. package/dist/LambderSessionManager.d.ts +3 -1
  26. package/dist/LambderSessionManager.js +15 -6
  27. package/dist/LambderTemplatingEngine.d.ts +87 -0
  28. package/dist/LambderTemplatingEngine.js +156 -0
  29. package/dist/index.d.ts +14 -2
  30. package/dist/index.js +10 -1
  31. package/dist/node-polyfills.d.ts +4 -2
  32. package/dist/node-polyfills.js +28 -0
  33. package/package.json +7 -5
  34. package/.eslintrc.cjs +0 -26
  35. package/.vscode/settings.json +0 -26
  36. package/deploy +0 -22
  37. package/dist/LambderUtils.d.ts +0 -10
  38. package/dist/LambderUtils.js +0 -70
  39. package/docs/DYNAMODB_SETUP.md +0 -96
  40. package/docs/LAMBDER_MSW.md +0 -409
  41. package/docs/TYPE_SAFE_QUICK_START.md +0 -77
  42. package/examples/msw-testing-example.ts +0 -280
  43. package/examples/secure-session-example.ts +0 -207
  44. package/examples/zod-chained-api-example.ts +0 -63
  45. package/src/Lambder.ts +0 -430
  46. package/src/LambderApiContract.ts +0 -20
  47. package/src/LambderCaller.ts +0 -238
  48. package/src/LambderContext.ts +0 -78
  49. package/src/LambderMSW.ts +0 -180
  50. package/src/LambderResolver.ts +0 -101
  51. package/src/LambderResponseBuilder.ts +0 -332
  52. package/src/LambderSessionController.ts +0 -114
  53. package/src/LambderSessionManager.ts +0 -217
  54. package/src/LambderUtils.ts +0 -75
  55. package/src/index.ts +0 -17
  56. package/src/node-polyfills.ts +0 -27
  57. package/tests/error-handling.test.ts +0 -585
  58. package/tests/file-serving.test.ts +0 -194
  59. package/tests/fixtures/public/index.html +0 -1
  60. package/tests/fixtures/public/main.css +0 -1
  61. package/tests/hooks.test.ts +0 -561
  62. package/tests/output-type-runtime.test.ts +0 -381
  63. package/tests/redirect.test.ts +0 -88
  64. package/tests/routes.test.ts +0 -543
  65. package/tests/session.test.ts +0 -1083
  66. package/tests/use-plugin.test.ts +0 -460
  67. package/tsconfig.json +0 -24
package/dist/Lambder.js CHANGED
@@ -1,10 +1,12 @@
1
- import { match } from "path-to-regexp";
2
1
  import LambderResolver from "./LambderResolver.js";
3
2
  import LambderResponseBuilder from "./LambderResponseBuilder.js";
4
- import LambderUtils from "./LambderUtils.js";
3
+ import { LambderResponse, finalizeResponse, DEFAULT_FINALIZE_OPTIONS, } from "./LambderResponse.js";
4
+ import { compileRouteMatcher } from "./LambderRouting.js";
5
+ import { applyCorsHeaders } from "./LambderCors.js";
5
6
  import LambderSessionManager from "./LambderSessionManager.js";
6
7
  import LambderSessionController from "./LambderSessionController.js";
7
- import { createContext } from "./LambderContext.js";
8
+ import { LambderPublicFilesHandler } from "./LambderPublicFiles.js";
9
+ import { createContext, isV2HttpEvent } from "./LambderContext.js";
8
10
  /**
9
11
  * Main Lambder class for building type-safe serverless APIs
10
12
  *
@@ -23,9 +25,7 @@ import { createContext } from "./LambderContext.js";
23
25
  export default class Lambder {
24
26
  apiPath;
25
27
  apiVersion;
26
- isCorsEnabled = false;
27
28
  publicPath;
28
- ejsPath;
29
29
  /**
30
30
  * Type property for extracting the API contract
31
31
  * Use this to export your API types to the frontend
@@ -37,37 +37,48 @@ export default class Lambder {
37
37
  * ```
38
38
  */
39
39
  ApiContract;
40
- actionList;
41
- hookList;
40
+ actionList = [];
41
+ hookList = { "beforeRender": [], "afterRender": [], "fallback": [] };
42
+ createdHooks = [];
43
+ initPromise = null;
42
44
  globalErrorHandler = null;
43
45
  routeFallbackHandler = null;
44
46
  apiFallbackHandler = null;
45
47
  apiInputValidationErrorHandler = null;
46
- utils;
48
+ sessionExpiredRouteHandler = null;
49
+ publicFilesHandler = null;
50
+ indexHtmlConfig = null;
51
+ eventActionList = [];
52
+ corsConfig = null;
53
+ finalizeOptions;
47
54
  lambderSessionManager;
55
+ sessionCookieOptions = {};
48
56
  sessionTokenCookieKey = "LMDRSESSIONTKID";
49
57
  sessionCsrfCookieKey = "LMDRSESSIONCSTK";
50
- constructor({ publicPath, apiPath, ejsPath, apiVersion }) {
51
- this.publicPath = publicPath || "/incorrect-path-not-found";
52
- this.ejsPath = ejsPath || "/incorrect-ejs-path-not-found";
53
- this.apiPath = apiPath ?? "/api";
54
- this.apiVersion = apiVersion ?? null;
55
- this.actionList = [];
56
- this.hookList = {
57
- "beforeRender": [],
58
- "afterRender": [],
59
- "fallback": [],
58
+ constructor(options = {}) {
59
+ this.publicPath = options.publicPath || "/incorrect-path-not-found";
60
+ this.apiPath = options.apiPath ?? "/api";
61
+ this.apiVersion = options.apiVersion ?? null;
62
+ this.finalizeOptions = {
63
+ compression: options.compression === false
64
+ ? false
65
+ : { minBytes: options.compression?.minBytes ?? DEFAULT_FINALIZE_OPTIONS.compression.minBytes },
66
+ etag: options.etag ?? DEFAULT_FINALIZE_OPTIONS.etag,
67
+ maxResponseBytes: options.maxResponseBytes ?? DEFAULT_FINALIZE_OPTIONS.maxResponseBytes,
60
68
  };
61
- this.utils = new LambderUtils({ ejsPath });
62
69
  }
63
- enableCors(isCorsEnabled) {
64
- this.isCorsEnabled = isCorsEnabled;
70
+ enableCors(config) {
71
+ this.corsConfig = config === true ? {} : (config === false ? null : config);
65
72
  return this;
66
73
  }
67
- enableDdbSession({ tableName, tableRegion, sessionSalt, enableSlidingExpiration }, { partitionKey, sortKey } = { partitionKey: "pk", sortKey: "sk" }) {
74
+ enableDdbSession({ tableName, tableRegion, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, cookie, partitionKey, sortKey, }) {
68
75
  this.lambderSessionManager = new LambderSessionManager({
69
- tableName, tableRegion, partitionKey, sortKey, sessionSalt, enableSlidingExpiration
76
+ tableName, tableRegion,
77
+ partitionKey: partitionKey ?? "pk",
78
+ sortKey: sortKey ?? "sk",
79
+ sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds,
70
80
  });
81
+ this.sessionCookieOptions = cookie ?? {};
71
82
  return this;
72
83
  }
73
84
  setSessionCookieKey(sessionTokenCookieKey, sessionCsrfCookieKey) {
@@ -91,71 +102,71 @@ export default class Lambder {
91
102
  this.globalErrorHandler = globalErrorHandler;
92
103
  return this;
93
104
  }
94
- getPatternMatch(pattern, path) {
95
- const result = (match(pattern, { decode: decodeURIComponent }))(path);
96
- if (!result)
97
- return {};
98
- return result?.params || {};
105
+ /** Response for session routes when the session is missing/expired (non-API). Default: 401. */
106
+ setSessionExpiredRouteHandler(handler) {
107
+ this.sessionExpiredRouteHandler = handler;
108
+ return this;
99
109
  }
100
- testPatternMatch(pattern, path) {
101
- return (match(pattern, { decode: decodeURIComponent }))(path) !== false;
110
+ /**
111
+ * Terminal public-file layer. Runs only when no route matched, so it can
112
+ * never shadow routes registered after it. Serves real files under
113
+ * publicPath (traversal-safe, mime-typed, memory-cached, immutable-cache
114
+ * heuristic for content-hashed assets); when the file does not exist the
115
+ * request falls through to setRouteFallbackHandler, where the app decides
116
+ * what remains (e.g. render an app shell with res.templateFile).
117
+ */
118
+ servePublicFiles(options = {}) {
119
+ this.publicFilesHandler = new LambderPublicFilesHandler(this.publicPath, options);
120
+ return this;
102
121
  }
103
- async handleNoMatchedAction(ctx, resolver) {
104
- for (const hook of this.hookList["fallback"]) {
105
- await hook.hookFn(ctx, resolver);
106
- }
107
- const isAPI = ctx.path === this.apiPath;
108
- if (isAPI && this.apiFallbackHandler) {
109
- resolver.resolve(await this.apiFallbackHandler(ctx, resolver));
110
- }
111
- else if (isAPI) {
112
- resolver.resolve({ statusCode: 204, body: "API handler not set.", });
113
- }
114
- else if (this.routeFallbackHandler) {
115
- resolver.resolve(await this.routeFallbackHandler(ctx, resolver));
122
+ /**
123
+ * Serve the app shell for page requests that nothing else handled. Runs
124
+ * after servePublicFiles in the fallback chain, gated by a built-in
125
+ * filter: only configured methods (default GET/HEAD) and, by default, only
126
+ * paths that do not look like files. Gated-out requests fall through to
127
+ * setRouteFallbackHandler. Without a handler, publicPath/index.html is
128
+ * served via res.templateFile (markers optional) with no-cache.
129
+ */
130
+ serveIndexHtml(handler, options = {}) {
131
+ this.indexHtmlConfig = { handler: handler ?? null, options };
132
+ return this;
133
+ }
134
+ /** Apply the serveIndexHtml gates; null means fall through. */
135
+ async tryServeIndexHtml(ctx, resolver) {
136
+ if (!this.indexHtmlConfig)
137
+ return null;
138
+ const { handler, options } = this.indexHtmlConfig;
139
+ const methods = (options.methods ?? ["GET", "HEAD"]).map((m) => m.toUpperCase());
140
+ if (!methods.includes(ctx.method.toUpperCase()))
141
+ return null;
142
+ if ((options.skipFilePaths ?? true) && (ctx.path.split("/").pop() ?? "").includes("."))
143
+ return null;
144
+ if (options.redirectTrailingSlash && ctx.path.length > 1 && ctx.path.endsWith("/")) {
145
+ const target = ctx.path.replace(/\/+$/, "") || "/";
146
+ return resolver.redirect(target + buildQueryString(ctx), 301);
116
147
  }
117
- else {
118
- resolver.resolve({ statusCode: 204, body: "Route handler not set.", });
148
+ const response = handler
149
+ ? await handler(ctx, resolver)
150
+ : await resolver.templateFile(typeof options.indexFile === "function" ? options.indexFile(ctx) : (options.indexFile ?? "index.html"), {}, { cacheControl: "no-cache" });
151
+ if (options.compress !== undefined) {
152
+ response.compress = typeof options.compress === "function" ? options.compress(ctx) : options.compress;
119
153
  }
154
+ return response;
120
155
  }
121
156
  addRoute(condition, actionFn) {
122
157
  this.actionList.push({
123
- conditionFn: (ctx) => (((typeof condition === "string" && this.testPatternMatch(condition, ctx.path)) ||
124
- (typeof condition === "function" && condition(ctx)) ||
125
- (condition?.constructor == RegExp && condition.test(ctx.path)))),
126
- actionFn: async (ctx, resolver) => {
127
- if (typeof condition === "string") {
128
- ctx.pathParams = this.getPatternMatch(condition, ctx.path);
129
- }
130
- else if (condition?.constructor == RegExp) {
131
- const match = ctx.path.match(condition);
132
- ctx.pathParams = match ? (match.groups || match) : {};
133
- }
134
- return await actionFn(ctx, resolver);
135
- }
158
+ match: compileRouteMatcher(condition),
159
+ actionFn: (ctx, resolver) => actionFn(ctx, resolver),
136
160
  });
137
161
  return this;
138
162
  }
139
163
  addSessionRoute(condition, actionFn) {
140
164
  this.actionList.push({
141
- conditionFn: (ctx) => (((typeof condition === "string" && this.testPatternMatch(condition, ctx.path)) ||
142
- (typeof condition === "function" && condition(ctx)) ||
143
- (condition?.constructor == RegExp && condition.test(ctx.path)))),
165
+ match: compileRouteMatcher(condition),
144
166
  actionFn: async (ctx, resolver) => {
145
- if (typeof condition === "string") {
146
- ctx.pathParams = this.getPatternMatch(condition, ctx.path);
147
- }
148
- else if (condition?.constructor == RegExp) {
149
- const match = ctx.path.match(condition);
150
- ctx.pathParams = match ? (match.groups || match) : {};
151
- }
152
- const sessionCtx = ctx;
153
- await this.getSessionController(ctx).fetchSession();
154
- if (!sessionCtx.session) {
155
- throw new Error("Session not found.");
156
- }
157
- return await actionFn(sessionCtx, resolver);
158
- }
167
+ await this.requireSession(ctx, resolver);
168
+ return await actionFn(ctx, resolver);
169
+ },
159
170
  });
160
171
  return this;
161
172
  }
@@ -166,21 +177,15 @@ export default class Lambder {
166
177
  // Typed API with Zod
167
178
  addApi(name, schema, handler) {
168
179
  this.actionList.push({
169
- conditionFn: (ctx) => ctx.apiName === name,
180
+ match: (ctx) => ctx.apiName === name ? {} : false,
170
181
  actionFn: async (ctx, resolver) => {
171
- // Validate Input
172
182
  const inputResult = schema.input.safeParse(ctx.apiPayload);
173
183
  if (!inputResult.success) {
174
184
  if (this.apiInputValidationErrorHandler) {
175
185
  return await this.apiInputValidationErrorHandler(ctx, resolver, inputResult.error);
176
186
  }
177
- return resolver.raw({
178
- statusCode: 422,
179
- body: JSON.stringify({ error: "Input validation failed", zodError: inputResult.error }),
180
- multiValueHeaders: { "Content-Type": ["application/json"] }
181
- });
187
+ return resolver.json({ error: "Input validation failed", zodError: inputResult.error }, { statusCode: 422 });
182
188
  }
183
- // Run Handler with validated data
184
189
  ctx.apiPayload = inputResult.data;
185
190
  return await handler(ctx, resolver);
186
191
  },
@@ -190,41 +195,49 @@ export default class Lambder {
190
195
  // Typed Session API with Zod
191
196
  addSessionApi(name, schema, handler) {
192
197
  this.actionList.push({
193
- conditionFn: (ctx) => ctx.apiName === name,
198
+ match: (ctx) => ctx.apiName === name ? {} : false,
194
199
  actionFn: async (ctx, resolver) => {
195
- const sessionCtx = ctx;
196
- await this.getSessionController(ctx).fetchSession();
197
- if (!sessionCtx.session) {
198
- throw new Error("Session not found.");
199
- }
200
- // Validate Input
200
+ await this.requireSession(ctx, resolver);
201
201
  const inputResult = schema.input.safeParse(ctx.apiPayload);
202
202
  if (!inputResult.success) {
203
203
  if (this.apiInputValidationErrorHandler) {
204
204
  return await this.apiInputValidationErrorHandler(ctx, resolver, inputResult.error);
205
205
  }
206
- return resolver.raw({
207
- statusCode: 400,
208
- body: JSON.stringify({ error: "Input validation failed", zodError: inputResult.error }),
209
- multiValueHeaders: { "Content-Type": ["application/json"] }
210
- });
206
+ return resolver.json({ error: "Input validation failed", zodError: inputResult.error }, { statusCode: 422 });
211
207
  }
212
- // Run Handler with validated data
213
208
  ctx.apiPayload = inputResult.data;
214
- return await handler(sessionCtx, resolver);
209
+ return await handler(ctx, resolver);
215
210
  }
216
211
  });
217
212
  return this;
218
213
  }
214
+ /**
215
+ * Fetch the session or short-circuit the request: API calls get the
216
+ * protocol's { sessionExpired: true } response (handled by LambderCaller),
217
+ * routes get the sessionExpiredRouteHandler response (default 401).
218
+ */
219
+ async requireSession(ctx, resolver) {
220
+ const session = await this.getSessionController(ctx).fetchSessionIfExists();
221
+ if (!session) {
222
+ if (ctx._otherInternal.isApiCall) {
223
+ throw resolver.api(null, { sessionExpired: true });
224
+ }
225
+ if (this.sessionExpiredRouteHandler) {
226
+ throw await this.sessionExpiredRouteHandler(ctx, resolver);
227
+ }
228
+ throw resolver.status(401, "Session required.");
229
+ }
230
+ }
219
231
  addHook(hookEvent, hookFn, priority = 0) {
220
232
  if (hookEvent === "created") {
221
- return hookFn(this).then(() => this);
233
+ // Runs once, lazily, at the first render() call.
234
+ this.createdHooks.push(hookFn);
222
235
  }
223
236
  else {
224
237
  this.hookList[hookEvent].push({ priority, hookFn });
225
238
  this.hookList[hookEvent].sort((a, b) => a.priority - b.priority);
226
- return this;
227
239
  }
240
+ return this;
228
241
  }
229
242
  getSessionController(ctx) {
230
243
  if (!this.lambderSessionManager)
@@ -233,97 +246,230 @@ export default class Lambder {
233
246
  lambderSessionManager: this.lambderSessionManager,
234
247
  sessionTokenCookieKey: this.sessionTokenCookieKey,
235
248
  sessionCsrfCookieKey: this.sessionCsrfCookieKey,
249
+ cookieOptions: this.sessionCookieOptions,
236
250
  ctx,
237
251
  });
238
252
  }
239
- getResponseBuilder() {
253
+ getResponseBuilder(ctx) {
240
254
  return new LambderResponseBuilder({
241
- isCorsEnabled: this.isCorsEnabled,
242
255
  publicPath: this.publicPath,
243
256
  apiVersion: this.apiVersion,
244
- lambderUtils: this.utils,
257
+ ctx,
245
258
  });
246
259
  }
247
260
  ;
248
- getResolver(ctx, resolve, reject) {
261
+ getResolver(ctx) {
249
262
  return new LambderResolver({
250
- isCorsEnabled: this.isCorsEnabled,
251
263
  publicPath: this.publicPath,
252
264
  apiVersion: this.apiVersion,
253
- lambderUtils: this.utils,
254
- ctx, resolve, reject
265
+ ctx,
255
266
  });
256
267
  }
257
268
  ;
258
269
  getHandler() {
259
- return (event, context) => this.render(event, context);
270
+ return ((event, context) => Lambder.isHttpEvent(event)
271
+ ? this.render(event, context)
272
+ : this.renderEvent(event, context));
273
+ }
274
+ // ---------------------------------------------------------------------
275
+ // Actions (raw-event or context filtering; the only handler for non-HTTP)
276
+ // ---------------------------------------------------------------------
277
+ /** True when the Lambda event is an API Gateway HTTP event (REST API v1 or HTTP API / Function URL v2). */
278
+ static isHttpEvent(event) {
279
+ if (!event || typeof event !== "object")
280
+ return false;
281
+ if ("httpMethod" in event && "path" in event)
282
+ return true;
283
+ return isV2HttpEvent(event);
284
+ }
285
+ addAction(filter, actionFn) {
286
+ // HTTP side: joins the route/API chain in registration order.
287
+ this.actionList.push({
288
+ match: (ctx) => filter(ctx.event, ctx) ? {} : false,
289
+ actionFn: async (ctx, resolver) => {
290
+ const result = await actionFn(ctx.event, { ctx, res: resolver, lambdaContext: ctx.lambdaContext });
291
+ if (!(result instanceof LambderResponse)) {
292
+ throw new Error("Lambder: an addAction matched an HTTP request but did not return a response. Build one with tools.res.");
293
+ }
294
+ return result;
295
+ },
296
+ });
297
+ // Non-HTTP side.
298
+ this.eventActionList.push({
299
+ match: (event) => filter(event, null),
300
+ actionFn: (event, lambdaContext) => actionFn(event, { ctx: null, res: null, lambdaContext }),
301
+ });
302
+ return this;
303
+ }
304
+ /** Dispatch a non-HTTP Lambda event to the registered actions. */
305
+ async renderEvent(event, lambdaContext) {
306
+ await this.ensureInitialized();
307
+ for (const action of this.eventActionList) {
308
+ if (action.match(event)) {
309
+ return await action.actionFn(event, lambdaContext);
310
+ }
311
+ }
312
+ const summary = event && typeof event === "object"
313
+ ? ` (source: ${String(event.source ?? "?")}, detail-type: ${String(event["detail-type"] ?? "?")})`
314
+ : "";
315
+ throw new Error(`Lambder: no action matched non-HTTP event${summary}. Register one with addAction(); a trailing addAction(() => true, ...) acts as a fallback.`);
316
+ }
317
+ // ---------------------------------------------------------------------
318
+ // Render pipeline
319
+ // ---------------------------------------------------------------------
320
+ ensureInitialized() {
321
+ if (!this.initPromise) {
322
+ this.initPromise = (async () => {
323
+ for (const hookFn of this.createdHooks) {
324
+ await hookFn(this);
325
+ }
326
+ })();
327
+ }
328
+ return this.initPromise;
329
+ }
330
+ applyCors(ctx, response, isPreflight) {
331
+ applyCorsHeaders(this.corsConfig, ctx, response, isPreflight);
332
+ }
333
+ async handleNoMatchedAction(ctx, resolver) {
334
+ for (const hook of this.hookList["fallback"]) {
335
+ await hook.hookFn(ctx, resolver);
336
+ }
337
+ const isAPI = ctx._otherInternal.isApiCall || ctx.path === this.apiPath;
338
+ if (isAPI) {
339
+ if (this.apiFallbackHandler)
340
+ return await this.apiFallbackHandler(ctx, resolver);
341
+ return resolver.api(null, { errorMessage: "API not found." });
342
+ }
343
+ if (this.publicFilesHandler) {
344
+ const fileResponse = await this.publicFilesHandler.handle(ctx);
345
+ if (fileResponse)
346
+ return fileResponse;
347
+ }
348
+ const indexResponse = await this.tryServeIndexHtml(ctx, resolver);
349
+ if (indexResponse)
350
+ return indexResponse;
351
+ if (this.routeFallbackHandler)
352
+ return await this.routeFallbackHandler(ctx, resolver);
353
+ return resolver.text("Not found.", { statusCode: 404 });
354
+ }
355
+ async resolveRequest(ctx, resolver) {
356
+ if (ctx.method === "OPTIONS" && this.corsConfig) {
357
+ const preflight = new LambderResponse({ statusCode: 204, body: null });
358
+ this.applyCors(ctx, preflight, true);
359
+ return preflight;
360
+ }
361
+ // Version check if provided by both the client and the server
362
+ if (this.apiVersion && ctx._otherInternal.requestVersion && ctx._otherInternal.requestVersion !== this.apiVersion) {
363
+ return resolver.versionExpired();
364
+ }
365
+ let matched = null;
366
+ for (const action of this.actionList) {
367
+ const params = action.match(ctx);
368
+ if (params !== false) {
369
+ matched = { action, params };
370
+ break;
371
+ }
372
+ }
373
+ if (!matched)
374
+ return await this.handleNoMatchedAction(ctx, resolver);
375
+ ctx.pathParams = matched.params;
376
+ let currentCtx = ctx;
377
+ for (const hook of this.hookList["beforeRender"]) {
378
+ const hookResult = await hook.hookFn(currentCtx, resolver);
379
+ if (hookResult instanceof Error)
380
+ throw hookResult;
381
+ if (hookResult instanceof LambderResponse)
382
+ return hookResult;
383
+ currentCtx = hookResult;
384
+ }
385
+ return await matched.action.actionFn(currentCtx, resolver);
260
386
  }
261
387
  async render(event, lambdaContext) {
262
- let eventRenderContext = null;
388
+ let ctx = null;
263
389
  try {
264
- let ctx = createContext(event, lambdaContext, this.apiPath);
265
- eventRenderContext = ctx;
266
- return await new Promise(async (resolve, reject) => {
267
- try {
268
- const resolver = this.getResolver(ctx, resolve, reject);
269
- if (ctx.method === "OPTIONS")
270
- return resolver.cors();
271
- const firstMatchedAction = this.actionList.find(action => action.conditionFn(ctx));
272
- if (firstMatchedAction) {
273
- // Check version if provided by the client and the server
274
- if (this.apiVersion && ctx._otherInternal.requestVersion) {
275
- if (ctx._otherInternal.requestVersion !== this.apiVersion) {
276
- const responseBuilder = this.getResponseBuilder();
277
- return resolve(responseBuilder.versionExpired());
278
- }
279
- }
280
- ;
281
- // Run beforeRender hooks
282
- for (const hook of this.hookList["beforeRender"]) {
283
- const hookCtx = await hook.hookFn(ctx, resolver);
284
- if (hookCtx instanceof Error) {
285
- throw hookCtx;
286
- }
287
- ctx = hookCtx;
288
- }
289
- // Run matched action
290
- let response = await firstMatchedAction.actionFn(ctx, resolver);
291
- // Run afterRender hooks
292
- for (const hook of this.hookList["afterRender"]) {
293
- const hookResponse = await hook.hookFn(ctx, resolver, response);
294
- if (hookResponse instanceof Error) {
295
- throw hookResponse;
296
- }
297
- response = hookResponse;
298
- }
299
- // Apply setHeader, addHeader values.
300
- response.multiValueHeaders = response.multiValueHeaders || {};
301
- for (const header of ctx._otherInternal.setHeaderFnAccumulator) {
302
- response.multiValueHeaders[header.key] = Array.isArray(header.value) ? header.value : [header.value];
303
- }
304
- for (const header of ctx._otherInternal.addHeaderFnAccumulator) {
305
- response.multiValueHeaders[header.key] = response.multiValueHeaders[header.key] || [];
306
- response.multiValueHeaders[header.key].push(header.value);
307
- }
308
- resolve(response);
309
- }
310
- else {
311
- return this.handleNoMatchedAction(ctx, resolver);
312
- }
390
+ await this.ensureInitialized();
391
+ ctx = createContext(event, lambdaContext, this.apiPath);
392
+ const resolver = this.getResolver(ctx);
393
+ let response;
394
+ try {
395
+ response = await this.resolveRequest(ctx, resolver);
396
+ }
397
+ catch (err) {
398
+ // A thrown LambderResponse IS the response (res.die.*, throw res.html(...)).
399
+ if (err instanceof LambderResponse) {
400
+ response = err;
401
+ }
402
+ else {
403
+ throw err;
313
404
  }
314
- catch (err) {
315
- const wrappedError = err instanceof Error ? err : new Error("Error: " + String(err));
316
- reject(wrappedError);
405
+ }
406
+ try {
407
+ for (const hook of this.hookList["afterRender"]) {
408
+ const hookResponse = await hook.hookFn(ctx, resolver, response);
409
+ if (hookResponse instanceof Error)
410
+ throw hookResponse;
411
+ response = hookResponse;
317
412
  }
318
- });
413
+ }
414
+ catch (err) {
415
+ if (err instanceof LambderResponse) {
416
+ response = err;
417
+ }
418
+ else {
419
+ throw err;
420
+ }
421
+ }
422
+ // Apply setHeader, addHeader values.
423
+ for (const header of ctx._otherInternal.setHeaderFnAccumulator) {
424
+ response.setHeader(header.key, header.value);
425
+ }
426
+ for (const header of ctx._otherInternal.addHeaderFnAccumulator) {
427
+ response.addHeader(header.key, header.value);
428
+ }
429
+ this.applyCors(ctx, response, false);
430
+ return await finalizeResponse(ctx, response, this.finalizeOptions, ctx._otherInternal.eventFormat);
319
431
  }
320
432
  catch (err) {
321
- if (this.globalErrorHandler) {
322
- const wrappedError = err instanceof Error ? err : new Error("Error: " + String(err));
323
- const responseBuilder = this.getResponseBuilder();
324
- return this.globalErrorHandler(wrappedError, eventRenderContext, responseBuilder, eventRenderContext?._otherInternal.logToApiResponseAccumulator);
433
+ const wrappedError = err instanceof Error ? err : new Error("Error: " + String(err));
434
+ try {
435
+ if (this.globalErrorHandler) {
436
+ const responseBuilder = this.getResponseBuilder(ctx ?? undefined);
437
+ const errorResponse = await this.globalErrorHandler(wrappedError, ctx, responseBuilder, ctx?._otherInternal.logToApiResponseAccumulator);
438
+ return await finalizeResponse(ctx, errorResponse, this.finalizeOptions, ctx?._otherInternal.eventFormat ?? "v1");
439
+ }
440
+ }
441
+ catch (handlerErr) {
442
+ if (handlerErr instanceof LambderResponse) {
443
+ try {
444
+ return await finalizeResponse(ctx, handlerErr, this.finalizeOptions, ctx?._otherInternal.eventFormat ?? "v1");
445
+ }
446
+ catch { /* fall through */ }
447
+ }
325
448
  }
326
- return { statusCode: 500, body: "Internal Server Error.", };
449
+ return { statusCode: 500, multiValueHeaders: {}, body: "Internal Server Error.", isBase64Encoded: false };
327
450
  }
328
451
  }
329
452
  }
453
+ /** Rebuild the query string from the API Gateway event for redirects. */
454
+ const buildQueryString = (ctx) => {
455
+ if (isV2HttpEvent(ctx.event)) {
456
+ return ctx.event.rawQueryString ? `?${ctx.event.rawQueryString}` : "";
457
+ }
458
+ const multi = ctx.event.multiValueQueryStringParameters;
459
+ const single = ctx.event.queryStringParameters;
460
+ const params = new URLSearchParams();
461
+ if (multi) {
462
+ for (const [key, values] of Object.entries(multi)) {
463
+ for (const value of values ?? [])
464
+ params.append(key, value);
465
+ }
466
+ }
467
+ else if (single) {
468
+ for (const [key, value] of Object.entries(single)) {
469
+ if (value !== undefined)
470
+ params.append(key, value);
471
+ }
472
+ }
473
+ const queryString = params.toString();
474
+ return queryString ? `?${queryString}` : "";
475
+ };
@@ -49,8 +49,11 @@ export default class LambderCaller {
49
49
  const token = Cookies.get(this.sessionCsrfCookieKey) || "";
50
50
  const siteHost = window.location.hostname;
51
51
  let data = await fetch(this.apiPath, {
52
- method: 'POST', mode: 'same-origin', cache: 'no-cache',
53
- credentials: 'same-origin', redirect: 'follow', referrerPolicy: 'origin',
52
+ method: 'POST', cache: 'no-cache',
53
+ // Cross-origin API hosts need CORS mode and included credentials.
54
+ mode: this.isCorsEnabled ? 'cors' : 'same-origin',
55
+ credentials: this.isCorsEnabled ? 'include' : 'same-origin',
56
+ redirect: 'follow', referrerPolicy: 'origin',
54
57
  headers: { 'Content-Type': 'application/json', ...(headers || {}) },
55
58
  body: JSON.stringify({ apiName, version, token, siteHost, payload, }),
56
59
  }).then(async (res) => {
@@ -105,7 +108,7 @@ export default class LambderCaller {
105
108
  await this.sessionExpiredHandler();
106
109
  }
107
110
  else if (this.errorHandler) {
108
- await this.errorHandler(new Error("Version Expired; Please refresh;"));
111
+ await this.errorHandler(new Error("Session Expired; Please log in again;"));
109
112
  }
110
113
  return null;
111
114
  }