@kasperrt/smolter 0.3.0 → 0.3.2

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 (76) hide show
  1. package/.generated/client/deployment-check.ts +70 -0
  2. package/.generated/client/deployment.ts +124 -0
  3. package/.generated/client/index.ts +9 -1
  4. package/.generated/core/responses.ts +8 -3
  5. package/.generated/core/router.ts +32 -0
  6. package/.generated/core/types.ts +2 -0
  7. package/.generated/preact/client.tsx +48 -6
  8. package/.generated/preact/document.tsx +4 -2
  9. package/.generated/preact/lazy-route.ts +2 -0
  10. package/.generated/preact/root-context.ts +2 -0
  11. package/.generated/preact/route-types.ts +1 -0
  12. package/.generated/preact/router.tsx +9 -3
  13. package/.generated/server/application.tsx +83 -19
  14. package/.generated/server/handler.ts +8 -2
  15. package/.generated/server/http.ts +24 -2
  16. package/.generated/server/index.ts +17 -5
  17. package/.generated/server/preact.ts +1 -0
  18. package/.generated/server/private-response.ts +11 -0
  19. package/.generated/server/stream-document.ts +121 -0
  20. package/.generated/vite/application/lazy-files.ts +10 -2
  21. package/.generated/vite/application/vite.ts +9 -3
  22. package/.generated/vite/deployment.ts +46 -0
  23. package/.generated/vite/index.ts +15 -6
  24. package/.generated/vite/preact.ts +5 -0
  25. package/.generated/vite/react.ts +5 -0
  26. package/.generated/vite/transport.ts +4 -1
  27. package/CHANGELOG.md +19 -0
  28. package/README.md +79 -11
  29. package/dist/client/deployment-check.d.ts +6 -0
  30. package/dist/client/deployment-check.js +51 -0
  31. package/dist/client/deployment.d.ts +14 -0
  32. package/dist/client/deployment.js +74 -0
  33. package/dist/client/index.d.ts +3 -0
  34. package/dist/client/index.js +3 -2
  35. package/dist/core/responses.d.ts +1 -1
  36. package/dist/core/responses.js +3 -3
  37. package/dist/core/router.js +16 -0
  38. package/dist/core/types.d.ts +2 -0
  39. package/dist/preact/client.d.ts +6 -1
  40. package/dist/preact/client.js +27 -7
  41. package/dist/preact/document.js +4 -2
  42. package/dist/preact/lazy-route.d.ts +1 -0
  43. package/dist/preact/lazy-route.js +2 -1
  44. package/dist/preact/root-context.d.ts +2 -0
  45. package/dist/preact/route-types.d.ts +1 -0
  46. package/dist/preact/router.d.ts +1 -1
  47. package/dist/preact/router.js +3 -3
  48. package/dist/server/application.d.ts +4 -1
  49. package/dist/server/application.js +47 -13
  50. package/dist/server/handler.d.ts +3 -1
  51. package/dist/server/handler.js +5 -3
  52. package/dist/server/http.d.ts +5 -1
  53. package/dist/server/http.js +10 -1
  54. package/dist/server/index.d.ts +1 -1
  55. package/dist/server/index.js +12 -2
  56. package/dist/server/preact.d.ts +1 -1
  57. package/dist/server/private-response.d.ts +2 -0
  58. package/dist/server/private-response.js +13 -0
  59. package/dist/server/stream-document.d.ts +9 -0
  60. package/dist/server/stream-document.js +78 -0
  61. package/dist/vite/application/lazy-files.d.ts +1 -1
  62. package/dist/vite/application/lazy-files.js +6 -2
  63. package/dist/vite/application/vite.d.ts +2 -1
  64. package/dist/vite/application/vite.js +7 -4
  65. package/dist/vite/deployment.d.ts +8 -0
  66. package/dist/vite/deployment.js +36 -0
  67. package/dist/vite/index.d.ts +2 -1
  68. package/dist/vite/index.js +15 -7
  69. package/dist/vite/preact.d.ts +1 -0
  70. package/dist/vite/preact.js +2 -1
  71. package/dist/vite/react.d.ts +1 -0
  72. package/dist/vite/react.js +2 -1
  73. package/dist/vite/transport.d.ts +1 -1
  74. package/dist/vite/transport.js +3 -2
  75. package/jsr.json +1 -1
  76. package/package.json +3 -3
