lambder 2.0.18 → 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
@@ -1,205 +1,174 @@
1
1
  import mimeTypeResolver from "mime-types";
2
2
  import { getFS, getPath } from "./node-polyfills.js";
3
- const convertToMultiHeader = (headers) => Object.fromEntries(Object.entries(headers || {}).map(([k, v]) => [k, Array.isArray(v) ? v : [v]]));
4
- const CORS_HEADERS = convertToMultiHeader({
5
- "Access-Control-Allow-Credentials": "true",
6
- "Access-Control-Allow-Headers": "Origin, X-Requested-With, Content-Type, Accept",
7
- "Access-Control-Allow-Origin": "*",
8
- "Access-Control-Allow-Methods": "OPTIONS,POST",
9
- });
3
+ import { LambderResponse } from "./LambderResponse.js";
4
+ import { LambderTemplatingEngine } from "./LambderTemplatingEngine.js";
5
+ // Compiled templates survive across requests (builder instances are per-request).
6
+ const templateFileCache = new Map();
10
7
  export default class LambderResponseBuilder {
11
- isCorsEnabled;
12
8
  publicPath;
13
9
  apiVersion;
14
- lambderUtils;
15
10
  ctx;
16
- constructor({ isCorsEnabled, publicPath, apiVersion, lambderUtils, ctx }) {
17
- this.isCorsEnabled = isCorsEnabled;
11
+ constructor({ publicPath, apiVersion, ctx }) {
18
12
  this.publicPath = publicPath;
19
13
  this.apiVersion = apiVersion ?? null;
20
- this.lambderUtils = lambderUtils;
21
14
  this.ctx = ctx;
22
15
  }
23
16
  ;
24
- async readPublicFileSync(filePath) {
25
- const fs = await getFS();
26
- const path = await getPath();
27
- if (!fs || !path) {
28
- throw new Error("File system operations are not available in browser environment");
29
- }
30
- const publicPath = path.resolve(this.publicPath);
31
- const normalizedFilePath = filePath.startsWith('/') ? filePath.slice(1) : filePath;
32
- const absolutePath = path.resolve(publicPath, normalizedFilePath);
33
- if (!absolutePath.startsWith(publicPath)) {
34
- return "forbidden-public-path";
17
+ buildResponse(statusCode, contentType, body, options, defaults) {
18
+ const response = new LambderResponse({
19
+ statusCode: options?.statusCode ?? statusCode,
20
+ headers: contentType ? { "Content-Type": contentType } : {},
21
+ body,
22
+ compress: options?.compress ?? defaults?.compress ?? "auto",
23
+ etag: options?.etag ?? defaults?.etag ?? "auto",
24
+ });
25
+ if (options?.headers) {
26
+ for (const [key, value] of Object.entries(options.headers))
27
+ response.setHeader(key, value);
35
28
  }
36
- return await fs.promises.readFile(absolutePath);
29
+ if (options?.cacheControl)
30
+ response.setHeader("Cache-Control", options.cacheControl);
31
+ return response;
37
32
  }
38
- ;
39
- async checkPublicFileExist(filePath) {
33
+ async resolvePublicFilePath(filePath) {
40
34
  const fs = await getFS();
41
35
  const path = await getPath();
42
- if (!fs || !path) {
43
- return false;
44
- }
36
+ if (!fs || !path)
37
+ return null;
45
38
  const publicPath = path.resolve(this.publicPath);
46
39
  const normalizedFilePath = filePath.startsWith('/') ? filePath.slice(1) : filePath;
47
40
  const absolutePath = path.resolve(publicPath, normalizedFilePath);
48
- if (!absolutePath.startsWith(publicPath)) {
49
- return false;
50
- }
41
+ if (absolutePath !== publicPath && !absolutePath.startsWith(publicPath + path.sep))
42
+ return null;
51
43
  try {
52
44
  const stat = await fs.promises.stat(absolutePath);
53
- return stat.isFile();
45
+ return stat.isFile() ? absolutePath : null;
54
46
  }
55
47
  catch {
56
- return false;
48
+ return null;
57
49
  }
58
50
  }
59
51
  ;
60
52
  addHeader(key, value) {
61
53
  if (!this.ctx)
62
54
  throw new Error(".addHeader function is not available within this hook");
63
- else {
64
- this.ctx._otherInternal.addHeaderFnAccumulator.push({ key, value });
65
- }
55
+ this.ctx._otherInternal.addHeaderFnAccumulator.push({ key, value });
66
56
  }
67
57
  ;
68
58
  setHeader(key, value) {
69
59
  if (!this.ctx)
70
60
  throw new Error(".setHeader function is not available within this hook");
71
- else {
72
- this.ctx._otherInternal.addHeaderFnAccumulator = this.ctx._otherInternal.addHeaderFnAccumulator
73
- .filter((header) => header.key !== key);
74
- this.ctx._otherInternal.setHeaderFnAccumulator.push({ key, value });
75
- }
61
+ this.ctx._otherInternal.addHeaderFnAccumulator = this.ctx._otherInternal.addHeaderFnAccumulator
62
+ .filter((header) => header.key !== key);
63
+ this.ctx._otherInternal.setHeaderFnAccumulator.push({ key, value });
76
64
  }
77
65
  ;
78
66
  logToApiResponse(input) {
79
67
  if (!this.ctx)
80
- throw new Error(".logToResponse function is not available within this hook");
81
- else {
82
- this.ctx._otherInternal.logToApiResponseAccumulator.push(input);
83
- }
68
+ throw new Error(".logToApiResponse function is not available within this hook");
69
+ this.ctx._otherInternal.logToApiResponseAccumulator.push(input);
70
+ }
71
+ ;
72
+ raw(init) {
73
+ return new LambderResponse({
74
+ statusCode: init.statusCode,
75
+ headers: init.headers ?? init.multiValueHeaders,
76
+ body: init.body,
77
+ isBodyBase64: init.isBase64Encoded ?? false,
78
+ compress: init.compress ?? (init.isBase64Encoded ? false : "auto"),
79
+ etag: init.etag ?? "auto",
80
+ });
84
81
  }
85
82
  ;
86
- raw(param) {
87
- return param;
83
+ json(data, options) {
84
+ return this.buildResponse(200, "application/json; charset=utf-8", JSON.stringify(data), options);
88
85
  }
89
- ;
90
- json(data, headers) {
91
- return this.raw({
92
- statusCode: 200,
93
- multiValueHeaders: {
94
- "Content-Type": ["application/json; charset=utf-8"],
95
- ...(this.isCorsEnabled ? CORS_HEADERS : {}),
96
- ...convertToMultiHeader(headers)
97
- },
98
- body: JSON.stringify(data),
99
- });
86
+ text(data, options) {
87
+ return this.buildResponse(200, "text/plain; charset=utf-8", data, options);
100
88
  }
101
- xml(data) {
102
- return this.raw({
103
- statusCode: 200,
104
- isBase64Encoded: true,
105
- multiValueHeaders: { "Content-Type": ["application/xml; charset=utf-8"] },
106
- body: Buffer.from(data).toString("base64"),
107
- });
89
+ xml(data, options) {
90
+ return this.buildResponse(200, "application/xml; charset=utf-8", String(data), options);
108
91
  }
109
92
  ;
110
- html(data, headers) {
111
- return this.raw({
112
- statusCode: 200,
113
- isBase64Encoded: true,
114
- multiValueHeaders: { "Content-Type": ["text/html; charset=utf-8"], ...convertToMultiHeader(headers) },
115
- body: Buffer.from(data).toString("base64"),
116
- });
93
+ html(data, options) {
94
+ return this.buildResponse(200, "text/html; charset=utf-8", String(data), options);
117
95
  }
118
96
  ;
119
- redirect(url, statusCode = 302, headers) {
120
- return this.raw({
121
- statusCode: statusCode,
122
- multiValueHeaders: { "Location": [url], ...convertToMultiHeader(headers) },
123
- body: null
124
- });
97
+ status(statusCode, body, options) {
98
+ return this.buildResponse(statusCode, "text/html; charset=utf-8", body ?? "", options);
125
99
  }
126
100
  ;
127
- status404(data, headers) {
128
- return this.raw({
129
- statusCode: 404,
130
- isBase64Encoded: true,
131
- multiValueHeaders: { "Content-Type": ["text/html; charset=utf-8"], ...convertToMultiHeader(headers) },
132
- body: Buffer.from(data).toString("base64"),
133
- });
101
+ status404(data, options) {
102
+ return this.buildResponse(404, "text/html; charset=utf-8", data, options);
134
103
  }
135
104
  ;
136
- versionExpired(headers) {
137
- return this.api(null, { versionExpired: true }, headers);
105
+ redirect(url, statusCode = 302, options) {
106
+ const response = this.buildResponse(statusCode, null, null, options);
107
+ response.setHeader("Location", url);
108
+ return response;
138
109
  }
139
110
  ;
140
- cors() {
141
- return this.raw({
142
- statusCode: 200,
143
- multiValueHeaders: this.isCorsEnabled ? CORS_HEADERS : {},
144
- body: JSON.stringify(""),
145
- });
111
+ versionExpired(options) {
112
+ return this.api(null, { versionExpired: true }, options);
146
113
  }
147
114
  ;
148
- fileBase64(fileBase64, mimeType, headers) {
149
- return this.raw({
150
- statusCode: 200,
151
- isBase64Encoded: true,
152
- multiValueHeaders: { "Content-Type": [mimeType || "application/octet-stream"], ...convertToMultiHeader(headers) },
115
+ fileBase64(fileBase64, mimeType, options) {
116
+ const response = new LambderResponse({
117
+ statusCode: options?.statusCode ?? 200,
118
+ headers: { "Content-Type": mimeType || "application/octet-stream" },
153
119
  body: fileBase64,
120
+ isBodyBase64: true,
121
+ compress: false,
122
+ etag: options?.etag ?? "auto",
154
123
  });
124
+ if (options?.headers) {
125
+ for (const [key, value] of Object.entries(options.headers))
126
+ response.setHeader(key, value);
127
+ }
128
+ if (options?.cacheControl)
129
+ response.setHeader("Cache-Control", options.cacheControl);
130
+ return response;
155
131
  }
156
132
  ;
157
- async file(filePath, headers, fallbackFilePath) {
158
- const doesFileExist = await this.checkPublicFileExist(filePath);
159
- if (!doesFileExist) {
160
- if (fallbackFilePath) {
161
- const doesFallbackExist = await this.checkPublicFileExist(fallbackFilePath);
162
- if (doesFallbackExist) {
163
- return await this.file(fallbackFilePath, headers);
164
- }
165
- }
166
- return this.json({ error: "File not found: " + filePath });
133
+ async file(filePath, options) {
134
+ let resolvedPath = await this.resolvePublicFilePath(filePath);
135
+ let effectivePath = filePath;
136
+ if (!resolvedPath && options?.fallback) {
137
+ resolvedPath = await this.resolvePublicFilePath(options.fallback);
138
+ effectivePath = options.fallback;
167
139
  }
168
- const mimeType = mimeTypeResolver.lookup(filePath);
169
- const body = await this.readPublicFileSync(filePath);
170
- if (body === "forbidden-public-path") {
171
- throw new Error("Forbidden public path: " + filePath);
140
+ if (!resolvedPath) {
141
+ return this.status404("File not found", { etag: false });
172
142
  }
173
- const bodyBuffer = Buffer.isBuffer(body) ? body : Buffer.from(body);
174
- const bodyBase64 = bodyBuffer.toString("base64");
175
- console.log("bodyBase64.length", bodyBase64.length);
176
- return this.fileBase64(bodyBase64, mimeType || "application/octet-stream", headers);
177
- }
178
- ;
179
- async ejsTemplate(template, pageData, headers) {
180
- const renderedResult = await this.lambderUtils.renderEjs(template, pageData);
181
- return this.raw({
182
- statusCode: 200,
183
- isBase64Encoded: true,
184
- multiValueHeaders: { "Content-Type": ["text/html; charset=utf-8"], ...convertToMultiHeader(headers) },
185
- body: Buffer.from(renderedResult).toString("base64"),
186
- });
187
- }
188
- ;
189
- async ejsFile(filePath, pageData, headers) {
190
- const renderedResult = await this.lambderUtils.renderEjsFile(filePath, pageData);
191
- return this.raw({
192
- statusCode: 200,
193
- isBase64Encoded: true,
194
- multiValueHeaders: { "Content-Type": ["text/html; charset=utf-8"], ...convertToMultiHeader(headers) },
195
- body: Buffer.from(renderedResult).toString("base64"),
196
- });
143
+ const fs = await getFS();
144
+ if (!fs)
145
+ return this.status404("File not found", { etag: false });
146
+ const body = await fs.promises.readFile(resolvedPath);
147
+ const mimeType = mimeTypeResolver.lookup(effectivePath) || "application/octet-stream";
148
+ return this.buildResponse(200, mimeType, body, options);
149
+ }
150
+ ;
151
+ /**
152
+ * Render an HTML file under publicPath through LambderTemplatingEngine
153
+ * (comment-based slots/conditionals) and return it as an HTML response.
154
+ * The compiled template is cached across warm invocations; a missing file
155
+ * throws (it is a server-side configuration error, not a client 404).
156
+ * Set htmlVirtualSlots to expose "title"/"head" slots on marker-less files.
157
+ */
158
+ async templateFile(filePath, data, options) {
159
+ const resolvedPath = await this.resolvePublicFilePath(filePath);
160
+ if (!resolvedPath)
161
+ throw new Error(`templateFile: file not found under publicPath: ${filePath}`);
162
+ const cacheKey = `${resolvedPath}|${options?.htmlVirtualSlots ? "v" : ""}`;
163
+ let template = templateFileCache.get(cacheKey);
164
+ if (!template) {
165
+ template = await LambderTemplatingEngine.fromFile(resolvedPath, { htmlVirtualSlots: options?.htmlVirtualSlots });
166
+ templateFileCache.set(cacheKey, template);
167
+ }
168
+ return this.buildResponse(200, "text/html; charset=utf-8", template.render(data), options);
197
169
  }
198
170
  ;
199
- api(payload, { versionExpired, sessionExpired, notAuthorized, message, errorMessage, logList, } = {
200
- versionExpired: undefined, sessionExpired: undefined, notAuthorized: undefined,
201
- message: null, errorMessage: null, logList: undefined
202
- }, headers) {
171
+ api(payload, { versionExpired, sessionExpired, notAuthorized, message, errorMessage, logList, } = {}, options) {
203
172
  const finalLogList = logList || this.ctx?._otherInternal?.logToApiResponseAccumulator;
204
173
  return this.json({
205
174
  apiVersion: this.apiVersion,
@@ -210,34 +179,12 @@ export default class LambderResponseBuilder {
210
179
  ...(message ? { message } : {}),
211
180
  ...(errorMessage ? { errorMessage } : {}),
212
181
  ...(finalLogList?.length ? { logList: finalLogList } : {}),
213
- }, headers);
182
+ }, options);
214
183
  }
215
184
  ;
216
- apiBinary(payload, { versionExpired, sessionExpired, notAuthorized, message, errorMessage, logList, } = {
217
- versionExpired: undefined, sessionExpired: undefined, notAuthorized: undefined,
218
- message: null, errorMessage: null, logList: undefined
219
- }, headers) {
220
- const finalLogList = logList || this.ctx?._otherInternal?.logToApiResponseAccumulator;
221
- const result = {
222
- apiVersion: this.apiVersion,
223
- payload,
224
- ...(versionExpired ? { versionExpired } : {}),
225
- ...(sessionExpired ? { sessionExpired } : {}),
226
- ...(notAuthorized ? { notAuthorized } : {}),
227
- ...(message ? { message } : {}),
228
- ...(errorMessage ? { errorMessage } : {}),
229
- ...(finalLogList?.length ? { logList: finalLogList } : {}),
230
- };
231
- return this.raw({
232
- statusCode: 200,
233
- isBase64Encoded: true,
234
- multiValueHeaders: {
235
- "Content-Type": ["application/lambder-json-stream"],
236
- "Content-Encoding": ["gzip"],
237
- ...convertToMultiHeader(headers)
238
- },
239
- body: Buffer.from(JSON.stringify(result)).toString("base64"),
240
- });
185
+ /** Same as api() but forces gzip compression of the response body. */
186
+ apiBinary(payload, config = {}, options) {
187
+ return this.api(payload, config, { ...options, compress: true });
241
188
  }
242
189
  ;
243
190
  }
@@ -0,0 +1,23 @@
1
+ import type { LambderRenderContext } from "./LambderContext.js";
2
+ type Path = `/${string}`;
3
+ type CutAt<S extends string, D extends string> = S extends `${infer Head}${D}${string}` ? Head : S;
4
+ type ParamNameFrom<S extends string> = CutAt<CutAt<CutAt<CutAt<CutAt<CutAt<CutAt<S, "/">, ".">, "(">, "?">, "+">, "*">, "-">;
5
+ type PathParamNames<T extends string> = T extends `${string}:${infer Rest}` ? (ParamNameFrom<Rest> extends "" ? never : ParamNameFrom<Rest>) | PathParamNames<Rest> : never;
6
+ export type PathParamsOf<T extends string> = string extends T ? Record<string, string> : T extends `${string}(${string}` ? Record<string, string> : [PathParamNames<T>] extends [never] ? Record<string, string> : {
7
+ [K in PathParamNames<T>]: string;
8
+ };
9
+ export type ConditionFunction = (ctx: LambderRenderContext) => boolean;
10
+ /** Structured route matcher: all provided fields must match. */
11
+ export type LambderRouteMatcher = {
12
+ path?: Path | RegExp;
13
+ host?: string | RegExp;
14
+ /** One or more HTTP methods; HEAD requests also match GET routes. */
15
+ method?: string | string[];
16
+ condition?: ConditionFunction;
17
+ };
18
+ export type RouteCondition = Path | RegExp | ConditionFunction | LambderRouteMatcher;
19
+ /** Returns matched path params, or false when the route doesn't match. */
20
+ export type CompiledMatcher = (ctx: LambderRenderContext) => false | Record<string, string>;
21
+ /** Compile a route condition once at registration time. */
22
+ export declare const compileRouteMatcher: (condition: RouteCondition) => CompiledMatcher;
23
+ export {};
@@ -0,0 +1,67 @@
1
+ import { match as pathToRegexpMatch } from "path-to-regexp";
2
+ const compilePathMatcher = (path) => {
3
+ if (typeof path === "string") {
4
+ const matchFn = pathToRegexpMatch(path, { decode: decodeURIComponent });
5
+ return (requestPath) => {
6
+ const result = matchFn(requestPath);
7
+ if (!result)
8
+ return false;
9
+ const params = {};
10
+ for (const [key, value] of Object.entries(result.params ?? {})) {
11
+ params[key] = Array.isArray(value) ? value.join("/") : String(value);
12
+ }
13
+ return params;
14
+ };
15
+ }
16
+ return (requestPath) => {
17
+ const matched = requestPath.match(path);
18
+ if (!matched)
19
+ return false;
20
+ if (matched.groups)
21
+ return { ...matched.groups };
22
+ const params = {};
23
+ matched.forEach((value, index) => {
24
+ if (value !== undefined)
25
+ params[String(index)] = value;
26
+ });
27
+ return params;
28
+ };
29
+ };
30
+ /** Compile a route condition once at registration time. */
31
+ export const compileRouteMatcher = (condition) => {
32
+ if (typeof condition === "string" || condition instanceof RegExp) {
33
+ const pathMatcher = compilePathMatcher(condition);
34
+ return (ctx) => pathMatcher(ctx.path);
35
+ }
36
+ if (typeof condition === "function") {
37
+ return (ctx) => condition(ctx) ? {} : false;
38
+ }
39
+ const matcher = condition;
40
+ const pathMatcher = matcher.path !== undefined ? compilePathMatcher(matcher.path) : null;
41
+ const methods = matcher.method !== undefined
42
+ ? new Set((Array.isArray(matcher.method) ? matcher.method : [matcher.method]).map((m) => m.toUpperCase()))
43
+ : null;
44
+ return (ctx) => {
45
+ if (methods) {
46
+ let requestMethod = ctx.method.toUpperCase();
47
+ if (requestMethod === "HEAD" && !methods.has("HEAD"))
48
+ requestMethod = "GET";
49
+ if (!methods.has(requestMethod))
50
+ return false;
51
+ }
52
+ if (matcher.host !== undefined) {
53
+ if (typeof matcher.host === "string") {
54
+ if (ctx.host.toLowerCase() !== matcher.host.toLowerCase())
55
+ return false;
56
+ }
57
+ else if (!matcher.host.test(ctx.host)) {
58
+ return false;
59
+ }
60
+ }
61
+ if (matcher.condition && !matcher.condition(ctx))
62
+ return false;
63
+ if (pathMatcher)
64
+ return pathMatcher(ctx.path);
65
+ return {};
66
+ };
67
+ };
@@ -1,17 +1,29 @@
1
1
  import { LambderRenderContext, LambderSessionRenderContext } from "./LambderContext.js";
2
2
  import type LambderSessionManager from "./LambderSessionManager.js";
3
3
  import type { LambderSessionContext } from "./LambderSessionManager.js";
4
+ export type LambderSessionCookieOptions = {
5
+ /** e.g. ".example.com" to share sessions across subdomains. */
6
+ domain?: string;
7
+ path?: string;
8
+ sameSite?: "Strict" | "Lax" | "None";
9
+ secure?: boolean;
10
+ };
4
11
  export default class LambderSessionController<TSessionData = any> {
5
12
  lambderSessionManager: LambderSessionManager;
6
13
  sessionTokenCookieKey: string;
7
14
  sessionCsrfCookieKey: string;
15
+ cookieOptions: LambderSessionCookieOptions;
8
16
  ctx: LambderRenderContext<any> | LambderSessionRenderContext<any, TSessionData>;
9
- constructor({ lambderSessionManager, sessionTokenCookieKey, sessionCsrfCookieKey, ctx, }: {
17
+ constructor({ lambderSessionManager, sessionTokenCookieKey, sessionCsrfCookieKey, cookieOptions, ctx, }: {
10
18
  lambderSessionManager: LambderSessionManager;
11
19
  sessionTokenCookieKey: string;
12
20
  sessionCsrfCookieKey: string;
21
+ cookieOptions?: LambderSessionCookieOptions;
13
22
  ctx: LambderRenderContext<any> | LambderSessionRenderContext<any, TSessionData>;
14
23
  });
24
+ private buildCookie;
25
+ private setSessionCookies;
26
+ private clearSessionCookies;
15
27
  private areRequestSessionTokensValid;
16
28
  createSession(sessionKey: string, data?: TSessionData, ttlInSeconds?: number): Promise<LambderSessionContext<TSessionData>>;
17
29
  regenerateSession(): Promise<LambderSessionContext<TSessionData>>;
@@ -2,17 +2,44 @@ export default class LambderSessionController {
2
2
  lambderSessionManager;
3
3
  sessionTokenCookieKey;
4
4
  sessionCsrfCookieKey;
5
+ cookieOptions;
5
6
  ctx; // Internal context with mutable session property
6
- constructor({ lambderSessionManager, sessionTokenCookieKey, sessionCsrfCookieKey, ctx, }) {
7
+ constructor({ lambderSessionManager, sessionTokenCookieKey, sessionCsrfCookieKey, cookieOptions, ctx, }) {
7
8
  this.lambderSessionManager = lambderSessionManager;
8
9
  this.sessionTokenCookieKey = sessionTokenCookieKey;
9
10
  this.sessionCsrfCookieKey = sessionCsrfCookieKey;
11
+ this.cookieOptions = cookieOptions ?? {};
10
12
  this.ctx = ctx;
11
13
  }
12
14
  ;
15
+ buildCookie(key, value, expiresAtMs, httpOnly) {
16
+ const { domain, path = "/", sameSite = "Lax", secure = true } = this.cookieOptions;
17
+ const parts = [
18
+ `${key}=${value}`,
19
+ `Expires=${new Date(expiresAtMs).toUTCString()}`,
20
+ `Path=${path}`,
21
+ ...(domain ? [`Domain=${domain}`] : []),
22
+ ...(httpOnly ? ["HttpOnly"] : []),
23
+ `SameSite=${sameSite}`,
24
+ ...(secure ? ["Secure"] : []),
25
+ ];
26
+ return parts.join("; ");
27
+ }
28
+ ;
29
+ setSessionCookies(session) {
30
+ this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: this.buildCookie(this.sessionTokenCookieKey, session.sessionToken, session.expiresAt * 1000, true) });
31
+ this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: this.buildCookie(this.sessionCsrfCookieKey, session.csrfToken, session.expiresAt * 1000, false) });
32
+ }
33
+ ;
34
+ clearSessionCookies() {
35
+ const expired = Date.now() - 100000;
36
+ this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: this.buildCookie(this.sessionTokenCookieKey, "0", expired, true) });
37
+ this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: this.buildCookie(this.sessionCsrfCookieKey, "0", expired, false) });
38
+ }
39
+ ;
13
40
  areRequestSessionTokensValid() {
14
41
  const sessionToken = this.ctx.cookie?.[this.sessionTokenCookieKey];
15
- const isSessionTokenValid = sessionToken && sessionToken?.split(":")?.length === 2;
42
+ const isSessionTokenValid = !!sessionToken && sessionToken.split(":").length === 2;
16
43
  if (this.ctx._otherInternal.isApiCall) {
17
44
  const csrfToken = this.ctx.post?.token;
18
45
  const isCsrfTokenValid = typeof csrfToken === "string" && csrfToken.length > 0;
@@ -25,8 +52,7 @@ export default class LambderSessionController {
25
52
  ;
26
53
  async createSession(sessionKey, data, ttlInSeconds) {
27
54
  const session = await this.lambderSessionManager.createSession(sessionKey, data, ttlInSeconds);
28
- this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: `${this.sessionTokenCookieKey}=${session.sessionToken}; Expires=${new Date(session.expiresAt * 1000).toUTCString()}; Path=/; HttpOnly; SameSite=Lax; Secure` });
29
- this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: `${this.sessionCsrfCookieKey}=${session.csrfToken}; Expires=${new Date(session.expiresAt * 1000).toUTCString()}; Path=/; SameSite=Lax; Secure` });
55
+ this.setSessionCookies(session);
30
56
  this.ctx.session = session;
31
57
  return this.ctx.session;
32
58
  }
@@ -35,8 +61,7 @@ export default class LambderSessionController {
35
61
  if (!this.ctx.session)
36
62
  throw new Error("Session not found.");
37
63
  const newSession = await this.lambderSessionManager.regenerateSession(this.ctx.session);
38
- this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: `${this.sessionTokenCookieKey}=${newSession.sessionToken}; Expires=${new Date(newSession.expiresAt * 1000).toUTCString()}; Path=/; HttpOnly; SameSite=Lax; Secure` });
39
- this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: `${this.sessionCsrfCookieKey}=${newSession.csrfToken}; Expires=${new Date(newSession.expiresAt * 1000).toUTCString()}; Path=/; SameSite=Lax; Secure` });
64
+ this.setSessionCookies(newSession);
40
65
  this.ctx.session = newSession;
41
66
  return this.ctx.session;
42
67
  }
@@ -89,8 +114,7 @@ export default class LambderSessionController {
89
114
  if (!this.ctx.session)
90
115
  throw new Error("Session not found.");
91
116
  await this.lambderSessionManager.deleteSession(this.ctx.session);
92
- this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: `${this.sessionTokenCookieKey}=0; Expires=${new Date(Date.now() - 100000).toUTCString()}; Path=/; HttpOnly; SameSite=Lax; Secure` });
93
- this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: `${this.sessionCsrfCookieKey}=0; Expires=${new Date(Date.now() - 100000).toUTCString()}; Path=/; SameSite=Lax; Secure` });
117
+ this.clearSessionCookies();
94
118
  this.ctx.session = null;
95
119
  }
96
120
  ;
@@ -98,8 +122,7 @@ export default class LambderSessionController {
98
122
  if (!this.ctx.session)
99
123
  throw new Error("Session not found.");
100
124
  await this.lambderSessionManager.deleteSessionAll(this.ctx.session);
101
- this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: `${this.sessionTokenCookieKey}=0; Expires=${new Date(Date.now() - 100000).toUTCString()}; Path=/; HttpOnly; SameSite=Lax; Secure` });
102
- this.ctx._otherInternal.addHeaderFnAccumulator.push({ key: "Set-Cookie", value: `${this.sessionCsrfCookieKey}=0; Expires=${new Date(Date.now() - 100000).toUTCString()}; Path=/; SameSite=Lax; Secure` });
125
+ this.clearSessionCookies();
103
126
  this.ctx.session = null;
104
127
  }
105
128
  ;
@@ -16,13 +16,15 @@ export default class LambderSessionManager {
16
16
  private sortKey;
17
17
  private ddbDocumentClient;
18
18
  private enableSlidingExpiration;
19
- constructor({ tableName, tableRegion, partitionKey, sortKey, sessionSalt, enableSlidingExpiration, }: {
19
+ private slidingWriteIntervalSeconds;
20
+ constructor({ tableName, tableRegion, partitionKey, sortKey, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, }: {
20
21
  tableName: string;
21
22
  tableRegion: string;
22
23
  partitionKey: string;
23
24
  sortKey: string;
24
25
  sessionSalt: string;
25
26
  enableSlidingExpiration?: boolean;
27
+ slidingWriteIntervalSeconds?: number;
26
28
  });
27
29
  private sessionUserKeyHasher;
28
30
  private constantTimeCompare;
@@ -8,12 +8,14 @@ export default class LambderSessionManager {
8
8
  sortKey;
9
9
  ddbDocumentClient;
10
10
  enableSlidingExpiration;
11
- constructor({ tableName, tableRegion, partitionKey, sortKey, sessionSalt, enableSlidingExpiration = true, }) {
11
+ slidingWriteIntervalSeconds;
12
+ constructor({ tableName, tableRegion, partitionKey, sortKey, sessionSalt, enableSlidingExpiration = true, slidingWriteIntervalSeconds, }) {
12
13
  this.tableName = tableName;
13
14
  this.sessionSalt = sessionSalt;
14
15
  this.partitionKey = partitionKey;
15
16
  this.sortKey = sortKey;
16
17
  this.enableSlidingExpiration = enableSlidingExpiration;
18
+ this.slidingWriteIntervalSeconds = slidingWriteIntervalSeconds ?? null;
17
19
  const ddbClient = new DynamoDBClient({ region: tableRegion });
18
20
  this.ddbDocumentClient = DynamoDBDocumentClient.from(ddbClient);
19
21
  }
@@ -124,12 +126,19 @@ export default class LambderSessionManager {
124
126
  return null;
125
127
  if (!session.expiresAt || session.expiresAt < Date.now() / 1000)
126
128
  return null;
127
- // Update last accessed time if sliding expiration is enabled
129
+ // Update last accessed time if sliding expiration is enabled.
130
+ // Throttled: skip the DynamoDB write when the session was refreshed
131
+ // recently, to avoid a write on every request.
128
132
  if (this.enableSlidingExpiration) {
129
- session.lastAccessedAt = Math.floor(Date.now() / 1000);
130
- session.expiresAt = session.lastAccessedAt + session.ttlInSeconds;
131
- // Wait for the update to ensure it persists before Lambda freezes
132
- await this.ddbPutItem(session).catch(() => { });
133
+ const now = Math.floor(Date.now() / 1000);
134
+ const minInterval = this.slidingWriteIntervalSeconds
135
+ ?? Math.max(60, Math.floor((session.ttlInSeconds || 0) * 0.05));
136
+ if (now - (session.lastAccessedAt || 0) >= minInterval) {
137
+ session.lastAccessedAt = now;
138
+ session.expiresAt = now + session.ttlInSeconds;
139
+ // Wait for the update to ensure it persists before Lambda freezes
140
+ await this.ddbPutItem(session).catch(() => { });
141
+ }
133
142
  }
134
143
  return session;
135
144
  }