@stacksjs/router 0.70.88 → 0.70.91
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.d.ts +7 -0
- package/dist/action-paths.js +0 -0
- package/dist/api-shape.d.ts +24 -0
- package/dist/api-shape.js +11 -0
- package/dist/encrypted-session-store.d.ts +19 -0
- package/dist/encrypted-session-store.js +73 -0
- package/dist/error-handler.d.ts +68 -0
- package/dist/error-handler.js +310 -0
- package/dist/index.d.ts +81 -0
- package/dist/index.js +37 -0
- package/dist/middleware.d.ts +37 -0
- package/dist/middleware.js +23 -0
- package/dist/path-sanitize.d.ts +64 -0
- package/dist/path-sanitize.js +37 -0
- package/dist/rate-limit.d.ts +35 -0
- package/dist/rate-limit.js +73 -0
- package/dist/request-augmentation.d.ts +71 -0
- package/dist/request-augmentation.js +0 -0
- package/dist/request-context.d.ts +82 -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.d.ts +5 -0
- package/dist/route-loader.js +72 -0
- package/dist/route-types.d.ts +12 -0
- package/dist/route-types.js +0 -0
- package/dist/security-headers.d.ts +20 -0
- package/dist/security-headers.js +46 -0
- package/dist/session-factory.d.ts +57 -0
- package/dist/session-factory.js +26 -0
- package/dist/signed-url.d.ts +50 -0
- package/dist/signed-url.js +76 -0
- package/dist/stacks-router.d.ts +213 -0
- package/dist/stacks-router.js +1425 -0
- package/package.json +11 -11
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action path types for the Stacks router.
|
|
3
|
+
* This file is auto-generated or manually maintained to provide
|
|
4
|
+
* type-safe string paths for routing to actions and controllers.
|
|
5
|
+
*/
|
|
6
|
+
// Base type for all action paths - will be narrowed as actions are added
|
|
7
|
+
export type StacksActionPath = string;
|
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/*`,
|
|
2
|
+
* not `application/json`). This predicate fixes that asymmetry by making
|
|
3
|
+
* JSON the default unless the client explicitly opts into HTML.
|
|
4
|
+
*/
|
|
5
|
+
export declare function isApiRequest(req: Request | { headers: Headers }): boolean;
|
|
6
|
+
/**
|
|
7
|
+
* "Is this request JSON-shaped?" — the single source of truth used across
|
|
8
|
+
* the framework to decide JSON vs HTML for responses (errors, primitives,
|
|
9
|
+
* empty results) and to widen request-body parsing to every JSON variant.
|
|
10
|
+
*
|
|
11
|
+
* The historical bug was scattered ad-hoc checks: `error-handler` looked at
|
|
12
|
+
* `Accept`, `formatResult` ignored the request entirely, `parseRequestBody`
|
|
13
|
+
* did `contentType.includes('application/json')` so `application/vnd.api+json`
|
|
14
|
+
* went unparsed. Centralizing the decision here means a future tweak (e.g.,
|
|
15
|
+
* treating an `apiResponse: true` route group as always-JSON) lands in one
|
|
16
|
+
* place and every response path picks it up.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Matches `application/json` plus any RFC-6838 structured-suffix subtype:
|
|
20
|
+
* `application/vnd.api+json`, `application/ld+json`, `application/hal+json`,
|
|
21
|
+
* `application/problem+json`, etc. Case-insensitive; tolerates a trailing
|
|
22
|
+
* `;` (for `;charset=utf-8`) or end-of-string.
|
|
23
|
+
*/
|
|
24
|
+
export declare const JSON_CONTENT_TYPE: unknown;
|
|
@@ -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,19 @@
|
|
|
1
|
+
import type { SessionData, SessionStore } from '@stacksjs/bun-router';
|
|
2
|
+
export declare interface EncryptedSessionStoreOptions {
|
|
3
|
+
appKey?: string
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Wrap a bun-router `SessionStore<SessionData>` so all writes are
|
|
7
|
+
* encrypted on the way in and decrypted on the way out. Drop-in
|
|
8
|
+
* replacement for any of the built-in stores.
|
|
9
|
+
*/
|
|
10
|
+
export declare class EncryptedSessionStore implements SessionStore<SessionData> {
|
|
11
|
+
constructor(inner: SessionStore<SessionData>, opts?: EncryptedSessionStoreOptions);
|
|
12
|
+
set(sid: string, session: SessionData, ttl?: number): Promise<void>;
|
|
13
|
+
touch(sid: string, session: SessionData, ttl?: number): Promise<void>;
|
|
14
|
+
get(sid: string): Promise<SessionData | null>;
|
|
15
|
+
destroy(sid: string): Promise<void>;
|
|
16
|
+
all(): Promise<Record<string, SessionData>>;
|
|
17
|
+
length(): Promise<number>;
|
|
18
|
+
clear(): Promise<void>;
|
|
19
|
+
}
|
|
@@ -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,68 @@
|
|
|
1
|
+
import type { EnhancedRequest } from '@stacksjs/bun-router';
|
|
2
|
+
/**
|
|
3
|
+
* Add a query to the recent queries list for error context.
|
|
4
|
+
* Uses a circular buffer for O(1) insert instead of array.shift().
|
|
5
|
+
*
|
|
6
|
+
* Also runs N+1 detection: when the same query *shape* (with bound
|
|
7
|
+
* values normalized away) repeats more than `N1_THRESHOLD` times within
|
|
8
|
+
* a single request lifecycle, we warn once via `log.warn`. The signal
|
|
9
|
+
* is highly correlated with missing eager loading.
|
|
10
|
+
*/
|
|
11
|
+
export declare function trackQuery(query: string, time?: number, connection?: string): void;
|
|
12
|
+
/**
|
|
13
|
+
* Snapshot of query shape counts for the active request. Useful for
|
|
14
|
+
* tests asserting that an action ran a single query for `posts`
|
|
15
|
+
* instead of one-per-user.
|
|
16
|
+
*/
|
|
17
|
+
export declare function getQueryShapeCounts(): ReadonlyMap<string, number>;
|
|
18
|
+
/**
|
|
19
|
+
* Reset query tracking for the active scope.
|
|
20
|
+
*
|
|
21
|
+
* Inside a request, this clears the per-request tracking object — but
|
|
22
|
+
* the object is also auto-collected when the request goes out of scope,
|
|
23
|
+
* so the explicit call is mainly useful for tests that re-use a single
|
|
24
|
+
* request. Outside a request, this clears the process-wide fallback.
|
|
25
|
+
*/
|
|
26
|
+
export declare function clearTrackedQueries(): void;
|
|
27
|
+
/**
|
|
28
|
+
* Create an Ignition-style error response for development
|
|
29
|
+
*/
|
|
30
|
+
export declare function createErrorResponse(error: Error, request: Request | EnhancedRequest, options?: {
|
|
31
|
+
status?: number
|
|
32
|
+
handlerPath?: string
|
|
33
|
+
routingContext?: {
|
|
34
|
+
controller?: string
|
|
35
|
+
routeName?: string
|
|
36
|
+
middleware?: string[]
|
|
37
|
+
}
|
|
38
|
+
}): Promise<Response>;
|
|
39
|
+
/**
|
|
40
|
+
* Create a middleware error response (401, 403, etc.)
|
|
41
|
+
*
|
|
42
|
+
* Reads `statusCode` OR `status` off the error so both shapes are honored:
|
|
43
|
+
* - middleware that throws `Object.assign(new Error('msg'), { statusCode: 401 })`
|
|
44
|
+
* - framework HttpError instances where the field is named `status`
|
|
45
|
+
*
|
|
46
|
+
* Without the `status` fallback, every `HttpError(401, …)` throw from auth or
|
|
47
|
+
* validation middleware leaks out as a 500 with an Ignition error page —
|
|
48
|
+
* which is what we used to ship for `GET /api/me` without a token.
|
|
49
|
+
*/
|
|
50
|
+
export declare function createMiddlewareErrorResponse(error: Error & { statusCode?: number, status?: number, headers?: Record<string, string> }, request: Request | EnhancedRequest): Promise<Response>;
|
|
51
|
+
/**
|
|
52
|
+
* Create a validation error response
|
|
53
|
+
*/
|
|
54
|
+
export declare function createValidationErrorResponse(errors: Record<string, string[]>, _request: Request | EnhancedRequest): Response;
|
|
55
|
+
/**
|
|
56
|
+
* Create a 404 Not Found response
|
|
57
|
+
*/
|
|
58
|
+
export declare function createNotFoundResponse(path: string, request: Request | EnhancedRequest): Promise<Response>;
|
|
59
|
+
/**
|
|
60
|
+
* Standard error response structure used across all JSON error responses.
|
|
61
|
+
*/
|
|
62
|
+
export declare interface ErrorResponseBody {
|
|
63
|
+
error: string
|
|
64
|
+
message: string
|
|
65
|
+
status: number
|
|
66
|
+
timestamp: string
|
|
67
|
+
details?: Record<string, unknown>
|
|
68
|
+
}
|
|
@@ -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.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import './request-augmentation';
|
|
2
|
+
// Re-export the augmentation types so userland can refer to the marker
|
|
3
|
+
// surface explicitly when needed.
|
|
4
|
+
export type { StacksRequestExtensions, StacksRequestMacros, StacksRequestMarkers } from './request-augmentation';
|
|
5
|
+
export type { MiddlewareConfig, Request } from './middleware';
|
|
6
|
+
// Export route registry types — owned here rather than in app/Routes.ts
|
|
7
|
+
// so the path doesn't depend on a 5-level relative reach across the
|
|
8
|
+
// framework defaults tree (stacksjs/stacks#1863, T-10).
|
|
9
|
+
export type { RouteDefinition, RouteRegistry } from './route-types';
|
|
10
|
+
export type { PathParamRejection, SanitizePathParamOptions } from './path-sanitize';
|
|
11
|
+
export type { StreamOptions } from './stacks-router';
|
|
12
|
+
export type { SignedUrlOptions, SignedUrlVerifyResult } from './signed-url';
|
|
13
|
+
export type { EncryptedSessionStoreOptions } from './encrypted-session-store';
|
|
14
|
+
export type {
|
|
15
|
+
RedisClient,
|
|
16
|
+
SessionConfig,
|
|
17
|
+
SessionData,
|
|
18
|
+
SessionStore,
|
|
19
|
+
StacksSessionConfig,
|
|
20
|
+
} from './session-factory';
|
|
21
|
+
// Re-export everything from bun-router (includes response factory)
|
|
22
|
+
export * from '@stacksjs/bun-router';
|
|
23
|
+
// Export Stacks-specific action resolver and URL helper
|
|
24
|
+
export { assertRouteMiddlewareResolvable, clearMiddlewareCache, createStacksRouter, findUnresolvableRouteMiddleware, installMiddlewareHotReload, route, serve, serverResponse, url, warnOnMultipleRouterInstances } from './stacks-router';
|
|
25
|
+
// Export request context helpers
|
|
26
|
+
export { cacheRequestQuery, getCurrentRequest, getTraceId, request, runWithRequest, setCurrentRequest, withTraceId } from './request-context';
|
|
27
|
+
// Export Middleware class for defining route middleware
|
|
28
|
+
export { Middleware } from './middleware';
|
|
29
|
+
// Export route loader
|
|
30
|
+
export { loadRoutes } from './route-loader';
|
|
31
|
+
// Export error handler utilities
|
|
32
|
+
export {
|
|
33
|
+
clearTrackedQueries,
|
|
34
|
+
createErrorResponse,
|
|
35
|
+
createMiddlewareErrorResponse,
|
|
36
|
+
createNotFoundResponse,
|
|
37
|
+
createValidationErrorResponse,
|
|
38
|
+
getQueryShapeCounts,
|
|
39
|
+
trackQuery,
|
|
40
|
+
} from './error-handler';
|
|
41
|
+
// Export route introspection helpers
|
|
42
|
+
export { listRegisteredRoutes, routeParams } from './stacks-router';
|
|
43
|
+
// Export JSON-vs-HTML negotiation predicate so userland can short-circuit
|
|
44
|
+
// the same decision the framework makes in formatResult / error-handler.
|
|
45
|
+
export { isApiRequest, JSON_CONTENT_TYPE } from './api-shape';
|
|
46
|
+
// Export action-level rate limiting helpers
|
|
47
|
+
export { rateLimit, rateLimitStatus, clearRateLimit } from './rate-limit';
|
|
48
|
+
// Export path-param sanitization helper (stacksjs/stacks#1870 R-12).
|
|
49
|
+
// Defense-in-depth for actions that interpolate route params into
|
|
50
|
+
// filesystem paths; the helper enforces no-traversal / no-absolute /
|
|
51
|
+
// no-null-byte / length ceiling at a single chokepoint.
|
|
52
|
+
export { PathParamError, safePathParam, sanitizePathParam } from './path-sanitize';
|
|
53
|
+
// Export the streaming-response helper for SSE / NDJSON / chunked
|
|
54
|
+
// binary returns (stacksjs/stacks#1870 R-4). Actions can return
|
|
55
|
+
// `stream(asyncGen, { type: 'sse' })` and the router pipes it back
|
|
56
|
+
// with the right Content-Type + no-cache headers.
|
|
57
|
+
export { stream } from './stacks-router';
|
|
58
|
+
// Signed-URL helpers — HMAC over the URL + optional expiry so single-
|
|
59
|
+
// use links (email verify, password reset, unsubscribe) can be handed
|
|
60
|
+
// out without long-lived bearer tokens. Pair `signedUrl(...)` with the
|
|
61
|
+
// `signed` middleware (or call `verifySignedUrl(req.url)` directly).
|
|
62
|
+
// See stacksjs/stacks#1870 R-7.
|
|
63
|
+
export { signedUrl, signUrl, verifySignedUrl, verifySignedUrlMiddleware } from './signed-url';
|
|
64
|
+
// Encryption-at-rest wrapper for any bun-router SessionStore
|
|
65
|
+
// (stacksjs/stacks#1878 Se-4). Opt-in: wrap your existing store
|
|
66
|
+
// instance so session payloads are AES-GCM encrypted via APP_KEY
|
|
67
|
+
// before being persisted.
|
|
68
|
+
export { EncryptedSessionStore } from './encrypted-session-store';
|
|
69
|
+
// Session driver factory (stacksjs/stacks#1889, F-2 from #1874).
|
|
70
|
+
// Builds a SessionStore from the Stacks config — picks the right
|
|
71
|
+
// driver from `config.session.driver`, optionally wraps with
|
|
72
|
+
// EncryptedSessionStore. Re-exports all four bun-router store
|
|
73
|
+
// classes so callers can assemble custom stacks manually too.
|
|
74
|
+
export {
|
|
75
|
+
createSessionStore,
|
|
76
|
+
createStacksSessionStore,
|
|
77
|
+
DatabaseSessionStore,
|
|
78
|
+
FileSessionStore,
|
|
79
|
+
MemorySessionStore,
|
|
80
|
+
RedisSessionStore,
|
|
81
|
+
} from './session-factory';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
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,37 @@
|
|
|
1
|
+
import type { EnhancedRequest } from '@stacksjs/bun-router';
|
|
2
|
+
export declare interface MiddlewareConfig {
|
|
3
|
+
name: string
|
|
4
|
+
priority?: number
|
|
5
|
+
handle: (request: EnhancedRequest) => void | Promise<void>
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Middleware class for defining route middleware
|
|
9
|
+
*
|
|
10
|
+
* Provides a simple, structured way to define middleware handlers
|
|
11
|
+
* that can be attached to routes and route groups.
|
|
12
|
+
*
|
|
13
|
+
* The request object is an EnhancedRequest with helper methods like
|
|
14
|
+
* `bearerToken()`, `get()`, `input()`, `has()`, etc.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* import { Middleware } from '@stacksjs/router'
|
|
19
|
+
*
|
|
20
|
+
* export default new Middleware({
|
|
21
|
+
* name: 'Auth',
|
|
22
|
+
* priority: 1,
|
|
23
|
+
* async handle(request) {
|
|
24
|
+
* const token = request.bearerToken()
|
|
25
|
+
* if (!token) throw new HttpError(401, 'Unauthorized')
|
|
26
|
+
* },
|
|
27
|
+
* })
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export type Request = EnhancedRequest;
|
|
31
|
+
export declare class Middleware {
|
|
32
|
+
readonly name: string;
|
|
33
|
+
readonly priority: number;
|
|
34
|
+
readonly handle: (request: EnhancedRequest) => void | Promise<void>;
|
|
35
|
+
constructor(config: MiddlewareConfig);
|
|
36
|
+
toRouterHandler(): (req: EnhancedRequest, next: () => Promise<Response>) => Promise<Response>;
|
|
37
|
+
}
|