@pitlane/dev 0.4.0 → 0.5.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 +45 -0
- package/README.md +63 -7
- package/dist/index.d.mts +67 -1
- package/dist/index.mjs +276 -7
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
# @pitlane/dev
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
Build-time prerendering.
|
|
6
|
+
|
|
7
|
+
- `remix({ prerender })` renders paths to static HTML during `vite build` and
|
|
8
|
+
writes them into the client output, so a host answers those URLs and the
|
|
9
|
+
server never sees them. The API mirrors React Router's: `true` for every
|
|
10
|
+
static path in the route map, an array for an explicit list, a function
|
|
11
|
+
receiving `getStaticPaths()` for a list that mixes static and dynamic paths,
|
|
12
|
+
or an object adding `concurrency`. `spider` is one option beyond that set —
|
|
13
|
+
it follows the links each rendered page contains, which suits a site whose
|
|
14
|
+
pages all reach each other.
|
|
15
|
+
- There is no second rendering path. The build sends a `Request` through the
|
|
16
|
+
same fetch handler production runs, after both environments are built and the
|
|
17
|
+
assets manifest is written, so the HTML on disk names real hashed chunks
|
|
18
|
+
rather than dev URLs.
|
|
19
|
+
- `getStaticPaths()` reads the `routes` named export of the built server entry.
|
|
20
|
+
A Remix 3 router exposes no route table, but the route map it is built from
|
|
21
|
+
is an ordinary object, so exporting it is all the app has to do. A build that
|
|
22
|
+
asks for static paths without that export fails with a message saying so.
|
|
23
|
+
- A path that answers with a redirect is logged and skipped rather than failing
|
|
24
|
+
the build. `prerender: true` asks for every static path in the route map, and
|
|
25
|
+
a `/` that points at the real landing path is an ordinary thing to find in
|
|
26
|
+
there; there is no document to write for one, and the app still answers it at
|
|
27
|
+
runtime. Under `spider` the redirect is followed instead, because that is
|
|
28
|
+
what following links means. Any other failing response still stops the build,
|
|
29
|
+
since a listed path that 404s is a stale list and a spidered one is a dead
|
|
30
|
+
internal link.
|
|
31
|
+
- Bundles built for another runtime prerender too, with no extra
|
|
32
|
+
configuration. Node cannot import a Workers bundle, so when the import
|
|
33
|
+
fails the build starts the project's own preview server and renders through
|
|
34
|
+
that: `@cloudflare/vite-plugin` boots workerd with the app's real bindings,
|
|
35
|
+
and any platform plugin contributing a preview server works the same way. On
|
|
36
|
+
that path the route map is read from the module the server entry gets it
|
|
37
|
+
from, since the bundle holding the export is the thing that will not load.
|
|
38
|
+
- Prerendered output is written relative to Vite's `base`: an app whose routes
|
|
39
|
+
live under `/repo/` still writes `blog/index.html`, because the host mounts
|
|
40
|
+
the client directory at the base.
|
|
41
|
+
- `remix({ server: false, prerender })` throws. Prerendering renders through the
|
|
42
|
+
server entry, and SPA mode builds no server.
|
|
43
|
+
- The crawler is a new package,
|
|
44
|
+
[`@pitlane/crawler`](https://pitlane.tools/package/crawler/), installable on
|
|
45
|
+
its own. It brings back the `crawl()` API from
|
|
46
|
+
[remix-run/remix#11150](https://github.com/remix-run/remix/pull/11150).
|
|
47
|
+
|
|
3
48
|
## 0.4.0
|
|
4
49
|
|
|
5
50
|
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
|
|
126
|
-
| -------------------- |
|
|
127
|
-
| `server` | `boolean`
|
|
128
|
-
| `
|
|
129
|
-
| `
|
|
130
|
-
| `
|
|
131
|
-
| `
|
|
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
|
|
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)
|
|
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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pitlane/dev",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "remix() — the Remix 3 Vite plugin: build orchestration, clientEntry() hydration transform, dev server with component and server-data HMR, and preview for any Vite or Vite+ project.",
|
|
3
|
+
"version": "0.5.0",
|
|
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
5
|
"keywords": [
|
|
6
6
|
"pitlane",
|
|
7
7
|
"remix",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@hiogawa/vite-plugin-fullstack": "0.0.11",
|
|
46
|
+
"@pitlane/crawler": "workspace:^",
|
|
46
47
|
"magic-string": "^0.30.21",
|
|
47
48
|
"oxc-parser": "^0.141.0"
|
|
48
49
|
},
|