@stacksjs/router 0.70.87 → 0.70.90
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/dist/action-paths.js +0 -0
- package/dist/api-shape.js +11 -0
- package/dist/encrypted-session-store.js +73 -0
- package/dist/error-handler.js +310 -0
- package/dist/index.js +37 -14
- package/dist/middleware.js +23 -0
- package/dist/path-sanitize.js +37 -0
- package/dist/rate-limit.js +73 -0
- package/dist/request-augmentation.js +0 -0
- package/dist/request-context.js +78 -0
- package/dist/response.d.ts +31 -0
- package/dist/response.js +1 -0
- package/dist/route-loader.js +72 -0
- package/dist/route-types.js +0 -0
- package/dist/security-headers.js +46 -0
- package/dist/session-factory.js +26 -0
- package/dist/signed-url.js +76 -0
- package/dist/stacks-router.js +1425 -0
- package/package.json +11 -11
|
File without changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const JSON_CONTENT_TYPE = /^application\/(?:json|.+\+json)(?:;|$)/i;
|
|
2
|
+
export function isApiRequest(req) {
|
|
3
|
+
const headers = req.headers, contentType = headers.get("content-type") || "";
|
|
4
|
+
if (JSON_CONTENT_TYPE.test(contentType))
|
|
5
|
+
return !0;
|
|
6
|
+
if (headers.get("sec-fetch-dest") === "document")
|
|
7
|
+
return !1;
|
|
8
|
+
if ((headers.get("accept") || "").includes("text/html"))
|
|
9
|
+
return !1;
|
|
10
|
+
return !0;
|
|
11
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { decrypt, encrypt } from "@stacksjs/security";
|
|
2
|
+
|
|
3
|
+
export class EncryptedSessionStore {
|
|
4
|
+
inner;
|
|
5
|
+
opts;
|
|
6
|
+
constructor(inner, opts = {}) {
|
|
7
|
+
this.inner = inner;
|
|
8
|
+
this.opts = opts;
|
|
9
|
+
}
|
|
10
|
+
async set(sid, session, ttl) {
|
|
11
|
+
const envelope = await this.wrap(sid, session);
|
|
12
|
+
await this.inner.set(sid, envelope, ttl);
|
|
13
|
+
}
|
|
14
|
+
async touch(sid, session, ttl) {
|
|
15
|
+
const envelope = await this.wrap(sid, session);
|
|
16
|
+
if (this.inner.touch)
|
|
17
|
+
await this.inner.touch(sid, envelope, ttl);
|
|
18
|
+
else
|
|
19
|
+
await this.inner.set(sid, envelope, ttl);
|
|
20
|
+
}
|
|
21
|
+
async get(sid) {
|
|
22
|
+
const stored = await this.inner.get(sid);
|
|
23
|
+
if (!stored)
|
|
24
|
+
return null;
|
|
25
|
+
return this.unwrap(stored);
|
|
26
|
+
}
|
|
27
|
+
destroy(sid) {
|
|
28
|
+
return this.inner.destroy(sid);
|
|
29
|
+
}
|
|
30
|
+
async all() {
|
|
31
|
+
const wrapped = await this.inner.all?.() ?? {}, out = {};
|
|
32
|
+
for (const [sid, envelope] of Object.entries(wrapped)) {
|
|
33
|
+
const decrypted = await this.unwrap(envelope);
|
|
34
|
+
if (decrypted)
|
|
35
|
+
out[sid] = decrypted;
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
async length() {
|
|
40
|
+
if (this.inner.length)
|
|
41
|
+
return this.inner.length();
|
|
42
|
+
return Object.keys(await this.all()).length;
|
|
43
|
+
}
|
|
44
|
+
async clear() {
|
|
45
|
+
if (this.inner.clear)
|
|
46
|
+
return this.inner.clear();
|
|
47
|
+
const sessions = await this.inner.all?.() ?? {};
|
|
48
|
+
await Promise.all(Object.keys(sessions).map((sid) => this.inner.destroy(sid)));
|
|
49
|
+
}
|
|
50
|
+
async wrap(sid, session) {
|
|
51
|
+
const { id, ...rest } = session, ciphertext = await encrypt(JSON.stringify(rest), this.opts.appKey);
|
|
52
|
+
return {
|
|
53
|
+
_enc: !0,
|
|
54
|
+
id: id ?? sid,
|
|
55
|
+
data: ciphertext
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
async unwrap(stored) {
|
|
59
|
+
if (!stored || typeof stored !== "object")
|
|
60
|
+
return null;
|
|
61
|
+
const candidate = stored;
|
|
62
|
+
if (candidate._enc === !0 && typeof candidate.data === "string")
|
|
63
|
+
try {
|
|
64
|
+
const decrypted = await decrypt(candidate.data, this.opts.appKey), parsed = JSON.parse(decrypted);
|
|
65
|
+
if (candidate.id !== void 0)
|
|
66
|
+
parsed.id = candidate.id;
|
|
67
|
+
return parsed;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return candidate;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { log } from "@stacksjs/logging";
|
|
3
|
+
import {
|
|
4
|
+
createErrorHandler,
|
|
5
|
+
renderProductionErrorPage
|
|
6
|
+
} from "@stacksjs/error-handling";
|
|
7
|
+
import { isApiRequest } from "./api-shape";
|
|
8
|
+
import { getCurrentRequest } from "./request-context";
|
|
9
|
+
function buildErrorJson(opts) {
|
|
10
|
+
const body = {
|
|
11
|
+
error: opts.error,
|
|
12
|
+
message: opts.message,
|
|
13
|
+
status: opts.status,
|
|
14
|
+
timestamp: new Date().toISOString()
|
|
15
|
+
};
|
|
16
|
+
if (opts.details)
|
|
17
|
+
body.details = opts.details;
|
|
18
|
+
return JSON.stringify(body);
|
|
19
|
+
}
|
|
20
|
+
function isDebugAllowed() {
|
|
21
|
+
const appEnv = (process.env.APP_ENV ?? "").toLowerCase();
|
|
22
|
+
if (appEnv === "development")
|
|
23
|
+
return !0;
|
|
24
|
+
if (!appEnv && process.env.NODE_ENV === "development")
|
|
25
|
+
return !0;
|
|
26
|
+
return !1;
|
|
27
|
+
}
|
|
28
|
+
function getJsonHeaders() {
|
|
29
|
+
return { "Content-Type": "application/json" };
|
|
30
|
+
}
|
|
31
|
+
function getJsonHeadersFull() {
|
|
32
|
+
return getJsonHeaders();
|
|
33
|
+
}
|
|
34
|
+
const MAX_QUERIES = 50, N1_THRESHOLD = 5;
|
|
35
|
+
function newQueryTrack() {
|
|
36
|
+
return {
|
|
37
|
+
buffer: Array(MAX_QUERIES).fill(null),
|
|
38
|
+
writeIndex: 0,
|
|
39
|
+
count: 0,
|
|
40
|
+
shapeCounts: new Map,
|
|
41
|
+
n1Warned: new Set
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const REQUEST_QUERY_TRACK_KEY = Symbol.for("stacks.queryTracking");
|
|
45
|
+
let fallbackTrack = newQueryTrack();
|
|
46
|
+
function getQueryTrack() {
|
|
47
|
+
const req = getCurrentRequest();
|
|
48
|
+
if (!req)
|
|
49
|
+
return fallbackTrack;
|
|
50
|
+
let track = req[REQUEST_QUERY_TRACK_KEY];
|
|
51
|
+
if (!track) {
|
|
52
|
+
track = newQueryTrack();
|
|
53
|
+
req[REQUEST_QUERY_TRACK_KEY] = track;
|
|
54
|
+
}
|
|
55
|
+
return track;
|
|
56
|
+
}
|
|
57
|
+
function normalizeQueryShape(query) {
|
|
58
|
+
return query.replace(/'(?:[^']|'')*'/g, "?").replace(/"(?:[^"]|"")*"/g, "?").replace(/\b\d+(?:\.\d+)?\b/g, "?").replace(/IN\s*\([^)]*\)/gi, "IN (?)").replace(/\s+/g, " ").trim().toUpperCase();
|
|
59
|
+
}
|
|
60
|
+
export function trackQuery(query, time, connection) {
|
|
61
|
+
const track = getQueryTrack();
|
|
62
|
+
track.buffer[track.writeIndex] = { query, time, connection };
|
|
63
|
+
track.writeIndex = (track.writeIndex + 1) % MAX_QUERIES;
|
|
64
|
+
if (track.count < MAX_QUERIES)
|
|
65
|
+
track.count++;
|
|
66
|
+
if (!isDebugAllowed())
|
|
67
|
+
return;
|
|
68
|
+
const shape = normalizeQueryShape(query);
|
|
69
|
+
if (shape.startsWith("INSERT INTO QUERY_LOGS") || shape.startsWith("EXPLAIN"))
|
|
70
|
+
return;
|
|
71
|
+
const next = (track.shapeCounts.get(shape) ?? 0) + 1;
|
|
72
|
+
track.shapeCounts.set(shape, next);
|
|
73
|
+
if (next === N1_THRESHOLD + 1 && !track.n1Warned.has(shape)) {
|
|
74
|
+
track.n1Warned.add(shape);
|
|
75
|
+
import("@stacksjs/logging").then(({ log }) => {
|
|
76
|
+
log.warn(`[orm] Possible N+1 \u2014 query shape ran ${next}\xD7 in this request:
|
|
77
|
+
${shape}
|
|
78
|
+
Hint: load related rows with .with('relation') or eager-load via includes() before iterating.`);
|
|
79
|
+
}).catch(() => {});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function getRecentQueries() {
|
|
83
|
+
const track = getQueryTrack();
|
|
84
|
+
if (track.count === 0)
|
|
85
|
+
return [];
|
|
86
|
+
const result = [], start = track.count < MAX_QUERIES ? 0 : track.writeIndex;
|
|
87
|
+
for (let i = 0;i < track.count; i++) {
|
|
88
|
+
const entry = track.buffer[(start + i) % MAX_QUERIES];
|
|
89
|
+
if (entry)
|
|
90
|
+
result.push(entry);
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
export function getQueryShapeCounts() {
|
|
95
|
+
return new Map(getQueryTrack().shapeCounts);
|
|
96
|
+
}
|
|
97
|
+
export function clearTrackedQueries() {
|
|
98
|
+
const req = getCurrentRequest();
|
|
99
|
+
if (req && req[REQUEST_QUERY_TRACK_KEY]) {
|
|
100
|
+
req[REQUEST_QUERY_TRACK_KEY] = newQueryTrack();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
fallbackTrack = newQueryTrack();
|
|
104
|
+
}
|
|
105
|
+
function getErrorHandlerConfig() {
|
|
106
|
+
return {
|
|
107
|
+
appName: "Stacks",
|
|
108
|
+
theme: "auto",
|
|
109
|
+
showEnvironment: !0,
|
|
110
|
+
showQueries: !0,
|
|
111
|
+
showRequest: !0,
|
|
112
|
+
enableCopyMarkdown: !0,
|
|
113
|
+
snippetLines: 8,
|
|
114
|
+
basePaths: [process.cwd()]
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const SENSITIVE_PATTERNS = [
|
|
118
|
+
"password",
|
|
119
|
+
"secret",
|
|
120
|
+
"token",
|
|
121
|
+
"api_key",
|
|
122
|
+
"apikey",
|
|
123
|
+
"access_key",
|
|
124
|
+
"accesskey",
|
|
125
|
+
"private_key",
|
|
126
|
+
"privatekey",
|
|
127
|
+
"credit_card",
|
|
128
|
+
"creditcard",
|
|
129
|
+
"card_number",
|
|
130
|
+
"cardnumber",
|
|
131
|
+
"cvv",
|
|
132
|
+
"ssn",
|
|
133
|
+
"authorization",
|
|
134
|
+
"credential",
|
|
135
|
+
"aws_secret",
|
|
136
|
+
"aws_access",
|
|
137
|
+
"database_password",
|
|
138
|
+
"db_password",
|
|
139
|
+
"encryption_key",
|
|
140
|
+
"signing_key",
|
|
141
|
+
"bearer",
|
|
142
|
+
"session_id",
|
|
143
|
+
"sessionid",
|
|
144
|
+
"cookie"
|
|
145
|
+
], MAX_SANITIZE_DEPTH = 10, CIRCULAR_PLACEHOLDER = "[Circular]";
|
|
146
|
+
function sanitizeData(data, depth = 0, seen = new WeakSet) {
|
|
147
|
+
if (!data || typeof data !== "object" || depth >= MAX_SANITIZE_DEPTH)
|
|
148
|
+
return data;
|
|
149
|
+
if (seen.has(data))
|
|
150
|
+
return CIRCULAR_PLACEHOLDER;
|
|
151
|
+
seen.add(data);
|
|
152
|
+
if (Array.isArray(data))
|
|
153
|
+
return data.map((item) => sanitizeData(item, depth + 1, seen));
|
|
154
|
+
const sanitized = {};
|
|
155
|
+
for (const [key, value] of Object.entries(data)) {
|
|
156
|
+
const lowerKey = key.toLowerCase();
|
|
157
|
+
if (SENSITIVE_PATTERNS.some((pattern) => lowerKey.includes(pattern)))
|
|
158
|
+
sanitized[key] = "********";
|
|
159
|
+
else if (typeof value === "object" && value !== null)
|
|
160
|
+
sanitized[key] = sanitizeData(value, depth + 1, seen);
|
|
161
|
+
else
|
|
162
|
+
sanitized[key] = value;
|
|
163
|
+
}
|
|
164
|
+
return sanitized;
|
|
165
|
+
}
|
|
166
|
+
function getRequestBody(request) {
|
|
167
|
+
const req = request;
|
|
168
|
+
if (req.jsonBody)
|
|
169
|
+
return sanitizeData(req.jsonBody);
|
|
170
|
+
if (req.formBody)
|
|
171
|
+
return sanitizeData(req.formBody);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
async function getUserContext(request) {
|
|
175
|
+
const authed = request._authenticatedUser;
|
|
176
|
+
if (authed)
|
|
177
|
+
return {
|
|
178
|
+
id: authed.id,
|
|
179
|
+
email: authed.email,
|
|
180
|
+
name: authed.name || authed.username
|
|
181
|
+
};
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
export async function createErrorResponse(error, request, options) {
|
|
185
|
+
const status = options?.status || 500;
|
|
186
|
+
log.debug(`[error] ${status} ${error.message}`);
|
|
187
|
+
if (!isDebugAllowed()) {
|
|
188
|
+
if (isApiRequest(request)) {
|
|
189
|
+
const isClientError = status >= 400 && status < 500, errDetails = error.details;
|
|
190
|
+
return new Response(buildErrorJson({
|
|
191
|
+
error: isClientError ? error.name || "Client Error" : "Internal Server Error",
|
|
192
|
+
message: isClientError ? error.message : "An unexpected error occurred.",
|
|
193
|
+
status,
|
|
194
|
+
details: isClientError && errDetails && typeof errDetails === "object" ? errDetails : void 0
|
|
195
|
+
}), { status, headers: getJsonHeaders() });
|
|
196
|
+
}
|
|
197
|
+
return new Response(renderProductionErrorPage(status), {
|
|
198
|
+
status,
|
|
199
|
+
headers: { "Content-Type": "text/html; charset=utf-8" }
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
const handler = createErrorHandler(getErrorHandlerConfig());
|
|
204
|
+
handler.setFramework("Stacks", "0.70.0");
|
|
205
|
+
const requestBody = getRequestBody(request);
|
|
206
|
+
if (requestBody) {
|
|
207
|
+
const url = new URL(request.url);
|
|
208
|
+
handler.setRequest({
|
|
209
|
+
method: request.method,
|
|
210
|
+
url: request.url,
|
|
211
|
+
headers: Object.fromEntries(request.headers.entries()),
|
|
212
|
+
queryParams: Object.fromEntries(url.searchParams.entries()),
|
|
213
|
+
body: requestBody
|
|
214
|
+
});
|
|
215
|
+
} else
|
|
216
|
+
handler.setRequest(request);
|
|
217
|
+
const userContext = await getUserContext(request);
|
|
218
|
+
if (userContext)
|
|
219
|
+
handler.setUser(userContext);
|
|
220
|
+
if (options?.routingContext)
|
|
221
|
+
handler.setRouting(options.routingContext);
|
|
222
|
+
else if (options?.handlerPath)
|
|
223
|
+
handler.setRouting({
|
|
224
|
+
controller: options.handlerPath
|
|
225
|
+
});
|
|
226
|
+
for (const query of getRecentQueries())
|
|
227
|
+
handler.addQuery(query.query, query.time, query.connection);
|
|
228
|
+
if (isApiRequest(request)) {
|
|
229
|
+
const details = { handler: options?.handlerPath };
|
|
230
|
+
if (isDebugAllowed()) {
|
|
231
|
+
details.stack = error.stack?.split(`
|
|
232
|
+
`).slice(0, 10);
|
|
233
|
+
details.queries = getRecentQueries().slice(-10);
|
|
234
|
+
}
|
|
235
|
+
return new Response(buildErrorJson({
|
|
236
|
+
error: error.name || "Error",
|
|
237
|
+
message: error.message,
|
|
238
|
+
status,
|
|
239
|
+
details
|
|
240
|
+
}), { status, headers: getJsonHeadersFull() });
|
|
241
|
+
}
|
|
242
|
+
const corsOrigin = process.env.APP_URL ? process.env.APP_URL.startsWith("http") ? process.env.APP_URL : `https://${process.env.APP_URL}` : isDebugAllowed() ? "*" : request.headers.get("origin") ?? "null", html = await handler.render(error, status);
|
|
243
|
+
return new Response(html, {
|
|
244
|
+
status,
|
|
245
|
+
headers: {
|
|
246
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
247
|
+
"Access-Control-Allow-Origin": corsOrigin
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
} catch (renderError) {
|
|
251
|
+
console.error("[Error Handler] Failed to render error page:", renderError);
|
|
252
|
+
const escapeHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
253
|
+
return new Response(`
|
|
254
|
+
<html>
|
|
255
|
+
<head><title>Error</title></head>
|
|
256
|
+
<body>
|
|
257
|
+
<h1>Error</h1>
|
|
258
|
+
<p>${escapeHtml(error.message)}</p>
|
|
259
|
+
<pre>${escapeHtml(error.stack || "")}</pre>
|
|
260
|
+
</body>
|
|
261
|
+
</html>
|
|
262
|
+
`, {
|
|
263
|
+
status,
|
|
264
|
+
headers: { "Content-Type": "text/html; charset=utf-8" }
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
export async function createMiddlewareErrorResponse(error, request) {
|
|
269
|
+
const status = error.statusCode ?? error.status ?? 500, isDevelopment = isDebugAllowed();
|
|
270
|
+
if (status >= 400 && status < 500) {
|
|
271
|
+
const headers = error.headers ? { ...error.headers, ...getJsonHeaders() } : getJsonHeaders();
|
|
272
|
+
return new Response(buildErrorJson({
|
|
273
|
+
error: error.name || "ClientError",
|
|
274
|
+
message: error.message,
|
|
275
|
+
status
|
|
276
|
+
}), { status, headers });
|
|
277
|
+
}
|
|
278
|
+
if (isDevelopment)
|
|
279
|
+
return await createErrorResponse(error, request, { status });
|
|
280
|
+
return new Response(buildErrorJson({
|
|
281
|
+
error: "Internal Server Error",
|
|
282
|
+
message: "An unexpected error occurred.",
|
|
283
|
+
status
|
|
284
|
+
}), { status, headers: getJsonHeaders() });
|
|
285
|
+
}
|
|
286
|
+
export function createValidationErrorResponse(errors, _request) {
|
|
287
|
+
return new Response(buildErrorJson({
|
|
288
|
+
error: "ValidationError",
|
|
289
|
+
message: "Validation failed",
|
|
290
|
+
status: 422,
|
|
291
|
+
details: { errors }
|
|
292
|
+
}), { status: 422, headers: getJsonHeaders() });
|
|
293
|
+
}
|
|
294
|
+
export async function createNotFoundResponse(path, request) {
|
|
295
|
+
if (isDebugAllowed()) {
|
|
296
|
+
const error = Error(`Route not found: ${path}`);
|
|
297
|
+
error.name = "NotFoundError";
|
|
298
|
+
return await createErrorResponse(error, request, { status: 404 });
|
|
299
|
+
}
|
|
300
|
+
if (isApiRequest(request))
|
|
301
|
+
return new Response(buildErrorJson({
|
|
302
|
+
error: "NotFound",
|
|
303
|
+
message: `Route not found: ${path}`,
|
|
304
|
+
status: 404
|
|
305
|
+
}), { status: 404, headers: getJsonHeaders() });
|
|
306
|
+
return new Response(renderProductionErrorPage(404), {
|
|
307
|
+
status: 404,
|
|
308
|
+
headers: { "Content-Type": "text/html; charset=utf-8" }
|
|
309
|
+
});
|
|
310
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,14 +1,37 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
1
|
+
var {require}=import.meta;import"./request-augmentation";
|
|
2
|
+
|
|
3
|
+
export * from "@stacksjs/bun-router";
|
|
4
|
+
export { assertRouteMiddlewareResolvable, clearMiddlewareCache, createStacksRouter, findUnresolvableRouteMiddleware, installMiddlewareHotReload, route, serve, serverResponse, url, warnOnMultipleRouterInstances } from "./stacks-router";
|
|
5
|
+
export { cacheRequestQuery, getCurrentRequest, getTraceId, request, runWithRequest, setCurrentRequest, withTraceId } from "./request-context";
|
|
6
|
+
export { Middleware } from "./middleware";
|
|
7
|
+
export { loadRoutes } from "./route-loader";
|
|
8
|
+
export {
|
|
9
|
+
clearTrackedQueries,
|
|
10
|
+
createErrorResponse,
|
|
11
|
+
createMiddlewareErrorResponse,
|
|
12
|
+
createNotFoundResponse,
|
|
13
|
+
createValidationErrorResponse,
|
|
14
|
+
getQueryShapeCounts,
|
|
15
|
+
trackQuery
|
|
16
|
+
} from "./error-handler";
|
|
17
|
+
export { listRegisteredRoutes, routeParams } from "./stacks-router";
|
|
18
|
+
export { isApiRequest, JSON_CONTENT_TYPE } from "./api-shape";
|
|
19
|
+
export { rateLimit, rateLimitStatus, clearRateLimit } from "./rate-limit";
|
|
20
|
+
export { PathParamError, safePathParam, sanitizePathParam } from "./path-sanitize";
|
|
21
|
+
export { stream } from "./stacks-router";
|
|
22
|
+
export { signedUrl, signUrl, verifySignedUrl, verifySignedUrlMiddleware } from "./signed-url";
|
|
23
|
+
export { EncryptedSessionStore } from "./encrypted-session-store";
|
|
24
|
+
export {
|
|
25
|
+
createSessionStore,
|
|
26
|
+
createStacksSessionStore,
|
|
27
|
+
DatabaseSessionStore,
|
|
28
|
+
FileSessionStore,
|
|
29
|
+
MemorySessionStore,
|
|
30
|
+
RedisSessionStore
|
|
31
|
+
} from "./session-factory";
|
|
32
|
+
import("@stacksjs/database").then(({ setQueryTracker }) => {
|
|
33
|
+
if (typeof setQueryTracker === "function") {
|
|
34
|
+
const { trackQuery } = require("./error-handler");
|
|
35
|
+
setQueryTracker(trackQuery);
|
|
36
|
+
}
|
|
37
|
+
}).catch(() => {});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export class Middleware {
|
|
2
|
+
name;
|
|
3
|
+
priority;
|
|
4
|
+
handle;
|
|
5
|
+
constructor(config) {
|
|
6
|
+
this.name = config.name;
|
|
7
|
+
this.priority = config.priority ?? 10;
|
|
8
|
+
this.handle = config.handle;
|
|
9
|
+
}
|
|
10
|
+
toRouterHandler() {
|
|
11
|
+
const handle = this.handle.bind(this);
|
|
12
|
+
return async (req, next) => {
|
|
13
|
+
try {
|
|
14
|
+
await handle(req);
|
|
15
|
+
} catch (thrown) {
|
|
16
|
+
if (thrown instanceof Response)
|
|
17
|
+
return thrown;
|
|
18
|
+
throw thrown;
|
|
19
|
+
}
|
|
20
|
+
return next();
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export class PathParamError extends Error {
|
|
2
|
+
reason;
|
|
3
|
+
constructor(reason, value, context) {
|
|
4
|
+
const ctx = context ? ` in ${context}` : "";
|
|
5
|
+
super(`[router] Refusing to use ${JSON.stringify(value)} as a path parameter${ctx} \u2014 ${reason}`);
|
|
6
|
+
this.name = "PathParamError";
|
|
7
|
+
this.reason = reason;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/;
|
|
11
|
+
export function sanitizePathParam(value, options = {}) {
|
|
12
|
+
if (typeof value !== "string")
|
|
13
|
+
throw new PathParamError("not-string", value, options.context);
|
|
14
|
+
if (value.length === 0)
|
|
15
|
+
throw new PathParamError("empty", value, options.context);
|
|
16
|
+
const maxLength = options.maxLength ?? 255;
|
|
17
|
+
if (value.length > maxLength)
|
|
18
|
+
throw new PathParamError("too-long", value, options.context);
|
|
19
|
+
if (value.includes("\x00"))
|
|
20
|
+
throw new PathParamError("null-byte", value, options.context);
|
|
21
|
+
if (CONTROL_CHARS.test(value))
|
|
22
|
+
throw new PathParamError("control-char", value, options.context);
|
|
23
|
+
if (value.startsWith("/") || /^[A-Z]:[\\/]/i.test(value))
|
|
24
|
+
throw new PathParamError("absolute-path", value, options.context);
|
|
25
|
+
if (/(^|[\\/])\.\.([\\/]|$)/.test(value))
|
|
26
|
+
throw new PathParamError("traversal", value, options.context);
|
|
27
|
+
if (!options.allowSlashes && /[\\/]/.test(value))
|
|
28
|
+
throw new PathParamError("traversal", value, options.context);
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
export function safePathParam(value, options = {}) {
|
|
32
|
+
try {
|
|
33
|
+
return sanitizePathParam(value, options);
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { HttpError } from "@stacksjs/error-handling";
|
|
2
|
+
import { RateLimitError, RateLimiter, defaultIdentity } from "ts-rate-limiter";
|
|
3
|
+
import { getCurrentRequest } from "./request-context";
|
|
4
|
+
const PERIOD_SECONDS = {
|
|
5
|
+
second: 1,
|
|
6
|
+
minute: 60,
|
|
7
|
+
hour: 3600,
|
|
8
|
+
day: 86400
|
|
9
|
+
}, limiterCache = new Map;
|
|
10
|
+
function getLimiter(max, windowMs) {
|
|
11
|
+
const cacheKey = `${windowMs}:${max}`;
|
|
12
|
+
let limiter = limiterCache.get(cacheKey);
|
|
13
|
+
if (!limiter) {
|
|
14
|
+
limiter = new RateLimiter({
|
|
15
|
+
windowMs,
|
|
16
|
+
maxRequests: max,
|
|
17
|
+
algorithm: "fixed-window",
|
|
18
|
+
standardHeaders: !1,
|
|
19
|
+
legacyHeaders: !1
|
|
20
|
+
});
|
|
21
|
+
limiterCache.set(cacheKey, limiter);
|
|
22
|
+
}
|
|
23
|
+
return limiter;
|
|
24
|
+
}
|
|
25
|
+
function resolveIdentity(explicit) {
|
|
26
|
+
if (explicit !== void 0)
|
|
27
|
+
return explicit;
|
|
28
|
+
const req = getCurrentRequest();
|
|
29
|
+
return req ? defaultIdentity(req) : "anon";
|
|
30
|
+
}
|
|
31
|
+
export function rateLimit(key, max, options = {}) {
|
|
32
|
+
const id = resolveIdentity(options.identity), bucketKey = `${key}:${id}`, run = async (windowMs) => {
|
|
33
|
+
const limiter = getLimiter(max, windowMs);
|
|
34
|
+
try {
|
|
35
|
+
await limiter.enforce(bucketKey);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
if (err instanceof RateLimitError)
|
|
38
|
+
throw Object.assign(new HttpError(429, "Too many requests", {
|
|
39
|
+
key,
|
|
40
|
+
max,
|
|
41
|
+
retryAfter: err.retryAfter
|
|
42
|
+
}), { headers: err.toHeaders() });
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
async per(period) {
|
|
48
|
+
const seconds = PERIOD_SECONDS[period];
|
|
49
|
+
if (!seconds)
|
|
50
|
+
throw Error(`rateLimit().per: unknown period '${period}'`);
|
|
51
|
+
await run(seconds * 1000);
|
|
52
|
+
},
|
|
53
|
+
async over(ttlSeconds) {
|
|
54
|
+
if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0)
|
|
55
|
+
throw Error(`rateLimit().over: ttl must be a positive number, got ${ttlSeconds}`);
|
|
56
|
+
await run(ttlSeconds * 1000);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export async function rateLimitStatus(key, max, windowSeconds, options = {}) {
|
|
61
|
+
const id = resolveIdentity(options.identity), bucketKey = `${key}:${id}`, result = await getLimiter(max, windowSeconds * 1000).peek(bucketKey);
|
|
62
|
+
if (!result)
|
|
63
|
+
return null;
|
|
64
|
+
return {
|
|
65
|
+
count: result.current,
|
|
66
|
+
limit: result.limit,
|
|
67
|
+
remaining: Math.max(0, result.limit - result.current)
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export async function clearRateLimit(key, max, windowSeconds, options = {}) {
|
|
71
|
+
const id = resolveIdentity(options.identity), bucketKey = `${key}:${id}`;
|
|
72
|
+
await getLimiter(max, windowSeconds * 1000).reset(bucketKey);
|
|
73
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
+
import { log } from "@stacksjs/logging";
|
|
4
|
+
const REQUEST_STORAGE_KEY = Symbol.for("stacks.router.requestStorage"), requestStorage = globalThis[REQUEST_STORAGE_KEY] ??= new AsyncLocalStorage, TRACE_STORAGE_KEY = Symbol.for("stacks.router.traceStorage"), traceStorage = globalThis[TRACE_STORAGE_KEY] ??= new AsyncLocalStorage;
|
|
5
|
+
export function getTraceId() {
|
|
6
|
+
const explicit = traceStorage.getStore();
|
|
7
|
+
if (explicit)
|
|
8
|
+
return explicit;
|
|
9
|
+
return requestStorage.getStore()?._requestId;
|
|
10
|
+
}
|
|
11
|
+
export function withTraceId(id, fn) {
|
|
12
|
+
return traceStorage.run(id, fn);
|
|
13
|
+
}
|
|
14
|
+
const REQUEST_QUERY_CACHE_KEY = Symbol.for("stacks.requestQueryCache");
|
|
15
|
+
function getRequestCache() {
|
|
16
|
+
const req = requestStorage.getStore();
|
|
17
|
+
if (!req)
|
|
18
|
+
return;
|
|
19
|
+
let cache = req[REQUEST_QUERY_CACHE_KEY];
|
|
20
|
+
if (!cache) {
|
|
21
|
+
cache = { map: new Map };
|
|
22
|
+
req[REQUEST_QUERY_CACHE_KEY] = cache;
|
|
23
|
+
}
|
|
24
|
+
return cache;
|
|
25
|
+
}
|
|
26
|
+
export async function cacheRequestQuery(key, fetcher) {
|
|
27
|
+
const cache = getRequestCache();
|
|
28
|
+
if (!cache)
|
|
29
|
+
return fetcher();
|
|
30
|
+
const existing = cache.map.get(key);
|
|
31
|
+
if (existing)
|
|
32
|
+
return existing;
|
|
33
|
+
const promise = Promise.resolve().then(() => fetcher());
|
|
34
|
+
cache.map.set(key, promise);
|
|
35
|
+
promise.catch(() => cache.map.delete(key));
|
|
36
|
+
return promise;
|
|
37
|
+
}
|
|
38
|
+
export function setCurrentRequest(req) {
|
|
39
|
+
log.debug(`[request] ${req.method} ${new URL(req.url).pathname}`);
|
|
40
|
+
requestStorage.enterWith(req);
|
|
41
|
+
}
|
|
42
|
+
export function clearCurrentRequest() {
|
|
43
|
+
requestStorage.disable();
|
|
44
|
+
}
|
|
45
|
+
export function runWithRequest(req, fn) {
|
|
46
|
+
return requestStorage.run(req, fn);
|
|
47
|
+
}
|
|
48
|
+
export function getCurrentRequest() {
|
|
49
|
+
return requestStorage.getStore();
|
|
50
|
+
}
|
|
51
|
+
export const request = new Proxy({}, {
|
|
52
|
+
get(_target, prop) {
|
|
53
|
+
const currentRequest = getCurrentRequest();
|
|
54
|
+
if (!currentRequest) {
|
|
55
|
+
if (process.env.NODE_ENV !== "production")
|
|
56
|
+
console.warn(`[RequestContext] Accessing request.${String(prop)} outside of request context`);
|
|
57
|
+
if (prop === "bearerToken")
|
|
58
|
+
return () => null;
|
|
59
|
+
if (prop === "user" || prop === "userToken")
|
|
60
|
+
return async () => {
|
|
61
|
+
return;
|
|
62
|
+
};
|
|
63
|
+
if (prop === "tokenCan" || prop === "tokenCant")
|
|
64
|
+
return async () => !1;
|
|
65
|
+
if (prop === "headers")
|
|
66
|
+
return new Headers;
|
|
67
|
+
if (prop === "url")
|
|
68
|
+
return "";
|
|
69
|
+
if (prop === "method")
|
|
70
|
+
return "GET";
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const value = currentRequest[prop];
|
|
74
|
+
if (typeof value === "function")
|
|
75
|
+
return value.bind(currentRequest);
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
});
|