@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 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
+ }
@@ -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 };
@@ -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
+ }