@bractjs/bractjs 0.1.29 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1449 -0
- package/package.json +1 -1
- package/src/__tests__/fixtures/app/routes/redirect-loader.tsx +13 -0
- package/src/__tests__/integration.test.ts +18 -0
- package/src/client/components/Form.tsx +5 -1
- package/src/client/components/Image.tsx +4 -2
- package/src/client/components/Toaster.tsx +117 -0
- package/src/client/hooks/useToast.ts +12 -0
- package/src/client/toast-store.ts +132 -0
- package/src/index.ts +5 -0
- package/src/server/adapter.ts +7 -1
- package/src/server/request-handler.ts +6 -1
- package/src/server/serve.ts +17 -1
- package/types/index.d.ts +62 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bractjs/bractjs",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Production-grade SSR framework for Bun + React 19. File-based routing, streaming SSR, server actions, typed routes.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/bractjs/bractjs#readme",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Fixture for the "/_data must surface a redirect THROWN from a loader" test.
|
|
2
|
+
// Auth gates like requireAdmin() do `throw redirect("/login")` inside a loader.
|
|
3
|
+
// On the /_data soft-nav path that thrown redirect must come back as a real 3xx
|
|
4
|
+
// (so the client follows it), not escape to the top-level handler as a 500.
|
|
5
|
+
import { redirect } from "../../../../server/response.ts";
|
|
6
|
+
|
|
7
|
+
export function loader(): never {
|
|
8
|
+
throw redirect("/login");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default function RedirectLoaderPage() {
|
|
12
|
+
return <p>redirect loader page</p>;
|
|
13
|
+
}
|
|
@@ -183,6 +183,24 @@ test("/_data of a beforeLoad-gated route is blocked and never leaks loader data"
|
|
|
183
183
|
expect(body).not.toContain("TOP-SECRET-LOADER-DATA");
|
|
184
184
|
});
|
|
185
185
|
|
|
186
|
+
// Regression: a redirect THROWN from a loader (the requireAdmin/auth-gate
|
|
187
|
+
// pattern) must come back from /_data as a real 3xx, not a 500. The /_data
|
|
188
|
+
// handler wraps loaders in `return await runRouteMiddleware(...)`; a bare
|
|
189
|
+
// `return` would let the rejection escape its try/catch (which handles
|
|
190
|
+
// isRedirect) to the top-level handler, which logs "unhandled request error"
|
|
191
|
+
// and returns 500 — breaking soft-nav redirects for gated routes.
|
|
192
|
+
test("full-page GET of a loader that throws redirect returns the 3xx", async () => {
|
|
193
|
+
const res = await fetch(`${BASE}/redirect-loader`, { redirect: "manual" });
|
|
194
|
+
expect(res.status).toBe(302);
|
|
195
|
+
expect(res.headers.get("location")).toBe("/login");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("/_data of a loader that throws redirect returns the 3xx (not 500)", async () => {
|
|
199
|
+
const res = await fetch(`${BASE}/_data?path=/redirect-loader`, { redirect: "manual" });
|
|
200
|
+
expect(res.status).toBe(302);
|
|
201
|
+
expect(res.headers.get("location")).toBe("/login");
|
|
202
|
+
});
|
|
203
|
+
|
|
186
204
|
// ── Route headers / useMatches / nested middleware (Phases 1, 2, 4) ──────────
|
|
187
205
|
|
|
188
206
|
test("route `headers` export sets Cache-Control on the document response", async () => {
|
|
@@ -56,8 +56,12 @@ export function Form({ method = "post", action, intent, children, ...rest }: For
|
|
|
56
56
|
await submit(url, { method, body: new FormData(target) });
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
// Render `action` here too (SSR sets it on line 34): handleSubmit preventDefaults
|
|
60
|
+
// so it's never used for a native submit, but keeping it on the element makes
|
|
61
|
+
// the client markup match the server's and avoids a hydration mismatch for any
|
|
62
|
+
// <Form action="…"> (e.g. a logout form posting to a different route).
|
|
59
63
|
return (
|
|
60
|
-
<form method={method} onSubmit={(e) => { void handleSubmit(e); }} {...rest}>
|
|
64
|
+
<form method={method} action={action} onSubmit={(e) => { void handleSubmit(e); }} {...rest}>
|
|
61
65
|
{intentInput}
|
|
62
66
|
{children}
|
|
63
67
|
</form>
|
|
@@ -69,8 +69,10 @@ export function Image({
|
|
|
69
69
|
height={height}
|
|
70
70
|
loading={priority ? "eager" : "lazy"}
|
|
71
71
|
decoding={priority ? "sync" : "async"}
|
|
72
|
-
//
|
|
73
|
-
fetchpriority
|
|
72
|
+
// React 19 prop is camelCase `fetchPriority`; React emits the lowercase
|
|
73
|
+
// `fetchpriority` HTML attribute. Using the lowercase prop here triggers
|
|
74
|
+
// "Invalid DOM property `fetchpriority`" at hydration.
|
|
75
|
+
fetchPriority={priority ? "high" : "auto"}
|
|
74
76
|
sizes={sizes}
|
|
75
77
|
className={className}
|
|
76
78
|
style={style}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import { useToasts } from "../hooks/useToast.ts";
|
|
3
|
+
import { toast } from "../toast-store.ts";
|
|
4
|
+
import type { ToastEntry, ToastType } from "../toast-store.ts";
|
|
5
|
+
|
|
6
|
+
export type ToastPosition =
|
|
7
|
+
| "top-left" | "top-center" | "top-right"
|
|
8
|
+
| "bottom-left" | "bottom-center" | "bottom-right";
|
|
9
|
+
|
|
10
|
+
export interface ToasterProps {
|
|
11
|
+
position?: ToastPosition;
|
|
12
|
+
/** Gap between stacked toasts, px. */
|
|
13
|
+
gap?: number;
|
|
14
|
+
/** Custom renderer — receives the entry and a dismiss callback. Falls back to the default card. */
|
|
15
|
+
renderToast?: (toast: ToastEntry, dismiss: () => void) => ReactNode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const ACCENT: Record<ToastType, string> = {
|
|
19
|
+
success: "#16a34a", error: "#dc2626", warning: "#d97706", info: "#2563eb", loading: "#6b7280",
|
|
20
|
+
};
|
|
21
|
+
const ICON: Record<ToastType, string> = {
|
|
22
|
+
success: "✓", error: "✕", warning: "!", info: "i", loading: "↻",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function isTop(p: ToastPosition) { return p.startsWith("top"); }
|
|
26
|
+
|
|
27
|
+
function containerStyle(position: ToastPosition, gap: number): CSSProperties {
|
|
28
|
+
const [, x] = position.split("-");
|
|
29
|
+
return {
|
|
30
|
+
position: "fixed", zIndex: 9999, display: "flex", flexDirection: "column", gap,
|
|
31
|
+
pointerEvents: "none", maxWidth: "calc(100vw - 32px)", width: 380,
|
|
32
|
+
top: isTop(position) ? 16 : undefined, bottom: isTop(position) ? undefined : 16,
|
|
33
|
+
left: x === "left" ? 16 : x === "center" ? "50%" : undefined,
|
|
34
|
+
right: x === "right" ? 16 : undefined,
|
|
35
|
+
transform: x === "center" ? "translateX(-50%)" : undefined,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function ToastCard({ entry, top }: { entry: ToastEntry; top: boolean }) {
|
|
40
|
+
const [shown, setShown] = useState(false);
|
|
41
|
+
useEffect(() => { const r = requestAnimationFrame(() => setShown(true)); return () => cancelAnimationFrame(r); }, []);
|
|
42
|
+
const dismiss = () => toast.dismiss(entry.id);
|
|
43
|
+
return (
|
|
44
|
+
<div
|
|
45
|
+
role={entry.type === "error" ? "alert" : "status"}
|
|
46
|
+
aria-live={entry.type === "error" ? "assertive" : "polite"}
|
|
47
|
+
data-bract-toast={entry.type}
|
|
48
|
+
style={{
|
|
49
|
+
pointerEvents: "auto", display: "flex", alignItems: "flex-start", gap: 12,
|
|
50
|
+
padding: "12px 14px", borderRadius: 10, background: "#fff", color: "#111",
|
|
51
|
+
border: "1px solid rgba(0,0,0,0.08)", borderLeft: `4px solid ${ACCENT[entry.type]}`,
|
|
52
|
+
boxShadow: "0 6px 24px rgba(0,0,0,0.12)", fontSize: 14, lineHeight: 1.4,
|
|
53
|
+
transition: "opacity .22s ease, transform .22s ease",
|
|
54
|
+
opacity: shown ? 1 : 0,
|
|
55
|
+
transform: shown ? "translateY(0)" : `translateY(${top ? -8 : 8}px)`,
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
<span
|
|
59
|
+
aria-hidden
|
|
60
|
+
style={{
|
|
61
|
+
flex: "0 0 auto", width: 20, height: 20, borderRadius: "50%", color: "#fff",
|
|
62
|
+
background: ACCENT[entry.type], display: "grid", placeItems: "center",
|
|
63
|
+
fontSize: 12, fontWeight: 700, marginTop: 1,
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
{ICON[entry.type]}
|
|
67
|
+
</span>
|
|
68
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
69
|
+
<div style={{ fontWeight: 600, wordBreak: "break-word" }}>{entry.message}</div>
|
|
70
|
+
{entry.description ? (
|
|
71
|
+
<div style={{ marginTop: 2, color: "#555", fontWeight: 400 }}>{entry.description}</div>
|
|
72
|
+
) : null}
|
|
73
|
+
{entry.action ? (
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
onClick={() => { entry.action!.onClick(); dismiss(); }}
|
|
77
|
+
style={{
|
|
78
|
+
marginTop: 8, padding: "4px 10px", fontSize: 13, fontWeight: 600, cursor: "pointer",
|
|
79
|
+
color: ACCENT[entry.type], background: "transparent",
|
|
80
|
+
border: `1px solid ${ACCENT[entry.type]}`, borderRadius: 6,
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
{entry.action.label}
|
|
84
|
+
</button>
|
|
85
|
+
) : null}
|
|
86
|
+
</div>
|
|
87
|
+
<button
|
|
88
|
+
type="button" aria-label="Dismiss" onClick={dismiss}
|
|
89
|
+
style={{
|
|
90
|
+
flex: "0 0 auto", border: "none", background: "transparent", cursor: "pointer",
|
|
91
|
+
color: "#888", fontSize: 16, lineHeight: 1, padding: 0,
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
×
|
|
95
|
+
</button>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Renders the active toast queue. Mount once in root.tsx, then call `toast.*`
|
|
102
|
+
* (or `useToast()`) anywhere — e.g. after a save/delete action resolves.
|
|
103
|
+
*/
|
|
104
|
+
export function Toaster({ position = "top-right", gap = 10, renderToast }: ToasterProps): ReactNode {
|
|
105
|
+
const toasts = useToasts();
|
|
106
|
+
if (toasts.length === 0) return null;
|
|
107
|
+
const ordered = isTop(position) ? toasts : [...toasts].reverse();
|
|
108
|
+
return (
|
|
109
|
+
<div data-bract-toaster={position} style={containerStyle(position, gap)}>
|
|
110
|
+
{ordered.map((entry) =>
|
|
111
|
+
renderToast
|
|
112
|
+
? <div key={entry.id} style={{ pointerEvents: "auto" }}>{renderToast(entry, () => toast.dismiss(entry.id))}</div>
|
|
113
|
+
: <ToastCard key={entry.id} entry={entry} top={isTop(position)} />,
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
import { toast, toastStore, EMPTY_TOASTS, type Toast, type ToastEntry } from "../toast-store.ts";
|
|
3
|
+
|
|
4
|
+
/** The stable `toast` API — `toast.success(...)`, `toast.error(...)`, `toast.promise(...)`. */
|
|
5
|
+
export function useToast(): Toast {
|
|
6
|
+
return toast;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Reactive list of active toasts. <Toaster> uses this; expose it to build a custom renderer. */
|
|
10
|
+
export function useToasts(): ToastEntry[] {
|
|
11
|
+
return useSyncExternalStore(toastStore.subscribe, toastStore.getSnapshot, () => EMPTY_TOASTS);
|
|
12
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Module-level toast state, shaped for React's useSyncExternalStore (mirrors
|
|
2
|
+
// fetcher-store.ts). Living outside component state means `toast()` is callable
|
|
3
|
+
// from anywhere — event handlers, fetcher callbacks, non-React code — and every
|
|
4
|
+
// <Toaster> in the tree observes the same queue.
|
|
5
|
+
|
|
6
|
+
export type ToastType = "success" | "error" | "info" | "warning" | "loading";
|
|
7
|
+
|
|
8
|
+
export interface ToastAction {
|
|
9
|
+
label: string;
|
|
10
|
+
onClick: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ToastEntry {
|
|
14
|
+
id: string;
|
|
15
|
+
type: ToastType;
|
|
16
|
+
message: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
/** ms before auto-dismiss; `Infinity`/`0` keeps it until dismissed. */
|
|
19
|
+
duration: number;
|
|
20
|
+
action?: ToastAction;
|
|
21
|
+
createdAt: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ToastOptions {
|
|
25
|
+
/** Reuse an id to update an existing toast in place (e.g. loading → success). */
|
|
26
|
+
id?: string;
|
|
27
|
+
type?: ToastType;
|
|
28
|
+
description?: string;
|
|
29
|
+
duration?: number;
|
|
30
|
+
action?: ToastAction;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type Listener = () => void;
|
|
34
|
+
const DEFAULT_DURATION = 4000;
|
|
35
|
+
|
|
36
|
+
class ToastStore {
|
|
37
|
+
private entries = new Map<string, ToastEntry>();
|
|
38
|
+
private timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
39
|
+
private listeners = new Set<Listener>();
|
|
40
|
+
// Stable reference between emits or useSyncExternalStore loops.
|
|
41
|
+
private snapshot: ToastEntry[] = [];
|
|
42
|
+
private seq = 0;
|
|
43
|
+
|
|
44
|
+
add(message: string, opts: ToastOptions = {}): string {
|
|
45
|
+
const id = opts.id ?? `bract-toast-${++this.seq}`;
|
|
46
|
+
const type = opts.type ?? "info";
|
|
47
|
+
const duration = opts.duration ?? (type === "loading" ? Infinity : DEFAULT_DURATION);
|
|
48
|
+
this.clearTimer(id);
|
|
49
|
+
const prev = this.entries.get(id);
|
|
50
|
+
this.entries.set(id, {
|
|
51
|
+
id, type, message, description: opts.description, duration,
|
|
52
|
+
action: opts.action, createdAt: prev?.createdAt ?? Date.now(),
|
|
53
|
+
});
|
|
54
|
+
this.schedule(id, duration);
|
|
55
|
+
this.emit();
|
|
56
|
+
return id;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
dismiss(id: string): void {
|
|
60
|
+
this.clearTimer(id);
|
|
61
|
+
if (this.entries.delete(id)) this.emit();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
clear(): void {
|
|
65
|
+
for (const id of [...this.timers.keys()]) this.clearTimer(id);
|
|
66
|
+
if (this.entries.size) { this.entries.clear(); this.emit(); }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private schedule(id: string, duration: number): void {
|
|
70
|
+
if (!Number.isFinite(duration) || duration <= 0) return;
|
|
71
|
+
this.timers.set(id, setTimeout(() => this.dismiss(id), duration));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private clearTimer(id: string): void {
|
|
75
|
+
const t = this.timers.get(id);
|
|
76
|
+
if (t !== undefined) { clearTimeout(t); this.timers.delete(id); }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
subscribe = (listener: Listener): (() => void) => {
|
|
80
|
+
this.listeners.add(listener);
|
|
81
|
+
return () => { this.listeners.delete(listener); };
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
getSnapshot = (): ToastEntry[] => this.snapshot;
|
|
85
|
+
|
|
86
|
+
private emit(): void {
|
|
87
|
+
this.snapshot = Array.from(this.entries.values());
|
|
88
|
+
for (const listener of this.listeners) listener();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const toastStore = new ToastStore();
|
|
93
|
+
|
|
94
|
+
/** Stable server snapshot — SSR renders with no toasts. */
|
|
95
|
+
export const EMPTY_TOASTS: ToastEntry[] = [];
|
|
96
|
+
|
|
97
|
+
type Msg<T> = string | ((value: T) => string);
|
|
98
|
+
const resolve = <T,>(m: Msg<T>, v: T): string => (typeof m === "function" ? m(v) : m);
|
|
99
|
+
const typed = (type: ToastType) =>
|
|
100
|
+
(message: string, opts?: Omit<ToastOptions, "type">): string => toastStore.add(message, { ...opts, type });
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Fire a toast from anywhere. Use the typed helpers for status feedback:
|
|
104
|
+
* toast.success("Saved"); toast.error("Delete failed");
|
|
105
|
+
* `toast.promise` shows loading → success/error around an async action.
|
|
106
|
+
*/
|
|
107
|
+
export const toast = Object.assign(
|
|
108
|
+
(message: string, opts?: ToastOptions): string => toastStore.add(message, opts),
|
|
109
|
+
{
|
|
110
|
+
success: typed("success"),
|
|
111
|
+
error: typed("error"),
|
|
112
|
+
info: typed("info"),
|
|
113
|
+
warning: typed("warning"),
|
|
114
|
+
loading: typed("loading"),
|
|
115
|
+
/** Dismiss one toast by id, or all toasts when called with no id. */
|
|
116
|
+
dismiss: (id?: string): void => (id ? toastStore.dismiss(id) : toastStore.clear()),
|
|
117
|
+
promise<T>(
|
|
118
|
+
promise: Promise<T>,
|
|
119
|
+
msgs: { loading: string; success: Msg<T>; error: Msg<unknown> },
|
|
120
|
+
opts?: Omit<ToastOptions, "type" | "id">,
|
|
121
|
+
): Promise<T> {
|
|
122
|
+
const id = toastStore.add(msgs.loading, { ...opts, type: "loading" });
|
|
123
|
+
promise.then(
|
|
124
|
+
(value) => toastStore.add(resolve(msgs.success, value), { ...opts, id, type: "success" }),
|
|
125
|
+
(err) => toastStore.add(resolve(msgs.error, err), { ...opts, id, type: "error" }),
|
|
126
|
+
);
|
|
127
|
+
return promise;
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
export type Toast = typeof toast;
|
package/src/index.ts
CHANGED
|
@@ -119,6 +119,8 @@ export { Image } from "./client/components/Image.tsx";
|
|
|
119
119
|
export type { ImageProps, ImageFormat, ImageFit } from "./client/components/Image.tsx";
|
|
120
120
|
export { ScrollRestoration } from "./client/components/ScrollRestoration.tsx";
|
|
121
121
|
export type { ScrollRestorationProps } from "./client/components/ScrollRestoration.tsx";
|
|
122
|
+
export { Toaster } from "./client/components/Toaster.tsx";
|
|
123
|
+
export type { ToasterProps, ToastPosition } from "./client/components/Toaster.tsx";
|
|
122
124
|
|
|
123
125
|
// Client hooks
|
|
124
126
|
export { useLoaderData } from "./client/hooks/useLoaderData.ts";
|
|
@@ -140,6 +142,9 @@ export type { SearchParamsResult } from "./client/hooks/useSearchParams.ts";
|
|
|
140
142
|
export { useSearch, useSetSearch } from "./client/hooks/useSearch.ts";
|
|
141
143
|
export type { SetSearchFn, SetSearchOptions } from "./client/hooks/useSearch.ts";
|
|
142
144
|
export { serializeSearch } from "./client/search-serializer.ts";
|
|
145
|
+
export { useToast, useToasts } from "./client/hooks/useToast.ts";
|
|
146
|
+
export { toast, toastStore } from "./client/toast-store.ts";
|
|
147
|
+
export type { Toast, ToastEntry, ToastOptions, ToastType, ToastAction } from "./client/toast-store.ts";
|
|
143
148
|
export { useBlocker } from "./client/hooks/useBlocker.ts";
|
|
144
149
|
export { useLocale } from "./client/hooks/useLocale.ts";
|
|
145
150
|
export { useLocalizedLink } from "./client/hooks/useLocalizedLink.ts";
|
package/src/server/adapter.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isExplicitDev } from "./env.ts";
|
|
2
|
+
|
|
1
3
|
// ── BractAdapter ──────────────────────────────────────────────────────────
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -59,7 +61,11 @@ export class BunAdapter implements BractAdapter {
|
|
|
59
61
|
fetch: handler,
|
|
60
62
|
error(err: Error) {
|
|
61
63
|
console.error("[bractjs] unhandled server error:", err);
|
|
62
|
-
|
|
64
|
+
// SECURITY(high): never leak internal error details in production. This
|
|
65
|
+
// is a last-resort backstop (buildFetchHandler already catches request
|
|
66
|
+
// errors) — mirror the isExplicitDev() gating used on every other path.
|
|
67
|
+
const message = isExplicitDev() ? err.message : "Internal Server Error";
|
|
68
|
+
return new Response(JSON.stringify({ error: message }), {
|
|
63
69
|
status: 500,
|
|
64
70
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
|
65
71
|
});
|
|
@@ -101,7 +101,12 @@ async function route(
|
|
|
101
101
|
// and leak loader data. Run the route middleware chain around the work,
|
|
102
102
|
// sharing the same mutable `context` so a gate can set/clear fields.
|
|
103
103
|
const mwCtx: MiddlewareContext = { request: loaderRequest, params: match.params, context };
|
|
104
|
-
return
|
|
104
|
+
// `return await` (not bare `return`): a loader/gate inside the middleware
|
|
105
|
+
// work can throw a redirect (e.g. requireAdmin). Without awaiting here the
|
|
106
|
+
// returned promise rejects *after* this try block, so the catch below never
|
|
107
|
+
// runs isRedirect() and the redirect escapes to the top-level handler as a
|
|
108
|
+
// 500 instead of being returned as a 302 for the soft-nav client.
|
|
109
|
+
return await runRouteMiddleware(collectRouteMiddleware(chain), mwCtx, async () => {
|
|
105
110
|
const routeContext = await runRouteContext(
|
|
106
111
|
chain.route as Parameters<typeof runRouteContext>[0],
|
|
107
112
|
loaderRequest,
|
package/src/server/serve.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { handleActionRequest } from "./action-handler.ts";
|
|
|
13
13
|
import { BunAdapter, type BractAdapter } from "./adapter.ts";
|
|
14
14
|
import type { ModuleRegistry } from "./layout.ts";
|
|
15
15
|
import { resolve, join } from "node:path";
|
|
16
|
+
import { error } from "./response.ts";
|
|
16
17
|
import { fireOnError, type OnErrorHook } from "./lifecycle.ts";
|
|
17
18
|
import { installUseClientServerStub } from "./use-client-runtime.ts";
|
|
18
19
|
|
|
@@ -310,7 +311,22 @@ export function buildFetchHandler(config: Partial<BractJSConfig>) {
|
|
|
310
311
|
// only SSR documents. The per-route (nested) middleware chain still runs
|
|
311
312
|
// inside handleRequest for SSR/_data, sharing this same `context` object.
|
|
312
313
|
const ctx: MiddlewareContext = { request, params: {}, context: {} };
|
|
313
|
-
|
|
314
|
+
// SECURITY(high): adapter-agnostic catch-all. An uncaught throw from a
|
|
315
|
+
// global middleware or from dispatch itself (e.g. resolveRouteChain at
|
|
316
|
+
// import time) would otherwise reach the adapter's error handler — which on
|
|
317
|
+
// Bun leaks err.message and on Cloudflare/custom adapters isn't handled at
|
|
318
|
+
// all. Log, fire onError (so observability still sees it), and return a
|
|
319
|
+
// generic 500 with the message gated to dev — matching every other path.
|
|
320
|
+
try {
|
|
321
|
+
return await pipeline.run(ctx, () => dispatch(request, ctx.context));
|
|
322
|
+
} catch (err) {
|
|
323
|
+
console.error("[bract] unhandled request error:", err);
|
|
324
|
+
await fireOnError(onError, err, request);
|
|
325
|
+
return error(
|
|
326
|
+
isExplicitDev() ? (err instanceof Error ? err.message : String(err)) : "Internal Server Error",
|
|
327
|
+
500,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
314
330
|
};
|
|
315
331
|
}
|
|
316
332
|
|
package/types/index.d.ts
CHANGED
|
@@ -4,11 +4,12 @@ import type { ReactNode, Context, CSSProperties } from "react";
|
|
|
4
4
|
export type {
|
|
5
5
|
LoaderArgs, ActionArgs, MetaDescriptor, MetaArgs,
|
|
6
6
|
LoaderFunction, ActionFunction, MetaFunction, RouteModule,
|
|
7
|
-
RouteFile, Segment, RouterLocation,
|
|
7
|
+
RouteFile, Segment, RouterLocation, RouteMatch,
|
|
8
8
|
ShouldRevalidateArgs, ShouldRevalidateFunction,
|
|
9
|
+
HeadersFunction, HeadersArgs,
|
|
9
10
|
LoaderData, ActionData,
|
|
10
11
|
} from "./route.d.ts";
|
|
11
|
-
import type { RouterLocation, LoaderData, ActionData, ActionArgs } from "./route.d.ts";
|
|
12
|
+
import type { RouterLocation, LoaderData, ActionData, ActionArgs, RouteMatch } from "./route.d.ts";
|
|
12
13
|
|
|
13
14
|
// ── Config + Server ───────────────────────────────────────────────────────
|
|
14
15
|
export type { BractJSConfig, ServerManifest, BuildConfig } from "./config.d.ts";
|
|
@@ -259,6 +260,50 @@ export interface ScrollRestorationProps {
|
|
|
259
260
|
/** Restores scroll on back/forward, scrolls to top (or `#hash`) on new navigations. Render once in root.tsx. */
|
|
260
261
|
export declare function ScrollRestoration(props?: ScrollRestorationProps): null;
|
|
261
262
|
|
|
263
|
+
export type ToastType = "success" | "error" | "info" | "warning" | "loading";
|
|
264
|
+
export interface ToastAction { label: string; onClick: () => void }
|
|
265
|
+
export interface ToastEntry {
|
|
266
|
+
id: string;
|
|
267
|
+
type: ToastType;
|
|
268
|
+
message: string;
|
|
269
|
+
description?: string;
|
|
270
|
+
duration: number;
|
|
271
|
+
action?: ToastAction;
|
|
272
|
+
createdAt: number;
|
|
273
|
+
}
|
|
274
|
+
export interface ToastOptions {
|
|
275
|
+
id?: string;
|
|
276
|
+
type?: ToastType;
|
|
277
|
+
description?: string;
|
|
278
|
+
duration?: number;
|
|
279
|
+
action?: ToastAction;
|
|
280
|
+
}
|
|
281
|
+
type ToastMsg<T> = string | ((value: T) => string);
|
|
282
|
+
export interface Toast {
|
|
283
|
+
(message: string, opts?: ToastOptions): string;
|
|
284
|
+
success(message: string, opts?: Omit<ToastOptions, "type">): string;
|
|
285
|
+
error(message: string, opts?: Omit<ToastOptions, "type">): string;
|
|
286
|
+
info(message: string, opts?: Omit<ToastOptions, "type">): string;
|
|
287
|
+
warning(message: string, opts?: Omit<ToastOptions, "type">): string;
|
|
288
|
+
loading(message: string, opts?: Omit<ToastOptions, "type">): string;
|
|
289
|
+
dismiss(id?: string): void;
|
|
290
|
+
promise<T>(
|
|
291
|
+
promise: Promise<T>,
|
|
292
|
+
msgs: { loading: string; success: ToastMsg<T>; error: ToastMsg<unknown> },
|
|
293
|
+
opts?: Omit<ToastOptions, "type" | "id">,
|
|
294
|
+
): Promise<T>;
|
|
295
|
+
}
|
|
296
|
+
export type ToastPosition =
|
|
297
|
+
| "top-left" | "top-center" | "top-right"
|
|
298
|
+
| "bottom-left" | "bottom-center" | "bottom-right";
|
|
299
|
+
export interface ToasterProps {
|
|
300
|
+
position?: ToastPosition;
|
|
301
|
+
gap?: number;
|
|
302
|
+
renderToast?: (toast: ToastEntry, dismiss: () => void) => ReactNode;
|
|
303
|
+
}
|
|
304
|
+
/** Renders the active toast queue. Mount once in root.tsx, then call `toast.*` anywhere. */
|
|
305
|
+
export declare function Toaster(props?: ToasterProps): ReactNode;
|
|
306
|
+
|
|
262
307
|
export interface FormProps { method?: "post" | "put" | "delete"; action?: string; /** Renders a hidden `intent` input (pairs with defineActions()). */ intent?: string; children?: ReactNode; [key: string]: unknown; }
|
|
263
308
|
export declare function Form(props: FormProps): ReactNode;
|
|
264
309
|
|
|
@@ -294,6 +339,8 @@ export declare function useLoaderData<T = unknown>(): LoaderData<T>;
|
|
|
294
339
|
export declare function useActionData<T = unknown>(): ActionData<T> | null;
|
|
295
340
|
/** The current location — reactive on the client, request-derived during SSR. */
|
|
296
341
|
export declare function useLocation(): RouterLocation;
|
|
342
|
+
/** The matched route chain (root → layouts → route); each entry has `{ id, pathname, params, data, handle }`. */
|
|
343
|
+
export declare function useMatches(): RouteMatch[];
|
|
297
344
|
export declare function useParams<TTo extends string>(): ParamsFor<TTo>;
|
|
298
345
|
export declare function useParams<T extends Record<string, string> = Record<string, string>>(): T;
|
|
299
346
|
export type NavigationState = "idle" | "loading" | "submitting";
|
|
@@ -393,6 +440,19 @@ export interface ContextFactory<T> {
|
|
|
393
440
|
// ── beforeLoad / useBlocker ───────────────────────────────────────────────
|
|
394
441
|
export declare function useBlocker(shouldBlock: () => boolean): void;
|
|
395
442
|
|
|
443
|
+
// ── Toasts ────────────────────────────────────────────────────────────────
|
|
444
|
+
/** The stable `toast` API — call from anywhere to flash status feedback. */
|
|
445
|
+
export declare const toast: Toast;
|
|
446
|
+
export declare const toastStore: {
|
|
447
|
+
add(message: string, opts?: ToastOptions): string;
|
|
448
|
+
dismiss(id: string): void;
|
|
449
|
+
clear(): void;
|
|
450
|
+
subscribe(listener: () => void): () => void;
|
|
451
|
+
getSnapshot(): ToastEntry[];
|
|
452
|
+
};
|
|
453
|
+
export declare function useToast(): Toast;
|
|
454
|
+
export declare function useToasts(): ToastEntry[];
|
|
455
|
+
|
|
396
456
|
// ── i18n routing (E2) ────────────────────────────────────────────────────
|
|
397
457
|
export declare function useLocale(defaultLocale?: string): string;
|
|
398
458
|
export declare function useLocalizedLink(defaultLocale?: string): (path: string) => string;
|