@bractjs/bractjs 0.2.0 → 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 +41 -4
- package/package.json +8 -8
- 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/LICENSE +0 -21
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ This README is a **step-by-step guide to every function and feature** BractJS ex
|
|
|
26
26
|
7. [Per-route context: `defineContext`](#7-per-route-context-definecontext)
|
|
27
27
|
8. [Streaming data: `defer`, `Deferred`, `isDeferred`, `<Await>`](#8-streaming-data)
|
|
28
28
|
9. [Client hooks](#9-client-hooks)
|
|
29
|
-
10. [Client components: `<Outlet>`, `<Link>`, `<Form>`, `<Scripts>`, `<LiveReload>`, `<Image>`](#10-client-components)
|
|
29
|
+
10. [Client components: `<Outlet>`, `<Link>`, `<Form>`, `<Scripts>`, `<LiveReload>`, `<Image>`, `<Toaster>`](#10-client-components)
|
|
30
30
|
11. [Server Actions (`"use server"`) & client-only (`"use client"`)](#11-server-actions--client-components)
|
|
31
31
|
12. [Typed API routes: `route` + `createClient`](#12-typed-api-routes)
|
|
32
32
|
13. [Input validation: `validate`](#13-input-validation-validate)
|
|
@@ -564,6 +564,30 @@ Prompt before leaving when there are unsaved changes (intercepts back/forward an
|
|
|
564
564
|
useBlocker(() => formIsDirty);
|
|
565
565
|
```
|
|
566
566
|
|
|
567
|
+
### `useToast()` → `toast` and `useToasts()` → `ToastEntry[]`
|
|
568
|
+
Flash status feedback ("Saved", "Delete completed", …) from anywhere. `useToast()` returns the stable `toast` API; you can also import `toast` directly to fire from non-React code (event handlers, fetcher callbacks). Render a single [`<Toaster />`](#10-client-components) in `root.tsx`. `useToasts()` exposes the live queue if you want to build a custom renderer.
|
|
569
|
+
```ts
|
|
570
|
+
import { toast, useToast } from "@bractjs/bractjs";
|
|
571
|
+
|
|
572
|
+
toast.success("Saved successfully");
|
|
573
|
+
toast.error("Delete failed", { description: "Try again." });
|
|
574
|
+
toast.info("Heads up"); // also: .warning, .loading
|
|
575
|
+
toast.dismiss(id); // or toast.dismiss() to clear all
|
|
576
|
+
|
|
577
|
+
// Wrap an async action: loading → success / error
|
|
578
|
+
await toast.promise(savePost(data), {
|
|
579
|
+
loading: "Saving…",
|
|
580
|
+
success: "Saved successfully",
|
|
581
|
+
error: (e) => `Save failed: ${e}`,
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
// Optional action button (e.g. undo) + custom auto-dismiss
|
|
585
|
+
toast.success("Delete completed", {
|
|
586
|
+
duration: 6000, // ms; Infinity/0 = sticky
|
|
587
|
+
action: { label: "Undo", onClick: () => restore(id) },
|
|
588
|
+
});
|
|
589
|
+
```
|
|
590
|
+
|
|
567
591
|
### `useLocale(defaultLocale?)` → `string` and `useLocalizedLink(defaultLocale?)` → `(path) => string`
|
|
568
592
|
For i18n prefix routing (§19).
|
|
569
593
|
```ts
|
|
@@ -632,6 +656,17 @@ Markers used inside `root.tsx` (§3). `<Scripts />` is where the client bundle +
|
|
|
632
656
|
### `<Image />`
|
|
633
657
|
Responsive, format-converted images via the built-in `/_image` endpoint — see §20.
|
|
634
658
|
|
|
659
|
+
### `<Toaster position? gap? renderToast?>`
|
|
660
|
+
Renders the toast queue fired by [`toast` / `useToast()`](#9-client-hooks). Mount it **once** in `app/root.tsx`, next to `<Scripts />`. Self-contained inline styles (no CSS import, CSP-safe — no injected `<style>`/nonce).
|
|
661
|
+
```tsx
|
|
662
|
+
<body>
|
|
663
|
+
<Outlet />
|
|
664
|
+
<Toaster position="top-right" /> {/* top|bottom + left|center|right */}
|
|
665
|
+
<Scripts />
|
|
666
|
+
</body>
|
|
667
|
+
```
|
|
668
|
+
Style hooks: target `[data-bract-toaster]` / `[data-bract-toast="success|error|…"]` with your own CSS, or pass `renderToast={(t, dismiss) => <YourCard …/>}` to fully replace the card.
|
|
669
|
+
|
|
635
670
|
---
|
|
636
671
|
|
|
637
672
|
## 11. Server Actions & Client Components
|
|
@@ -1353,9 +1388,11 @@ Everything importable from `@bractjs/bractjs` ([packages/core/src/index.ts](pack
|
|
|
1353
1388
|
|
|
1354
1389
|
**Sessions:** `createCookieSession`
|
|
1355
1390
|
|
|
1356
|
-
**Components:** `Outlet`, `Link`, `Form`, `Scripts`, `LiveReload`, `Await`, `Image`, `ScrollRestoration`
|
|
1391
|
+
**Components:** `Outlet`, `Link`, `Form`, `Scripts`, `LiveReload`, `Await`, `Image`, `ScrollRestoration`, `Toaster`
|
|
1392
|
+
|
|
1393
|
+
**Hooks:** `useLoaderData`, `useActionData`, `useLocation`, `useParams`, `useMatches`, `useNavigation`, `useNavigate`, `useFetcher`, `useFetchers`, `useRevalidator`, `useSearch`, `useSetSearch`, `useSearchParams`, `useBlocker`, `useToast`, `useToasts`, `useLocale`, `useLocalizedLink`
|
|
1357
1394
|
|
|
1358
|
-
**
|
|
1395
|
+
**Toasts:** `toast`, `toastStore`
|
|
1359
1396
|
|
|
1360
1397
|
**Search serialization:** `serializeSearch`
|
|
1361
1398
|
|
|
@@ -1371,7 +1408,7 @@ Everything importable from `@bractjs/bractjs` ([packages/core/src/index.ts](pack
|
|
|
1371
1408
|
|
|
1372
1409
|
**Adapters:** `createCloudflareAdapter`, `makeCloudflareHandler`
|
|
1373
1410
|
|
|
1374
|
-
**Types:** `LoaderArgs`, `ActionArgs`, `MetaArgs`, `MetaDescriptor`, `LoaderFunction`, `ActionFunction`, `MetaFunction`, `RouteModule`, `RouteDefinition`, `RouteFile`, `Segment`, `RouterLocation`, `ShouldRevalidateArgs`, `ShouldRevalidateFunction`, `BractJSConfig`, `RenderOptions`, `ServerManifest`, `ContextFactory`, `ApiRouteDefinition`, `ApiRouteOptions`, `AppApiRoutes`, `FieldErrors`, `ValidationError`, `BractAdapter`, `LifecycleHooks`, `MiddlewareFn`, `MiddlewareContext`, `CorsOptions`, `AuthGuardOptions`, `CspOptions`, `SessionStorageLike`, `SessionLike`, `Session`, `SessionStorage`, `SessionData`, `CookieSessionOptions`, `CommitOptions`, `ImageProps`, `ImageFormat`, `ImageFit`, `SearchParamsResult`, `SetSearchFn`, `SetSearchOptions`, `SearchOutputFor`, `InferSchemaOutput`, `LoaderData`, `ActionData`, `SafeValidateResult`, `FetcherResult`, `FetcherEntry`, `FetcherState`, `FetcherFormProps`, `UseFetcherOptions`, `Revalidator`, `ScrollRestorationProps`, `PrerenderOptions`, `PrerenderResult`, `I18nConfig`, `DevServerOptions`, `DevServer`, `BuildConfig`, `CodegenResult`, `ModuleRegistry`, `BractJSContextValue`, `RouteManifest`
|
|
1411
|
+
**Types:** `LoaderArgs`, `ActionArgs`, `MetaArgs`, `MetaDescriptor`, `LoaderFunction`, `ActionFunction`, `MetaFunction`, `RouteModule`, `RouteDefinition`, `RouteFile`, `Segment`, `RouterLocation`, `ShouldRevalidateArgs`, `ShouldRevalidateFunction`, `BractJSConfig`, `RenderOptions`, `ServerManifest`, `ContextFactory`, `ApiRouteDefinition`, `ApiRouteOptions`, `AppApiRoutes`, `FieldErrors`, `ValidationError`, `BractAdapter`, `LifecycleHooks`, `MiddlewareFn`, `MiddlewareContext`, `CorsOptions`, `AuthGuardOptions`, `CspOptions`, `SessionStorageLike`, `SessionLike`, `Session`, `SessionStorage`, `SessionData`, `CookieSessionOptions`, `CommitOptions`, `ImageProps`, `ImageFormat`, `ImageFit`, `SearchParamsResult`, `SetSearchFn`, `SetSearchOptions`, `SearchOutputFor`, `InferSchemaOutput`, `LoaderData`, `ActionData`, `SafeValidateResult`, `FetcherResult`, `FetcherEntry`, `FetcherState`, `FetcherFormProps`, `UseFetcherOptions`, `Revalidator`, `ScrollRestorationProps`, `ToasterProps`, `ToastPosition`, `Toast`, `ToastEntry`, `ToastOptions`, `ToastType`, `ToastAction`, `PrerenderOptions`, `PrerenderResult`, `I18nConfig`, `DevServerOptions`, `DevServer`, `BuildConfig`, `CodegenResult`, `ModuleRegistry`, `BractJSContextValue`, `RouteManifest`
|
|
1375
1412
|
|
|
1376
1413
|
---
|
|
1377
1414
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bractjs/bractjs",
|
|
3
|
-
"version": "0.2.
|
|
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",
|
|
@@ -42,6 +42,12 @@
|
|
|
42
42
|
"default": "./src/index.ts"
|
|
43
43
|
}
|
|
44
44
|
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"dev": "bun run src/dev/server.ts",
|
|
47
|
+
"build": "bun run src/build/bundler.ts",
|
|
48
|
+
"test": "bun test",
|
|
49
|
+
"typecheck": "bunx tsc --noEmit"
|
|
50
|
+
},
|
|
45
51
|
"peerDependencies": {
|
|
46
52
|
"react": "^19",
|
|
47
53
|
"react-dom": "^19"
|
|
@@ -52,11 +58,5 @@
|
|
|
52
58
|
"@types/react-dom": "^19",
|
|
53
59
|
"react": "^19",
|
|
54
60
|
"react-dom": "^19"
|
|
55
|
-
},
|
|
56
|
-
"scripts": {
|
|
57
|
-
"dev": "bun run src/dev/server.ts",
|
|
58
|
-
"build": "bun run src/build/bundler.ts",
|
|
59
|
-
"test": "bun test",
|
|
60
|
-
"typecheck": "bunx tsc --noEmit"
|
|
61
61
|
}
|
|
62
|
-
}
|
|
62
|
+
}
|
|
@@ -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;
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 YOUR_NAME
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|