@zerotal/arch 1.7.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.
Files changed (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -0
  4. package/api-surface.md +283 -0
  5. package/docs/about.md +676 -0
  6. package/docs/admin/actions.md +183 -0
  7. package/docs/admin/auth.md +89 -0
  8. package/docs/admin/dashboard.md +155 -0
  9. package/docs/admin/extending-ui.md +229 -0
  10. package/docs/admin/extending.md +205 -0
  11. package/docs/admin/forms.md +155 -0
  12. package/docs/admin/index.md +144 -0
  13. package/docs/admin/operations.md +224 -0
  14. package/docs/admin/references.md +174 -0
  15. package/docs/admin/resources.md +132 -0
  16. package/docs/admin/structure.md +158 -0
  17. package/docs/admin/tables.md +185 -0
  18. package/docs/admin/testing.md +138 -0
  19. package/docs/ai.md +373 -0
  20. package/docs/application.md +435 -0
  21. package/docs/arch.md +263 -0
  22. package/docs/assets.md +755 -0
  23. package/docs/audit.md +367 -0
  24. package/docs/authentication.md +1000 -0
  25. package/docs/authorization.md +529 -0
  26. package/docs/broadcasting/channels.md +170 -0
  27. package/docs/broadcasting/client.md +108 -0
  28. package/docs/broadcasting/events.md +190 -0
  29. package/docs/broadcasting/index.md +163 -0
  30. package/docs/broadcasting/references.md +84 -0
  31. package/docs/broadcasting/testing.md +132 -0
  32. package/docs/cache.md +387 -0
  33. package/docs/carbon.md +830 -0
  34. package/docs/changelog.md +465 -0
  35. package/docs/client/auth.md +113 -0
  36. package/docs/client/errors.md +139 -0
  37. package/docs/client/files.md +118 -0
  38. package/docs/client/index.md +128 -0
  39. package/docs/client/references.md +58 -0
  40. package/docs/client/requests.md +131 -0
  41. package/docs/client/resilience.md +141 -0
  42. package/docs/client/testing.md +146 -0
  43. package/docs/commands.md +450 -0
  44. package/docs/components.md +2427 -0
  45. package/docs/config-system.md +258 -0
  46. package/docs/container.md +596 -0
  47. package/docs/context.md +579 -0
  48. package/docs/contributing.md +164 -0
  49. package/docs/controllers.md +354 -0
  50. package/docs/conventions.md +466 -0
  51. package/docs/cookies.md +101 -0
  52. package/docs/csrf.md +206 -0
  53. package/docs/database.md +476 -0
  54. package/docs/deployment.md +434 -0
  55. package/docs/devtools.md +1013 -0
  56. package/docs/email-verification.md +320 -0
  57. package/docs/encryption.md +380 -0
  58. package/docs/errors.md +317 -0
  59. package/docs/events.md +420 -0
  60. package/docs/flow/components.md +846 -0
  61. package/docs/flow/decorators.md +458 -0
  62. package/docs/flow/events.md +420 -0
  63. package/docs/flow/forms.md +551 -0
  64. package/docs/flow/index.md +568 -0
  65. package/docs/flow/layouts.md +793 -0
  66. package/docs/flow/lifecycle.md +336 -0
  67. package/docs/flow/pagination.md +402 -0
  68. package/docs/flow/performance.md +262 -0
  69. package/docs/flow/references.md +317 -0
  70. package/docs/flow/routing.md +550 -0
  71. package/docs/flow/testing.md +447 -0
  72. package/docs/getting-started.md +286 -0
  73. package/docs/health.md +269 -0
  74. package/docs/helpers.md +423 -0
  75. package/docs/i18n.md +414 -0
  76. package/docs/index.md +65 -0
  77. package/docs/inertia/build.md +95 -0
  78. package/docs/inertia/devtools.md +197 -0
  79. package/docs/inertia/index.md +267 -0
  80. package/docs/inertia/middleware.md +81 -0
  81. package/docs/inertia/props.md +507 -0
  82. package/docs/inertia/references.md +144 -0
  83. package/docs/inertia/rendering.md +189 -0
  84. package/docs/inertia/ssr.md +102 -0
  85. package/docs/inspirations.md +257 -0
  86. package/docs/lifecycle.md +331 -0
  87. package/docs/lock.md +454 -0
  88. package/docs/logger.md +517 -0
  89. package/docs/media.md +582 -0
  90. package/docs/middleware.md +509 -0
  91. package/docs/migrations.md +529 -0
  92. package/docs/monitor.md +377 -0
  93. package/docs/notifications.md +848 -0
  94. package/docs/orm/casts.md +365 -0
  95. package/docs/orm/factories.md +231 -0
  96. package/docs/orm/index.md +663 -0
  97. package/docs/orm/lifecycle.md +421 -0
  98. package/docs/orm/queries.md +692 -0
  99. package/docs/orm/relationships.md +440 -0
  100. package/docs/orm/serialization.md +251 -0
  101. package/docs/package-development.md +536 -0
  102. package/docs/pagination.md +340 -0
  103. package/docs/password-reset.md +335 -0
  104. package/docs/providers.md +447 -0
  105. package/docs/query-builder.md +465 -0
  106. package/docs/queue.md +549 -0
  107. package/docs/rate-limiting.md +253 -0
  108. package/docs/responses.md +336 -0
  109. package/docs/roles-and-2fa.md +418 -0
  110. package/docs/routing.md +1008 -0
  111. package/docs/scaffolding.md +335 -0
  112. package/docs/scheduler.md +533 -0
  113. package/docs/seeding.md +229 -0
  114. package/docs/session.md +451 -0
  115. package/docs/social.md +524 -0
  116. package/docs/storage.md +622 -0
  117. package/docs/structure.md +230 -0
  118. package/docs/support-policy.md +114 -0
  119. package/docs/telemetry.md +505 -0
  120. package/docs/tenancy.md +608 -0
  121. package/docs/testing/browser.md +153 -0
  122. package/docs/testing/console.md +130 -0
  123. package/docs/testing/database.md +238 -0
  124. package/docs/testing/flow-browser.md +216 -0
  125. package/docs/testing/http.md +441 -0
  126. package/docs/testing/index.md +240 -0
  127. package/docs/testing/mocking.md +410 -0
  128. package/docs/upgrade.md +192 -0
  129. package/docs/validator.md +549 -0
  130. package/docs/view.md +453 -0
  131. package/package.json +60 -0
  132. package/src/bin/mcp.ts +73 -0
  133. package/src/config.ts +63 -0
  134. package/src/errors.ts +27 -0
  135. package/src/index.ts +56 -0
  136. package/src/install/ArchInstallCommand.ts +189 -0
  137. package/src/install/detect.ts +98 -0
  138. package/src/install/guidelines.ts +290 -0
  139. package/src/install/markers.ts +77 -0
  140. package/src/install/mcpConfig.ts +81 -0
  141. package/src/mcp/index.ts +27 -0
  142. package/src/mcp/jsonrpc.ts +133 -0
  143. package/src/mcp/server.ts +307 -0
  144. package/src/mcp/stdio.ts +111 -0
  145. package/src/mcp/types.ts +146 -0
  146. package/src/probe/ArchProbeCommand.ts +48 -0
  147. package/src/probe/sentinel.ts +13 -0
  148. package/src/probe/topics.ts +291 -0
  149. package/src/provider/ArchProvider.ts +101 -0
  150. package/src/tools/_probe.ts +164 -0
  151. package/src/tools/apiSurface.ts +241 -0
  152. package/src/tools/appInfo.ts +88 -0
  153. package/src/tools/baselines.ts +210 -0
  154. package/src/tools/context.ts +37 -0
  155. package/src/tools/doctor.ts +93 -0
  156. package/src/tools/index.ts +50 -0
  157. package/src/tools/logs.ts +265 -0
  158. package/src/tools/routes.ts +133 -0
  159. package/src/tools/schema.ts +119 -0
  160. package/src/tools/searchDocs.ts +345 -0
package/docs/assets.md ADDED
@@ -0,0 +1,755 @@
1
+ ---
2
+ title: Assets
3
+ description: Compile and serve your CSS and JavaScript with Bun's native bundler and hot reload — no separate build tool to configure.
4
+ ---
5
+
6
+ # Assets
7
+
8
+ Zerotal compiles and serves your frontend assets — CSS and JavaScript — with Bun's
9
+ native bundler, so there's no separate build tool to configure. Each stack (Flow,
10
+ View, Inertia) has its own pipeline and hot-reload behaviour; all three use
11
+ [Tailwind CSS v4](https://tailwindcss.com/docs/v4-beta).
12
+
13
+ Asset compilation is built into the framework — there's no package to install or
14
+ provider to register for the pipeline itself. You only add Tailwind and wire up a
15
+ static route, then `bun zt serve --dev` handles build-and-reload for you.
16
+
17
+ > **Note** — Projects ship a `zt.ts` entry and a `zt` package script,
18
+ > so commands are written `bun zt <command>` (e.g. `bun zt serve --dev`).
19
+
20
+ ## Getting Started
21
+
22
+ Asset building is part of `@zerotal/core` — nothing to install and no provider
23
+ to register. `bun zt serve --dev` builds on change, and `bun zt css:build`
24
+ produces a production bundle:
25
+
26
+ ```bash
27
+ # in your project root
28
+ bun zt serve --dev # watch and rebuild
29
+ bun zt css:build # one production build
30
+ ```
31
+
32
+ Tailwind is the only optional piece; [add it](#tailwind-css) when you want it.
33
+
34
+ ### Which build command should I use?
35
+
36
+ Pick by stack — the dev server runs the right pipeline automatically:
37
+
38
+ - **Flow or View** (server-rendered, one stylesheet): `bun zt css:build` for
39
+ production. Dev rebuilds are wired up by `FlowProvider`.
40
+ - **Inertia** (React or Vue SPA, JS + CSS bundle): `bun zt inertia:build` for
41
+ production. Dev rebuilds are wired up by `InertiaProvider`.
42
+ - **Any stack, development**: `bun zt serve --dev` — initial build, file watch,
43
+ rebuild on save, and a browser reload over WebSocket.
44
+
45
+ ## Tailwind CSS
46
+
47
+ Install Tailwind v4 and Bun's native Tailwind plugin:
48
+
49
+ ```bash
50
+ # in your project root
51
+ bun add -d tailwindcss bun-plugin-tailwind
52
+ ```
53
+
54
+ Create `resources/css/app.css`:
55
+
56
+ ```css
57
+ /* resources/css/app.css */
58
+ @import "tailwindcss";
59
+
60
+ /* Extend Tailwind's theme with @theme */
61
+ @theme {
62
+ --color-brand: oklch(0.6 0.2 260);
63
+ --font-sans: "Inter", sans-serif;
64
+ }
65
+ ```
66
+
67
+ That's it — no `tailwind.config.js`, no PostCSS config, no purge list. Tailwind
68
+ v4 detects your templates automatically via content scanning.
69
+
70
+ > **Tip** — Why `bun-plugin-tailwind` and not PostCSS?
71
+ > Bun has native CSS support built in. `bun-plugin-tailwind` is a first-party
72
+ > Bun plugin that processes Tailwind v4 without PostCSS as an intermediary —
73
+ > it's faster and requires no extra peer dependencies. PostCSS is not needed.
74
+
75
+ ### How the build finds Tailwind
76
+
77
+ Zerotal looks for `bun-plugin-tailwind` in your project's `node_modules` at build
78
+ time. When it's installed, the plugin runs inside Bun's bundler and processes
79
+ every `@import "tailwindcss"` directive natively — no PostCSS, no extra peer
80
+ dependencies.
81
+
82
+ When it isn't, the build falls back to spawning `bunx @tailwindcss/cli` as a
83
+ subprocess, which needs only `tailwindcss`. Your stylesheet is built either way;
84
+ installing the plugin keeps the work in-process and faster.
85
+
86
+ ## CSS builds for Flow and View
87
+
88
+ Flow and View apps ship a single compiled stylesheet — the entry lives in
89
+ `resources/`, the built output in `public/`:
90
+
91
+ ```text
92
+ # project layout (Flow / View)
93
+ resources/
94
+ css/
95
+ app.css ← Tailwind entry point
96
+ public/
97
+ css/
98
+ app.css ← Built output (git-ignored)
99
+ ```
100
+
101
+ ### Linking the built CSS
102
+
103
+ Reference the built stylesheet with a plain `<link>` pointing at `/css/app.css`. In a
104
+ Flow layout it goes in the `static head`; in a View controller it's a tag in the
105
+ returned HTML:
106
+
107
+ ```html
108
+ <!-- in a Flow layout head, or a View controller's HTML -->
109
+ <link rel="stylesheet" href="/css/app.css" />
110
+ ```
111
+
112
+ ### Dev mode
113
+
114
+ ```bash
115
+ # in your project root
116
+ bun zt serve --dev
117
+ ```
118
+
119
+ When `resources/css/app.css` exists, `FlowProvider.onBooted()` registers a
120
+ combined CSS/JS build hook. The `DevOrchestrator` then:
121
+
122
+ 1. Runs an initial CSS build before starting the server
123
+ 2. Watches `resources/css/` for changes
124
+ 3. Rebuilds CSS on every save (using `bun-plugin-tailwind` if installed,
125
+ falling back to `bunx @tailwindcss/cli`)
126
+ 4. Signals the browser to reload over WebSocket (`/__dev/ws`)
127
+
128
+ The browser receives the reload signal and does a full page refresh — the
129
+ updated CSS is served from `public/css/app.css`.
130
+
131
+ > **Note** — The same `onBooted` hook also bundles `resources/js/app.js` →
132
+ > `public/js/app.js` when that entry exists, so Flow apps with a small client
133
+ > bundle get the same watch-and-rebuild treatment as their CSS.
134
+
135
+ ### Production build
136
+
137
+ ```bash
138
+ # in your project root
139
+ bun zt css:build
140
+ ```
141
+
142
+ Override the defaults with flags:
143
+
144
+ ```bash
145
+ # in your project root
146
+ bun zt css:build --input resources/css/app.css --output public/css --minify
147
+ ```
148
+
149
+ | Flag | Short | Default | Description |
150
+ | ---------- | ----- | ----------------------- | ---------------------------- |
151
+ | `--input` | `-i` | `resources/css/app.css` | Path to the CSS entry point. |
152
+ | `--output` | `-o` | `public/css` | Output directory. |
153
+ | `--minify` | `-m` | `true` | Minify the output. |
154
+
155
+ All flags default to sensible values; the short form with no flags is
156
+ sufficient for most apps.
157
+
158
+ ### Static file serving
159
+
160
+ Add `Router.static` to serve `public/` at the root:
161
+
162
+ ```typescript
163
+ // routes/index.ts
164
+ Router.static("/", "public");
165
+ ```
166
+
167
+ Or target `public/css/` specifically:
168
+
169
+ ```typescript
170
+ // routes/index.ts
171
+ Router.static("/css", "public/css");
172
+ ```
173
+
174
+ Every file present when the server starts is registered as its own route and
175
+ served without entering JavaScript, which is why a static asset costs
176
+ essentially nothing per request. The trade-off is that the list is fixed at
177
+ startup, so a file written afterwards has no route. That is fine for a deployed
178
+ app, where the build finishes before the server boots. Where it isn't — a
179
+ directory whose contents change while the server runs — pass `eager: false` to
180
+ fall back to a per-request disk lookup. Dev mode applies that fallback to every
181
+ static directory automatically, which is how a freshly-built chunk is served the
182
+ moment it lands.
183
+
184
+ ### The asset helper
185
+
186
+ Hard-coding `/css/app.css` into a template works right up until you move the
187
+ build output or serve it from a CDN prefix. `asset()` builds the URL for you
188
+ from the configured `app.assets.prefix`, so the template stops caring where the
189
+ file actually lives:
190
+
191
+ ```typescript
192
+ import { asset } from "zerotal/assets";
193
+
194
+ asset("app.css"); // → "/css/app.css"
195
+ ```
196
+
197
+ Reach for it in layouts and templates you write by hand — the Flow scaffold
198
+ uses it for exactly this. You do not need it for Inertia's `resources/app.html`,
199
+ which already gets its URLs rewritten for you.
200
+
201
+ In dev it also appends `?v=<build token>`, so a rebuilt file is refetched rather
202
+ than served from cache. In production it returns the clean path unchanged — a
203
+ deployed build is immutable, so cache invalidation belongs to your deploy (a
204
+ CDN purge, or the Inertia asset version described below) rather than to a query
205
+ string that would change on every boot.
206
+
207
+ ## JavaScript builds for Inertia
208
+
209
+ The React and Vue stacks share one build pipeline — same bundler, dev reload, and
210
+ output. The only differences are the page file extension (`.tsx` vs `.vue`) and the
211
+ JS entry point.
212
+
213
+ ### Inertia directory layout
214
+
215
+ ```text
216
+ # project layout (Inertia)
217
+ resources/
218
+ app.html ← HTML shell template
219
+ css/
220
+ app.css ← Tailwind entry (imported in app.tsx)
221
+ js/
222
+ app.tsx ← JS entry point
223
+ env.d.ts ← ambient module decls (*.css, *.vue, images)
224
+ pages.generated.ts ← auto-generated page registry (do not edit)
225
+ Layouts/
226
+ AppLayout.tsx ← (.vue for Vue)
227
+ pages/ ← page components live here by default
228
+ home.tsx ← (.vue for Vue)
229
+ Posts/
230
+ Index.tsx
231
+ Show.tsx
232
+ public/
233
+ assets/
234
+ app.js ← Built bundle (git-ignored)
235
+ app.css ← Extracted CSS (git-ignored)
236
+ chunk-*.js ← Per-page chunks (code splitting, git-ignored)
237
+ ```
238
+
239
+ ### JS entry point
240
+
241
+ `resources/js/app.tsx` is the bundler's entry point. It imports your CSS (so Tailwind
242
+ runs as part of the JS build) and boots the Inertia client, resolving each page from
243
+ the generated registry:
244
+
245
+ ```typescript
246
+ // resources/js/app.tsx (React)
247
+ import { createInertiaApp, type ResolvedComponent } from "@inertiajs/react";
248
+ import { createRoot } from "react-dom/client";
249
+ import { pages } from "./pages.generated.ts";
250
+ import "../css/app.css"; // ← imports CSS into the JS bundle
251
+
252
+ createInertiaApp({
253
+ resolve: async (name): Promise<ResolvedComponent> => {
254
+ const page = pages[name];
255
+ if (!page) throw new Error(`Inertia page not found: "${name}"`);
256
+ return (await page()).default as ResolvedComponent;
257
+ },
258
+ setup({ el, App, props }) {
259
+ createRoot(el).render(<App {...props} />);
260
+ },
261
+ });
262
+ ```
263
+
264
+ ```typescript
265
+ // resources/js/app.tsx (Vue)
266
+ import { createInertiaApp } from "@inertiajs/vue3";
267
+ import { createApp, h, type DefineComponent } from "vue";
268
+ import { pages } from "./pages.generated.ts";
269
+ import "../css/app.css";
270
+
271
+ createInertiaApp({
272
+ resolve: async (name): Promise<DefineComponent> => {
273
+ const page = pages[name];
274
+ if (!page) throw new Error(`Inertia page not found: "${name}"`);
275
+ return (await page()).default as DefineComponent;
276
+ },
277
+ setup({ el, App, props, plugin }) {
278
+ createApp({ render: () => h(App, props) })
279
+ .use(plugin)
280
+ .mount(el as Element);
281
+ },
282
+ });
283
+ ```
284
+
285
+ Tailwind is processed because `app.tsx` imports `app.css`, and `Bun.build()`
286
+ passes all CSS through `bun-plugin-tailwind` before emitting
287
+ `public/assets/app.css`. For Vue, the build also runs `@vue/compiler-sfc` via the
288
+ Inertia Vue plugin so `.vue` single-file components compile.
289
+
290
+ > **Warning** — `tsconfig.json` must set the right JSX runtime. React pages need
291
+ > `"jsxImportSource": "react"` and Vue pages need `"jsxImportSource": "vue"`.
292
+ > Using a server-JSX runtime instead (`"zerotal"` for views, `"@zerotal/flow"` for Flow) produces a runtime
293
+ > "Invalid hook call" / null-dispatcher error.
294
+
295
+ ### Inertia dev mode
296
+
297
+ ```bash
298
+ # in your project root
299
+ bun zt serve --dev
300
+ ```
301
+
302
+ `InertiaProvider.onBooted()` registers Inertia's build routine under its own
303
+ name. Each view layer registers its own, and every one of them runs on a change
304
+ — an Inertia app that also installs `@zerotal/monitor` gets Flow alongside
305
+ it, and both bundles keep rebuilding. The orchestrator:
306
+
307
+ 1. Runs `generatePageRegistry()` + `Bun.build()` on startup
308
+ 2. Watches `resources/js/` (which includes `resources/js/pages/`) and
309
+ `resources/css/`
310
+ 3. Regenerates the page registry and rebuilds the bundle on changes
311
+ 4. Signals the browser over WebSocket at `/__dev/ws`
312
+
313
+ Code splitting is enabled (`splitting: true`), so each page becomes its own
314
+ lazy-loaded chunk. Only the entry bundle (with the shared framework runtime) is
315
+ loaded on first navigation; each subsequent page chunk is fetched on demand.
316
+
317
+ A rebuild that changes the shape of the module graph — adding a page, adding an
318
+ import — emits chunk filenames the previous build never had. Those are served
319
+ as soon as they are written: in dev the server falls back to a disk lookup for
320
+ any path its startup scan didn't already know about, so a fresh chunk never
321
+ 404s while waiting for a restart.
322
+
323
+ #### Asset cache-busting in dev
324
+
325
+ To stop the browser serving a stale bundle after a rebuild, dev mode busts the
326
+ cache two ways (no-op in production):
327
+
328
+ - **Versioned URLs.** The served HTML rewrites local `/assets/*.js` and `*.css`
329
+ URLs to `?v=<file mtime>`. After a rebuild the mtime changes → the URL changes
330
+ → the browser fetches fresh; when nothing changed the URL is stable, so the
331
+ browser keeps using its cached copy. This works even on frontend-only rebuilds,
332
+ where the server is never restarted.
333
+ - **A per-build token on `asset()`.** URLs built by the
334
+ [`asset()` helper](#the-asset-helper) instead carry `?v=<build token>` — one
335
+ token for the whole build, reissued on every rebuild and handed to the server
336
+ over the same channel that triggers the browser reload. Templates that call
337
+ `asset()` therefore bust every URL at once, rather than file by file.
338
+ - **`Cache-Control: no-cache`** is set on static assets served from `public/`, so
339
+ the browser always revalidates.
340
+
341
+ In production, asset URLs are served as-is. Use `ASSET_VERSION` (surfaced as
342
+ `inertia.version`) to set the Inertia asset version — on a mismatch the client
343
+ triggers a full reload (HTTP 409), which is how you invalidate clients after a
344
+ deploy.
345
+
346
+ ### Inertia production build
347
+
348
+ ```bash
349
+ # in your project root
350
+ bun zt inertia:build
351
+ bun zt inertia:build --production # minified, no source maps
352
+ ```
353
+
354
+ The build:
355
+
356
+ 1. Regenerates `pages.generated.ts`
357
+ 2. Runs `Bun.build()` with `splitting: true` (plus minify / no source maps for
358
+ `--production`)
359
+ 3. Outputs all assets to `public/assets/`
360
+ 4. Deletes what the previous build left behind
361
+
362
+ Run it before `bun zt serve` in production (it is wired to the `build` script in
363
+ the scaffolded `package.json`).
364
+
365
+ #### Why the output directory needs sweeping
366
+
367
+ Code-splitting names each shared chunk after its content — `chunk-3f9a2c.js` —
368
+ so a rebuild that changes one page emits a new set of chunk names and abandons
369
+ the old ones. Nothing overwrites them. Left alone, a long dev session buries the
370
+ output directory in hundreds of dead chunks, all of them registered as static
371
+ routes at startup and all of them shipped in the next deploy.
372
+
373
+ Every build that splits therefore sweeps up after itself, in dev and in
374
+ production alike. It removes two things and nothing else: files the previous
375
+ build recorded as its own output, and files named the way the bundler names
376
+ chunks. Anything else in the directory is left untouched, which matters because
377
+ an output directory is often `public/`, where your images and favicon live
378
+ alongside the bundle. The record of what each build wrote is kept in
379
+ `.zerotal/build/`, outside the served directory.
380
+
381
+ A directory that has been collecting chunks since before this existed is cleaned
382
+ by the next build, since chunks are recognised by name and not only by the
383
+ record.
384
+
385
+ ## Controlling how files are loaded
386
+
387
+ `url()` references in your CSS are resolved at build time, and Bun inlines small
388
+ files as `data:` URIs. That is the right default for an icon and the wrong one for a
389
+ font: the bytes move **into the stylesheet**, which blocks first paint. Nine woff2
390
+ subsets can turn a 36 KB stylesheet into 260 KB that must download before anything
391
+ renders — the opposite of what `font-display: swap` is for, and expensive on exactly
392
+ the connections that need it most.
393
+
394
+ Set a per-extension loader to emit them as separate files instead:
395
+
396
+ ```typescript
397
+ // config/app.ts
398
+ export default AppConfig({
399
+ assets: {
400
+ entrypoint: "resources/css/app.css",
401
+ loader: { ".woff2": "file", ".woff": "file" },
402
+ },
403
+ });
404
+ ```
405
+
406
+ `file` copies the asset to `outDir` and rewrites the `url()` to point at it, so the
407
+ stylesheet stays small and the fonts load in parallel. The other accepted values are
408
+ `dataurl` (force inlining), `base64`, `text`, `json`, and `toml`.
409
+
410
+ ## Hot reload
411
+
412
+ Both pipelines above are driven by the same dev process. `bun zt serve --dev`
413
+ builds once, spawns the server as a child process, and watches the filesystem;
414
+ what happens on a change depends on which directory the change landed in:
415
+
416
+ ```text
417
+ # bun zt serve --dev process model
418
+ ┌──────────────────────────────────────────────────────┐
419
+ │ bun zt serve --dev │
420
+ │ │
421
+ │ Process 1: DevOrchestrator │
422
+ │ ├─ Initial build (CSS or JS+CSS) │
423
+ │ ├─ Spawn Process 2 (server) │
424
+ │ └─ Watch filesystem │
425
+ │ │ │
426
+ │ ├─ Backend change (app/, routes/, bootstrap/) │
427
+ │ │ └─ Rebuild, then kill + respawn (150 ms) │
428
+ │ │ │
429
+ │ └─ Frontend change (resources/css/, js/) │
430
+ │ └─ Rebuild assets (80 ms debounce) │
431
+ │ └─ Write "reload:<token>" to │
432
+ │ Process 2 stdin │
433
+ │ │
434
+ │ Process 2: Zerotal server (--dev-worker) │
435
+ │ ├─ Serves app routes │
436
+ │ ├─ Sends "version:<token>" on every WS connect │
437
+ │ ├─ Broadcasts "reload" to WS clients │
438
+ │ └─ WebSocket at /__dev/ws │
439
+ │ │ │
440
+ │ ▼ │
441
+ │ Browser (JS client) │
442
+ │ connects on load │
443
+ │ reloads on "reload" message │
444
+ │ reloads when the token changed │
445
+ │ reconnects on close (1 s delay) │
446
+ └──────────────────────────────────────────────────────┘
447
+ ```
448
+
449
+ ### What triggers a restart, and what only rebuilds
450
+
451
+ | Changed path | Action |
452
+ | ------------------------------------------ | -------------------------------------- |
453
+ | `app/`, `routes/`, `bootstrap/`, `config/` | Server restart (150 ms) |
454
+ | `resources/css/`, `resources/js/` | Asset rebuild + browser reload (80 ms) |
455
+ | `public/` | Ignored (output dir) |
456
+ | `node_modules/`, `.git/` | Ignored |
457
+
458
+ Inertia pages live under `resources/js/pages/`, so they're covered by the
459
+ `resources/js/` watch and rebuild rather than restart.
460
+
461
+ ### Browser client
462
+
463
+ Under `--dev-worker`, `DevReloadMiddleware` injects this script into every
464
+ `text/html` response, so live reload works for any view layer — Flow, View,
465
+ plain HTML — with no wiring on your part:
466
+
467
+ ```html
468
+ <!-- injected before </body> in --dev-worker mode -->
469
+ <script>
470
+ (function () {
471
+ var proto = location.protocol === "https:" ? "wss:" : "ws:";
472
+ var build = null;
473
+ function connect() {
474
+ var ws = new WebSocket(proto + "//" + location.host + "/__dev/ws");
475
+ ws.onmessage = function (e) {
476
+ var d = e.data;
477
+ if (d === "reload") {
478
+ location.reload();
479
+ return;
480
+ }
481
+ if (d.indexOf("version:") === 0) {
482
+ var v = d.slice(8);
483
+ if (!v) return;
484
+ if (build === null) build = v;
485
+ else if (build !== v) location.reload();
486
+ }
487
+ };
488
+ ws.onclose = function () {
489
+ setTimeout(connect, 1000);
490
+ };
491
+ }
492
+ connect();
493
+ })();
494
+ </script>
495
+ ```
496
+
497
+ The two signals cover two different situations. A frontend change pushes
498
+ `reload` down a socket that is already open. A backend change cannot: it
499
+ restarts the server, and the restart closes every socket, so a reload pushed at
500
+ that moment reaches nobody — the tab does not reconnect for another second. The
501
+ build token closes that gap. The server states its token whenever a socket
502
+ opens; the tab remembers the first one it sees and reloads when a reconnect
503
+ reports a different one. That is how a rebuild triggered by a backend edit still
504
+ reaches the browser.
505
+
506
+ Inertia is the one exception to where the script comes from: it bakes the same
507
+ client into its cached HTML template at boot rather than receiving it from the
508
+ middleware, because injecting into the response body means buffering it, and
509
+ `Inertia.stream()` exists precisely to avoid that. The middleware skips any page
510
+ that already carries a `/__dev/ws` client, so the two never collide.
511
+
512
+ ## Styling
513
+
514
+ Zerotal is unopinionated about CSS — the Tailwind pipeline above is the default, but
515
+ nothing forces it. For state-driven classes inside Flow components, see
516
+ [reactive classes and attributes](/docs/flow#reactive-classes-and-attributes); the
517
+ patterns below are the styling techniques you'll reach for most, whichever stack you
518
+ picked.
519
+
520
+ ### CSS Modules
521
+
522
+ Bun's bundler understands CSS Modules out of the box. Import a `.module.css` file and
523
+ use the generated class names — styles are scoped to the component, so names never
524
+ collide across files:
525
+
526
+ ```tsx
527
+ // app/components/Button.tsx
528
+ import styles from "./Button.module.css";
529
+
530
+ export function Button({ label }: { label: string }) {
531
+ return <button class={styles.button}>{label}</button>;
532
+ }
533
+ ```
534
+
535
+ ```css
536
+ /* app/components/Button.module.css */
537
+ .button {
538
+ padding: 0.5rem 1rem;
539
+ border-radius: 0.375rem;
540
+ background: var(--color-brand);
541
+ font-weight: 600;
542
+ }
543
+ ```
544
+
545
+ ### Component libraries
546
+
547
+ For ready-made, themeable UI, reach for **flow-ui** — Zerotal's first-party,
548
+ shadcn-style component set built on the same Tailwind tokens, so it follows your
549
+ theme automatically:
550
+
551
+ ```bash
552
+ # in your project root
553
+ bun zt flow:add button card dialog
554
+ ```
555
+
556
+ Browse the full catalogue in [Components](/docs/components). Any npm component library
557
+ also works with the Bun bundler — common choices:
558
+
559
+ | Library | Install |
560
+ | ----------- | ---------------------------------------- |
561
+ | shadcn/ui | `bunx shadcn@latest init` |
562
+ | Radix UI | `bun add @radix-ui/react-dialog` |
563
+ | Headless UI | `bun add @headlessui/react` |
564
+ | DaisyUI | `bun add -d daisyui` (a Tailwind plugin) |
565
+
566
+ ### Web fonts
567
+
568
+ Load fonts from a CDN for a quick start, or self-host them in `public/` for one fewer
569
+ network round-trip (and to keep requests on your own domain). Pair a self-hosted face
570
+ with `--font-sans` in your `@theme` block so Tailwind's `font-sans` utility picks it up:
571
+
572
+ ```html
573
+ <!-- in your HTML head — Google Fonts quick start -->
574
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
575
+ <link
576
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
577
+ rel="stylesheet"
578
+ />
579
+ ```
580
+
581
+ ```css
582
+ /* resources/css/app.css — self-hosted alternative */
583
+ @font-face {
584
+ font-family: "Inter";
585
+ src: url("/assets/fonts/Inter.woff2") format("woff2");
586
+ font-weight: 100 900;
587
+ font-display: swap;
588
+ }
589
+ ```
590
+
591
+ ## Testing
592
+
593
+ Set your suite up once as described in [Testing](/docs/testing). Assets are built
594
+ artifacts, so the useful tests are about what a request receives — not about the
595
+ bundler.
596
+
597
+ That boundary is worth holding. Tailwind emitting the right rules and Bun bundling
598
+ the right modules are already covered by those tools' own suites, and a test that
599
+ asserts on the _content_ of built CSS breaks every time a class is added. What is
600
+ genuinely yours to verify is the wiring: that the page points at the asset, that
601
+ the asset is served, and that a deploy invalidates the old one.
602
+
603
+ **Assert the page references the asset**, which catches a build config that
604
+ silently stopped emitting it:
605
+
606
+ ```typescript
607
+ // tests/http/assets.test.ts
608
+ import { test } from "bun:test";
609
+ import { createApp } from "../helpers.ts";
610
+
611
+ test("the layout links the built stylesheet", async () => {
612
+ const app = await createApp();
613
+
614
+ const res = await app.get("/");
615
+
616
+ res.assertSee("/assets/app.css");
617
+ await app.close();
618
+ });
619
+ ```
620
+
621
+ **Assert it is actually served**, because a reference to a missing file is a
622
+ `404` the page will not tell you about:
623
+
624
+ ```typescript
625
+ // tests/http/assets.test.ts
626
+ const css = await app.get("/assets/app.css");
627
+
628
+ css.assertOk();
629
+ css.assertHeader("Content-Type", "text/css");
630
+ ```
631
+
632
+ These two belong together. Each passes on its own while the pair is broken — a
633
+ page can link a path nothing serves, and a served file can go unreferenced — so
634
+ testing only one leaves the failure that actually reaches users uncovered.
635
+
636
+ **Cache-busting is worth one test** if you rely on it for deploys. In dev the URL
637
+ carries a `?v=` token that changes on rebuild; in production `ASSET_VERSION`
638
+ drives the Inertia version header:
639
+
640
+ ```typescript
641
+ // tests/http/assets.test.ts
642
+ test("asset URLs carry a version in dev", async () => {
643
+ const res = await app.get("/");
644
+
645
+ expect(res.text()).toMatch(/\/assets\/app\.css\?v=/);
646
+ });
647
+ ```
648
+
649
+ > **Warning** — These tests need a build to have run. A suite in CI that never
650
+ > runs `bun zt css:build` will fail them for a reason unrelated to the code —
651
+ > either build in the pipeline before testing, or scope these to a suite you run
652
+ > after building.
653
+
654
+ The cleanest arrangement is to build once as a CI step before the test job, the way
655
+ you would before starting the server. Failing that, keep asset tests in their own
656
+ file and run it as a separate command after the build, so a missing artifact fails
657
+ one obvious job rather than scattering unrelated failures through the whole suite.
658
+
659
+ ### Unit-testing the asset helper
660
+
661
+ `asset()` is a pure function of the configured prefix, so its behaviour can be
662
+ pinned down without a request at all. This is the cheapest place to catch a
663
+ misconfigured CDN prefix:
664
+
665
+ ```typescript
666
+ // tests/unit/asset.test.ts
667
+ import { asset } from "zerotal/assets";
668
+
669
+ test("asset() resolves against the configured prefix", () => {
670
+ expect(asset("app.css")).toBe("/css/app.css");
671
+ });
672
+ ```
673
+
674
+ ### Inertia stale-bundle reloads
675
+
676
+ Inertia apps carry an asset version on every page object, and a request whose
677
+ `X-Inertia-Version` no longer matches the server's is answered with a `409` and an
678
+ `X-Inertia-Location` header, which is what makes a browser holding yesterday's
679
+ bundle do a full reload after a deploy. That flow only fires when the version
680
+ actually changes between builds, so it is worth one test:
681
+
682
+ ```typescript
683
+ // tests/http/assets.test.ts
684
+ test("a stale bundle is told to reload", async () => {
685
+ const res = await app.get("/dashboard", {
686
+ "X-Inertia": "true",
687
+ "X-Inertia-Version": "stale-version",
688
+ });
689
+
690
+ res.assertStatus(409);
691
+ res.assertHeader("X-Inertia-Location");
692
+ });
693
+ ```
694
+
695
+ ## References
696
+
697
+ ### Commands
698
+
699
+ | Command | Effect |
700
+ | ----------------------------------- | ----------------------------------------------- |
701
+ | `bun zt serve` | Serve the app; assets are built once at boot |
702
+ | `bun zt serve --dev` | Dev server with hot reload |
703
+ | `bun zt css:build` | Production CSS build (Flow/View) |
704
+ | `bun zt inertia:build` | Development JS+CSS build (Inertia) |
705
+ | `bun zt inertia:build --production` | Production JS+CSS build (Inertia) |
706
+ | `bun zt route:list` | List all routes (including static asset routes) |
707
+
708
+ Which build command belongs to your app follows from the view layer, not from
709
+ preference: `css:build` covers Flow and View, which ship server-rendered HTML
710
+ and need only a stylesheet; `inertia:build` covers Inertia, which additionally
711
+ bundles page components into JavaScript. Running the wrong one leaves the app
712
+ without the output it looks for at boot.
713
+
714
+ ### Files
715
+
716
+ | File | Purpose |
717
+ | --------------------------------- | --------------------------------- |
718
+ | `resources/css/app.css` | Tailwind entry (all stacks) |
719
+ | `resources/app.html` | HTML shell (Inertia only) |
720
+ | `resources/js/app.tsx` | JS entry (Inertia only) |
721
+ | `resources/js/pages/` | Inertia page components (default) |
722
+ | `resources/js/pages.generated.ts` | Auto-generated page registry |
723
+ | `public/css/app.css` | Built CSS output (Flow/View) |
724
+ | `public/assets/app.js` | Built JS output (Inertia) |
725
+ | `public/assets/app.css` | Built CSS output (Inertia) |
726
+
727
+ Everything under `resources/` is source you edit; everything under `public/` is
728
+ build output. `public/` is regenerated, so nothing hand-written should live there
729
+ and the whole directory is safe to delete and rebuild.
730
+
731
+ ### Configuration
732
+
733
+ | Key | Controls |
734
+ | ------------------- | ------------------------------------------------------ |
735
+ | `app.assets.prefix` | URL prefix `asset()` resolves against — point at a CDN |
736
+ | `ASSET_VERSION` | Inertia asset version; a mismatch forces a full reload |
737
+
738
+ ```typescript
739
+ import { asset } from "zerotal/assets";
740
+
741
+ asset("app.css"); // → "/css/app.css"
742
+ ```
743
+
744
+ See [the asset helper](#the-asset-helper) for what it does in dev versus
745
+ production, and [what triggers a restart](#what-triggers-a-restart-and-what-only-rebuilds)
746
+ for the watcher's rules.
747
+
748
+ ## Next steps
749
+
750
+ - [Flow](/docs/flow) — the zero-config server-rendered SPA stack.
751
+ - [Inertia](/docs/inertia) — controllers, pages, shared props, and SSR.
752
+ - [Middleware & versioning](/docs/inertia/middleware) — the asset-version flow in full.
753
+ - [Components](/docs/components) — themeable flow-ui components for your UI.
754
+ - [View](/docs/view) — classic SSR with JSX controllers.
755
+ - [Deployment](/docs/deployment) — run the production asset build before serving.