@bractjs/bractjs 0.1.6 → 0.1.8
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/package.json +1 -1
- package/src/__tests__/loader.test.ts +5 -2
- package/src/adapters/cloudflare.ts +65 -0
- package/src/build/bundler.ts +5 -3
- package/src/build/env-plugin.ts +7 -0
- package/src/build/plugins/css-modules.ts +110 -0
- package/src/client/ClientRouter.tsx +113 -9
- package/src/client/cache.ts +69 -0
- package/src/client/components/Link.tsx +16 -2
- package/src/client/components/LiveReload.tsx +4 -0
- package/src/client/hooks/useBlocker.ts +44 -0
- package/src/client/hooks/useFetcher.ts +66 -6
- package/src/client/hooks/useLocale.ts +12 -0
- package/src/client/hooks/useLocalizedLink.ts +18 -0
- package/src/client/hooks/useSearchParams.ts +74 -0
- package/src/client/rpc.ts +70 -0
- package/src/codegen/route-codegen.ts +63 -1
- package/src/dev/devtools.ts +144 -0
- package/src/dev/hmr-client.ts +14 -0
- package/src/dev/hmr-module-handler.ts +17 -1
- package/src/dev/hmr-server.ts +16 -0
- package/src/image/handler.ts +5 -2
- package/src/image/optimizer.ts +6 -1
- package/src/index.ts +27 -0
- package/src/middleware/cors.ts +4 -0
- package/src/middleware/requestLogger.ts +4 -0
- package/src/server/action-handler.ts +8 -4
- package/src/server/adapter.ts +57 -0
- package/src/server/api-route.ts +127 -0
- package/src/server/context.ts +22 -0
- package/src/server/csrf.ts +1 -0
- package/src/server/env.ts +16 -0
- package/src/server/i18n.ts +63 -0
- package/src/server/loader.ts +61 -1
- package/src/server/render.ts +7 -0
- package/src/server/request-handler.ts +66 -8
- package/src/server/serve.ts +102 -55
- package/src/server/session.ts +1 -0
- package/src/server/static.ts +8 -1
- package/src/server/stream-handler.ts +111 -0
- package/src/server/validate.ts +89 -0
- package/src/shared/route-types.ts +11 -0
- package/types/index.d.ts +94 -1
- package/types/route.d.ts +11 -0
|
@@ -16,10 +16,70 @@ interface FetcherResult {
|
|
|
16
16
|
submit(path: string, opts: SubmitOptions): Promise<void>;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
interface StreamFetcherResult<T = unknown> {
|
|
20
|
+
events: AsyncGenerator<T>;
|
|
21
|
+
connect(actionId: string): AsyncGenerator<T>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ── SSE async generator ────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
async function* sseStream<T>(actionId: string): AsyncGenerator<T> {
|
|
27
|
+
// Send X-BractJS-Action so the server's CSRF gate accepts this same-origin
|
|
28
|
+
// GET. Cross-origin <script>/<img>/<link rel=prefetch> tags cannot set this
|
|
29
|
+
// header, so the gate blocks CSRF invocations of server actions.
|
|
30
|
+
const res = await fetch(`/_stream?id=${encodeURIComponent(actionId)}`, {
|
|
31
|
+
headers: { "X-BractJS-Action": "1" },
|
|
32
|
+
});
|
|
33
|
+
if (!res.ok || !res.body) {
|
|
34
|
+
throw new Error(`[bractjs] /_stream ${res.status}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const reader = res.body.getReader();
|
|
38
|
+
const decoder = new TextDecoder();
|
|
39
|
+
let buf = "";
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
while (true) {
|
|
43
|
+
const { done, value } = await reader.read();
|
|
44
|
+
if (done) break;
|
|
45
|
+
buf += decoder.decode(value, { stream: true });
|
|
46
|
+
const parts = buf.split("\n\n");
|
|
47
|
+
buf = parts.pop() ?? "";
|
|
48
|
+
for (const part of parts) {
|
|
49
|
+
const lines = part.trim().split("\n");
|
|
50
|
+
let event = "data";
|
|
51
|
+
let data = "";
|
|
52
|
+
for (const line of lines) {
|
|
53
|
+
if (line.startsWith("event: ")) event = line.slice(7).trim();
|
|
54
|
+
else if (line.startsWith("data: ")) data = line.slice(6);
|
|
55
|
+
}
|
|
56
|
+
if (event === "done") return;
|
|
57
|
+
if (event === "error") throw new Error((JSON.parse(data) as { message: string }).message);
|
|
58
|
+
if (event === "data" && data) yield JSON.parse(data) as T;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} finally {
|
|
62
|
+
reader.releaseLock();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
19
66
|
// ── Hook ───────────────────────────────────────────────────────────────────
|
|
20
67
|
|
|
21
|
-
export function useFetcher(): FetcherResult
|
|
68
|
+
export function useFetcher(): FetcherResult;
|
|
69
|
+
export function useFetcher<T>(opts: { stream: true }): StreamFetcherResult<T>;
|
|
70
|
+
export function useFetcher<T>(opts?: { stream?: boolean }): FetcherResult | StreamFetcherResult<T> {
|
|
71
|
+
if (opts?.stream) {
|
|
72
|
+
return {
|
|
73
|
+
events: (null as unknown) as AsyncGenerator<T>,
|
|
74
|
+
connect(actionId: string): AsyncGenerator<T> {
|
|
75
|
+
return sseStream<T>(actionId);
|
|
76
|
+
},
|
|
77
|
+
} satisfies StreamFetcherResult<T>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
22
81
|
const [data, setData] = useState<unknown>(undefined);
|
|
82
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
23
83
|
const [state, setState] = useState<FetcherState>("idle");
|
|
24
84
|
|
|
25
85
|
async function load(path: string): Promise<void> {
|
|
@@ -33,14 +93,14 @@ export function useFetcher(): FetcherResult {
|
|
|
33
93
|
}
|
|
34
94
|
}
|
|
35
95
|
|
|
36
|
-
async function submit(path: string,
|
|
96
|
+
async function submit(path: string, submitOpts: SubmitOptions): Promise<void> {
|
|
37
97
|
setState("submitting");
|
|
38
98
|
try {
|
|
39
99
|
const body =
|
|
40
|
-
|
|
41
|
-
?
|
|
42
|
-
: new URLSearchParams(
|
|
43
|
-
const res = await fetch(path, { method:
|
|
100
|
+
submitOpts.body instanceof FormData
|
|
101
|
+
? submitOpts.body
|
|
102
|
+
: new URLSearchParams(submitOpts.body as Record<string, string>);
|
|
103
|
+
const res = await fetch(path, { method: submitOpts.method, body });
|
|
44
104
|
setData(await res.json());
|
|
45
105
|
} finally {
|
|
46
106
|
setState("idle");
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useParams } from "./useParams.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns the current locale from URL params.
|
|
5
|
+
* Works when the router is configured with i18n prefix routes (`/:locale/...`).
|
|
6
|
+
*
|
|
7
|
+
* Falls back to `defaultLocale` when no locale param is present (e.g. SSR without locale prefix).
|
|
8
|
+
*/
|
|
9
|
+
export function useLocale(defaultLocale = "en"): string {
|
|
10
|
+
const params = useParams<{ locale?: string }>();
|
|
11
|
+
return params.locale ?? defaultLocale;
|
|
12
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useLocale } from "./useLocale.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns a helper that prepends the current locale to a path.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* const localizedTo = useLocalizedLink();
|
|
8
|
+
* <Link to={localizedTo('/about')} /> // → /en/about
|
|
9
|
+
*/
|
|
10
|
+
export function useLocalizedLink(defaultLocale = "en"): (path: string) => string {
|
|
11
|
+
const locale = useLocale(defaultLocale);
|
|
12
|
+
return (path: string) => {
|
|
13
|
+
// Don't double-prefix if the path already starts with /<locale>.
|
|
14
|
+
const alreadyPrefixed = path.startsWith(`/${locale}/`) || path === `/${locale}`;
|
|
15
|
+
if (alreadyPrefixed) return path;
|
|
16
|
+
return `/${locale}${path.startsWith("/") ? path : "/" + path}`;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useState, useCallback, useEffect, useRef, startTransition } from "react";
|
|
2
|
+
import { NavigationContext } from "../router.tsx";
|
|
3
|
+
import { useContext } from "react";
|
|
4
|
+
|
|
5
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
type SetSearchParams = (
|
|
8
|
+
updater: Record<string, string> | ((prev: URLSearchParams) => URLSearchParams),
|
|
9
|
+
) => void;
|
|
10
|
+
|
|
11
|
+
export interface SearchParamsResult<T extends Record<string, string>> {
|
|
12
|
+
searchParams: URLSearchParams;
|
|
13
|
+
getParam<K extends keyof T & string>(key: K): T[K] | null;
|
|
14
|
+
setSearchParams: SetSearchParams;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ── Hook ───────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Reads and writes URL search params, typed per-route via generic T.
|
|
21
|
+
* Triggers a loader re-run (soft-nav fetch) when params change.
|
|
22
|
+
*
|
|
23
|
+
* T is the route's SearchParams shape (e.g. { page: string; sort: string }).
|
|
24
|
+
* This hook is SSR-safe: on the server window is absent, so it returns empty params.
|
|
25
|
+
*/
|
|
26
|
+
export function useSearchParams<T extends Record<string, string> = Record<string, string>>(): SearchParamsResult<T> {
|
|
27
|
+
const navCtx = useContext(NavigationContext);
|
|
28
|
+
|
|
29
|
+
function readCurrent(): URLSearchParams {
|
|
30
|
+
if (typeof window === "undefined") return new URLSearchParams();
|
|
31
|
+
return new URLSearchParams(window.location.search);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const [searchParams, setSearchParamsState] = useState<URLSearchParams>(readCurrent);
|
|
35
|
+
|
|
36
|
+
// Track whether we triggered the change ourselves to avoid double re-run.
|
|
37
|
+
const selfTriggerRef = useRef(false);
|
|
38
|
+
|
|
39
|
+
// Sync when the browser's history changes (back/forward, external pushState).
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
function onPopState() {
|
|
42
|
+
setSearchParamsState(new URLSearchParams(window.location.search));
|
|
43
|
+
}
|
|
44
|
+
window.addEventListener("popstate", onPopState);
|
|
45
|
+
return () => window.removeEventListener("popstate", onPopState);
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
const setSearchParams: SetSearchParams = useCallback((updater) => {
|
|
49
|
+
const next =
|
|
50
|
+
typeof updater === "function"
|
|
51
|
+
? updater(new URLSearchParams(window.location.search))
|
|
52
|
+
: new URLSearchParams(updater);
|
|
53
|
+
|
|
54
|
+
const newSearch = next.toString();
|
|
55
|
+
const newUrl = window.location.pathname + (newSearch ? "?" + newSearch : "") + window.location.hash;
|
|
56
|
+
|
|
57
|
+
// Update browser URL without pushing a new history entry if only params changed.
|
|
58
|
+
history.pushState({}, "", newUrl);
|
|
59
|
+
selfTriggerRef.current = true;
|
|
60
|
+
startTransition(() => setSearchParamsState(next));
|
|
61
|
+
|
|
62
|
+
// Trigger a loader re-run via the NavigationContext navigate so the full
|
|
63
|
+
// soft-nav fetch path is exercised (meta update, module swap, etc.).
|
|
64
|
+
if (navCtx) {
|
|
65
|
+
void navCtx.navigate(window.location.pathname + (newSearch ? "?" + newSearch : ""));
|
|
66
|
+
}
|
|
67
|
+
}, [navCtx]);
|
|
68
|
+
|
|
69
|
+
const getParam = useCallback(<K extends keyof T & string>(key: K): T[K] | null => {
|
|
70
|
+
return (searchParams.get(key) as T[K] | null);
|
|
71
|
+
}, [searchParams]);
|
|
72
|
+
|
|
73
|
+
return { searchParams, getParam, setSearchParams };
|
|
74
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// ── createClient ───────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Creates a fully-typed fetch client for BractJS API routes.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* import type { AppApiRoutes } from 'bractjs';
|
|
8
|
+
* const client = createClient<AppApiRoutes>();
|
|
9
|
+
* const users = await client['/api/users'].GET();
|
|
10
|
+
*
|
|
11
|
+
* The proxy builds the fetch URL from the property access chain and HTTP method,
|
|
12
|
+
* so `client['/api/users'].GET()` calls `GET /api/users`.
|
|
13
|
+
*
|
|
14
|
+
* This is intentionally minimal (no batching, no retries) — add wrapping as needed.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
|
|
18
|
+
|
|
19
|
+
// Given a union of route definitions (method/path/input/output), extract
|
|
20
|
+
// the output type for a specific method + path pair.
|
|
21
|
+
type RouteOutput<
|
|
22
|
+
TRoutes extends { method: string; path: string; input: unknown; output: unknown },
|
|
23
|
+
TMethod extends string,
|
|
24
|
+
TPath extends string,
|
|
25
|
+
> = Extract<TRoutes, { method: TMethod; path: TPath }>["output"];
|
|
26
|
+
|
|
27
|
+
type RouteInput<
|
|
28
|
+
TRoutes extends { method: string; path: string; input: unknown; output: unknown },
|
|
29
|
+
TMethod extends string,
|
|
30
|
+
TPath extends string,
|
|
31
|
+
> = Extract<TRoutes, { method: TMethod; path: TPath }>["input"];
|
|
32
|
+
|
|
33
|
+
type ApiClient<TRoutes extends { method: string; path: string; input: unknown; output: unknown }> = {
|
|
34
|
+
[TPath in TRoutes["path"]]: {
|
|
35
|
+
[TMethod in Extract<TRoutes, { path: TPath }>["method"]]: (
|
|
36
|
+
input?: RouteInput<TRoutes, TMethod, TPath>,
|
|
37
|
+
) => Promise<UnwrapPromise<RouteOutput<TRoutes, TMethod, TPath>>>;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function createClient<
|
|
42
|
+
TRoutes extends { method: string; path: string; input: unknown; output: unknown },
|
|
43
|
+
>(baseUrl = ""): ApiClient<TRoutes> {
|
|
44
|
+
return new Proxy({} as ApiClient<TRoutes>, {
|
|
45
|
+
get(_target, path: string) {
|
|
46
|
+
return new Proxy({} as Record<string, unknown>, {
|
|
47
|
+
get(_t, method: string) {
|
|
48
|
+
return async (input?: unknown) => {
|
|
49
|
+
const httpMethod = method.toUpperCase();
|
|
50
|
+
const url = baseUrl + path;
|
|
51
|
+
const hasBody = httpMethod !== "GET" && httpMethod !== "DELETE" && input !== undefined;
|
|
52
|
+
const res = await fetch(url, {
|
|
53
|
+
method: httpMethod,
|
|
54
|
+
headers: hasBody ? { "Content-Type": "application/json" } : undefined,
|
|
55
|
+
body: hasBody ? JSON.stringify(input) : undefined,
|
|
56
|
+
});
|
|
57
|
+
if (!res.ok) {
|
|
58
|
+
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
59
|
+
throw Object.assign(new Error((err as { error?: string }).error ?? res.statusText), {
|
|
60
|
+
status: res.status,
|
|
61
|
+
response: res,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return res.json();
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -78,6 +78,61 @@ const HEADER =
|
|
|
78
78
|
"\n" +
|
|
79
79
|
"/* eslint-disable */\n";
|
|
80
80
|
|
|
81
|
+
function searchParamsTypeLines(routes: Array<{ pattern: string }>): string {
|
|
82
|
+
// Route files may declare `export type SearchParams = { page: string }`.
|
|
83
|
+
// We emit a mapped type that falls back to Record<string,string> per route.
|
|
84
|
+
// Users augment via module augmentation or via their route file's export.
|
|
85
|
+
if (routes.length === 0) {
|
|
86
|
+
return "export type SearchParams<_T extends AppRoutes> = Record<string, string>;";
|
|
87
|
+
}
|
|
88
|
+
const branches = routes
|
|
89
|
+
.map((r) => {
|
|
90
|
+
assertSafePattern(r.pattern);
|
|
91
|
+
return " T extends " + JSON.stringify(r.pattern) + " ? RouteSearchParamsMap[" + JSON.stringify(r.pattern) + "] :";
|
|
92
|
+
})
|
|
93
|
+
.join("\n");
|
|
94
|
+
const mapEntries = routes
|
|
95
|
+
.map((r) => " " + JSON.stringify(r.pattern) + ": Record<string, string>;")
|
|
96
|
+
.join("\n");
|
|
97
|
+
return [
|
|
98
|
+
"// Augment RouteSearchParamsMap to type search params per route:",
|
|
99
|
+
"// declare module 'bractjs' { interface RouteSearchParamsMap { '/blog': { page: string } } }",
|
|
100
|
+
"export interface RouteSearchParamsMap {",
|
|
101
|
+
mapEntries,
|
|
102
|
+
"}",
|
|
103
|
+
"",
|
|
104
|
+
"export type SearchParams<T extends AppRoutes> =",
|
|
105
|
+
branches,
|
|
106
|
+
" Record<string, string>;",
|
|
107
|
+
].join("\n");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function contextTypeLines(routes: Array<{ pattern: string }>): string {
|
|
111
|
+
if (routes.length === 0) {
|
|
112
|
+
return "export type Context<_T extends AppRoutes> = Record<string, unknown>;";
|
|
113
|
+
}
|
|
114
|
+
const branches = routes
|
|
115
|
+
.map((r) => {
|
|
116
|
+
assertSafePattern(r.pattern);
|
|
117
|
+
return " T extends " + JSON.stringify(r.pattern) + " ? RouteContextMap[" + JSON.stringify(r.pattern) + "] :";
|
|
118
|
+
})
|
|
119
|
+
.join("\n");
|
|
120
|
+
const mapEntries = routes
|
|
121
|
+
.map((r) => " " + JSON.stringify(r.pattern) + ": Record<string, unknown>;")
|
|
122
|
+
.join("\n");
|
|
123
|
+
return [
|
|
124
|
+
"// Augment RouteContextMap to type context per route:",
|
|
125
|
+
"// declare module 'bractjs' { interface RouteContextMap { '/blog': { user: User } } }",
|
|
126
|
+
"export interface RouteContextMap {",
|
|
127
|
+
mapEntries,
|
|
128
|
+
"}",
|
|
129
|
+
"",
|
|
130
|
+
"export type Context<T extends AppRoutes> =",
|
|
131
|
+
branches,
|
|
132
|
+
" Record<string, unknown>;",
|
|
133
|
+
].join("\n");
|
|
134
|
+
}
|
|
135
|
+
|
|
81
136
|
export async function generateRouteTypes(appDir: string): Promise<string> {
|
|
82
137
|
const routeFiles = await scanRoutes(appDir);
|
|
83
138
|
const routes = routeFiles.map((r) => ({
|
|
@@ -101,14 +156,21 @@ export async function generateRouteTypes(appDir: string): Promise<string> {
|
|
|
101
156
|
"",
|
|
102
157
|
paramsTypeLines(routes),
|
|
103
158
|
"",
|
|
159
|
+
searchParamsTypeLines(routes),
|
|
160
|
+
"",
|
|
161
|
+
contextTypeLines(routes),
|
|
162
|
+
"",
|
|
104
163
|
"export type TypedLoaderArgs<T extends AppRoutes> = {",
|
|
105
164
|
" request: Request;",
|
|
106
165
|
" params: RouteParams<T>;",
|
|
107
|
-
" context:
|
|
166
|
+
" context: Context<T>;",
|
|
108
167
|
"};",
|
|
109
168
|
"export type TypedActionArgs<T extends AppRoutes> =",
|
|
110
169
|
" TypedLoaderArgs<T> & { formData: FormData };",
|
|
111
170
|
"",
|
|
171
|
+
"/** A locale-prefixed variant of a route (E2 i18n routing). */",
|
|
172
|
+
"export type LocalizedRoute<T extends AppRoutes> = `/${string}${T}`;",
|
|
173
|
+
"",
|
|
112
174
|
"export const routes = {",
|
|
113
175
|
builderEntries,
|
|
114
176
|
"} as const;",
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BractJS DevTools Panel (E3).
|
|
3
|
+
*
|
|
4
|
+
* In dev mode this module is imported by the HMR client and registers a
|
|
5
|
+
* `<bractjs-devtools>` custom element. The element reads shared state from
|
|
6
|
+
* `window.__BRACTJS_DEVTOOLS__` which is populated by ClientRouter.
|
|
7
|
+
*
|
|
8
|
+
* Ctrl+Shift+B toggles the panel.
|
|
9
|
+
* Zero production overhead — this file is never imported in production because
|
|
10
|
+
* it is only loaded via `if (__BRACT_DEV__)` in the HMR client.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface DevtoolsState {
|
|
14
|
+
route: string | null;
|
|
15
|
+
loaderData: Record<string, unknown>;
|
|
16
|
+
navState: string;
|
|
17
|
+
cacheEntries: Array<{ key: string; age: number; staleTime: number; gcTime: number }>;
|
|
18
|
+
beforeLoadTrace: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
declare global {
|
|
22
|
+
interface Window {
|
|
23
|
+
__BRACTJS_DEVTOOLS__?: DevtoolsState;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const PANEL_ID = "bractjs-devtools-panel";
|
|
28
|
+
|
|
29
|
+
class BractJSDevtools extends HTMLElement {
|
|
30
|
+
private open = false;
|
|
31
|
+
private panel: HTMLDivElement | null = null;
|
|
32
|
+
|
|
33
|
+
connectedCallback() {
|
|
34
|
+
this.style.cssText = "position:fixed;bottom:0;right:0;z-index:2147483647;font-family:monospace;";
|
|
35
|
+
|
|
36
|
+
const toggle = document.createElement("button");
|
|
37
|
+
toggle.textContent = "⚡ BractJS";
|
|
38
|
+
toggle.style.cssText =
|
|
39
|
+
"background:#1e1e1e;color:#61dafb;border:none;padding:4px 10px;cursor:pointer;font-size:12px;";
|
|
40
|
+
toggle.onclick = () => this.togglePanel();
|
|
41
|
+
this.appendChild(toggle);
|
|
42
|
+
|
|
43
|
+
// Keyboard shortcut
|
|
44
|
+
document.addEventListener("keydown", (e) => {
|
|
45
|
+
if (e.ctrlKey && e.shiftKey && e.key === "B") {
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
this.togglePanel();
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private togglePanel() {
|
|
53
|
+
if (this.panel) {
|
|
54
|
+
this.panel.remove();
|
|
55
|
+
this.panel = null;
|
|
56
|
+
this.open = false;
|
|
57
|
+
} else {
|
|
58
|
+
this.open = true;
|
|
59
|
+
this.renderPanel();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private renderPanel() {
|
|
64
|
+
const state = window.__BRACTJS_DEVTOOLS__ ?? {
|
|
65
|
+
route: null,
|
|
66
|
+
loaderData: {},
|
|
67
|
+
navState: "idle",
|
|
68
|
+
cacheEntries: [],
|
|
69
|
+
beforeLoadTrace: [],
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const panel = document.createElement("div");
|
|
73
|
+
panel.id = PANEL_ID;
|
|
74
|
+
panel.style.cssText =
|
|
75
|
+
"background:#1e1e1e;color:#ccc;width:480px;max-height:60vh;overflow:auto;" +
|
|
76
|
+
"border-top:2px solid #61dafb;border-left:2px solid #61dafb;padding:12px;font-size:11px;";
|
|
77
|
+
|
|
78
|
+
const header = document.createElement("div");
|
|
79
|
+
header.style.cssText = "color:#61dafb;font-weight:bold;margin-bottom:8px;font-size:13px;";
|
|
80
|
+
header.textContent = "BractJS DevTools";
|
|
81
|
+
panel.appendChild(header);
|
|
82
|
+
|
|
83
|
+
this.section(panel, "Route", state.route ?? "(none)");
|
|
84
|
+
this.section(panel, "Navigation state", state.navState);
|
|
85
|
+
this.section(panel, "Loader data", JSON.stringify(state.loaderData, null, 2));
|
|
86
|
+
|
|
87
|
+
if (state.cacheEntries.length > 0) {
|
|
88
|
+
const cacheText = state.cacheEntries
|
|
89
|
+
.map((e) => `${e.key}\n age=${e.age}ms stale=${e.staleTime}ms gc=${e.gcTime}ms`)
|
|
90
|
+
.join("\n");
|
|
91
|
+
this.section(panel, `Cache (${state.cacheEntries.length})`, cacheText);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (state.beforeLoadTrace.length > 0) {
|
|
95
|
+
this.section(panel, "beforeLoad trace", state.beforeLoadTrace.join("\n"));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
this.panel = panel;
|
|
99
|
+
this.appendChild(panel);
|
|
100
|
+
|
|
101
|
+
// Auto-refresh every second while open.
|
|
102
|
+
const timer = setInterval(() => {
|
|
103
|
+
if (!this.open) { clearInterval(timer); return; }
|
|
104
|
+
panel.remove();
|
|
105
|
+
this.panel = null;
|
|
106
|
+
this.renderPanel();
|
|
107
|
+
}, 1000);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private section(parent: HTMLElement, title: string, content: string) {
|
|
111
|
+
const h = document.createElement("div");
|
|
112
|
+
h.style.cssText = "color:#61dafb;margin-top:8px;margin-bottom:2px;";
|
|
113
|
+
h.textContent = title;
|
|
114
|
+
parent.appendChild(h);
|
|
115
|
+
|
|
116
|
+
const pre = document.createElement("pre");
|
|
117
|
+
pre.style.cssText = "margin:0;white-space:pre-wrap;word-break:break-all;color:#ccc;";
|
|
118
|
+
pre.textContent = content;
|
|
119
|
+
parent.appendChild(pre);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (typeof customElements !== "undefined" && !customElements.get("bractjs-devtools")) {
|
|
124
|
+
customElements.define("bractjs-devtools", BractJSDevtools);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Inject the `<bractjs-devtools>` element into the document body.
|
|
129
|
+
* Called by the HMR client in dev mode.
|
|
130
|
+
*/
|
|
131
|
+
export function injectDevtools(): void {
|
|
132
|
+
if (typeof document === "undefined") return;
|
|
133
|
+
if (document.querySelector("bractjs-devtools")) return;
|
|
134
|
+
const el = document.createElement("bractjs-devtools");
|
|
135
|
+
document.body.appendChild(el);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Update the shared devtools state object.
|
|
140
|
+
* Called by ClientRouter on every navigation.
|
|
141
|
+
*/
|
|
142
|
+
export function updateDevtoolsState(state: Partial<DevtoolsState>): void {
|
|
143
|
+
window.__BRACTJS_DEVTOOLS__ = { ...window.__BRACTJS_DEVTOOLS__, ...state } as DevtoolsState;
|
|
144
|
+
}
|
package/src/dev/hmr-client.ts
CHANGED
|
@@ -13,6 +13,15 @@
|
|
|
13
13
|
*/
|
|
14
14
|
export const hmrClientScript: string = `
|
|
15
15
|
(function () {
|
|
16
|
+
// Inject DevTools panel in dev mode (E3).
|
|
17
|
+
if (typeof customElements !== 'undefined') {
|
|
18
|
+
import('/_bractjs/devtools.js').then(function(m) {
|
|
19
|
+
if (typeof m.injectDevtools === 'function') m.injectDevtools();
|
|
20
|
+
}).catch(function() {
|
|
21
|
+
// DevTools module not available — skip silently.
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
function connect() {
|
|
17
26
|
var ws = new WebSocket("ws://localhost:3001");
|
|
18
27
|
ws.onmessage = function (event) {
|
|
@@ -21,6 +30,11 @@ export const hmrClientScript: string = `
|
|
|
21
30
|
if (msg.type === "hmr:reload") {
|
|
22
31
|
location.reload();
|
|
23
32
|
} else if (msg.type === "hmr:route" && msg.pattern != null && msg.chunkUrl) {
|
|
33
|
+
// Validate chunk URL is a same-origin relative path before importing.
|
|
34
|
+
// Prevents a compromised/MITM'd dev WS from executing arbitrary URLs.
|
|
35
|
+
if (typeof msg.chunkUrl !== 'string' || !/^\/build\//.test(msg.chunkUrl)) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
24
38
|
// Cache-bust so the browser re-fetches the rebuilt chunk.
|
|
25
39
|
// The chunk was built with splitting, so it shares the same React
|
|
26
40
|
// instance as client.js — no dual-React issue.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { resolve, join, sep } from "node:path";
|
|
2
2
|
import { realpath } from "node:fs/promises";
|
|
3
|
+
import { serverOnlyPlugin } from "../build/env-plugin.ts";
|
|
4
|
+
import { useServerProxyPlugin } from "../build/directives.ts";
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Dev-only HTTP handler for /_hmr/module?file=routes/about.tsx
|
|
@@ -18,6 +20,14 @@ export async function handleHmrModuleRequest(
|
|
|
18
20
|
return new Response("Missing file param", { status: 400 });
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
// SECURITY(high): restrict to JS/TS source files. Without this, /_hmr/module
|
|
24
|
+
// would build and ship the contents of any file inside appDir (e.g. .env,
|
|
25
|
+
// .json, .md) as JavaScript to the browser — useful only for compiling
|
|
26
|
+
// route modules, so allowlist their extensions.
|
|
27
|
+
if (!/\.(tsx?|jsx?)$/.test(file)) {
|
|
28
|
+
return new Response("Forbidden", { status: 403 });
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
// Resolve and guard against path traversal AND symlink escape.
|
|
22
32
|
const rootDir = resolve(appDir);
|
|
23
33
|
const candidate = resolve(join(rootDir, file));
|
|
@@ -34,12 +44,18 @@ export async function handleHmrModuleRequest(
|
|
|
34
44
|
return new Response("Forbidden", { status: 403 });
|
|
35
45
|
}
|
|
36
46
|
|
|
37
|
-
//
|
|
47
|
+
// SECURITY(high): apply the same client-bundle guard plugins the production
|
|
48
|
+
// build uses. Without these, a route module that imports `*.server.ts` or
|
|
49
|
+
// contains "use server" exports would have that server source compiled and
|
|
50
|
+
// shipped to the browser as JavaScript over /_hmr/module — leaking
|
|
51
|
+
// credentials, DB code, etc. The serverOnlyPlugin hard-fails such imports
|
|
52
|
+
// and useServerProxyPlugin rewrites "use server" exports to fetch stubs.
|
|
38
53
|
const result = await Bun.build({
|
|
39
54
|
entrypoints: [fullPath],
|
|
40
55
|
target: "browser",
|
|
41
56
|
minify: false,
|
|
42
57
|
sourcemap: "inline",
|
|
58
|
+
plugins: [serverOnlyPlugin, useServerProxyPlugin],
|
|
43
59
|
});
|
|
44
60
|
|
|
45
61
|
if (!result.success || result.outputs.length === 0) {
|
package/src/dev/hmr-server.ts
CHANGED
|
@@ -18,6 +18,22 @@ export function createHmrServer(port = 3001): {
|
|
|
18
18
|
const server = Bun.serve({
|
|
19
19
|
port,
|
|
20
20
|
fetch(req, srv) {
|
|
21
|
+
// SECURITY(medium): reject WebSocket upgrades that don't come from a
|
|
22
|
+
// loopback Origin. Without this, any website the developer visits could
|
|
23
|
+
// open a WS to ws://localhost:<port> and receive file paths from HMR
|
|
24
|
+
// broadcasts (a passive leak of project structure). Same-origin /
|
|
25
|
+
// missing Origin (curl, native ws clients) are allowed for dev DX.
|
|
26
|
+
const origin = req.headers.get("Origin");
|
|
27
|
+
if (origin) {
|
|
28
|
+
try {
|
|
29
|
+
const host = new URL(origin).hostname;
|
|
30
|
+
if (host !== "localhost" && host !== "127.0.0.1" && host !== "[::1]" && host !== "::1") {
|
|
31
|
+
return new Response("Forbidden", { status: 403 });
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
return new Response("Forbidden", { status: 403 });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
21
37
|
if (srv.upgrade(req)) return undefined;
|
|
22
38
|
return new Response("HMR WebSocket endpoint", { status: 426 });
|
|
23
39
|
},
|
package/src/image/handler.ts
CHANGED
|
@@ -14,8 +14,11 @@ async function parseParams(
|
|
|
14
14
|
publicDir: string,
|
|
15
15
|
): Promise<{ src: string; filePath: string; params: ImageTransformParams } | null> {
|
|
16
16
|
const src = sp.get("src");
|
|
17
|
-
// src must be a /public/ path with no
|
|
18
|
-
|
|
17
|
+
// src must be a /public/ path with no ".." path segment. We check segments
|
|
18
|
+
// (not substring) so filenames like "foo..bar.jpg" are still allowed —
|
|
19
|
+
// realpath()/prefix check below is the authoritative escape guard.
|
|
20
|
+
if (!src || !src.startsWith("/public/")) return null;
|
|
21
|
+
if (src.split("/").includes("..")) return null;
|
|
19
22
|
|
|
20
23
|
const rel = src.slice("/public/".length);
|
|
21
24
|
const root = resolve(publicDir);
|
package/src/image/optimizer.ts
CHANGED
|
@@ -57,9 +57,14 @@ function resizeArgs(params: ImageTransformParams): string[] {
|
|
|
57
57
|
|
|
58
58
|
function buildArgs(binary: string, input: string, params: ImageTransformParams): string[] {
|
|
59
59
|
const base = binary === "magick" ? ["magick", "convert"] : ["convert"];
|
|
60
|
+
// SECURITY(low): prefix the input with `file:` so ImageMagick treats it as
|
|
61
|
+
// a filesystem path even if it contains a coder prefix like `mvg:` or
|
|
62
|
+
// `https:` (e.g. an attacker who plants a file named "https:evil.txt"
|
|
63
|
+
// inside publicDir). Defense in depth — realpath() already constrains the
|
|
64
|
+
// path to publicDir, so this prevents the "format coder hijack" class only.
|
|
60
65
|
return [
|
|
61
66
|
...base,
|
|
62
|
-
input
|
|
67
|
+
`file:${input}`,
|
|
63
68
|
...resizeArgs(params),
|
|
64
69
|
"-quality", String(params.q),
|
|
65
70
|
"-strip",
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
// Server
|
|
2
2
|
export { createServer, renderRoute, redirect, json, error } from "./server/index.ts";
|
|
3
|
+
export { buildFetchHandler } from "./server/serve.ts";
|
|
4
|
+
export { defineContext } from "./server/context.ts";
|
|
5
|
+
export type { ContextFactory } from "./server/context.ts";
|
|
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";
|
|
10
|
+
export type { BractAdapter } from "./server/adapter.ts";
|
|
11
|
+
export { BunAdapter } from "./server/adapter.ts";
|
|
12
|
+
|
|
13
|
+
// Adapters
|
|
14
|
+
export { createCloudflareAdapter, makeCloudflareHandler } from "./adapters/cloudflare.ts";
|
|
15
|
+
|
|
16
|
+
// Build plugins
|
|
17
|
+
export { cssModulesPlugin, transformCssModule } from "./build/plugins/css-modules.ts";
|
|
18
|
+
|
|
19
|
+
// Client RPC
|
|
20
|
+
export { createClient } from "./client/rpc.ts";
|
|
3
21
|
export type { BractJSConfig, RenderOptions, ServerManifest } from "./server/index.ts";
|
|
4
22
|
|
|
5
23
|
// Shared types
|
|
@@ -49,3 +67,12 @@ export { useActionData } from "./client/hooks/useActionData.ts";
|
|
|
49
67
|
export { useParams } from "./client/hooks/useParams.ts";
|
|
50
68
|
export { useNavigation } from "./client/hooks/useNavigation.ts";
|
|
51
69
|
export { useFetcher } from "./client/hooks/useFetcher.ts";
|
|
70
|
+
export { useSearchParams } from "./client/hooks/useSearchParams.ts";
|
|
71
|
+
export type { SearchParamsResult } from "./client/hooks/useSearchParams.ts";
|
|
72
|
+
export { useBlocker } from "./client/hooks/useBlocker.ts";
|
|
73
|
+
export { useLocale } from "./client/hooks/useLocale.ts";
|
|
74
|
+
export { useLocalizedLink } from "./client/hooks/useLocalizedLink.ts";
|
|
75
|
+
|
|
76
|
+
// i18n utilities (server-side)
|
|
77
|
+
export { wrapRoutesWithLocale, stripLocale, localizedDataPath } from "./server/i18n.ts";
|
|
78
|
+
export type { I18nConfig } from "./server/serve.ts";
|