@rangojs/router 0.24.4 → 0.25.0
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 +1 -0
- package/dist/bin/rango.js +363 -39
- package/dist/testing/vitest.js +37 -4
- package/dist/types/browser/event-controller.d.ts +24 -5
- package/dist/types/browser/intercept-utils.d.ts +5 -2
- package/dist/types/browser/merge-segment-loaders.d.ts +0 -23
- package/dist/types/browser/navigation-transaction.d.ts +5 -5
- package/dist/types/browser/partial-update.d.ts +21 -13
- package/dist/types/browser/prefetch/cache.d.ts +8 -0
- package/dist/types/browser/rango-marker.d.ts +7 -0
- package/dist/types/browser/react/filter-segment-order.d.ts +10 -0
- package/dist/types/browser/react/use-handle.d.ts +20 -0
- package/dist/types/browser/react/use-href.d.ts +6 -0
- package/dist/types/browser/react/use-router.d.ts +6 -1
- package/dist/types/browser/response-adapter.d.ts +5 -3
- package/dist/types/browser/scroll-restoration.d.ts +9 -3
- package/dist/types/browser/segment-reconciler.d.ts +10 -13
- package/dist/types/browser/types.d.ts +38 -8
- package/dist/types/build/route-types/per-module-writer.d.ts +12 -0
- package/dist/types/cache/cf/cf-shell-frame.d.ts +2 -0
- package/dist/types/cache/types.d.ts +8 -0
- package/dist/types/client-urls/client-urls.d.ts +6 -2
- package/dist/types/client-urls/revalidate-registry.d.ts +1 -1
- package/dist/types/client-urls/server-projection.d.ts +9 -5
- package/dist/types/client-urls/types.d.ts +29 -16
- package/dist/types/client.d.ts +2 -2
- package/dist/types/client.rsc.d.ts +2 -2
- package/dist/types/decode-loader-results.d.ts +28 -7
- package/dist/types/href-client.d.ts +3 -1
- package/dist/types/index.rsc.d.ts +1 -1
- package/dist/types/loader-redirect.d.ts +2 -3
- package/dist/types/outlet-context.d.ts +7 -6
- package/dist/types/prefetch-markers.d.ts +14 -5
- package/dist/types/route-content-wrapper.d.ts +17 -14
- package/dist/types/route-definition/helpers-types.d.ts +7 -2
- package/dist/types/route-definition/resolve-handler-use.d.ts +6 -1
- package/dist/types/route-definition/rewrite.d.ts +48 -5
- package/dist/types/route-types.d.ts +2 -2
- package/dist/types/router/handler-context.d.ts +14 -2
- package/dist/types/router/intercept-resolution.d.ts +19 -46
- package/dist/types/router/lazy-includes.d.ts +7 -0
- package/dist/types/router/match-middleware/intercept-resolution.d.ts +2 -2
- package/dist/types/router/middleware.d.ts +0 -16
- package/dist/types/router/navigation-snapshot.d.ts +2 -0
- package/dist/types/router/prefetch-cache-ttl.d.ts +8 -11
- package/dist/types/router/revalidation.d.ts +8 -1
- package/dist/types/router/router-context.d.ts +4 -16
- package/dist/types/router/router-interfaces.d.ts +0 -6
- package/dist/types/router/router-options.d.ts +4 -7
- package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +31 -8
- package/dist/types/router/segment-resolution/revalidation.d.ts +38 -4
- package/dist/types/router/segment-resolution.d.ts +1 -1
- package/dist/types/router/segment-wrappers.d.ts +3 -5
- package/dist/types/router/substitute-pattern-params.d.ts +3 -2
- package/dist/types/router/tracing.d.ts +2 -2
- package/dist/types/rsc/full-payload.d.ts +14 -2
- package/dist/types/rsc/helpers.d.ts +8 -0
- package/dist/types/rsc/manifest-init.d.ts +20 -0
- package/dist/types/rsc/prefetch-render-error.d.ts +22 -0
- package/dist/types/rsc/progressive-enhancement.d.ts +20 -0
- package/dist/types/rsc/rewrite-passes.d.ts +12 -2
- package/dist/types/rsc/server-action.d.ts +6 -0
- package/dist/types/rsc/shell-capture-constants.d.ts +10 -1
- package/dist/types/rsc/shell-capture.d.ts +7 -0
- package/dist/types/rsc/types.d.ts +5 -0
- package/dist/types/segment-system.d.ts +15 -0
- package/dist/types/server/context.d.ts +64 -4
- package/dist/types/server/request-context.d.ts +41 -4
- package/dist/types/server/request-headers.d.ts +12 -0
- package/dist/types/server/rewrite-trail.d.ts +8 -1
- package/dist/types/testing/e2e/index.d.ts +1 -1
- package/dist/types/testing/flight.entry.d.ts +1 -1
- package/dist/types/testing/index.d.ts +1 -1
- package/dist/types/testing/internal/client-urls-projection.d.ts +4 -3
- package/dist/types/testing/render-route.d.ts +21 -11
- package/dist/types/testing/run-loader.d.ts +7 -0
- package/dist/types/testing/run-middleware.d.ts +7 -0
- package/dist/types/testing/run-revalidate.d.ts +5 -4
- package/dist/types/testing/serve-shell-request.d.ts +41 -4
- package/dist/types/testing/shell-status.d.ts +17 -0
- package/dist/types/testing/vitest-stubs/version.d.ts +1 -0
- package/dist/types/testing/vitest.d.ts +10 -1
- package/dist/types/thenable-status.d.ts +0 -6
- package/dist/types/types/handler-context.d.ts +12 -1
- package/dist/types/types/loader-types.d.ts +4 -3
- package/dist/types/types/route-entry.d.ts +2 -1
- package/dist/types/urls/path-helper-types.d.ts +4 -2
- package/dist/types/vite/discovery/client-urls-projection.d.ts +55 -5
- package/dist/types/vite/discovery/state.d.ts +20 -0
- package/dist/types/vite/plugins/client-urls-revalidate-hoist.d.ts +28 -16
- package/dist/types/vite/plugins/hoist/source.d.ts +8 -7
- package/dist/types/vite/plugins/virtual-entries.d.ts +6 -0
- package/dist/types/vite/utils/prerender-utils.d.ts +7 -0
- package/dist/vite/index.js +6816 -6547
- package/package.json +1 -1
- package/skills/app-structure/SKILL.md +255 -0
- package/skills/app-structure/content-site.md +333 -0
- package/skills/app-structure/e-commerce.md +435 -0
- package/skills/app-structure/large-app.md +885 -0
- package/skills/app-structure/multi-app.md +280 -0
- package/skills/breadcrumbs/SKILL.md +6 -1
- package/skills/caching/SKILL.md +3 -1
- package/skills/catalog.json +12 -0
- package/skills/client-urls/SKILL.md +185 -32
- package/skills/composability/SKILL.md +10 -8
- package/skills/handler-use/SKILL.md +48 -9
- package/skills/hooks/handle-and-actions.md +104 -0
- package/skills/hooks/navigation.md +24 -10
- package/skills/hooks/urls.md +1 -1
- package/skills/i18n/SKILL.md +2 -1
- package/skills/intercept/SKILL.md +60 -29
- package/skills/layout/SKILL.md +6 -3
- package/skills/links/SKILL.md +151 -17
- package/skills/loader/SKILL.md +32 -10
- package/skills/middleware/SKILL.md +111 -15
- package/skills/observability/SKILL.md +11 -0
- package/skills/parallel/SKILL.md +5 -5
- package/skills/ppr/SKILL.md +43 -8
- package/skills/prerender/SKILL.md +1 -2
- package/skills/rango/SKILL.md +3 -0
- package/skills/response-routes/SKILL.md +4 -1
- package/skills/scripts/SKILL.md +12 -1
- package/skills/server-actions/SKILL.md +60 -20
- package/skills/testing/SKILL.md +1 -0
- package/skills/testing/client-components.md +13 -10
- package/skills/testing/loader.md +25 -24
- package/skills/testing/middleware.md +11 -11
- package/skills/testing/response-routes.md +4 -1
- package/skills/testing/serve-shell-request.md +85 -21
- package/skills/ux-architecture/SKILL.md +980 -0
- package/skills/view-transitions/SKILL.md +43 -10
- package/src/__augment-tests__/augmented.check.ts +28 -0
- package/src/browser/event-controller.ts +44 -18
- package/src/browser/intercept-utils.ts +3 -2
- package/src/browser/merge-segment-loaders.ts +0 -85
- package/src/browser/navigation-bridge.ts +112 -84
- package/src/browser/navigation-client.ts +20 -15
- package/src/browser/navigation-store.ts +4 -2
- package/src/browser/navigation-transaction.ts +9 -8
- package/src/browser/partial-update.ts +90 -32
- package/src/browser/prefetch/cache.ts +8 -0
- package/src/browser/prefetch/fetch.ts +13 -3
- package/src/browser/rango-marker.ts +13 -0
- package/src/browser/react/NavigationProvider.tsx +25 -7
- package/src/browser/react/filter-segment-order.ts +13 -0
- package/src/browser/react/use-action.ts +9 -15
- package/src/browser/react/use-handle.ts +63 -71
- package/src/browser/react/use-href.tsx +24 -5
- package/src/browser/react/use-link-status.ts +1 -1
- package/src/browser/react/use-navigation.ts +30 -40
- package/src/browser/react/use-router.ts +37 -6
- package/src/browser/react/use-segments.ts +8 -12
- package/src/browser/response-adapter.ts +11 -5
- package/src/browser/rsc-router.tsx +3 -1
- package/src/browser/scroll-restoration.ts +34 -20
- package/src/browser/segment-reconciler.ts +11 -29
- package/src/browser/server-action-bridge.ts +52 -16
- package/src/browser/types.ts +40 -8
- package/src/build/generate-manifest.ts +12 -1
- package/src/build/route-types/codegen.ts +6 -2
- package/src/build/route-types/per-module-writer.ts +94 -61
- package/src/cache/cache-runtime.ts +9 -0
- package/src/cache/cache-scope.ts +2 -1
- package/src/cache/cf/cf-cache-store.ts +2 -0
- package/src/cache/cf/cf-shell-frame.ts +3 -0
- package/src/cache/types.ts +8 -0
- package/src/cache/vercel/vercel-cache-store.ts +6 -1
- package/src/client-urls/client-urls.ts +202 -153
- package/src/client-urls/revalidate-registry.ts +12 -3
- package/src/client-urls/server-projection.ts +137 -66
- package/src/client-urls/types.ts +30 -15
- package/src/client.rsc.tsx +11 -1
- package/src/client.tsx +9 -1
- package/src/decode-loader-results.ts +64 -31
- package/src/href-client.ts +3 -1
- package/src/index.rsc.ts +1 -0
- package/src/index.ts +12 -4
- package/src/loader-redirect.tsx +2 -3
- package/src/outlet-context.ts +7 -6
- package/src/prefetch-markers.ts +15 -5
- package/src/reverse.ts +3 -3
- package/src/route-content-wrapper.tsx +19 -17
- package/src/route-definition/dsl-helpers.ts +27 -7
- package/src/route-definition/helpers-types.ts +7 -2
- package/src/route-definition/resolve-handler-use.ts +23 -10
- package/src/route-definition/rewrite.ts +86 -19
- package/src/route-types.ts +1 -2
- package/src/router/find-match.ts +37 -1
- package/src/router/handler-context.ts +85 -40
- package/src/router/intercept-resolution.ts +161 -307
- package/src/router/lazy-includes.ts +43 -27
- package/src/router/loader-resolution.ts +21 -1
- package/src/router/manifest.ts +6 -8
- package/src/router/match-api.ts +11 -1
- package/src/router/match-middleware/background-revalidation.ts +2 -4
- package/src/router/match-middleware/cache-lookup.ts +16 -20
- package/src/router/match-middleware/intercept-resolution.ts +41 -60
- package/src/router/match-result.ts +31 -2
- package/src/router/middleware.ts +6 -147
- package/src/router/navigation-snapshot.ts +38 -2
- package/src/router/prefetch-cache-ttl.ts +10 -18
- package/src/router/prerender-match.ts +2 -1
- package/src/router/revalidation.ts +24 -3
- package/src/router/router-context.ts +5 -18
- package/src/router/router-interfaces.ts +0 -7
- package/src/router/router-options.ts +4 -7
- package/src/router/segment-resolution/fresh.ts +6 -2
- package/src/router/segment-resolution/helpers.ts +7 -1
- package/src/router/segment-resolution/prefetch-deferral.ts +62 -12
- package/src/router/segment-resolution/revalidation.ts +75 -35
- package/src/router/segment-resolution.ts +0 -1
- package/src/router/segment-wrappers.ts +8 -13
- package/src/router/substitute-pattern-params.ts +3 -2
- package/src/router/tracing.ts +2 -2
- package/src/router.ts +2 -9
- package/src/rsc/full-payload.ts +26 -4
- package/src/rsc/handler.ts +83 -41
- package/src/rsc/helpers.ts +13 -0
- package/src/rsc/manifest-init.ts +56 -3
- package/src/rsc/prefetch-render-error.ts +38 -0
- package/src/rsc/progressive-enhancement.ts +161 -211
- package/src/rsc/rewrite-passes.ts +56 -9
- package/src/rsc/rsc-rendering.ts +79 -23
- package/src/rsc/server-action.ts +25 -2
- package/src/rsc/shell-capture-constants.ts +22 -0
- package/src/rsc/shell-capture.ts +11 -0
- package/src/rsc/types.ts +5 -0
- package/src/segment-system.tsx +125 -119
- package/src/server/context.ts +96 -5
- package/src/server/handle-store.ts +3 -2
- package/src/server/request-context.ts +42 -3
- package/src/server/request-headers.ts +32 -0
- package/src/server/rewrite-trail.ts +55 -9
- package/src/ssr/ssr-root.tsx +6 -10
- package/src/testing/dispatch.ts +4 -5
- package/src/testing/e2e/index.ts +1 -0
- package/src/testing/flight.entry.ts +1 -0
- package/src/testing/index.ts +1 -0
- package/src/testing/internal/client-urls-projection.ts +31 -11
- package/src/testing/render-route.tsx +83 -40
- package/src/testing/run-loader.ts +26 -2
- package/src/testing/run-middleware.ts +10 -0
- package/src/testing/run-revalidate.ts +13 -6
- package/src/testing/serve-shell-request.ts +77 -4
- package/src/testing/shell-status.ts +40 -0
- package/src/testing/vitest-stubs/version.ts +1 -0
- package/src/testing/vitest.ts +74 -7
- package/src/thenable-status.ts +0 -11
- package/src/types/handler-context.ts +12 -1
- package/src/types/loader-types.ts +4 -3
- package/src/types/route-entry.ts +2 -1
- package/src/urls/include-helper.ts +38 -11
- package/src/urls/path-helper-types.ts +4 -2
- package/src/vite/discovery/client-urls-projection.ts +205 -16
- package/src/vite/discovery/discover-routers.ts +2 -2
- package/src/vite/discovery/prerender-collection.ts +2 -6
- package/src/vite/discovery/state.ts +26 -0
- package/src/vite/plugins/client-urls-revalidate-hoist.ts +190 -77
- package/src/vite/plugins/expose-internal-ids.ts +46 -40
- package/src/vite/plugins/hoist/source.ts +8 -7
- package/src/vite/plugins/transition-when-hoist.ts +1 -0
- package/src/vite/plugins/version-plugin.ts +7 -0
- package/src/vite/plugins/version.d.ts +7 -0
- package/src/vite/plugins/virtual-entries.ts +20 -0
- package/src/vite/rango.ts +4 -1
- package/src/vite/router-discovery.ts +39 -7
- package/src/vite/utils/prerender-utils.ts +17 -0
|
@@ -0,0 +1,885 @@
|
|
|
1
|
+
# A large app with a protected admin area
|
|
2
|
+
|
|
3
|
+
The shape for a signed-in product: a dashboard or SaaS app with public
|
|
4
|
+
marketing pages, sign-in, an onboarding flow, the app itself, settings, an
|
|
5
|
+
admin area for staff, and a JSON API.
|
|
6
|
+
|
|
7
|
+
- **A server (RSC) shell** renders the chrome of every signed-in page: the
|
|
8
|
+
top nav and the user menu. It renders on the document load and is kept on
|
|
9
|
+
every navigation inside the areas below it.
|
|
10
|
+
- **User and app data come from loaders**, read with `useLoader()` where they
|
|
11
|
+
are shown. Request-scoped values (the session user) come from middleware
|
|
12
|
+
`ctx.set()`.
|
|
13
|
+
- **Each large area is its own `clientUrls()` group**: `auth`, `onboarding`,
|
|
14
|
+
`app`, `settings`, `admin`, each mounted under the shell with a sync
|
|
15
|
+
`include()`.
|
|
16
|
+
- **Middleware gates each area**: the session is resolved globally, a
|
|
17
|
+
sign-in guard wraps every protected include (`auth` sits outside it), an
|
|
18
|
+
onboarding gate wraps the app and settings, and a role guard wraps admin.
|
|
19
|
+
- **Links prefetch**, and `prefetch: false` keeps the expensive loaders out
|
|
20
|
+
of the prefetches that rarely become clicks.
|
|
21
|
+
|
|
22
|
+
## How navigations behave in this shape
|
|
23
|
+
|
|
24
|
+
- **Inside a group** (`/app` to `/app/projects/42`): the group's definition
|
|
25
|
+
matches in the browser, so the destination renders at the click, its loader
|
|
26
|
+
reads suspend behind their own `<Suspense>`, and the group layout's
|
|
27
|
+
`useOutlet().pending` turns on. Loaders whose `revalidate()` says hold keep
|
|
28
|
+
their data. The canonical request still goes to the server.
|
|
29
|
+
- **Between groups** (`/app/projects` to `/settings`): a normal server
|
|
30
|
+
navigation. Only the mounted group matches locally
|
|
31
|
+
(`src/client-urls/navigation.ts`), so there is no optimistic frame; the
|
|
32
|
+
shell is kept (its segment and its loader are not re-rendered) and the
|
|
33
|
+
destination group's layout mounts.
|
|
34
|
+
- **Every navigation sends a request, and route middleware wraps each one.**
|
|
35
|
+
A user whose session ended is redirected on the next click, inside a group
|
|
36
|
+
or not; the instant feel comes from held data and the optimistic frame, not
|
|
37
|
+
from skipping the server.
|
|
38
|
+
|
|
39
|
+
Where the loaders, `<Suspense>` boundaries and `revalidate()` rules go so
|
|
40
|
+
that these navigations show the next page early and never flash a fallback
|
|
41
|
+
is the `ux-architecture` skill's subject; this file places the routes, the
|
|
42
|
+
middleware and the data around them.
|
|
43
|
+
|
|
44
|
+
## File tree
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
src/
|
|
48
|
+
router.tsx createRouter(), global middleware
|
|
49
|
+
router.named-routes.gen.ts generated (Vite plugin / rango generate), committed
|
|
50
|
+
document.tsx "use client" Document
|
|
51
|
+
urls.tsx root: marketing, auth, the shell and its groups, api
|
|
52
|
+
middleware/
|
|
53
|
+
session.ts loadSession (global): CurrentUser from the cookie
|
|
54
|
+
guards.ts requireUser, requireOnboarded, requireAdmin, requireUserForFetch
|
|
55
|
+
maintenance.ts optional: throw rewrite("maintenance"), a global router.use()
|
|
56
|
+
same-origin.ts requireSameOrigin for /api (see /response-routes, CSRF)
|
|
57
|
+
shell/
|
|
58
|
+
app-shell.tsx server layout: nav, user menu slot, <Outlet />
|
|
59
|
+
user-menu.tsx "use client": useLoader(ViewerLoader)
|
|
60
|
+
boundaries.tsx AppError, AppNotFound
|
|
61
|
+
loaders/ viewer.ts, projects.ts, …
|
|
62
|
+
actions/ "use server": auth.ts, profile.ts, projects.ts, admin.ts, …
|
|
63
|
+
urls/
|
|
64
|
+
marketing.tsx urls(): public pages, ppr
|
|
65
|
+
auth.client.tsx clientUrls(): login, signup
|
|
66
|
+
onboarding.client.tsx clientUrls(): the onboarding steps
|
|
67
|
+
app.client.tsx clientUrls(): dashboard, projects
|
|
68
|
+
app.predicates.ts revalidate() predicates the app group shares
|
|
69
|
+
settings.client.tsx clientUrls(): profile, billing
|
|
70
|
+
admin.client.tsx clientUrls(): users
|
|
71
|
+
api.tsx urls(): path.json() routes
|
|
72
|
+
*.gen.ts generated per module by rango generate, committed
|
|
73
|
+
pages/ server pages (marketing, maintenance)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## router.tsx
|
|
77
|
+
|
|
78
|
+
```tsx
|
|
79
|
+
// src/router.tsx
|
|
80
|
+
import { createRouter } from "@rangojs/router";
|
|
81
|
+
import { MemorySegmentCacheStore } from "@rangojs/router/cache";
|
|
82
|
+
import { Document } from "./document";
|
|
83
|
+
import { urlpatterns } from "./urls";
|
|
84
|
+
import { loadSession } from "./middleware/session";
|
|
85
|
+
import { maintenance } from "./middleware/maintenance";
|
|
86
|
+
import { requireSameOrigin } from "./middleware/same-origin";
|
|
87
|
+
|
|
88
|
+
export const router = createRouter({
|
|
89
|
+
document: Document,
|
|
90
|
+
// The store behind ppr shells, cache() and "use cache". One process here;
|
|
91
|
+
// CFCacheStore on Workers, VercelCacheStore on Vercel.
|
|
92
|
+
cache: { store: new MemorySegmentCacheStore() },
|
|
93
|
+
})
|
|
94
|
+
.use(maintenance) // optional, runs first
|
|
95
|
+
.use(loadSession) // every request: renders, actions, fetchable loaders
|
|
96
|
+
.use("/api/*", requireSameOrigin)
|
|
97
|
+
.routes(urlpatterns);
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`defaultPrefetch` is left at its default: `"none"` in development,
|
|
101
|
+
`"viewport"` in production, so every visible `<Link>` prefetches its route.
|
|
102
|
+
On pages with long lists of links, pass `prefetch="hover"` on those links, or
|
|
103
|
+
set the router default to `"hover"` or `"adaptive"` (`/links`).
|
|
104
|
+
|
|
105
|
+
## The middleware
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
// src/middleware/session.ts
|
|
109
|
+
import { cookies, createVar } from "@rangojs/router";
|
|
110
|
+
import type { Middleware } from "@rangojs/router";
|
|
111
|
+
import { sessions, type User } from "../lib/db";
|
|
112
|
+
|
|
113
|
+
// cache: false: a cache() boundary or "use cache" function that reads it
|
|
114
|
+
// throws instead of storing one user's data for everyone.
|
|
115
|
+
export const CurrentUser = createVar<User>({ cache: false });
|
|
116
|
+
|
|
117
|
+
// Global: resolves identity on every request. It does not gate.
|
|
118
|
+
export const loadSession: Middleware = async (ctx, next) => {
|
|
119
|
+
const token = cookies().get("session")?.value;
|
|
120
|
+
const user = token ? await sessions.verify(token) : null;
|
|
121
|
+
if (user) ctx.set(CurrentUser, user);
|
|
122
|
+
return next();
|
|
123
|
+
};
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
// src/middleware/guards.ts
|
|
128
|
+
import { redirect } from "@rangojs/router";
|
|
129
|
+
import type { Middleware } from "@rangojs/router";
|
|
130
|
+
import { CurrentUser } from "./session";
|
|
131
|
+
|
|
132
|
+
// Route middleware: wraps every render of the routes below it, document loads
|
|
133
|
+
// and client navigations. It does not wrap server actions or fetchable-loader
|
|
134
|
+
// calls.
|
|
135
|
+
export const requireUser: Middleware = async (ctx, next) => {
|
|
136
|
+
if (!ctx.get(CurrentUser)) {
|
|
137
|
+
return redirect(ctx.reverse("auth.login", {}, { next: ctx.pathname }));
|
|
138
|
+
}
|
|
139
|
+
return next();
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export const requireOnboarded: Middleware = async (ctx, next) => {
|
|
143
|
+
if (!ctx.get(CurrentUser)?.onboarded) {
|
|
144
|
+
return redirect(ctx.reverse("onboarding.start"));
|
|
145
|
+
}
|
|
146
|
+
return next();
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
export const requireAdmin: Middleware = async (ctx, next) => {
|
|
150
|
+
if (ctx.get(CurrentUser)?.role !== "admin") {
|
|
151
|
+
return redirect(ctx.reverse("app.dashboard"));
|
|
152
|
+
}
|
|
153
|
+
return next();
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Per-loader middleware for fetchable loaders. A redirect on that lane would
|
|
157
|
+
// hand the login page's HTML to load(); answer 401 instead.
|
|
158
|
+
export const requireUserForFetch: Middleware = async (ctx, next) => {
|
|
159
|
+
if (!ctx.get(CurrentUser)) {
|
|
160
|
+
return Response.json({ error: "unauthorized" }, { status: 401 });
|
|
161
|
+
}
|
|
162
|
+
return next();
|
|
163
|
+
};
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`ctx.reverse()` in middleware takes global names only and fills no params
|
|
167
|
+
from the request, so the guards name full routes (`auth.login`) and pass the
|
|
168
|
+
search object the login route's schema declares. (`getRequestContext().reverse`
|
|
169
|
+
in route middleware does fill mount params after the match.)
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
// src/middleware/maintenance.ts (optional)
|
|
173
|
+
import { rewrite } from "@rangojs/router";
|
|
174
|
+
import type { Middleware } from "@rangojs/router";
|
|
175
|
+
import { flags } from "../lib/flags";
|
|
176
|
+
|
|
177
|
+
export const maintenance: Middleware = async (ctx, next) => {
|
|
178
|
+
if (!flags.maintenance || ctx.pathname === "/maintenance") return next();
|
|
179
|
+
// A rewrite moves a server action or form post to its target, where the
|
|
180
|
+
// action runs: a lock answers mutations itself (HEAD rewrites like GET).
|
|
181
|
+
if (ctx.request.method !== "GET" && ctx.request.method !== "HEAD") {
|
|
182
|
+
return new Response("Maintenance", { status: 503 });
|
|
183
|
+
}
|
|
184
|
+
throw rewrite("maintenance"); // renders /maintenance at the visitor's URL
|
|
185
|
+
};
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## urls.tsx
|
|
189
|
+
|
|
190
|
+
```tsx
|
|
191
|
+
// src/urls.tsx
|
|
192
|
+
import { urls } from "@rangojs/router";
|
|
193
|
+
import { marketingPatterns } from "./urls/marketing";
|
|
194
|
+
import authUrls from "./urls/auth.client";
|
|
195
|
+
import onboardingUrls from "./urls/onboarding.client";
|
|
196
|
+
import appUrls from "./urls/app.client";
|
|
197
|
+
import settingsUrls from "./urls/settings.client";
|
|
198
|
+
import adminUrls from "./urls/admin.client";
|
|
199
|
+
import { apiPatterns } from "./urls/api";
|
|
200
|
+
import { AppShell } from "./shell/app-shell";
|
|
201
|
+
import { AppError, AppNotFound } from "./shell/boundaries";
|
|
202
|
+
import { ViewerLoader } from "./loaders/viewer";
|
|
203
|
+
import { MaintenancePage } from "./pages/marketing";
|
|
204
|
+
import { updateProfile } from "./actions/profile";
|
|
205
|
+
import {
|
|
206
|
+
requireAdmin,
|
|
207
|
+
requireOnboarded,
|
|
208
|
+
requireUser,
|
|
209
|
+
} from "./middleware/guards";
|
|
210
|
+
|
|
211
|
+
export const urlpatterns = urls(
|
|
212
|
+
({
|
|
213
|
+
path,
|
|
214
|
+
layout,
|
|
215
|
+
include,
|
|
216
|
+
middleware,
|
|
217
|
+
loader,
|
|
218
|
+
revalidate,
|
|
219
|
+
errorBoundary,
|
|
220
|
+
notFoundBoundary,
|
|
221
|
+
}) => [
|
|
222
|
+
// Public server routes with ppr shells.
|
|
223
|
+
include("/", marketingPatterns, { name: "marketing" }),
|
|
224
|
+
|
|
225
|
+
// Outside the guard: requireUser redirects here.
|
|
226
|
+
include("/auth", authUrls, { name: "auth" }),
|
|
227
|
+
|
|
228
|
+
// Every signed-in page shares one server shell.
|
|
229
|
+
layout(AppShell, () => [
|
|
230
|
+
middleware(requireUser),
|
|
231
|
+
loader(ViewerLoader, () => [
|
|
232
|
+
// A loader above the route re-runs after every action by default;
|
|
233
|
+
// only a profile change touches this one. Navigations hold it.
|
|
234
|
+
revalidate((ctx) =>
|
|
235
|
+
ctx.isAction() ? ctx.isAction(updateProfile) : undefined,
|
|
236
|
+
),
|
|
237
|
+
]),
|
|
238
|
+
errorBoundary(AppError),
|
|
239
|
+
notFoundBoundary(AppNotFound),
|
|
240
|
+
|
|
241
|
+
include("/onboarding", onboardingUrls, { name: "onboarding" }),
|
|
242
|
+
middleware(requireOnboarded, () => [
|
|
243
|
+
include("/app", appUrls, { name: "app" }),
|
|
244
|
+
include("/settings", settingsUrls, { name: "settings" }),
|
|
245
|
+
]),
|
|
246
|
+
middleware(requireAdmin, () => [
|
|
247
|
+
include("/admin", adminUrls, { name: "admin" }),
|
|
248
|
+
]),
|
|
249
|
+
]),
|
|
250
|
+
|
|
251
|
+
include("/api", apiPatterns, { name: "api" }),
|
|
252
|
+
path("/maintenance", MaintenancePage, { name: "maintenance" }),
|
|
253
|
+
],
|
|
254
|
+
);
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
- `middleware(guard, () => [include(...)])` scopes the guard to the routes
|
|
258
|
+
of those includes: it is a transparent layout that carries the middleware.
|
|
259
|
+
- Route middleware runs outer to inner: a signed-out request for `/settings`
|
|
260
|
+
meets `requireUser` (redirect to `/auth/login?next=%2Fsettings`) before
|
|
261
|
+
`requireOnboarded`.
|
|
262
|
+
- `onboarding` sits inside `requireUser` but outside `requireOnboarded`, or
|
|
263
|
+
the gate would redirect to itself. A middleware around the `auth` include
|
|
264
|
+
can send signed-in users on to `/app` the same way.
|
|
265
|
+
|
|
266
|
+
## The shell
|
|
267
|
+
|
|
268
|
+
```tsx
|
|
269
|
+
// src/shell/app-shell.tsx: a server layout
|
|
270
|
+
import { Suspense } from "react";
|
|
271
|
+
import type { HandlerContext } from "@rangojs/router";
|
|
272
|
+
import { Link, Outlet } from "@rangojs/router/client";
|
|
273
|
+
import { UserMenu } from "./user-menu";
|
|
274
|
+
|
|
275
|
+
export function AppShell(ctx: HandlerContext) {
|
|
276
|
+
return (
|
|
277
|
+
<div className="app">
|
|
278
|
+
<header>
|
|
279
|
+
<nav>
|
|
280
|
+
<Link to={ctx.reverse("app.dashboard")}>Dashboard</Link>
|
|
281
|
+
<Link to={ctx.reverse("app.projects")}>Projects</Link>
|
|
282
|
+
<Link to={ctx.reverse("settings.profile")}>Settings</Link>
|
|
283
|
+
</nav>
|
|
284
|
+
<Suspense fallback={<span className="avatar-skeleton" />}>
|
|
285
|
+
<UserMenu adminHref={ctx.reverse("admin.users")} />
|
|
286
|
+
</Suspense>
|
|
287
|
+
</header>
|
|
288
|
+
<main>
|
|
289
|
+
<Outlet />
|
|
290
|
+
</main>
|
|
291
|
+
</div>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
```tsx
|
|
297
|
+
// src/shell/user-menu.tsx
|
|
298
|
+
"use client";
|
|
299
|
+
|
|
300
|
+
import { Link, useLoader } from "@rangojs/router/client";
|
|
301
|
+
import { ViewerLoader } from "../loaders/viewer";
|
|
302
|
+
import { logout } from "../actions/auth";
|
|
303
|
+
|
|
304
|
+
export function UserMenu({ adminHref }: { adminHref: string }) {
|
|
305
|
+
const { data: viewer } = useLoader(ViewerLoader);
|
|
306
|
+
return (
|
|
307
|
+
<div className="user-menu">
|
|
308
|
+
<span>{viewer.name}</span>
|
|
309
|
+
{viewer.role === "admin" ? <Link to={adminHref}>Admin</Link> : null}
|
|
310
|
+
<form action={logout}>
|
|
311
|
+
<button type="submit">Sign out</button>
|
|
312
|
+
</form>
|
|
313
|
+
</div>
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
// src/loaders/viewer.ts
|
|
320
|
+
import { createLoader } from "@rangojs/router";
|
|
321
|
+
import { CurrentUser } from "../middleware/session";
|
|
322
|
+
import { requireUserForFetch } from "../middleware/guards";
|
|
323
|
+
|
|
324
|
+
// An options object makes the loader fetchable (refetch() works on it); the
|
|
325
|
+
// middleware guards that fetch lane.
|
|
326
|
+
export const ViewerLoader = createLoader(
|
|
327
|
+
async (ctx) => {
|
|
328
|
+
const user = ctx.get(CurrentUser)!;
|
|
329
|
+
return { name: user.name, email: user.email, role: user.role };
|
|
330
|
+
},
|
|
331
|
+
{ middleware: [requireUserForFetch] },
|
|
332
|
+
);
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
The shell renders no user data itself: the user menu reads the loader under
|
|
336
|
+
its own `<Suspense>`, so the nav paints before the loader answers. Any
|
|
337
|
+
component below the shell can read `useLoader(ViewerLoader)` too (the
|
|
338
|
+
settings form below does); the read walks up to the layout that registered
|
|
339
|
+
it (`src/use-loader.tsx`).
|
|
340
|
+
|
|
341
|
+
## The groups
|
|
342
|
+
|
|
343
|
+
Each group is a `"use client"` module whose default export is a
|
|
344
|
+
`clientUrls()` definition: named function components, loaders from
|
|
345
|
+
`createLoader()`, no handlers.
|
|
346
|
+
|
|
347
|
+
```tsx
|
|
348
|
+
// src/urls/auth.client.tsx
|
|
349
|
+
"use client";
|
|
350
|
+
|
|
351
|
+
import { useActionState } from "react";
|
|
352
|
+
import { clientUrls, useOutlet, useSearchParams } from "@rangojs/router/client";
|
|
353
|
+
import { login } from "../actions/auth";
|
|
354
|
+
|
|
355
|
+
function AuthLayout() {
|
|
356
|
+
const { content } = useOutlet();
|
|
357
|
+
return <div className="auth-card">{content}</div>;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function LoginPage() {
|
|
361
|
+
const [state, formAction, pending] = useActionState(login, null);
|
|
362
|
+
const [search] = useSearchParams();
|
|
363
|
+
return (
|
|
364
|
+
<form action={formAction}>
|
|
365
|
+
<input type="hidden" name="next" value={search.get("next") ?? ""} />
|
|
366
|
+
<input name="email" type="email" />
|
|
367
|
+
<input name="password" type="password" />
|
|
368
|
+
{state?.error ? <p role="alert">{state.error}</p> : null}
|
|
369
|
+
<button disabled={pending}>Sign in</button>
|
|
370
|
+
</form>
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function SignupPage() {
|
|
375
|
+
return <form>{/* … */}</form>;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export default clientUrls(({ path, layout }) => [
|
|
379
|
+
layout(AuthLayout, () => [
|
|
380
|
+
path("/login", LoginPage, { name: "login", search: { next: "string?" } }),
|
|
381
|
+
path("/signup", SignupPage, { name: "signup" }),
|
|
382
|
+
]),
|
|
383
|
+
]);
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
```tsx
|
|
387
|
+
// src/urls/app.client.tsx
|
|
388
|
+
"use client";
|
|
389
|
+
|
|
390
|
+
import { Suspense, useActionState } from "react";
|
|
391
|
+
import {
|
|
392
|
+
clientUrls,
|
|
393
|
+
Link,
|
|
394
|
+
useLoader,
|
|
395
|
+
useOutlet,
|
|
396
|
+
useReverse,
|
|
397
|
+
} from "@rangojs/router/client";
|
|
398
|
+
import {
|
|
399
|
+
ActivityLoader,
|
|
400
|
+
ProjectLoader,
|
|
401
|
+
ProjectsLoader,
|
|
402
|
+
StatsLoader,
|
|
403
|
+
} from "../loaders/projects";
|
|
404
|
+
import { renameProject } from "../actions/projects";
|
|
405
|
+
import { projectData } from "./app.predicates";
|
|
406
|
+
import { routes } from "./app.client.gen";
|
|
407
|
+
|
|
408
|
+
function AppLayout() {
|
|
409
|
+
const { content, pending } = useOutlet();
|
|
410
|
+
const reverse = useReverse(routes); // mount-aware: "/app/projects"
|
|
411
|
+
return (
|
|
412
|
+
<div aria-busy={pending}>
|
|
413
|
+
<nav>
|
|
414
|
+
<Link to={reverse("dashboard")}>Overview</Link>
|
|
415
|
+
<Link to={reverse("projects")}>Projects</Link>
|
|
416
|
+
</nav>
|
|
417
|
+
{content}
|
|
418
|
+
</div>
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function Stats() {
|
|
423
|
+
const { data } = useLoader(StatsLoader);
|
|
424
|
+
return <p>{data.projects} projects</p>;
|
|
425
|
+
}
|
|
426
|
+
function Dashboard() {
|
|
427
|
+
return (
|
|
428
|
+
<Suspense fallback={<p>…</p>}>
|
|
429
|
+
<Stats />
|
|
430
|
+
</Suspense>
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function ProjectRows() {
|
|
435
|
+
const { data: projects } = useLoader(ProjectsLoader);
|
|
436
|
+
const reverse = useReverse(routes);
|
|
437
|
+
return (
|
|
438
|
+
<ul>
|
|
439
|
+
{projects.map((p) => (
|
|
440
|
+
<li key={p.id}>
|
|
441
|
+
<Link to={reverse("project", { projectId: p.id })}>{p.name}</Link>
|
|
442
|
+
</li>
|
|
443
|
+
))}
|
|
444
|
+
</ul>
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
function ProjectList() {
|
|
448
|
+
return (
|
|
449
|
+
<Suspense fallback={<p>Loading projects…</p>}>
|
|
450
|
+
<ProjectRows />
|
|
451
|
+
</Suspense>
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function ProjectHeader() {
|
|
456
|
+
const { data: project } = useLoader(ProjectLoader);
|
|
457
|
+
const [, formAction, pending] = useActionState(renameProject, null);
|
|
458
|
+
return (
|
|
459
|
+
<form action={formAction}>
|
|
460
|
+
<input type="hidden" name="projectId" value={project.id} />
|
|
461
|
+
<input name="name" defaultValue={project.name} />
|
|
462
|
+
<button disabled={pending}>Rename</button>
|
|
463
|
+
</form>
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function Activity() {
|
|
468
|
+
const { data: items } = useLoader(ActivityLoader);
|
|
469
|
+
return (
|
|
470
|
+
<ul>
|
|
471
|
+
{items.map((i) => (
|
|
472
|
+
<li key={i.id}>{i.text}</li>
|
|
473
|
+
))}
|
|
474
|
+
</ul>
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function ProjectPage() {
|
|
479
|
+
return (
|
|
480
|
+
<>
|
|
481
|
+
<Suspense fallback={<h1>…</h1>}>
|
|
482
|
+
<ProjectHeader />
|
|
483
|
+
</Suspense>
|
|
484
|
+
<Suspense fallback={<p>Loading activity…</p>}>
|
|
485
|
+
<Activity />
|
|
486
|
+
</Suspense>
|
|
487
|
+
</>
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export default clientUrls(({ path, layout, loader, revalidate }) => [
|
|
492
|
+
layout(AppLayout, () => [
|
|
493
|
+
path("/", Dashboard, { name: "dashboard" }, () => [loader(StatsLoader)]),
|
|
494
|
+
path(
|
|
495
|
+
"/projects",
|
|
496
|
+
ProjectList,
|
|
497
|
+
{ name: "projects", search: { q: "string?" } },
|
|
498
|
+
() => [loader(ProjectsLoader)],
|
|
499
|
+
),
|
|
500
|
+
path("/projects/:projectId", ProjectPage, { name: "project" }, () => [
|
|
501
|
+
loader(ProjectLoader, () => [revalidate(projectData)]),
|
|
502
|
+
// Below the fold and costly: kept out of link prefetches.
|
|
503
|
+
loader(ActivityLoader, { prefetch: false }, () => [
|
|
504
|
+
revalidate(projectData),
|
|
505
|
+
]),
|
|
506
|
+
]),
|
|
507
|
+
]),
|
|
508
|
+
]);
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
```ts
|
|
512
|
+
// src/urls/app.predicates.ts: no "use client"
|
|
513
|
+
import type { RevalidateParams } from "@rangojs/router";
|
|
514
|
+
import { renameProject } from "../actions/projects";
|
|
515
|
+
|
|
516
|
+
// After an action: re-run only for a rename of THIS project.
|
|
517
|
+
// On a navigation: re-run when the project changes.
|
|
518
|
+
export function projectData(ctx: RevalidateParams): boolean | undefined {
|
|
519
|
+
if (ctx.isAction()) {
|
|
520
|
+
return (
|
|
521
|
+
ctx.isAction(renameProject) &&
|
|
522
|
+
ctx.actionResult?.projectId === ctx.currentParams.projectId
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
return ctx.currentParams.projectId !== ctx.nextParams.projectId
|
|
526
|
+
? ctx.defaultShouldRevalidate
|
|
527
|
+
: false;
|
|
528
|
+
}
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
A group's `revalidate()` runs on the server; the build hoists it out of the
|
|
532
|
+
`"use client"` module, so its argument may reference imports and globals only.
|
|
533
|
+
A predicate that several loaders share lives in its own module without
|
|
534
|
+
`"use client"` (`/client-urls` → "Revalidation runs on the server").
|
|
535
|
+
|
|
536
|
+
`onboarding.client.tsx`, `settings.client.tsx` and `admin.client.tsx` follow
|
|
537
|
+
the same pattern:
|
|
538
|
+
|
|
539
|
+
```tsx
|
|
540
|
+
// src/urls/settings.client.tsx (excerpt)
|
|
541
|
+
function ProfileForm() {
|
|
542
|
+
const { data: viewer } = useLoader(ViewerLoader); // registered on the shell
|
|
543
|
+
const [state, formAction, pending] = useActionState(updateProfile, null);
|
|
544
|
+
return (
|
|
545
|
+
<form action={formAction}>
|
|
546
|
+
<input name="name" defaultValue={viewer.name} />
|
|
547
|
+
{state && !state.ok ? <p role="alert">{state.error}</p> : null}
|
|
548
|
+
<button disabled={pending}>Save</button>
|
|
549
|
+
</form>
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
export default clientUrls(({ path, layout }) => [
|
|
554
|
+
layout(SettingsLayout, () => [
|
|
555
|
+
path("/", ProfilePage, { name: "profile" }), // ProfileForm under <Suspense>
|
|
556
|
+
path("/billing", BillingPage, { name: "billing" }),
|
|
557
|
+
]),
|
|
558
|
+
]);
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
```tsx
|
|
562
|
+
// src/urls/admin.client.tsx (excerpt)
|
|
563
|
+
export default clientUrls(({ path, layout, loader, revalidate }) => [
|
|
564
|
+
layout(AdminLayout, () => [
|
|
565
|
+
path("/", UsersPage, { name: "users", search: { q: "string?" } }, () => [
|
|
566
|
+
loader(UsersLoader, () => [
|
|
567
|
+
revalidate(({ isAction }) =>
|
|
568
|
+
isAction() ? isAction(setRole) : undefined,
|
|
569
|
+
),
|
|
570
|
+
]),
|
|
571
|
+
]),
|
|
572
|
+
]),
|
|
573
|
+
]);
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
## Marketing and the API
|
|
577
|
+
|
|
578
|
+
```tsx
|
|
579
|
+
// src/urls/marketing.tsx
|
|
580
|
+
import { urls } from "@rangojs/router";
|
|
581
|
+
import { HomePage, MarketingLayout, PricingPage } from "../pages/marketing";
|
|
582
|
+
|
|
583
|
+
// Public and the same for every visitor: each page serves from a ppr shell.
|
|
584
|
+
export const marketingPatterns = urls(({ path, layout }) => [
|
|
585
|
+
layout(MarketingLayout, () => [
|
|
586
|
+
path("/", HomePage, { name: "home", ppr: true }),
|
|
587
|
+
path("/pricing", PricingPage, { name: "pricing", ppr: true }),
|
|
588
|
+
]),
|
|
589
|
+
]);
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
A marketing header that shows the signed-in state reads it from a live
|
|
593
|
+
loader under `<Suspense>`, never in a handler: a `cookies()` read the shell
|
|
594
|
+
capture waits for refuses the capture, and the page stays a MISS (`/ppr`).
|
|
595
|
+
|
|
596
|
+
```ts
|
|
597
|
+
// src/urls/api.tsx
|
|
598
|
+
import { urls } from "@rangojs/router";
|
|
599
|
+
import { db } from "../lib/db";
|
|
600
|
+
import { CurrentUser } from "../middleware/session";
|
|
601
|
+
|
|
602
|
+
export const apiPatterns = urls(({ path }) => [
|
|
603
|
+
path.json("/health", () => ({ ok: true }), { name: "health" }),
|
|
604
|
+
path.json(
|
|
605
|
+
"/projects",
|
|
606
|
+
async (ctx) => {
|
|
607
|
+
const user = ctx.get(CurrentUser); // set by the global loadSession
|
|
608
|
+
if (!user)
|
|
609
|
+
throw Response.json({ error: "unauthorized" }, { status: 401 });
|
|
610
|
+
return db.projects.list(user.id);
|
|
611
|
+
},
|
|
612
|
+
{ name: "projects" },
|
|
613
|
+
),
|
|
614
|
+
]);
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
Response routes skip the RSC pipeline and the router's `originCheck`. A
|
|
618
|
+
cookie-authenticated route that changes state needs the same-origin guard
|
|
619
|
+
(`requireSameOrigin` in `/response-routes` → "CSRF"), mounted here with
|
|
620
|
+
`router.use("/api/*", …)`.
|
|
621
|
+
|
|
622
|
+
## Loaders, caches and actions
|
|
623
|
+
|
|
624
|
+
```ts
|
|
625
|
+
// src/loaders/projects.ts
|
|
626
|
+
import { cacheTag, createLoader, notFound } from "@rangojs/router";
|
|
627
|
+
import { db } from "../lib/db";
|
|
628
|
+
import { CurrentUser } from "../middleware/session";
|
|
629
|
+
import { requireUserForFetch } from "../middleware/guards";
|
|
630
|
+
|
|
631
|
+
// Shared by everyone who opens the project: cached and tagged.
|
|
632
|
+
async function getProject(id: string) {
|
|
633
|
+
"use cache";
|
|
634
|
+
cacheTag(`project:${id}`);
|
|
635
|
+
return db.projects.get(id);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
export const ProjectLoader = createLoader(
|
|
639
|
+
async (ctx) => {
|
|
640
|
+
const project = await getProject(ctx.params.projectId!);
|
|
641
|
+
if (!project) notFound("No such project"); // AppNotFound renders
|
|
642
|
+
return project;
|
|
643
|
+
},
|
|
644
|
+
{ middleware: [requireUserForFetch] }, // fetchable, guarded
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
// Per user: a live loader, never cached.
|
|
648
|
+
export const ProjectsLoader = createLoader(async (ctx) =>
|
|
649
|
+
db.projects.list(
|
|
650
|
+
ctx.get(CurrentUser)!.id,
|
|
651
|
+
ctx.searchParams.get("q") ?? undefined,
|
|
652
|
+
),
|
|
653
|
+
);
|
|
654
|
+
|
|
655
|
+
// Costly: registered with { prefetch: false } in the app group.
|
|
656
|
+
export const ActivityLoader = createLoader(async (ctx) =>
|
|
657
|
+
db.activity.list(ctx.params.projectId!),
|
|
658
|
+
);
|
|
659
|
+
|
|
660
|
+
export const StatsLoader = createLoader(async (ctx) =>
|
|
661
|
+
db.stats.forUser(ctx.get(CurrentUser)!.id),
|
|
662
|
+
);
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
```ts
|
|
666
|
+
// src/actions/projects.ts
|
|
667
|
+
"use server";
|
|
668
|
+
|
|
669
|
+
import { getRequestContext, redirect, updateTag } from "@rangojs/router";
|
|
670
|
+
import { db } from "../lib/db";
|
|
671
|
+
import { CurrentUser } from "../middleware/session";
|
|
672
|
+
|
|
673
|
+
export async function renameProject(_prev: unknown, formData: FormData) {
|
|
674
|
+
// Route middleware does not wrap actions: check here.
|
|
675
|
+
const ctx = getRequestContext();
|
|
676
|
+
if (!ctx.get(CurrentUser)) throw redirect(ctx.reverse("auth.login"));
|
|
677
|
+
const projectId = String(formData.get("projectId"));
|
|
678
|
+
await db.projects.rename(projectId, String(formData.get("name")));
|
|
679
|
+
await updateTag(`project:${projectId}`); // every user's cached copy
|
|
680
|
+
return { ok: true as const, projectId };
|
|
681
|
+
}
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
The `admin` actions check the role the same way: `requireAdmin` guards the
|
|
685
|
+
admin pages, not the actions they call, and any URL that matches a route can
|
|
686
|
+
carry an action (`/server-actions` → "Authorization in actions").
|
|
687
|
+
|
|
688
|
+
## Prefetch and `prefetch: false`
|
|
689
|
+
|
|
690
|
+
A `<Link>` prefetch is the navigation sent early: the server runs the
|
|
691
|
+
destination's loaders, and a click that adopts it commits the complete page.
|
|
692
|
+
`loader(Def, { prefetch: false })` keeps one loader out of every prefetch; a
|
|
693
|
+
click then shows the page with that loader's fallback and sends one
|
|
694
|
+
follow-up request (the fill) for it.
|
|
695
|
+
|
|
696
|
+
The flag moves that loader's wait to after the click. In one measurement on a real app (development build, a
|
|
697
|
+
loaded machine, a hover then a click 150 ms later), a `clientUrls()` tab
|
|
698
|
+
whose loader was flagged showed its content 843 to 918 ms after the click,
|
|
699
|
+
and the same tab prefetched whole showed it in 77 to 157 ms. The gap is the
|
|
700
|
+
flagged loader's own latency, the fill's round trip, and React holding a
|
|
701
|
+
revealed fallback for at least 300 ms.
|
|
702
|
+
|
|
703
|
+
Rules of thumb:
|
|
704
|
+
|
|
705
|
+
- Flag a loader that is expensive or metered, and whose prefetches mostly
|
|
706
|
+
never become clicks: activity feeds, analytics panels, reviews below the
|
|
707
|
+
fold, a list behind a tab.
|
|
708
|
+
- Do not flag what the likely click needs first: the page's main content.
|
|
709
|
+
- Read a flagged loader inside its own `<Suspense>`; with no boundary, React
|
|
710
|
+
keeps the page being left on screen until the fill answers.
|
|
711
|
+
- A same-route click (`/app/projects/1` to `/app/projects/2`) prefetches a
|
|
712
|
+
flagged loader as usual: only data the page does not have yet is deferred.
|
|
713
|
+
|
|
714
|
+
Details: `/loader` → "`prefetch: false`", `/client-urls` → "`prefetch: false`".
|
|
715
|
+
|
|
716
|
+
## Keeping data current
|
|
717
|
+
|
|
718
|
+
| Change | Mechanism |
|
|
719
|
+
| -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
720
|
+
| This user's own mutation | A server action plus `revalidate()` rules on the loaders it touches (`isAction(ref)`, `actionResult`): the server re-runs exactly those, in the action's response |
|
|
721
|
+
| The same data cached for many users | `"use cache"` with `cacheTag()`, dropped by `updateTag()` in the action (awaited: the action's own response reads fresh) |
|
|
722
|
+
| A change this tab did not make (a push, polling, another tab, a REST call) | `refetch()` / `load()` from `useLoader()`, `useRefreshLoaders()(group)` for several loaders, plus `invalidateClientCache()` for prefetches and history |
|
|
723
|
+
| "Re-render everything on this page" | `useRouter().refresh()`: refetches the current page with no held segments, so every segment and loader renders |
|
|
724
|
+
| An action that changed nothing visible (an autosave) | `keepClientCache()` in the action: the browser keeps its prefetches and history cache |
|
|
725
|
+
|
|
726
|
+
```tsx
|
|
727
|
+
// src/shell/live-updates.tsx
|
|
728
|
+
"use client";
|
|
729
|
+
|
|
730
|
+
import { useEffect } from "react";
|
|
731
|
+
import { invalidateClientCache } from "@rangojs/router";
|
|
732
|
+
import { useLoader } from "@rangojs/router/client";
|
|
733
|
+
import { ProjectLoader } from "../loaders/projects";
|
|
734
|
+
|
|
735
|
+
// Another user renamed the project: their action already dropped the server
|
|
736
|
+
// cache (updateTag). This tab refetches what it shows and drops its
|
|
737
|
+
// prefetches and history cache.
|
|
738
|
+
export function ProjectLiveUpdates({ projectId }: { projectId: string }) {
|
|
739
|
+
const { refetch } = useLoader(ProjectLoader);
|
|
740
|
+
useEffect(() => {
|
|
741
|
+
// An SSE route (path.stream, see the streams-and-websockets skill).
|
|
742
|
+
const events = new EventSource(`/api/projects/${projectId}/events`);
|
|
743
|
+
events.onmessage = () => {
|
|
744
|
+
invalidateClientCache();
|
|
745
|
+
void refetch();
|
|
746
|
+
};
|
|
747
|
+
return () => events.close();
|
|
748
|
+
}, [projectId, refetch]);
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
`load()`, `refetch` and `useRefreshLoaders()` call the loader through the
|
|
754
|
+
`_rsc_loader` endpoint, which serves only fetchable loaders:
|
|
755
|
+
`createLoader(fn, true)`, or `createLoader(fn, { middleware })`. A plain
|
|
756
|
+
`createLoader(fn)` answers 403 there. That request runs `router.use()`
|
|
757
|
+
middleware and the loader's own `middleware` list, and **no route
|
|
758
|
+
middleware** (`src/rsc/handler.ts`, `src/rsc/loader-fetch.ts`): the
|
|
759
|
+
`requireUser` around the include does not protect a refetch. The session
|
|
760
|
+
comes from the global `loadSession`, and each fetchable loader in a
|
|
761
|
+
protected group lists its guard (`requireUserForFetch` above). For several
|
|
762
|
+
loaders at once, tag the reads with `useLoader(Def, { refreshGroup: "account" })`
|
|
763
|
+
and call `useRefreshLoaders()("account")` (plain GET, no params).
|
|
764
|
+
|
|
765
|
+
## Names, links and generated files
|
|
766
|
+
|
|
767
|
+
The include names make the route names: `auth.login`, `app.dashboard`,
|
|
768
|
+
`app.project`, `settings.profile`, `admin.users`, `api.projects`,
|
|
769
|
+
`marketing.home`.
|
|
770
|
+
|
|
771
|
+
- Server code links with `ctx.reverse("app.project", { projectId })` (handlers,
|
|
772
|
+
layouts, loaders; middleware with global names only).
|
|
773
|
+
- A group's components link inside the group with `useReverse(routes)` and
|
|
774
|
+
the group's generated map (`./app.client.gen`): mount-aware, params filled
|
|
775
|
+
from the URL.
|
|
776
|
+
- A server action redirects by name too: `getRequestContext().reverse("auth.login")`
|
|
777
|
+
(global names; see `renameProject`). Client components that need a URL from
|
|
778
|
+
another module take it as a prop from the server layout, as `UserMenu` does.
|
|
779
|
+
- Static paths with no route behind them: `href("/healthz")`.
|
|
780
|
+
|
|
781
|
+
Generated and committed: `src/router.named-routes.gen.ts`, and from
|
|
782
|
+
`rango generate src` one `*.gen.ts` per module (`urls/app.client.gen.ts`,
|
|
783
|
+
`urls/auth.client.gen.ts`, …, `urls/api.gen.ts`). Wire `rango generate src`
|
|
784
|
+
into a `predev` script so a new route never waits for a manual run.
|
|
785
|
+
|
|
786
|
+
### URL-scoped tenants
|
|
787
|
+
|
|
788
|
+
If the account or project is in the URL, nest the includes and let every
|
|
789
|
+
module link by local name; none of them reads `:accountId` itself.
|
|
790
|
+
|
|
791
|
+
```tsx
|
|
792
|
+
// router.tsx urls: include("/:accountId", accountUrls, { name: "account" })
|
|
793
|
+
// urls/account.tsx: include("/:projectId", projectUrls, { name: "project" })
|
|
794
|
+
|
|
795
|
+
// urls/project.tsx, on /acme/web/issues
|
|
796
|
+
ctx.reverse(".issue", { issueId: "7" }); // /acme/web/issues/7
|
|
797
|
+
ctx.reverse("account.settings"); // /acme/settings, cross-module: global name
|
|
798
|
+
// urls/project.client.tsx
|
|
799
|
+
const reverse = useReverse(routes); // ./project.client.gen
|
|
800
|
+
reverse(".issues", { projectId: "api" }); // /acme/api/issues
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
- A link into another module (the project nav's "Account settings") is built
|
|
804
|
+
on the server and passed as a prop or loader data. `useReverse(accountRoutes)`
|
|
805
|
+
in a project component gives a wrong URL with no error (dc-t57).
|
|
806
|
+
- A server layout stays held when `:projectId` changes (partial rendering by
|
|
807
|
+
design), so links it rendered keep the old project. Render the project
|
|
808
|
+
switcher and nav in a client component with `useReverse(routes)`, or give
|
|
809
|
+
the layout a `revalidate()` comparing `projectId`.
|
|
810
|
+
- The account layout's `ctx.reverse(".settings")` resolves in the account
|
|
811
|
+
module on every page below it, the project's pages included: it links to
|
|
812
|
+
`/acme/settings`, not the project's settings route.
|
|
813
|
+
|
|
814
|
+
Links built on the server, from the RSC as a prop or from a loader as data,
|
|
815
|
+
carry every param already filled; the client component only renders them:
|
|
816
|
+
|
|
817
|
+
```tsx
|
|
818
|
+
// urls/project.tsx (server): build the links where the names resolve
|
|
819
|
+
export const ProjectNavLoader = createLoader((ctx) => ({
|
|
820
|
+
issues: ctx.reverse(".issues"), // /acme/web/issues
|
|
821
|
+
settings: ctx.reverse(".settings"), // /acme/web/settings
|
|
822
|
+
account: ctx.reverse("account.settings"), // /acme/settings, another module
|
|
823
|
+
}));
|
|
824
|
+
|
|
825
|
+
export const projectUrls = urls(({ layout, path, loader, revalidate }) => [
|
|
826
|
+
layout(
|
|
827
|
+
// From the RSC: fine for links that do not follow :projectId.
|
|
828
|
+
(ctx) => <ProjectShell accountHref={ctx.reverse("account.settings")} />,
|
|
829
|
+
() => [
|
|
830
|
+
// From a loader: held like the layout, re-run when the project changes.
|
|
831
|
+
loader(ProjectNavLoader, () => [
|
|
832
|
+
revalidate(({ currentParams, nextParams }) =>
|
|
833
|
+
currentParams.projectId !== nextParams.projectId ? true : undefined,
|
|
834
|
+
),
|
|
835
|
+
]),
|
|
836
|
+
path("/issues", Issues, { name: "issues" }),
|
|
837
|
+
path("/settings", Settings, { name: "settings" }),
|
|
838
|
+
],
|
|
839
|
+
),
|
|
840
|
+
]);
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
```tsx
|
|
844
|
+
"use client";
|
|
845
|
+
export function ProjectNav() {
|
|
846
|
+
const { data: links } = useLoader(ProjectNavLoader);
|
|
847
|
+
return (
|
|
848
|
+
<nav>
|
|
849
|
+
<Link to={links.issues}>Issues</Link>
|
|
850
|
+
<Link to={links.settings}>Settings</Link>
|
|
851
|
+
<Link to={links.account}>Account</Link>
|
|
852
|
+
</nav>
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
```
|
|
856
|
+
|
|
857
|
+
## Why each piece sits where it does
|
|
858
|
+
|
|
859
|
+
| Piece | Where | Why |
|
|
860
|
+
| ------------------------------------------ | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
861
|
+
| Session lookup | `router.use(loadSession)` | The only layer that wraps actions and fetchable-loader calls too; one lookup, read everywhere with `ctx.get(CurrentUser)` |
|
|
862
|
+
| Sign-in, onboarding, role gates | `middleware()` around the includes | Runs on every document load and navigation into those routes, so every click is re-authorized; the group itself cannot hold middleware |
|
|
863
|
+
| Authorization of a mutation | inside the action, and the fetchable loader's `middleware` | Route middleware does not wrap either |
|
|
864
|
+
| Top nav, user menu | the server `AppShell` layout | Spans every group; rendered once, kept across navigations within and between groups |
|
|
865
|
+
| The user's data | `loader(ViewerLoader)` on the shell, `useLoader()` in the menu | Live per request; held on navigations; re-run only after the actions its `revalidate()` names |
|
|
866
|
+
| Area chrome (tabs, pending state) | the group's `layout()` | Matches in the browser: instant at the click, `useOutlet().pending`, instance kept across the group's routes |
|
|
867
|
+
| Error and not-found UI for signed-in pages | `errorBoundary()` / `notFoundBoundary()` on the shell | Groups reject DSL boundaries; one on the server layout around the mounts catches a group loader's throw or `notFound()`, and the shell's chrome stays |
|
|
868
|
+
| Marketing pages | server `urls()` with `ppr` | Same HTML for every visitor; the first byte comes from the stored shell |
|
|
869
|
+
| JSON API | `path.json()` in `urls/api.tsx` | Outside the RSC pipeline; guard state-changing routes against CSRF |
|
|
870
|
+
| A cached read shared by users | `"use cache"` + `cacheTag()` inside the loader | Loaders stay live; the expensive call is what caches, and `updateTag()` reaches every user |
|
|
871
|
+
|
|
872
|
+
## Mistakes this shape avoids
|
|
873
|
+
|
|
874
|
+
- **Trusting the include's guard for actions and refetches.** It wraps renders
|
|
875
|
+
only. Authorize in the action body and in each fetchable loader's
|
|
876
|
+
`middleware`.
|
|
877
|
+
- **Putting `auth` (or `onboarding`) inside the gate that redirects to it**:
|
|
878
|
+
a redirect loop.
|
|
879
|
+
- **Reading the session in a `ppr` handler** (a marketing header with the
|
|
880
|
+
user's name): the capture is refused and the page never serves a shell.
|
|
881
|
+
- **`prefetch: false` on every loader**: each click then waits for a fill.
|
|
882
|
+
Flag the expensive, rarely-clicked ones.
|
|
883
|
+
- **A bare `isAction(refs)` predicate**: it is a hard `false` on navigation
|
|
884
|
+
too, so the loader stops following param changes. Use
|
|
885
|
+
`isAction() ? isAction(refs) : undefined` (`/loader`).
|