@kasperrt/smolter 0.3.0 → 0.3.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.
@@ -0,0 +1,161 @@
1
+ import type { NavigationRouter } from "../core/index.ts";
2
+
3
+ /** Build identity and the fresh, non-cacheable manifest URL emitted by deploymentPlugin. */
4
+ export interface DeploymentOptions {
5
+ version: string;
6
+ manifestURL: string;
7
+ /** Maximum time to wait for the update check before continuing navigation. */
8
+ timeout?: number;
9
+ /** Override document navigation, for example to ask about unsaved changes. */
10
+ reload?: (destination: URL) => void;
11
+ }
12
+
13
+ /** Check for deployment changes before navigation. Returns a cleanup function. */
14
+ export function watchDeployment(
15
+ router: NavigationRouter,
16
+ options: DeploymentOptions | null,
17
+ ): () => void {
18
+ if (!options || typeof window === "undefined") return () => {};
19
+
20
+ const { version, manifestURL, timeout = 2000 } = options;
21
+ const navigate = router.navigate;
22
+ const dispose = router.dispose;
23
+ const manifest = new URL(manifestURL, location.href);
24
+ const key = `smolter:deployment:${manifest.href}:${version}`;
25
+ let sequence = 0;
26
+ let stopped = false;
27
+ let replay = false;
28
+ let destination = new URL(location.href);
29
+ let pending: Promise<boolean> | undefined;
30
+ let controller: AbortController | undefined;
31
+
32
+ function changed() {
33
+ if (pending) return pending;
34
+
35
+ controller = new AbortController();
36
+ const timer = setTimeout(() => controller?.abort(), timeout);
37
+ pending = fetch(manifest, { cache: "no-store", signal: controller.signal })
38
+ .then(async (response) => {
39
+ if (!response.ok) return false;
40
+
41
+ const data: unknown = await response.json();
42
+
43
+ return (
44
+ typeof data === "object" &&
45
+ data !== null &&
46
+ "version" in data &&
47
+ typeof data.version === "string" &&
48
+ data.version.length > 0 &&
49
+ data.version !== version
50
+ );
51
+ })
52
+ .catch(() => false)
53
+ .finally(() => {
54
+ clearTimeout(timer);
55
+ pending = undefined;
56
+ });
57
+
58
+ return pending;
59
+ }
60
+
61
+ async function reload(target: URL) {
62
+ // If storage is unavailable, do not risk reloading the same cached document forever.
63
+ const allowed = await Promise.resolve()
64
+ .then(() => {
65
+ if (sessionStorage.getItem(key)) return false;
66
+
67
+ sessionStorage.setItem(key, "1");
68
+
69
+ return true;
70
+ })
71
+ .catch(() => false);
72
+
73
+ if (allowed && !stopped) {
74
+ if (options?.reload) options.reload(target);
75
+ if (!options?.reload) {
76
+ if (target.href === location.href) location.reload();
77
+ if (target.href !== location.href) location.assign(target.href);
78
+ }
79
+ }
80
+
81
+ return allowed && !stopped;
82
+ }
83
+
84
+ const guarded: NavigationRouter["navigate"] = async (href, operation) => {
85
+ const target = new URL(href, router.getSnapshot().url);
86
+ const currentURL = new URL(router.getSnapshot().url);
87
+ const current = ++sequence;
88
+ destination = target;
89
+
90
+ if (
91
+ target.origin === location.origin &&
92
+ (target.pathname !== currentURL.pathname ||
93
+ target.search !== currentURL.search) &&
94
+ (await changed())
95
+ ) {
96
+ if (current !== sequence || stopped || (await reload(target))) return;
97
+ }
98
+
99
+ if (current === sequence && !stopped) await navigate(href, operation);
100
+ };
101
+
102
+ const pop = (event: PopStateEvent) => {
103
+ if (replay) return;
104
+
105
+ const target = new URL(location.href);
106
+ const previous = new URL(router.getSnapshot().url);
107
+
108
+ if (
109
+ target.pathname === previous.pathname &&
110
+ target.search === previous.search
111
+ )
112
+ return;
113
+
114
+ event.stopImmediatePropagation();
115
+ const current = ++sequence;
116
+ destination = target;
117
+ void changed().then(async (updated) => {
118
+ if (stopped || current !== sequence) return;
119
+ if (updated && (await reload(target))) return;
120
+
121
+ replay = true;
122
+ window.dispatchEvent(
123
+ new PopStateEvent("popstate", { state: event.state }),
124
+ );
125
+ replay = false;
126
+ });
127
+ };
128
+
129
+ const preloadError = () => {
130
+ if (router.getSnapshot().state === "submitting") return;
131
+
132
+ const current = sequence;
133
+ const target = destination;
134
+ void changed().then((updated) => {
135
+ if (updated && current === sequence && !stopped) return reload(target);
136
+ });
137
+ };
138
+
139
+ function cleanup() {
140
+ stopped = true;
141
+ sequence++;
142
+ controller?.abort();
143
+ window.removeEventListener("popstate", pop, true);
144
+ window.removeEventListener("vite:preloadError", preloadError);
145
+
146
+ if (router.navigate === guarded) router.navigate = navigate;
147
+ if (router.dispose === guardedDispose) router.dispose = dispose;
148
+ }
149
+
150
+ function guardedDispose() {
151
+ cleanup();
152
+ dispose();
153
+ }
154
+
155
+ router.navigate = guarded;
156
+ router.dispose = guardedDispose;
157
+ window.addEventListener("popstate", pop, true);
158
+ window.addEventListener("vite:preloadError", preloadError);
159
+
160
+ return cleanup;
161
+ }
@@ -83,3 +83,5 @@ export function serverRoute<M extends RouteModule = RouteModule>(
83
83
  }),
