@pitlane/dev 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -0
- package/README.md +86 -2
- package/dist/assets.d.mts +20 -2
- package/dist/index.d.mts +24 -0
- package/dist/index.mjs +326 -5
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,67 @@
|
|
|
1
1
|
# @pitlane/dev
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
SPA mode.
|
|
6
|
+
|
|
7
|
+
- `remix({ server: false })` targets client-rendered apps. No server
|
|
8
|
+
environment is configured, nothing builds to `dist/ssr`, and `vite build`
|
|
9
|
+
emits a static site from `index.html`. What remains is the part a SPA still
|
|
10
|
+
wants: component HMR through the `remix/ui-hmr` browser transform, including
|
|
11
|
+
the arrow-form normalization, so edits hot-swap in place and keep live
|
|
12
|
+
component state. Every `server*` option goes with it, and `clientEntry` too,
|
|
13
|
+
and `<HMR />` from `pitlane:dev` resolves to the inert component — there is
|
|
14
|
+
no server data to revalidate.
|
|
15
|
+
- The option is named for what it removes. React Router spells the same switch
|
|
16
|
+
`ssr: false`, which reads like it only turns off server rendering; it does
|
|
17
|
+
not, in either plugin. An app that wants browser-rendered UI in front of
|
|
18
|
+
routes that still run per request keeps `server: true` and writes a server
|
|
19
|
+
entry that answers data and a shell.
|
|
20
|
+
- SPA mode works under Vite's experimental bundled dev mode
|
|
21
|
+
(`experimental.bundledDev` / `vite dev --experimentalBundle`), component
|
|
22
|
+
hot-swap included. Server-rendered apps do not: bundled dev serves only
|
|
23
|
+
bundle entrypoints, so the client module URLs an SSR render writes into its
|
|
24
|
+
HTML have nothing behind them. That is upstream's Phase 4 (server
|
|
25
|
+
environments), still a prototype.
|
|
26
|
+
|
|
27
|
+
## 0.3.0
|
|
28
|
+
|
|
29
|
+
Dev-time hot module replacement.
|
|
30
|
+
|
|
31
|
+
- Component HMR: component and `clientEntry()` exports hot-swap in place
|
|
32
|
+
during `vite dev`, preserving live island state, via the `remix/ui-hmr`
|
|
33
|
+
browser and server transforms. Both named-function and arrow forms work —
|
|
34
|
+
arrow-form component/`clientEntry()` exports are normalized to named
|
|
35
|
+
function expressions before instrumentation, so idiomatic Remix code
|
|
36
|
+
hot-swaps with no source changes.
|
|
37
|
+
- Server-data HMR: editing a server-only module re-fetches the current page
|
|
38
|
+
through the app's fetch handler and reconciles the new server-rendered HTML
|
|
39
|
+
into the DOM, keeping hydrated island state — the Remix 3 analog of React
|
|
40
|
+
Router's loader/action revalidation, driven through the frame runtime. A
|
|
41
|
+
changed file the client graph serves as a script is left to component HMR
|
|
42
|
+
instead. Only `js` client modules count as client-served: plugins that scan
|
|
43
|
+
sources for their own purposes register non-script nodes for ordinary server
|
|
44
|
+
files (Tailwind's content scanner, for one), which previously classified
|
|
45
|
+
every server module as client-owned and silenced server-data HMR for the
|
|
46
|
+
whole app.
|
|
47
|
+
- Revalidating is one line in the document, `<HMR />` from the new `pitlane:dev`
|
|
48
|
+
module. It is a hydrated island, so it holds a component handle and
|
|
49
|
+
revalidates with `handle.frames.top.reload()`; `remix/ui` hands the top frame
|
|
50
|
+
to components only, so nothing the plugin injects could reach it. A frame
|
|
51
|
+
reload produces no history entry and fires no `navigate` event, which leaves
|
|
52
|
+
apps that intercept navigation themselves working unchanged. Needs no
|
|
53
|
+
environment guard: in a build, and in apps with no client runtime to hydrate
|
|
54
|
+
it, the specifier resolves to a component that renders nothing and carries no
|
|
55
|
+
client code. Types come with `@pitlane/dev/assets`.
|
|
56
|
+
- Revalidation waits a beat after a server change before refetching, so it
|
|
57
|
+
cannot reach the fetch handler while the server entry is still half-applied
|
|
58
|
+
(which served a dev error page on slower runtimes like workerd), and a burst
|
|
59
|
+
of saves coalesces into one refetch.
|
|
60
|
+
- `@pitlane/dev/runtime` imports now inline this package's real runtime module
|
|
61
|
+
rather than a hand-written copy of `mergeAssets`, so every export stays in
|
|
62
|
+
one place. What that module imports is bundled too, which keeps the built
|
|
63
|
+
server free of any dev-dependency import.
|
|
64
|
+
|
|
3
65
|
## 0.2.0
|
|
4
66
|
|
|
5
67
|
Target Remix `3.0.0-beta.10`.
|
package/README.md
CHANGED
|
@@ -70,13 +70,51 @@ run({
|
|
|
70
70
|
|
|
71
71
|
`vite dev` serves the app through your router. `vite build` produces `dist/ssr` and `dist/client`. `vite preview` serves the production build through the same fetch handler production runs.
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
## Hot module replacement
|
|
74
|
+
|
|
75
|
+
`vite dev` hot-updates both halves of a Remix app in place, keeping live client state. Full details, including which edits preserve state and which remount, are in the [HMR guide](https://pitlane.tools/guides/hmr).
|
|
76
|
+
|
|
77
|
+
**Components.** Editing a component swaps its new code in without remounting, so hydrated `clientEntry()` islands keep their state (open menus, form input, counters). This runs the [`remix/ui-hmr`](https://github.com/remix-run/remix/tree/main/packages/ui-hmr) transforms during dev. Both authoring styles hot-swap, because `@pitlane/dev` normalizes arrow-form component and `clientEntry()` exports to named functions before instrumenting them:
|
|
78
|
+
|
|
79
|
+
```tsx
|
|
80
|
+
// All of these hot-swap in place, preserving live state:
|
|
81
|
+
export const Counter = clientEntry(import.meta.url, handle => {
|
|
82
|
+
/* ... */
|
|
83
|
+
});
|
|
84
|
+
export const Toggle = clientEntry(import.meta.url, function Toggle(handle) {
|
|
85
|
+
/* ... */
|
|
86
|
+
});
|
|
87
|
+
export const Card = handle => () => <div />;
|
|
88
|
+
export function Panel(handle) {
|
|
89
|
+
/* ... */
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Only named (PascalCase) component exports in `.tsx`/`.jsx` files whose setup returns a render function are instrumented; other exports are left untouched. Editing the render function keeps live state. Editing the setup scope above the `return` remounts the component, so its state resets.
|
|
94
|
+
|
|
95
|
+
**Server data.** Editing a server-only module (the document, a middleware, a route handler, any module the client never imports) re-fetches the current page through your fetch handler and reconciles the new server-rendered HTML into the DOM. Hydrated island state survives, so you see fresh server output without a full-page reload. This is the Remix 3 analog of React Router's loader/action revalidation, driven through the frame runtime rather than a client data router.
|
|
96
|
+
|
|
97
|
+
It needs one line in your document:
|
|
98
|
+
|
|
99
|
+
```tsx
|
|
100
|
+
import { HMR } from "pitlane:dev";
|
|
101
|
+
|
|
102
|
+
// ...
|
|
103
|
+
<body>
|
|
104
|
+
<HMR />
|
|
105
|
+
{/* ... */}
|
|
106
|
+
</body>;
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`<HMR />` is a hydrated island, so it has a component handle, and it revalidates with `handle.frames.top.reload()`. Remix hands the top frame to components only, which is why this is a component rather than something the plugin injects. Reloading the frame produces no history entry and fires no `navigate` event, so apps that intercept navigation themselves work unchanged.
|
|
110
|
+
|
|
111
|
+
Leave it unguarded: in a production build the specifier resolves to a component that renders nothing and carries no client code. Apps with `clientEntry: false` have nothing to hydrate it, so it stays inert there too. See the [HMR guide](https://pitlane.tools/guides/hmr).
|
|
75
112
|
|
|
76
113
|
## Options
|
|
77
114
|
|
|
78
115
|
```ts
|
|
79
116
|
remix({
|
|
117
|
+
server: true, // default — false selects SPA mode
|
|
80
118
|
clientEntry: "app/entry.browser", // default — false disables the client build
|
|
81
119
|
serverEntry: "app/entry.server", // default
|
|
82
120
|
serverEnvironments: ["ssr"], // default
|
|
@@ -86,11 +124,57 @@ remix({
|
|
|
86
124
|
|
|
87
125
|
| Option | Type | Default | Purpose |
|
|
88
126
|
| -------------------- | ----------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
127
|
+
| `server` | `boolean` | `true` | Whether the app has a server. Pass `false` for [SPA mode](#spa-mode), which ignores every option below. |
|
|
89
128
|
| `clientEntry` | `string \| false` | `"app/entry.browser"` | Client entry module. Pass `false` for fully server-rendered apps with no hydration. |
|
|
90
129
|
| `serverEntry` | `string` | `"app/entry.server"` | Server entry module, built as `dist/ssr/index.js`. |
|
|
91
130
|
| `serverEnvironments` | `string[]` | `["ssr"]` | Environment names the `clientEntry()` transform treats as "server". |
|
|
92
131
|
| `serverHandler` | `boolean` | `true` | Serve dev requests through your server entry. Set `false` when `@cloudflare/vite-plugin`, `@netlify/vite-plugin`, or `nitro/vite` owns dev-time request handling. |
|
|
93
132
|
|
|
133
|
+
## SPA mode
|
|
134
|
+
|
|
135
|
+
Some apps have no server — a static host, a router that never touches one.
|
|
136
|
+
`remix({ server: false })` targets those. React Router spells the same switch
|
|
137
|
+
`ssr: false`:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
// vite.config.ts
|
|
141
|
+
import { remix } from "@pitlane/dev";
|
|
142
|
+
import { defineConfig } from "vite";
|
|
143
|
+
|
|
144
|
+
export default defineConfig({
|
|
145
|
+
plugins: [remix({ server: false })],
|
|
146
|
+
});
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
There is no server environment, nothing is built to `dist/ssr`, and
|
|
150
|
+
`vite build` emits a static site from your `index.html`. The plugin's one
|
|
151
|
+
remaining job is the one a SPA still wants: component HMR. Editing a component
|
|
152
|
+
swaps it in place and keeps live state, arrow forms included.
|
|
153
|
+
|
|
154
|
+
Every `server*` option goes with it, and `clientEntry` too — the browser entry
|
|
155
|
+
is whatever `index.html` loads. `<HMR />` from `pitlane:dev` resolves to the
|
|
156
|
+
inert component, because there is no server data to revalidate.
|
|
157
|
+
|
|
158
|
+
Deploying means pointing every unknown URL at `index.html` so the client router
|
|
159
|
+
can resolve it; on GitHub Pages that is a copy of `index.html` at `404.html`,
|
|
160
|
+
on Netlify a `/* /index.html 200` redirect.
|
|
161
|
+
|
|
162
|
+
The option removes the server, not the server rendering, which is what React
|
|
163
|
+
Router's `ssr: false` does too. For a browser-rendered UI in front of routes
|
|
164
|
+
that still run per request, stay in the default mode and let the server entry
|
|
165
|
+
answer JSON on its data routes and one `remix/ui` shell on its document
|
|
166
|
+
routes: nothing here asks it to render app UI. [Client rendering with a
|
|
167
|
+
server](https://pitlane.tools/guides/spa#client-rendering-with-a-server) shows
|
|
168
|
+
the shape.
|
|
169
|
+
|
|
170
|
+
SPA mode also works under Vite's experimental bundled dev mode
|
|
171
|
+
(`experimental.bundledDev`, or `vite dev --experimentalBundle`), component
|
|
172
|
+
hot-swap included. Server-rendered apps do not yet: bundled dev serves only
|
|
173
|
+
bundle entrypoints, so the client module URLs an SSR render writes into its
|
|
174
|
+
HTML resolve to nothing. That is upstream's
|
|
175
|
+
[Phase 4](https://github.com/vitejs/vite/discussions/22746) — server
|
|
176
|
+
environments — still a prototype.
|
|
177
|
+
|
|
94
178
|
## The server entry contract
|
|
95
179
|
|
|
96
180
|
The server entry **default-exports a fetch handler** — an object exposing `fetch(request: Request): Response | Promise<Response>`. A `createRouter()` router already is one:
|
package/dist/assets.d.mts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Type declarations for Pitlane's `?assets=` import convention
|
|
2
|
+
* Type declarations for Pitlane's `?assets=` import convention and the
|
|
3
|
+
* `pitlane:dev` module.
|
|
3
4
|
*
|
|
4
|
-
* Add to an app's tsconfig to type
|
|
5
|
+
* Add to an app's tsconfig to type both:
|
|
5
6
|
*
|
|
6
7
|
* ```jsonc
|
|
7
8
|
* { "compilerOptions": { "types": ["@pitlane/dev/assets"] } }
|
|
@@ -26,3 +27,20 @@ declare module "*?assets=ssr" {
|
|
|
26
27
|
const assets: import("@pitlane/dev/runtime").ImportedAssets;
|
|
27
28
|
export default assets;
|
|
28
29
|
}
|
|
30
|
+
|
|
31
|
+
declare module "pitlane:dev" {
|
|
32
|
+
/**
|
|
33
|
+
* Revalidates the page when a server-only module changes during `vite dev`,
|
|
34
|
+
* keeping hydrated island state. Render it once, anywhere in the document:
|
|
35
|
+
*
|
|
36
|
+
* ```tsx
|
|
37
|
+
* import { HMR } from "pitlane:dev";
|
|
38
|
+
* // ...
|
|
39
|
+
* <HMR />
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* Renders nothing, and carries no client code in a production build, so it
|
|
43
|
+
* needs no environment guard.
|
|
44
|
+
*/
|
|
45
|
+
export const HMR: () => () => null;
|
|
46
|
+
}
|
package/dist/index.d.mts
CHANGED
|
@@ -9,6 +9,21 @@ interface RemixPluginOptions {
|
|
|
9
9
|
* @default "app/entry.browser"
|
|
10
10
|
*/
|
|
11
11
|
clientEntry?: string | false;
|
|
12
|
+
/**
|
|
13
|
+
* Whether the app has a server at all. Pass `false` for SPA mode: no
|
|
14
|
+
* server environment is configured, nothing is built to `dist/ssr`, and
|
|
15
|
+
* `vite build` emits a static site from `index.html`.
|
|
16
|
+
*
|
|
17
|
+
* This is about the server, not about server rendering. With `false`
|
|
18
|
+
* every `server*` option below goes with it, because there is no server
|
|
19
|
+
* for them to describe, and `clientEntry` goes too: the browser entry is
|
|
20
|
+
* whatever `index.html` loads. An app that wants its UI rendered in the
|
|
21
|
+
* browser while its routes still answer per request keeps `true` and
|
|
22
|
+
* writes a server entry that serves data and a shell.
|
|
23
|
+
*
|
|
24
|
+
* @default true
|
|
25
|
+
*/
|
|
26
|
+
server?: boolean;
|
|
12
27
|
/**
|
|
13
28
|
* Server entry module, built as `dist/ssr/index.js`. Must default-export
|
|
14
29
|
* a fetch handler: an object exposing
|
|
@@ -31,6 +46,8 @@ interface RemixPluginOptions {
|
|
|
31
46
|
* Set to `false` when another plugin owns dev-time request handling —
|
|
32
47
|
* e.g. `@cloudflare/vite-plugin`, `@netlify/vite-plugin`, or `nitro/vite`.
|
|
33
48
|
*
|
|
49
|
+
* Ignored when `server` is `false`, which has no fetch handler.
|
|
50
|
+
*
|
|
34
51
|
* @default true
|
|
35
52
|
*/
|
|
36
53
|
serverHandler?: boolean;
|
|
@@ -41,6 +58,13 @@ interface RemixPluginOptions {
|
|
|
41
58
|
* `clientEntry(import.meta.url, …)` hydration transform, dev serving through
|
|
42
59
|
* the app's fetch handler, and a preview server for the production build.
|
|
43
60
|
*
|
|
61
|
+
* During `vite dev` it also installs hot module replacement: component edits
|
|
62
|
+
* swap in place through the `remix/ui-hmr` transforms, and edits to modules the
|
|
63
|
+
* browser never loads refetch the current page through the app's fetch handler,
|
|
64
|
+
* keeping hydrated island state. Both are dev-only. The second half needs the
|
|
65
|
+
* app to render `<HMR />` from the `pitlane:dev` module, which resolves to an
|
|
66
|
+
* inert component in a build and when `clientEntry` is `false`.
|
|
67
|
+
*
|
|
44
68
|
* Platform-agnostic by design: deploy targets compose alongside it in the
|
|
45
69
|
* plugin array (`@cloudflare/vite-plugin`, `@netlify/vite-plugin`,
|
|
46
70
|
* `nitro/vite`), or the built fetch handler runs directly on Node, Bun, and
|
package/dist/index.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { mergeAssets } from "./runtime.mjs";
|
|
2
1
|
import fullstack from "@hiogawa/vite-plugin-fullstack";
|
|
3
2
|
import * as fs from "node:fs";
|
|
4
3
|
import * as path from "node:path";
|
|
4
|
+
import * as url from "node:url";
|
|
5
5
|
import MagicString from "magic-string";
|
|
6
6
|
import { parseSync } from "oxc-parser";
|
|
7
|
+
import { transformComponentsForBrowser, transformComponentsForServer } from "remix/ui-hmr";
|
|
7
8
|
//#region src/build.ts
|
|
8
9
|
function isFullstackBuilder(builder) {
|
|
9
10
|
return "writeAssetsManifest" in builder && typeof builder.writeAssetsManifest === "function";
|
|
@@ -189,7 +190,7 @@ function build({ clientEntry, serverEntry }) {
|
|
|
189
190
|
rollupOptions: { input: hasUserClientInput ? void 0 : clientEntry || void 0 }
|
|
190
191
|
} } },
|
|
191
192
|
ssr: {
|
|
192
|
-
resolve: { noExternal: [/^@pitlane\/dev(\/|$)/] },
|
|
193
|
+
resolve: { noExternal: [/^@pitlane\/dev(\/|$)/, /^@hiogawa\/vite-plugin-fullstack(\/|$)/] },
|
|
193
194
|
build: {
|
|
194
195
|
outDir: "dist/ssr",
|
|
195
196
|
rollupOptions: { input: hasUserServerInput ? void 0 : { index: serverEntry } }
|
|
@@ -208,6 +209,11 @@ const RUNTIME_MODULE_FILTER = new RegExp(`^${RUNTIME_MODULE_ID}$`);
|
|
|
208
209
|
* import it at runtime (pruned containers, Deno import maps, serverless
|
|
209
210
|
* bundles), and dependency-externalization behavior varies across cores and
|
|
210
211
|
* orchestrators — inlining by construction removes the variable.
|
|
212
|
+
*
|
|
213
|
+
* The virtual module re-exports this package's own runtime file by absolute
|
|
214
|
+
* path, so the bundler inlines it and every export stays in one place. Emitting
|
|
215
|
+
* hand-written source here instead would drift from the real module the moment
|
|
216
|
+
* it gained an export.
|
|
211
217
|
*/
|
|
212
218
|
function runtimeInline() {
|
|
213
219
|
return {
|
|
@@ -222,11 +228,296 @@ function runtimeInline() {
|
|
|
222
228
|
load: {
|
|
223
229
|
filter: { id: RUNTIME_MODULE_FILTER },
|
|
224
230
|
handler(id) {
|
|
225
|
-
if (id === RUNTIME_MODULE_ID) return `export
|
|
231
|
+
if (id === RUNTIME_MODULE_ID) return `export * from ${JSON.stringify(runtimeImplementationPath())};\n`;
|
|
226
232
|
}
|
|
227
233
|
}
|
|
228
234
|
};
|
|
229
235
|
}
|
|
236
|
+
/**
|
|
237
|
+
* Absolute path to this package's runtime module. Sits beside the built plugin
|
|
238
|
+
* as `runtime.mjs` in an installed package, and beside the source as
|
|
239
|
+
* `runtime.ts` when the repo's own fixtures load the plugin from `src`.
|
|
240
|
+
*/
|
|
241
|
+
function runtimeImplementationPath() {
|
|
242
|
+
let here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
243
|
+
for (let name of ["runtime.mjs", "runtime.ts"]) {
|
|
244
|
+
let candidate = path.join(here, name);
|
|
245
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
246
|
+
}
|
|
247
|
+
throw new Error(`@pitlane/dev: runtime module not found next to ${here}`);
|
|
248
|
+
}
|
|
249
|
+
//#endregion
|
|
250
|
+
//#region src/hmr-protocol.ts
|
|
251
|
+
/**
|
|
252
|
+
* The dev-only contract between the `serverDataHmr` plugin and the island that
|
|
253
|
+
* revalidates. Shared so the two cannot drift apart.
|
|
254
|
+
*/
|
|
255
|
+
/** Custom Vite HMR event that asks the browser to revalidate server-rendered data. */
|
|
256
|
+
const SERVER_UPDATE_EVENT = "pitlane:server-update";
|
|
257
|
+
//#endregion
|
|
258
|
+
//#region src/hmr-component.ts
|
|
259
|
+
/** Public specifier apps import the dev HMR component from. */
|
|
260
|
+
const PUBLIC_ID = "pitlane:dev";
|
|
261
|
+
const ISLAND_ID = `\0${PUBLIC_ID}`;
|
|
262
|
+
const INERT_ID = `\0${PUBLIC_ID}?inert`;
|
|
263
|
+
/**
|
|
264
|
+
* The `clientEntry()` transform asks a module for its own client URL through
|
|
265
|
+
* `?assets=client` on its resolved id. This module answers for itself.
|
|
266
|
+
*/
|
|
267
|
+
const ISLAND_ASSETS_ID = `${ISLAND_ID}?assets=client`;
|
|
268
|
+
const RESOLVED_ISLAND_ASSETS_ID = `\0${PUBLIC_ID}?island-assets`;
|
|
269
|
+
/**
|
|
270
|
+
* Serves the `<HMR />` component apps render in their document, which drives
|
|
271
|
+
* server-data revalidation.
|
|
272
|
+
*
|
|
273
|
+
* The specifier is an indirection the plugin repoints by mode. During `vite dev`
|
|
274
|
+
* it is a hydrated island whose handle reaches the top frame. In a build, and in
|
|
275
|
+
* apps with no client runtime to hydrate it, it is a component that renders
|
|
276
|
+
* nothing, so `<HMR />` can stay in the document unconditionally and cost
|
|
277
|
+
* nothing in production.
|
|
278
|
+
*
|
|
279
|
+
* Kept virtual rather than shipped as a file on disk: a file inside the package
|
|
280
|
+
* resolves outside the app's root, and the dev server hands out a `file://` URL
|
|
281
|
+
* for those, which a browser refuses to import.
|
|
282
|
+
*/
|
|
283
|
+
function hmrComponent(clientEntry) {
|
|
284
|
+
return {
|
|
285
|
+
name: "pitlane-remix-hmr-component",
|
|
286
|
+
enforce: "pre",
|
|
287
|
+
resolveId(id) {
|
|
288
|
+
if (id === ISLAND_ASSETS_ID) return RESOLVED_ISLAND_ASSETS_ID;
|
|
289
|
+
if (id !== PUBLIC_ID) return;
|
|
290
|
+
if (this.environment.mode === "build" || clientEntry === false) return INERT_ID;
|
|
291
|
+
return ISLAND_ID;
|
|
292
|
+
},
|
|
293
|
+
load(id) {
|
|
294
|
+
if (id === INERT_ID) return INERT_SOURCE;
|
|
295
|
+
if (id === ISLAND_ID) return ISLAND_SOURCE;
|
|
296
|
+
if (id === RESOLVED_ISLAND_ASSETS_ID) return islandAssetsSource(this.environment.config.base);
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
/** A component that renders nothing and carries no client code. */
|
|
301
|
+
const INERT_SOURCE = `export const HMR = () => () => null;\n`;
|
|
302
|
+
/**
|
|
303
|
+
* The island. Revalidates by reloading its top frame, which refetches the page
|
|
304
|
+
* through the app's fetch handler and reconciles it, with no navigation
|
|
305
|
+
* involved. Overlapping revalidations collapse into one follow-up.
|
|
306
|
+
*
|
|
307
|
+
* Renders nothing: it contributes markup only as the hydration marker Remix
|
|
308
|
+
* emits around it, which is what gives it a handle in the browser.
|
|
309
|
+
*/
|
|
310
|
+
const ISLAND_SOURCE = `import { clientEntry } from "remix/ui";
|
|
311
|
+
|
|
312
|
+
export const HMR = clientEntry(import.meta.url, function HMR(handle) {
|
|
313
|
+
if (import.meta.hot) {
|
|
314
|
+
let inFlight = false;
|
|
315
|
+
let queued = false;
|
|
316
|
+
|
|
317
|
+
let revalidate = async () => {
|
|
318
|
+
if (inFlight) {
|
|
319
|
+
queued = true;
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
inFlight = true;
|
|
323
|
+
try {
|
|
324
|
+
await handle.frames.top.reload();
|
|
325
|
+
} finally {
|
|
326
|
+
inFlight = false;
|
|
327
|
+
}
|
|
328
|
+
if (queued) {
|
|
329
|
+
queued = false;
|
|
330
|
+
await revalidate();
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
import.meta.hot.on(${JSON.stringify(SERVER_UPDATE_EVENT)}, () => void revalidate());
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return () => null;
|
|
338
|
+
});
|
|
339
|
+
`;
|
|
340
|
+
/**
|
|
341
|
+
* The island's own `?assets=client` answer: the dev-server URL for a virtual
|
|
342
|
+
* module, which is what the server writes into the hydration marker for the
|
|
343
|
+
* browser to import.
|
|
344
|
+
*/
|
|
345
|
+
function islandAssetsSource(base) {
|
|
346
|
+
let url = `${base.endsWith("/") ? base : `${base}/`}@id/__x00__${PUBLIC_ID}`;
|
|
347
|
+
return `export default { entry: ${JSON.stringify(url)}, js: [], css: [] };\n`;
|
|
348
|
+
}
|
|
349
|
+
//#endregion
|
|
350
|
+
//#region src/hmr.ts
|
|
351
|
+
/**
|
|
352
|
+
* Component modules Remix authors as `.tsx`/`.jsx`. The `remix/ui-hmr` transform
|
|
353
|
+
* self-guards (it returns the source unchanged when a module holds no
|
|
354
|
+
* `function`-form component or `clientEntry`), so this filter only trims the
|
|
355
|
+
* common non-candidates — non-JSX source, query variants like `?assets=`, and
|
|
356
|
+
* dependencies — before the parse cost.
|
|
357
|
+
*/
|
|
358
|
+
const COMPONENT_ID_FILTER = /\.[jt]sx$/;
|
|
359
|
+
/**
|
|
360
|
+
* How long to wait after a server module changes before asking the browser to
|
|
361
|
+
* revalidate. Vite applies the update to the server environment on its own
|
|
362
|
+
* schedule; revalidating in the same tick can reach the fetch handler while the
|
|
363
|
+
* server entry is still half-applied, which serves a dev error page instead of
|
|
364
|
+
* the new content. The delay also coalesces bursts of saves into one refetch.
|
|
365
|
+
*/
|
|
366
|
+
const SERVER_UPDATE_SETTLE_MS = 50;
|
|
367
|
+
/**
|
|
368
|
+
* Instruments Remix UI components and `clientEntry()` exports with the
|
|
369
|
+
* `remix/ui-hmr` transforms so component edits hot-swap in place while
|
|
370
|
+
* preserving live component state.
|
|
371
|
+
*
|
|
372
|
+
* `ui-hmr` only recognizes named-function component forms, so arrow-form
|
|
373
|
+
* exports (`export const Name = clientEntry(url, (handle) => …)` and
|
|
374
|
+
* `export const Name = (handle) => …`) are first normalized to named function
|
|
375
|
+
* expressions — the idiomatic Remix authoring style then hot-swaps without any
|
|
376
|
+
* source changes. The normalization is discarded when `ui-hmr` does not
|
|
377
|
+
* instrument the module, so non-component arrows are never rewritten.
|
|
378
|
+
*
|
|
379
|
+
* The browser transform runs in the client environment and the server transform
|
|
380
|
+
* in the server environment(s); both emit the standard `import.meta.hot.accept()`
|
|
381
|
+
* protocol that Vite's own HMR runtime drives.
|
|
382
|
+
*
|
|
383
|
+
* Dev-only: production builds never carry the wrapper indirection or the runtime
|
|
384
|
+
* imports.
|
|
385
|
+
*/
|
|
386
|
+
function componentHmr(serverEnvironments) {
|
|
387
|
+
return {
|
|
388
|
+
name: "pitlane-remix-component-hmr",
|
|
389
|
+
apply: "serve",
|
|
390
|
+
transform: {
|
|
391
|
+
filter: { id: {
|
|
392
|
+
include: COMPONENT_ID_FILTER,
|
|
393
|
+
exclude: /\/node_modules\//
|
|
394
|
+
} },
|
|
395
|
+
handler(code, id) {
|
|
396
|
+
let source = normalizeArrowComponents(code, id) ?? code;
|
|
397
|
+
let result = serverEnvironments.has(this.environment.name) ? transformComponentsForServer(source, {
|
|
398
|
+
importSource: "remix",
|
|
399
|
+
moduleUrl: id,
|
|
400
|
+
sourceMap: true
|
|
401
|
+
}) : transformComponentsForBrowser(source, {
|
|
402
|
+
importSource: "remix",
|
|
403
|
+
moduleUrl: id,
|
|
404
|
+
sourceMap: true
|
|
405
|
+
});
|
|
406
|
+
if (!result.transformed) return;
|
|
407
|
+
return {
|
|
408
|
+
code: result.code,
|
|
409
|
+
map: result.map
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Server-data HMR, broadcast half: when a server-only module changes, tell the
|
|
417
|
+
* browser to revalidate its server-rendered content. The `<HMR />` component
|
|
418
|
+
* from the `pitlane:dev` module receives the event and reloads the top frame,
|
|
419
|
+
* which refetches the page through the app's fetch handler and reconciles it,
|
|
420
|
+
* keeping hydrated island state. This is the Remix 3 analog of React Router's
|
|
421
|
+
* loader/action revalidation.
|
|
422
|
+
*
|
|
423
|
+
* A file counts as server-only when the client graph does not serve it as a
|
|
424
|
+
* script; those are left to the client component-HMR boundary, so a
|
|
425
|
+
* `function`-form component edit still hot-swaps instantly instead of triggering
|
|
426
|
+
* a network reload. Only `js` client modules count: plugins that scan sources
|
|
427
|
+
* for other reasons (Tailwind's content scanner, for one) register `asset` nodes
|
|
428
|
+
* for ordinary server files, and treating those as client modules would silently
|
|
429
|
+
* disable server-data HMR for the whole app.
|
|
430
|
+
*
|
|
431
|
+
* Dev-only. An app that never renders `<HMR />` simply has no listener, so the
|
|
432
|
+
* event is inert.
|
|
433
|
+
*/
|
|
434
|
+
function serverDataHmr(serverEnvironments) {
|
|
435
|
+
let pending;
|
|
436
|
+
return {
|
|
437
|
+
name: "pitlane-remix-server-data-hmr",
|
|
438
|
+
apply: "serve",
|
|
439
|
+
hotUpdate({ file, server }) {
|
|
440
|
+
if (!serverEnvironments.has(this.environment.name)) return;
|
|
441
|
+
if (!/\.[jt]sx?$/.test(file)) return;
|
|
442
|
+
if ([...(server.environments.client?.moduleGraph)?.getModulesByFile(file) ?? []].some((module) => module.type === "js")) return;
|
|
443
|
+
clearTimeout(pending);
|
|
444
|
+
pending = setTimeout(() => {
|
|
445
|
+
pending = void 0;
|
|
446
|
+
server.hot.send({
|
|
447
|
+
type: "custom",
|
|
448
|
+
event: SERVER_UPDATE_EVENT
|
|
449
|
+
});
|
|
450
|
+
}, SERVER_UPDATE_SETTLE_MS);
|
|
451
|
+
pending.unref?.();
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Rewrites arrow-form component and `clientEntry()` exports to named function
|
|
457
|
+
* expressions so `remix/ui-hmr` can instrument them. Returns the rewritten
|
|
458
|
+
* source, or `undefined` when nothing qualified.
|
|
459
|
+
*
|
|
460
|
+
* Handles the two idiomatic Remix arrow forms:
|
|
461
|
+
*
|
|
462
|
+
* - `export const Name = clientEntry(url, (handle) => …)`
|
|
463
|
+
* - `export const Name = (handle) => () => <jsx/>`
|
|
464
|
+
*
|
|
465
|
+
* Both become `export const Name = clientEntry(url, function Name(handle) { … })`
|
|
466
|
+
* / `export const Name = function Name(handle) { … }`, which is behavior-
|
|
467
|
+
* identical for component setup functions (they never rely on a lexical `this`
|
|
468
|
+
* or `arguments`).
|
|
469
|
+
*/
|
|
470
|
+
function normalizeArrowComponents(code, id) {
|
|
471
|
+
if (!code.includes("=>")) return;
|
|
472
|
+
let program = parseSync(id, code).program;
|
|
473
|
+
let rewritten;
|
|
474
|
+
for (let node of program.body) {
|
|
475
|
+
if (node.type !== "ExportNamedDeclaration") continue;
|
|
476
|
+
if (node.declaration?.type !== "VariableDeclaration") continue;
|
|
477
|
+
for (let declarator of node.declaration.declarations) {
|
|
478
|
+
if (declarator.id.type !== "Identifier") continue;
|
|
479
|
+
if (!isPascalCase(declarator.id.name)) continue;
|
|
480
|
+
if (!declarator.init) continue;
|
|
481
|
+
let arrow = getNormalizableArrow(declarator.init);
|
|
482
|
+
if (!arrow) continue;
|
|
483
|
+
rewritten ??= new MagicString(code);
|
|
484
|
+
let params = getParamsSource(code, arrow);
|
|
485
|
+
let bodySource = code.slice(arrow.body.start, arrow.body.end);
|
|
486
|
+
let block = arrow.body.type === "BlockStatement" ? bodySource : `{ return ${bodySource} }`;
|
|
487
|
+
let asyncPrefix = arrow.async ? "async " : "";
|
|
488
|
+
rewritten.overwrite(arrow.start, arrow.end, `${asyncPrefix}function ${declarator.id.name}${params} ${block}`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return rewritten?.toString();
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Returns the arrow function to normalize for a component export initializer:
|
|
495
|
+
* the setup argument of a `clientEntry()` call, or a bare arrow component whose
|
|
496
|
+
* body returns a render function. Anything else yields `undefined`.
|
|
497
|
+
*/
|
|
498
|
+
function getNormalizableArrow(init) {
|
|
499
|
+
if (init.type === "CallExpression" && init.callee.type === "Identifier" && init.callee.name === "clientEntry") {
|
|
500
|
+
let setup = init.arguments[1];
|
|
501
|
+
return setup?.type === "ArrowFunctionExpression" ? setup : void 0;
|
|
502
|
+
}
|
|
503
|
+
if (init.type === "ArrowFunctionExpression" && returnsRenderFunction(init)) return init;
|
|
504
|
+
}
|
|
505
|
+
/** A Remix component setup returns a render function; that is the HMR signal. */
|
|
506
|
+
function returnsRenderFunction(arrow) {
|
|
507
|
+
let body = arrow.body;
|
|
508
|
+
if (body.type === "ArrowFunctionExpression" || body.type === "FunctionExpression") return true;
|
|
509
|
+
if (body.type === "BlockStatement") return body.body.some((statement) => statement.type === "ReturnStatement" && (statement.argument?.type === "ArrowFunctionExpression" || statement.argument?.type === "FunctionExpression"));
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
/** Source of the arrow's parameter list, always parenthesized. */
|
|
513
|
+
function getParamsSource(code, arrow) {
|
|
514
|
+
let params = arrow.params;
|
|
515
|
+
if (params.length === 0) return "()";
|
|
516
|
+
return `(${code.slice(params[0].start, params[params.length - 1].end)})`;
|
|
517
|
+
}
|
|
518
|
+
function isPascalCase(name) {
|
|
519
|
+
return /^[A-Z]/.test(name);
|
|
520
|
+
}
|
|
230
521
|
//#endregion
|
|
231
522
|
//#region src/preview.ts
|
|
232
523
|
function isFetchHandler(value) {
|
|
@@ -345,13 +636,22 @@ function findClientEntryCalls(program) {
|
|
|
345
636
|
* `clientEntry(import.meta.url, …)` hydration transform, dev serving through
|
|
346
637
|
* the app's fetch handler, and a preview server for the production build.
|
|
347
638
|
*
|
|
639
|
+
* During `vite dev` it also installs hot module replacement: component edits
|
|
640
|
+
* swap in place through the `remix/ui-hmr` transforms, and edits to modules the
|
|
641
|
+
* browser never loads refetch the current page through the app's fetch handler,
|
|
642
|
+
* keeping hydrated island state. Both are dev-only. The second half needs the
|
|
643
|
+
* app to render `<HMR />` from the `pitlane:dev` module, which resolves to an
|
|
644
|
+
* inert component in a build and when `clientEntry` is `false`.
|
|
645
|
+
*
|
|
348
646
|
* Platform-agnostic by design: deploy targets compose alongside it in the
|
|
349
647
|
* plugin array (`@cloudflare/vite-plugin`, `@netlify/vite-plugin`,
|
|
350
648
|
* `nitro/vite`), or the built fetch handler runs directly on Node, Bun, and
|
|
351
649
|
* Deno.
|
|
352
650
|
*/
|
|
353
651
|
function remix(options = {}) {
|
|
354
|
-
let { clientEntry = "app/entry.browser", serverEntry = "app/entry.server", serverEnvironments = ["ssr"], serverHandler = true } = options;
|
|
652
|
+
let { clientEntry = "app/entry.browser", server = true, serverEntry = "app/entry.server", serverEnvironments = ["ssr"], serverHandler = true } = options;
|
|
653
|
+
if (!server) return spa();
|
|
654
|
+
let serverEnvironmentSet = new Set(serverEnvironments);
|
|
355
655
|
return [
|
|
356
656
|
fullstack({
|
|
357
657
|
serverEnvironments,
|
|
@@ -366,7 +666,28 @@ function remix(options = {}) {
|
|
|
366
666
|
preview(),
|
|
367
667
|
suppressAbortErrors(),
|
|
368
668
|
normalizeWriteHead(),
|
|
369
|
-
|
|
669
|
+
componentHmr(serverEnvironmentSet),
|
|
670
|
+
hmrComponent(clientEntry),
|
|
671
|
+
clientEntryTransform(serverEnvironmentSet),
|
|
672
|
+
serverDataHmr(serverEnvironmentSet)
|
|
673
|
+
];
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* SPA mode: the subset of `remix()` that applies when there is no server.
|
|
677
|
+
* Vite already serves `index.html` and builds it to a static site, so the only
|
|
678
|
+
* thing left to wire is component hot module replacement — which a
|
|
679
|
+
* client-rendered app wants just as much as a server-rendered one.
|
|
680
|
+
*
|
|
681
|
+
* Everything server-shaped is absent by construction: no server environment,
|
|
682
|
+
* no `dist/ssr`, no dev fetch handler, and no server-data HMR (there is no
|
|
683
|
+
* server data to revalidate, so `<HMR />` resolves to the inert component).
|
|
684
|
+
*/
|
|
685
|
+
function spa() {
|
|
686
|
+
let serverEnvironmentSet = /* @__PURE__ */ new Set();
|
|
687
|
+
return [
|
|
688
|
+
componentHmr(serverEnvironmentSet),
|
|
689
|
+
hmrComponent(false),
|
|
690
|
+
clientEntryTransform(serverEnvironmentSet)
|
|
370
691
|
];
|
|
371
692
|
}
|
|
372
693
|
/**
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pitlane/dev",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "remix() — the Remix 3 Vite plugin: build orchestration, clientEntry() hydration transform, dev server, and preview for any Vite or Vite+ project.",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "remix() — the Remix 3 Vite plugin: build orchestration, clientEntry() hydration transform, dev server with component and server-data HMR, and preview for any Vite or Vite+ project.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pitlane",
|
|
7
7
|
"remix",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@cloudflare/vite-plugin": "^1.31.0",
|
|
51
51
|
"@types/node": "^25.5.0",
|
|
52
|
+
"playwright": "1.61.1",
|
|
52
53
|
"remix": "3.0.0-beta.10",
|
|
53
54
|
"typescript": "^7.0.2",
|
|
54
55
|
"vite": "^8.1.5",
|