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.
- package/Readme.md +162 -41
- package/dist/Lambder.d.ts +154 -46
- package/dist/Lambder.js +312 -166
- package/dist/LambderCaller.js +6 -3
- package/dist/LambderContext.d.ts +20 -9
- package/dist/LambderContext.js +57 -17
- package/dist/LambderCors.d.ts +12 -0
- package/dist/LambderCors.js +30 -0
- package/dist/LambderHtml.d.ts +33 -0
- package/dist/LambderHtml.js +62 -0
- package/dist/LambderMSW.d.ts +16 -1
- package/dist/LambderMSW.js +5 -9
- package/dist/LambderPublicFiles.d.ts +47 -0
- package/dist/LambderPublicFiles.js +108 -0
- package/dist/LambderResolver.d.ts +30 -31
- package/dist/LambderResolver.js +29 -43
- package/dist/LambderResponse.d.ts +71 -0
- package/dist/LambderResponse.js +196 -0
- package/dist/LambderResponseBuilder.d.ts +58 -33
- package/dist/LambderResponseBuilder.js +114 -167
- package/dist/LambderRouting.d.ts +23 -0
- package/dist/LambderRouting.js +67 -0
- package/dist/LambderSessionController.d.ts +13 -1
- package/dist/LambderSessionController.js +33 -10
- package/dist/LambderSessionManager.d.ts +3 -1
- package/dist/LambderSessionManager.js +15 -6
- package/dist/LambderTemplatingEngine.d.ts +87 -0
- package/dist/LambderTemplatingEngine.js +156 -0
- package/dist/index.d.ts +14 -2
- package/dist/index.js +10 -1
- package/dist/node-polyfills.d.ts +4 -2
- package/dist/node-polyfills.js +28 -0
- package/package.json +7 -5
- package/.eslintrc.cjs +0 -26
- package/.vscode/settings.json +0 -26
- package/deploy +0 -22
- package/dist/LambderUtils.d.ts +0 -10
- package/dist/LambderUtils.js +0 -70
- package/docs/DYNAMODB_SETUP.md +0 -96
- package/docs/LAMBDER_MSW.md +0 -409
- package/docs/TYPE_SAFE_QUICK_START.md +0 -77
- package/examples/msw-testing-example.ts +0 -280
- package/examples/secure-session-example.ts +0 -207
- package/examples/zod-chained-api-example.ts +0 -63
- package/src/Lambder.ts +0 -430
- package/src/LambderApiContract.ts +0 -20
- package/src/LambderCaller.ts +0 -238
- package/src/LambderContext.ts +0 -78
- package/src/LambderMSW.ts +0 -180
- package/src/LambderResolver.ts +0 -101
- package/src/LambderResponseBuilder.ts +0 -332
- package/src/LambderSessionController.ts +0 -114
- package/src/LambderSessionManager.ts +0 -217
- package/src/LambderUtils.ts +0 -75
- package/src/index.ts +0 -17
- package/src/node-polyfills.ts +0 -27
- package/tests/error-handling.test.ts +0 -585
- package/tests/file-serving.test.ts +0 -194
- package/tests/fixtures/public/index.html +0 -1
- package/tests/fixtures/public/main.css +0 -1
- package/tests/hooks.test.ts +0 -561
- package/tests/output-type-runtime.test.ts +0 -381
- package/tests/redirect.test.ts +0 -88
- package/tests/routes.test.ts +0 -543
- package/tests/session.test.ts +0 -1083
- package/tests/use-plugin.test.ts +0 -460
- 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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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({
|
|
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
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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(".
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
87
|
-
return
|
|
83
|
+
json(data, options) {
|
|
84
|
+
return this.buildResponse(200, "application/json; charset=utf-8", JSON.stringify(data), options);
|
|
88
85
|
}
|
|
89
|
-
|
|
90
|
-
|
|
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.
|
|
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,
|
|
111
|
-
return this.
|
|
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
|
-
|
|
120
|
-
return this.
|
|
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,
|
|
128
|
-
return this.
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
141
|
-
return this.
|
|
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,
|
|
149
|
-
|
|
150
|
-
statusCode: 200,
|
|
151
|
-
|
|
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,
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
},
|
|
182
|
+
}, options);
|
|
214
183
|
}
|
|
215
184
|
;
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
}
|