@bractjs/bractjs 0.1.27 → 0.1.29
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/bin/cli.ts +18 -1
- package/package.json +3 -2
- package/src/__tests__/codegen-write.test.ts +67 -0
- package/src/__tests__/codegen.test.ts +29 -2
- package/src/__tests__/compile-safety.test.ts +4 -0
- package/src/__tests__/csp.test.ts +10 -0
- package/src/__tests__/define-actions.test.ts +69 -0
- package/src/__tests__/env.test.ts +18 -0
- package/src/__tests__/fetcher-store.test.ts +67 -0
- package/src/__tests__/fixtures/app/root.tsx +7 -2
- package/src/__tests__/fixtures/app/routes/boom.tsx +9 -0
- package/src/__tests__/fixtures/app/routes/client-only.tsx +16 -0
- package/src/__tests__/fixtures/app/routes/counter.tsx +16 -0
- package/src/__tests__/fixtures/app/routes/data-only.tsx +16 -0
- package/src/__tests__/fixtures/app/routes/features-demo.tsx +28 -0
- package/src/__tests__/fixtures/app/routes/intent-demo.tsx +46 -0
- package/src/__tests__/fixtures/app/routes/protected-client-only.tsx +15 -0
- package/src/__tests__/fixtures/app/routes/search-demo.tsx +39 -0
- package/src/__tests__/form-data-helpers.test.ts +43 -0
- package/src/__tests__/headers.test.ts +111 -0
- package/src/__tests__/integration.test.ts +90 -0
- package/src/__tests__/layout-registry.test.ts +7 -3
- package/src/__tests__/loader.test.ts +32 -1
- package/src/__tests__/matcher.test.ts +29 -0
- package/src/__tests__/module-registry.test.ts +2 -3
- package/src/__tests__/nav-utils.test.ts +46 -0
- package/src/__tests__/prerender.test.ts +102 -0
- package/src/__tests__/programmatic-api.test.ts +20 -1
- package/src/__tests__/revalidation.test.ts +65 -0
- package/src/__tests__/route-lint.test.ts +79 -0
- package/src/__tests__/route-middleware.test.ts +84 -0
- package/src/__tests__/route-table.test.ts +33 -0
- package/src/__tests__/safe-validate.test.ts +96 -0
- package/src/__tests__/scanner.test.ts +46 -1
- package/src/__tests__/scroll-restoration.test.ts +66 -0
- package/src/__tests__/search-serializer.test.ts +42 -0
- package/src/__tests__/search-validation.test.ts +125 -0
- package/src/__tests__/security-fixes.test.ts +201 -0
- package/src/__tests__/security.test.ts +110 -1
- package/src/__tests__/selective-ssr.test.ts +85 -0
- package/src/__tests__/spa-mode.test.ts +77 -0
- package/src/__tests__/typed-routing.test.ts +51 -1
- package/src/__tests__/use-matches.test.ts +54 -0
- package/src/build/bundler.ts +33 -0
- package/src/build/prerender.ts +88 -0
- package/src/build/route-lint.ts +49 -0
- package/src/client/ClientRouter.tsx +339 -47
- package/src/client/cache.ts +8 -0
- package/src/client/components/Await.tsx +9 -2
- package/src/client/components/Form.tsx +23 -34
- package/src/client/components/Link.tsx +80 -9
- package/src/client/components/Outlet.tsx +8 -2
- package/src/client/components/ScrollRestoration.tsx +125 -0
- package/src/client/entry.tsx +39 -2
- package/src/client/fetcher-store.ts +61 -0
- package/src/client/form-utils.ts +3 -0
- package/src/client/hooks/useActionData.ts +7 -3
- package/src/client/hooks/useFetcher.ts +116 -33
- package/src/client/hooks/useFetchers.ts +23 -0
- package/src/client/hooks/useLoaderData.ts +8 -4
- package/src/client/hooks/useLocation.ts +27 -0
- package/src/client/hooks/useMatches.ts +32 -0
- package/src/client/hooks/useNavigate.ts +11 -6
- package/src/client/hooks/useRevalidator.ts +26 -0
- package/src/client/hooks/useSearch.ts +73 -0
- package/src/client/hooks/useSearchParams.ts +7 -2
- package/src/client/nav-utils.ts +26 -0
- package/src/client/prefetch.ts +110 -15
- package/src/client/registry.ts +24 -0
- package/src/client/revalidation.ts +25 -0
- package/src/client/router.tsx +34 -1
- package/src/client/rpc.ts +11 -1
- package/src/client/scroll-restoration.ts +48 -0
- package/src/client/search-serializer.ts +40 -0
- package/src/client/types.ts +6 -0
- package/src/codegen/module-registry.ts +13 -21
- package/src/codegen/route-codegen.ts +148 -10
- package/src/config/load.ts +22 -0
- package/src/dev/hmr-client.ts +3 -1
- package/src/dev/route-table.ts +27 -0
- package/src/dev/server.ts +106 -8
- package/src/dev/watcher.ts +25 -3
- package/src/index.ts +38 -6
- package/src/server/action-handler.ts +3 -13
- package/src/server/action-registry.ts +35 -0
- package/src/server/adapter.ts +16 -0
- package/src/server/api-route.ts +47 -0
- package/src/server/csp.ts +19 -4
- package/src/server/csrf.ts +36 -3
- package/src/server/env.ts +26 -5
- package/src/server/headers.ts +49 -0
- package/src/server/layout.ts +43 -20
- package/src/server/loader.ts +14 -8
- package/src/server/matcher.ts +29 -2
- package/src/server/matches.ts +50 -0
- package/src/server/middleware.ts +66 -0
- package/src/server/proto-guard.ts +56 -0
- package/src/server/render.ts +51 -18
- package/src/server/request-handler.ts +111 -29
- package/src/server/scanner.ts +45 -3
- package/src/server/search.ts +47 -0
- package/src/server/serve.ts +116 -4
- package/src/server/session.ts +12 -1
- package/src/server/spa.ts +62 -0
- package/src/server/stream-handler.ts +10 -1
- package/src/server/validate.ts +89 -14
- package/src/shared/context.ts +7 -0
- package/src/shared/define-actions.ts +39 -0
- package/src/shared/form-data.ts +34 -0
- package/src/shared/route-types.ts +191 -2
- package/templates/new-app/app/root.tsx +2 -1
- package/templates/new-app/bractjs.config.ts +7 -12
- package/types/config.d.ts +24 -0
- package/types/index.d.ts +182 -9
- package/types/route.d.ts +138 -3
- package/LICENSE +0 -21
- package/README.md +0 -1125
package/src/index.ts
CHANGED
|
@@ -4,9 +4,13 @@ export { buildFetchHandler } from "./server/serve.ts";
|
|
|
4
4
|
export { defineContext } from "./server/context.ts";
|
|
5
5
|
export type { ContextFactory } from "./server/context.ts";
|
|
6
6
|
export { route } from "./server/api-route.ts";
|
|
7
|
-
export type { ApiRouteDefinition, AppApiRoutes } from "./server/api-route.ts";
|
|
8
|
-
export { validate } from "./server/validate.ts";
|
|
9
|
-
export type { FieldErrors, ValidationError } from "./server/validate.ts";
|
|
7
|
+
export type { ApiRouteDefinition, ApiRouteOptions, AppApiRoutes } from "./server/api-route.ts";
|
|
8
|
+
export { validate, safeValidate, isValidationResponse, readValidationError } from "./server/validate.ts";
|
|
9
|
+
export type { FieldErrors, ValidationError, SafeValidateResult } from "./server/validate.ts";
|
|
10
|
+
export { hasForbiddenKey, nullProtoFromEntries } from "./server/proto-guard.ts";
|
|
11
|
+
export { formText, formValues } from "./shared/form-data.ts";
|
|
12
|
+
export { defineActions } from "./shared/define-actions.ts";
|
|
13
|
+
export { validateSearch, searchParamsToObject } from "./server/search.ts";
|
|
10
14
|
export type { BractAdapter } from "./server/adapter.ts";
|
|
11
15
|
export { BunAdapter } from "./server/adapter.ts";
|
|
12
16
|
|
|
@@ -68,8 +72,19 @@ export type {
|
|
|
68
72
|
LoaderFunction,
|
|
69
73
|
ActionFunction,
|
|
70
74
|
MetaFunction,
|
|
75
|
+
HeadersFunction,
|
|
76
|
+
HeadersArgs,
|
|
77
|
+
RouteMiddlewareFunction,
|
|
78
|
+
ClientLoaderFunction,
|
|
79
|
+
ClientActionFunction,
|
|
80
|
+
RouteMatch,
|
|
71
81
|
RouteModule,
|
|
72
82
|
RouteDefinition,
|
|
83
|
+
RouterLocation,
|
|
84
|
+
ShouldRevalidateArgs,
|
|
85
|
+
ShouldRevalidateFunction,
|
|
86
|
+
LoaderData,
|
|
87
|
+
ActionData,
|
|
73
88
|
} from "./shared/route-types.ts";
|
|
74
89
|
export type { RouteFile, Segment } from "./server/scanner.ts";
|
|
75
90
|
|
|
@@ -79,8 +94,8 @@ export { BractJSContext, BractJSProvider, useBractJSContext } from "./shared/con
|
|
|
79
94
|
export type { BractJSContextValue, RouteManifest } from "./shared/context.ts";
|
|
80
95
|
|
|
81
96
|
// Middleware
|
|
82
|
-
export { pipeline, MiddlewarePipeline } from "./server/middleware.ts";
|
|
83
|
-
export type { MiddlewareFn, MiddlewareContext } from "./server/middleware.ts";
|
|
97
|
+
export { pipeline, MiddlewarePipeline, runRouteMiddleware, collectRouteMiddleware } from "./server/middleware.ts";
|
|
98
|
+
export type { MiddlewareFn, MiddlewareContext, RouteMiddleware } from "./server/middleware.ts";
|
|
84
99
|
export { requestLogger } from "./middleware/requestLogger.ts";
|
|
85
100
|
export { cors } from "./middleware/cors.ts";
|
|
86
101
|
export type { CorsOptions } from "./middleware/cors.ts";
|
|
@@ -102,17 +117,29 @@ export { Form } from "./client/components/Form.tsx";
|
|
|
102
117
|
export { Await } from "./client/components/Await.tsx";
|
|
103
118
|
export { Image } from "./client/components/Image.tsx";
|
|
104
119
|
export type { ImageProps, ImageFormat, ImageFit } from "./client/components/Image.tsx";
|
|
120
|
+
export { ScrollRestoration } from "./client/components/ScrollRestoration.tsx";
|
|
121
|
+
export type { ScrollRestorationProps } from "./client/components/ScrollRestoration.tsx";
|
|
105
122
|
|
|
106
123
|
// Client hooks
|
|
107
124
|
export { useLoaderData } from "./client/hooks/useLoaderData.ts";
|
|
108
125
|
export { useActionData } from "./client/hooks/useActionData.ts";
|
|
126
|
+
export { useLocation } from "./client/hooks/useLocation.ts";
|
|
109
127
|
export { useParams } from "./client/hooks/useParams.ts";
|
|
128
|
+
export { useMatches } from "./client/hooks/useMatches.ts";
|
|
110
129
|
export { useNavigation } from "./client/hooks/useNavigation.ts";
|
|
111
130
|
export { useNavigate } from "./client/hooks/useNavigate.ts";
|
|
112
131
|
export type { NavigateFn, NavigateOptions } from "./client/hooks/useNavigate.ts";
|
|
113
132
|
export { useFetcher } from "./client/hooks/useFetcher.ts";
|
|
133
|
+
export type { FetcherResult, FetcherFormProps, UseFetcherOptions } from "./client/hooks/useFetcher.ts";
|
|
134
|
+
export { useFetchers } from "./client/hooks/useFetchers.ts";
|
|
135
|
+
export type { FetcherEntry, FetcherState } from "./client/fetcher-store.ts";
|
|
136
|
+
export { useRevalidator } from "./client/hooks/useRevalidator.ts";
|
|
137
|
+
export type { Revalidator } from "./client/hooks/useRevalidator.ts";
|
|
114
138
|
export { useSearchParams } from "./client/hooks/useSearchParams.ts";
|
|
115
139
|
export type { SearchParamsResult } from "./client/hooks/useSearchParams.ts";
|
|
140
|
+
export { useSearch, useSetSearch } from "./client/hooks/useSearch.ts";
|
|
141
|
+
export type { SetSearchFn, SetSearchOptions } from "./client/hooks/useSearch.ts";
|
|
142
|
+
export { serializeSearch } from "./client/search-serializer.ts";
|
|
116
143
|
export { useBlocker } from "./client/hooks/useBlocker.ts";
|
|
117
144
|
export { useLocale } from "./client/hooks/useLocale.ts";
|
|
118
145
|
export { useLocalizedLink } from "./client/hooks/useLocalizedLink.ts";
|
|
@@ -127,6 +154,8 @@ export type {
|
|
|
127
154
|
RegisteredRoutes,
|
|
128
155
|
ParamsFor,
|
|
129
156
|
SearchFor,
|
|
157
|
+
SearchOutputFor,
|
|
158
|
+
InferSchemaOutput,
|
|
130
159
|
RouteSearchParamsMap,
|
|
131
160
|
RouteContextMap,
|
|
132
161
|
} from "./client/registry.ts";
|
|
@@ -141,4 +170,7 @@ export { createDevServer } from "./dev/server.ts";
|
|
|
141
170
|
export type { DevServerOptions, DevServer } from "./dev/server.ts";
|
|
142
171
|
export { runBuild } from "./build/bundler.ts";
|
|
143
172
|
export type { BuildConfig } from "./build/bundler.ts";
|
|
144
|
-
export { loadUserConfig } from "./config/load.ts";
|
|
173
|
+
export { loadUserConfig, defineConfig } from "./config/load.ts";
|
|
174
|
+
export { runPrerender } from "./build/prerender.ts";
|
|
175
|
+
export type { PrerenderOptions, PrerenderResult } from "./build/prerender.ts";
|
|
176
|
+
export { renderSpaShell } from "./server/spa.ts";
|
|
@@ -1,29 +1,19 @@
|
|
|
1
1
|
import { resolveAction } from "./action-registry.ts";
|
|
2
2
|
import { json } from "./response.ts";
|
|
3
|
-
import { isAllowedMutation } from "./csrf.ts";
|
|
3
|
+
import { isAllowedMutation, csrfForbiddenResponse } from "./csrf.ts";
|
|
4
|
+
import { hasForbiddenKey } from "./proto-guard.ts";
|
|
4
5
|
|
|
5
|
-
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
6
6
|
// Cap action JSON bodies. Anything over this looks like an abuse attempt;
|
|
7
7
|
// FormData uploads (large files) take the multipart branch and bypass this.
|
|
8
8
|
const MAX_JSON_BODY_BYTES = 1_048_576; // 1 MiB
|
|
9
9
|
|
|
10
|
-
// Deep scan: nested objects can carry __proto__ pollution vectors too.
|
|
11
|
-
function hasForbiddenKey(value: unknown, depth = 0): boolean {
|
|
12
|
-
if (depth > 20 || !value || typeof value !== "object") return false;
|
|
13
|
-
for (const key of Object.keys(value as Record<string, unknown>)) {
|
|
14
|
-
if (FORBIDDEN_KEYS.has(key)) return true;
|
|
15
|
-
if (hasForbiddenKey((value as Record<string, unknown>)[key], depth + 1)) return true;
|
|
16
|
-
}
|
|
17
|
-
return false;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
10
|
export async function handleActionRequest(request: Request): Promise<Response | null> {
|
|
21
11
|
const url = new URL(request.url);
|
|
22
12
|
// SECURITY(medium): exact-match prevents URL confusion (e.g. "/_actionfoo"
|
|
23
13
|
// would otherwise also reach this handler).
|
|
24
14
|
if (url.pathname !== "/_action") return null;
|
|
25
15
|
if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405 });
|
|
26
|
-
if (!isAllowedMutation(request)) return
|
|
16
|
+
if (!isAllowedMutation(request)) return csrfForbiddenResponse();
|
|
27
17
|
|
|
28
18
|
const id = url.searchParams.get("id");
|
|
29
19
|
if (!id) return new Response("Bad Request: missing action id", { status: 400 });
|
|
@@ -6,6 +6,37 @@ import { join, relative, resolve, isAbsolute } from "node:path";
|
|
|
6
6
|
const SERVER_RE = /^(?:\s|\/\/[^\n]*\n|\/\*[\s\S]*?\*\/)*["']use server["']/;
|
|
7
7
|
const registry = new Map<string, (...args: unknown[]) => Promise<unknown>>();
|
|
8
8
|
|
|
9
|
+
// SECURITY(high): exporting a function from a `"use server"` module publishes
|
|
10
|
+
// it as an unauthenticated RPC endpoint reachable via POST /_action and
|
|
11
|
+
// GET /_stream. For files under `routes/`, these reserved exports are framework
|
|
12
|
+
// lifecycle hooks / components — NOT intended as callable actions — so we never
|
|
13
|
+
// register them. Without this filter a route's `loader`/`action`/`default`
|
|
14
|
+
// could be invoked directly over the wire, bypassing search/param validation
|
|
15
|
+
// and (for /_stream) with zero arguments. Authors who genuinely want an action
|
|
16
|
+
// must export it under a different name.
|
|
17
|
+
const RESERVED_ROUTE_EXPORTS = new Set([
|
|
18
|
+
"default",
|
|
19
|
+
"loader",
|
|
20
|
+
"action",
|
|
21
|
+
"meta",
|
|
22
|
+
"beforeLoad",
|
|
23
|
+
"context",
|
|
24
|
+
"ErrorBoundary",
|
|
25
|
+
"Fallback",
|
|
26
|
+
"config",
|
|
27
|
+
"searchSchema",
|
|
28
|
+
"ssr",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
function isRouteFile(rel: string): boolean {
|
|
32
|
+
return rel.startsWith("routes/") || rel.startsWith("routes\\");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function shouldRegisterExport(name: string, fromRouteFile: boolean): boolean {
|
|
36
|
+
if (fromRouteFile && RESERVED_ROUTE_EXPORTS.has(name)) return false;
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
9
40
|
/**
|
|
10
41
|
* Hash key for an action — must use the same string the client-side proxy
|
|
11
42
|
* plugin hashes (`pathKey + "#" + name`). Mismatch → `/_action?id=...` 404.
|
|
@@ -61,8 +92,10 @@ export async function loadServerActions(appDir: string): Promise<void> {
|
|
|
61
92
|
continue;
|
|
62
93
|
}
|
|
63
94
|
|
|
95
|
+
const fromRouteFile = isRouteFile(rel);
|
|
64
96
|
for (const [name, val] of Object.entries(mod)) {
|
|
65
97
|
if (typeof val !== "function") continue;
|
|
98
|
+
if (!shouldRegisterExport(name, fromRouteFile)) continue;
|
|
66
99
|
const id = await computeId(pathKeyForAction(filePath, appDir), name);
|
|
67
100
|
registry.set(id, val as (...args: unknown[]) => Promise<unknown>);
|
|
68
101
|
}
|
|
@@ -82,8 +115,10 @@ export async function loadServerActionsFromRegistry(
|
|
|
82
115
|
entries: Array<{ relPath: string; mod: Record<string, unknown> }>,
|
|
83
116
|
): Promise<void> {
|
|
84
117
|
for (const { relPath, mod } of entries) {
|
|
118
|
+
const fromRouteFile = isRouteFile(relPath);
|
|
85
119
|
for (const [name, val] of Object.entries(mod)) {
|
|
86
120
|
if (typeof val !== "function") continue;
|
|
121
|
+
if (!shouldRegisterExport(name, fromRouteFile)) continue;
|
|
87
122
|
const id = await computeId(relPath, name);
|
|
88
123
|
registry.set(id, val as (...args: unknown[]) => Promise<unknown>);
|
|
89
124
|
}
|
package/src/server/adapter.ts
CHANGED
|
@@ -22,9 +22,24 @@ export interface BractAdapter {
|
|
|
22
22
|
* Default adapter — wraps `Bun.serve()`.
|
|
23
23
|
* Created internally by `createServer()` when no adapter is provided.
|
|
24
24
|
*/
|
|
25
|
+
// SECURITY(medium): hard ceiling on request body size at the server boundary,
|
|
26
|
+
// independent of any Content-Length the client advertises. The per-route and
|
|
27
|
+
// /_action handlers apply their own (smaller) caps and double-check the decoded
|
|
28
|
+
// size, but this is the single backstop every code path inherits — it bounds
|
|
29
|
+
// memory even for paths that don't pre-check (e.g. an app's own /api handler
|
|
30
|
+
// that reads request.formData() directly). Sits above the 10 MiB route-form
|
|
31
|
+
// cap so legitimate uploads still pass; raise it via the `maxRequestBodySize`
|
|
32
|
+
// config for apps with a dedicated large-upload endpoint.
|
|
33
|
+
const DEFAULT_MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024; // 16 MiB
|
|
34
|
+
|
|
25
35
|
export class BunAdapter implements BractAdapter {
|
|
26
36
|
private server: ReturnType<typeof Bun.serve> | null = null;
|
|
27
37
|
private handler: ((request: Request) => Promise<Response>) | null = null;
|
|
38
|
+
private maxRequestBodySize: number;
|
|
39
|
+
|
|
40
|
+
constructor(maxRequestBodySize: number = DEFAULT_MAX_REQUEST_BODY_BYTES) {
|
|
41
|
+
this.maxRequestBodySize = maxRequestBodySize;
|
|
42
|
+
}
|
|
28
43
|
|
|
29
44
|
setHandler(handler: (request: Request) => Promise<Response>): void {
|
|
30
45
|
this.handler = handler;
|
|
@@ -40,6 +55,7 @@ export class BunAdapter implements BractAdapter {
|
|
|
40
55
|
const handler = this.handler;
|
|
41
56
|
this.server = Bun.serve({
|
|
42
57
|
port,
|
|
58
|
+
maxRequestBodySize: this.maxRequestBodySize,
|
|
43
59
|
fetch: handler,
|
|
44
60
|
error(err: Error) {
|
|
45
61
|
console.error("[bractjs] unhandled server error:", err);
|
package/src/server/api-route.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { isExplicitDev } from "./env.ts";
|
|
2
|
+
import { isAllowedMutation, csrfForbiddenResponse } from "./csrf.ts";
|
|
3
|
+
import { hasForbiddenKey } from "./proto-guard.ts";
|
|
2
4
|
|
|
3
5
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
4
6
|
|
|
@@ -8,6 +10,27 @@ export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
|
8
10
|
// cannot exhaust memory. Same 1 MiB ceiling used by /_action JSON.
|
|
9
11
|
const MAX_BODY_BYTES = 1_048_576;
|
|
10
12
|
|
|
13
|
+
// SECURITY(high): the same state-changing methods the route-action / _action
|
|
14
|
+
// paths CSRF-gate. A typed API route using one of these is cross-site
|
|
15
|
+
// forgeable (cookies ride along; a form-encoded body is CORS-"simple" and
|
|
16
|
+
// skips preflight) unless the caller proves same-origin. We require that proof
|
|
17
|
+
// by default — see the gate in handleApiRequest.
|
|
18
|
+
const MUTATING_METHODS = new Set<HttpMethod>(["POST", "PUT", "PATCH", "DELETE"]);
|
|
19
|
+
|
|
20
|
+
export interface ApiRouteOptions {
|
|
21
|
+
/**
|
|
22
|
+
* Cross-site-request-forgery protection for this route. Default `true` for
|
|
23
|
+
* mutating methods (POST/PUT/PATCH/DELETE): the request must be same-origin
|
|
24
|
+
* (proven via `Sec-Fetch-Site`, the `X-BractJS-Action` header, or a matching
|
|
25
|
+
* `Origin`), exactly like server actions. Set `false` ONLY for endpoints
|
|
26
|
+
* that are safe to call cross-site — i.e. they do NOT rely on ambient
|
|
27
|
+
* credentials (session cookies / Basic auth) and are intentionally public
|
|
28
|
+
* (webhooks, token-authenticated APIs, public read/write services).
|
|
29
|
+
* Only GET is exempt from the gate; DELETE is treated as mutating.
|
|
30
|
+
*/
|
|
31
|
+
csrf?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
11
34
|
export interface ApiRouteDefinition<
|
|
12
35
|
TMethod extends HttpMethod,
|
|
13
36
|
TPath extends string,
|
|
@@ -17,6 +40,8 @@ export interface ApiRouteDefinition<
|
|
|
17
40
|
method: TMethod;
|
|
18
41
|
path: TPath;
|
|
19
42
|
handler: (input: TInput, request: Request) => TOutput | Promise<TOutput>;
|
|
43
|
+
/** Resolved CSRF setting (defaults applied at registration). */
|
|
44
|
+
csrf: boolean;
|
|
20
45
|
_types: { input: TInput; output: TOutput };
|
|
21
46
|
}
|
|
22
47
|
|
|
@@ -29,6 +54,11 @@ const routeRegistry: ApiRouteDefinition<HttpMethod, string, any, any>[] = [];
|
|
|
29
54
|
*
|
|
30
55
|
* Usage in app/api/users.ts:
|
|
31
56
|
* export const getUsers = bract.route("GET", "/api/users", async () => db.users.findAll());
|
|
57
|
+
*
|
|
58
|
+
* Mutating routes (POST/PUT/PATCH/DELETE) are CSRF-protected by default — the
|
|
59
|
+
* request must be same-origin. Pass `{ csrf: false }` for a deliberately public,
|
|
60
|
+
* credential-free endpoint (e.g. a webhook):
|
|
61
|
+
* bract.route("POST", "/api/webhook", handler, { csrf: false });
|
|
32
62
|
*/
|
|
33
63
|
export function route<
|
|
34
64
|
TMethod extends HttpMethod,
|
|
@@ -39,11 +69,14 @@ export function route<
|
|
|
39
69
|
method: TMethod,
|
|
40
70
|
path: TPath,
|
|
41
71
|
handler: (input: TInput, request: Request) => TOutput | Promise<TOutput>,
|
|
72
|
+
options?: ApiRouteOptions,
|
|
42
73
|
): ApiRouteDefinition<TMethod, TPath, TInput, TOutput> {
|
|
43
74
|
const def: ApiRouteDefinition<TMethod, TPath, TInput, TOutput> = {
|
|
44
75
|
method,
|
|
45
76
|
path,
|
|
46
77
|
handler,
|
|
78
|
+
// Default ON. Opt out only for endpoints that don't trust ambient creds.
|
|
79
|
+
csrf: options?.csrf ?? true,
|
|
47
80
|
_types: {} as { input: TInput; output: TOutput },
|
|
48
81
|
};
|
|
49
82
|
routeRegistry.push(def);
|
|
@@ -62,6 +95,14 @@ export async function handleApiRequest(request: Request): Promise<Response | nul
|
|
|
62
95
|
if (def.method !== request.method) continue;
|
|
63
96
|
if (!pathMatches(def.path, url.pathname)) continue;
|
|
64
97
|
|
|
98
|
+
// SECURITY(high): CSRF gate for mutating methods. Same check the route
|
|
99
|
+
// action / _action / _stream paths use, so an authenticated user's cookies
|
|
100
|
+
// can't be used to forge a cross-site write to an /api route. Routes that
|
|
101
|
+
// opt out (`csrf: false`) are responsible for not trusting ambient creds.
|
|
102
|
+
if (def.csrf && MUTATING_METHODS.has(def.method) && !isAllowedMutation(request)) {
|
|
103
|
+
return csrfForbiddenResponse();
|
|
104
|
+
}
|
|
105
|
+
|
|
65
106
|
let input: unknown = undefined;
|
|
66
107
|
if (request.method !== "GET" && request.method !== "DELETE") {
|
|
67
108
|
// Trust an advertised Content-Length up front so oversized payloads
|
|
@@ -86,6 +127,12 @@ export async function handleApiRequest(request: Request): Promise<Response | nul
|
|
|
86
127
|
} catch {
|
|
87
128
|
return new Response("Bad Request: invalid JSON", { status: 400 });
|
|
88
129
|
}
|
|
130
|
+
// SECURITY(high): reject prototype-pollution keys before the parsed
|
|
131
|
+
// body reaches a handler that might merge it into another object.
|
|
132
|
+
// Parity with the /_action JSON path.
|
|
133
|
+
if (hasForbiddenKey(input)) {
|
|
134
|
+
return new Response("Bad Request: forbidden keys", { status: 400 });
|
|
135
|
+
}
|
|
89
136
|
} else if (ct.includes("application/x-www-form-urlencoded") || ct.includes("multipart/form-data")) {
|
|
90
137
|
input = await request.formData();
|
|
91
138
|
}
|
package/src/server/csp.ts
CHANGED
|
@@ -21,6 +21,15 @@ export interface CspOptions {
|
|
|
21
21
|
* Useful for staging a policy before turning it on. Default: false.
|
|
22
22
|
*/
|
|
23
23
|
reportOnly?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Drop `'unsafe-inline'` from the default `style-src`. The baseline policy
|
|
26
|
+
* allows inline styles for ergonomics (React inline styles, CSS-in-JS), which
|
|
27
|
+
* leaves inline-style injection (CSS exfiltration / UI redress) possible.
|
|
28
|
+
* Set `strict: true` for `style-src 'self'` only — you must then serve all
|
|
29
|
+
* styles from same-origin stylesheets (or override `style-src` yourself with
|
|
30
|
+
* a nonce/hash via `directives`). Default: false.
|
|
31
|
+
*/
|
|
32
|
+
strict?: boolean;
|
|
24
33
|
}
|
|
25
34
|
|
|
26
35
|
/**
|
|
@@ -66,14 +75,20 @@ export function csp(options: CspOptions = {}): MiddlewareFn {
|
|
|
66
75
|
|
|
67
76
|
const directives: Record<string, string | null> = {
|
|
68
77
|
"default-src": "'self'",
|
|
69
|
-
// 'strict-dynamic'
|
|
70
|
-
// imports without each chunk
|
|
71
|
-
// in browsers that
|
|
78
|
+
// 'strict-dynamic': trust flows through the nonce — a nonced script may
|
|
79
|
+
// load the chunks it imports without each chunk carrying its own nonce.
|
|
80
|
+
// NOTE: in browsers that support 'strict-dynamic', the 'self' and any
|
|
81
|
+
// host/allowlist expressions in script-src are IGNORED; only the nonce
|
|
82
|
+
// (and scripts it transitively loads) are trusted. 'self' is kept solely
|
|
83
|
+
// as a fallback for older browsers that don't implement 'strict-dynamic'.
|
|
72
84
|
"script-src": `'self' 'nonce-${nonce}' 'strict-dynamic'`,
|
|
73
|
-
"style-src": "'self' 'unsafe-inline'",
|
|
85
|
+
"style-src": options.strict ? "'self'" : "'self' 'unsafe-inline'",
|
|
74
86
|
"img-src": "'self' data: blob:",
|
|
75
87
|
"connect-src": "'self'",
|
|
76
88
|
"base-uri": "'self'",
|
|
89
|
+
// Restrict where <form> can submit so an injected form can't exfiltrate
|
|
90
|
+
// to an attacker origin even if it slips past other controls.
|
|
91
|
+
"form-action": "'self'",
|
|
77
92
|
"frame-ancestors": "'self'",
|
|
78
93
|
"object-src": "'none'",
|
|
79
94
|
...(options.directives ?? {}),
|
package/src/server/csrf.ts
CHANGED
|
@@ -23,12 +23,45 @@
|
|
|
23
23
|
* none of these headers and are rejected by default — they must set
|
|
24
24
|
* `X-BractJS-Action` or a same-origin `Origin` to mutate.
|
|
25
25
|
*/
|
|
26
|
+
// This gate protects server actions (/_action), streaming actions (/_stream),
|
|
27
|
+
// route mutations, AND typed /api routes (see api-route.ts) — every
|
|
28
|
+
// state-changing, cookie-trusting surface in the framework.
|
|
29
|
+
//
|
|
26
30
|
// SECURITY(medium): X-BractJS-Action acts as a CSRF token by relying on CORS
|
|
27
31
|
// preflight blocking custom headers cross-origin. This is safe only while the
|
|
28
32
|
// server does NOT emit a permissive Access-Control-Allow-Headers listing this
|
|
29
|
-
// header.
|
|
30
|
-
//
|
|
31
|
-
// cryptographic double-submit token.
|
|
33
|
+
// header. The built-in cors() (middleware/cors.ts) deliberately omits it; if
|
|
34
|
+
// you ship your OWN CORS layer and expose this header cross-origin, you defeat
|
|
35
|
+
// CSRF everywhere — add a cryptographic double-submit token in that case.
|
|
36
|
+
// Sec-Fetch-Site (1) remains a browser-enforced backstop, but note it is NOT
|
|
37
|
+
// sent by every client/proxy: behind a header-stripping proxy the gate falls
|
|
38
|
+
// back to the same-origin Origin check, which cors() does not weaken.
|
|
39
|
+
import { isExplicitDev } from "./env.ts";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The developer-facing explanation of a CSRF rejection. In dev it spells out
|
|
43
|
+
* the accepted signals and the usual fix; in prod it stays terse so the 403
|
|
44
|
+
* leaks nothing. Used for the plain route/action 403 bodies — the stream
|
|
45
|
+
* handler embeds {@link csrfHint} in its SSE error event instead.
|
|
46
|
+
*/
|
|
47
|
+
export function csrfHint(): string {
|
|
48
|
+
return (
|
|
49
|
+
"Blocked a cross-site or unattributed mutation (CSRF protection). " +
|
|
50
|
+
"Same-origin browser requests are allowed automatically; a manual fetch() " +
|
|
51
|
+
'must send the header `X-BractJS-Action: 1` (BractJS\'s <Form> and ' +
|
|
52
|
+
"useFetcher do this for you)."
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A 403 Response for a rejected mutation: explanatory in dev, terse in prod. */
|
|
57
|
+
export function csrfForbiddenResponse(): Response {
|
|
58
|
+
if (isExplicitDev()) {
|
|
59
|
+
console.warn("[bractjs] 403 (CSRF): " + csrfHint());
|
|
60
|
+
return new Response("Forbidden — " + csrfHint(), { status: 403 });
|
|
61
|
+
}
|
|
62
|
+
return new Response("Forbidden", { status: 403 });
|
|
63
|
+
}
|
|
64
|
+
|
|
32
65
|
export function isAllowedMutation(request: Request): boolean {
|
|
33
66
|
// (1) Browser-enforced signal. If present, it vetoes cross-origin requests
|
|
34
67
|
// regardless of what the Origin/custom headers claim.
|
package/src/server/env.ts
CHANGED
|
@@ -20,6 +20,19 @@ export function isDevRuntime(): boolean {
|
|
|
20
20
|
return _runtimeMode === "dev";
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The dev HMR WebSocket port, set by createDevServer and read when rendering
|
|
25
|
+
* the dev bootstrap so the injected HMR client connects to the right port
|
|
26
|
+
* (the config's `hmrPort`, not a hardcoded 3001). 0 = default.
|
|
27
|
+
*/
|
|
28
|
+
let _devHmrPort = 0;
|
|
29
|
+
export function setDevHmrPort(port: number): void {
|
|
30
|
+
_devHmrPort = port;
|
|
31
|
+
}
|
|
32
|
+
export function getDevHmrPort(): number {
|
|
33
|
+
return _devHmrPort;
|
|
34
|
+
}
|
|
35
|
+
|
|
23
36
|
/**
|
|
24
37
|
* Strict "is development?" check used to gate sensitive output (error
|
|
25
38
|
* messages, stack traces) that would otherwise leak in production.
|
|
@@ -50,12 +63,20 @@ const LS = String.fromCharCode(0x2028);
|
|
|
50
63
|
const PS = String.fromCharCode(0x2029);
|
|
51
64
|
|
|
52
65
|
export function safeStringify(data: unknown): string {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
66
|
+
// Cycle detection must track ANCESTORS, not every visited node — a WeakSet
|
|
67
|
+
// of all seen objects flags legitimate shared references as "[Circular]"
|
|
68
|
+
// (e.g. a loader echoing `args.search` while the payload also carries
|
|
69
|
+
// `search` at the top level). MDN's replacer pattern: `this` is the holder
|
|
70
|
+
// object, so popping the stack until the top is the holder leaves exactly
|
|
71
|
+
// the current ancestor chain.
|
|
72
|
+
const ancestors: object[] = [];
|
|
73
|
+
const json = JSON.stringify(data, function (_key, value: unknown) {
|
|
74
|
+
if (typeof value !== "object" || value === null) return value;
|
|
75
|
+
while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) {
|
|
76
|
+
ancestors.pop();
|
|
58
77
|
}
|
|
78
|
+
if (ancestors.includes(value)) return "[Circular]";
|
|
79
|
+
ancestors.push(value);
|
|
59
80
|
return value;
|
|
60
81
|
});
|
|
61
82
|
// Escape HTML-sensitive chars + JS LineTerminators (U+2028 / U+2029) so this
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { LayoutChain } from "./layout.ts";
|
|
2
|
+
import type { LoaderResults } from "./loader.ts";
|
|
3
|
+
import type { HeadersFunction } from "../shared/route-types.ts";
|
|
4
|
+
|
|
5
|
+
type Params = Record<string, string>;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Walk the route module chain (root → layouts → route) calling each module's
|
|
9
|
+
* optional `headers()` export, threading the accumulated `Headers` through as
|
|
10
|
+
* `parentHeaders`. Each call's returned `HeadersInit` is merged on top, so the
|
|
11
|
+
* innermost route wins per key (RR7 semantics).
|
|
12
|
+
*
|
|
13
|
+
* Returns `null` when no module in the chain exports `headers` — callers keep
|
|
14
|
+
* their existing default headers untouched in that case.
|
|
15
|
+
*/
|
|
16
|
+
export function resolveHeaders(
|
|
17
|
+
chain: LayoutChain,
|
|
18
|
+
loaderData: LoaderResults,
|
|
19
|
+
params: Params,
|
|
20
|
+
request: Request,
|
|
21
|
+
): Headers | null {
|
|
22
|
+
const links: Array<{ fn: HeadersFunction; data: unknown }> = [];
|
|
23
|
+
|
|
24
|
+
if (chain.root.headers) links.push({ fn: chain.root.headers, data: loaderData.root });
|
|
25
|
+
chain.layouts.forEach((mod, i) => {
|
|
26
|
+
if (mod.headers) links.push({ fn: mod.headers, data: loaderData.layouts[i] ?? null });
|
|
27
|
+
});
|
|
28
|
+
if (chain.route.headers) links.push({ fn: chain.route.headers, data: loaderData.route });
|
|
29
|
+
|
|
30
|
+
if (links.length === 0) return null;
|
|
31
|
+
|
|
32
|
+
const merged = new Headers();
|
|
33
|
+
for (const { fn, data } of links) {
|
|
34
|
+
const produced = new Headers(fn({ loaderData: data, params, request, parentHeaders: merged }));
|
|
35
|
+
// `set` (not `append`) so an inner route overrides an ancestor's value for
|
|
36
|
+
// the same key rather than accumulating duplicates.
|
|
37
|
+
produced.forEach((value, key) => merged.set(key, value));
|
|
38
|
+
}
|
|
39
|
+
return merged;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Copy resolved route headers onto a base headers object, overriding any
|
|
44
|
+
* same-key defaults. Mutates and returns `base`. No-op when `resolved` is null.
|
|
45
|
+
*/
|
|
46
|
+
export function applyRouteHeaders(base: Headers, resolved: Headers | null): Headers {
|
|
47
|
+
if (resolved) resolved.forEach((value, key) => base.set(key, value));
|
|
48
|
+
return base;
|
|
49
|
+
}
|
package/src/server/layout.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join, resolve } from "node:path";
|
|
2
|
-
import type
|
|
2
|
+
import { layoutDirsFromFilePath, type RouteFile } from "./scanner.ts";
|
|
3
3
|
import type { RouteModule } from "../shared/route-types.ts";
|
|
4
4
|
|
|
5
5
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
@@ -8,6 +8,12 @@ export interface LayoutChain {
|
|
|
8
8
|
root: RouteModule;
|
|
9
9
|
layouts: RouteModule[];
|
|
10
10
|
route: RouteModule;
|
|
11
|
+
/**
|
|
12
|
+
* appDir-relative source paths for each module in the chain, for error
|
|
13
|
+
* messages ("loader error in routes/blog/[id].tsx"). Optional so hand-built
|
|
14
|
+
* chains in tests don't have to supply it.
|
|
15
|
+
*/
|
|
16
|
+
files?: { root?: string; layouts: string[]; route?: string };
|
|
11
17
|
}
|
|
12
18
|
|
|
13
19
|
export interface ResolvedRoute extends RouteFile {
|
|
@@ -22,21 +28,6 @@ export interface ResolvedRoute extends RouteFile {
|
|
|
22
28
|
*/
|
|
23
29
|
export type ModuleRegistry = Record<string, RouteModule | Record<string, unknown>>;
|
|
24
30
|
|
|
25
|
-
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
26
|
-
|
|
27
|
-
/** Derive the ancestor directory segments from a route's urlPattern. */
|
|
28
|
-
function layoutDirs(urlPattern: string): string[] {
|
|
29
|
-
if (urlPattern === "") return [];
|
|
30
|
-
const segments = urlPattern.split("/");
|
|
31
|
-
// For "blog/[id]" → check "routes/blog/layout.tsx" only (not the leaf)
|
|
32
|
-
segments.pop();
|
|
33
|
-
const dirs: string[] = [];
|
|
34
|
-
for (let i = 1; i <= segments.length; i++) {
|
|
35
|
-
dirs.push(segments.slice(0, i).join("/"));
|
|
36
|
-
}
|
|
37
|
-
return dirs;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
31
|
// ── resolveLayoutChain ─────────────────────────────────────────────────────
|
|
41
32
|
|
|
42
33
|
export async function resolveLayoutChain(
|
|
@@ -52,8 +43,9 @@ export async function resolveLayoutChain(
|
|
|
52
43
|
layoutFiles.push(rootPath);
|
|
53
44
|
}
|
|
54
45
|
|
|
55
|
-
// Intermediate layout.tsx files, outermost → innermost
|
|
56
|
-
|
|
46
|
+
// Intermediate layout.tsx files, outermost → innermost. Derived from the
|
|
47
|
+
// file path so route-group folders ((marketing)/…) contribute their layout.
|
|
48
|
+
for (const dir of layoutDirsFromFilePath(routeFile.filePath)) {
|
|
57
49
|
const layoutPath = resolve(join(appDir, "routes", dir, "layout.tsx"));
|
|
58
50
|
if (await Bun.file(layoutPath).exists()) {
|
|
59
51
|
layoutFiles.push(layoutPath);
|
|
@@ -78,7 +70,7 @@ export function resolveLayoutChainFromRegistry(
|
|
|
78
70
|
if (registry["root.tsx"]) layoutFiles.push("root.tsx");
|
|
79
71
|
else if (registry["root.ts"]) layoutFiles.push("root.ts");
|
|
80
72
|
|
|
81
|
-
for (const dir of
|
|
73
|
+
for (const dir of layoutDirsFromFilePath(routeFile.filePath)) {
|
|
82
74
|
const tsxKey = `routes/${dir}/layout.tsx`;
|
|
83
75
|
const tsKey = `routes/${dir}/layout.ts`;
|
|
84
76
|
if (registry[tsxKey]) layoutFiles.push(tsxKey);
|
|
@@ -96,12 +88,24 @@ export async function importRouteModule(filePath: string): Promise<RouteModule>
|
|
|
96
88
|
loader: mod.loader,
|
|
97
89
|
action: mod.action,
|
|
98
90
|
meta: mod.meta,
|
|
91
|
+
headers: mod.headers,
|
|
92
|
+
// SECURITY(high): like beforeLoad, route middleware can be an auth gate —
|
|
93
|
+
// project it or every `middleware` export becomes a silent no-op.
|
|
94
|
+
middleware: mod.middleware,
|
|
99
95
|
// SECURITY(high): beforeLoad is the auth/redirect gate and `context` is the
|
|
100
96
|
// per-route context factory. Both MUST be projected here — dropping them
|
|
101
97
|
// turns every beforeLoad() export into a silent no-op, bypassing auth on
|
|
102
98
|
// full-page GET, POST actions, and the /_data soft-nav endpoint alike.
|
|
103
99
|
beforeLoad: mod.beforeLoad,
|
|
104
100
|
context: mod.context,
|
|
101
|
+
// searchSchema gates loader input — dropping it silently skips search
|
|
102
|
+
// validation, so loaders would see raw strings where they expect coerced data.
|
|
103
|
+
searchSchema: mod.searchSchema,
|
|
104
|
+
// Selective-SSR surface: dropping `ssr` would silently restore full SSR
|
|
105
|
+
// (running loaders the route opted out of); dropping `Fallback` would
|
|
106
|
+
// SSR an empty outlet and guarantee a hydration mismatch.
|
|
107
|
+
ssr: mod.ssr,
|
|
108
|
+
Fallback: mod.Fallback,
|
|
105
109
|
handle: mod.handle,
|
|
106
110
|
ErrorBoundary: mod.ErrorBoundary,
|
|
107
111
|
default: mod.default,
|
|
@@ -120,10 +124,18 @@ function pickRouteModule(mod: Record<string, unknown> | RouteModule | undefined)
|
|
|
120
124
|
loader: m.loader as RouteModule["loader"],
|
|
121
125
|
action: m.action as RouteModule["action"],
|
|
122
126
|
meta: m.meta as RouteModule["meta"],
|
|
127
|
+
headers: m.headers as RouteModule["headers"],
|
|
128
|
+
// SECURITY(high): keep middleware (auth gate) — see importRouteModule.
|
|
129
|
+
middleware: m.middleware as RouteModule["middleware"],
|
|
123
130
|
// SECURITY(high): keep beforeLoad + context in the projection — see the
|
|
124
131
|
// note in importRouteModule. The compiled-binary path goes through here.
|
|
125
132
|
beforeLoad: m.beforeLoad as RouteModule["beforeLoad"],
|
|
126
133
|
context: m.context as unknown,
|
|
134
|
+
// Keep searchSchema too — see importRouteModule. Missing it here would
|
|
135
|
+
// skip search validation only in compiled binaries, the worst kind of skew.
|
|
136
|
+
searchSchema: m.searchSchema,
|
|
137
|
+
ssr: m.ssr as RouteModule["ssr"],
|
|
138
|
+
Fallback: m.Fallback as RouteModule["Fallback"],
|
|
127
139
|
handle: m.handle as RouteModule["handle"],
|
|
128
140
|
ErrorBoundary: m.ErrorBoundary as RouteModule["ErrorBoundary"],
|
|
129
141
|
default: m.default as RouteModule["default"],
|
|
@@ -155,7 +167,12 @@ export async function resolveRouteChain(
|
|
|
155
167
|
const layoutMods = layoutKeys.map((k) => pickRouteModule(registry[k]));
|
|
156
168
|
const routeKey = routeFile.filePath.split("\\").join("/");
|
|
157
169
|
const routeMod = pickRouteModule(registry[routeKey]);
|
|
158
|
-
return {
|
|
170
|
+
return {
|
|
171
|
+
root: rootMod,
|
|
172
|
+
layouts: layoutMods,
|
|
173
|
+
route: routeMod,
|
|
174
|
+
files: { root: rootKey, layouts: layoutKeys, route: routeKey },
|
|
175
|
+
};
|
|
159
176
|
}
|
|
160
177
|
|
|
161
178
|
const resolved = await resolveLayoutChain(routeFile, appDir);
|
|
@@ -167,9 +184,15 @@ export async function resolveRouteChain(
|
|
|
167
184
|
resolve(join(appDir, routeFile.filePath))
|
|
168
185
|
);
|
|
169
186
|
|
|
187
|
+
// Relativize the absolute layout paths back to appDir-relative for messages.
|
|
188
|
+
const appRoot = resolve(appDir);
|
|
189
|
+
const rel = (abs: string) => abs.startsWith(appRoot + "/") ? abs.slice(appRoot.length + 1) : abs;
|
|
190
|
+
const [rootFile, ...layoutFiles] = resolved.layoutFiles.map(rel);
|
|
191
|
+
|
|
170
192
|
return {
|
|
171
193
|
root: rootMod ?? {},
|
|
172
194
|
layouts: layoutMods,
|
|
173
195
|
route: routeMod,
|
|
196
|
+
files: { root: rootFile, layouts: layoutFiles, route: routeFile.filePath },
|
|
174
197
|
};
|
|
175
198
|
}
|