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/LICENSE +21 -0
- package/README.md +936 -0
- package/dist/bindings-B1P6pL93.js +21 -0
- package/dist/bindings-BDe-v5i6.mjs +10 -0
- package/dist/chunk-8l464Juk.js +28 -0
- package/dist/client.d.mts +35 -0
- package/dist/client.d.ts +35 -0
- package/dist/client.js +1633 -0
- package/dist/client.mjs +1625 -0
- package/dist/index.d.mts +559 -0
- package/dist/index.d.ts +559 -0
- package/dist/index.js +360 -0
- package/dist/index.mjs +344 -0
- package/dist/internal-transport-DR0r68ff.js +161 -0
- package/dist/internal-transport-dsMykcNK.mjs +114 -0
- package/dist/request-headers-DepZ5tjg.mjs +35 -0
- package/dist/request-headers-ZPR3TQs3.js +46 -0
- package/dist/server.d.mts +74 -0
- package/dist/server.d.ts +74 -0
- package/dist/server.js +316 -0
- package/dist/server.mjs +315 -0
- package/dist/vite.d.mts +10043 -0
- package/dist/vite.d.ts +10043 -0
- package/dist/vite.js +1481 -0
- package/dist/vite.mjs +1474 -0
- package/package.json +90 -0
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/).
|