@kasperrt/smolter 0.0.0-stage → 0.0.1-rc.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 (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +470 -2
  3. package/dist/client.d.ts +2 -0
  4. package/dist/client.js +2 -0
  5. package/dist/core.d.ts +2 -0
  6. package/dist/core.js +2 -0
  7. package/dist/files.d.ts +2 -0
  8. package/dist/files.js +2 -0
  9. package/dist/internal/client/deserialize.d.ts +3 -0
  10. package/dist/internal/client/deserialize.js +11 -0
  11. package/dist/internal/client/index.d.ts +23 -0
  12. package/dist/internal/client/index.js +44 -0
  13. package/dist/internal/core/errors.d.ts +3 -0
  14. package/dist/internal/core/errors.js +6 -0
  15. package/dist/internal/core/index.d.ts +6 -0
  16. package/dist/internal/core/index.js +6 -0
  17. package/dist/internal/core/match.d.ts +5 -0
  18. package/dist/internal/core/match.js +27 -0
  19. package/dist/internal/core/router.d.ts +3 -0
  20. package/dist/internal/core/router.js +189 -0
  21. package/dist/internal/core/types.d.ts +119 -0
  22. package/dist/internal/core/types.js +5 -0
  23. package/dist/internal/core/url.d.ts +2 -0
  24. package/dist/internal/core/url.js +6 -0
  25. package/dist/internal/files/index.d.ts +11 -0
  26. package/dist/internal/files/index.js +77 -0
  27. package/dist/internal/preact/context.d.ts +18 -0
  28. package/dist/internal/preact/context.js +20 -0
  29. package/dist/internal/preact/fetcher.d.ts +22 -0
  30. package/dist/internal/preact/fetcher.js +28 -0
  31. package/dist/internal/preact/form.d.ts +17 -0
  32. package/dist/internal/preact/form.js +41 -0
  33. package/dist/internal/preact/hooks.d.ts +21 -0
  34. package/dist/internal/preact/hooks.js +35 -0
  35. package/dist/internal/preact/index.d.ts +9 -0
  36. package/dist/internal/preact/index.js +7 -0
  37. package/dist/internal/preact/link.d.ts +12 -0
  38. package/dist/internal/preact/link.js +28 -0
  39. package/dist/internal/preact/routes.d.ts +22 -0
  40. package/dist/internal/preact/routes.js +22 -0
  41. package/dist/internal/preact/runtime.d.ts +2 -0
  42. package/dist/internal/preact/runtime.js +1 -0
  43. package/dist/internal/react/context.d.ts +18 -0
  44. package/dist/internal/react/context.js +20 -0
  45. package/dist/internal/react/fetcher.d.ts +22 -0
  46. package/dist/internal/react/fetcher.js +28 -0
  47. package/dist/internal/react/form.d.ts +17 -0
  48. package/dist/internal/react/form.js +41 -0
  49. package/dist/internal/react/hooks.d.ts +21 -0
  50. package/dist/internal/react/hooks.js +35 -0
  51. package/dist/internal/react/index.d.ts +9 -0
  52. package/dist/internal/react/index.js +7 -0
  53. package/dist/internal/react/link.d.ts +12 -0
  54. package/dist/internal/react/link.js +28 -0
  55. package/dist/internal/react/routes.d.ts +22 -0
  56. package/dist/internal/react/routes.js +22 -0
  57. package/dist/internal/react/runtime.d.ts +2 -0
  58. package/dist/internal/react/runtime.js +2 -0
  59. package/dist/internal/server/index.d.ts +11 -0
  60. package/dist/internal/server/index.js +81 -0
  61. package/dist/internal/vite/index.d.ts +10 -0
  62. package/dist/internal/vite/index.js +132 -0
  63. package/dist/internal/vite/transport.d.ts +2 -0
  64. package/dist/internal/vite/transport.js +42 -0
  65. package/dist/preact.d.ts +2 -0
  66. package/dist/preact.js +2 -0
  67. package/dist/react.d.ts +2 -0
  68. package/dist/react.js +2 -0
  69. package/dist/server.d.ts +2 -0
  70. package/dist/server.js +2 -0
  71. package/dist/vite.d.ts +2 -0
  72. package/dist/vite.js +2 -0
  73. package/jsr.json +26 -0
  74. package/package.json +94 -4
  75. package/src/client.ts +2 -0
  76. package/src/core.ts +2 -0
  77. package/src/files.ts +2 -0
  78. package/src/internal/client/deserialize.ts +15 -0
  79. package/src/internal/client/index.ts +84 -0
  80. package/src/internal/core/errors.ts +13 -0
  81. package/src/internal/core/index.ts +7 -0
  82. package/src/internal/core/match.ts +45 -0
  83. package/src/internal/core/router.ts +251 -0
  84. package/src/internal/core/types.ts +151 -0
  85. package/src/internal/core/url.ts +6 -0
  86. package/src/internal/files/index.ts +112 -0
  87. package/src/internal/preact/context.ts +46 -0
  88. package/src/internal/preact/fetcher.ts +83 -0
  89. package/src/internal/preact/form.ts +79 -0
  90. package/src/internal/preact/hooks.ts +72 -0
  91. package/src/internal/preact/index.ts +13 -0
  92. package/src/internal/preact/link.ts +46 -0
  93. package/src/internal/preact/routes.ts +50 -0
  94. package/src/internal/preact/runtime.ts +15 -0
  95. package/src/internal/react/context.ts +46 -0
  96. package/src/internal/react/fetcher.ts +83 -0
  97. package/src/internal/react/form.ts +79 -0
  98. package/src/internal/react/hooks.ts +72 -0
  99. package/src/internal/react/index.ts +13 -0
  100. package/src/internal/react/link.ts +46 -0
  101. package/src/internal/react/routes.ts +50 -0
  102. package/src/internal/react/runtime.ts +18 -0
  103. package/src/internal/server/index.ts +120 -0
  104. package/src/internal/vite/index.ts +208 -0
  105. package/src/internal/vite/transport.ts +60 -0
  106. package/src/preact.ts +2 -0
  107. package/src/react.ts +2 -0
  108. package/src/server.ts +2 -0
  109. package/src/vite.ts +2 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kasperrt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,471 @@
1
- # Temporary Holding Version
1
+ # smolter _
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ <!-- bundlejs:start -->
4
+ [![react gzip size](https://deno.bundlejs.com/?q=%40kasperrt%2Fsmolter%400.0.1-rc.1%2Freact&treeshake=%5B*%5D&config=%7B%22compression%22%3A%22gzip%22%2C%22esbuild%22%3A%7B%22external%22%3A%5B%22react%22%2C%22react%2F*%22%5D%7D%7D&badge)](https://bundlejs.com/?q=%40kasperrt%2Fsmolter%400.0.1-rc.1%2Freact&treeshake=%5B*%5D&config=%7B%22compression%22%3A%22gzip%22%2C%22esbuild%22%3A%7B%22external%22%3A%5B%22react%22%2C%22react%2F*%22%5D%7D%7D&bundle)
5
+ [![preact gzip size](https://deno.bundlejs.com/?q=%40kasperrt%2Fsmolter%400.0.1-rc.1%2Fpreact&treeshake=%5B*%5D&config=%7B%22compression%22%3A%22gzip%22%2C%22esbuild%22%3A%7B%22external%22%3A%5B%22preact%22%2C%22preact%2F*%22%5D%7D%7D&badge)](https://bundlejs.com/?q=%40kasperrt%2Fsmolter%400.0.1-rc.1%2Fpreact&treeshake=%5B*%5D&config=%7B%22compression%22%3A%22gzip%22%2C%22esbuild%22%3A%7B%22external%22%3A%5B%22preact%22%2C%22preact%2F*%22%5D%7D%7D&bundle)
6
+ <!-- bundlejs:end -->
7
+
8
+ A typed data router for React and Preact. **Zero runtime dependencies.**
9
+
10
+ Loaders read. Actions mutate. Components render. Fetchers read or submit to a
11
+ route without navigating. Successful mutations can revalidate the current
12
+ loader; `shouldRevalidate` decides when that work is needed.
13
+
14
+ Inspired by [wouter](https://github.com/molefrog/wouter)
15
+ and [React Router](https://reactrouter.com/).
16
+
17
+ ## Contents
18
+
19
+ - [Install](#install)
20
+ - [Try the four standalone examples](#try-the-four-standalone-examples)
21
+ - [Choose file routing or manual routes](#choose-file-routing-or-manual-routes)
22
+ - [Server and client handlers](#server-and-client-handlers)
23
+ - [Fetchers read and mutate](#fetchers-read-and-mutate)
24
+ - [Decide when to revalidate](#decide-when-to-revalidate)
25
+ - [Types follow the handler](#types-follow-the-handler)
26
+ - [Server rendering](#server-rendering)
27
+ - [Package entries](#package-entries)
28
+ - [Size and checks](#size-and-checks)
29
+
30
+ ## Install
31
+
32
+ The npm package is `@kasperrt/smolter`. Install it with an alias to use the
33
+ short imports shown throughout these examples:
34
+
35
+ ```sh
36
+ pnpm add smolter@npm:@kasperrt/smolter@next
37
+ ```
38
+
39
+ Choose your framework explicitly: `smolter/react` or `smolter/preact`.
40
+ You can also install `@kasperrt/smolter@next` directly and import from
41
+ `@kasperrt/smolter/react` or `@kasperrt/smolter/preact` instead.
42
+
43
+ ## Try the four standalone examples
44
+
45
+ ```sh
46
+ pnpm install
47
+ pnpm dev
48
+ ```
49
+
50
+ | App | Open | Data |
51
+ | --- | --- | --- |
52
+ | [React SPA](https://github.com/kasperrt/smolter/tree/main/apps/react) | http://127.0.0.1:5173 | Client loaders/actions, localStorage |
53
+ | [Preact SPA](https://github.com/kasperrt/smolter/tree/main/apps/preact) | http://127.0.0.1:5174 | Client loaders/actions, localStorage |
54
+ | [React SSR](https://github.com/kasperrt/smolter/tree/main/apps/react-ssr) | http://127.0.0.1:5175 | Server loaders/actions, hydration |
55
+ | [Preact SSR](https://github.com/kasperrt/smolter/tree/main/apps/preact-ssr) | http://127.0.0.1:5176 | Server loaders/actions, hydration |
56
+
57
+ Each app has its own entrypoints, routes, and data code. Only UI components are
58
+ shared. Preact examples do not import React. Each includes:
59
+
60
+ - `/`: counter, fetcher POST, automatic and manual revalidation.
61
+ - `/tasks`: list, create form, and a fetcher-only task preview.
62
+ - `/tasks/:id`: PATCH, DELETE, validation, and skipping no-op revalidation.
63
+ - `/about`: the separation between data and rendering.
64
+
65
+ The right panel shows an equivalent public-API example with styling omitted,
66
+ plus the actual loader/action files. Its content follows the current route and
67
+ framework. Gzip labels are generated from the source measurement during dev and
68
+ build; SSR labels include the optional browser transport.
69
+
70
+ Use `SMOLTER_ROUTES=manual pnpm dev` to run the same apps with manual route tables.
71
+ Build with `pnpm build:examples`. Each SSR app also has a `start` command for its
72
+ production HTTP server; see its README.
73
+
74
+ ## Choose file routing or manual routes
75
+
76
+ ### Flat files
77
+
78
+ The optional Vite 8+ plugin discovers route files at build time. No file scanner
79
+ or filename parser enters the browser bundle.
80
+
81
+ ```ts
82
+ // vite.config.ts
83
+ import { defineConfig } from "vite";
84
+ import { fileRouter } from "smolter/vite";
85
+
86
+ export default defineConfig({
87
+ plugins: [fileRouter({ directory: "src/routes" })],
88
+ });
89
+ ```
90
+
91
+ ```ts
92
+ // app.ts
93
+ import { createRouter } from "smolter/react";
94
+ import routes from "virtual:smolter/routes";
95
+
96
+ export const router = createRouter(routes, { browser: true });
97
+ ```
98
+
99
+ | File or folder | URL |
100
+ | --- | --- |
101
+ | `routes/_index/route.tsx` | `/` |
102
+ | `routes/tasks._index/route.tsx` | `/tasks` |
103
+ | `routes/tasks.$id/route.tsx` | `/tasks/:id` |
104
+ | `routes/_account.settings/route.tsx` | `/settings` |
105
+ | `routes/files.$/route.tsx` | `/files/*` |
106
+ | `routes/about.tsx` | `/about` |
107
+
108
+ Dots separate URL segments; `$name` is a parameter; terminal `$` is a splat.
109
+ `_index` is an index route. Leading underscores are pathless naming groups;
110
+ a trailing underscore is removed from that segment. Colocated helpers inside
111
+ route folders are ignored. `.ts`, `.tsx`, `.js`, and `.jsx` are supported.
112
+
113
+ **Only flat matching:** there are no nested outlets, inherited loaders, or layout
114
+ chains. A pathless group module itself is not rendered or executed. Group names
115
+ do not provide authentication. Static paths are ordered before parameters, then
116
+ splats. Conflicting patterns and unsupported optional/escaped syntax are rejected.
117
+
118
+ For the virtual module, add this declaration to your app:
119
+
120
+ ```ts
121
+ // env.d.ts
122
+ // Include vite/client in your tsconfig types as usual.
123
+ declare module "virtual:smolter/routes" {
124
+ const routes: import("smolter/core").Route[];
125
+ export default routes;
126
+ }
127
+ ```
128
+
129
+ Outside Vite, `flatRoutes(directory)` from `smolter/files` returns
130
+ `{ path, file }[] | Error` for your build tool to turn into imports.
131
+
132
+ ### Manual routes
133
+
134
+ ```tsx
135
+ import { createRouter, defineRoute } from "smolter/react";
136
+ import * as tasks from "./tasks/route";
137
+ import * as task from "./task/route";
138
+
139
+ export const tasksRoute = defineRoute("/tasks", tasks);
140
+ export const taskRoute = defineRoute("/tasks/:id", task);
141
+ export const routes = [tasksRoute, taskRoute];
142
+ export const router = createRouter(routes, { browser: true });
143
+ ```
144
+
145
+ Or pass handlers directly to `defineRoute(path, { loader, action, default: View })`.
146
+ First registered match wins. Trailing slashes are ignored. `/tasks/42` supplies
147
+ `params.id === "42"`; query parameters come from `new URL(request.url)`.
148
+
149
+ Use the same API from **`smolter/preact`** for Preact. Render the selected route
150
+ inside the provider:
151
+
152
+ ```tsx
153
+ <RouterProvider router={router}>
154
+ <Link href="/tasks">Tasks</Link>
155
+ <Routes />
156
+ </RouterProvider>
157
+ ```
158
+
159
+ ## Server and client handlers
160
+
161
+ A route can export all four handlers together:
162
+
163
+ ```ts
164
+ // routes/tasks.$id/route.ts
165
+ export { default } from "./view";
166
+
167
+ export { loader } from "./loader";
168
+ export { action } from "./action";
169
+
170
+ export { clientLoader } from "./client-loader";
171
+ export { clientAction } from "./client-action";
172
+
173
+ export { shouldRevalidate } from "./should-revalidate";
174
+ export { HydrateFallback, ErrorBoundary } from "../../boundaries";
175
+ ```
176
+
177
+ With the optional server integration:
178
+
179
+ - `loader` and `action` execute on the server.
180
+ - Browser navigation and submissions reach them automatically over HTTP.
181
+ - Optional `clientLoader` and `clientAction` take over browser execution. They
182
+ may do browser-only work or call `serverLoader()` / `serverAction()`.
183
+ - SSR executes the server loader and hydration reuses that data.
184
+
185
+ ```ts
186
+ // client-loader.ts
187
+ import type { ClientLoaderArgs } from "smolter/client";
188
+ import type { loader } from "./loader";
189
+
190
+ export async function clientLoader({ serverLoader }: ClientLoaderArgs<typeof loader>) {
191
+ return await serverLoader();
192
+ }
193
+ ```
194
+
195
+ ```ts
196
+ // client-action.ts
197
+ import type { ClientActionArgs } from "smolter/client";
198
+ import type { action } from "./action";
199
+
200
+ export async function clientAction({ serverAction }: ClientActionArgs<typeof action>) {
201
+ return await serverAction();
202
+ }
203
+ ```
204
+
205
+ Handlers receive `{ request, params, signal }`. Server callbacks add the typed
206
+ call-through functions. A client-only handler can instead use browser storage
207
+ or call an unrelated API itself; components do not need to fetch.
208
+
209
+ A new navigation aborts the previous navigation's signal. Pass `signal` (or
210
+ `request.signal`) to your own `fetch` calls so the underlying request is cancelled:
211
+
212
+ ```ts
213
+ // loader.ts
214
+ import type { DataArgs } from "smolter/react";
215
+
216
+ export async function clientLoader({ signal }: DataArgs) {
217
+ const response = await fetch("/api/tasks", { signal });
218
+
219
+ return await response.json();
220
+ }
221
+ ```
222
+
223
+ Server transport forwards this signal automatically, including calls through
224
+ `serverLoader()`. For SSR, `loadRouter(routes, request)` combines the incoming
225
+ request's signal with the router's signal. The host must abort that incoming
226
+ request when a client disconnects. Cancelled navigation requests cannot overwrite
227
+ the current page's data or error state. Aborting a request does not undo a mutation already processed
228
+ by the server.
229
+
230
+ Keep server handlers in separate files and re-export them as shown. The plugin
231
+ removes these re-exports from browser modules and inserts transport functions.
232
+ It rejects inline server handlers and `export *` rather than risking server code
233
+ in a client bundle. Type-only imports from server handlers are safe. Do not
234
+ import server code directly into client views or client handlers.
235
+
236
+ The Vite dev plugin mounts `/_smolter?url=...`. Production hosts dispatch that
237
+ endpoint through `handleRequest(routes, request)` from `smolter/server`, using
238
+ the requested route URL. Both SSR examples include a working host. The host
239
+ owns authentication, session cookies, HTTP policy, and deployment.
240
+
241
+ Manual server routes use `serverRoute<typeof import("./route")>(path, clientModule)`
242
+ from `smolter/client` in the browser and `defineRoute` on the server. This keeps
243
+ the type connection without importing server code at runtime. To use manual
244
+ tables with the dev transport:
245
+
246
+ ```ts
247
+ import { routerPlugin } from "smolter/vite";
248
+
249
+ routerPlugin({
250
+ clientRoutes: "src/manual-browser-routes.ts",
251
+ serverRoutes: "src/manual-routes.ts",
252
+ });
253
+ ```
254
+
255
+ Each table exports `routes`. The standalone examples contain both tables.
256
+
257
+ Without the optional integration, `createRouter` invokes handlers locally:
258
+ client handlers take precedence, with ordinary handlers as fallbacks.
259
+ `loadRouter` selects server handlers only. Core-only usage does not create HTTP
260
+ endpoints or compiler boundaries by itself.
261
+
262
+ ## Fetchers read and mutate
263
+
264
+ A fetcher read returns data **to that fetcher**, not the page loader:
265
+
266
+ ```tsx
267
+ const preview = useFetcher<typeof taskLoader>();
268
+
269
+ <button onClick={() => preview.load("/tasks/42")}>Preview</button>
270
+ <p>{preview.data?.title}</p>
271
+ ```
272
+
273
+ `load` calls the matched route's loader without navigating or revalidating the
274
+ current page. In an SSR app it crosses the server transport; in a client-only
275
+ app it invokes the client loader locally.
276
+
277
+ A mutation targets a registered route too:
278
+
279
+ ```tsx
280
+ const save = useFetcher<typeof taskAction>();
281
+
282
+ <save.Form action="/tasks/42" method="patch">
283
+ <input name="title" defaultValue="Write the docs" />
284
+
285
+ <button disabled={save.state !== "idle"}>Save</button>
286
+
287
+ {save.error && <p role="alert">{save.error.message}</p>}
288
+ </save.Form>
289
+ ```
290
+
291
+ This matches `/tasks/:id` and calls its action with a PATCH `Request` and
292
+ `params.id === "42"`. The result goes into `save.data`; the current page loader
293
+ then revalidates unless it opts out. The page URL stays unchanged.
294
+
295
+ ```ts
296
+ const body = new FormData();
297
+
298
+ body.set("title", "Updated title");
299
+
300
+ await save.submit(body, { action: "/tasks/42", method: "PATCH" });
301
+ ```
302
+
303
+ POST, PUT, PATCH, and DELETE are supported. GET forms call loaders. Named submit
304
+ buttons and `formaction` / `formmethod` overrides are supported.
305
+
306
+ `useFetcher(taskRoute)` infers its result types and binds a default target.
307
+ For a different parameterized resource, supply a concrete URL; `/tasks/:id` is
308
+ a pattern, not a destination. Without an explicit target, a fetcher uses its
309
+ bound route or the current URL. Each hook owns independent `data`, `error`, and
310
+ `state` (`idle`, `loading`, `submitting`).
311
+
312
+ Regular `<Form>` navigates and exposes action results through `useActionData()`.
313
+ Fetcher results belong to `fetcher.data`, not `useActionData()`.
314
+
315
+ ## Decide when to revalidate
316
+
317
+ ```ts
318
+ // should-revalidate.ts
319
+ import type { RevalidationArgs } from "smolter/react";
320
+
321
+ export function shouldRevalidate({ actionResult, defaultShouldRevalidate }: RevalidationArgs) {
322
+ if (actionResult && typeof actionResult === "object" && "changed" in actionResult) {
323
+ return actionResult.changed !== false;
324
+ }
325
+
326
+ return defaultShouldRevalidate;
327
+ }
328
+ ```
329
+
330
+ The callback receives `reason` (`"action"`, `"manual"`, or `"navigation"`),
331
+ `currentUrl`, `nextUrl`, `formMethod`, `formAction`, `formData`, `actionResult`,
332
+ and `defaultShouldRevalidate`. `url` is an alias for `nextUrl`.
333
+
334
+ For an already-loaded route, returning `false` also retains its data when navigating
335
+ to a different query string or parameter, including back/forward navigation. The URL
336
+ and params still update. Compare `currentUrl` with `nextUrl` before skipping a reload
337
+ when the destination needs different data. First visits, retries after loader errors,
338
+ and explicit `fetcher.load()` calls always load their destination.
339
+
340
+ This controls loader refreshes; it does not rerun actions or reload the document.
341
+ Existing page data stays visible during manual and post-action refreshes.
342
+
343
+ `const revalidate = useRevalidator(); await revalidate()` requests a manual refresh.
344
+ The task-detail examples return `{ changed: false }` for an unchanged title;
345
+ the corresponding loader is skipped.
346
+
347
+ ## Types follow the handler
348
+
349
+ ```tsx
350
+ const data = useLoaderData(taskRoute);
351
+ const result = useActionData(taskRoute);
352
+ const fetcher = useFetcher(taskRoute);
353
+ ```
354
+
355
+ Or use `useLoaderData<typeof loader>()` / `useActionData<typeof action>()` inside
356
+ a route module to avoid circular imports. The return type is inferred; you do
357
+ not repeat a data interface. TypeScript needs the route argument or function
358
+ type—it cannot infer an unrelated nearby export from a filename.
359
+
360
+ Route-based inference includes ordinary and client handler results. Data is
361
+ optional before a read or submission. Returned errors are excluded from data
362
+ types and appear in error state. `ErrorBoundary` handles route data failures;
363
+ fetcher errors stay local. `HydrateFallback` renders during an initial pending read; a completed loader returning `undefined` does not
364
+ remount the fallback during revalidation. Return a `Response` with a `Location` header to redirect.
365
+
366
+ ## Server rendering
367
+
368
+ ```tsx
369
+ import { loadRouter, serialize } from "smolter/server";
370
+
371
+ const router = await loadRouter(routes, incomingRequest);
372
+ const html = renderToString(
373
+ <RouterProvider router={router}><Routes /></RouterProvider>,
374
+ );
375
+ const hydrationJSON = serialize(router.getSnapshot());
376
+ ```
377
+
378
+ Create a fresh router per request. Embed the serialized snapshot in a JSON
379
+ script, restore it with `deserialize` from `smolter/client`, and pass it as
380
+ `hydrationData` to `createRouter`. This preserves loader errors through hydration
381
+ without exposing server stacks or causes. Error boundaries should display the
382
+ error message, not its stack.
383
+
384
+ ```ts
385
+ import { deserialize } from "smolter/client";
386
+ import { createRouter } from "smolter/react";
387
+ import routes from "virtual:smolter/routes";
388
+
389
+ const data = document.getElementById("smolter-data");
390
+ const router = createRouter(routes, {
391
+ browser: true,
392
+ hydrationData: data?.textContent ? deserialize(data.textContent) : undefined,
393
+ });
394
+ ```
395
+ Then hydrate with your framework. A client-only route renders its fallback on
396
+ the server and loads after hydration. Data must be JSON serializable; script
397
+ closing characters are escaped and errors omitted.
398
+
399
+ `handleRequest` dispatches data requests directly to registered server loaders
400
+ and actions. It does not manufacture a second counter API or run client handlers.
401
+ The SSR examples use the same server route definitions for HTML and data requests.
402
+
403
+ ## Package entries
404
+
405
+ Use `smolter/react` for React 18+ and `smolter/preact` for Preact 10.25+.
406
+ The root `smolter` entry (also available as `smolter/core`) is framework-independent.
407
+ Server helpers live in `smolter/server`; optional browser transport in `smolter/client`;
408
+ flat file routing in `smolter/vite` and `smolter/files`.
409
+
410
+ On JSR, install `@kasperrt/smolter` using the
411
+ [JSR package instructions](https://jsr.io/docs/npm-compatibility) and import
412
+ `@kasperrt/smolter/react` or `@kasperrt/smolter/preact`. Its `core`, `client`,
413
+ `server`, and `files` subpaths match npm. The Vite plugin is npm-only:
414
+ use `smolter/vite` from the npm package when adding file routing.
415
+
416
+ ## Size and checks
417
+
418
+ `pnpm size` measures every browser export with esbuild, Terser, and gzip level 9.
419
+ Frameworks are excluded; library dependencies are included. It compares the same
420
+ build settings against pinned wouter entries. Both adapters must be strictly
421
+ smaller than their corresponding wouter entry; exceeding either fails the check. Optional server transport is measured separately and included in SSR
422
+ example labels. File discovery is build-only.
423
+
424
+ <!-- size-comparison:start -->
425
+ | Browser exports | Gzip bytes |
426
+ | --- | ---: |
427
+ | `smolter/react` | 2445 |
428
+ | `smolter/preact` | 2445 |
429
+ | `smolter/react + smolter/client` | 2782 |
430
+ | `smolter/preact + smolter/client` | 2781 |
431
+ | `wouter@3.13.0` | 3091 |
432
+ | `wouter-preact@3.13.0` | 2450 |
433
+ | `react-router@8.4.0` | 60994 |
434
+ <!-- size-comparison:end -->
435
+
436
+ The table is generated by `pnpm size` from all exports of each listed entry,
437
+ using production ESM bundles, esbuild, Terser, and gzip level 9. React, React DOM,
438
+ and Preact are external in every bundle. Transitive library dependencies are
439
+ included. React Router provides a broader API; this compares complete entry
440
+ sizes, not equivalent feature sets or the minimum code for one route.
441
+ `smolter/client` adds browser transport for server loaders/actions; server-only
442
+ rendering code and the Vite plugin are not shipped to the browser.
443
+
444
+ Exact current measurements are generated in
445
+ [`packages/ui/src/size.json`](https://github.com/kasperrt/smolter/blob/main/packages/ui/src/size.json).
446
+
447
+ ```sh
448
+ pnpm fix # Formatting
449
+ pnpm check # Lint, types, tests, build, size gate
450
+ pnpm coverage # 100% lines per published source file, including browser tests
451
+ pnpm pack:smoke # React-only and Preact-only packed installs
452
+ pnpm build:examples # Four independent builds
453
+ pnpm smoke # Four-app browser checks, file routing
454
+ SMOLTER_ROUTES=manual SMOLTER_TEST_PORT=4473 pnpm smoke
455
+ pnpm smoke:production # Built SSR servers, hydration, reads, mutations
456
+ ```
457
+
458
+ The CI workflow defines checks for PRs and pushes to main. Coverage must remain at 100%
459
+ for every implementation file across the core, adapters, transport, and routing
460
+ packages. Reports are uploaded as CI artifacts.
461
+
462
+ Publishing requires passing CI, including the strict size gate. Direct commits
463
+ to main are allowed until 0.0.1 exists on npm and JSR; subsequent changes require
464
+ PRs. [Release workflow](https://github.com/kasperrt/smolter/blob/main/RELEASING.md).
465
+
466
+ To test a local package build in another project:
467
+
468
+ ```sh
469
+ pnpm --dir packages/smolter pack --pack-destination ../..
470
+ pnpm add smolter@file:/absolute/path/to/kasperrt-smolter-0.0.1-rc.1.tgz
471
+ ```
@@ -0,0 +1,2 @@
1
+ /** Client entry point. @module */
2
+ export * from "./internal/client/index.ts";
package/dist/client.js ADDED
@@ -0,0 +1,2 @@
1
+ /** Client entry point. @module */
2
+ export * from "./internal/client/index.js";
package/dist/core.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** Core entry point. @module */
2
+ export * from "./internal/core/index.ts";
package/dist/core.js ADDED
@@ -0,0 +1,2 @@
1
+ /** Core entry point. @module */
2
+ export * from "./internal/core/index.js";
@@ -0,0 +1,2 @@
1
+ /** Files entry point. @module */
2
+ export * from "./internal/files/index.ts";
package/dist/files.js ADDED
@@ -0,0 +1,2 @@
1
+ /** Files entry point. @module */
2
+ export * from "./internal/files/index.js";
@@ -0,0 +1,3 @@
1
+ import type { Snapshot } from "../core/index.ts";
2
+ /** Restore a trusted snapshot produced by smolter/server serialize, including its route error. */
3
+ export declare function deserialize(json: string): Snapshot;
@@ -0,0 +1,11 @@
1
+ /** Restore a trusted snapshot produced by smolter/server serialize, including its route error. */
2
+ export function deserialize(json) {
3
+ const snapshot = JSON.parse(json);
4
+ const error = snapshot.error;
5
+ if (error) {
6
+ snapshot.error = Object.assign(new Error(error.message), {
7
+ name: error.name,
8
+ });
9
+ }
10
+ return snapshot;
11
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Optional browser transport for server loaders and actions.
3
+ * @module
4
+ */
5
+ import type { ClientActionArgs, ClientLoaderArgs, Route, RouteModule } from "../core/index.ts";
6
+ export type { ClientActionArgs, ClientLoaderArgs } from "../core/index.ts";
7
+ export { deserialize } from "./deserialize.ts";
8
+ /** Browser-safe route exports, excluding server implementation code. */
9
+ export interface ClientRouteModule {
10
+ default?: unknown;
11
+ HydrateFallback?: unknown;
12
+ ErrorBoundary?: unknown;
13
+ clientLoader?(args: ClientLoaderArgs): unknown;
14
+ clientAction?(args: ClientActionArgs): unknown;
15
+ shouldRevalidate?: RouteModule["shouldRevalidate"];
16
+ }
17
+ /** Select the server loader and action proxies available on a browser route. */
18
+ export interface ServerHandlers {
19
+ loader?: boolean;
20
+ action?: boolean;
21
+ }
22
+ /** Browser transport for server routes. File-router builds generate this call. */
23
+ export declare function serverRoute<M extends RouteModule = RouteModule>(path: string, module: ClientRouteModule, handlers?: ServerHandlers): Route<M>;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Optional browser transport for server loaders and actions.
3
+ * @module
4
+ */
5
+ export { deserialize } from "./deserialize.js";
6
+ /** Browser transport for server routes. File-router builds generate this call. */
7
+ export function serverRoute(path, module, handlers = { loader: true, action: true }) {
8
+ const remote = async ({ request, signal }) => {
9
+ const url = new URL(request.url);
10
+ const response = await fetch(`/_smolter?url=${encodeURIComponent(url.pathname + url.search)}`, {
11
+ method: request.method,
12
+ signal,
13
+ ...(request.method !== "GET" && { body: await request.formData() }),
14
+ });
15
+ const location = response.headers.get("X-Smolter-Redirect");
16
+ if (location)
17
+ return new Response(null, {
18
+ status: 303,
19
+ headers: { Location: location },
20
+ });
21
+ const data = await response.json();
22
+ if (!response.ok)
23
+ return new Error(data.error ?? "Route request failed");
24
+ return data;
25
+ };
26
+ return {
27
+ ...module,
28
+ path,
29
+ ...(handlers.loader && { loader: remote }),
30
+ ...(handlers.action && { action: remote }),
31
+ ...(module.clientLoader && {
32
+ clientLoader: (args) => module.clientLoader({ ...args, serverLoader: () => remote(args) }),
33
+ }),
34
+ ...(module.clientAction && {
35
+ clientAction: (args) => {
36
+ const request = args.request.clone();
37
+ return module.clientAction({
38
+ ...args,
39
+ serverAction: () => remote({ ...args, request: request.clone() }),
40
+ });
41
+ },
42
+ }),
43
+ };
44
+ }
@@ -0,0 +1,3 @@
1
+ import type { Result } from "./types.ts";
2
+ /** Resolve returned or rejected errors into Error values without losing their cause. */
3
+ export declare function safeWrapAsync<T>(work: () => T | Promise<T>): Promise<Result<T>>;
@@ -0,0 +1,6 @@
1
+ /** Resolve returned or rejected errors into Error values without losing their cause. */
2
+ export function safeWrapAsync(work) {
3
+ return Promise.resolve()
4
+ .then(work)
5
+ .then((data) => (data instanceof Error ? { error: data } : { data }), (cause) => ({ error: new Error(String(cause), { cause }) }));
6
+ }
@@ -0,0 +1,6 @@
1
+ /** Framework-independent data routing. @module */
2
+ export * from "./errors.ts";
3
+ export * from "./match.ts";
4
+ export * from "./router.ts";
5
+ export * from "./types.ts";
6
+ export * from "./url.ts";
@@ -0,0 +1,6 @@
1
+ /** Framework-independent data routing. @module */
2
+ export * from "./errors.js";
3
+ export * from "./match.js";
4
+ export * from "./router.js";
5
+ export * from "./types.js";
6
+ export * from "./url.js";
@@ -0,0 +1,5 @@
1
+ import type { Match, Route, RouteModule } from "./types.ts";
2
+ /** Associate a URL pattern with a module while retaining its inferred handler types. */
3
+ export declare function defineRoute<const M extends RouteModule>(path: string, module: M): Route<M>;
4
+ /** Static segments, :params and a terminal * wildcard; first match wins. */
5
+ export declare function matchRoutes(routes: readonly Route[], pathname: string): Match | undefined;
@@ -0,0 +1,27 @@
1
+ /** Associate a URL pattern with a module while retaining its inferred handler types. */
2
+ export function defineRoute(path, module) {
3
+ return { ...module, path };
4
+ }
5
+ /** Static segments, :params and a terminal * wildcard; first match wins. */
6
+ export function matchRoutes(routes, pathname) {
7
+ const segments = pathname.replace(/\/$/, "").split("/");
8
+ for (const route of routes) {
9
+ const parts = route.path.replace(/\/$/, "").split("/");
10
+ const params = {};
11
+ const matched = (parts.length === segments.length || parts.at(-1) === "*") &&
12
+ parts.every((part, index) => {
13
+ const value = segments[index];
14
+ if (part === "*") {
15
+ params["*"] = segments.slice(index).join("/");
16
+ return true;
17
+ }
18
+ if (part[0] === ":" && value) {
19
+ params[part.slice(1)] = new URLSearchParams(`=${value.replace(/[+&]/g, encodeURIComponent)}`).get("");
20
+ return true;
21
+ }
22
+ return part === value;
23
+ });
24
+ if (matched)
25
+ return { route, params };
26
+ }
27
+ }