@@ -1,5 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { send, webRequest } from "./http.ts";
3
+ import { privateResponse } from "./private-response.ts";
3
4
 
4
5
  /** Server module produced by an application's SSR entrypoint. */
5
6
  export interface ServerBuild {
@@ -11,6 +12,8 @@ export interface RequestHandlerOptions {
11
12
  build: ServerBuild;
12
13
  /** Browser entry URL resolved from Vite's production manifest. */
13
14
  entry: string;
15
+ /** Must match the browser and Vite transport endpoint. */
16
+ dataEndpoint?: string;
14
17
  }
15
18
 
16
19
  export interface ServerIncomingMessage extends IncomingMessage {
@@ -25,6 +28,7 @@ export type NextHandler = (error?: unknown) => void;
25
28
  export function createRequestHandler({
26
29
  build,
27
30
  entry,
31
+ dataEndpoint = "/_smolter",
28
32
  }: RequestHandlerOptions): (
29
33
  incoming: ServerIncomingMessage,
30
34
  outgoing: ServerResponse,
@@ -40,7 +44,9 @@ export function createRequestHandler({
40
44
  incoming.originalUrl ?? incoming.url ?? "/",
41
45
  "http://local",
42
46
  );
43
- const dataRequest = url.pathname === "/_smolter";
47
+ const dataRequest = url.pathname === dataEndpoint;
48
+ if (dataRequest) outgoing.setHeader("Cache-Control", "private, no-store");
49
+
44
50
  const target = dataRequest ? url.searchParams.get("url") : undefined;
45
51
 
46
52
  if (
@@ -61,7 +67,7 @@ export function createRequestHandler({
61
67
  ? await build.handleDataRequest(request)
62
68
  : await build.default(request, entry);
63
69
 
64
- await send(response, outgoing);
70
+ await send(dataRequest ? privateResponse(response) : response, outgoing);
65
71
  };
66
72
 
67
73
  return handle().catch(next);
@@ -1,4 +1,6 @@
1
1
  import type { ServerResponse } from "node:http";
2
+ import { Readable, Transform } from "node:stream";
3
+ import { pipeline } from "node:stream/promises";
2
4
  import type { ServerIncomingMessage } from "./handler.ts";
3
5
 
4
6
  export async function webRequest(
@@ -34,7 +36,11 @@ export async function webRequest(
34
36
  );
35
37
  }
36
38
 
37
- export async function send(response: Response, outgoing: ServerResponse) {
39
+ interface StreamingResponse extends ServerResponse {
40
+ flush?: () => void;
41
+ }
42
+
43
+ export async function send(response: Response, outgoing: StreamingResponse) {
38
44
  outgoing.statusCode = response.status;
39
45
  response.headers.forEach((value, name) => {
40
46
  if (name !== "set-cookie") outgoing.setHeader(name, value);
@@ -51,5 +57,21 @@ export async function send(response: Response, outgoing: ServerResponse) {
51
57
  ),
52
58
  );
53
59
 
54
- outgoing.end(new Uint8Array(await response.arrayBuffer()));
60
+ if (!response.body) {
61
+ outgoing.end();
62
+ return;
63
+ }
64
+
65
+ await pipeline(
66
+ Readable.fromWeb(
67
+ response.body as import("node:stream/web").ReadableStream<Uint8Array>,
68
+ ),
69
+ new Transform({
70
+ transform(chunk, _encoding, done) {
71
+ done(null, chunk);
72
+ outgoing.flush?.();
73
+ },
74
+ }),
75
+ outgoing,
76
+ );
55
77
  }
@@ -1,3 +1,4 @@
1
+ import { privateResponse } from "./private-response.ts";
1
2
  /**
2
3
  * Request-scoped server rendering and HTTP data dispatch.
3
4
  * @module
@@ -63,7 +64,7 @@ export async function loadRouter(
63
64
  export { serialize } from "../core/index.ts";
64
65
 
65
66
  /** Dispatch an HTTP data request to a registered server loader or action. */
66
- export async function handleRequest(
67
+ async function dispatchRequest(
67
68
  routes: readonly Route[],
68
69
  request: Request,
69
70
  ): Promise<Response> {
@@ -93,10 +94,13 @@ export async function handleRequest(
93
94
  if (result.data instanceof Response) {
94
95
  const location = result.data.headers.get("Location");
95
96
 
96
- if (location)
97
- return new Response(null, {
98
- headers: { "X-Smolter-Redirect": location },
99
- });
97
+ if (location) {
98
+ const headers = new Headers(result.data.headers);
99
+ headers.delete("Location");
100
+ headers.set("X-Smolter-Redirect", location);
101
+
102
+ return new Response(null, { headers });
103
+ }
100
104
 
101
105
  return result.data;
102
106
  }
@@ -105,3 +109,11 @@ export async function handleRequest(
105
109
  headers: { "Cache-Control": "no-store" },
106
110
  });
107
111
  }
112
+
113
+ /** Dispatch a server data request with an explicit private cache policy. */
114
+ export async function handleRequest(
115
+ routes: readonly Route[],
116
+ request: Request,
117
+ ): Promise<Response> {
118
+ return privateResponse(await dispatchRequest(routes, request));
119
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  export {
4
4
  dispatchRoute,
5
+ type RenderApplicationOptions,
5
6
  renderApplication,
6
7
  } from "./application.tsx";
7
8
  export * from "./index.ts";
@@ -0,0 +1,11 @@
1
+ /** Transport data must not be cached by shared intermediaries or the browser. */
2
+ export function privateResponse(response: Response): Response {
3
+ const headers = new Headers(response.headers);
4
+ headers.set("Cache-Control", "private, no-store");
5
+
6
+ return new Response(response.body, {
7
+ status: response.status,
8
+ statusText: response.statusText,
9
+ headers,
10
+ });
11
+ }
@@ -0,0 +1,121 @@
1
+ import { serializeData } from "../core/serialize.ts";
2
+
3
+ // Deferred failures must remain rejected promises after hydration so Await's
4
+ // errorElement has the same behavior on the server and in the browser.
5
+ export async function resolveHydration(value: unknown): Promise<unknown> {
6
+ if (value instanceof Promise)
7
+ return value.then(resolveHydration, (error: unknown) => ({
8
+ __smolterRejected: {
9
+ message: error instanceof Error ? error.message : String(error),
10
+ },
11
+ }));
12
+
13
+ if (Array.isArray(value)) return Promise.all(value.map(resolveHydration));
14
+
15
+ if (
16
+ value &&
17
+ typeof value === "object" &&
18
+ Object.getPrototypeOf(value) === Object.prototype
19
+ ) {
20
+ const entries = await Promise.all(
21
+ Object.entries(value).map(async ([key, field]) => [
22
+ key,
23
+ await resolveHydration(field),
24
+ ]),
25
+ );
26
+
27
+ return Object.fromEntries(entries);
28
+ }
29
+
30
+ return value;
31
+ }
32
+
33
+ export interface StreamDocumentOptions {
34
+ hydration: Promise<unknown>;
35
+ nonce?: string;
36
+ signal: AbortSignal;
37
+ dispose: () => void;
38
+ }
39
+
40
+ /** Stream the shell immediately, then finalize the JSON snapshot before module scripts execute. */
41
+ export function streamDocument(
42
+ source: ReadableStream<Uint8Array>,
43
+ options: StreamDocumentOptions,
44
+ ): ReadableStream<Uint8Array> {
45
+ const reader = source.getReader();
46
+ const encoder = new TextEncoder();
47
+ const decoder = new TextDecoder();
48
+ let suffix = "";
49
+ let first = true;
50
+ let cancelled = false;
51
+ let finish!: () => void;
52
+ const interrupted = new Promise<void>((resolve) => {
53
+ finish = resolve;
54
+ });
55
+
56
+ const abort = () => {
57
+ finish();
58
+ void reader.cancel().catch(() => {});
59
+ };
60
+ options.signal.addEventListener("abort", abort, { once: true });
61
+ if (options.signal.aborted) abort();
62
+
63
+ function dispose() {
64
+ options.signal.removeEventListener("abort", abort);
65
+ options.dispose();
66
+ }
67
+
68
+ return new ReadableStream({
69
+ async pull(controller) {
70
+ const result = await reader.read().catch((error: unknown) => ({ error }));
71
+
72
+ if ("error" in result) {
73
+ dispose();
74
+ controller.error(result.error);
75
+ return;
76
+ }
77
+
78
+ if (result.done) {
79
+ if (!options.signal.aborted) {
80
+ const value = await Promise.race([options.hydration, interrupted]);
81
+ if (cancelled) return;
82
+ if (options.signal.aborted) {
83
+ dispose();
84
+ controller.close();
85
+ return;
86
+ }
87
+ const hydration = serializeData(value);
88
+ const nonce = options.nonce
89
+ ? ` nonce="${options.nonce.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;")}"`
90
+ : "";
91
+ controller.enqueue(
92
+ encoder.encode(
93
+ `<script type="application/json"${nonce}>${hydration}</script><script${nonce}>(function(){var script=document.currentScript,payload=script.previousElementSibling,data=document.getElementById("smolter-data");if(data)data.textContent=payload.textContent;payload.remove();script.remove()})()</script>${suffix}`,
94
+ ),
95
+ );
96
+ }
97
+
98
+ dispose();
99
+ controller.close();
100
+ return;
101
+ }
102
+
103
+ let chunk = decoder.decode(result.value, { stream: true });
104
+ if (first && !/^<!doctype/i.test(chunk))
105
+ chunk = `<!doctype html>${chunk}`;
106
+ first = false;
107
+ const closing = chunk.lastIndexOf("</body>");
108
+ if (closing !== -1) {
109
+ suffix = chunk.slice(closing);
110
+ chunk = chunk.slice(0, closing);
111
+ }
112
+ controller.enqueue(encoder.encode(chunk));
113
+ },
114
+ async cancel() {
115
+ cancelled = true;
116
+ finish();
117
+ dispose();
118
+ await reader.cancel();
119
+ },
120
+ });
121
+ }
@@ -5,7 +5,10 @@ import { type Plugin, transformWithOxc } from "vite";
5
5
 
6
6
  // Reuse Smolter's file discovery and server-export stripping. Only the browser
7
7
  // route manifest changes: matched routes load before their data is committed.
8
- export function lazyFiles(directory: string): Plugin {
8
+ export function lazyFiles(
9
+ directory: string,
10
+ dataEndpoint = "/_smolter",
11
+ ): Plugin {
9
12
  let root: string;
10
13
  const modules = new Map<string, string>();
11
14
 
@@ -64,6 +67,8 @@ export function lazyFiles(directory: string): Plugin {
64
67
 
65
68
  if (routes instanceof Error) return this.error(routes.message);
66
69
 
70
+ modules.clear();
71
+
67
72
  const definitions: string[] = [];
68
73
 
69
74
  for (const route of routes) {
@@ -72,7 +77,10 @@ export function lazyFiles(directory: string): Plugin {
72
77
  const source = await readFile(route.file, "utf8");
73
78
  const transformed = await transformWithOxc(source, route.file);
74
79
  const ast = this.parse(transformed.code);
75
- const fields: Record<string, string | boolean> = { path: route.path };
80
+ const fields: Record<string, string | boolean> = {
81
+ path: route.path,
82
+ dataEndpoint,
83
+ };
76
84
 
77
85
  for (const statement of ast.body) {
78
86
  if (statement.type === "ExportDefaultDeclaration") {
@@ -6,6 +6,7 @@ import { lazyFiles } from "./lazy-files.ts";
6
6
 
7
7
  export interface ApplicationOptions {
8
8
  directory?: string;
9
+ dataEndpoint?: string;
9
10
  ssr?: boolean;
10
11
  splitRoutes?: boolean;
11
12
  resourceRequest?: (url: string) => boolean;
@@ -42,6 +43,7 @@ export const preactConfig: UserConfig = {
42
43
 
43
44
  export function applicationPlugins({
44
45
  directory = "src/routes",
46
+ dataEndpoint = "/_smolter",
45
47
  ssr = true,
46
48
  splitRoutes = true,
47
49
  resourceRequest,
@@ -72,7 +74,9 @@ export function applicationPlugins({
72
74
  configureServer(server) {
73
75
  // Own the HTTP adapter so redirects retain authentication cookies.
74
76
  server.middlewares.use((incoming, outgoing, next) => {
75
- if (!incoming.url?.startsWith("/_smolter?")) return next();
77
+ if (!incoming.url?.startsWith(`${dataEndpoint}?`)) return next();
78
+
79
+ outgoing.setHeader("Cache-Control", "private, no-store");
76
80
 
77
81
  const run = async () => {
78
82
  const target = new URL(
@@ -106,6 +110,8 @@ export function applicationPlugins({
106
110
  )
107
111
  return next();
108
112
 
113
+ outgoing.setHeader("Cache-Control", "private, no-store");
114
+
109
115
  const run = async () => {
110
116
  const request = await webRequest(incoming, outgoing);
111
117
  const entry = await server.ssrLoadModule("/src/entry.server.tsx");
@@ -138,7 +144,7 @@ export function applicationPlugins({
138
144
  return [
139
145
  application,
140
146
  browserBoundary(),
141
- ...(splitRoutes ? [lazyFiles(directory)] : []),
142
- routerPlugin({ directory, framework: "preact" }),
147
+ ...(splitRoutes ? [lazyFiles(directory, dataEndpoint)] : []),
148
+ routerPlugin({ directory, framework: "preact", dataEndpoint }),
143
149
  ];
144
150
  }
@@ -0,0 +1,46 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { Plugin } from "vite";
3
+
4
+ /** Deployment manifest configuration. Serve the emitted file with Cache-Control: no-store. */
5
+ export interface DeploymentPluginOptions {
6
+ /** Public URL, optionally on an asset host. Defaults to Vite base + smolter-manifest.json. */
7
+ manifestURL?: string;
8
+ }
9
+
10
+ /** Emit a build manifest and expose its identity through virtual:smolter/deployment. */
11
+ export function deploymentPlugin(
12
+ options: DeploymentPluginOptions = {},
13
+ ): Plugin {
14
+ const version = randomUUID();
15
+ const virtualId = "\0virtual:smolter/deployment";
16
+ let enabled = false;
17
+ let manifestURL = "";
18
+
19
+ return {
20
+ name: "smolter-deployment",
21
+ configResolved(config) {
22
+ enabled = config.command === "build" && !config.build.ssr;
23
+ manifestURL =
24
+ options.manifestURL ?? `${config.base}smolter-manifest.json`;
25
+ },
26
+ resolveId(id) {
27
+ if (id === "virtual:smolter/deployment") return virtualId;
28
+ },
29
+ load(id) {
30
+ if (id !== virtualId) return;
31
+
32
+ const deployment = enabled ? { version, manifestURL } : null;
33
+
34
+ return `export default ${JSON.stringify(deployment)};`;
35
+ },
36
+ generateBundle() {
37
+ if (!enabled) return;
38
+
39
+ this.emitFile({
40
+ type: "asset",
41
+ fileName: "smolter-manifest.json",
42
+ source: JSON.stringify({ version }),
43
+ });
44
+ },
45
+ };
46
+ }
@@ -1,11 +1,12 @@
1
1
  import { readFile } from "node:fs/promises";
2
- import { resolve } from "node:path";
2
+ import { relative, resolve } from "node:path";
3
3
  import { flatRoutes } from "../files/index.ts";
4
4
  import { type Plugin, transformWithOxc } from "vite";
5
5
  import { configureTransport } from "./transport.ts";
6
6
 
7
7
  export interface FileRouterOptions {
8
8
  directory?: string;
9
+ dataEndpoint?: string;
9
10
  clientRoutes?: string;
10
11
  serverRoutes?: string;
11
12
  framework?: "react" | "preact";
@@ -14,6 +15,7 @@ export interface FileRouterOptions {
14
15
  /** Build-time flat route discovery and the optional server data transport. */
15
16
  export function routerPlugin({
16
17
  directory = "src/routes",
18
+ dataEndpoint = "/_smolter",
17
19
  clientRoutes,
18
20
  serverRoutes,
19
21
  framework = "react",
@@ -166,7 +168,7 @@ export function routerPlugin({
166
168
  definitions.push(
167
169
  options?.ssr || !Object.keys(handlers).length
168
170
  ? `{ ...route${index}, path: ${JSON.stringify(route.path)} }`
169
- : `serverRoute(${JSON.stringify(route.path)}, route${index}, ${JSON.stringify(handlers)})`,
171
+ : `serverRoute(${JSON.stringify(route.path)}, route${index}, ${JSON.stringify({ ...handlers, dataEndpoint })})`,
170
172
  );
171
173
  }
172
174
 
@@ -185,11 +187,18 @@ export function routerPlugin({
185
187
  server.watcher.add(root);
186
188
 
187
189
  const refresh = (file: string) => {
188
- if (!file.startsWith(`${root}/`)) return;
190
+ if (clientRoutes || serverRoutes) return;
189
191
 
190
- const module = server.moduleGraph.getModuleById(virtualId);
192
+ const path = relative(root, file).replaceAll("\\", "/");
191
193
 
192
- if (module) server.moduleGraph.invalidateModule(module);
194
+ if (!/^(?:[^/]+\.[jt]sx?|[^/]+\/route\.[jt]sx?)$/.test(path)) return;
195
+ if (path.endsWith(".d.ts")) return;
196
+
197
+ for (const environment of Object.values(server.environments)) {
198
+ const module = environment.moduleGraph.getModuleById(virtualId);
199
+
200
+ if (module) environment.moduleGraph.invalidateModule(module);
201
+ }
193
202
 
194
203
  server.ws.send({ type: "full-reload" });
195
204
  };
@@ -203,7 +212,7 @@ export function routerPlugin({
203
212
  server.watcher.off("unlink", refresh);
204
213
  });
205
214
 
206
- configureTransport(server, publicId, framework);
215
+ configureTransport(server, publicId, framework, dataEndpoint);
207
216
  },
208
217
  };
209
218
  }
@@ -22,3 +22,8 @@ export {
22
22
  applicationPlugins,
23
23
  preactConfig,
24
24
  } from "./application/vite.ts";
25
+
26
+ export {
27
+ type DeploymentPluginOptions,
28
+ deploymentPlugin,
29
+ } from "./deployment.ts";
@@ -16,3 +16,8 @@ export function routerPlugin(options: FileRouterOptions = {}): Plugin {
16
16
 
17
17
  /** File routing is the default; this alias keeps configuration explicit. */
18
18
  export const fileRouter = routerPlugin;
19
+
20
+ export {
21
+ type DeploymentPluginOptions,
22
+ deploymentPlugin,
23
+ } from "./deployment.ts";
@@ -4,9 +4,12 @@ export function configureTransport(
4
4
  server: ViteDevServer,
5
5
  publicId: string,
6
6
  framework: "react" | "preact" = "react",
7
+ dataEndpoint = "/_smolter",
7
8
  ): void {
8
9
  server.middlewares.use((incoming, outgoing, next) => {
9
- if (!incoming.url?.startsWith("/_smolter?")) return next();
10
+ if (!incoming.url?.startsWith(`${dataEndpoint}?`)) return next();
11
+
12
+ outgoing.setHeader("Cache-Control", "private, no-store");
10
13
 
11
14
  const dispatch = async () => {
12
15
  const origin = `http://${incoming.headers.host}`;
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.2
4
+
5
+ - Configure transport endpoints consistently across browser handlers, file routes, Express and development middleware for multiple apps sharing one origin.
6
+ - Refresh root data after action redirects, preserve redirect cookies and synchronize hash-history redirects.
7
+ - Recover missing initial route chunks when deployment metadata is passed to mountApplication.
8
+ - Stream Preact SSR with concurrent root/route loading, deferred data, root error boundaries and disconnect cleanup. Hydration begins after the document stream completes; requires preact-render-to-string >=6.8.1.
9
+ - Enforce private, no-store on route-data responses, including errors and mutations.
10
+ - Expose loader execution reason for explicit navigation/revalidation handling.
11
+ - Add regression coverage, production streaming browser tests and updated API documentation.
12
+
13
+ ## 0.3.1
14
+
15
+ - Refresh browser and SSR file-route discovery on additions, renames and removals; preserve normal Vite HMR for colocated helpers and components.
16
+
17
+ - Add optional production deployment manifests through `deploymentPlugin` for both React and Preact Vite integrations.
18
+ - Add `watchDeployment` to both client entrypoints: check before navigation and Back/Forward, recover stale Vite chunks after confirmed build changes, and preserve the requested URL.
19
+ - Skip hash-only links, development builds and SSR; bound failed checks and prevent repeated reloads. Keep routing-only bundles unchanged.
20
+ - Document production cache headers, CDN/application-specific manifest paths, unsaved state and rollout limitations.
21
+
3
22
  ## 0.3.0
4
23
 
5
24
  ### Migration from 0.2.0
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # smolter _
2
2
 
3
3
  <!-- bundlejs:start -->
4
- [![react gzip size](https://deno.bundlejs.com/?q=%40kasperrt%2Fsmolter%400.3.0%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.3.0%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.3.0%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.3.0%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)
4
+ [![react gzip size](https://deno.bundlejs.com/?q=%40kasperrt%2Fsmolter%400.3.2%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.3.2%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.3.2%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.3.2%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
6
  <!-- bundlejs:end -->
7
7
 
8
8
  A typed data router for React and Preact. **Zero runtime dependencies.**
@@ -20,6 +20,8 @@ Start with [loaders](https://kasperrt.github.io/smolter/react/docs/loaders/), [a
20
20
 
21
21
  ## Contents
22
22
 
23
+ - [Deployment updates](#deployment-updates)
24
+
23
25
  - [Install](#install)
24
26
  - [Try the four standalone examples](#try-the-four-standalone-examples)
25
27
  - [Choose file routing or manual routes](#choose-file-routing-or-manual-routes)
@@ -532,7 +534,7 @@ consume request bodies with Express body parsers before this handler.
532
534
  The adapter has no Express dependency and forwards failures to `next(error)`.
533
535
 
534
536
  See the [Express guide](https://kasperrt.github.io/smolter/react/docs/express)
535
- for a complete server example and current buffering limitations.
537
+ for a complete server example and streaming and hydration behavior.
536
538
 
537
539
  ## Resource subscriptions
538
540
 
@@ -567,6 +569,22 @@ This optional entry does not contribute to the base React bundle measurement.
567
569
 
568
570
  ## Size and checks
569
571
 
572
+ | Need | Public import |
573
+ | --- | --- |
574
+ | Navigation only | `@kasperrt/smolter/react` or `/preact`, using `createRouter` |
575
+ | Loaders, actions, forms and fetchers | Same entry, using `createDataRouter` |
576
+ | Browser server-request transport | Renderer `/client` subpath |
577
+ | Preact application hydration | `@kasperrt/smolter/preact/client` |
578
+ | Preact document components | `@kasperrt/smolter/preact/document` |
579
+ | Lazy Preact route modules | `@kasperrt/smolter/preact/lazy-route` |
580
+ | Optional React resources | `@kasperrt/smolter/react/data` |
581
+ | Server rendering / Vite integration | Renderer `/server` / `/vite` subpaths; keep out of browser imports |
582
+
583
+ The measurements below describe retained exports, not the cost of an import
584
+ statement. Browser transport rows include the router and transport together;
585
+ application rows include the listed modules together. Server and Vite modules
586
+ have no browser cost when kept in their intended server/build entrypoints.
587
+
570
588
  Use `createRouter` for navigation only, or `createDataRouter` for loaders,
571
589
  actions, fetchers and revalidation. Both are named exports from
572
590
  `@kasperrt/smolter/preact` and `/react`. No extra package or plugin is required.
@@ -581,17 +599,19 @@ a claim of identical features.
581
599
  <!-- size-comparison:start -->
582
600
  | Browser exports | Gzip bytes |
583
601
  | --- | ---: |
602
+ | `React Router: declarative routing subset` | 12882 |
603
+ | `React Router: data routing subset` | 29780 |
584
604
  | `React: routing-only named exports` | 1245 |
585
605
  | `Preact: routing-only named exports` | 1405 |
586
- | `@kasperrt/smolter/react` | 2873 |
587
- | `@kasperrt/smolter/preact` | 4748 |
588
- | `@kasperrt/smolter/react + @kasperrt/smolter/react/client` | 3219 |
589
- | `@kasperrt/smolter/preact + server transport` | 5059 |
590
- | `@kasperrt/smolter/preact + client + document (application without lazy routes)` | 7415 |
591
- | `@kasperrt/smolter/preact + client + document + lazy-route (file-routed application)` | 7696 |
606
+ | `@kasperrt/smolter/react` | 2948 |
607
+ | `@kasperrt/smolter/preact` | 4842 |
608
+ | `@kasperrt/smolter/react + @kasperrt/smolter/react/client` | 4046 |
609
+ | `@kasperrt/smolter/preact + server transport` | 5886 |
610
+ | `@kasperrt/smolter/preact + client + document (application without lazy routes)` | 8418 |
611
+ | `@kasperrt/smolter/preact + client + document + lazy-route (file-routed application)` | 8704 |
592
612
  | `@kasperrt/smolter/react/data (optional resource API)` | 2962 |
593
- | `React: common routing/data exports` | 2683 |
594
- | `Preact: common routing/data exports` | 2966 |
613
+ | `React: common routing/data exports` | 2761 |
614
+ | `Preact: common routing/data exports` | 3046 |
595
615
  | `wouter: routing subset (Router, Route, Switch, Link, useParams, useLocation)` | 2854 |
596
616
  | `wouter-preact: same routing subset` | 2211 |
597
617
  | `wouter@3.13.0` | 3091 |
@@ -604,6 +624,13 @@ The common-export rows measure the same named API set: `createDataRouter`,
604
624
  `useLoaderData`, `useActionData`, `useParams`, `useLocation`,
605
625
  `useRevalidator` and `useFetcher`. Preact’s link and submission APIs currently
606
626
  have additional behavior; equal names do not imply identical implementations.
627
+ React Router's declarative subset retains `BrowserRouter`, `Routes`, `Route`,
628
+ `Link`, `useParams`, and `useLocation`. Its data subset retains
629
+ `createBrowserRouter`, `RouterProvider`, `Link`, `Form`, `useLoaderData`,
630
+ `useActionData`, `useParams`, `useLocation`, `useRevalidator`, and `useFetcher`.
631
+ React Router has no dedicated Preact entry; these are React API measurements,
632
+ not a Preact compatibility build. The selected APIs have different capabilities.
633
+
607
634
  The routing-only rows retain `createRouter`, `defineRoute`, `RouterProvider`,
608
635
  `Routes`, `Link`, `useParams` and `useLocation`. Full Preact also exposes richer
609
636
  application APIs. These tiers overlap: do not add the rows together.
@@ -683,3 +710,44 @@ pnpm add /absolute/path/to/kasperrt-smolter-0.0.1.tgz
683
710
  ```
684
711
 
685
712
  Workspace structure and rendering checks are documented in [CONTRIBUTING.md](./CONTRIBUTING.md).
713
+
714
+ ## Deployment updates
715
+
716
+ For production tabs that survive a deployment, add `deploymentPlugin()` from
717
+ `@kasperrt/smolter/react/vite` or `/preact/vite`, then connect the router:
718
+
719
+ ```ts
720
+ import deployment from "virtual:smolter/deployment";
721
+ import { watchDeployment } from "@kasperrt/smolter/preact/client";
722
+
723
+ watchDeployment(router, deployment);
724
+ ```
725
+
726
+ Serve the emitted `smolter-manifest.json` with `Cache-Control: no-store` and HTML
727
+ with `no-cache`. Navigation checks its build version before loading another route;
728
+ a newer version reloads the destination, including its query and hash. Vite chunk
729
+ failures are checked again. Hash-only links skip the check, failed checks continue
730
+ normally, and a session marker prevents reload loops. Development/SSR builds
731
+ disable the helper. This optional client module adds nothing to the routing-only
732
+ entrypoints and never replays form submissions.
733
+
734
+ See [deployment updates](https://kasperrt.github.io/smolter/react/docs/deployments/)
735
+ for Express, CDN paths, TypeScript declarations and unsaved-state handling.
736
+
737
+
738
+ ### Shared origins and streaming SSR
739
+
740
+ Set the same `dataEndpoint` in `applicationPlugins` (or `fileRouter`), browser `serverRoute` handlers and `createRequestHandler`. For example, Admin can use `/admin/_smolter` while Platform uses `/_smolter`. Transport responses, including errors and redirects, use `Cache-Control: private, no-store`.
741
+
742
+ Pass deployment metadata before mounting so recovery covers the initial route import:
743
+
744
+ ```ts
745
+ import deployment from "virtual:smolter/deployment";
746
+ import { mountApplication } from "@kasperrt/smolter/preact/client";
747
+
748
+ await mountApplication(routes, root, { deployment });
749
+ ```
750
+
751
+ Preact SSR starts root and route loaders concurrently. Return nested promises and render them with `Await` inside `Suspense` to stream a shell before optional data resolves. The Node/Express adapter streams the response; hydration starts after the document stream finishes. Root-loader failures render the root error boundary. Vite development HTML transformation still buffers the document.
752
+
753
+ Successful action redirects refresh registered root data without replaying the mutation. Loader arguments expose `reason` to distinguish initial loads, navigation, revalidation and fetcher reads without comparing browser URLs.
@@ -0,0 +1,6 @@
1
+ import type { DeploymentOptions } from "./deployment.ts";
2
+ export declare function deploymentCheck(options: DeploymentOptions): {
3
+ changed: () => Promise<boolean>;
4
+ reload: (target: URL) => Promise<boolean>;
5
+ dispose(): void;
6
+ };