@lovrozagar/flare 0.1.0 → 0.1.1

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 (2) hide show
  1. package/README.md +1200 -2
  2. package/package.json +27 -2
package/README.md CHANGED
@@ -1,11 +1,1209 @@
1
1
  # Flare
2
2
 
3
- Solid meta-framework. Server-driven, NDJSON streaming, `renderToStream`.
3
+ Solid + Vite meta-framework. You declare pages and layouts with a typed builder. The server streams HTML (`renderToStream`). Later navigations speak NDJSON (`x-d: 1`). Prefetch, ISR, deferred loaders, and forms reuse that protocol.
4
+
5
+ This repo is the source of [`@lovrozagar/flare`](https://www.npmjs.com/package/@lovrozagar/flare) `0.1.1`. The CLI binary is `flare`.
6
+
7
+ If you are an agent: read this file end to end. It is the usage contract. Import only from the paths in [Package exports](#package-exports). Do not invent a kitchen-sink `flare` barrel for UI.
8
+
9
+ ## Table of contents
10
+
11
+ - [Start](#start)
12
+ - [What Flare is](#what-flare-is)
13
+ - [Install](#install)
14
+ - [First app](#first-app)
15
+ - [App anatomy](#app-anatomy)
16
+ - [CLI](#cli)
17
+ - [Routes](#routes)
18
+ - [Route builder](#route-builder)
19
+ - [Params, search, and input](#params-search-and-input)
20
+ - [Layouts, outlet, path segments](#layouts-outlet-path-segments)
21
+ - [Loaders and streaming](#loaders-and-streaming)
22
+ - [Hooks](#hooks)
23
+ - [Auth](#auth)
24
+ - [Errors and redirects](#errors-and-redirects)
25
+ - [Cache](#cache)
26
+ - [Head](#head)
27
+ - [Headers and response routes](#headers-and-response-routes)
28
+ - [Navigation](#navigation)
29
+ - [Rewrite](#rewrite)
30
+ - [Intercept](#intercept)
31
+ - [Forms and server functions](#forms-and-server-functions)
32
+ - [Env split functions](#env-split-functions)
33
+ - [Styles](#styles)
34
+ - [Fonts and images](#fonts-and-images)
35
+ - [i18n, theme, direction](#i18n-theme-direction)
36
+ - [Middleware](#middleware)
37
+ - [Mount](#mount)
38
+ - [Security](#security)
39
+ - [Store and revalidation](#store-and-revalidation)
40
+ - [Query](#query)
41
+ - [Lazy](#lazy)
42
+ - [Service worker](#service-worker)
43
+ - [Sitemap and search engines](#sitemap-and-search-engines)
44
+ - [Tracing](#tracing)
45
+ - [Testing](#testing)
46
+ - [NDJSON protocol](#ndjson-protocol)
47
+ - [Duration strings](#duration-strings)
48
+ - [Plugin](#plugin)
49
+ - [Package exports](#package-exports)
50
+ - [Repository layout](#repository-layout)
51
+ - [Develop](#develop)
52
+ - [License](#license)
53
+
54
+ ## Start
55
+
56
+ ```bash
57
+ bun add @lovrozagar/flare
58
+ flare init
59
+ bun run dev
60
+ ```
61
+
62
+ ```ts
63
+ /* src/routes/_root_.tsx */
64
+ import { createRootLayout } from "@lovrozagar/flare/root-layout";
65
+ import { ResetCSS } from "@lovrozagar/flare/reset-css";
66
+
67
+ export const route = createRootLayout("_root_").render((props) => (
68
+ <html lang="en">
69
+ <head>
70
+ <meta charset="utf-8" />
71
+ <ResetCSS />
72
+ </head>
73
+ <body>{props.children}</body>
74
+ </html>
75
+ ));
76
+ ```
4
77
 
5
78
  ```ts
79
+ /* src/routes/index.tsx */
6
80
  import { createPage } from "@lovrozagar/flare/page";
81
+ import { Link } from "@lovrozagar/flare/link";
82
+
83
+ export const route = createPage("_root_/")
84
+ .loader(() => ({ message: "Hello from Flare" }))
85
+ .head(() => ({ title: "Home" }))
86
+ .render((props) => (
87
+ <main>
88
+ <h1>{props.loaderData.message}</h1>
89
+ <Link to="/about">About</Link>
90
+ </main>
91
+ ));
92
+ ```
93
+
94
+ ```bash
95
+ curl http://127.0.0.1:5173/
96
+ # streamed HTML document + FlareState for hydration
97
+ ```
98
+
99
+ `flare init` writes `src/client.tsx`, `src/server.ts`, `src/router.ts`, a root layout, and `vite.config.ts` with `flare()`. `flare generate` (or the Vite plugin on boot/watch) writes `src/_gen/routes.gen.ts` and `src/_gen/types.gen.d.ts`.
100
+
101
+ The same app runs on Node, Bun, Deno, and Cloudflare Workers. Vite is the bundler. Solid is the renderer.
102
+
103
+ ## What Flare is
104
+
105
+ - **Builder DX.** `createPage("_root_/about").loader(...).head(...).render(...)`. Types flow from the generated route tree into `Link`, `navigate`, and loader `ctx`.
106
+ - **Server-driven.** Loaders, auth, cache, and head run on the server. The client hydrates, then fetches NDJSON for SPA navigations.
107
+ - **NDJSON only.** Data requests are line-delimited JSON (`t:"l"` loader, `t:"c"` deferred chunk, `t:"h"` head, `t:"r"` redirect). There is no parallel JSON-RPC surface.
108
+ - **Streaming first.** `ctx.defer()` + `<Await>` stream after the shell. HTML never waits for every promise.
109
+ - **One plugin.** `flare()` from `@lovrozagar/flare/plugins` is Vite: codegen, `sx` / `class=` compile, images, server functions, prerender, service worker, dev dashboard.
110
+ - **Web Standards.** Handlers see `Request`. Responses are `Response`. Workers get `waitUntil` via `serverContext` / `background()`.
111
+
112
+ Flare is not Next with Solid bolted on. There is no `getServerSideProps` object. A route is one file and one chain.
113
+
114
+ ## Install
115
+
116
+ Consumers need Vite 8 and Solid 1.9. This repo develops on [Bun](https://bun.sh) 1.3+.
117
+
118
+ ```bash
119
+ bun add @lovrozagar/flare
120
+ # or
121
+ npm add @lovrozagar/flare
122
+ # or
123
+ pnpm add @lovrozagar/flare
124
+ ```
125
+
126
+ Peers: `solid-js`, `vite`, `vite-plugin-solid`. Optional: `sharp` (images), `isbot` (skip locale cookies for bots), `@tanstack/solid-query` (query), `@tanstack/query-broadcast-client-experimental` (query broadcast), `oxc-parser`, `oxc-resolver`.
127
+
128
+ The CLI is a workspace package (`@flare/cli`, binary `flare`). In this repo it is on the path via workspace linking. A published consumer uses `flare init` after adding the package.
129
+
130
+ ## First app
131
+
132
+ ```bash
133
+ mkdir my-app && cd my-app
134
+ bun init -y
135
+ bun add @lovrozagar/flare solid-js vite vite-plugin-solid
136
+ flare init
137
+ bun run dev
138
+ ```
139
+
140
+ | File | Role |
141
+ | ------------------------- | ----------------------------------------------------------- |
142
+ | `src/client.tsx` | `createClient(() => router)` |
143
+ | `src/server.ts` | `createServer(router)` — Vite SSR / Workers `fetch` |
144
+ | `src/router.ts` | `createRouter({ layouts, routeTree })` |
145
+ | `src/routes/_root_.tsx` | Root HTML document |
146
+ | `src/routes/index.tsx` | Home page |
147
+ | `vite.config.ts` | `flare()` plugin |
148
+ | `src/_gen/routes.gen.ts` | Generated tree + lazy imports (do not hand-edit) |
149
+ | `src/_gen/types.gen.d.ts` | `/// <reference types="@lovrozagar/flare/virtual-types" />` |
150
+
151
+ Scripts: `dev` (`vite dev`), `build`, `preview`, `generate` (`flare generate`).
152
+
153
+ ```bash
154
+ flare init --template saas
155
+ flare init --template blog
156
+ flare init --template marketing
157
+ ```
158
+
159
+ Flags: `--auth cookie|jwt|none`, `--cache isr|ssg|ssr|mixed`, `--style tailwind|css-modules|none`, `--locale en,hr`. Existing files refuse overwrite unless you pass `--force`.
160
+
161
+ ## App anatomy
162
+
163
+ ### Client
164
+
165
+ ```ts
166
+ import { createClient } from "@lovrozagar/flare/client";
167
+ import { router } from "./router";
168
+
169
+ createClient(() => router)
170
+ .onReady((ctx) => {
171
+ /* ctx.navigate, ctx.invalidate, ctx.navigationPhase */
172
+ })
173
+ .onHydrated(() => {})
174
+ .onIdle(() => {})
175
+ .onInteraction(() => {});
176
+ ```
177
+
178
+ `createClient` hydrates on the next microtask. Or call `hydrate(router)` from `@lovrozagar/flare/hydrate` yourself.
179
+
180
+ ### Router
181
+
182
+ ```ts
7
183
  import { createRouter } from "@lovrozagar/flare/router";
184
+ import { layouts, routeTree } from "./_gen/routes.gen";
185
+
186
+ export const router = createRouter({
187
+ layouts,
188
+ routeTree,
189
+ basePath: undefined,
190
+ caseSensitive: false,
191
+ notFoundMode: "fuzzy",
192
+ trailingSlash: "preserve",
193
+ scrollRestoration: true,
194
+ viewTransitions: true,
195
+ cache: { client: { prefetch: "intent", staleTime: 30_000 } },
196
+ locale: { defaultLocale: "en", locales: ["en", "hr"], paramName: "locale" },
197
+ theme: { defaultTheme: "system" },
198
+ direction: { defaultDir: "ltr" },
199
+ });
200
+ ```
201
+
202
+ | Option | Meaning |
203
+ | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
204
+ | `layouts` / `routeTree` | From `src/_gen/routes.gen.ts` |
205
+ | `basePath` | App mounted under `/app` |
206
+ | `caseSensitive` | URL matching |
207
+ | `notFoundMode` | `"fuzzy"` hydrate nearest match; `"root"` use `_root_/` |
208
+ | `trailingSlash` | `"always"` \| `"never"` \| `"preserve"` |
209
+ | `scrollRestoration` / `getScrollRestorationKey` / `scrollRestorationBehavior` / `scrollRestorationMaxEntries` | History scroll |
210
+ | `routeCacheMaxEntries` | Client match cache size |
211
+ | `rewrite` | Vanity URLs — see [Rewrite](#rewrite) |
212
+ | `queryClientGetter` | TanStack — see [Query](#query) |
213
+
214
+ ### Server
215
+
216
+ ```ts
217
+ import { createServer } from "@lovrozagar/flare/server";
218
+ import { router } from "./router";
219
+
220
+ export const server = createServer(router)
221
+ .authenticateFn(async ({ request }) => {
222
+ const session = request.headers.get("cookie");
223
+ return session ? { id: "user-1" } : null;
224
+ })
225
+ .serverContext(({ request }) => ({ requestId: request.headers.get("x-request-id") ?? "" }))
226
+ .security({ "X-Frame-Options": "DENY" })
227
+ .cache({ store })
228
+ .keepalive({ interval: 30_000 })
229
+ .use("/api/*", apiProxy({ target: "https://api.example.com" }))
230
+ .mount("/rpc", (request) => new Response("ok"))
231
+ .sitemap({ origin: "https://example.com" })
232
+ .tracing({ timing: true });
233
+
234
+ export default {
235
+ fetch(request: Request, env?: unknown, ctx?: { waitUntil?: (p: Promise<unknown>) => void }) {
236
+ return server.fetch(request, env, ctx);
237
+ },
238
+ };
239
+ ```
240
+
241
+ `server.fetch` is the Vite SSR handler and the Workers `fetch` export. `getStaticParams()` feeds prerender. `background(promise)` from `@lovrozagar/flare/server-context` binds `waitUntil` so ISR work cannot 500 a finished render.
242
+
243
+ ## CLI
244
+
245
+ ```
246
+ flare init [--template saas|blog|marketing] [--auth] [--cache] [--style] [--force]
247
+ flare generate | flare gen [--watch]
248
+ flare add auth|cache|loader|head|input|error-boundary <paths...>
249
+ flare font add|list|info|remove
250
+ flare routes
251
+ flare status
252
+ flare validate
253
+ ```
254
+
255
+ `plan`, `remove`, `rename`, `setup-ai` are reserved and not implemented.
256
+
257
+ ### `flare generate` / `flare gen`
258
+
259
+ Scans `src/routes/` and writes `src/_gen/`. The Vite plugin also runs this on boot and on watch. `--watch` regenerates when route files change.
260
+
261
+ `codegen.fsVirtualPaths` (plugin default `true`) requires suffix files. Set `fsVirtualPaths: false` to keep handwritten `createPage("_root_/about")` strings (the product e2e app does this).
262
+
263
+ ### `flare add`
264
+
265
+ Patches an existing route chain: `auth`, `cache [--isr N] [--ssg]`, `loader`, `head`, `input`, `error-boundary`.
266
+
267
+ ### `flare font`
268
+
269
+ ```bash
270
+ flare font add --name Inter
271
+ flare font list
272
+ flare font info Inter
273
+ flare font remove Inter
274
+ ```
275
+
276
+ Writes `public/fonts/` and prints `import { inter } from "@lovrozagar/flare/fonts/inter"`.
277
+
278
+ ### `flare routes` / `status` / `validate`
279
+
280
+ - `routes` — print the generated tree.
281
+ - `status` — project health (flare dep, `_gen`, Vite config).
282
+ - `validate` — chain / file conventions.
283
+
284
+ ## Routes
285
+
286
+ ### String virtual paths
287
+
288
+ ```ts
289
+ import { createPage } from "@lovrozagar/flare/page";
290
+
291
+ export const route = createPage("_root_/blog/[slug]")
292
+ .loader((ctx) => ({ slug: ctx.location.params.slug }))
293
+ .render((props) => <h1>{props.loaderData.slug}</h1>);
294
+ ```
295
+
296
+ The string is the virtual path. `_root_` is the root layout scope. Groups like `_root_/(blog)/blog` share a layout without a URL segment.
297
+
298
+ ### Filesystem virtual paths
299
+
300
+ With `codegen: { fsVirtualPaths: true }` (plugin default):
301
+
302
+ ```
303
+ src/routes/_root_/about/about.page.tsx → createPage("_root_/about")
304
+ src/routes/_root_/(blog)/blog.layout.tsx → createLayout("_root_/(blog)")
305
+ src/routes/_root_/users/[id]/user.page.tsx → createPage("_root_/users/[id]")
306
+ src/routes/_root_/files/[...path]/files.page.tsx
307
+ src/routes/_root_/optional-locale/[[locale]]/opt.page.tsx
308
+ src/routes/_admin_/dashboard/dash.page.tsx → createPage("_admin_/dashboard")
309
+ ```
310
+
311
+ Folders starting with `_` other than `_name_` root scopes are ignored (`_utils`). `[_]internal` is a literal `_internal` URL segment. `ignorePrefix` on the plugin skips extra names.
312
+
313
+ ### Multiple roots
314
+
315
+ `_root_` and `_admin_` are separate HTML documents. Crossing from one to the other is a full load, not SPA.
316
+
317
+ ## Route builder
318
+
319
+ Chain order (typical): `intercept` → `cache` → `authenticate` → `input` → `effects` → `authorize` → `preloader` → `loader` → `head` / `headers` → `render` or `response` → error slots.
320
+
321
+ | Method | Role |
322
+ | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
323
+ | `.cache(config)` | Client / SSR / ISR / SSG / CDN |
324
+ | `.authenticate()` / `.authenticate("optional")` / `.authenticate(false)` | Auth gate |
325
+ | `.authorize(fn)` | Role check; inherits parent unless overridden |
326
+ | `.input({ params, searchParams })` | Standard Schema or parse fn |
327
+ | `.effects({ loaderDeps, shouldRefetch })` | When to rerun the loader on search change |
328
+ | `.preloader(fn)` | Runs parent → child before loaders; result is `preloaderContext` |
329
+ | `.loader(fn)` | Server data |
330
+ | `.head(fn)` / `.head(fn, { replace: true })` | Title, meta, JSON-LD |
331
+ | `.headers(fn)` | Extra response headers |
332
+ | `.render(fn)` | Solid UI |
333
+ | `.response(fn)` | Raw `Response` (sitemap.xml, robots) — no HTML |
334
+ | `.intercept({ from, render })` | Modal / drawer over another route |
335
+ | `.redirect({ to, status })` | Static redirect route |
336
+ | `.middleware(...)` | Per-route middleware |
337
+ | `.errorRender()` / `.notFoundRender()` / `.unauthenticatedRender()` / `.unauthorizedRender()` | Boundaries |
338
+
339
+ `createLayout` and `createRootLayout` share the same idea. Root layouts own `<html>`. `createPathSegment` is URL-only (no UI) — used for `[[locale]]`.
340
+
341
+ Loader / preloader / authorize `ctx` always has: `request`, `location`, `auth`, `env`, `locale()`, `serverContext`, `abortController`, plus throw helpers (`notFound`, `redirect`, `unauthenticated`, `unauthorized`) and URL helpers. Loader `ctx` also has `defer`, `cause` (`"enter"` \| `"prefetch"` \| `"stay"`), `prefetch`, `deps`.
342
+
343
+ ## Params, search, and input
344
+
345
+ | Pattern | Type |
346
+ | ------------- | ----------------------- |
347
+ | `[id]` | `string` |
348
+ | `[...path]` | `string[]` |
349
+ | `[[locale]]` | `string \| undefined` |
350
+ | `[[...rest]]` | `string[] \| undefined` |
351
+
352
+ ```ts
353
+ import { z } from "zod";
354
+
355
+ export const route = createPage("_root_/users/[id]")
356
+ .input({
357
+ params: z.object({ id: z.string().min(1) }),
358
+ searchParams: z.object({ tab: z.string().optional() }),
359
+ })
360
+ .loader((ctx) => ({ id: ctx.location.params.id, tab: ctx.location.search.tab }))
361
+ .render((props) => <p>{props.loaderData.id}</p>);
362
+ ```
363
+
364
+ Validators: Zod, Valibot, ArkType, TypeBox, Yup, Effect, Superstruct, or `{ parse }` / a function. Invalid params are 400/500 depending on the schema path. `Link` and `navigate({ to, params, search })` are typed from `src/_gen/types.gen.d.ts`.
365
+
366
+ ## Layouts, outlet, path segments
367
+
368
+ ```ts
369
+ import { createLayout } from "@lovrozagar/flare/layout";
370
+ import { Outlet } from "@lovrozagar/flare/outlet";
371
+
372
+ export const route = createLayout("_root_/(blog)")
373
+ .loader(() => ({ section: "Blog" }))
374
+ .render((props) => (
375
+ <div>
376
+ <nav>{props.loaderData?.section}</nav>
377
+ {props.children}
378
+ {/* or <Outlet /> */}
379
+ </div>
380
+ ));
381
+ ```
382
+
383
+ ```ts
384
+ import { createPathSegment } from "@lovrozagar/flare/path-segment";
385
+
386
+ export const route = createPathSegment("[[locale]]");
387
+ ```
388
+
389
+ Child errors bubble to the nearest layout/root that defines `.errorRender()` / `.notFoundRender()` / `.unauthenticatedRender()` / `.unauthorizedRender()`.
390
+
391
+ ```ts
392
+ .errorRender((props) => (
393
+ <main>
394
+ <p>{props.error.message}</p>
395
+ <button type="button" onClick={() => props.retry()}>
396
+ Retry
397
+ </button>
398
+ </main>
399
+ ))
400
+ ```
401
+
402
+ ## Loaders and streaming
403
+
404
+ ```ts
405
+ import { Await } from "@lovrozagar/flare/await";
406
+ import { createPage } from "@lovrozagar/flare/page";
407
+
408
+ export const route = createPage("_root_/blog/[slug]")
409
+ .preloader((ctx) => ({ locale: ctx.locale() }))
410
+ .loader((ctx) => {
411
+ const slug = ctx.location.params.slug;
412
+ const comments = ctx.defer(async () => db.comments(slug), { key: "comments" });
413
+ return { slug, title: `Post: ${slug}`, comments };
414
+ })
415
+ .render((props) => (
416
+ <main>
417
+ <h1>{props.loaderData.title}</h1>
418
+ <Await pending={<p>Loading…</p>} promise={props.loaderData.comments}>
419
+ {(comments) => <ul>{/* … */}</ul>}
420
+ </Await>
421
+ </main>
422
+ ));
423
+ ```
424
+
425
+ Pipeline: authenticate → authorize → preloaders (parent → child) → loaders. `ctx.defer(fn, { key, prerender: "resolve" | "stream" })` marks a field for NDJSON `t:"c"` chunks. Prefetch skips deferred execution (shell only). History restore of a deferred match refetches so `<Await>` gets a live promise.
426
+
427
+ `<Await>` also accepts `error` and `onError`. Missing deferred data must not throw in `.then` — the component treats a missing promise as pending.
428
+
429
+ ## Hooks
430
+
431
+ From `@lovrozagar/flare` (route-builder barrel) / the same names on the provider:
432
+
433
+ ```ts
434
+ import {
435
+ useBlocker,
436
+ useLoaderData,
437
+ useLoaderT,
438
+ useLocation,
439
+ useMatch,
440
+ useNavigate,
441
+ useParams,
442
+ usePreloaderContext,
443
+ useSearch,
444
+ } from "@lovrozagar/flare";
445
+
446
+ const data = useLoaderData({ from: "_root_/about" });
447
+ const params = useParams({ from: "_root_/users/[id]" });
448
+ const search = useSearch({ from: "_root_/users/[id]" });
449
+ const navigate = useNavigate();
450
+ const blocker = useBlocker(() => dirty());
451
+ ```
452
+
453
+ `from` is a virtual path, not a URL. Accessors are Solid signals.
454
+
455
+ ## Auth
456
+
457
+ ```ts
458
+ export const route = createPage("_root_/dashboard")
459
+ .authenticate()
460
+ .authorize(({ user }) => user.role === "admin")
461
+ .render(() => <h1>Dashboard</h1>);
462
+ ```
463
+
464
+ - `.authenticate()` — required; null user → `UnauthenticatedError` (401).
465
+ - `.authenticate("optional")` — user may be null.
466
+ - `.authenticate(false)` — skip even if a parent required it.
467
+ - Child inherits parent auth unless it sets its own mode.
468
+ - `createServer(router).authenticateFn(fn)` is the app-wide hook (cookie, header, JWT).
469
+
470
+ Throw helpers: `ctx.notFound()`, `ctx.redirect({ to, params, search, status, replace })`, `ctx.unauthenticated()`, `ctx.unauthorized()`.
471
+
472
+ ## Errors and redirects
473
+
474
+ ```ts
475
+ import {
476
+ NotFoundError,
477
+ RedirectResponse,
478
+ UnauthenticatedError,
479
+ UnauthorizedError,
480
+ isNotFoundError,
481
+ isRedirectResponse,
482
+ } from "@lovrozagar/flare/errors";
483
+ ```
484
+
485
+ Default redirect status is **303**. `.redirect({ to, status: 307 })` overrides. External: `{ href: "https://example.com" }`. `javascript:` is rejected.
486
+
487
+ ## Cache
488
+
489
+ ### Client
490
+
491
+ ```ts
492
+ createRouter({
493
+ cache: { client: { prefetch: "intent", staleTime: 30_000, gcTime: "5m" } },
494
+ });
495
+
496
+ export const route = createPage("_root_/about").cache({
497
+ client: { staleTime: "10s", prefetch: "viewport", cacheDeferred: true },
498
+ });
499
+ ```
500
+
501
+ `prefetch`: `false` | `"intent"` | `"viewport"` | `"render"`. `staleTime` / `gcTime` / `prefetchStaleTime` accept ms or [duration strings](#duration-strings). `hasDeferred` cache entries are treated stale so popstate does not replay a dead marker. `client: false` turns client cache off for that route.
502
+
503
+ ### SSR store / ISR / SSG
504
+
505
+ `ssr`, `isr`, and `ssg` are mutually exclusive.
506
+
507
+ ```ts
508
+ .cache({
509
+ ssr: { staleTime: 5_000, ttl: 60, tags: ["posts"], key: ({ params }) => params.slug },
510
+ })
511
+
512
+ .cache({ ssg: true })
513
+
514
+ .cache({
515
+ ssg: {
516
+ params: () => [{ slug: "hello" }],
517
+ defer: "resolve",
518
+ },
519
+ })
520
+
521
+ .cache({
522
+ isr: {
523
+ revalidate: 60,
524
+ params: () => [{ slug: "hello" }],
525
+ dynamicParams: true,
526
+ },
527
+ })
528
+ ```
529
+
530
+ - **SSR cache** — store-backed HTML/data. Needs a `FlareStore` on `createServer(...).cache({ store })`.
531
+ - **SSG** — built at `vite build` when `flare({ prerender: true })`.
532
+ - **ISR** — `{ revalidate }` time-based, or omit `revalidate` for tag-only. `isr: true` is on-demand only. `dynamicParams: false` 404s unlisted params.
533
+ - Tag purge: [Store and revalidation](#store-and-revalidation).
534
+
535
+ HTML that embeds a per-request CSP nonce is never `304`. A 304 would reuse the old body (old nonce) against a new CSP and block inline scripts. ETag still lands on the 200.
536
+
537
+ ### CDN
538
+
539
+ ```ts
540
+ .cache({
541
+ cdn: { maxAge: 60, swr: 300, tags: ["posts"], vary: ["Accept-Language"], private: false },
542
+ })
543
+ ```
544
+
545
+ Sets `Cache-Control` / `CDN-Cache-Control`. Dev can emulate a CDN disk cache (`dev.cdnCache`, default on). Product e2e turns it off so HTML stays fresh.
546
+
547
+ ## Head
548
+
549
+ ```ts
550
+ .head((ctx) => ({
551
+ title: `About — ${ctx.loaderData.year}`,
552
+ description: "…",
553
+ canonical: "https://example.com/about",
554
+ robots: { index: true, follow: true },
555
+ openGraph: { title: "…", images: [{ url: "…", width: 1200, height: 630 }] },
556
+ twitter: { card: "summary_large_image", title: "…" },
557
+ jsonLd: [{ "@type": "WebPage", name: "About" }],
558
+ icons: { ico: "/favicon.ico", svg: "/icon.svg" },
559
+ css: "/page.css",
560
+ }))
561
+ ```
562
+
563
+ `ctx.parentHead` is the merged parent. Child titles win. `.head(fn, { replace: true })` drops inherited description/keywords. SPA navigation applies per-route heads and removes stale meta / JSON-LD.
564
+
565
+ ## Headers and response routes
566
+
567
+ ```ts
568
+ .headers((ctx) => ({
569
+ "Cache-Control": "private",
570
+ "X-From": ctx.loaderData.id,
571
+ }))
572
+ ```
573
+
574
+ ```ts
575
+ /* src/routes/_root_/sitemap.xml/sitemap.page.tsx */
576
+ export const route = createPage("_root_/sitemap.xml").response(() => {
577
+ return new Response("<urlset>…</urlset>", {
578
+ headers: { "content-type": "application/xml" },
579
+ });
580
+ });
581
+ ```
582
+
583
+ `.response()` skips HTML / loaders / head. Use it for `sitemap.xml`, `robots.txt`, feeds.
584
+
585
+ ## Navigation
586
+
587
+ ```tsx
588
+ import { Link } from "@lovrozagar/flare/link";
589
+ import { navigate } from "@lovrozagar/flare";
590
+
591
+ <Link to="/blog/[slug]" params={{ slug: "hello-world" }} prefetch="intent">
592
+ Post
593
+ </Link>
594
+
595
+ <Link href="https://example.com" target="_blank">
596
+ External
597
+ </Link>
598
+
599
+ <Link to="/about" replace hash="section" disabled />
600
+
601
+ <button
602
+ type="button"
603
+ onClick={() => navigate({ to: "/users/[id]", params: { id: "1" }, search: { tab: "bio" } })}
604
+ >
605
+ Go
606
+ </button>
607
+ ```
608
+
609
+ - Internal `to` is typed. External `href` is not rewritten.
610
+ - `prefetch={false}` disables. Default comes from router / route cache.
611
+ - `activeClass` / `inactiveClass` / `activeProps` / `inactiveProps` / `aria-current`.
612
+ - `createRouter({ viewTransitions: true })` wraps updates in `document.startViewTransition` (Chromium). Put `<ViewTransitionCSS />` from `@lovrozagar/flare/view-transition-css` in the root head.
613
+ - `useBlocker(() => dirty())` — first-class leave guard.
614
+ - Optional chrome: `<NavigationProgress />` from `@lovrozagar/flare/navigation-progress`.
615
+
616
+ `ctx.invalidate()` / `router.invalidate()` refetches current matches.
617
+
618
+ ## Rewrite
619
+
620
+ Vanity URLs without changing the matched virtual path.
621
+
622
+ ```ts
623
+ import type { LocationRewrite } from "@lovrozagar/flare/rewrite";
624
+
625
+ const rewrite: LocationRewrite = {
626
+ input: ({ url }) => {
627
+ if (url.pathname === "/vanity") {
628
+ const next = new URL(url);
629
+ next.pathname = "/about";
630
+ return next;
631
+ }
632
+ return undefined;
633
+ },
634
+ output: ({ url }) => {
635
+ if (url.pathname === "/about") {
636
+ const next = new URL(url);
637
+ next.pathname = "/vanity";
638
+ return next;
639
+ }
640
+ return undefined;
641
+ },
642
+ };
643
+
644
+ createRouter({ rewrite, layouts, routeTree });
645
+ ```
646
+
647
+ `input` maps the request URL to the real route. `output` maps generated links / redirects back to the vanity URL. Return `undefined` to leave the URL alone.
648
+
649
+ ## Intercept
650
+
651
+ Modal / drawer that keeps the background route mounted.
652
+
653
+ ```ts
654
+ createPage("_root_/photo/[id]").intercept({
655
+ from: ["_root_/gallery"],
656
+ render: "_root_/photo/[id]",
657
+ });
658
+ ```
659
+
660
+ ```tsx
661
+ import { InterceptOutlet } from "@lovrozagar/flare/intercept-outlet";
662
+
663
+ <InterceptOutlet />;
664
+ ```
665
+
666
+ `from` is the background virtual path(s). Closing the intercept returns to that route.
667
+
668
+ ## Forms and server functions
669
+
670
+ ```ts
671
+ import { z } from "zod";
672
+ import { createServerFn } from "@lovrozagar/flare/server-fn";
673
+ import { Form, FieldError } from "@lovrozagar/flare/form";
674
+
675
+ const save = createServerFn({ name: "save" })
676
+ .input(z.object({ email: z.string().email() }))
677
+ .handler(async ({ input, auth, revalidate, piggyback }) => {
678
+ await revalidate({ tags: ["contacts"], tiers: ["ssr"] });
679
+ piggyback(["contact", input.email], { email: input.email });
680
+ return { ok: true, email: input.email };
681
+ });
682
+
683
+ <Form
684
+ action={save}
685
+ onSuccess={(data) => console.log(data.email)}
686
+ onError={(err) => console.error(err)}
687
+ >
688
+ {(form) => (
689
+ <>
690
+ <input name="email" value={form.value("email")} />
691
+ <FieldError name="email" />
692
+ <button type="submit" disabled={form.pending()}>
693
+ Save
694
+ </button>
695
+ </>
696
+ )}
697
+ </Form>
698
+ ```
699
+
700
+ - The Vite plugin strips handler bodies from the client bundle.
701
+ - Progressive enhancement works with JS off (`POST` + redirect).
702
+ - CSRF: mutating methods check `Origin` / `Referer`.
703
+ - `.authenticate()` / `.authorize(fn)` on the server fn (same idea as routes).
704
+ - `.validator()` is an alias of `.input()`.
705
+ - Streaming: `.handler(async function* ({ signal }) { yield chunk })`.
706
+ - `createServerFn({ name, method: "get" })` for idempotent GET RPC.
707
+ - `@lovrozagar/flare/server-fn-query` wires a server fn to TanStack Query.
708
+
709
+ `form.pending()`, `form.error()`, `form.result()`, `form.fieldErrors()`, `form.hasErrors()`, `form.reset()`, `form.value(name)`.
710
+
711
+ ## Env split functions
712
+
713
+ ```ts
714
+ import { createServerOnlyFn } from "@lovrozagar/flare/server-only";
715
+ import { createClientOnlyFn } from "@lovrozagar/flare/client-only";
716
+ import { createIsomorphicFn } from "@lovrozagar/flare/isomorphic";
717
+
718
+ const readSecret = createServerOnlyFn(() => process.env.SECRET);
719
+ const measure = createClientOnlyFn(() => performance.now());
720
+ const now = createIsomorphicFn({
721
+ server: () => Date.now(),
722
+ client: () => performance.now(),
723
+ });
724
+ ```
725
+
726
+ The plugin drops the unused side from each bundle. Calling a server-only fn on the client throws.
727
+
728
+ ## Styles
729
+
730
+ Three supported surfaces:
731
+
732
+ | API | Use |
733
+ | ------------------------------------------------------------------------------------------ | ---------------------------------------------------- |
734
+ | `class="flex gap-4 p-8"` | Tailwind utilities, compiled when `sx: { tw: true }` |
735
+ | `sx={{ color: "rgb(0,0,255)", padding: "16px", variants: { hover: { opacity: "0.8" } } }}` | Typed style object → atomic classes |
736
+ | `styles("box", { css, state, vars })` | Named scoped CSS, `data-c` attribute |
737
+
738
+ ```tsx
739
+ import { styles, cn } from "@lovrozagar/flare/styles";
740
+
741
+ <div class="bg-blue-500 p-4" />
742
+ <div sx={{ color: "rgb(0, 100, 200)", padding: "16px" }} />
743
+ <div {...styles("box", { css: "color: rgb(255, 0, 0)" })} />
744
+ <div class={cn("base", on() && "active")} />
745
+ ```
746
+
747
+ `tw=` on `styles()` or as a JSX attribute is **dropped**. Put utilities in `class=`. `css=` compiles through the same plugin (not a `data-c` hash).
748
+
749
+ ## Fonts and images
750
+
751
+ ```tsx
752
+ import { FontCSS } from "@lovrozagar/flare/fonts";
753
+ import { inter } from "@lovrozagar/flare/fonts/inter";
754
+ import { Image } from "@lovrozagar/flare/image";
755
+ import hero from "../assets/hero.jpg";
756
+
757
+ <FontCSS family="Inter" />
758
+ <Image src={hero} alt="Hero" widths={[400, 800, 1200]} placeholder={false} />
759
+ ```
760
+
761
+ `flare font add` writes `public/fonts/` + subset CSS. Fallback metrics (`size-adjust`) reduce CLS.
762
+
763
+ `Image` emits `srcset`, optional blur placeholder. The Vite image plugin rewrites imports. `configureImage({ ... })` sets app-wide defaults. Typed imports look like `hero.d.jpg.ts` next to the file.
764
+
765
+ ## i18n, theme, direction
766
+
767
+ ```ts
768
+ createRouter({
769
+ locale: { defaultLocale: "en", locales: ["en", "hr", "fr"], paramName: "locale" },
770
+ theme: { defaultTheme: "system" },
771
+ direction: { defaultDir: "ltr" },
772
+ });
773
+ ```
774
+
775
+ Put the blocking scripts in the root `<head>` so first paint is correct:
776
+
777
+ ```tsx
778
+ import { LocaleScript, LocaleProvider } from "@lovrozagar/flare/locale";
779
+ import { ThemeScript, ThemeProvider } from "@lovrozagar/flare/theme";
780
+ import { DirectionScript } from "@lovrozagar/flare/direction";
781
+
782
+ <head>
783
+ <LocaleScript />
784
+ <ThemeScript />
785
+ <DirectionScript />
786
+ </head>;
787
+ ```
788
+
789
+ - **Locale** — optional `[[locale]]` segment or prefix. Cookie `flare.locale` + `Accept-Language`. Default locale is stripped (`/en/about` → `/about`). Playwright / bot UAs skip Set-Cookie (`isbot`). Prefetch (`x-p: 1`) never writes the cookie.
790
+ - **Theme** — `data-theme`, system preference, `localStorage`.
791
+ - **Direction** — `dir` / `data-dir`.
792
+
793
+ Copy (separate from routing locale):
794
+
795
+ ```ts
796
+ import { createTranslations, formatMessage } from "@lovrozagar/flare/i18n";
797
+
798
+ const translations = createTranslations({
799
+ common: {
800
+ en: () => import("./en/common"),
801
+ hr: () => import("./hr/common"),
802
+ },
803
+ });
804
+
805
+ const dict = await translations.load("en", ["common"]);
806
+ formatMessage(dict.common.hello, { name: "Flare" });
807
+ ```
808
+
809
+ `useLoaderT({ from })` / `usePreloaderT({ from })` format messages stored on loader / preloader data.
810
+
811
+ ## Middleware
812
+
813
+ ```ts
814
+ import type { FlareMiddleware } from "@lovrozagar/flare/middleware";
815
+ import { onPage, virtualPath } from "@lovrozagar/flare/middleware";
816
+ import { i18n } from "@lovrozagar/flare/middleware/i18n";
817
+ import { keepalive } from "@lovrozagar/flare/middleware/keepalive";
818
+ import { staticAssets } from "@lovrozagar/flare/middleware/static-assets";
819
+ import { apiProxy } from "@lovrozagar/flare/middleware/api-proxy";
820
+ import { cdnProxy } from "@lovrozagar/flare/middleware/cdn-proxy";
821
+ import { markdownNegotiation } from "@lovrozagar/flare/middleware/markdown-negotiation";
822
+
823
+ const timing: FlareMiddleware = async (ctx) => {
824
+ const start = Date.now();
825
+ ctx.onResponse((response) => {
826
+ const headers = new Headers(response.headers);
827
+ headers.set("x-timing", `${Date.now() - start}ms`);
828
+ return new Response(response.body, { headers, status: response.status });
829
+ });
830
+ return ctx.next();
831
+ };
832
+
833
+ createServer(router)
834
+ .use(i18n({ locales: ["en", "hr"], defaultLocale: "en" }))
835
+ .use(onPage(timing))
836
+ .use(virtualPath("_root_/about"), timing)
837
+ .use(keepalive())
838
+ .use(staticAssets())
839
+ .use("/api/*", apiProxy({ target: "https://api.example.com" }))
840
+ .use(cdnProxy())
841
+ .use(markdownNegotiation());
842
+ ```
843
+
844
+ | Result | Meaning |
845
+ | ----------------------- | ------------------------------------------------------- |
846
+ | `ctx.next()` | Continue |
847
+ | `ctx.respond(response)` | Stop and return that response (still runs `onResponse`) |
848
+ | `ctx.bypass(response)` | Return as-is |
849
+
850
+ `requestType` is `"page"` \| `"server-fn"` \| `"mount"` \| `"internal"`. Per-route: `.middleware(fn)`.
851
+
852
+ ## Mount
853
+
854
+ In-process island that is not a Flare page:
855
+
856
+ ```ts
857
+ createServer(router).mount("/api", (request, env, ctx) => {
858
+ ctx.waitUntil(log(request));
859
+ return new Response(JSON.stringify({ ok: true }), {
860
+ headers: { "content-type": "application/json" },
861
+ });
862
+ });
863
+ ```
864
+
865
+ Or pass an object with `{ fetch }`. Also exported as `mount` from `@lovrozagar/flare/mount`.
866
+
867
+ ## Security
868
+
869
+ ```ts
870
+ createServer(router).security(({ nonce }) => ({
871
+ "Content-Security-Policy": {
872
+ "default-src": ["'self'"],
873
+ "script-src": ["'self'", `'nonce-${nonce}'`],
874
+ },
875
+ "X-Frame-Options": "DENY",
876
+ "Referrer-Policy": "strict-origin-when-cross-origin",
877
+ "Strict-Transport-Security": "max-age=31536000",
878
+ }));
879
+ ```
880
+
881
+ Defaults: `nosniff`, CSP (dev `unsafe-inline`; prod nonce on HTML), HSTS in prod (skipped in dev). Set a header to `false` to omit it.
882
+
883
+ ## Store and revalidation
884
+
885
+ ```ts
886
+ import type { FlareStore, FlareStoreEntry } from "@lovrozagar/flare/store";
887
+ import { createFilesystemStore } from "@lovrozagar/flare/store-filesystem";
888
+ import { createRevalidateFn } from "@lovrozagar/flare/revalidation";
889
+
890
+ const store: FlareStore = createFilesystemStore(".flare/cache");
891
+ /* or implement { get, set, delete, deleteByTags } yourself */
892
+
893
+ createServer(router).cache({ store, cdnPurgeAdapter });
894
+
895
+ const revalidate = createRevalidateFn({ store, cdnPurgeAdapter });
896
+ await revalidate({ tags: ["posts"], keys: ["static:/about"], tiers: ["ssr", "cdn"] });
897
+ ```
898
+
899
+ HTTP purge: `POST` with header `x-revalidation-secret` (set the secret on the handler cache config). Load prerendered artifacts with `loadPrerenderArtifacts(dir, store)` from `@lovrozagar/flare/prerender`.
900
+
901
+ ## Query
902
+
903
+ ```ts
904
+ import { getQueryClient } from "./query-client";
905
+ import { useSuspenseQuery } from "@lovrozagar/flare/suspense-query";
906
+ import { BroadcastProvider } from "@lovrozagar/flare/broadcast";
907
+
908
+ createRouter({ queryClientGetter: getQueryClient });
909
+ ```
910
+
911
+ SSR dehydrates into the stream (`__flare_qc` / `t:"q"`). `useSuspenseQuery` suspends until the dehydrated entry is ready. Optional `BroadcastProvider` invalidates across tabs.
912
+
913
+ ```ts
914
+ import { createQueryClientGetter } from "@lovrozagar/flare/query-client";
915
+
916
+ export const getQueryClient = createQueryClientGetter(() => new QueryClient());
917
+ ```
918
+
919
+ ## Lazy
920
+
921
+ ```ts
922
+ import { lazy, clientLazy } from "@lovrozagar/flare/lazy";
923
+
924
+ const Heavy = lazy({
925
+ loader: () => import("./heavy"),
926
+ pending: () => <p>Loading</p>,
927
+ error: (err) => <p>{err.message}</p>,
928
+ });
929
+
930
+ const BrowserOnly = clientLazy({
931
+ loader: () => import("./chart"),
932
+ });
933
+ ```
934
+
935
+ `lazy()` works on server and client. `clientLazy()` is browser-only (no SSR). Prefetch can preload chunks.
936
+
937
+ ## Service worker
938
+
939
+ ```ts
940
+ flare({
941
+ serviceWorker: { offlineFallback: "/offline" },
942
+ });
943
+ ```
944
+
945
+ Dev `sw.js` uses `skipWaiting` + `clients.claim`. The offline route is your page (`/offline`). Disable with `serviceWorker: false`.
946
+
947
+ ## Sitemap and search engines
948
+
949
+ ```ts
950
+ createServer(router).sitemap({
951
+ origin: "https://example.com",
952
+ changefreq: "weekly",
953
+ exclude: ["/admin/*"],
954
+ additionalEntries: [{ loc: "https://example.com/extra", priority: 0.3 }],
955
+ });
956
+ ```
957
+
958
+ Or `generateSitemap(defs, config)` from `@lovrozagar/flare/sitemap`.
959
+
960
+ ```ts
961
+ import { submitIndexNow, indexNowVerification } from "@lovrozagar/flare/search-engine";
962
+ import { submitSitemapToGoogle, notifyGoogleIndexing } from "@lovrozagar/flare/search-engine";
963
+ import { submitUrlsToBing } from "@lovrozagar/flare/search-engine";
964
+ ```
965
+
966
+ IndexNow, Google Indexing / sitemap ping, Bing URL submit. Credentials stay on the server.
967
+
968
+ ## Tracing
969
+
970
+ ```ts
971
+ import { createTimingTracer, createOtelTracer, noopTracer } from "@lovrozagar/flare/tracing";
972
+
973
+ createServer(router).tracing({
974
+ timing: true,
975
+ tracer: createTimingTracer(),
976
+ });
977
+ ```
978
+
979
+ `timing: true` emits `Server-Timing`. Pass an OTel-compatible tracer for spans.
980
+
981
+ ## Testing
982
+
983
+ ```ts
984
+ import { FlarePage, assertFlareStateShape, isHydrationError } from "@lovrozagar/flare/testing";
985
+
986
+ const flare = new FlarePage(page);
987
+ await flare.goto("/");
988
+ await flare.assertHydrated();
989
+ assertFlareStateShape(await flare.state());
990
+ ```
991
+
992
+ Playwright helpers: hydration, FlareState shape, console-error filters. Product e2e also has app-local helpers under `e2e/apps/product/tests/e2e/helpers.ts`.
993
+
994
+ ## NDJSON protocol
995
+
996
+ SPA / prefetch / data requests send `x-d: 1`. Prefetch also sends `x-p: 1`. Stale match skip uses `x-m`.
997
+
998
+ | `t` | Meaning |
999
+ | --- | --------------- |
1000
+ | `l` | Loader payload |
1001
+ | `c` | Deferred chunk |
1002
+ | `h` | Head |
1003
+ | `r` | Redirect |
1004
+ | `q` | Query dehydrate |
1005
+
1006
+ There is no JSON-RPC twin. HTML is `renderToStream`. Hydration reads `self.flare` (FlareState): `p` pathname, `r` params/search, `m` matches, `c` serializable router config.
1007
+
1008
+ ## Duration strings
1009
+
1010
+ Anywhere a duration is accepted: number (ms) or `"30s"` / `"5m"` / `"1h"` / `"1d"`.
1011
+
1012
+ ## Plugin
1013
+
1014
+ ```ts
1015
+ import { defineConfig } from "vite";
8
1016
  import { flare } from "@lovrozagar/flare/plugins";
1017
+
1018
+ export default defineConfig({
1019
+ plugins: [
1020
+ flare({
1021
+ codegen: { fsVirtualPaths: false },
1022
+ dev: { cdnCache: false, dashboard: true, serverTiming: true },
1023
+ prerender: true,
1024
+ serviceWorker: { offlineFallback: "/offline" },
1025
+ sx: { tw: true },
1026
+ image: { quality: 80, widths: [400, 800, 1200] },
1027
+ assetsBase: "/assets",
1028
+ alias: { "@": "/src" },
1029
+ port: undefined,
1030
+ purge: false,
1031
+ logLevel: "info",
1032
+ }),
1033
+ ],
1034
+ });
1035
+ ```
1036
+
1037
+ | Option | Default | Meaning |
1038
+ | -------------------------------------- | ------------------------------------------- | ------------------------------------------------------------ |
1039
+ | `codegen.fsVirtualPaths` | `true` | Suffix files vs string `createPage` |
1040
+ | `codegen.routesFilePath` | `src/_gen/routes.gen.ts` | |
1041
+ | `codegen.typesFilePath` | `src/_gen/types.gen.d.ts` | |
1042
+ | `dev.cdnCache` | `true` | Disk CDN emulator |
1043
+ | `dev.dashboard` | `true` | `/__flare` (node-only) |
1044
+ | `dev.serverTiming` | `true` | `Server-Timing` |
1045
+ | `dev.staticCache` | `true` | |
1046
+ | `prerender` | off | SSG/ISR emit |
1047
+ | `purge` | off | Dead CSS / `data-testid` strip |
1048
+ | `serviceWorker` | off | `sw.js` |
1049
+ | `sx.tw` | compile `class=` Tailwind | |
1050
+ | `image.quality` / `widths` / `exclude` | image pipeline | |
1051
+ | `assetsBase` | `"/assets"` | Must start with `/`, no trailing `/` (`"/"` = root-relative) |
1052
+ | `entry.client` / `entry.server` | `src/client`, `src/server` | |
1053
+ | `ignorePrefix` | extra ignored folder names | |
1054
+ | `port` | Vite `server.port` (overrides CLI `--port`) | |
1055
+ | `alias` | Vite alias | |
1056
+ | `solid` | passed to `vite-plugin-solid` | |
1057
+
1058
+ `dev: false` turns every `dev.*` flag off. Dev dashboard is `@node-only` in e2e (not on Workers).
1059
+
1060
+ Do **not** set `flare({ port: 3000 })` in e2e apps — it steals Playwright’s `--port`.
1061
+
1062
+ ## Package exports
1063
+
1064
+ Import features from their path.
1065
+
1066
+ | Export | You get |
1067
+ | --------------------------------------- | --------------------------------------- |
1068
+ | `@lovrozagar/flare` | `createRouter`, hooks, `navigate` |
1069
+ | `@lovrozagar/flare/router` | `createRouter` |
1070
+ | `@lovrozagar/flare/page` | `createPage` |
1071
+ | `@lovrozagar/flare/layout` | `createLayout` |
1072
+ | `@lovrozagar/flare/root-layout` | `createRootLayout` |
1073
+ | `@lovrozagar/flare/path-segment` | `createPathSegment` |
1074
+ | `@lovrozagar/flare/link` | `Link` |
1075
+ | `@lovrozagar/flare/outlet` | `Outlet` |
1076
+ | `@lovrozagar/flare/hydrate` | `hydrate` |
1077
+ | `@lovrozagar/flare/client` | `createClient` |
1078
+ | `@lovrozagar/flare/await` | `<Await>` |
1079
+ | `@lovrozagar/flare/form` | `Form`, `FieldError` |
1080
+ | `@lovrozagar/flare/server-fn` | `createServerFn` |
1081
+ | `@lovrozagar/flare/server-fn-query` | server fn ↔ Query |
1082
+ | `@lovrozagar/flare/plugins` | `flare()` |
1083
+ | `@lovrozagar/flare/styles` | `styles`, `cn`, `compileSx` |
1084
+ | `@lovrozagar/flare/fonts` | `FontCSS`, `createFont` |
1085
+ | `@lovrozagar/flare/fonts/<family>` | `import { inter } from "…/fonts/inter"` |
1086
+ | `@lovrozagar/flare/image` | `Image`, `configureImage` |
1087
+ | `@lovrozagar/flare/theme` | `ThemeScript`, `ThemeProvider` |
1088
+ | `@lovrozagar/flare/direction` | `DirectionScript` |
1089
+ | `@lovrozagar/flare/locale` | `LocaleScript`, `LocaleProvider` |
1090
+ | `@lovrozagar/flare/i18n` | `createTranslations`, `formatMessage` |
1091
+ | `@lovrozagar/flare/middleware` | `onPage`, `virtualPath`, types |
1092
+ | `@lovrozagar/flare/middleware/*` | builtins |
1093
+ | `@lovrozagar/flare/errors` | `NotFoundError`, redirects, auth errors |
1094
+ | `@lovrozagar/flare/security` | `SecurityConfig` |
1095
+ | `@lovrozagar/flare/revalidation` | `createRevalidateFn` |
1096
+ | `@lovrozagar/flare/store` | `FlareStore` |
1097
+ | `@lovrozagar/flare/store-filesystem` | disk store |
1098
+ | `@lovrozagar/flare/query-client` | `createQueryClientGetter` |
1099
+ | `@lovrozagar/flare/suspense-query` | `useSuspenseQuery` |
1100
+ | `@lovrozagar/flare/broadcast` | cross-tab |
1101
+ | `@lovrozagar/flare/lazy` | `lazy`, `clientLazy` |
1102
+ | `@lovrozagar/flare/server` | `createServer` |
1103
+ | `@lovrozagar/flare/server-context` | ALS, `background` |
1104
+ | `@lovrozagar/flare/server-only` | `createServerOnlyFn` |
1105
+ | `@lovrozagar/flare/client-only` | `createClientOnlyFn` |
1106
+ | `@lovrozagar/flare/isomorphic` | `createIsomorphicFn` |
1107
+ | `@lovrozagar/flare/testing` | Playwright helpers |
1108
+ | `@lovrozagar/flare/sitemap` | sitemap XML |
1109
+ | `@lovrozagar/flare/search-engine` | IndexNow / Google / Bing |
1110
+ | `@lovrozagar/flare/rewrite` | `LocationRewrite` |
1111
+ | `@lovrozagar/flare/mount` | `mount` |
1112
+ | `@lovrozagar/flare/intercept-outlet` | `InterceptOutlet` |
1113
+ | `@lovrozagar/flare/navigation-progress` | `<NavigationProgress>` |
1114
+ | `@lovrozagar/flare/reset-css` | `<ResetCSS>` |
1115
+ | `@lovrozagar/flare/view-transition-css` | `<ViewTransitionCSS>` |
1116
+ | `@lovrozagar/flare/prerender` | `loadPrerenderArtifacts` |
1117
+ | `@lovrozagar/flare/tracing` | timing / OTel |
1118
+ | `@lovrozagar/flare/validation` | `Validator`, `runValidator` |
1119
+ | `@lovrozagar/flare/codegen` | generated types |
1120
+ | `@lovrozagar/flare/generators` | `runGenerate` |
1121
+ | `@lovrozagar/flare/virtual-types` | `/// <reference types="…" />` |
1122
+
1123
+ ## Repository layout
1124
+
1125
+ ```
1126
+ packages/core/ published `@lovrozagar/flare` (src, tests, spec)
1127
+ packages/cli/ `flare` CLI (`@flare/cli`)
1128
+ e2e/apps/ product, demo, fs-routes, tauri
1129
+ e2e/{node,bun,workers,deno}/ env hosts + Playwright
1130
+ e2e/run-env.ts env × app runner
1131
+ benchmark/ flare vs Next vs TanStack
1132
+ ```
1133
+
1134
+ `e2e/apps/*` own the tests. Runtimes only listen. `FLARE_E2E_APP` and `FLARE_E2E_ENV` select app and host.
1135
+
1136
+ ## Develop
1137
+
1138
+ Requires [Bun](https://bun.sh) 1.3+ and TypeScript 7.
1139
+
1140
+ ```bash
1141
+ bun install
1142
+ bun run test # core unit
1143
+ bun run test:cli # CLI unit
1144
+ bun run test:all # unit + all e2e apps on node
1145
+ bun run test:all -- --env bun
1146
+ bun run typecheck
1147
+ bun run typecheck:consumers
1148
+ bun run lint
1149
+ bun run fmt:check
1150
+ ```
1151
+
1152
+ GitHub Actions (`.github/workflows/ci.yml`): `test` runs typecheck, fmt, lint, unit. `e2e` is a matrix of node / bun / workers / deno.
1153
+
1154
+ ### Checks
1155
+
1156
+ TypeScript 7 `strict` plus [oxlint](https://oxc.rs/docs/guide/usage/linter.html) / [oxfmt](https://oxc.rs/docs/guide/usage/formatter.html):
1157
+
1158
+ ```bash
1159
+ bun run typecheck
1160
+ bun run typecheck:consumers
1161
+ bun run lint
1162
+ bun run lint:fix
1163
+ bun run fmt
1164
+ bun run fmt:check
1165
+ ```
1166
+
1167
+ Config: `.oxlintrc.json`, `.oxfmtrc.jsonc`. Tabs, double quotes, `printWidth` 120, semicolons. Generated `_gen/` / `*.gen.ts` are ignored.
1168
+
1169
+ Do not weaken `strict` or add `as any` to make typecheck pass.
1170
+
1171
+ ### Test matrix
1172
+
1173
+ | Command | What it proves |
1174
+ | -------------------------- | ------------------------------ |
1175
+ | `bun run test` | Core unit + integration |
1176
+ | `bun run test:cli` | CLI unit |
1177
+ | `bun run test:e2e` | Playwright, every app × node |
1178
+ | `bun run test:e2e:bun` | Same tests, Bun listen |
1179
+ | `bun run test:e2e:workers` | Same tests, local workerd |
1180
+ | `bun run test:e2e:deno` | Same tests, Deno |
1181
+ | `bun run test:e2e:firefox` | Same tests, Firefox (dev only) |
1182
+ | `bun run test:e2e:prod` | Node, `TEST_MODE=prod` |
1183
+
1184
+ ```bash
1185
+ bun run test:e2e -- --app product
1186
+ bun run e2e/run-env.ts --env workers --app demo
1187
+ TEST_MODE=prod bun run test:e2e:workers
9
1188
  ```
10
1189
 
11
- See the repo root README for layout, develop commands, and the consumer proof.
1190
+ | App | Covers |
1191
+ | ----------- | ----------------------------- |
1192
+ | `product` | Full framework surface |
1193
+ | `demo` | Locale / i18n chrome |
1194
+ | `fs-routes` | `fsVirtualPaths` suffix files |
1195
+ | `tauri` | Desktop Vite shell |
1196
+
1197
+ Runtimes: `e2e/node`, `e2e/bun`, `e2e/workers`, `e2e/deno`. Firefox uses the node host.
1198
+
1199
+ ### Bench
1200
+
1201
+ `benchmark/` compares Flare, Next, and TanStack. Numbers: [`benchmark/RESULTS.md`](benchmark/RESULTS.md).
1202
+
1203
+ ```bash
1204
+ bun run --filter @lovrozagar/flare bench
1205
+ ```
1206
+
1207
+ ## License
1208
+
1209
+ MIT. Copyright (c) 2026 Lovro Žagar.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovrozagar/flare",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Solid meta-framework. Server-driven, NDJSON streaming, renderToStream.",
5
5
  "keywords": [
6
6
  "flare",
@@ -12,6 +12,7 @@
12
12
  "streaming",
13
13
  "vite"
14
14
  ],
15
+ "homepage": "https://github.com/lovrozagar/flare",
15
16
  "license": "MIT",
16
17
  "repository": {
17
18
  "type": "git",
@@ -105,7 +106,7 @@
105
106
  "@capsizecss/metrics": "catalog:",
106
107
  "@formatjs/intl-localematcher": "catalog:",
107
108
  "@solidjs/testing-library": "catalog:",
108
- "@tanstack/query-broadcast-client-experimental": "5.101.2",
109
+ "@tanstack/query-broadcast-client-experimental": "catalog:",
109
110
  "@tanstack/solid-query": "catalog:",
110
111
  "@types/negotiator": "catalog:",
111
112
  "isbot": "catalog:",
@@ -139,8 +140,32 @@
139
140
  "vite-plugin-solid": ">=2.0.0"
140
141
  },
141
142
  "peerDependenciesMeta": {
143
+ "@formatjs/intl-localematcher": {
144
+ "optional": true
145
+ },
146
+ "@tanstack/query-broadcast-client-experimental": {
147
+ "optional": true
148
+ },
149
+ "@tanstack/solid-query": {
150
+ "optional": true
151
+ },
152
+ "isbot": {
153
+ "optional": true
154
+ },
155
+ "negotiator": {
156
+ "optional": true
157
+ },
142
158
  "node-html-markdown": {
143
159
  "optional": true
160
+ },
161
+ "oxc-parser": {
162
+ "optional": true
163
+ },
164
+ "oxc-resolver": {
165
+ "optional": true
166
+ },
167
+ "sharp": {
168
+ "optional": true
144
169
  }
145
170
  }
146
171
  }