@rangojs/router 0.0.0-experimental.13 → 0.0.0-experimental.15

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 CHANGED
@@ -1,8 +1,22 @@
1
1
  # @rangojs/router
2
2
 
3
- > **Warning:** This package is experimental and under active development. APIs may change without notice.
3
+ Django-inspired RSC router with type-safe partial rendering for Vite.
4
4
 
5
- Type-safe RSC router with partial rendering support for Vite.
5
+ > **Experimental:** This package is under active development. APIs may change between releases. Install with `@experimental` tag.
6
+
7
+ ## Features
8
+
9
+ - **Composable URL patterns** — Django-style `urls()` DSL with `path`, `layout`, `include`
10
+ - **Named routes** — `reverse("blogPost", { slug })` for type-safe URL generation (Django-style)
11
+ - **Data loaders** — `createLoader()` with automatic streaming and Suspense integration
12
+ - **Layouts & nesting** — Nested layouts with `<Outlet />` and parallel routes
13
+ - **Segment-level caching** — `cache()` DSL with TTL/SWR and pluggable cache stores
14
+ - **Middleware** — Route-level middleware with cookie and header access
15
+ - **Pre-rendering** — `Prerender()` and `Static()` handlers for build-time rendering
16
+ - **Theme support** — Light/dark mode with FOUC prevention and system detection
17
+ - **Host routing** — Multi-app routing by domain/subdomain via `@rangojs/router/host`
18
+ - **Response routes** — `path.json()`, `path.text()`, `path.xml()` for API endpoints
19
+ - **CLI codegen** — `rango generate` for route type generation
6
20
 
7
21
  ## Installation
8
22
 
@@ -10,9 +24,580 @@ Type-safe RSC router with partial rendering support for Vite.
10
24
  npm install @rangojs/router@experimental
