@pitlane/dev 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,65 @@
1
1
  # @pitlane/dev
2
2
 
3
+ ## 0.5.1
4
+
5
+ Fixes an unusable 0.5.0 on npm.
6
+
7
+ - 0.5.0 was published with `"@pitlane/crawler": "workspace:^"` in its
8
+ dependencies, so every install failed with `EUNSUPPORTEDPROTOCOL` on npm and
9
+ `ERR_PNPM_WORKSPACE_PKG_NOT_FOUND` on pnpm. The release workflow packed with
10
+ `npm publish`, which has no idea what pnpm's `workspace:` protocol means and
11
+ ships it verbatim. It now packs with pnpm, which rewrites the range to the
12
+ version it resolved to, and fails the job if any `workspace:` specifier
13
+ survives into the tarball. Nothing about the code changed; 0.5.0 is
14
+ deprecated and this is the same release with a manifest that installs.
15
+ - Latent until now: `@pitlane/crawler` is the first workspace dependency any
16
+ published Pitlane package has had.
17
+
18
+ ## 0.5.0
19
+
20
+ Build-time prerendering.
21
+
22
+ - `remix({ prerender })` renders paths to static HTML during `vite build` and
23
+ writes them into the client output, so a host answers those URLs and the
24
+ server never sees them. The API mirrors React Router's: `true` for every
25
+ static path in the route map, an array for an explicit list, a function
26
+ receiving `getStaticPaths()` for a list that mixes static and dynamic paths,
27
+ or an object adding `concurrency`. `spider` is one option beyond that set —
28
+ it follows the links each rendered page contains, which suits a site whose
29
+ pages all reach each other.
30
+ - There is no second rendering path. The build sends a `Request` through the
31
+ same fetch handler production runs, after both environments are built and the
32
+ assets manifest is written, so the HTML on disk names real hashed chunks
33
+ rather than dev URLs.
34
+ - `getStaticPaths()` reads the `routes` named export of the built server entry.
35
+ A Remix 3 router exposes no route table, but the route map it is built from
36
+ is an ordinary object, so exporting it is all the app has to do. A build that
37
+ asks for static paths without that export fails with a message saying so.
38
+ - A path that answers with a redirect is logged and skipped rather than failing
39
+ the build. `prerender: true` asks for every static path in the route map, and
40
+ a `/` that points at the real landing path is an ordinary thing to find in
41
+ there; there is no document to write for one, and the app still answers it at
42
+ runtime. Under `spider` the redirect is followed instead, because that is
43
+ what following links means. Any other failing response still stops the build,
44
+ since a listed path that 404s is a stale list and a spidered one is a dead
45
+ internal link.
46
+ - Bundles built for another runtime prerender too, with no extra
47
+ configuration. Node cannot import a Workers bundle, so when the import
48
+ fails the build starts the project's own preview server and renders through
49
+ that: `@cloudflare/vite-plugin` boots workerd with the app's real bindings,
50
+ and any platform plugin contributing a preview server works the same way. On
51
+ that path the route map is read from the module the server entry gets it
52
+ from, since the bundle holding the export is the thing that will not load.
53
+ - Prerendered output is written relative to Vite's `base`: an app whose routes
54
+ live under `/repo/` still writes `blog/index.html`, because the host mounts
55
+ the client directory at the base.
56
+ - `remix({ server: false, prerender })` throws. Prerendering renders through the
57
+ server entry, and SPA mode builds no server.
58
+ - The crawler is a new package,
59
+ [`@pitlane/crawler`](https://pitlane.tools/package/crawler/), installable on
60
+ its own. It brings back the `crawl()` API from
61
+ [remix-run/remix#11150](https://github.com/remix-run/remix/pull/11150).
62
+
3
63
  ## 0.4.0
4
64
 
5
65
  SPA mode.
package/README.md CHANGED
@@ -115,6 +115,7 @@ Leave it unguarded: in a production build the specifier resolves to a component
115
115
  ```ts
116
116
  remix({
117
117
  server: true, // default — false selects SPA mode
118
+ prerender: undefined, // default — true, a path array, a function, or a config object
118
119
  clientEntry: "app/entry.browser", // default — false disables the client build
119
120
  serverEntry: "app/entry.server", // default
120
121
  serverEnvironments: ["ssr"], // default
@@ -122,13 +123,68 @@ remix({
122
123
  });
123
124
  ```
124
125
 
125
- | Option | Type | Default | Purpose |
126
- | -------------------- | ----------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
127
- | `server` | `boolean` | `true` | Whether the app has a server. Pass `false` for [SPA mode](#spa-mode), which ignores every option below. |
128
- | `clientEntry` | `string \| false` | `"app/entry.browser"` | Client entry module. Pass `false` for fully server-rendered apps with no hydration. |
129
- | `serverEntry` | `string` | `"app/entry.server"` | Server entry module, built as `dist/ssr/index.js`. |
130
- | `serverEnvironments` | `string[]` | `["ssr"]` | Environment names the `clientEntry()` transform treats as "server". |
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. |
126
+ | Option | Type | Default | Purpose |
127
+ | -------------------- | ---------------------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
128
+ | `server` | `boolean` | `true` | Whether the app has a server. Pass `false` for [SPA mode](#spa-mode), which ignores every option below. |
129
+ | `prerender` | `boolean \| string[] \| fn \| obj` | none | Render paths to static HTML at build time. See [Prerendering](#prerendering). |
130
+ | `clientEntry` | `string \| false` | `"app/entry.browser"` | Client entry module. Pass `false` for fully server-rendered apps with no hydration. |
131
+ | `serverEntry` | `string` | `"app/entry.server"` | Server entry module, built as `dist/ssr/index.js`. |
132
+ | `serverEnvironments` | `string[]` | `["ssr"]` | Environment names the `clientEntry()` transform treats as "server". |
133
+ | `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. |
134
+
135
+ ## Prerendering
136
+
137
+ `prerender` renders paths to static HTML during `vite build` and writes them
138
+ into the client output, so a CDN answers those URLs and the server never sees
139
+ them. There is no second rendering path: the build sends a `Request` through
140
+ the same fetch handler production runs.
141
+
142
+ ```ts
143
+ remix({ prerender: ["/", "/blog", "/blog/hello-world"] });
144
+ ```
145
+
146
+ `true` prerenders every static path in the app's route map, which the server
147
+ entry exports alongside its handler:
148
+
149
+ ```ts
150
+ // app/entry.server.tsx
151
+ export { routes } from "./routes.ts";
152
+ export default router;
153
+ ```
154
+
155
+ A function computes the list, and gets `getStaticPaths()` for the route-map
156
+ half of it:
157
+
158
+ ```ts
159
+ remix({
160
+ async prerender({ getStaticPaths }) {
161
+ let slugs = await getPostSlugsFromCMS();
162
+ return [...getStaticPaths(), ...slugs.map(slug => `/blog/${slug}`)];
163
+ },
164
+ });
165
+ ```
166
+
167
+ The object form adds `concurrency`, and `spider` for following the links each
168
+ rendered page contains:
169
+
170
+ ```ts
171
+ remix({ prerender: { paths: ["/"], spider: true, concurrency: 4 } });
172
+ ```
173
+
174
+ Each path lands at `<path>/index.html` under the client output. Rendering runs
175
+ after both builds and the assets manifest, so the HTML names real hashed
176
+ chunks. Unsupported with `server: false`, which builds no server to render
177
+ with.
178
+
179
+ A bundle Node cannot import renders anyway: `@cloudflare/vite-plugin` and
180
+ friends already contribute a preview server, so when the import fails the
181
+ build starts that server and renders through it, inside the runtime that will
182
+ serve the pages. Nothing extra to configure.
183
+
184
+ Full details in the [prerendering guide](https://pitlane.tools/guides/prerendering);
185
+ the crawler underneath is [`@pitlane/crawler`](https://pitlane.tools/package/crawler/),
186
+ and the [crawling guide](https://pitlane.tools/guides/crawler) covers using it
187
+ on its own.
132
188
 
133
189
  ## SPA mode
134
190
 
package/dist/index.d.mts CHANGED
@@ -1,4 +1,54 @@
1
1
  import { PluginOption } from "vite";
2
+ //#region src/prerender.d.ts
3
+ /**
4
+ * What a `prerender` function receives.
5
+ */
6
+ interface PrerenderContext {
7
+ /**
8
+ * Every path the app's route map can serve without params — `/` and
9
+ * `/blog`, but not `/blog/:slug`, whose values live outside the route map.
10
+ *
11
+ * Reads the `routes` named export of the built server entry. Throws when
12
+ * the server entry does not export one, since there is nothing else to
13
+ * enumerate.
14
+ */
15
+ getStaticPaths(): string[];
16
+ }
17
+ /**
18
+ * A function that decides which paths to prerender, for path sets that need
19
+ * async work — a CMS query, a filesystem scan, a database read.
20
+ */
21
+ type PrerenderPaths = (context: PrerenderContext) => string[] | Promise<string[]> | Iterable<string> | Promise<Iterable<string>>;
22
+ /**
23
+ * The paths to prerender: `true` for every static path in the route map, an
24
+ * explicit list, or a function that computes one.
25
+ */
26
+ type PrerenderPathsOption = boolean | string[] | PrerenderPaths;
27
+ interface PrerenderConfig {
28
+ /**
29
+ * The paths to prerender.
30
+ *
31
+ * @default true
32
+ */
33
+ paths?: PrerenderPathsOption;
34
+ /**
35
+ * How many paths to render at once. Rendering is CPU-bound in-process, so
36
+ * the useful value depends on how much of a render waits on I/O.
37
+ *
38
+ * @default 1
39
+ */
40
+ concurrency?: number;
41
+ /**
42
+ * Also follow the links each rendered page contains, and prerender those
43
+ * too. Turns the path list into a set of starting points rather than the
44
+ * complete answer, which suits a site whose pages all link to each other.
45
+ *
46
+ * @default false
47
+ */
48
+ spider?: boolean;
49
+ }
50
+ type PrerenderOption = PrerenderPathsOption | PrerenderConfig;
51
+ //#endregion
2
52
  //#region src/index.d.ts
3
53
  interface RemixPluginOptions {
4
54
  /**
@@ -51,6 +101,22 @@ interface RemixPluginOptions {
51
101
  * @default true
52
102
  */
53
103
  serverHandler?: boolean;
104
+ /**
105
+ * Render paths to static HTML at build time and write them into the client
106
+ * output, so a host can serve the file and skip the server entirely.
107
+ *
108
+ * `true` prerenders every static path in the app's route map, which the
109
+ * server entry must export as `routes`. An array prerenders exactly those
110
+ * paths. A function computes them, and receives `getStaticPaths()` for the
111
+ * route-map half of a list that also has dynamic paths in it. The object
112
+ * form adds `concurrency` and `spider`.
113
+ *
114
+ * Build-time only, and unsupported with `server: false`: prerendering
115
+ * renders through the server entry, and there is none.
116
+ *
117
+ * @default undefined
118
+ */
119
+ prerender?: PrerenderOption;
54
120
  }
55
121
  /**
56
122
  * Wires Remix 3 into a Vite or Vite+ project: multi-environment build
@@ -72,4 +138,4 @@ interface RemixPluginOptions {
72
138
  */
73
139
  declare function remix(options?: RemixPluginOptions): PluginOption;
74
140
  //#endregion
75
- export { RemixPluginOptions, remix };
141
+ export { type PrerenderConfig, type PrerenderContext, type PrerenderOption, type PrerenderPaths, type PrerenderPathsOption, RemixPluginOptions, remix };
package/dist/index.mjs CHANGED
@@ -2,9 +2,266 @@ import fullstack from "@hiogawa/vite-plugin-fullstack";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import * as url from "node:url";
5
- import MagicString from "magic-string";
5
+ import { crawl, staticPaths } from "@pitlane/crawler";
6
+ import * as fs$1 from "node:fs/promises";
7
+ import { createServer, preview } from "vite";
6
8
  import { parseSync } from "oxc-parser";
9
+ import MagicString from "magic-string";
7
10
  import { transformComponentsForBrowser, transformComponentsForServer } from "remix/ui-hmr";
11
+ //#region src/prerender-target.ts
12
+ /**
13
+ * Opens the built application for prerendering.
14
+ *
15
+ * The fast path imports the server bundle and calls its fetch handler in this
16
+ * process: no socket, no second copy of the app, and the route map comes along
17
+ * as a named export. A bundle built for another runtime cannot be imported
18
+ * here — a Workers bundle opens with `import { env } from "cloudflare:workers"`
19
+ * — so the fallback boots the project's own `vite preview` server and
20
+ * dispatches over loopback. The platform's Vite plugin owns that server, so
21
+ * pages still render inside the runtime that will serve them, and nothing in
22
+ * the app has to name the runtime a second time.
23
+ *
24
+ * @param ssrConfig The resolved config of the `ssr` environment.
25
+ */
26
+ async function openTarget(ssrConfig) {
27
+ let entry = await importServerEntry(ssrConfig);
28
+ if (!entry) return openPreviewTarget(ssrConfig);
29
+ let handler = entry.default;
30
+ if (!handler || typeof handler.fetch !== "function") throw new Error(`[@pitlane/dev] prerender needs ${serverEntryPath(ssrConfig)} to default-export a fetch handler (an object with fetch(request: Request)), e.g. \`export default router\`.`);
31
+ return {
32
+ fetch: (request) => handler.fetch(request),
33
+ routes: entry.routes,
34
+ viaPreviewServer: false,
35
+ close: () => Promise.resolve()
36
+ };
37
+ }
38
+ function serverEntryPath(ssrConfig) {
39
+ return path.resolve(ssrConfig.root, ssrConfig.build.outDir, "index.js");
40
+ }
41
+ /**
42
+ * Imports the built server entry, or reports that this process cannot.
43
+ *
44
+ * Dynamic by necessity: the specifier is a runtime-computed path into the
45
+ * app's own build output. The bundle's modification time rides along in the
46
+ * URL, because Node's module registry is keyed on the specifier — without it a
47
+ * second build in the same process (a watch rebuild, or one test after
48
+ * another) would prerender through the first build's handler.
49
+ */
50
+ async function importServerEntry(ssrConfig) {
51
+ let entryPath = serverEntryPath(ssrConfig);
52
+ let stats = await fs$1.stat(entryPath).catch(() => void 0);
53
+ if (!stats) throw new Error(`[@pitlane/dev] prerender expected the server build at ${entryPath}, and found nothing there.`);
54
+ try {
55
+ let specifier = url.pathToFileURL(entryPath);
56
+ specifier.searchParams.set("t", String(stats.mtimeMs));
57
+ return await import(
58
+ /* @vite-ignore */
59
+ specifier.href
60
+ );
61
+ } catch {
62
+ return;
63
+ }
64
+ }
65
+ /**
66
+ * Boots the project's preview server and dispatches through it.
67
+ *
68
+ * `preview()` re-reads the project's config, so every plugin that contributes
69
+ * a preview server gets to — `@cloudflare/vite-plugin` boots workerd with the
70
+ * app's real bindings, and the same holds for any other platform plugin.
71
+ */
72
+ async function openPreviewTarget(ssrConfig) {
73
+ let server = await preview({
74
+ root: ssrConfig.root,
75
+ configFile: ssrConfig.configFile,
76
+ logLevel: "silent",
77
+ preview: { host: "127.0.0.1" }
78
+ });
79
+ let local = server.resolvedUrls?.local[0];
80
+ if (!local) {
81
+ await server.close();
82
+ throw new Error(`[@pitlane/dev] prerender started a preview server to render ${serverEntryPath(ssrConfig)} through, and it is not listening on any URL.`);
83
+ }
84
+ let origin = new URL(local).origin;
85
+ return {
86
+ fetch(request) {
87
+ let { pathname, search } = new URL(request.url);
88
+ return fetch(new URL(pathname + search, origin), { redirect: "manual" });
89
+ },
90
+ viaPreviewServer: true,
91
+ close: () => server.close()
92
+ };
93
+ }
94
+ //#endregion
95
+ //#region src/route-map.ts
96
+ /**
97
+ * Loads the app's route map from source.
98
+ *
99
+ * Prerendering normally reads the `routes` export straight off the built
100
+ * server bundle, which is the artifact production runs. A bundle built for
101
+ * another runtime cannot be imported here, so this takes the long way round:
102
+ * find the module the server entry gets `routes` from, and load that one. A
103
+ * route map is a plain object of patterns, so it runs anywhere — the platform
104
+ * imports that make the bundle unloadable live in the entry, not in it.
105
+ *
106
+ * @param ssrConfig The resolved config of the `ssr` environment.
107
+ * @param serverEntry The plugin's `serverEntry` option.
108
+ * @returns The route map, or `undefined` when the entry does not export one.
109
+ */
110
+ async function loadRouteMap(ssrConfig, serverEntry) {
111
+ let server = await createServer({
112
+ configFile: false,
113
+ root: ssrConfig.root,
114
+ logLevel: "silent",
115
+ server: {
116
+ middlewareMode: true,
117
+ watch: null
118
+ },
119
+ optimizeDeps: { noDiscovery: true },
120
+ resolve: {
121
+ alias: ssrConfig.resolve.alias,
122
+ extensions: ssrConfig.resolve.extensions
123
+ }
124
+ });
125
+ try {
126
+ let entryId = await resolve(server, path.resolve(ssrConfig.root, serverEntry));
127
+ if (!entryId) return void 0;
128
+ let specifier = routeMapSpecifier(await fs$1.readFile(entryId, "utf8"), entryId);
129
+ if (!specifier) return void 0;
130
+ let routesId = await resolve(server, specifier, entryId);
131
+ if (!routesId) return void 0;
132
+ return (await server.ssrLoadModule(routesId)).routes;
133
+ } finally {
134
+ await server.close();
135
+ }
136
+ }
137
+ async function resolve(server, source, importer) {
138
+ return (await server.environments.ssr.pluginContainer.resolveId(source, importer))?.id;
139
+ }
140
+ /**
141
+ * The module a server entry gets its `routes` binding from, in either of the
142
+ * two shapes that reads naturally:
143
+ *
144
+ * ```ts
145
+ * export { routes } from "./routes.ts";
146
+ * // or
147
+ * import { routes } from "./routes.ts";
148
+ * export { routes };
149
+ * ```
150
+ *
151
+ * A route map declared inline in the entry has no other module to point at,
152
+ * and yields `undefined`.
153
+ */
154
+ function routeMapSpecifier(code, filename) {
155
+ let program = parseSync(filename, code).program;
156
+ let local;
157
+ for (let node of program.body) {
158
+ if (node.type !== "ExportNamedDeclaration") continue;
159
+ for (let specifier of node.specifiers) {
160
+ if (moduleExportName(specifier.exported) !== "routes") continue;
161
+ if (node.source) return node.source.value;
162
+ local = moduleExportName(specifier.local);
163
+ }
164
+ }
165
+ if (!local) return void 0;
166
+ for (let node of program.body) {
167
+ if (node.type !== "ImportDeclaration") continue;
168
+ for (let specifier of node.specifiers ?? []) if (specifier.local.name === local) return node.source.value;
169
+ }
170
+ }
171
+ /** An exported or imported name, which the grammar allows to be a string. */
172
+ function moduleExportName(node) {
173
+ return node.type === "Identifier" ? node.name ?? "" : node.value ?? "";
174
+ }
175
+ //#endregion
176
+ //#region src/prerender.ts
177
+ function isPrerenderConfig(option) {
178
+ return typeof option === "object" && !Array.isArray(option);
179
+ }
180
+ /**
181
+ * Renders paths to static HTML at build time by dispatching requests through
182
+ * the app's own fetch handler, then writing each response into the client
183
+ * output. A host serves those files directly and the runtime server never sees
184
+ * the request.
185
+ *
186
+ * Runs after both environments are built and the assets manifest is written,
187
+ * so the server entry resolves real hashed client asset URLs — the HTML on
188
+ * disk is the same HTML the runtime server would produce.
189
+ *
190
+ * @param builder The Vite builder mid-`buildApp`.
191
+ * @param option The plugin's `prerender` option.
192
+ * @param serverEntry The plugin's `serverEntry` option.
193
+ * @returns The files written and the paths that redirected instead.
194
+ */
195
+ async function prerender(builder, option, serverEntry) {
196
+ let { paths = true, concurrency = 1, spider = false } = isPrerenderConfig(option) ? option : { paths: option };
197
+ let redirected = [];
198
+ if (paths === false) return {
199
+ written: [],
200
+ redirected
201
+ };
202
+ let ssrConfig = builder.environments.ssr?.config;
203
+ let clientConfig = builder.environments.client?.config ?? ssrConfig;
204
+ if (!ssrConfig || !clientConfig) throw new Error("[@pitlane/dev] prerender needs both an ssr and a client environment.");
205
+ let target = await openTarget(ssrConfig);
206
+ let outDir = path.resolve(clientConfig.root, clientConfig.build.outDir);
207
+ let written = [];
208
+ try {
209
+ let resolved = await resolvePaths(paths, target.routes ?? (Array.isArray(paths) ? void 0 : await loadRouteMap(ssrConfig, serverEntry)), ssrConfig);
210
+ if (resolved.length === 0) return {
211
+ written,
212
+ redirected
213
+ };
214
+ for await (let result of crawl(target, {
215
+ paths: resolved,
216
+ spider,
217
+ assets: false,
218
+ concurrency,
219
+ onRedirect: (pathname, location) => redirected.push({
220
+ pathname,
221
+ location
222
+ })
223
+ })) written.push(await writeResult(result, outDir, clientConfig.base));
224
+ } catch (error) {
225
+ if (!target.viaPreviewServer) throw error;
226
+ throw new Error(`[@pitlane/dev] prerender rendered through this project's preview server, because ${serverEntryPath(ssrConfig)} is a bundle built for another runtime that Node cannot import. The preview server has to answer the paths being prerendered, which is the platform plugin's job (\`@cloudflare/vite-plugin\` and the like).`, { cause: error });
227
+ } finally {
228
+ await target.close();
229
+ }
230
+ return {
231
+ written,
232
+ redirected
233
+ };
234
+ }
235
+ async function resolvePaths(paths, routes, ssrConfig) {
236
+ let getStaticPaths = () => {
237
+ if (!routes) throw new Error(`[@pitlane/dev] prerender needs ${serverEntryPath(ssrConfig)} to export its route map to enumerate static paths: \`export { routes } from "./routes.ts"\` in your server entry. Pass an explicit path array instead if the app has no route map.`);
238
+ return staticPaths(routes);
239
+ };
240
+ if (paths === true) return getStaticPaths();
241
+ if (Array.isArray(paths)) return [...paths];
242
+ return [...await paths({ getStaticPaths })];
243
+ }
244
+ /**
245
+ * Writes one crawl result under the client output directory, returning the
246
+ * path it wrote relative to that directory.
247
+ *
248
+ * `base` is stripped from the output path: a project deployed at `/repo/`
249
+ * routes on `/repo/blog` but the file still belongs at `blog/index.html`,
250
+ * because the host mounts the whole directory at the base.
251
+ */
252
+ async function writeResult(result, outDir, base) {
253
+ let relative = stripBase(result.filepath, base).replace(/^\/+/, "");
254
+ let outputPath = path.join(outDir, relative);
255
+ await fs$1.mkdir(path.dirname(outputPath), { recursive: true });
256
+ await fs$1.writeFile(outputPath, new Uint8Array(await result.response.arrayBuffer()));
257
+ return relative;
258
+ }
259
+ function stripBase(filepath, base) {
260
+ if (base === "/" || !base) return filepath;
261
+ let prefix = base.endsWith("/") ? base.slice(0, -1) : base;
262
+ return filepath.startsWith(`${prefix}/`) ? filepath.slice(prefix.length) : filepath;
263
+ }
264
+ //#endregion
8
265
  //#region src/build.ts
9
266
  function isFullstackBuilder(builder) {
10
267
  return "writeAssetsManifest" in builder && typeof builder.writeAssetsManifest === "function";
@@ -159,7 +416,7 @@ function ensureAssetsManifest() {
159
416
  * client: the client build resolves `?assets=ssr` against the SSR manifest,
160
417
  * so the order is load-bearing.
161
418
  */
162
- function build({ clientEntry, serverEntry }) {
419
+ function build({ clientEntry, serverEntry, prerender: paths }) {
163
420
  let hasClientEntry = clientEntry !== false;
164
421
  return {
165
422
  name: "pitlane-remix-build",
@@ -176,6 +433,14 @@ function build({ clientEntry, serverEntry }) {
176
433
  if (hasClientEntry) await builder.build(builder.environments.client);
177
434
  if (isFullstackBuilder(builder)) await builder.writeAssetsManifest();
178
435
  ensureAssetsManifest();
436
+ if (paths !== void 0) {
437
+ let { written, redirected } = await prerender(builder, paths, serverEntry);
438
+ for (let file of written) this.info(`prerendered ${file}`);
439
+ for (let { pathname, location } of redirected) {
440
+ let target = location ?? "an unnamed location";
441
+ this.info(`skipped ${pathname} (redirects to ${target})`);
442
+ }
443
+ }
179
444
  },
180
445
  config(userConfig) {
181
446
  let environments = userConfig.environments;
@@ -532,7 +797,7 @@ function isFetchHandler(value) {
532
797
  * plugin skips itself so the platform plugin's preview can take over. That
533
798
  * failure → skip contract is documented behavior, not an accident.
534
799
  */
535
- function preview() {
800
+ function preview$1() {
536
801
  return {
537
802
  name: "pitlane-remix-preview-server",
538
803
  async configurePreviewServer(server) {
@@ -649,8 +914,11 @@ function findClientEntryCalls(program) {
649
914
  * Deno.
650
915
  */
651
916
  function remix(options = {}) {
652
- let { clientEntry = "app/entry.browser", server = true, serverEntry = "app/entry.server", serverEnvironments = ["ssr"], serverHandler = true } = options;
653
- if (!server) return spa();
917
+ let { clientEntry = "app/entry.browser", server = true, serverEntry = "app/entry.server", serverEnvironments = ["ssr"], serverHandler = true, prerender } = options;
918
+ if (!server) {
919
+ if (prerender !== void 0) throw new Error("[@pitlane/dev] remix({ server: false, prerender }) is not supported: prerendering renders through the server entry, and `server: false` builds no server. Drop `server: false` to prerender, or drop `prerender` to stay a SPA.");
920
+ return spa();
921
+ }
654
922
  let serverEnvironmentSet = new Set(serverEnvironments);
655
923
  return [
656
924
  fullstack({
@@ -660,10 +928,11 @@ function remix(options = {}) {
660
928
  buildCompat(),
661
929
  build({
662
930
  clientEntry,
663
- serverEntry
931
+ serverEntry,
932
+ prerender
664
933
  }),
665
934
  runtimeInline(),
666
- preview(),
935
+ preview$1(),
667
936
  suppressAbortErrors(),
668
937
  normalizeWriteHead(),
669
938
  componentHmr(serverEnvironmentSet),
package/package.json CHANGED
@@ -1,66 +1,65 @@
1
1
  {
2
- "name": "@pitlane/dev",
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
- "keywords": [
6
- "pitlane",
7
- "remix",
8
- "vite",
9
- "vite-plugin"
10
- ],
11
- "homepage": "https://pitlane.tools/package/dev/",
12
- "bugs": {
13
- "url": "https://github.com/pitlane-tools/pitlane/issues"
2
+ "name": "@pitlane/dev",
3
+ "version": "0.5.1",
4
+ "description": "remix() — the Remix 3 Vite plugin: build orchestration, clientEntry() hydration transform, dev server with component and server-data HMR, SPA mode, build-time prerendering, and preview for any Vite or Vite+ project.",
5
+ "keywords": [
6
+ "pitlane",
7
+ "remix",
8
+ "vite",
9
+ "vite-plugin"
10
+ ],
11
+ "homepage": "https://pitlane.tools/package/dev/",
12
+ "bugs": {
13
+ "url": "https://github.com/pitlane-tools/pitlane/issues"
14
+ },
15
+ "license": "MIT",
16
+ "author": "Mark Malstrom <mark@malstrom.me>",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/pitlane-tools/pitlane.git",
20
+ "directory": "packages/dev"
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"
14
32
  },
15
- "license": "MIT",
16
- "author": "Mark Malstrom <mark@malstrom.me>",
17
- "repository": {
18
- "type": "git",
19
- "url": "git+https://github.com/pitlane-tools/pitlane.git",
20
- "directory": "packages/dev"
33
+ "./runtime": {
34
+ "types": "./dist/runtime.d.mts",
35
+ "import": "./dist/runtime.mjs"
21
36
  },
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
- "devDependencies": {
50
- "@cloudflare/vite-plugin": "^1.31.0",
51
- "@types/node": "^25.5.0",
52
- "playwright": "1.61.1",
53
- "remix": "3.0.0-beta.10",
54
- "typescript": "^7.0.2",
55
- "vite": "^8.1.5",
56
- "vite-plus": "^0.2.6",
57
- "wrangler": "^4.114.0"
58
- },
59
- "peerDependencies": {
60
- "remix": "^3.0.0-beta.10",
61
- "vite": ">=7.0.0"
62
- },
63
- "engines": {
64
- "node": "^20.19.0 || >=22.12.0"
37
+ "./assets": {
38
+ "types": "./dist/assets.d.mts"
65
39
  }
66
- }
40
+ },
41
+ "dependencies": {
42
+ "@hiogawa/vite-plugin-fullstack": "0.0.11",
43
+ "magic-string": "^0.30.21",
44
+ "oxc-parser": "^0.141.0",
45
+ "@pitlane/crawler": "^0.1.0"
46
+ },
47
+ "devDependencies": {
48
+ "@cloudflare/vite-plugin": "^1.31.0",
49
+ "@types/node": "^25.5.0",
50
+ "playwright": "1.61.1",
51
+ "remix": "3.0.0-beta.10",
52
+ "typescript": "^7.0.2",
53
+ "vite": "^8.1.5",
54
+ "vite-plus": "^0.2.6",
55
+ "wrangler": "^4.114.0"
56
+ },
57
+ "peerDependencies": {
58
+ "remix": "^3.0.0-beta.10",
59
+ "vite": ">=7.0.0"
60
+ },
61
+ "engines": {
62
+ "node": "^20.19.0 || >=22.12.0"
63
+ },
64
+ "scripts": {}
65
+ }