84
84
  } as Route<M>;
85
85
  }
86
+
87
+ export { type DeploymentOptions, watchDeployment } from "./deployment.ts";
@@ -64,6 +64,8 @@ export function lazyFiles(directory: string): Plugin {
64
64
 
65
65
  if (routes instanceof Error) return this.error(routes.message);
66
66
 
67
+ modules.clear();
68
+
67
69
  const definitions: string[] = [];
68
70
 
69
71
  for (const route of routes) {
@@ -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,5 +1,5 @@
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";
@@ -185,11 +185,18 @@ export function routerPlugin({
185
185
  server.watcher.add(root);
186
186
 
187
187
  const refresh = (file: string) => {
188
- if (!file.startsWith(`${root}/`)) return;
188
+ if (clientRoutes || serverRoutes) return;
189
189
 
190
- const module = server.moduleGraph.getModuleById(virtualId);
190
+ const path = relative(root, file).replaceAll("\\", "/");
191
191
 
192
- if (module) server.moduleGraph.invalidateModule(module);
192
+ if (!/^(?:[^/]+\.[jt]sx?|[^/]+\/route\.[jt]sx?)$/.test(path)) return;
193
+ if (path.endsWith(".d.ts")) return;
194
+
195
+ for (const environment of Object.values(server.environments)) {
196
+ const module = environment.moduleGraph.getModuleById(virtualId);
197
+
198
+ if (module) environment.moduleGraph.invalidateModule(module);
199
+ }
193
200
 
194
201
  server.ws.send({ type: "full-reload" });
195
202
  };
@@ -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";
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1
4
+
5
+ - Refresh browser and SSR file-route discovery on additions, renames and removals; preserve normal Vite HMR for colocated helpers and components.
6
+
7
+ - Add optional production deployment manifests through `deploymentPlugin` for both React and Preact Vite integrations.
8
+ - 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.
9
+ - Skip hash-only links, development builds and SSR; bound failed checks and prevent repeated reloads. Keep routing-only bundles unchanged.
10
+ - Document production cache headers, CDN/application-specific manifest paths, unsaved state and rollout limitations.
11
+
3
12
  ## 0.3.0
4
13
 
5
14
  ### 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.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.3.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.3.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.3.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
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)
@@ -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,14 +599,16 @@ 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
606
  | `@kasperrt/smolter/react` | 2873 |
587
607
  | `@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 |
608
+ | `@kasperrt/smolter/react + @kasperrt/smolter/react/client` | 3870 |
609
+ | `@kasperrt/smolter/preact + server transport` | 5697 |
610
+ | `@kasperrt/smolter/preact + client + document (application without lazy routes)` | 8033 |
611
+ | `@kasperrt/smolter/preact + client + document + lazy-route (file-routed application)` | 8317 |
592
612
  | `@kasperrt/smolter/react/data (optional resource API)` | 2962 |
593
613
  | `React: common routing/data exports` | 2683 |
594
614
  | `Preact: common routing/data exports` | 2966 |
@@ -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,26 @@ 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.
@@ -0,0 +1,12 @@
1
+ import type { NavigationRouter } from "../core/index.ts";
2
+ /** Build identity and the fresh, non-cacheable manifest URL emitted by deploymentPlugin. */
3
+ export interface DeploymentOptions {
4
+ version: string;
5
+ manifestURL: string;
6
+ /** Maximum time to wait for the update check before continuing navigation. */
7
+ timeout?: number;
8
+ /** Override document navigation, for example to ask about unsaved changes. */
9
+ reload?: (destination: URL) => void;
10
+ }
11
+ /** Check for deployment changes before navigation. Returns a cleanup function. */
12
+ export declare function watchDeployment(router: NavigationRouter, options: DeploymentOptions | null): () => void;
@@ -0,0 +1,102 @@
1
+ //#region .generated/client/deployment.ts
2
+ /** Check for deployment changes before navigation. Returns a cleanup function. */
3
+ function watchDeployment(router, options) {
4
+ if (!options || typeof window === "undefined") return () => {};
5
+ const { version, manifestURL, timeout = 2e3 } = options;
6
+ const navigate = router.navigate;
7
+ const dispose = router.dispose;
8
+ const manifest = new URL(manifestURL, location.href);
9
+ const key = `smolter:deployment:${manifest.href}:${version}`;
10
+ let sequence = 0;
11
+ let stopped = false;
12
+ let replay = false;
13
+ let destination = new URL(location.href);
14
+ let pending;
15
+ let controller;
16
+ function changed() {
17
+ if (pending) return pending;
18
+ controller = new AbortController();
19
+ const timer = setTimeout(() => controller?.abort(), timeout);
20
+ pending = fetch(manifest, {
21
+ cache: "no-store",
22
+ signal: controller.signal
23
+ }).then(async (response) => {
24
+ if (!response.ok) return false;
25
+ const data = await response.json();
26
+ return typeof data === "object" && data !== null && "version" in data && typeof data.version === "string" && data.version.length > 0 && data.version !== version;
27
+ }).catch(() => false).finally(() => {
28
+ clearTimeout(timer);
29
+ pending = void 0;
30
+ });
31
+ return pending;
32
+ }
33
+ async function reload(target) {
34
+ const allowed = await Promise.resolve().then(() => {
35
+ if (sessionStorage.getItem(key)) return false;
36
+ sessionStorage.setItem(key, "1");
37
+ return true;
38
+ }).catch(() => false);
39
+ if (allowed && !stopped) {
40
+ if (options?.reload) options.reload(target);
41
+ if (!options?.reload) {
42
+ if (target.href === location.href) location.reload();
43
+ if (target.href !== location.href) location.assign(target.href);
44
+ }
45
+ }
46
+ return allowed && !stopped;
47
+ }
48
+ const guarded = async (href, operation) => {
49
+ const target = new URL(href, router.getSnapshot().url);
50
+ const currentURL = new URL(router.getSnapshot().url);
51
+ const current = ++sequence;
52
+ destination = target;
53
+ if (target.origin === location.origin && (target.pathname !== currentURL.pathname || target.search !== currentURL.search) && await changed()) {
54
+ if (current !== sequence || stopped || await reload(target)) return;
55
+ }
56
+ if (current === sequence && !stopped) await navigate(href, operation);
57
+ };
58
+ const pop = (event) => {
59
+ if (replay) return;
60
+ const target = new URL(location.href);
61
+ const previous = new URL(router.getSnapshot().url);
62
+ if (target.pathname === previous.pathname && target.search === previous.search) return;
63
+ event.stopImmediatePropagation();
64
+ const current = ++sequence;
65
+ destination = target;
66
+ changed().then(async (updated) => {
67
+ if (stopped || current !== sequence) return;
68
+ if (updated && await reload(target)) return;
69
+ replay = true;
70
+ window.dispatchEvent(new PopStateEvent("popstate", { state: event.state }));
71
+ replay = false;
72
+ });
73
+ };
74
+ const preloadError = () => {
75
+ if (router.getSnapshot().state === "submitting") return;
76
+ const current = sequence;
77
+ const target = destination;
78
+ changed().then((updated) => {
79
+ if (updated && current === sequence && !stopped) return reload(target);
80
+ });
81
+ };
82
+ function cleanup() {
83
+ stopped = true;
84
+ sequence++;
85
+ controller?.abort();
86
+ window.removeEventListener("popstate", pop, true);
87
+ window.removeEventListener("vite:preloadError", preloadError);
88
+ if (router.navigate === guarded) router.navigate = navigate;
89
+ if (router.dispose === guardedDispose) router.dispose = dispose;
90
+ }
91
+ function guardedDispose() {
92
+ cleanup();
93
+ dispose();
94
+ }
95
+ router.navigate = guarded;
96
+ router.dispose = guardedDispose;
97
+ window.addEventListener("popstate", pop, true);
98
+ window.addEventListener("vite:preloadError", preloadError);
99
+ return cleanup;
100
+ }
101
+ //#endregion
102
+ export { watchDeployment };
@@ -22,3 +22,4 @@ export interface ServerHandlers {
22
22
  }
23
23
  /** Browser transport for server routes. File-router builds generate this call. */
24
24
  export declare function serverRoute<M extends RouteModule = RouteModule>(path: string, module: ClientRouteModule, handlers?: ServerHandlers): Route<M>;
25
+ export { type DeploymentOptions, watchDeployment } from "./deployment.ts";
@@ -1,4 +1,5 @@
1
1
  import { deserialize } from "./deserialize.js";
2
+ import { watchDeployment } from "./deployment.js";
2
3
  //#region .generated/client/index.ts
3
4
  /** Browser transport for server routes. File-router builds generate this call. */
4
5
  function serverRoute(path, module, handlers = {
@@ -43,4 +44,4 @@ function serverRoute(path, module, handlers = {
43
44
  };
44
45
  }
45
46
  //#endregion
46
- export { deserialize, serverRoute };
47
+ export { deserialize, serverRoute, watchDeployment };
@@ -6,6 +6,7 @@ import { connectNavigation } from "../core/navigation.js";
6
6
  import { attachRootData } from "./root-data.js";
7
7
  import { RouterApplication, prepareRoutes } from "./router.js";
8
8
  import { deserialize } from "../client/deserialize.js";
9
+ import { watchDeployment } from "../client/deployment.js";
9
10
  import { serverRoute } from "../client/index.js";
10
11
  import { flushSync } from "preact/compat";
11
12
  import { jsx } from "preact/jsx-runtime";
@@ -64,4 +65,4 @@ function connectHashHistory(router) {
64
65
  };
65
66
  }
66
67
  //#endregion
67
- export { configureApplication, deserialize, mountApplication, serverRoute };
68
+ export { configureApplication, deserialize, mountApplication, serverRoute, watchDeployment };
@@ -45,6 +45,7 @@ function lazyFiles(directory) {
45
45
  if (id !== "\0virtual:smolter/routes" || options?.ssr) return;
46
46
  const routes = await flatRoutes(root);
47
47
  if (routes instanceof Error) return this.error(routes.message);
48
+ modules.clear();
48
49
  const definitions = [];
49
50
  for (const route of routes) {
50
51
  this.addWatchFile(route.file);
@@ -0,0 +1,8 @@
1
+ import type { Plugin } from "vite";
2
+ /** Deployment manifest configuration. Serve the emitted file with Cache-Control: no-store. */
3
+ export interface DeploymentPluginOptions {
4
+ /** Public URL, optionally on an asset host. Defaults to Vite base + smolter-manifest.json. */
5
+ manifestURL?: string;
6
+ }
7
+ /** Emit a build manifest and expose its identity through virtual:smolter/deployment. */
8
+ export declare function deploymentPlugin(options?: DeploymentPluginOptions): Plugin;
@@ -0,0 +1,36 @@
1
+ import { randomUUID } from "node:crypto";
2
+ //#region .generated/vite/deployment.ts
3
+ /** Emit a build manifest and expose its identity through virtual:smolter/deployment. */
4
+ function deploymentPlugin(options = {}) {
5
+ const version = randomUUID();
6
+ const virtualId = "\0virtual:smolter/deployment";
7
+ let enabled = false;
8
+ let manifestURL = "";
9
+ return {
10
+ name: "smolter-deployment",
11
+ configResolved(config) {
12
+ enabled = config.command === "build" && !config.build.ssr;
13
+ manifestURL = options.manifestURL ?? `${config.base}smolter-manifest.json`;
14
+ },
15
+ resolveId(id) {
16
+ if (id === "virtual:smolter/deployment") return virtualId;
17
+ },
18
+ load(id) {
19
+ if (id !== virtualId) return;
20
+ return `export default ${JSON.stringify(enabled ? {
21
+ version,
22
+ manifestURL
23
+ } : null)};`;
24
+ },
25
+ generateBundle() {
26
+ if (!enabled) return;
27
+ this.emitFile({
28
+ type: "asset",
29
+ fileName: "smolter-manifest.json",
30
+ source: JSON.stringify({ version })
31
+ });
32
+ }
33
+ };
34
+ }
35
+ //#endregion
36
+ export { deploymentPlugin };
@@ -1,7 +1,7 @@
1
1
  import { flatRoutes } from "../files/index.js";
2
2
  import { configureTransport } from "./transport.js";
3
3
  import { readFile } from "node:fs/promises";
4
- import { resolve } from "node:path";
4
+ import { relative, resolve } from "node:path";
5
5
  import { transformWithOxc } from "vite";
6
6
  //#region .generated/vite/index.ts
7
7
  /** Build-time flat route discovery and the optional server data transport. */
@@ -83,9 +83,14 @@ function routerPlugin({ directory = "src/routes", clientRoutes, serverRoutes, fr
83
83
  configureServer(server) {
84
84
  server.watcher.add(root);
85
85
  const refresh = (file) => {
86
- if (!file.startsWith(`${root}/`)) return;
87
- const module = server.moduleGraph.getModuleById(virtualId);
88
- if (module) server.moduleGraph.invalidateModule(module);
86
+ if (clientRoutes || serverRoutes) return;
87
+ const path = relative(root, file).replaceAll("\\", "/");
88
+ if (!/^(?:[^/]+\.[jt]sx?|[^/]+\/route\.[jt]sx?)$/.test(path)) return;
89
+ if (path.endsWith(".d.ts")) return;
90
+ for (const environment of Object.values(server.environments)) {
91
+ const module = environment.moduleGraph.getModuleById(virtualId);
92
+ if (module) environment.moduleGraph.invalidateModule(module);
93
+ }
89
94
  server.ws.send({ type: "full-reload" });
90
95
  };
91
96
  server.watcher.on("add", refresh);
@@ -9,3 +9,4 @@ export declare function routerPlugin(options?: FileRouterOptions): Plugin;
9
9
  export declare const fileRouter: typeof routerPlugin;
10
10
  export type { ApplicationOptions } from "./application/vite.ts";
11
11
  export { applicationPlugins, preactConfig, } from "./application/vite.ts";
12
+ export { type DeploymentPluginOptions, deploymentPlugin, } from "./deployment.ts";
@@ -1,5 +1,6 @@
1
1
  import { flatRoutes } from "../files/index.js";
2
2
  import { routerPlugin as routerPlugin$1 } from "./index.js";
3
+ import { deploymentPlugin } from "./deployment.js";
3
4
  import { applicationPlugins, preactConfig } from "./application/vite.js";
4
5
  //#region .generated/vite/preact.ts
5
6
  /** Configure flat routes and server transport for Preact. */
@@ -12,4 +13,4 @@ function routerPlugin(options = {}) {
12
13
  /** File routing is the default; this alias keeps configuration explicit. */
13
14
  var fileRouter = routerPlugin;
14
15
  //#endregion
15
- export { applicationPlugins, fileRouter, flatRoutes, preactConfig, routerPlugin };
16
+ export { applicationPlugins, deploymentPlugin, fileRouter, flatRoutes, preactConfig, routerPlugin };
@@ -7,3 +7,4 @@ export type { FileRouterOptions } from "./index.ts";
7
7
  export declare function routerPlugin(options?: FileRouterOptions): Plugin;
8
8
  /** File routing is the default; this alias keeps configuration explicit. */
9
9
  export declare const fileRouter: typeof routerPlugin;
10
+ export { type DeploymentPluginOptions, deploymentPlugin, } from "./deployment.ts";
@@ -1,5 +1,6 @@
1
1
  import { flatRoutes } from "../files/index.js";
2
2
  import { routerPlugin as routerPlugin$1 } from "./index.js";
3
+ import { deploymentPlugin } from "./deployment.js";
3
4
  //#region .generated/vite/react.ts
4
5
  /** Configure flat routes and server transport for React. */
5
6
  function routerPlugin(options = {}) {
@@ -11,4 +12,4 @@ function routerPlugin(options = {}) {
11
12
  /** File routing is the default; this alias keeps configuration explicit. */
12
13
  var fileRouter = routerPlugin;
13
14
  //#endregion
14
- export { fileRouter, flatRoutes, routerPlugin };
15
+ export { deploymentPlugin, fileRouter, flatRoutes, routerPlugin };
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kasperrt/smolter",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "exports": {
5
5
  "./react": "./.generated/react/index.ts",
6
6
  "./preact": "./.generated/preact/index.ts",
package/package.json CHANGED
@@ -56,7 +56,7 @@
56
56
  "wouter": "3.13.0",
57
57
  "wouter-preact": "3.13.0"
58
58
  },
59
- "version": "0.3.0",
59
+ "version": "0.3.1",
60
60
  "description": "A tiny typed data router for React and Preact.",
61
61
  "sideEffects": false,
62
62
  "license": "MIT",
@@ -154,7 +154,7 @@
154
154
  "optional": true
155
155
  }
156
156
  },
157
- "author": "Kasper Rynning-T\u00f8nnesen <kasperrt@users.noreply.github.com> (https://github.com/kasperrt)",
157
+ "author": "Kasper Rynning-Tønnesen <kasperrt@users.noreply.github.com> (https://github.com/kasperrt)",
158
158
  "homepage": "https://kasperrt.github.io/smolter/",
159
159
  "bugs": {
160
160
  "url": "https://github.com/kasperrt/smolter/issues"