11
25
  ```
12
26
 
13
- ## Status
27
+ Peer dependencies:
28
+
29
+ ```bash
30
+ npm install react @vitejs/plugin-rsc
31
+ ```
32
+
33
+ For Cloudflare Workers:
34
+
35
+ ```bash
36
+ npm install @cloudflare/vite-plugin
37
+ ```
38
+
39
+ ## Quick Start
40
+
41
+ ### Vite Config
42
+
43
+ ```ts
44
+ // vite.config.ts
45
+ import react from "@vitejs/plugin-react";
46
+ import { defineConfig } from "vite";
47
+ import { rango } from "@rangojs/router/vite";
48
+
49
+ export default defineConfig({
50
+ plugins: [
51
+ react(),
52
+ rango({ preset: "cloudflare" }),
53
+ ],
54
+ });
55
+ ```
56
+
57
+ ### Router
58
+
59
+ ```tsx
60
+ // src/router.tsx
61
+ import { createRouter, urls } from "@rangojs/router";
62
+ import { Document } from "./document";
63
+
64
+ const urlpatterns = urls(({ path, layout }) => [
65
+ layout(<MainLayout />, () => [
66
+ path("/", HomePage, { name: "home" }),
67
+ path("/about", AboutPage, { name: "about" }),
68
+ path("/blog/:slug", BlogPostPage, { name: "blogPost" }),
69
+ ]),
70
+ ]);
71
+
72
+ export const router = createRouter({ document: Document })
73
+ .routes(urlpatterns);
74
+
75
+ // Export typed reverse function for URL generation by route name
76
+ export const reverse = router.reverse;
77
+ ```
78
+
79
+ ### Document
80
+
81
+ ```tsx
82
+ // src/document.tsx
83
+ "use client";
84
+
85
+ import type { ReactNode } from "react";
86
+ import { MetaTags } from "@rangojs/router/client";
87
+
88
+ export function Document({ children }: { children: ReactNode }) {
89
+ return (
90
+ <html lang="en">
91
+ <head>
92
+ <MetaTags />
93
+ </head>
94
+ <body>{children}</body>
95
+ </html>
96
+ );
97
+ }
98
+ ```
99
+
100
+ ## Defining Routes
101
+
102
+ ### Path Patterns
103
+
104
+ ```tsx
105
+ import { urls } from "@rangojs/router";
106
+
107
+ const urlpatterns = urls(({ path }) => [
108
+ path("/", HomePage, { name: "home" }),
109
+ path("/product/:slug", ProductPage, { name: "product" }),
110
+ path("/search/:query?", SearchPage, { name: "search" }),
111
+ path("/files/*", FilesPage, { name: "files" }),
112
+ ]);
113
+ ```
114
+
115
+ ### Typed Handlers
116
+
117
+ Route handlers receive a typed context with params, search params, and `reverse()`:
118
+
119
+ ```tsx
120
+ import type { Handler } from "@rangojs/router";
121
+
122
+ export const ProductPage: Handler<"product"> = (ctx) => {
123
+ const { slug } = ctx.params; // typed from pattern
124
+ const homeUrl = ctx.reverse("home"); // type-safe URL by route name
125
+ return <h1>Product: {slug}</h1>;
126
+ };
127
+ ```
128
+
129
+ ### Search Params
130
+
131
+ Define a search schema on the route for type-safe search parameters:
132
+
133
+ ```tsx
134
+ const urlpatterns = urls(({ path }) => [
135
+ path("/search", SearchPage, {
136
+ name: "search",
137
+ search: { q: "string", page: "number?", sort: "string?" },
138
+ }),
139
+ ]);
140
+
141
+ // Handler receives typed searchParams
142
+ const SearchPage: Handler<"search"> = (ctx) => {
143
+ const { q, page, sort } = ctx.searchParams;
144
+ // q: string, page: number | undefined, sort: string | undefined
145
+ };
146
+ ```
147
+
148
+ ### Response Routes
149
+
150
+ Define API endpoints that bypass the RSC pipeline:
151
+
152
+ ```tsx
153
+ const urlpatterns = urls(({ path }) => [
154
+ path.json("/api/health", () => ({ status: "ok" }), { name: "health" }),
155
+ path.text("/robots.txt", () => "User-agent: *\nAllow: /", { name: "robots" }),
156
+ path.xml("/feed.xml", () => "<rss>...</rss>", { name: "feed" }),
157
+ ]);
158
+ ```
159
+
160
+ Response types available: `path.json()`, `path.text()`, `path.html()`, `path.xml()`, `path.image()`, `path.stream()`, `path.any()`.
161
+
162
+ ## Layouts & Nesting
163
+
164
+ ### Layouts with Outlet
165
+
166
+ ```tsx
167
+ import { urls } from "@rangojs/router";
168
+
169
+ const urlpatterns = urls(({ path, layout }) => [
170
+ layout(<MainLayout />, () => [
171
+ path("/", HomePage, { name: "home" }),
172
+ path("/about", AboutPage, { name: "about" }),
173
+ ]),
174
+ ]);
175
+ ```
176
+
177
+ ```tsx
178
+ "use client";
179
+ import { Outlet } from "@rangojs/router/client";
180
+
181
+ function MainLayout() {
182
+ return (
183
+ <div>
184
+ <nav>...</nav>
185
+ <Outlet />
186
+ </div>
187
+ );
188
+ }
189
+ ```
190
+
191
+ ### Loading Skeletons
192
+
193
+ ```tsx
194
+ const urlpatterns = urls(({ path, loading }) => [
195
+ path("/product/:slug", ProductPage, { name: "product" }, () => [
196
+ loading(<ProductSkeleton />),
197
+ ]),
198
+ ]);
199
+ ```
200
+
201
+ ### Parallel Routes
202
+
203
+ ```tsx
204
+ const urlpatterns = urls(({ path, layout, parallel, loader, loading }) => [
205
+ layout(BlogLayout, () => [
206
+ parallel({ "@sidebar": BlogSidebarHandler }, () => [
207
+ loader(BlogSidebarLoader),
208
+ loading(<SidebarSkeleton />),
209
+ ]),
210
+ path("/blog", BlogIndexPage, { name: "blog" }),
211
+ path("/blog/:slug", BlogPostPage, { name: "blogPost" }),
212
+ ]),
213
+ ]);
214
+ ```
215
+
216
+ ## Data Loaders
217
+
218
+ ### Creating a Loader
219
+
220
+ ```tsx
221
+ import { createLoader } from "@rangojs/router";
222
+
223
+ export const BlogSidebarLoader = createLoader(async (ctx) => {
224
+ const posts = await db.getRecentPosts();
225
+ return { posts, loadedAt: new Date().toISOString() };
226
+ });
227
+ ```
228
+
229
+ ### Using in Server Components (Handlers)
230
+
231
+ ```tsx
232
+ import type { HandlerContext } from "@rangojs/router";
233
+ import { BlogSidebarLoader } from "./loaders/blog";
234
+
235
+ async function BlogSidebarHandler(ctx: HandlerContext) {
236
+ const { posts } = await ctx.use(BlogSidebarLoader);
237
+ return <ul>{posts.map(p => <li key={p.slug}>{p.title}</li>)}</ul>;
238
+ }
239
+ ```
240
+
241
+ ### Using in Client Components
242
+
243
+ ```tsx
244
+ "use client";
245
+ import { useLoader } from "@rangojs/router/client";
246
+ import { BlogSidebarLoader } from "./loaders/blog";
247
+
248
+ function BlogSidebar() {
249
+ const { posts } = useLoader(BlogSidebarLoader);
250
+ return <ul>{posts.map(p => <li key={p.slug}>{p.title}</li>)}</ul>;
251
+ }
252
+ ```
253
+
254
+ ### Attaching Loaders to Routes
255
+
256
+ ```tsx
257
+ const urlpatterns = urls(({ path, loader }) => [
258
+ path("/blog", BlogIndexPage, { name: "blog" }, () => [
259
+ loader(BlogSidebarLoader),
260
+ ]),
261
+ ]);
262
+ ```
263
+
264
+ ## Navigation & Links
265
+
266
+ ### Named Routes with `reverse()` (Server Components)
267
+
268
+ In server components, use `reverse()` to generate URLs by route name:
269
+
270
+ ```tsx
271
+ import { Link } from "@rangojs/router/client";
272
+ import { reverse } from "./router";
273
+
274
+ function BlogIndex() {
275
+ return (
276
+ <nav>
277
+ <Link to={reverse("home")}>Home</Link>
278
+ <Link to={reverse("blogPost", { slug: "my-post" })}>My Post</Link>
279
+ <Link to={reverse("about")}>About</Link>
280
+ </nav>
281
+ );
282
+ }
283
+ ```
284
+
285
+ `reverse()` is type-safe — route names and required params are checked at compile time. Included routes use dotted names: `reverse("api.health")`.
286
+
287
+ Handlers also have `ctx.reverse()` directly on the context:
288
+
289
+ ```tsx
290
+ const BlogPostPage: Handler<"blogPost"> = (ctx) => {
291
+ const backUrl = ctx.reverse("blog");
292
+ return <Link to={backUrl}>Back to blog</Link>;
293
+ };
294
+ ```
295
+
296
+ ### `href()` for Path Validation (Client Components)
297
+
298
+ In client components, use `href()` for compile-time path validation:
299
+
300
+ ```tsx
301
+ "use client";
302
+ import { Link, href } from "@rangojs/router/client";
303
+
304
+ function Nav() {
305
+ return (
306
+ <nav>
307
+ <Link to={href("/")}>Home</Link>
308
+ <Link to={href("/blog")} prefetch="intent">Blog</Link>
309
+ <Link to={href("/about")}>About</Link>
310
+ </nav>
311
+ );
312
+ }
313
+ ```
314
+
315
+ `href()` validates that the path matches a registered route pattern at compile time (e.g. `/blog/my-post` matches `/blog/:slug`).
316
+
317
+ ### Navigation Hook
318
+
319
+ ```tsx
320
+ "use client";
321
+ import { useNavigation } from "@rangojs/router/client";
322
+
323
+ function SearchForm() {
324
+ const { navigate, isPending } = useNavigation();
325
+
326
+ function handleSubmit(query: string) {
327
+ navigate(`/search?q=${encodeURIComponent(query)}`);
328
+ }
329
+
330
+ return <form onSubmit={...}>{isPending && <Spinner />}</form>;
331
+ }
332
+ ```
333
+
334
+ ### Scroll Restoration
335
+
336
+ ```tsx
337
+ "use client";
338
+ import { ScrollRestoration } from "@rangojs/router/client";
339
+
340
+ function Document({ children }) {
341
+ return (
342
+ <html>
343
+ <body>
344
+ {children}
345
+ <ScrollRestoration />
346
+ </body>
347
+ </html>
348
+ );
349
+ }
350
+ ```
351
+
352
+ ## Includes (Composable Modules)
353
+
354
+ Split URL patterns into composable modules with `include()`:
355
+
356
+ ```tsx
357
+ // src/api/urls.tsx
358
+ import { urls } from "@rangojs/router";
359
+
360
+ export const apiPatterns = urls(({ path }) => [
361
+ path.json("/health", () => ({ status: "ok" }), { name: "health" }),
362
+ path.json("/products", getProducts, { name: "products" }),
363
+ ]);
364
+
365
+ // src/urls.tsx
366
+ import { urls } from "@rangojs/router";
367
+ import { apiPatterns } from "./api/urls";
368
+
369
+ export const urlpatterns = urls(({ path, include }) => [
370
+ path("/", HomePage, { name: "home" }),
371
+ include("/api", apiPatterns, { name: "api" }),
372
+ // Mounts apiPatterns under /api: /api/health, /api/products
373
+ ]);
374
+ ```
375
+
376
+ Included route names are prefixed with the include name: `reverse("api.health")`, `reverse("api.products")`.
377
+
378
+ ## Middleware
379
+
380
+ ```tsx
381
+ const urlpatterns = urls(({ path, middleware }) => [
382
+ middleware(async (ctx, next) => {
383
+ const start = Date.now();
384
+ const response = await next();
385
+ console.log(`${ctx.request.method} ${ctx.url.pathname} ${Date.now() - start}ms`);
386
+ return response;
387
+ }, () => [
388
+ path("/dashboard", DashboardPage, { name: "dashboard" }),
389
+ ]),
390
+ ]);
391
+ ```
392
+
393
+ ## Caching
394
+
395
+ ### Route-Level Caching
396
+
397
+ ```tsx
398
+ const urlpatterns = urls(({ path, cache }) => [
399
+ cache({ ttl: 60, swr: 300 }, () => [
400
+ path("/blog", BlogIndexPage, { name: "blog" }),
401
+ path("/blog/:slug", BlogPostPage, { name: "blogPost" }),
402
+ ]),
403
+ ]);
404
+ ```
405
+
406
+ ### Cache Store Configuration
407
+
408
+ ```tsx
409
+ import { createRouter } from "@rangojs/router";
410
+ import { CFCacheStore, createDocumentCacheMiddleware } from "@rangojs/router/cache";
411
+
412
+ export const router = createRouter({
413
+ document: Document,
414
+ cache: (env) => ({
415
+ store: new CFCacheStore({
416
+ defaults: { ttl: 60, swr: 300 },
417
+ ctx: env.ctx,
418
+ }),
419
+ }),
420
+ })
421
+ .use(createDocumentCacheMiddleware())
422
+ .routes(urlpatterns);
423
+ ```
424
+
425
+ Available cache stores:
426
+ - `CFCacheStore` — Cloudflare edge cache (production)
427
+ - `MemorySegmentCacheStore` — In-memory cache (development/testing)
428
+
429
+ ## Pre-rendering
430
+
431
+ Pre-rendering generates route segments at build time. The worker handles all requests — there are no static files served from assets.
432
+
433
+ ### Static Segments
434
+
435
+ Use `Static()` for segments rendered once at build time (no params). Works on `path()`, `layout()`, and `parallel()`:
436
+
437
+ ```tsx
438
+ import { Static } from "@rangojs/router";
439
+
440
+ export const AboutPage = Static(async () => {
441
+ return <article>...</article>;
442
+ });
443
+
444
+ export const DocsNav = Static(async () => {
445
+ const items = await readDocsNavItems();
446
+ return <nav>{items.map(i => <a key={i.slug} href={i.slug}>{i.title}</a>)}</nav>;
447
+ });
448
+ ```
449
+
450
+ ### Dynamic Routes with Prerender
451
+
452
+ Use `Prerender()` for route-scoped pre-rendering. With params, provide `getParams` first, handler second:
453
+
454
+ ```tsx
455
+ import { Prerender } from "@rangojs/router";
456
+
457
+ export const BlogPost = Prerender(
458
+ async () => {
459
+ const slugs = await getAllBlogSlugs();
460
+ return slugs.map(slug => ({ slug }));
461
+ },
462
+ async (ctx) => {
463
+ const post = await getPost(ctx.params.slug);
464
+ return <article>{post.content}</article>;
465
+ },
466
+ );
467
+ ```
468
+
469
+ ### Passthrough for Unknown Params
470
+
471
+ ```tsx
472
+ import { Prerender } from "@rangojs/router";
473
+
474
+ export const ProductPage = Prerender(
475
+ async () => {
476
+ const featured = await db.getFeaturedProducts();
477
+ return featured.map(p => ({ id: p.id }));
478
+ },
479
+ async (ctx) => {
480
+ const product = await db.getProduct(ctx.params.id);
481
+ return <Product data={product} />;
482
+ },
483
+ { passthrough: true },
484
+ );
485
+ ```
486
+
487
+ With `passthrough: true`, known params are served from the build-time cache and unknown params fall through to live rendering.
488
+
489
+ ## Theme
490
+
491
+ ### Router Configuration
492
+
493
+ ```tsx
494
+ export const router = createRouter({
495
+ document: Document,
496
+ theme: {
497
+ defaultTheme: "light",
498
+ themes: ["light", "dark", "system"],
499
+ attribute: "class",
500
+ enableSystem: true,
501
+ },
502
+ }).routes(urlpatterns);
503
+ ```
504
+
505
+ ### Theme Toggle
506
+
507
+ ```tsx
508
+ "use client";
509
+ import { useTheme } from "@rangojs/router/theme";
510
+
511
+ function ThemeToggle() {
512
+ const { theme, setTheme, themes } = useTheme();
513
+ return (
514
+ <select value={theme} onChange={e => setTheme(e.target.value)}>
515
+ {themes.map(t => <option key={t}>{t}</option>)}
516
+ </select>
517
+ );
518
+ }
519
+ ```
520
+
521
+ ## Host Routing
522
+
523
+ Route requests to different apps based on domain/subdomain patterns using `@rangojs/router/host`:
524
+
525
+ ```tsx
526
+ // worker.rsc.tsx
527
+ import { createHostRouter } from "@rangojs/router/host";
528
+
529
+ const hostRouter = createHostRouter();
530
+
531
+ hostRouter.host(["*.localhost"]).map(() => import("./apps/admin/handler.js"));
532
+ hostRouter.host(["localhost"]).map(() => import("./apps/site/handler.js"));
533
+ hostRouter.fallback().map(() => import("./apps/site/handler.js"));
534
+
535
+ export default {
536
+ async fetch(request, env, ctx) {
537
+ return hostRouter.match(request, { Bindings: env, Variables: {}, ctx });
538
+ },
539
+ };
540
+ ```
541
+
542
+ Each sub-app has its own `createRouter()` and `urls()`. The host router lazily imports the matched app's handler. Patterns are matched in registration order — register more specific patterns (subdomains) before catch-alls.
543
+
544
+ ## Meta Tags
545
+
546
+ Accumulate meta tags across route segments using the built-in `Meta` handle:
547
+
548
+ ```tsx
549
+ import { Meta } from "@rangojs/router";
550
+ import type { HandlerContext } from "@rangojs/router";
551
+
552
+ export function BlogPostPage(ctx: HandlerContext) {
553
+ const meta = ctx.use(Meta);
554
+ meta({ title: "My Blog Post" });
555
+ meta({ name: "description", content: "A great blog post" });
556
+ meta({ property: "og:title", content: "My Blog Post" });
557
+
558
+ return <article>...</article>;
559
+ }
560
+ ```
561
+
562
+ Render collected tags in the document with `<MetaTags />` from `@rangojs/router/client`.
563
+
564
+ ## CLI: `rango generate`
565
+
566
+ Route types are generated automatically by the Vite plugin. The CLI is a manual fallback for generating types outside the dev server (e.g. in CI or for IDE support before first `pnpm dev`):
567
+
568
+ ```bash
569
+ npx rango generate src/router.tsx
570
+ npx rango generate src/ # recursive scan
571
+ npx rango generate src/urls.tsx src/api/ # mix files and directories
572
+ ```
573
+
574
+ Auto-detects file type:
575
+ - Files with `createRouter` → `*.named-routes.gen.ts` with global route map
576
+ - Files with `urls()` → `*.gen.ts` with per-module route names, params, and search types
577
+
578
+ ## Type Safety
579
+
580
+ The Vite plugin automatically generates a `router.named-routes.gen.ts` file that globally registers all route names, patterns, and search schemas. Type-safe `reverse()`, `Handler<"name">`, `href()`, and `RouteParams<"name">` work out of the box — no manual type registration needed. The gen file is updated on dev server startup, HMR, and production builds.
581
+
582
+ ## Subpath Exports
583
+
584
+ | Export | Description |
585
+ |--------|-------------|
586
+ | `@rangojs/router` | Core: `createRouter`, `urls`, `createLoader`, `Handler`, `Prerender`, `Meta` |
587
+ | `@rangojs/router/client` | Client: `Link`, `Outlet`, `href`, `useNavigation`, `useLoader`, `MetaTags` |
588
+ | `@rangojs/router/cache` | Cache: `CFCacheStore`, `MemorySegmentCacheStore`, `createDocumentCacheMiddleware` |
589
+ | `@rangojs/router/theme` | Theme: `useTheme`, `ThemeProvider`, `ThemeScript` |
590
+ | `@rangojs/router/host` | Host routing: `createHostRouter`, `defineHosts` |
591
+ | `@rangojs/router/vite` | Vite plugin: `rango()` |
592
+ | `@rangojs/router/server` | Server utilities |
593
+ | `@rangojs/router/build` | Build utilities |
594
+
595
+ ## Examples
596
+
597
+ See the `examples/` directory for full working applications:
14
598
 
15
- This package is in early experimental stages. It is not recommended for production use.
599
+ - [`cloudflare-basic`](../../examples/cloudflare-basic) Cloudflare Workers with caching, loaders, theme, and pre-rendering
600
+ - [`cloudflare-multi-router`](../../examples/cloudflare-multi-router) — Multi-app host routing
16
601
 
17
602
  ## License
18
603