@pitlane/dev 0.1.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 +24 -0
- package/LICENSE +21 -0
- package/README.md +310 -0
- package/dist/assets.d.mts +28 -0
- package/dist/index.d.mts +51 -0
- package/dist/index.mjs +413 -0
- package/dist/runtime.d.mts +32 -0
- package/dist/runtime.mjs +19 -0
- package/package.json +65 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# @pitlane/dev
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- `remix()` — Remix 3 build orchestration for any Vite or Vite+ project:
|
|
8
|
+
SSR-before-client multi-environment builds into `dist/ssr` + `dist/client`,
|
|
9
|
+
dev serving through the app's default-exported fetch handler, and a preview
|
|
10
|
+
server for the production build.
|
|
11
|
+
- `clientEntry()` hydration transform: named-export components become
|
|
12
|
+
hydratable islands; `import.meta.url` resolves to production asset URLs.
|
|
13
|
+
- The `?assets=` import protocol plus `mergeAssets` from
|
|
14
|
+
`@pitlane/dev/runtime`, with ambient types via `@pitlane/dev/assets`.
|
|
15
|
+
- Platform-agnostic by construction: composes with
|
|
16
|
+
`@cloudflare/vite-plugin`, `@netlify/vite-plugin`, and `nitro/vite`, or the
|
|
17
|
+
built handler runs directly on Node, Bun, and Deno. The assets manifest is
|
|
18
|
+
written eagerly and synthesized from bundle captures when an orchestrator
|
|
19
|
+
bundles the SSR output itself; runtime helpers are inlined into builds; dev
|
|
20
|
+
responses are normalized for runtimes with strict `node:http` semantics.
|
|
21
|
+
- Tested against Vite 8.1 (Rolldown), Vite+ 0.2 (`vp`), and
|
|
22
|
+
`remix@3.0.0-beta.5` across the eight
|
|
23
|
+
[pitlane-tools/templates](https://github.com/pitlane-tools/templates)
|
|
24
|
+
deploy targets.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mark Malstrom
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# @pitlane/dev
|
|
2
|
+
|
|
3
|
+
The `remix()` Vite plugin for [Remix 3](https://remix.run). One plugin wires a Remix app into any Vite or [Vite+](https://viteplus.dev) project: multi-environment build orchestration, the `clientEntry()` hydration transform, dev serving through your app's fetch handler, and a preview server for the production build.
|
|
4
|
+
|
|
5
|
+
`@pitlane/dev` is deliberately platform-agnostic. Your server entry default-exports a standard fetch handler, and hosting composes around it — platform plugins (`@cloudflare/vite-plugin`, `@netlify/vite-plugin`, `nitro/vite`) in the same plugin array, or plain runtimes (Node, Bun, Deno) running the built output directly. Composable hosting, not a hosting engine.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install --save-dev @pitlane/dev
|
|
11
|
+
# or
|
|
12
|
+
vp add -D @pitlane/dev
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Requires `remix@^3.0.0-beta.5` and `vite@>=7` as peers. v0.1.0 is tested against **Vite 8.1** (Rolldown), **Vite+ 0.2** (`vp`), and `remix@3.0.0-beta.5` — the [templates](https://github.com/pitlane-tools/templates) are the continuously tested reference.
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
// vite.config.ts
|
|
21
|
+
import { remix } from "@pitlane/dev";
|
|
22
|
+
import { defineConfig } from "vite"; // or "vite-plus"
|
|
23
|
+
|
|
24
|
+
export default defineConfig({
|
|
25
|
+
plugins: [remix()],
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
// app/entry.server.tsx
|
|
31
|
+
import { staticFiles } from "remix/middleware/static";
|
|
32
|
+
import { createRouter, type MiddlewareContext } from "remix/router";
|
|
33
|
+
|
|
34
|
+
import { Document } from "./document.tsx";
|
|
35
|
+
import { render, type RenderMiddleware } from "./render.tsx";
|
|
36
|
+
import { routes } from "./routes.ts";
|
|
37
|
+
|
|
38
|
+
type AppContext = MiddlewareContext<[RenderMiddleware]>;
|
|
39
|
+
|
|
40
|
+
declare module "remix/router" {
|
|
41
|
+
interface RouterTypes {
|
|
42
|
+
context: AppContext;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export let router = createRouter<AppContext>({
|
|
47
|
+
middleware: [staticFiles("./dist/client"), render()],
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
router.map(routes.home, ({ render }) => render(<Document />));
|
|
51
|
+
|
|
52
|
+
export default router;
|
|
53
|
+
|
|
54
|
+
if (import.meta.hot) {
|
|
55
|
+
import.meta.hot.accept();
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
// app/entry.browser.ts
|
|
61
|
+
import { run } from "remix/ui";
|
|
62
|
+
|
|
63
|
+
run({
|
|
64
|
+
async loadModule(moduleUrl, exportName) {
|
|
65
|
+
let mod = await import(/* @vite-ignore */ moduleUrl);
|
|
66
|
+
return mod[exportName];
|
|
67
|
+
},
|
|
68
|
+
async resolveFrame(src, signal) {
|
|
69
|
+
let response = await fetch(src, { headers: { accept: "text/html" }, signal });
|
|
70
|
+
return response.body ?? (await response.text());
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`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.
|
|
76
|
+
|
|
77
|
+
> [!NOTE]
|
|
78
|
+
> Dev updates are coarse-grained today: the server entry is re-imported per request, and client edits trigger a page refresh. Remix UI's first-class HMR runtime is in progress upstream ([remix-run/remix#11515](https://github.com/remix-run/remix/pull/11515)) — we're tracking it, and `@pitlane/dev` will ship the companion transform once it lands so components hot-swap in place.
|
|
79
|
+
|
|
80
|
+
## Options
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
remix({
|
|
84
|
+
clientEntry: "app/entry.browser", // default — false disables the client build
|
|
85
|
+
serverEntry: "app/entry.server", // default
|
|
86
|
+
serverEnvironments: ["ssr"], // default
|
|
87
|
+
serverHandler: true, // default — false when a platform plugin serves dev requests
|
|
88
|
+
});
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
| Option | Type | Default | Purpose |
|
|
92
|
+
| -------------------- | ----------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
|
93
|
+
| `clientEntry` | `string \| false` | `"app/entry.browser"` | Client entry module. Pass `false` for fully server-rendered apps with no hydration. |
|
|
94
|
+
| `serverEntry` | `string` | `"app/entry.server"` | Server entry module, built as `dist/ssr/index.js`. |
|
|
95
|
+
| `serverEnvironments` | `string[]` | `["ssr"]` | Environment names the `clientEntry()` transform treats as "server". |
|
|
96
|
+
| `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. |
|
|
97
|
+
|
|
98
|
+
## The server entry contract
|
|
99
|
+
|
|
100
|
+
The server entry **default-exports a fetch handler** — an object exposing `fetch(request: Request): Response | Promise<Response>`. A `createRouter()` router already is one:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
export default router;
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Every consumer speaks that same shape:
|
|
107
|
+
|
|
108
|
+
- **Dev** imports the entry through Vite's module runner and calls `default.fetch`.
|
|
109
|
+
- **Preview** imports `dist/ssr/index.js` and calls `default.fetch`.
|
|
110
|
+
- **Production** is whatever your target does with a fetch handler: `export default { fetch: router.fetch }` on Workers, `Bun.serve({ fetch: router.fetch })`, `deno serve dist/ssr/index.js`, or Node via `remix/node-fetch-server`.
|
|
111
|
+
|
|
112
|
+
Need extra worker exports? Wrap it:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
export default {
|
|
116
|
+
fetch: router.fetch,
|
|
117
|
+
async queue(batch) {
|
|
118
|
+
/* ... */
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Asset references — `?assets=`
|
|
124
|
+
|
|
125
|
+
Server-rendered documents need the hashed URLs of client assets. Import any module with the `?assets=` query to get its resolved assets for an environment:
|
|
126
|
+
|
|
127
|
+
```tsx
|
|
128
|
+
// app/document.tsx
|
|
129
|
+
import { mergeAssets } from "@pitlane/dev/runtime";
|
|
130
|
+
|
|
131
|
+
import clientAssets from "./entry.browser.ts?assets=client";
|
|
132
|
+
import serverAssets from "./entry.server.tsx?assets=ssr";
|
|
133
|
+
|
|
134
|
+
export function Document() {
|
|
135
|
+
let assets = mergeAssets(clientAssets, serverAssets);
|
|
136
|
+
|
|
137
|
+
return () => (
|
|
138
|
+
<html lang="en">
|
|
139
|
+
<head>
|
|
140
|
+
{assets.css.map(attrs => (
|
|
141
|
+
<link key={attrs.href} {...attrs} rel="stylesheet" />
|
|
142
|
+
))}
|
|
143
|
+
<script async src={clientAssets.entry} type="module" />
|
|
144
|
+
{assets.js.map(attrs => (
|
|
145
|
+
<link key={attrs.href} {...attrs} rel="modulepreload" />
|
|
146
|
+
))}
|
|
147
|
+
</head>
|
|
148
|
+
<body>{/* ... */}</body>
|
|
149
|
+
</html>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Each result is `{ entry?, js: [{ href }], css: [{ href }] }`; `mergeAssets` dedupes by href. In dev, URLs point at source modules and `js` is empty (Vite handles module loading); in production they point at hashed files in `dist/client`.
|
|
155
|
+
|
|
156
|
+
Type the query imports by adding the ambient declarations to your tsconfig:
|
|
157
|
+
|
|
158
|
+
```jsonc
|
|
159
|
+
{ "compilerOptions": { "types": ["@pitlane/dev/assets"] } }
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## `clientEntry()` authoring rules
|
|
163
|
+
|
|
164
|
+
The transform rewrites `clientEntry(import.meta.url, …)` so the first argument becomes the module's asset URL plus an `#ExportName` fragment — on the server via the `?assets=client` manifest, on the client via `import.meta.url` itself.
|
|
165
|
+
|
|
166
|
+
```tsx
|
|
167
|
+
import { clientEntry, on } from "remix/ui";
|
|
168
|
+
|
|
169
|
+
export const Counter = clientEntry(import.meta.url, handle => {
|
|
170
|
+
let count = 0;
|
|
171
|
+
return () => (
|
|
172
|
+
<button mix={[on("click", () => { count++; handle.update(); })]}>
|
|
173
|
+
Count: <span>{count}</span>
|
|
174
|
+
</button>
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The matched pattern is strict, by design:
|
|
180
|
+
|
|
181
|
+
- **Named, top-level exports only** — `export const Name = clientEntry(import.meta.url, …)`. The `#Name` fragment comes from the export name.
|
|
182
|
+
- Default exports, aliased imports of `clientEntry`, and non-exported calls are left untouched.
|
|
183
|
+
- Multiple `clientEntry` exports per file share one assets import.
|
|
184
|
+
|
|
185
|
+
## Deployment
|
|
186
|
+
|
|
187
|
+
The client build and component authoring never change across targets. Only two things vary: the `serverHandler` option and how production runs the built fetch handler.
|
|
188
|
+
|
|
189
|
+
### Node
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
// server.ts
|
|
193
|
+
import * as http from "node:http";
|
|
194
|
+
import { createRequestListener } from "remix/node-fetch-server";
|
|
195
|
+
|
|
196
|
+
// @ts-expect-error - built output has no types
|
|
197
|
+
import ssr from "./dist/ssr/index.js";
|
|
198
|
+
|
|
199
|
+
let server = http.createServer(createRequestListener(request => ssr.fetch(request)));
|
|
200
|
+
server.listen(process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000);
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Static assets are served by the `staticFiles("./dist/client")` middleware inside your router, so `server.ts` stays a one-liner and preview/production share one code path.
|
|
204
|
+
|
|
205
|
+
### Bun
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
// server.ts
|
|
209
|
+
import router from "./app/entry.server.tsx";
|
|
210
|
+
|
|
211
|
+
Bun.serve({
|
|
212
|
+
port: 3000,
|
|
213
|
+
fetch: request => router.fetch(request),
|
|
214
|
+
});
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Deno
|
|
218
|
+
|
|
219
|
+
The built entry already satisfies `deno serve`'s default-export contract:
|
|
220
|
+
|
|
221
|
+
```sh
|
|
222
|
+
deno serve --allow-read --allow-net dist/ssr/index.js
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### Cloudflare Workers
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
// vite.config.ts
|
|
229
|
+
import { remix } from "@pitlane/dev";
|
|
230
|
+
import { cloudflare } from "@cloudflare/vite-plugin";
|
|
231
|
+
import { defineConfig } from "vite";
|
|
232
|
+
|
|
233
|
+
export default defineConfig({
|
|
234
|
+
plugins: [remix({ serverHandler: false }), cloudflare({ viteEnvironment: { name: "ssr" } })],
|
|
235
|
+
});
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
```jsonc
|
|
239
|
+
// wrangler.jsonc
|
|
240
|
+
{
|
|
241
|
+
"name": "my-remix-app",
|
|
242
|
+
"main": "app/entry.server.tsx",
|
|
243
|
+
"assets": { "directory": "dist/client" },
|
|
244
|
+
"compatibility_date": "2026-04-02",
|
|
245
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
`vite dev` runs your server code inside workerd (real bindings, real runtime), `vite preview` serves the production build through Miniflare, and `wrangler deploy` ships it.
|
|
250
|
+
|
|
251
|
+
### Netlify
|
|
252
|
+
|
|
253
|
+
Keep the defaults — Netlify's plugin emulates the platform in dev while your fetch handler serves SSR. A three-line Netlify Function (`netlify/functions/server.mjs`) wraps the built entry; see the [Netlify guide](https://pitlane.tools/deploy/netlify).
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
export default defineConfig({
|
|
257
|
+
plugins: [remix(), netlify()],
|
|
258
|
+
});
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Vercel (via Nitro)
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
import { nitro } from "nitro/vite";
|
|
265
|
+
|
|
266
|
+
export default defineConfig({
|
|
267
|
+
plugins: [remix({ serverHandler: false }), nitro()],
|
|
268
|
+
});
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
## Build layout
|
|
272
|
+
|
|
273
|
+
```
|
|
274
|
+
dist/
|
|
275
|
+
├── client/ # static assets, hashed — serve as-is
|
|
276
|
+
│ └── assets/*
|
|
277
|
+
└── ssr/
|
|
278
|
+
└── index.js # your fetch handler, bundled
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
`vite build` builds the SSR environment first, then the client (the client build resolves asset references against the SSR manifest). When another plugin also orchestrates builds — Cloudflare's, for example — `remix()` coordinates so each environment builds exactly once.
|
|
282
|
+
|
|
283
|
+
## Compatibility
|
|
284
|
+
|
|
285
|
+
| Dependency | Tested against |
|
|
286
|
+
| ---------- | -------------- |
|
|
287
|
+
| `vite` | 8.1.5 |
|
|
288
|
+
| `vite-plus`| 0.2.6 |
|
|
289
|
+
| `remix` | 3.0.0-beta.5 |
|
|
290
|
+
| Node | 24 LTS, 25 |
|
|
291
|
+
|
|
292
|
+
Remix 3 is in beta; each `@pitlane/dev` release records the exact beta it was verified against. Rolldown is not required — the transform runs identically on generic Vite and Vite+.
|
|
293
|
+
|
|
294
|
+
### Troubleshooting
|
|
295
|
+
|
|
296
|
+
**`AssertionError: isRunnableDevEnvironment(environment)` on `vite dev`** — your project resolves two different `vite` packages (typically Vite+ running the server while a plain `vite` install satisfies peer ranges). Give the project a single vite identity by aliasing, e.g. with pnpm:
|
|
297
|
+
|
|
298
|
+
```jsonc
|
|
299
|
+
// package.json
|
|
300
|
+
{
|
|
301
|
+
"devDependencies": { "vite": "npm:@voidzero-dev/vite-plus-core@latest" },
|
|
302
|
+
"pnpm": { "overrides": { "vite": "npm:@voidzero-dev/vite-plus-core@latest" } }
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Generic-Vite projects have one vite by construction and are unaffected.
|
|
307
|
+
|
|
308
|
+
## License
|
|
309
|
+
|
|
310
|
+
MIT
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for Pitlane's `?assets=` import convention.
|
|
3
|
+
*
|
|
4
|
+
* Add to an app's tsconfig to type `?assets=` imports:
|
|
5
|
+
*
|
|
6
|
+
* ```jsonc
|
|
7
|
+
* { "compilerOptions": { "types": ["@pitlane/dev/assets"] } }
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* Ambient by necessity: these wildcard module declarations must stay global,
|
|
11
|
+
* so types are referenced with inline `import()` instead of top-level
|
|
12
|
+
* `import type` (which would turn this file into a module).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
declare module "*?assets" {
|
|
16
|
+
const assets: import("@pitlane/dev/runtime").ImportedAssets;
|
|
17
|
+
export default assets;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
declare module "*?assets=client" {
|
|
21
|
+
const assets: import("@pitlane/dev/runtime").ImportedAssets;
|
|
22
|
+
export default assets;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
declare module "*?assets=ssr" {
|
|
26
|
+
const assets: import("@pitlane/dev/runtime").ImportedAssets;
|
|
27
|
+
export default assets;
|
|
28
|
+
}
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { PluginOption } from "vite";
|
|
2
|
+
//#region src/index.d.ts
|
|
3
|
+
interface RemixPluginOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Client entry module, used as the client environment's build input.
|
|
6
|
+
* Pass `false` to disable the client environment entirely (fully
|
|
7
|
+
* server-rendered apps with no hydration).
|
|
8
|
+
*
|
|
9
|
+
* @default "app/entry.browser"
|
|
10
|
+
*/
|
|
11
|
+
clientEntry?: string | false;
|
|
12
|
+
/**
|
|
13
|
+
* Server entry module, built as `dist/ssr/index.js`. Must default-export
|
|
14
|
+
* a fetch handler: an object exposing
|
|
15
|
+
* `fetch(request: Request): Response | Promise<Response>`, e.g. a
|
|
16
|
+
* `createRouter()` router.
|
|
17
|
+
*
|
|
18
|
+
* @default "app/entry.server"
|
|
19
|
+
*/
|
|
20
|
+
serverEntry?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Environment names the `clientEntry()` transform treats as "server".
|
|
23
|
+
* In these environments the transform resolves the client chunk URL via a
|
|
24
|
+
* `?assets=client` import.
|
|
25
|
+
*
|
|
26
|
+
* @default ["ssr"]
|
|
27
|
+
*/
|
|
28
|
+
serverEnvironments?: string[];
|
|
29
|
+
/**
|
|
30
|
+
* Serve dev-server requests through the server entry's fetch handler.
|
|
31
|
+
* Set to `false` when another plugin owns dev-time request handling —
|
|
32
|
+
* e.g. `@cloudflare/vite-plugin`, `@netlify/vite-plugin`, or `nitro/vite`.
|
|
33
|
+
*
|
|
34
|
+
* @default true
|
|
35
|
+
*/
|
|
36
|
+
serverHandler?: boolean;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Wires Remix 3 into a Vite or Vite+ project: multi-environment build
|
|
40
|
+
* orchestration (`dist/ssr` + `dist/client`), the
|
|
41
|
+
* `clientEntry(import.meta.url, …)` hydration transform, dev serving through
|
|
42
|
+
* the app's fetch handler, and a preview server for the production build.
|
|
43
|
+
*
|
|
44
|
+
* Platform-agnostic by design: deploy targets compose alongside it in the
|
|
45
|
+
* plugin array (`@cloudflare/vite-plugin`, `@netlify/vite-plugin`,
|
|
46
|
+
* `nitro/vite`), or the built fetch handler runs directly on Node, Bun, and
|
|
47
|
+
* Deno.
|
|
48
|
+
*/
|
|
49
|
+
declare function remix(options?: RemixPluginOptions): PluginOption;
|
|
50
|
+
//#endregion
|
|
51
|
+
export { RemixPluginOptions, remix };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import { mergeAssets } from "./runtime.mjs";
|
|
2
|
+
import fullstack from "@hiogawa/vite-plugin-fullstack";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import MagicString from "magic-string";
|
|
6
|
+
import { parseSync } from "oxc-parser";
|
|
7
|
+
//#region src/build.ts
|
|
8
|
+
function isFullstackBuilder(builder) {
|
|
9
|
+
return "writeAssetsManifest" in builder && typeof builder.writeAssetsManifest === "function";
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Patches the builder so pitlane-remix-build coexists with plugins that also
|
|
13
|
+
* orchestrate builds (e.g. @cloudflare/vite-plugin). Runs at "pre" order so
|
|
14
|
+
* the guards are in place before any building starts, regardless of plugin
|
|
15
|
+
* registration order.
|
|
16
|
+
*
|
|
17
|
+
* Written against builder semantics, not any platform: everything is
|
|
18
|
+
* feature-detected, nothing platform-specific is imported.
|
|
19
|
+
*/
|
|
20
|
+
function buildCompat() {
|
|
21
|
+
return {
|
|
22
|
+
name: "pitlane-remix-build:compat",
|
|
23
|
+
buildApp: {
|
|
24
|
+
order: "pre",
|
|
25
|
+
async handler(builder) {
|
|
26
|
+
let originalBuild = builder.build.bind(builder);
|
|
27
|
+
builder.build = (async (environment) => {
|
|
28
|
+
if ("isBuilt" in environment && environment.isBuilt) return;
|
|
29
|
+
return originalBuild(environment);
|
|
30
|
+
});
|
|
31
|
+
if (isFullstackBuilder(builder)) {
|
|
32
|
+
let originalWrite = builder.writeAssetsManifest.bind(builder);
|
|
33
|
+
builder.writeAssetsManifest = async () => {
|
|
34
|
+
try {
|
|
35
|
+
await originalWrite();
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error.code !== "ENOENT") throw error;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Keyed on globalThis so the captures survive duplicate plugin instantiation —
|
|
47
|
+
* config files can be evaluated more than once in a single process.
|
|
48
|
+
*/
|
|
49
|
+
function bundleStore() {
|
|
50
|
+
let host = globalThis;
|
|
51
|
+
return host.__pitlaneBundleStore ??= /* @__PURE__ */ new Map();
|
|
52
|
+
}
|
|
53
|
+
const MANIFEST_NAME = "__fullstack_assets_manifest.js";
|
|
54
|
+
const MANIFEST_REFERENCE = /__assets_manifest\["([^"]+)"\]\["([^"]+)"\]/g;
|
|
55
|
+
function joinBase(base, fileName) {
|
|
56
|
+
return (base.endsWith("/") ? base : base + "/") + fileName;
|
|
57
|
+
}
|
|
58
|
+
function findChunkForModule(bundle, root, key) {
|
|
59
|
+
let moduleId = root.replaceAll("\\", "/") + "/" + key;
|
|
60
|
+
for (let entry of Object.values(bundle)) {
|
|
61
|
+
if (entry.type !== "chunk" || !entry.facadeModuleId) continue;
|
|
62
|
+
if (entry.facadeModuleId.replaceAll("\\", "/") === moduleId) return entry;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function collectDependencies(bundle, entryChunk) {
|
|
66
|
+
let js = [];
|
|
67
|
+
let css = /* @__PURE__ */ new Set();
|
|
68
|
+
let visited = /* @__PURE__ */ new Set();
|
|
69
|
+
let queue = [entryChunk];
|
|
70
|
+
while (queue.length > 0) {
|
|
71
|
+
let chunk = queue.shift();
|
|
72
|
+
if (visited.has(chunk.fileName)) continue;
|
|
73
|
+
visited.add(chunk.fileName);
|
|
74
|
+
js.push(chunk.fileName);
|
|
75
|
+
for (let file of chunk.viteMetadata?.importedCss ?? []) css.add(file);
|
|
76
|
+
for (let imported of chunk.imports) {
|
|
77
|
+
let entry = bundle[imported];
|
|
78
|
+
if (entry?.type === "chunk") queue.push(entry);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
js,
|
|
83
|
+
css: [...css]
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Synthesizes the `?assets=` manifest module when the upstream write never
|
|
88
|
+
* landed. Some orchestrators (observed with Nitro) bundle the SSR output
|
|
89
|
+
* inside their own buildApp without the upstream post-order write having run
|
|
90
|
+
* against the same builder, leaving the built server entry importing a
|
|
91
|
+
* manifest file that does not exist. Everything needed to produce it is in
|
|
92
|
+
* the captured output bundles: the built server code names the exact
|
|
93
|
+
* `(environment, module)` pairs it reads, and the chunk graph names the JS
|
|
94
|
+
* and CSS dependencies of each module.
|
|
95
|
+
*
|
|
96
|
+
* No-op whenever the upstream write already produced the file.
|
|
97
|
+
*/
|
|
98
|
+
function ensureAssetsManifest() {
|
|
99
|
+
let ssr = bundleStore().get("ssr");
|
|
100
|
+
if (!ssr) return;
|
|
101
|
+
let ssrOutDir = path.resolve(ssr.root, ssr.outDir);
|
|
102
|
+
let manifestPath = path.join(ssrOutDir, MANIFEST_NAME);
|
|
103
|
+
if (fs.existsSync(manifestPath)) return;
|
|
104
|
+
let references = /* @__PURE__ */ new Map();
|
|
105
|
+
for (let entry of Object.values(ssr.bundle)) {
|
|
106
|
+
if (entry.type !== "chunk") continue;
|
|
107
|
+
let code = entry.code;
|
|
108
|
+
if (code === void 0) try {
|
|
109
|
+
code = fs.readFileSync(path.join(ssrOutDir, entry.fileName), "utf8");
|
|
110
|
+
} catch {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
for (let match of code.matchAll(MANIFEST_REFERENCE)) {
|
|
114
|
+
let keys = references.get(match[1]) ?? /* @__PURE__ */ new Set();
|
|
115
|
+
keys.add(match[2]);
|
|
116
|
+
references.set(match[1], keys);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (references.size === 0) return;
|
|
120
|
+
let client = bundleStore().get("client");
|
|
121
|
+
let base = (client ?? ssr).base;
|
|
122
|
+
let manifest = {};
|
|
123
|
+
for (let [environmentName, keys] of references) {
|
|
124
|
+
let captured = bundleStore().get(environmentName);
|
|
125
|
+
if (!captured) continue;
|
|
126
|
+
for (let key of keys) {
|
|
127
|
+
let chunk = findChunkForModule(captured.bundle, captured.root, key);
|
|
128
|
+
if (!chunk) continue;
|
|
129
|
+
let dependencies = collectDependencies(captured.bundle, chunk);
|
|
130
|
+
let record = {
|
|
131
|
+
js: [],
|
|
132
|
+
css: []
|
|
133
|
+
};
|
|
134
|
+
if (environmentName === "client") {
|
|
135
|
+
record.entry = joinBase(base, chunk.fileName);
|
|
136
|
+
record.js = dependencies.js.map((fileName) => ({ href: joinBase(base, fileName) }));
|
|
137
|
+
}
|
|
138
|
+
record.css = dependencies.css.map((fileName) => ({ href: joinBase(base, fileName) }));
|
|
139
|
+
(manifest[environmentName] ??= {})[key] = record;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
fs.mkdirSync(ssrOutDir, { recursive: true });
|
|
143
|
+
fs.writeFileSync(manifestPath, `export default ${JSON.stringify(manifest)};\n`);
|
|
144
|
+
if (client) {
|
|
145
|
+
let clientOutDir = path.resolve(client.root, client.outDir);
|
|
146
|
+
for (let entry of Object.values(ssr.bundle)) {
|
|
147
|
+
if (entry.type !== "asset") continue;
|
|
148
|
+
let source = path.join(ssrOutDir, entry.fileName);
|
|
149
|
+
let destination = path.join(clientOutDir, entry.fileName);
|
|
150
|
+
if (!fs.existsSync(source) || fs.existsSync(destination)) continue;
|
|
151
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
152
|
+
fs.copyFileSync(source, destination);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Environment defaults and build sequencing. SSR builds first, then the
|
|
158
|
+
* client: the client build resolves `?assets=ssr` against the SSR manifest,
|
|
159
|
+
* so the order is load-bearing.
|
|
160
|
+
*/
|
|
161
|
+
function build({ clientEntry, serverEntry }) {
|
|
162
|
+
let hasClientEntry = clientEntry !== false;
|
|
163
|
+
return {
|
|
164
|
+
name: "pitlane-remix-build",
|
|
165
|
+
writeBundle(_options, bundle) {
|
|
166
|
+
bundleStore().set(this.environment.name, {
|
|
167
|
+
bundle,
|
|
168
|
+
root: this.environment.config.root,
|
|
169
|
+
base: this.environment.config.base,
|
|
170
|
+
outDir: this.environment.config.build.outDir
|
|
171
|
+
});
|
|
172
|
+
},
|
|
173
|
+
async buildApp(builder) {
|
|
174
|
+
await builder.build(builder.environments.ssr);
|
|
175
|
+
if (hasClientEntry) await builder.build(builder.environments.client);
|
|
176
|
+
if (isFullstackBuilder(builder)) await builder.writeAssetsManifest();
|
|
177
|
+
ensureAssetsManifest();
|
|
178
|
+
},
|
|
179
|
+
config(userConfig) {
|
|
180
|
+
let environments = userConfig.environments;
|
|
181
|
+
let hasUserClientInput = Boolean(environments?.client?.build?.rollupOptions?.input);
|
|
182
|
+
let hasUserServerInput = Boolean(environments?.ssr?.build?.rollupOptions?.input);
|
|
183
|
+
return {
|
|
184
|
+
builder: {},
|
|
185
|
+
build: { assetsInlineLimit: 0 },
|
|
186
|
+
environments: {
|
|
187
|
+
...hasClientEntry && { client: { build: {
|
|
188
|
+
outDir: "dist/client",
|
|
189
|
+
rollupOptions: { input: hasUserClientInput ? void 0 : clientEntry || void 0 }
|
|
190
|
+
} } },
|
|
191
|
+
ssr: {
|
|
192
|
+
resolve: { noExternal: [/^@pitlane\/dev(\/|$)/] },
|
|
193
|
+
build: {
|
|
194
|
+
outDir: "dist/ssr",
|
|
195
|
+
rollupOptions: { input: hasUserServerInput ? void 0 : { index: serverEntry } }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
const RUNTIME_MODULE_ID = "\0pitlane:runtime";
|
|
204
|
+
/**
|
|
205
|
+
* Resolves `@pitlane/dev/runtime` imports to an inlined copy of the
|
|
206
|
+
* implementation. The package is a dev dependency: built output must never
|
|
207
|
+
* import it at runtime (pruned containers, Deno import maps, serverless
|
|
208
|
+
* bundles), and dependency-externalization behavior varies across cores and
|
|
209
|
+
* orchestrators — inlining by construction removes the variable.
|
|
210
|
+
*/
|
|
211
|
+
function runtimeInline() {
|
|
212
|
+
return {
|
|
213
|
+
name: "pitlane-runtime-inline",
|
|
214
|
+
enforce: "pre",
|
|
215
|
+
resolveId: {
|
|
216
|
+
filter: { id: /^@pitlane\/dev\/runtime$/ },
|
|
217
|
+
handler(source) {
|
|
218
|
+
if (source === "@pitlane/dev/runtime") return RUNTIME_MODULE_ID;
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
load: {
|
|
222
|
+
filter: { id: /^\0pitlane:runtime$/ },
|
|
223
|
+
handler(id) {
|
|
224
|
+
if (id === RUNTIME_MODULE_ID) return `export const mergeAssets = ${mergeAssets.toString()};\n`;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
//#endregion
|
|
230
|
+
//#region src/preview.ts
|
|
231
|
+
function isFetchHandler(value) {
|
|
232
|
+
return typeof value === "object" && value !== null && "fetch" in value && typeof value.fetch === "function";
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Serves the production build through `vite preview` using the same SSR entry
|
|
236
|
+
* that production deploys, adapted with `remix/node-fetch-server`.
|
|
237
|
+
*
|
|
238
|
+
* When the SSR bundle targets a non-Node runtime (e.g. Cloudflare Workers,
|
|
239
|
+
* whose bundle imports `cloudflare:workers`), the dynamic import fails and the
|
|
240
|
+
* plugin skips itself so the platform plugin's preview can take over. That
|
|
241
|
+
* failure → skip contract is documented behavior, not an accident.
|
|
242
|
+
*/
|
|
243
|
+
function preview() {
|
|
244
|
+
return {
|
|
245
|
+
name: "pitlane-remix-preview-server",
|
|
246
|
+
async configurePreviewServer(server) {
|
|
247
|
+
let ssrOutDir = server.config.environments.ssr?.build?.outDir ?? "dist/ssr";
|
|
248
|
+
let entryPath = new URL(`${ssrOutDir}/index.js`, `file://${server.config.root}/`).href;
|
|
249
|
+
let mod;
|
|
250
|
+
try {
|
|
251
|
+
mod = await import(
|
|
252
|
+
/* @vite-ignore */
|
|
253
|
+
entryPath
|
|
254
|
+
);
|
|
255
|
+
} catch {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
let handler = mod.default;
|
|
259
|
+
if (!isFetchHandler(handler)) throw new Error(`[@pitlane/dev] ${ssrOutDir}/index.js must default-export a fetch handler (an object with fetch(request: Request)), e.g. \`export default router\`.`);
|
|
260
|
+
let { createRequestListener } = await import("remix/node-fetch-server");
|
|
261
|
+
return () => {
|
|
262
|
+
server.middlewares.use(createRequestListener((request) => handler.fetch(request)));
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
//#endregion
|
|
268
|
+
//#region src/transform.ts
|
|
269
|
+
const CLIENT_ENTRY_PATTERN = /\bclientEntry\b/;
|
|
270
|
+
/**
|
|
271
|
+
* Rewrites `export const Name = clientEntry(import.meta.url, …)` so the first
|
|
272
|
+
* argument resolves to a production asset URL carrying an `#ExportName`
|
|
273
|
+
* fragment.
|
|
274
|
+
*
|
|
275
|
+
* - In server environments the module gains one
|
|
276
|
+
* `import ___clientEntryAssets from "<id>?assets=client"` prepend, and each
|
|
277
|
+
* call site becomes `___clientEntryAssets.entry + "#Name"`.
|
|
278
|
+
* - In the client environment `import.meta.url` already resolves to the chunk
|
|
279
|
+
* URL at runtime, so each call site becomes `import.meta.url + "#Name"`.
|
|
280
|
+
*
|
|
281
|
+
* One code path runs everywhere — dev and build, generic Vite and Vite+.
|
|
282
|
+
* (Rolldown's native `meta.ast`/`meta.magicString` fast path was tried and
|
|
283
|
+
* reverted: unfinished types, never exercised. Revisit once it is real.)
|
|
284
|
+
*/
|
|
285
|
+
function clientEntryTransform(serverEnvironments) {
|
|
286
|
+
return {
|
|
287
|
+
name: "pitlane-remix-client-entry-transform",
|
|
288
|
+
transform: {
|
|
289
|
+
filter: { code: { include: CLIENT_ENTRY_PATTERN } },
|
|
290
|
+
handler(code, id) {
|
|
291
|
+
if (!code.includes("import.meta.url")) return;
|
|
292
|
+
let ast = parseSync(id, code).program;
|
|
293
|
+
let calls = findClientEntryCalls(ast);
|
|
294
|
+
if (calls.length === 0) return;
|
|
295
|
+
let ms = new MagicString(code);
|
|
296
|
+
if (serverEnvironments.has(this.environment.name)) {
|
|
297
|
+
ms.prepend(`import ___clientEntryAssets from "${id}?assets=client";\n`);
|
|
298
|
+
for (let call of calls) ms.overwrite(call.metaUrlStart, call.metaUrlEnd, `___clientEntryAssets.entry + "#${call.exportName}"`);
|
|
299
|
+
} else for (let call of calls) ms.overwrite(call.metaUrlStart, call.metaUrlEnd, `import.meta.url + "#${call.exportName}"`);
|
|
300
|
+
return {
|
|
301
|
+
code: ms.toString(),
|
|
302
|
+
map: ms.generateMap({
|
|
303
|
+
hires: "boundary",
|
|
304
|
+
source: id
|
|
305
|
+
})
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Matches exactly `export const Name = clientEntry(import.meta.url, …)` at the
|
|
313
|
+
* top level, with at least two arguments. Default exports, aliased callees,
|
|
314
|
+
* and non-exported calls are intentionally ignored — the `#Name` fragment
|
|
315
|
+
* requires a named export.
|
|
316
|
+
*/
|
|
317
|
+
function findClientEntryCalls(program) {
|
|
318
|
+
let results = [];
|
|
319
|
+
for (let node of program.body) {
|
|
320
|
+
if (node.type !== "ExportNamedDeclaration") continue;
|
|
321
|
+
if (node.declaration?.type !== "VariableDeclaration") continue;
|
|
322
|
+
for (let declarator of node.declaration.declarations) {
|
|
323
|
+
if (declarator.id.type !== "Identifier") continue;
|
|
324
|
+
if (declarator.init?.type !== "CallExpression") continue;
|
|
325
|
+
let call = declarator.init;
|
|
326
|
+
if (call.callee.type !== "Identifier" || call.callee.name !== "clientEntry") continue;
|
|
327
|
+
if (call.arguments.length < 2) continue;
|
|
328
|
+
let firstArg = call.arguments[0];
|
|
329
|
+
if (firstArg.type !== "MemberExpression" || firstArg.object.type !== "MetaProperty" || firstArg.property.type !== "Identifier" || firstArg.property.name !== "url") continue;
|
|
330
|
+
results.push({
|
|
331
|
+
exportName: declarator.id.name,
|
|
332
|
+
metaUrlStart: firstArg.start,
|
|
333
|
+
metaUrlEnd: firstArg.end
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return results;
|
|
338
|
+
}
|
|
339
|
+
//#endregion
|
|
340
|
+
//#region src/index.ts
|
|
341
|
+
/**
|
|
342
|
+
* Wires Remix 3 into a Vite or Vite+ project: multi-environment build
|
|
343
|
+
* orchestration (`dist/ssr` + `dist/client`), the
|
|
344
|
+
* `clientEntry(import.meta.url, …)` hydration transform, dev serving through
|
|
345
|
+
* the app's fetch handler, and a preview server for the production build.
|
|
346
|
+
*
|
|
347
|
+
* Platform-agnostic by design: deploy targets compose alongside it in the
|
|
348
|
+
* plugin array (`@cloudflare/vite-plugin`, `@netlify/vite-plugin`,
|
|
349
|
+
* `nitro/vite`), or the built fetch handler runs directly on Node, Bun, and
|
|
350
|
+
* Deno.
|
|
351
|
+
*/
|
|
352
|
+
function remix(options = {}) {
|
|
353
|
+
let { clientEntry = "app/entry.browser", serverEntry = "app/entry.server", serverEnvironments = ["ssr"], serverHandler = true } = options;
|
|
354
|
+
return [
|
|
355
|
+
fullstack({
|
|
356
|
+
serverEnvironments,
|
|
357
|
+
serverHandler
|
|
358
|
+
}),
|
|
359
|
+
buildCompat(),
|
|
360
|
+
build({
|
|
361
|
+
clientEntry,
|
|
362
|
+
serverEntry
|
|
363
|
+
}),
|
|
364
|
+
runtimeInline(),
|
|
365
|
+
preview(),
|
|
366
|
+
suppressAbortErrors(),
|
|
367
|
+
normalizeWriteHead(),
|
|
368
|
+
clientEntryTransform(new Set(serverEnvironments))
|
|
369
|
+
];
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Suppresses `aborted` errors from client disconnects (e.g. search-as-you-type
|
|
373
|
+
* or navigating away mid-fetch) that would otherwise trigger Vite's dev error
|
|
374
|
+
* overlay. The match is deliberately narrow so real failures still propagate.
|
|
375
|
+
*/
|
|
376
|
+
function suppressAbortErrors() {
|
|
377
|
+
return {
|
|
378
|
+
name: "pitlane-remix-suppress-abort-errors",
|
|
379
|
+
configureServer(server) {
|
|
380
|
+
return () => {
|
|
381
|
+
server.middlewares.use((err, _req, _res, next) => {
|
|
382
|
+
if (err?.message === "aborted") return;
|
|
383
|
+
next(err);
|
|
384
|
+
});
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Flattens `[["key", "value"], …]` header arguments to the documented flat
|
|
391
|
+
* form before they reach `res.writeHead`. Node tolerates nested pairs, but
|
|
392
|
+
* runtimes implementing the documented `node:http` contract (Deno) reject
|
|
393
|
+
* them — and dev-serving dependencies send pairs. Dev-only; production
|
|
394
|
+
* responses never pass through this server.
|
|
395
|
+
*/
|
|
396
|
+
function normalizeWriteHead() {
|
|
397
|
+
return {
|
|
398
|
+
name: "pitlane-remix-normalize-write-head",
|
|
399
|
+
configureServer(server) {
|
|
400
|
+
server.middlewares.use((_req, res, next) => {
|
|
401
|
+
let original = res.writeHead.bind(res);
|
|
402
|
+
res.writeHead = ((...args) => {
|
|
403
|
+
let last = args[args.length - 1];
|
|
404
|
+
if (Array.isArray(last) && last.every((entry) => Array.isArray(entry))) args[args.length - 1] = last.flat();
|
|
405
|
+
return original(...args);
|
|
406
|
+
});
|
|
407
|
+
next();
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
//#endregion
|
|
413
|
+
export { remix };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
//#region src/runtime.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The result shape of a `?assets=` import: the resolved entry URL (client
|
|
4
|
+
* environment only), plus the JS and CSS assets reachable from the imported
|
|
5
|
+
* module in that environment.
|
|
6
|
+
*
|
|
7
|
+
* During dev, `js` is always empty (no chunk graph exists yet) and
|
|
8
|
+
* `?assets=client` carries no CSS — Vite injects dev styles itself; the
|
|
9
|
+
* server-environment results carry `data-vite-dev-id` stylesheet links.
|
|
10
|
+
*/
|
|
11
|
+
interface ImportedAssets {
|
|
12
|
+
entry?: string;
|
|
13
|
+
js: Array<{
|
|
14
|
+
href: string;
|
|
15
|
+
}>;
|
|
16
|
+
css: Array<{
|
|
17
|
+
href: string;
|
|
18
|
+
"data-vite-dev-id"?: string;
|
|
19
|
+
}>;
|
|
20
|
+
merge(...results: ImportedAssets[]): ImportedAssets;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Merges multiple `?assets=` results, deduplicating `js` and `css` entries by
|
|
24
|
+
* href. Typical use: combining the client entry's assets with the SSR
|
|
25
|
+
* module's CSS inside a `<Document>` component.
|
|
26
|
+
*
|
|
27
|
+
* The annotation re-types the delegated implementation against Pitlane-owned
|
|
28
|
+
* shapes so the dependency never appears in this package's public types.
|
|
29
|
+
*/
|
|
30
|
+
declare const mergeAssets: (...results: ImportedAssets[]) => ImportedAssets;
|
|
31
|
+
//#endregion
|
|
32
|
+
export { ImportedAssets, mergeAssets };
|
package/dist/runtime.mjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { mergeAssets as mergeAssets$1 } from "@hiogawa/vite-plugin-fullstack/runtime";
|
|
2
|
+
//#region src/runtime.ts
|
|
3
|
+
/**
|
|
4
|
+
* Server- and client-safe runtime helpers for the `?assets=` import
|
|
5
|
+
* convention. Import from `@pitlane/dev/runtime` in application code.
|
|
6
|
+
*
|
|
7
|
+
* @module @pitlane/dev/runtime
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Merges multiple `?assets=` results, deduplicating `js` and `css` entries by
|
|
11
|
+
* href. Typical use: combining the client entry's assets with the SSR
|
|
12
|
+
* module's CSS inside a `<Document>` component.
|
|
13
|
+
*
|
|
14
|
+
* The annotation re-types the delegated implementation against Pitlane-owned
|
|
15
|
+
* shapes so the dependency never appears in this package's public types.
|
|
16
|
+
*/
|
|
17
|
+
const mergeAssets = mergeAssets$1;
|
|
18
|
+
//#endregion
|
|
19
|
+
export { mergeAssets };
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pitlane/dev",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "remix() \u2014 the Remix 3 Vite plugin: build orchestration, clientEntry() hydration transform, dev server, and preview for any Vite or Vite+ project.",
|
|
5
|
+
"homepage": "https://pitlane.tools/package/dev/",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/pitlane-tools/pitlane/issues"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"author": "Mark Malstrom <mark@malstrom.me>",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/pitlane-tools/pitlane.git",
|
|
14
|
+
"directory": "packages/dev"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"remix",
|
|
18
|
+
"vite",
|
|
19
|
+
"vite-plugin",
|
|
20
|
+
"pitlane"
|
|
21
|
+
],
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"CHANGELOG.md"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"types": "./dist/index.d.mts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.mts",
|
|
31
|
+
"import": "./dist/index.mjs"
|
|
32
|
+
},
|
|
33
|
+
"./runtime": {
|
|
34
|
+
"types": "./dist/runtime.d.mts",
|
|
35
|
+
"import": "./dist/runtime.mjs"
|
|
36
|
+
},
|
|
37
|
+
"./assets": {
|
|
38
|
+
"types": "./dist/assets.d.mts"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"prepublishOnly": "vp run build"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@hiogawa/vite-plugin-fullstack": "0.0.11",
|
|
46
|
+
"magic-string": "^0.30.21",
|
|
47
|
+
"oxc-parser": "^0.141.0"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"remix": "^3.0.0-beta.5",
|
|
51
|
+
"vite": ">=7.0.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@cloudflare/vite-plugin": "^1.31.0",
|
|
55
|
+
"@types/node": "^25.5.0",
|
|
56
|
+
"remix": "3.0.0-beta.5",
|
|
57
|
+
"typescript": "^7.0.2",
|
|
58
|
+
"vite": "^8.1.5",
|
|
59
|
+
"vite-plus": "^0.2.6",
|
|
60
|
+
"wrangler": "^4.114.0"
|
|
61
|
+
},
|
|
62
|
+
"engines": {
|
|
63
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
64
|
+
}
|
|
65
|
+
}
|