@pitlane/dev 0.3.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 +69 -0
- package/README.md +109 -6
- package/dist/index.d.mts +84 -1
- package/dist/index.mjs +294 -6
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,74 @@
|
|
|
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
|
+
|
|
48
|
+
## 0.4.0
|
|
49
|
+
|
|
50
|
+
SPA mode.
|
|
51
|
+
|
|
52
|
+
- `remix({ server: false })` targets client-rendered apps. No server
|
|
53
|
+
environment is configured, nothing builds to `dist/ssr`, and `vite build`
|
|
54
|
+
emits a static site from `index.html`. What remains is the part a SPA still
|
|
55
|
+
wants: component HMR through the `remix/ui-hmr` browser transform, including
|
|
56
|
+
the arrow-form normalization, so edits hot-swap in place and keep live
|
|
57
|
+
component state. Every `server*` option goes with it, and `clientEntry` too,
|
|
58
|
+
and `<HMR />` from `pitlane:dev` resolves to the inert component — there is
|
|
59
|
+
no server data to revalidate.
|
|
60
|
+
- The option is named for what it removes. React Router spells the same switch
|
|
61
|
+
`ssr: false`, which reads like it only turns off server rendering; it does
|
|
62
|
+
not, in either plugin. An app that wants browser-rendered UI in front of
|
|
63
|
+
routes that still run per request keeps `server: true` and writes a server
|
|
64
|
+
entry that answers data and a shell.
|
|
65
|
+
- SPA mode works under Vite's experimental bundled dev mode
|
|
66
|
+
(`experimental.bundledDev` / `vite dev --experimentalBundle`), component
|
|
67
|
+
hot-swap included. Server-rendered apps do not: bundled dev serves only
|
|
68
|
+
bundle entrypoints, so the client module URLs an SSR render writes into its
|
|
69
|
+
HTML have nothing behind them. That is upstream's Phase 4 (server
|
|
70
|
+
environments), still a prototype.
|
|
71
|
+
|
|
3
72
|
## 0.3.0
|
|
4
73
|
|
|
5
74
|
Dev-time hot module replacement.
|
package/README.md
CHANGED
|
@@ -114,6 +114,8 @@ Leave it unguarded: in a production build the specifier resolves to a component
|
|
|
114
114
|
|
|
115
115
|
```ts
|
|
116
116
|
remix({
|
|
117
|
+
server: true, // default — false selects SPA mode
|
|
118
|
+
prerender: undefined, // default — true, a path array, a function, or a config object
|
|
117
119
|
clientEntry: "app/entry.browser", // default — false disables the client build
|
|
118
120
|
serverEntry: "app/entry.server", // default
|
|
119
121
|
serverEnvironments: ["ssr"], // default
|
|
@@ -121,12 +123,113 @@ remix({
|
|
|
121
123
|
});
|
|
122
124
|
```
|
|
123
125
|
|
|
124
|
-
| Option | Type
|
|
125
|
-
| -------------------- |
|
|
126
|
-
| `
|
|
127
|
-
| `
|
|
128
|
-
| `
|
|
129
|
-
| `
|
|
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.
|
|
188
|
+
|
|
189
|
+
## SPA mode
|
|
190
|
+
|
|
191
|
+
Some apps have no server — a static host, a router that never touches one.
|
|
192
|
+
`remix({ server: false })` targets those. React Router spells the same switch
|
|
193
|
+
`ssr: false`:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
// vite.config.ts
|
|
197
|
+
import { remix } from "@pitlane/dev";
|
|
198
|
+
import { defineConfig } from "vite";
|
|
199
|
+
|
|
200
|
+
export default defineConfig({
|
|
201
|
+
plugins: [remix({ server: false })],
|
|
202
|
+
});
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
There is no server environment, nothing is built to `dist/ssr`, and
|
|
206
|
+
`vite build` emits a static site from your `index.html`. The plugin's one
|
|
207
|
+
remaining job is the one a SPA still wants: component HMR. Editing a component
|
|
208
|
+
swaps it in place and keeps live state, arrow forms included.
|
|
209
|
+
|
|
210
|
+
Every `server*` option goes with it, and `clientEntry` too — the browser entry
|
|
211
|
+
is whatever `index.html` loads. `<HMR />` from `pitlane:dev` resolves to the
|
|
212
|
+
inert component, because there is no server data to revalidate.
|
|
213
|
+
|
|
214
|
+
Deploying means pointing every unknown URL at `index.html` so the client router
|
|
215
|
+
can resolve it; on GitHub Pages that is a copy of `index.html` at `404.html`,
|
|
216
|
+
on Netlify a `/* /index.html 200` redirect.
|
|
217
|
+
|
|
218
|
+
The option removes the server, not the server rendering, which is what React
|
|
219
|
+
Router's `ssr: false` does too. For a browser-rendered UI in front of routes
|
|
220
|
+
that still run per request, stay in the default mode and let the server entry
|
|
221
|
+
answer JSON on its data routes and one `remix/ui` shell on its document
|
|
222
|
+
routes: nothing here asks it to render app UI. [Client rendering with a
|
|
223
|
+
server](https://pitlane.tools/guides/spa#client-rendering-with-a-server) shows
|
|
224
|
+
the shape.
|
|
225
|
+
|
|
226
|
+
SPA mode also works under Vite's experimental bundled dev mode
|
|
227
|
+
(`experimental.bundledDev`, or `vite dev --experimentalBundle`), component
|
|
228
|
+
hot-swap included. Server-rendered apps do not yet: bundled dev serves only
|
|
229
|
+
bundle entrypoints, so the client module URLs an SSR render writes into its
|
|
230
|
+
HTML resolve to nothing. That is upstream's
|
|
231
|
+
[Phase 4](https://github.com/vitejs/vite/discussions/22746) — server
|
|
232
|
+
environments — still a prototype.
|
|
130
233
|
|
|
131
234
|
## The server entry contract
|
|
132
235
|
|
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
|
/**
|
|
@@ -9,6 +59,21 @@ interface RemixPluginOptions {
|
|
|
9
59
|
* @default "app/entry.browser"
|
|
10
60
|
*/
|
|
11
61
|
clientEntry?: string | false;
|
|
62
|
+
/**
|
|
63
|
+
* Whether the app has a server at all. Pass `false` for SPA mode: no
|
|
64
|
+
* server environment is configured, nothing is built to `dist/ssr`, and
|
|
65
|
+
* `vite build` emits a static site from `index.html`.
|
|
66
|
+
*
|
|
67
|
+
* This is about the server, not about server rendering. With `false`
|
|
68
|
+
* every `server*` option below goes with it, because there is no server
|
|
69
|
+
* for them to describe, and `clientEntry` goes too: the browser entry is
|
|
70
|
+
* whatever `index.html` loads. An app that wants its UI rendered in the
|
|
71
|
+
* browser while its routes still answer per request keeps `true` and
|
|
72
|
+
* writes a server entry that serves data and a shell.
|
|
73
|
+
*
|
|
74
|
+
* @default true
|
|
75
|
+
*/
|
|
76
|
+
server?: boolean;
|
|
12
77
|
/**
|
|
13
78
|
* Server entry module, built as `dist/ssr/index.js`. Must default-export
|
|
14
79
|
* a fetch handler: an object exposing
|
|
@@ -31,9 +96,27 @@ interface RemixPluginOptions {
|
|
|
31
96
|
* Set to `false` when another plugin owns dev-time request handling —
|
|
32
97
|
* e.g. `@cloudflare/vite-plugin`, `@netlify/vite-plugin`, or `nitro/vite`.
|
|
33
98
|
*
|
|
99
|
+
* Ignored when `server` is `false`, which has no fetch handler.
|
|
100
|
+
*
|
|
34
101
|
* @default true
|
|
35
102
|
*/
|
|
36
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;
|
|
37
120
|
}
|
|
38
121
|
/**
|
|
39
122
|
* Wires Remix 3 into a Vite or Vite+ project: multi-environment build
|
|
@@ -55,4 +138,4 @@ interface RemixPluginOptions {
|
|
|
55
138
|
*/
|
|
56
139
|
declare function remix(options?: RemixPluginOptions): PluginOption;
|
|
57
140
|
//#endregion
|
|
58
|
-
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,7 +914,11 @@ function findClientEntryCalls(program) {
|
|
|
649
914
|
* Deno.
|
|
650
915
|
*/
|
|
651
916
|
function remix(options = {}) {
|
|
652
|
-
let { clientEntry = "app/entry.browser", serverEntry = "app/entry.server", serverEnvironments = ["ssr"], serverHandler = true } = options;
|
|
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
|
+
}
|
|
653
922
|
let serverEnvironmentSet = new Set(serverEnvironments);
|
|
654
923
|
return [
|
|
655
924
|
fullstack({
|
|
@@ -659,10 +928,11 @@ function remix(options = {}) {
|
|
|
659
928
|
buildCompat(),
|
|
660
929
|
build({
|
|
661
930
|
clientEntry,
|
|
662
|
-
serverEntry
|
|
931
|
+
serverEntry,
|
|
932
|
+
prerender
|
|
663
933
|
}),
|
|
664
934
|
runtimeInline(),
|
|
665
|
-
preview(),
|
|
935
|
+
preview$1(),
|
|
666
936
|
suppressAbortErrors(),
|
|
667
937
|
normalizeWriteHead(),
|
|
668
938
|
componentHmr(serverEnvironmentSet),
|
|
@@ -672,6 +942,24 @@ function remix(options = {}) {
|
|
|
672
942
|
];
|
|
673
943
|
}
|
|
674
944
|
/**
|
|
945
|
+
* SPA mode: the subset of `remix()` that applies when there is no server.
|
|
946
|
+
* Vite already serves `index.html` and builds it to a static site, so the only
|
|
947
|
+
* thing left to wire is component hot module replacement — which a
|
|
948
|
+
* client-rendered app wants just as much as a server-rendered one.
|
|
949
|
+
*
|
|
950
|
+
* Everything server-shaped is absent by construction: no server environment,
|
|
951
|
+
* no `dist/ssr`, no dev fetch handler, and no server-data HMR (there is no
|
|
952
|
+
* server data to revalidate, so `<HMR />` resolves to the inert component).
|
|
953
|
+
*/
|
|
954
|
+
function spa() {
|
|
955
|
+
let serverEnvironmentSet = /* @__PURE__ */ new Set();
|
|
956
|
+
return [
|
|
957
|
+
componentHmr(serverEnvironmentSet),
|
|
958
|
+
hmrComponent(false),
|
|
959
|
+
clientEntryTransform(serverEnvironmentSet)
|
|
960
|
+
];
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
675
963
|
* Suppresses `aborted` errors from client disconnects (e.g. search-as-you-type
|
|
676
964
|
* or navigating away mid-fetch) that would otherwise trigger Vite's dev error
|
|
677
965
|
* overlay. The match is deliberately narrow so real failures still propagate.
|
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
|
},
|