litzjs 0.0.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 ADDED
@@ -0,0 +1,936 @@
1
+ # Litz
2
+
3
+ Litz is a client-first React framework for Vite.
4
+
5
+ It gives you:
6
+
7
+ - client-side navigation by default
8
+ - explicit server boundaries with `server(...)`
9
+ - route loaders and actions
10
+ - reusable server-backed resources
11
+ - raw API routes
12
+ - `view(...)` responses powered by React Server Components / Flight
13
+
14
+ ## Status
15
+
16
+ Litz is currently a production candidate.
17
+
18
+ The core route, resource, API route, and RSC runtime now has deterministic route matching,
19
+ multipart-safe internal actions, and a release gate via `bun run check`.
20
+
21
+ ## Installation
22
+
23
+ Inside a React + Vite app:
24
+
25
+ ```bash
26
+ bun add litz react react-dom
27
+ ```
28
+
29
+ ## Quick Start
30
+
31
+ Add the Litz Vite plugin. By default, Litz discovers:
32
+
33
+ - routes from `src/routes/**/*.{ts,tsx}`
34
+ - API routes from `src/routes/api/**/*.{ts,tsx}`
35
+ - resources from `src/routes/resources/**/*.{ts,tsx}`
36
+ - a custom server entry from `src/server.ts`, falling back to `src/server/index.ts`
37
+
38
+ `vite.config.ts`
39
+
40
+ ```ts
41
+ import { defineConfig } from "vite";
42
+ import { litz } from "litz/vite";
43
+
44
+ export default defineConfig({
45
+ plugins: [litz()],
46
+ });
47
+ ```
48
+
49
+ You can still override discovery explicitly when you need a different project layout:
50
+
51
+ ```ts
52
+ export default defineConfig({
53
+ plugins: [
54
+ litz({
55
+ routes: ["app/pages/**/*.{ts,tsx}"],
56
+ resources: ["app/resources/**/*.{ts,tsx}"],
57
+ api: ["app/api/**/*.{ts,tsx}"],
58
+ server: "app/server/entry.ts",
59
+ }),
60
+ ],
61
+ });
62
+ ```
63
+
64
+ Mount the Litz app from your browser entry.
65
+
66
+ `src/main.tsx`
67
+
68
+ ```tsx
69
+ import { mountApp } from "litz/client";
70
+
71
+ const root = document.getElementById("app");
72
+
73
+ if (!root) {
74
+ throw new Error('Missing "#app" root element.');
75
+ }
76
+
77
+ mountApp(root);
78
+ ```
79
+
80
+ You can optionally provide a wrapper component around the app root:
81
+
82
+ ```tsx
83
+ import { StrictMode } from "react";
84
+ import { mountApp } from "litz/client";
85
+
86
+ mountApp(root, StrictMode);
87
+ ```
88
+
89
+ For providers or wrappers with props, pass a component:
90
+
91
+ ```tsx
92
+ import { mountApp } from "litz/client";
93
+
94
+ function AppProviders({ children }: React.PropsWithChildren) {
95
+ return <ThemeProvider theme={theme}>{children}</ThemeProvider>;
96
+ }
97
+
98
+ mountApp(root, AppProviders);
99
+ ```
100
+
101
+ `index.html`
102
+
103
+ ```html
104
+ <!doctype html>
105
+ <html lang="en">
106
+ <body>
107
+ <div id="app"></div>
108
+ <script type="module" src="/src/main.tsx"></script>
109
+ </body>
110
+ </html>
111
+ ```
112
+
113
+ Create your first route.
114
+
115
+ `src/routes/index.tsx`
116
+
117
+ ```tsx
118
+ import { defineRoute } from "litz";
119
+
120
+ export const route = defineRoute("/", {
121
+ component: HomePage,
122
+ });
123
+
124
+ function HomePage() {
125
+ return (
126
+ <main>
127
+ <h1>Welcome</h1>
128
+ <p>Your app is running on Litz.</p>
129
+ </main>
130
+ );
131
+ }
132
+ ```
133
+
134
+ ## Routes
135
+
136
+ Routes are explicit. The path you pass to `defineRoute(...)` is the source of truth.
137
+
138
+ Add a loader when you need server data:
139
+
140
+ ```tsx
141
+ import { data, defineRoute, server } from "litz";
142
+
143
+ export const route = defineRoute("/me", {
144
+ component: ProfilePage,
145
+ loader: server(async () => {
146
+ return data({
147
+ user: {
148
+ id: "u_123",
149
+ name: "Ada",
150
+ },
151
+ });
152
+ }),
153
+ });
154
+
155
+ function ProfilePage() {
156
+ const profile = route.useLoaderData();
157
+
158
+ if (!profile) {
159
+ return null;
160
+ }
161
+
162
+ return <p>{profile.user.name}</p>;
163
+ }
164
+ ```
165
+
166
+ Routes and layouts can also define:
167
+
168
+ - `pendingComponent` for the first unresolved loader pass
169
+ - `errorComponent` for `error(...)` results and unhandled route faults
170
+ - `middleware` for per-definition request handling
171
+
172
+ ## Layouts
173
+
174
+ Layouts are explicit too. A route opts into a layout by importing it and passing `layout`.
175
+
176
+ ```tsx
177
+ import type { ReactNode } from "react";
178
+ import { defineLayout, defineRoute } from "litz";
179
+
180
+ export const dashboardLayout = defineLayout("/dashboard", {
181
+ component: DashboardShell,
182
+ });
183
+
184
+ export const route = defineRoute("/dashboard/settings", {
185
+ component: SettingsPage,
186
+ layout: dashboardLayout,
187
+ });
188
+
189
+ function DashboardShell(props: { children: ReactNode }) {
190
+ return (
191
+ <div>
192
+ <aside>Dashboard nav</aside>
193
+ <section>{props.children}</section>
194
+ </div>
195
+ );
196
+ }
197
+
198
+ function SettingsPage() {
199
+ return <h1>Settings</h1>;
200
+ }
201
+ ```
202
+
203
+ Layouts can declare loaders and use the same route-state hooks:
204
+
205
+ - `layout.useLoaderResult()`
206
+ - `layout.useLoaderData()`
207
+ - `layout.useLoaderView()`
208
+ - `layout.useData()`
209
+ - `layout.useView()`
210
+ - `layout.useParams()`
211
+ - `layout.useSearch()`
212
+ - `layout.useStatus()`
213
+ - `layout.usePending()`
214
+ - `layout.useReload()`
215
+ - `layout.useRetry()`
216
+
217
+ ## `view(...)`
218
+
219
+ When you want the server to return UI instead of JSON, return `view(...)`.
220
+
221
+ ```tsx
222
+ import * as React from "react";
223
+ import { defineRoute, server, view } from "litz";
224
+
225
+ export const route = defineRoute("/reports", {
226
+ component: ReportsPage,
227
+ loader: server(async () => {
228
+ return view(<ReportsPanel />);
229
+ }),
230
+ });
231
+
232
+ function ReportsPage() {
233
+ const view = route.useLoaderView();
234
+
235
+ if (!view) {
236
+ return <p>Loading reports...</p>;
237
+ }
238
+
239
+ return <React.Suspense fallback={<p>Loading reports...</p>}>{view}</React.Suspense>;
240
+ }
241
+
242
+ function ReportsPanel() {
243
+ return <section>Rendered on the server.</section>;
244
+ }
245
+ ```
246
+
247
+ Result hooks are layered:
248
+
249
+ - `useLoaderResult()` and `useActionResult()` expose the raw normalized result branches
250
+ - `useLoaderData()` / `useLoaderView()` and `useActionData()` / `useActionView()` / `useActionError()` expose branch-specific values
251
+ - `useData()` / `useView()` / `useError()` expose the latest settled value from either the loader or action
252
+ - unresolved values are `null`
253
+
254
+ ## Route State Hooks
255
+
256
+ Routes expose state and control hooks beyond the result helpers:
257
+
258
+ ```tsx
259
+ function SaveToolbar() {
260
+ const status = route.useStatus();
261
+ const pending = route.usePending();
262
+ const reload = route.useReload();
263
+ const retry = route.useRetry();
264
+ const submit = route.useSubmit({
265
+ onSuccess(result) {
266
+ console.log("saved", result.kind);
267
+ },
268
+ });
269
+
270
+ return (
271
+ <div>
272
+ <p>Status: {status}</p>
273
+ <button onClick={() => reload()} disabled={pending}>
274
+ Reload
275
+ </button>
276
+ <button onClick={() => retry()} disabled={pending}>
277
+ Retry
278
+ </button>
279
+ <button onClick={() => submit({ name: "Ada" })} disabled={pending}>
280
+ Save
281
+ </button>
282
+ </div>
283
+ );
284
+ }
285
+ ```
286
+
287
+ `useStatus()` returns one of:
288
+
289
+ - `idle`
290
+ - `loading`
291
+ - `submitting`
292
+ - `revalidating`
293
+ - `offline-stale`
294
+ - `error`
295
+
296
+ Use the more specific hooks when you know which source you want:
297
+
298
+ - `useLoaderData()` if you only care about loader `data(...)`
299
+ - `useActionError()` if you only care about explicit action `error(...)`
300
+ - `useView()` if you want the latest settled `view(...)` from either side
301
+
302
+ ## Actions
303
+
304
+ Actions handle writes. They can return `data(...)`, `invalid(...)`, `redirect(...)`,
305
+ `error(...)`, or `view(...)`.
306
+
307
+ ```tsx
308
+ import { useFormStatus } from "react-dom";
309
+ import { data, defineRoute, invalid, server } from "litz";
310
+
311
+ export const route = defineRoute("/projects/new", {
312
+ component: NewProjectPage,
313
+ action: server(async ({ request }) => {
314
+ const formData = await request.formData();
315
+ const name = String(formData.get("name") ?? "").trim();
316
+
317
+ if (!name) {
318
+ return invalid({
319
+ fields: { name: "Name is required" },
320
+ });
321
+ }
322
+
323
+ return data({ ok: true, name });
324
+ }),
325
+ });
326
+
327
+ function NewProjectPage() {
328
+ const invalidResult = route.useInvalid();
329
+ const created = route.useActionData();
330
+
331
+ return (
332
+ <route.Form>
333
+ <input name="name" />
334
+ {invalidResult ? <p>{invalidResult.fields?.name}</p> : null}
335
+ {created ? <p>Created {created.name}</p> : null}
336
+ <SubmitButton />
337
+ </route.Form>
338
+ );
339
+ }
340
+
341
+ function SubmitButton() {
342
+ const status = useFormStatus();
343
+
344
+ return (
345
+ <button type="submit" disabled={status.pending}>
346
+ {status.pending ? "Creating..." : "Create"}
347
+ </button>
348
+ );
349
+ }
350
+ ```
351
+
352
+ `route.Form` uses React 19 form actions under the hood, so nested components can use
353
+ `useFormStatus()` without extra framework wrappers.
354
+
355
+ If you need imperative writes instead of a form, use `route.useSubmit()`.
356
+
357
+ ## Navigation
358
+
359
+ Litz ships a small client navigation layer.
360
+
361
+ ```tsx
362
+ import { Link, useNavigate } from "litz/client";
363
+
364
+ function Nav() {
365
+ const navigate = useNavigate();
366
+
367
+ return (
368
+ <>
369
+ <Link href="/reports">Reports</Link>
370
+ <button onClick={() => navigate("/me")}>Go to profile</button>
371
+ </>
372
+ );
373
+ }
374
+ ```
375
+
376
+ `Link` keeps normal anchor ergonomics:
377
+
378
+ - it uses `href`, not `to`
379
+ - only `Link` intercepts same-origin plain clicks for client navigation
380
+ - modifier clicks, external links, and downloads fall back to the browser
381
+
382
+ Plain `<a href>` elements stay native and perform normal browser navigations.
383
+
384
+ You can also inspect the active route chain:
385
+
386
+ ```tsx
387
+ import { useMatches } from "litz";
388
+
389
+ function Breadcrumbs() {
390
+ const matches = useMatches();
391
+
392
+ return (
393
+ <ol>
394
+ {matches.map((match) => (
395
+ <li key={match.id}>{match.path}</li>
396
+ ))}
397
+ </ol>
398
+ );
399
+ }
400
+ ```
401
+
402
+ If you want the current concrete browser location instead of the route pattern chain:
403
+
404
+ ```tsx
405
+ import { useLocation, usePathname } from "litz";
406
+
407
+ function RouteMeta() {
408
+ const pathname = usePathname();
409
+ const location = useLocation();
410
+
411
+ return (
412
+ <>
413
+ <p>Pathname: {pathname}</p>
414
+ <p>Hash: {location.hash || "(none)"}</p>
415
+ </>
416
+ );
417
+ }
418
+ ```
419
+
420
+ `useLocation()` returns:
421
+
422
+ - `href`
423
+ - `pathname`
424
+ - `search`
425
+ - `hash`
426
+
427
+ ## Search Params
428
+
429
+ Search params are part of the route runtime:
430
+
431
+ ```tsx
432
+ function ReportsFilters() {
433
+ const [searchParams, setSearch] = route.useSearch();
434
+ const tab = searchParams.get("tab") ?? "all";
435
+
436
+ return (
437
+ <>
438
+ <p>Current tab: {tab}</p>
439
+ <button onClick={() => setSearch({ tab: "open", tag: ["bug", "urgent"] })}>
440
+ Show open bugs
441
+ </button>
442
+ <button onClick={() => setSearch({ tag: null }, { replace: true })}>Clear tags</button>
443
+ </>
444
+ );
445
+ }
446
+ ```
447
+
448
+ `setSearch(...)` merges by default:
449
+
450
+ - `string` sets a single value
451
+ - `string[]` writes repeated keys
452
+ - `null` or `undefined` deletes a key
453
+ - unchanged updates are ignored
454
+ - updates go through the normal client navigation and revalidation path
455
+
456
+ Layouts expose the same `[searchParams, setSearch]` tuple.
457
+
458
+ ## Resources
459
+
460
+ Resources are route-agnostic ways to package client-side and server-side functionality into a
461
+ self-contained component or unit of code.
462
+
463
+ They are for cases where something should be reusable across routes, layouts, and app shells
464
+ without becoming a page of its own. A resource can own:
465
+
466
+ - its own server loader
467
+ - its own server action
468
+ - its own pending and error state
469
+ - its own params and search input
470
+ - its own client UI
471
+
472
+ The mental model is:
473
+
474
+ - routes own navigation
475
+ - resources own reusable server-backed UI behavior
476
+
477
+ Each rendered `<resource.Component ... />` creates a scoped resource instance. Inside that subtree,
478
+ resource hooks work like route hooks, but against that resource instance.
479
+
480
+ ### Loader-Only Resource
481
+
482
+ A resource always declares a `component`. That component reads resource state through hooks.
483
+
484
+ ```tsx
485
+ import { data, defineResource, server } from "litz";
486
+
487
+ export const resource = defineResource("/resource/user/:id", {
488
+ component: UserCard,
489
+ loader: server(async ({ params }) => {
490
+ return data({
491
+ user: {
492
+ id: params.id,
493
+ name: "Ada",
494
+ },
495
+ });
496
+ }),
497
+ });
498
+
499
+ function UserCard() {
500
+ const user = resource.useLoaderData();
501
+ const pending = resource.usePending();
502
+
503
+ if (!user) {
504
+ return <p>{pending ? "Loading..." : "No user"}</p>;
505
+ }
506
+
507
+ return <p>{user.user.name}</p>;
508
+ }
509
+ ```
510
+
511
+ Render it anywhere:
512
+
513
+ ```tsx
514
+ <resource.Component params={{ id: "u_123" }} />
515
+ ```
516
+
517
+ ### Search Params And Params
518
+
519
+ Resources receive `params` and optional `search` at the component boundary:
520
+
521
+ ```tsx
522
+ <resource.Component params={{ id: "u_123" }} search={{ tab: "profile" }} />
523
+ ```
524
+
525
+ Inside the resource, use the scoped hooks:
526
+
527
+ ```tsx
528
+ function UserCard() {
529
+ const params = resource.useParams();
530
+ const [searchParams, setSearch] = resource.useSearch();
531
+ const tab = searchParams.get("tab") ?? "profile";
532
+
533
+ return (
534
+ <>
535
+ <p>User id: {params.id}</p>
536
+ <p>Tab: {tab}</p>
537
+ <button onClick={() => setSearch({ tab: "security" })}>Security</button>
538
+ </>
539
+ );
540
+ }
541
+ ```
542
+
543
+ ### View-Based Resource
544
+
545
+ Resources can also return `view(...)` from the server and consume it with `resource.useView()`:
546
+
547
+ ```tsx
548
+ import * as React from "react";
549
+ import { defineResource, server, view } from "litz";
550
+
551
+ export const resource = defineResource("/resource/account/:id", {
552
+ component: AccountMenu,
553
+ loader: server(async ({ params }) => {
554
+ return view(<section>Account {params.id}</section>);
555
+ }),
556
+ });
557
+
558
+ function AccountMenu() {
559
+ const view = resource.useView();
560
+
561
+ if (!view) {
562
+ return <p>Loading account menu...</p>;
563
+ }
564
+
565
+ return <React.Suspense fallback={<p>Loading account menu...</p>}>{view}</React.Suspense>;
566
+ }
567
+ ```
568
+
569
+ ### Action-Enabled Resource
570
+
571
+ Resources can define actions with the same self-contained form story as routes:
572
+
573
+ ```tsx
574
+ import * as React from "react";
575
+ import { defineResource, server, view } from "litz";
576
+ import { useFormStatus } from "react-dom";
577
+
578
+ export const resource = defineResource("/resource/feed/:id", {
579
+ component: FeedPanel,
580
+ loader: server(async ({ params }) => {
581
+ return view(
582
+ <ul>
583
+ <li>Feed {params.id}</li>
584
+ </ul>,
585
+ );
586
+ }),
587
+ action: server(async ({ params, request }) => {
588
+ const formData = await request.formData();
589
+ const message = String(formData.get("message") ?? "");
590
+
591
+ return view(
592
+ <ul>
593
+ <li>{params.id}</li>
594
+ <li>{message}</li>
595
+ </ul>,
596
+ );
597
+ }),
598
+ });
599
+
600
+ function FeedPanel() {
601
+ const view = resource.useView();
602
+ const pending = resource.usePending();
603
+ const [message, setMessage] = React.useState("");
604
+
605
+ return (
606
+ <resource.Form
607
+ onSubmit={(event) => {
608
+ if (!message.trim()) {
609
+ event.preventDefault();
610
+ return;
611
+ }
612
+
613
+ setMessage("");
614
+ }}
615
+ >
616
+ <input
617
+ name="message"
618
+ value={message}
619
+ onChange={(event) => setMessage(event.target.value)}
620
+ disabled={pending}
621
+ />
622
+ <SubmitButton />
623
+ {view ? <React.Suspense fallback={<p>Loading...</p>}>{view}</React.Suspense> : null}
624
+ </resource.Form>
625
+ );
626
+ }
627
+
628
+ function SubmitButton() {
629
+ const status = useFormStatus();
630
+
631
+ return (
632
+ <button type="submit" disabled={status.pending}>
633
+ {status.pending ? "Sending..." : "Send"}
634
+ </button>
635
+ );
636
+ }
637
+ ```
638
+
639
+ You can also submit imperatively:
640
+
641
+ ```tsx
642
+ function QuickActions() {
643
+ const submit = resource.useSubmit();
644
+ const pending = resource.usePending();
645
+
646
+ return (
647
+ <button disabled={pending} onClick={() => void submit({ message: "Pinned update" })}>
648
+ Post preset message
649
+ </button>
650
+ );
651
+ }
652
+ ```
653
+
654
+ ### Available Resource Hooks
655
+
656
+ Inside a resource component subtree, resources expose the same style of hooks as routes:
657
+
658
+ - `resource.useLoaderResult()`
659
+ - `resource.useLoaderData()`
660
+ - `resource.useLoaderView()`
661
+ - `resource.useActionResult()`
662
+ - `resource.useActionData()`
663
+ - `resource.useActionView()`
664
+ - `resource.useActionError()`
665
+ - `resource.useInvalid()`
666
+ - `resource.useData()`
667
+ - `resource.useView()`
668
+ - `resource.useError()`
669
+ - `resource.useStatus()`
670
+ - `resource.usePending()`
671
+ - `resource.useParams()`
672
+ - `resource.useSearch()`
673
+ - `resource.useReload()`
674
+ - `resource.useRetry()`
675
+ - `resource.useSubmit()`
676
+ - `resource.Form`
677
+
678
+ The main split to keep in mind:
679
+
680
+ - `useLoaderData()` / `useLoaderView()` read loader-only state
681
+ - `useActionData()` / `useActionView()` / `useActionError()` / `useInvalid()` read action-only state
682
+ - `useData()` / `useView()` / `useError()` read the latest settled merged value for the resource
683
+
684
+ ### Multiple Resource Instances
685
+
686
+ Resources are instance-scoped, not global. You can render the same resource multiple times on the
687
+ same page with different inputs:
688
+
689
+ ```tsx
690
+ <>
691
+ <userCard.Component params={{ id: "u_123" }} />
692
+ <userCard.Component params={{ id: "u_456" }} />
693
+ </>
694
+ ```
695
+
696
+ Each instance resolves against its own `params` and `search`. If two instances render with the
697
+ same resource path and the same request identity, they share the keyed runtime state under the hood,
698
+ so they stay in sync instead of duplicating work.
699
+
700
+ ## API Routes
701
+
702
+ API routes expose raw HTTP handlers and come with a thin client helper.
703
+
704
+ ```ts
705
+ import { defineApiRoute } from "litz";
706
+
707
+ export const api = defineApiRoute("/api/health", {
708
+ middleware: [],
709
+ GET() {
710
+ return Response.json({ ok: true });
711
+ },
712
+ ALL({ request }) {
713
+ return Response.json({ method: request.method });
714
+ },
715
+ });
716
+ ```
717
+
718
+ ```ts
719
+ const response = await api.fetch();
720
+ const data = await response.json();
721
+ ```
722
+
723
+ Supported method keys:
724
+
725
+ - `GET`
726
+ - `POST`
727
+ - `PUT`
728
+ - `PATCH`
729
+ - `DELETE`
730
+ - `OPTIONS`
731
+ - `HEAD`
732
+ - `ALL`
733
+
734
+ `ALL` acts as a fallback when there is no method-specific handler.
735
+
736
+ `api.fetch(...)` accepts route params, search params, headers, and the HTTP method when needed.
737
+
738
+ ## Server Runtime
739
+
740
+ Litz ships a default WinterCG-style server runtime:
741
+
742
+ ```ts
743
+ import { createServer } from "litz/server";
744
+
745
+ export default createServer({
746
+ createContext(request) {
747
+ return {
748
+ requestId: request.headers.get("x-request-id"),
749
+ };
750
+ },
751
+ onError(error, context) {
752
+ console.error("Litz server error", { error, context });
753
+ },
754
+ });
755
+ ```
756
+
757
+ In simple apps, `createServer()` with no arguments is enough:
758
+
759
+ ```ts
760
+ import { createServer } from "litz/server";
761
+
762
+ export default createServer();
763
+ ```
764
+
765
+ The Vite plugin injects the discovered server manifest automatically into that entry.
766
+
767
+ ### Production Output
768
+
769
+ When you run `vite build`, Litz always writes the browser assets to `dist/client`.
770
+
771
+ Server output depends on whether you provide a custom server entry:
772
+
773
+ - No custom server entry:
774
+ Litz emits a self-contained `dist/server/index.js`.
775
+ That file inlines the built document HTML and all client asset contents, so the server handler can
776
+ serve `/` and `/assets/*` by itself. This is the default one-file server deployment mode.
777
+ - Custom server entry present:
778
+ Litz emits `dist/server/index.js` from your server entry and injects the discovered server
779
+ manifest into `createServer(...)`.
780
+ Litz does not inject static asset or document serving in this mode. Your host server or platform
781
+ is responsible for serving `dist/client` however you want, for example through `express.static`,
782
+ a CDN, or a platform asset binding.
783
+
784
+ You can let Litz discover `src/server.ts` or `src/server/index.ts`, or configure a different path
785
+ explicitly in `vite.config.ts`:
786
+
787
+ ```ts
788
+ import { defineConfig } from "vite";
789
+ import { litz } from "litz/vite";
790
+
791
+ export default defineConfig({
792
+ plugins: [
793
+ litz({
794
+ server: "app/server/entry.ts",
795
+ }),
796
+ ],
797
+ });
798
+ ```
799
+
800
+ In the custom-server case, unmatched document and static asset requests fall through to the normal
801
+ `createServer(...)` 404 behavior unless your host server handles them first.
802
+
803
+ ## Security Model
804
+
805
+ Litz's server boundaries are explicit, but they are still normal server request surfaces.
806
+
807
+ - Route loaders and actions are server handlers.
808
+ - Resource loaders and actions are server handlers.
809
+ - API routes are raw HTTP handlers.
810
+ - The `/_litz/*` transport used by the client runtime is an implementation detail, not a private trust boundary.
811
+
812
+ That means Litz apps should treat route loaders, actions, resources, and API routes like any other
813
+ server endpoint:
814
+
815
+ - authenticate and authorize inside middleware or handlers
816
+ - validate params, search params, headers, and form/body input
817
+ - apply CSRF protections when using cookie-backed auth for writes
818
+ - do not assume a request came from Litz just because it arrived through `/_litz/*`
819
+
820
+ Litz may serve `index.html` itself, but it also supports deployments where the document is served
821
+ statically or by a custom server. Security decisions must not depend on the document coming from
822
+ Litz.
823
+
824
+ ## Result Helpers
825
+
826
+ Server handlers can return these helpers:
827
+
828
+ - `data(value, options?)`
829
+ - `view(node, options?)`
830
+ - `invalid({ ... })`
831
+ - `redirect(location, options?)`
832
+ - `error(status, message, options?)`
833
+ - `withHeaders(result, headers)`
834
+
835
+ ```tsx
836
+ import { data, defineRoute, error, redirect, server, withHeaders } from "litz";
837
+
838
+ export const route = defineRoute("/projects/:id", {
839
+ component: ProjectPage,
840
+ loader: server(async ({ params }) => {
841
+ if (params.id === "new") {
842
+ return redirect("/projects/create");
843
+ }
844
+
845
+ return withHeaders(data({ id: params.id }, { revalidate: ["/projects/:id"] }), {
846
+ "cache-control": "private, max-age=60",
847
+ });
848
+ }),
849
+ action: server(async ({ request }) => {
850
+ const formData = await request.formData();
851
+
852
+ if (!formData.get("name")) {
853
+ return error(422, "Missing project name", {
854
+ code: "missing_name",
855
+ data: { field: "name" },
856
+ });
857
+ }
858
+
859
+ return data({ ok: true });
860
+ }),
861
+ });
862
+ ```
863
+
864
+ Behavior summary:
865
+
866
+ - `data(...)` populates loader/action data hooks
867
+ - `view(...)` populates loader/action view hooks
868
+ - `invalid(...)` populates `useInvalid()`
869
+ - `redirect(...)` navigates instead of producing hook state
870
+ - explicit action `error(...)` is available through `useActionError()` and `useError()`
871
+ - route faults and loader failures go through route error boundaries
872
+
873
+ ## Middleware
874
+
875
+ Routes, resources, and API routes can declare a `middleware` array. Middleware runs in order and can continue with `next()`, short-circuit with a result, or explicitly replace `context` with `next({ context })`.
876
+
877
+ ```tsx
878
+ import { data, defineApiRoute, defineRoute, error, server } from "litz";
879
+
880
+ export const route = defineRoute("/dashboard", {
881
+ component: DashboardPage,
882
+ middleware: [
883
+ async ({ context, next }) => {
884
+ if (!context.userId) {
885
+ return error(401, "Unauthorized");
886
+ }
887
+
888
+ return next();
889
+ },
890
+ ],
891
+ loader: server(async ({ context }) => {
892
+ return data({ userId: context.userId });
893
+ }),
894
+ });
895
+
896
+ export const api = defineApiRoute("/api/dashboard", {
897
+ middleware: [
898
+ async ({ context, next }) => {
899
+ if (!context.userId) {
900
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
901
+ }
902
+
903
+ return next();
904
+ },
905
+ ],
906
+ GET({ context }) {
907
+ return Response.json({ userId: context.userId });
908
+ },
909
+ });
910
+ ```
911
+
912
+ Middleware receives:
913
+
914
+ - `request`
915
+ - `params`
916
+ - `context`
917
+ - `signal`
918
+ - `next(...)`
919
+
920
+ ## Core Ideas
921
+
922
+ - Litz is SPA-first. The browser owns the document.
923
+ - Server logic only exists at explicit framework boundaries.
924
+ - `view(...)` uses RSC as a transport, not as the whole app architecture.
925
+ - Routes, resources, and API routes are discovered from top-level glob options.
926
+ - Paths are explicit and absolute.
927
+
928
+ ## Try The Fixture
929
+
930
+ This repo includes a working fixture app in [`fixtures/rsc-smoke`](./fixtures/rsc-smoke):
931
+
932
+ ```bash
933
+ bun run fixture:dev
934
+ ```
935
+
936
+ Then open [http://127.0.0.1:4173/](http://127.0.0.1:4173/).