@foldkit/vite-plugin 0.19.0-canary.f656c022545d → 0.20.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.
@@ -0,0 +1,109 @@
1
+ import { Schema as S } from 'effect';
2
+ import nodePath from 'node:path';
3
+ import type { Plugin } from 'vite';
4
+ /** How a build materializes the URLs a server entry can render. */
5
+ export type FoldkitPrerenderOptions = Readonly<{
6
+ /**
7
+ * The paths to generate. Defaults to the `prerenderPaths` the server entry
8
+ * exports, so the application names its own static routes.
9
+ */
10
+ paths?: ReadonlyArray<string>;
11
+ /**
12
+ * The origin the entry sees as `Request.url` while generating, such as
13
+ * `'https://app.example'`. It reaches canonical URLs and Open Graph URLs, so
14
+ * a deployment that publishes those should set the origin it publishes.
15
+ */
16
+ origin?: string;
17
+ /**
18
+ * The `id` of the placeholder element in `index.html` the rendered markup
19
+ * replaces. Defaults to `'root'`, and the aggregate plugin passes whatever
20
+ * `ssr.containerId` names, so a renamed container is renamed once.
21
+ */
22
+ containerId?: string;
23
+ }>;
24
+ /** How `vite build` builds a server entry and what it generates from it. */
25
+ export type FoldkitBuildOptions = Readonly<{
26
+ /**
27
+ * The module the server build starts from. Defaults to the `serverEntry`
28
+ * itself, which is what a generated site wants. Point it at a host module
29
+ * when the deployment serves requests through one, such as an HTTP server or
30
+ * a Worker that imports the entry and calls `renderPage`.
31
+ *
32
+ * A build that also generates pages needs `renderPage` and, unless the paths
33
+ * are configured here, `prerenderPaths` on whichever module this names.
34
+ */
35
+ entry?: string;
36
+ /** Where the browser build is written. */
37
+ clientOutDir?: string;
38
+ /** Where the server build is written. */
39
+ serverOutDir?: string;
40
+ /**
41
+ * Generate static HTML for a set of URLs after both builds. `true` takes the
42
+ * paths from the entry's `prerenderPaths` export.
43
+ */
44
+ prerender?: boolean | FoldkitPrerenderOptions;
45
+ }>;
46
+ /**
47
+ * What the build produced, written beside the server bundle for whatever
48
+ * deploys it.
49
+ *
50
+ * A host has to decide what the asset layer does with a request that matches no
51
+ * file, and that answer follows from the build rather than from taste: an
52
+ * application with generated pages and no others wants a miss to stay a miss,
53
+ * one with a server wants a miss to reach it, and one with neither wants the
54
+ * template. Reading it here is how a deployment target gets that right without
55
+ * asking its user to configure it twice.
56
+ */
57
+ export declare const FoldkitBuildManifest: S.Struct<{
58
+ /**
59
+ * The shape of this document. A consumer decodes before reading and refuses
60
+ * a version it does not know, so a manifest written by a newer Foldkit is a
61
+ * clear refusal rather than a field silently read as undefined.
62
+ */
63
+ readonly schemaVersion: S.Literals<readonly [1]>;
64
+ /**
65
+ * Where the browser build was written, as a POSIX path relative to the Vite
66
+ * root. Relative and normalized so a manifest survives being moved with the
67
+ * build it describes.
68
+ */
69
+ readonly client: S.String;
70
+ /** Where the server build was written, on the same terms as {@link client}. */
71
+ readonly server: S.String;
72
+ /** The server build's entry file, relative to `server`. */
73
+ readonly serverEntry: S.String;
74
+ /** Every path this build generated a page for, in the order it generated. */
75
+ readonly prerendered: S.$Array<S.String>;
76
+ }>;
77
+ /**
78
+ * What the build produced, written beside the server bundle for whatever
79
+ * deploys it.
80
+ *
81
+ * A host has to decide what the asset layer does with a request that matches no
82
+ * file, and that answer follows from the build rather than from taste: an
83
+ * application with generated pages and no others wants a miss to stay a miss,
84
+ * one with a server wants a miss to reach it, and one with neither wants the
85
+ * template. Reading it here is how a deployment target gets that right without
86
+ * asking its user to configure it twice.
87
+ *
88
+ * It is a file on disk that something else writes the next time it builds, so a
89
+ * consumer decodes it with this Schema and fails closed rather than trusting
90
+ * the shape it happens to find.
91
+ */
92
+ export type FoldkitBuildManifest = typeof FoldkitBuildManifest.Type;
93
+ export declare const manifestPath: (root: string, directory: string, pathApi?: typeof nodePath) => string;
94
+ export declare const renderTargetFor: (clientDirectory: string, path: string, origin: string, pathApi?: typeof nodePath) => Readonly<{
95
+ url: URL;
96
+ file: string;
97
+ }>;
98
+ /**
99
+ * Builds the server entry alongside the browser build, and generates static
100
+ * HTML from it, inside one `vite build`.
101
+ *
102
+ * Vite drives both environments and every host plugin composes with them, so a
103
+ * deployment target that runs `vite build` gets the whole application rather
104
+ * than the browser half. The generated pages take their template from the
105
+ * browser build's own output, so generating twice over one build produces the
106
+ * same pages.
107
+ */
108
+ export declare const foldkitBuild: (serverEntry: string, options?: FoldkitBuildOptions) => Plugin;
109
+ //# sourceMappingURL=build.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../src/build.ts"],"names":[],"mappings":"AAAA,OAAO,EAAS,MAAM,IAAI,CAAC,EAAE,MAAM,QAAQ,CAAA;AAG3C,OAAO,QAAiD,MAAM,WAAW,CAAA;AAEzE,OAAO,KAAK,EAGV,MAAM,EAEP,MAAM,MAAM,CAAA;AAEb,mEAAmE;AACnE,MAAM,MAAM,uBAAuB,GAAG,QAAQ,CAAC;IAC7C;;;OAGG;IACH,KAAK,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;IAC7B;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;IACf;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB,CAAC,CAAA;AAEF,4EAA4E;AAC5E,MAAM,MAAM,mBAAmB,GAAG,QAAQ,CAAC;IACzC;;;;;;;;OAQG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,0CAA0C;IAC1C,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,yCAAyC;IACzC,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,GAAG,uBAAuB,CAAA;CAC9C,CAAC,CAAA;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,oBAAoB;IAC/B;;;;OAIG;;IAEH;;;;OAIG;;IAEH,+EAA+E;;IAE/E,2DAA2D;;IAE3D,6EAA6E;;EAE7E,CAAA;AAEF;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,oBAAoB,GAAG,OAAO,oBAAoB,CAAC,IAAI,CAAA;AAcnE,eAAO,MAAM,YAAY,SACjB,MAAM,aACD,MAAM,YACR,OAAO,QAAQ,KACvB,MAQF,CAAA;AA2FD,eAAO,MAAM,eAAe,oBACT,MAAM,QACjB,MAAM,UACJ,MAAM,YACL,OAAO,QAAQ,KACvB,QAAQ,CAAC;IAAE,GAAG,EAAE,GAAG,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CA0CrC,CAAA;AA+DD;;;;;;;;;GASG;AACH,eAAO,MAAM,YAAY,gBACV,MAAM,YACV,mBAAmB,KAC3B,MA8MF,CAAA"}
package/dist/build.js ADDED
@@ -0,0 +1,338 @@
1
+ import { Array, Schema as S } from 'effect';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import nodePath, { basename, dirname, extname, resolve } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ /**
6
+ * What the build produced, written beside the server bundle for whatever
7
+ * deploys it.
8
+ *
9
+ * A host has to decide what the asset layer does with a request that matches no
10
+ * file, and that answer follows from the build rather than from taste: an
11
+ * application with generated pages and no others wants a miss to stay a miss,
12
+ * one with a server wants a miss to reach it, and one with neither wants the
13
+ * template. Reading it here is how a deployment target gets that right without
14
+ * asking its user to configure it twice.
15
+ */
16
+ export const FoldkitBuildManifest = S.Struct({
17
+ /**
18
+ * The shape of this document. A consumer decodes before reading and refuses
19
+ * a version it does not know, so a manifest written by a newer Foldkit is a
20
+ * clear refusal rather than a field silently read as undefined.
21
+ */
22
+ schemaVersion: S.Literals([1]),
23
+ /**
24
+ * Where the browser build was written, as a POSIX path relative to the Vite
25
+ * root. Relative and normalized so a manifest survives being moved with the
26
+ * build it describes.
27
+ */
28
+ client: S.String,
29
+ /** Where the server build was written, on the same terms as {@link client}. */
30
+ server: S.String,
31
+ /** The server build's entry file, relative to `server`. */
32
+ serverEntry: S.String,
33
+ /** Every path this build generated a page for, in the order it generated. */
34
+ prerendered: S.Array(S.String),
35
+ });
36
+ const MANIFEST_SCHEMA_VERSION = 1;
37
+ // Relative and POSIX so the manifest describes a layout rather than this
38
+ // machine: an absolute `clientOutDir` would otherwise be published verbatim and
39
+ // break the moment the build is copied anywhere else.
40
+ //
41
+ // On Windows, `path.relative` across drives has no relative answer and returns
42
+ // the absolute destination, which is exactly the machine-specific path the
43
+ // manifest must not carry. Refused rather than recorded. `pathApi` exists so a
44
+ // test can run the Windows rules anywhere.
45
+ //
46
+ // @internal Exported for tests.
47
+ export const manifestPath = (root, directory, pathApi = nodePath) => {
48
+ const related = pathApi.relative(root, pathApi.resolve(root, directory));
49
+ if (pathApi.isAbsolute(related)) {
50
+ throw new Error(`[foldkit] cannot record "${directory}" in the manifest: it has no path relative to the Vite root at "${root}". Keep the output directories on the root's volume.`);
51
+ }
52
+ return related.split(pathApi.sep).join('/');
53
+ };
54
+ const MANIFEST_FILE_NAME = 'foldkit.build.json';
55
+ const DEFAULT_CLIENT_OUT_DIR = 'dist/client';
56
+ const DEFAULT_SERVER_OUT_DIR = 'dist/server';
57
+ const DEFAULT_PRERENDER_ORIGIN = 'http://localhost';
58
+ // The chunk is named after the module it builds, which is what
59
+ // `vite build --ssr <file>` writes, so a host that starts `dist/server/main.js`
60
+ // keeps starting the same file.
61
+ const serverChunkName = (entry) => basename(entry, extname(entry));
62
+ // The chunk built from the configured entry, by name rather than by position.
63
+ //
64
+ // An SSR environment can carry more than one input, and prerendering imports
65
+ // whatever this returns, which runs that module's top-level code in the build
66
+ // process. Taking the first entry chunk would execute an unrelated module that
67
+ // happens to sort first, so the chunk is matched to the entry the build was
68
+ // given and anything else is refused.
69
+ const serverEntryFile = (outputs, entryName) => {
70
+ const entries = outputs.filter(file => file.type === 'chunk' && file.isEntry);
71
+ const named = entries.filter(file => file.name === entryName);
72
+ if (named.length === 1 && named[0] !== undefined) {
73
+ return named[0].fileName;
74
+ }
75
+ if (named.length > 1) {
76
+ throw new Error(`[foldkit] the server build emitted more than one entry chunk named "${entryName}": ${named
77
+ .map(file => file.fileName)
78
+ .join(', ')}. Prerendering cannot choose between them.`);
79
+ }
80
+ throw new Error(`[foldkit] the server build emitted no entry chunk named "${entryName}"${entries.length === 0
81
+ ? '.'
82
+ : `; it emitted ${entries.map(file => file.name).join(', ')}.`}`);
83
+ };
84
+ const environmentNamed = (builder, name) => {
85
+ const environment = builder.environments[name];
86
+ if (environment === undefined) {
87
+ throw new Error(`[foldkit] the build declares no "${name}" environment to build.`);
88
+ }
89
+ return environment;
90
+ };
91
+ // The validated render target: the URL the entry receives and the file its
92
+ // page is written to, from one resolution — returned together so the request
93
+ // can never be built from anything the validation did not see. Rendering runs
94
+ // application code with the build's privileges and may fetch from
95
+ // `Request.url`, so validation is the trust boundary and has to come first: a
96
+ // protocol-relative path like `//169.254.169.254/x` resolves to that host, and
97
+ // handing it to the entry before refusing it would let invalid path data
98
+ // trigger side effects on the way to the error.
99
+ //
100
+ // A path reaches here from application code and may come from data, so it is
101
+ // treated as a URL rather than as a filesystem path: `path.slice(1)` is a
102
+ // relative path on POSIX but not on Windows, where `/C:/outside` is
103
+ // drive-absolute and `/D:outside` is drive-relative, and either escapes the
104
+ // build output. The segments are taken from the parsed URL, rejected if any
105
+ // still carries a separator or a dot-segment after decoding, and the resolved
106
+ // file is required to sit under the client directory.
107
+ //
108
+ // `pathApi` is the platform's `path` by default and the parameter exists so a
109
+ // test can run the Windows rules anywhere.
110
+ export const renderTargetFor = (clientDirectory, path, origin, pathApi = nodePath) => {
111
+ const url = new URL(path, origin);
112
+ if (url.origin !== new URL(origin).origin || url.pathname !== path) {
113
+ throw new Error(`[foldkit] cannot generate the non-normalized same-origin path "${path}".`);
114
+ }
115
+ const segments = url.pathname.split('/').filter(segment => segment !== '');
116
+ for (const segment of segments) {
117
+ const decoded = decodeURIComponent(segment);
118
+ const isTraversal = decoded === '.' || decoded === '..';
119
+ const carriesSeparator = decoded.includes('/') ||
120
+ decoded.includes('\\') ||
121
+ decoded.includes('\u0000');
122
+ // A colon cannot appear in a Windows path segment, and a bare `C:` is a
123
+ // drive designator rather than a directory: `path.win32.resolve` re-anchors
124
+ // the rest of the path onto that drive's current directory.
125
+ const namesADrive = decoded.includes(':');
126
+ if (isTraversal || carriesSeparator || namesADrive) {
127
+ throw new Error(`[foldkit] cannot generate "${path}": the segment "${segment}" does not name a single directory.`);
128
+ }
129
+ }
130
+ const file = pathApi.resolve(clientDirectory, ...segments.map(segment => decodeURIComponent(segment)), 'index.html');
131
+ const root = pathApi.resolve(clientDirectory);
132
+ const isContained = file === pathApi.join(root, 'index.html') ||
133
+ file.startsWith(root.endsWith(pathApi.sep) ? root : `${root}${pathApi.sep}`);
134
+ if (!isContained) {
135
+ throw new Error(`[foldkit] cannot generate "${path}": it resolves to "${file}", outside the browser build at "${root}".`);
136
+ }
137
+ return { url, file };
138
+ };
139
+ // A static file is a body plus whatever headers its host adds, so a result that
140
+ // carries a redirect, a status, or headers of its own cannot be written to one.
141
+ // Refusing here keeps a redirect from being published as an ordinary page.
142
+ const renderedApplication = (path, result) => {
143
+ if (result._tag === 'Responded') {
144
+ throw new Error(`[foldkit] cannot write the complete Response returned while generating "${path}" to a static HTML file.`);
145
+ }
146
+ if (result.status !== undefined && result.status !== 200) {
147
+ throw new Error(`[foldkit] cannot preserve status ${result.status} while generating "${path}" as a static HTML file.`);
148
+ }
149
+ if (result.headers !== undefined) {
150
+ throw new Error(`[foldkit] cannot preserve response headers while generating "${path}" as a static HTML file.`);
151
+ }
152
+ return result.application;
153
+ };
154
+ const prerenderOptionsFrom = (prerender) => {
155
+ if (prerender === false) {
156
+ return undefined;
157
+ }
158
+ return prerender === true ? {} : prerender;
159
+ };
160
+ // Keyed by Vite root plus the output layout, so concurrent builds of different
161
+ // projects in one process never read each other's output.
162
+ //
163
+ // NOTE: the registry hangs off a global symbol rather than module scope. Vite
164
+ // re-bundles a config file for each environment it resolves, and every bundle
165
+ // is a fresh copy of this module with its own module scope, so what the client
166
+ // build recorded would be invisible to the instance that finalizes. The symbol
167
+ // is one registry for the process no matter how many copies of this module it
168
+ // loads.
169
+ const CAPTURES = Symbol.for('foldkit/vite-plugin:build-captures');
170
+ const captures = (() => {
171
+ const registry = globalThis;
172
+ const existing = registry[CAPTURES];
173
+ if (existing instanceof Map) {
174
+ return existing;
175
+ }
176
+ const fresh = new Map();
177
+ registry[CAPTURES] = fresh;
178
+ return fresh;
179
+ })();
180
+ /**
181
+ * Builds the server entry alongside the browser build, and generates static
182
+ * HTML from it, inside one `vite build`.
183
+ *
184
+ * Vite drives both environments and every host plugin composes with them, so a
185
+ * deployment target that runs `vite build` gets the whole application rather
186
+ * than the browser half. The generated pages take their template from the
187
+ * browser build's own output, so generating twice over one build produces the
188
+ * same pages.
189
+ */
190
+ export const foldkitBuild = (serverEntry, options = {}) => {
191
+ const entry = options.entry ?? serverEntry;
192
+ const clientOutDir = options.clientOutDir ?? DEFAULT_CLIENT_OUT_DIR;
193
+ const serverOutDir = options.serverOutDir ?? DEFAULT_SERVER_OUT_DIR;
194
+ const prerender = prerenderOptionsFrom(options.prerender ?? false);
195
+ // Prerendering imports the server bundle and runs it in the build process,
196
+ // with the build's own privileges. That module is the application's own code
197
+ // and its dependencies, built from the configured entry, and is trusted on
198
+ // exactly those terms. Nothing here is imported when prerendering is off.
199
+ const generatePages = async (builder, template, serverDirectory, entryFileName) => {
200
+ if (prerender === undefined) {
201
+ return [];
202
+ }
203
+ const origin = prerender.origin ?? DEFAULT_PRERENDER_ORIGIN;
204
+ const clientDirectory = resolve(builder.config.root, clientOutDir);
205
+ const entryFile = resolve(serverDirectory, entryFileName);
206
+ const contained = resolve(serverDirectory);
207
+ if (!entryFile.startsWith(`${contained}${nodePath.sep}`)) {
208
+ throw new Error(`[foldkit] the server entry "${entryFileName}" resolves outside the server build at "${contained}".`);
209
+ }
210
+ const entry = await import(pathToFileURL(entryFile).href);
211
+ if (typeof entry.renderPage !== 'function') {
212
+ throw new Error(`[foldkit] "${entryFileName}" exports no renderPage function, so there is nothing to generate pages with.`);
213
+ }
214
+ const paths = prerender.paths ?? entry.prerenderPaths;
215
+ if (paths === undefined) {
216
+ throw new Error(`[foldkit] cannot generate pages: "${entry}" exports no prerenderPaths and the build configured no paths.`);
217
+ }
218
+ const { injectIntoTemplate } = await import('foldkit/experimental/server');
219
+ for (const path of paths) {
220
+ const { url, file } = renderTargetFor(clientDirectory, path, origin);
221
+ const result = await entry.renderPage(new Request(url));
222
+ const html = injectIntoTemplate(template(), renderedApplication(path, result), prerender.containerId === undefined
223
+ ? undefined
224
+ : { containerId: prerender.containerId });
225
+ await mkdir(dirname(file), { recursive: true });
226
+ await writeFile(file, html);
227
+ builder.config.logger.info(` generated ${path}`);
228
+ }
229
+ return paths;
230
+ };
231
+ const writeManifest = async (builder, serverDirectory, entryFileName, prerendered) => {
232
+ const manifest = S.encodeSync(FoldkitBuildManifest)({
233
+ schemaVersion: MANIFEST_SCHEMA_VERSION,
234
+ client: manifestPath(builder.config.root, clientOutDir),
235
+ server: manifestPath(builder.config.root, serverOutDir),
236
+ serverEntry: entryFileName,
237
+ prerendered,
238
+ });
239
+ await writeFile(resolve(serverDirectory, MANIFEST_FILE_NAME), `${JSON.stringify(manifest, undefined, 2)}\n`);
240
+ builder.config.logger.info(` wrote ${MANIFEST_FILE_NAME}`);
241
+ };
242
+ // What each environment emitted, recorded as it is emitted.
243
+ //
244
+ // Vite resolves the config once per environment unless `sharedConfigBuild` is
245
+ // on, so the plugin that finalizes is not necessarily the instance that saw a
246
+ // given environment build. Keying the record by the output layout it
247
+ // describes is what lets the finalizing instance read what the others
248
+ // emitted, and what lets finalization work whether this plugin orchestrates
249
+ // the environments or a host does.
250
+ const key = [clientOutDir, serverOutDir, entry].join('\u0000');
251
+ const captured = (root) => {
252
+ const existing = captures.get(`${root}\u0000${key}`);
253
+ if (existing !== undefined) {
254
+ return existing;
255
+ }
256
+ const fresh = {};
257
+ captures.set(`${root}\u0000${key}`, fresh);
258
+ return fresh;
259
+ };
260
+ const finalize = async (builder) => {
261
+ const state = captured(builder.config.root);
262
+ const serverDirectory = resolve(builder.config.root, serverOutDir);
263
+ if (state.serverEntryFile === undefined) {
264
+ throw new Error('[foldkit] the server environment produced no entry chunk, so there is nothing to deploy or generate from.');
265
+ }
266
+ // Read only when a page is actually generated: a build that generates
267
+ // nothing has no use for an HTML entry and must not require one.
268
+ const template = () => {
269
+ if (state.template === undefined) {
270
+ throw new Error('[foldkit] the browser build emitted no index.html to generate pages from. Prerendering needs an HTML entry.');
271
+ }
272
+ return state.template;
273
+ };
274
+ const prerendered = await generatePages(builder, template, serverDirectory, state.serverEntryFile);
275
+ await writeManifest(builder, serverDirectory, state.serverEntryFile, prerendered);
276
+ };
277
+ return {
278
+ name: 'foldkit:build',
279
+ apply: 'build',
280
+ // NOTE: `writeBundle` rather than `generateBundle`: Vite's own HTML plugin
281
+ // emits `index.html` from a `generateBundle` hook of its own, and hook
282
+ // order between plugins decides whether that asset exists yet. By
283
+ // `writeBundle` the bundle is whatever the environment actually produced.
284
+ writeBundle(_options, bundle) {
285
+ const state = captured(this.environment.config.root);
286
+ const outputs = Object.values(bundle);
287
+ if (this.environment.name === 'client') {
288
+ const html = Array.findFirst(outputs, file => file.type === 'asset' && file.fileName === 'index.html');
289
+ if (html._tag === 'Some' && html.value.type === 'asset') {
290
+ state.template = String(html.value.source);
291
+ }
292
+ return;
293
+ }
294
+ if (this.environment.name === 'ssr') {
295
+ state.serverEntryFile = serverEntryFile(outputs, serverChunkName(entry));
296
+ }
297
+ },
298
+ config: userConfig => {
299
+ const client = {
300
+ build: { outDir: clientOutDir },
301
+ };
302
+ const ssr = {
303
+ build: {
304
+ ssr: true,
305
+ outDir: serverOutDir,
306
+ rollupOptions: { input: { [serverChunkName(entry)]: entry } },
307
+ },
308
+ };
309
+ // NOTE: a host framework that orchestrates its own environments owns
310
+ // the order they build in, and Vite's config merge keeps exactly one
311
+ // `builder.buildApp` — whichever config hook ran last. The default
312
+ // orchestrator is only offered when nothing else claimed the slot, and
313
+ // finalization deliberately does not live here: wrapping the host's
314
+ // orchestrator only works when Foldkit's config hook runs after the
315
+ // host's, so a project that lists the plugins the other way around
316
+ // would build and silently never finalize.
317
+ return userConfig.builder?.buildApp === undefined
318
+ ? {
319
+ environments: { client, ssr },
320
+ builder: {
321
+ buildApp: async (builder) => {
322
+ await builder.build(environmentNamed(builder, 'client'));
323
+ await builder.build(environmentNamed(builder, 'ssr'));
324
+ },
325
+ },
326
+ }
327
+ : { environments: { client, ssr } };
328
+ },
329
+ // The composable finalization point. `order: 'post'` runs this after the
330
+ // config-level orchestrator — the host's, or the default above — no matter
331
+ // where this plugin sits in the plugin list, which a wrapped
332
+ // `builder.buildApp` could not guarantee.
333
+ buildApp: {
334
+ order: 'post',
335
+ handler: finalize,
336
+ },
337
+ };
338
+ };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { Plugin } from 'vite';
2
+ import { type FoldkitBuildOptions } from './build.js';
2
3
  import { type FoldkitSsrOptions } from './ssr.js';
3
4
  export { type BrandDistResult, brandDistDirectory } from './brandDist.js';
5
+ export { FoldkitBuildManifest, type FoldkitBuildOptions, type FoldkitPrerenderOptions, foldkitBuild, } from './build.js';
4
6
  export { type FoldkitSsrOptions, foldkitSsr } from './ssr.js';
5
7
  export { type ViewIdentityTransformResult, foldkitViewIdentity, transformViewIdentity, } from './viewIdentity.js';
6
8
  /** Options for the `foldkit` Vite plugin. */
@@ -18,7 +20,17 @@ export type FoldkitPluginOptions = Readonly<{
18
20
  * `renderPage` from the module at `ssr.serverEntry`. When `undefined` (the
19
21
  * default), the dev server serves the client entry only.
20
22
  */
21
- ssr?: Omit<FoldkitSsrOptions, 'buildId'>;
23
+ ssr?: Omit<FoldkitSsrOptions, 'buildId'> & Readonly<{
24
+ /**
25
+ * Build the server entry alongside the browser build, and generate static
26
+ * HTML from it, inside this project's own `vite build`. `true` builds it
27
+ * with the default output directories and generates nothing.
28
+ *
29
+ * When this is absent, `vite build` builds the browser bundle only and
30
+ * the server build is a separate command the deployment runs itself.
31
+ */
32
+ build?: boolean | FoldkitBuildOptions;
33
+ }>;
22
34
  /**
23
35
  * The deployment this build belongs to, compiled into application code as
24
36
  * `import.meta.env.FOLDKIT_BUILD_ID` for the entries to pass to
@@ -31,15 +43,16 @@ export type FoldkitPluginOptions = Readonly<{
31
43
  * deployment already has, such as a commit or a release tag, and give the
32
44
  * client build and the server build the same one. It is published in the
33
45
  * page, so it must not be a secret.
46
+ *
47
+ * Whatever supplies it has to answer with the same value every time it is
48
+ * asked, because Vite reads a config file once per environment it builds. A
49
+ * config that computes a fresh value on each read — `randomUUID()`, a
50
+ * timestamp — gives the browser bundle and the server bundle different ids
51
+ * within one build, and every page of that deployment is then refused at
52
+ * hydration. Read it from the environment, or store a generated fallback
53
+ * back into the environment so later reads resolve the same id.
34
54
  */
35
55
  buildId?: string;
36
56
  }>;
37
- /**
38
- * Foldkit's Vite plugin set: the view-identity branding transform and
39
- * DevTools overlay injection (dev and build), plus the HMR bridge with state
40
- * preservation and the optional DevTools MCP relay (dev only). Returned as
41
- * an array; Vite flattens nested plugin arrays, so `plugins: [foldkit()]`
42
- * keeps working.
43
- */
44
57
  export declare const foldkit: (options?: FoldkitPluginOptions) => Array<Plugin>;
45
58
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAmCA,OAAO,KAAK,EACV,MAAM,EAIP,MAAM,MAAM,CAAA;AAKb,OAAO,EAAE,KAAK,iBAAiB,EAAc,MAAM,UAAU,CAAA;AAG7D,OAAO,EAAE,KAAK,eAAe,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAA;AACzE,OAAO,EAAE,KAAK,iBAAiB,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AAC7D,OAAO,EACL,KAAK,2BAA2B,EAChC,mBAAmB,EACnB,qBAAqB,GACtB,MAAM,mBAAmB,CAAA;AAE1B,6CAA6C;AAC7C,MAAM,MAAM,oBAAoB,GAAG,QAAQ,CAAC;IAC1C;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB;;;;;OAKG;IACH,GAAG,CAAC,EAAE,IAAI,CAAC,iBAAiB,EAAE,SAAS,CAAC,CAAA;IACxC;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB,CAAC,CAAA;AAklBF;;;;;;GAMG;AACH,eAAO,MAAM,OAAO,aAAa,oBAAoB,KAAQ,KAAK,CAAC,MAAM,CA+ExE,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAmCA,OAAO,KAAK,EACV,MAAM,EAIP,MAAM,MAAM,CAAA;AAGb,OAAO,EAAE,KAAK,mBAAmB,EAAgB,MAAM,YAAY,CAAA;AAGnE,OAAO,EAAE,KAAK,iBAAiB,EAAc,MAAM,UAAU,CAAA;AAG7D,OAAO,EAAE,KAAK,eAAe,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAA;AACzE,OAAO,EACL,oBAAoB,EACpB,KAAK,mBAAmB,EACxB,KAAK,uBAAuB,EAC5B,YAAY,GACb,MAAM,YAAY,CAAA;AACnB,OAAO,EAAE,KAAK,iBAAiB,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AAC7D,OAAO,EACL,KAAK,2BAA2B,EAChC,mBAAmB,EACnB,qBAAqB,GACtB,MAAM,mBAAmB,CAAA;AAE1B,6CAA6C;AAC7C,MAAM,MAAM,oBAAoB,GAAG,QAAQ,CAAC;IAC1C;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB;;;;;OAKG;IACH,GAAG,CAAC,EAAE,IAAI,CAAC,iBAAiB,EAAE,SAAS,CAAC,GACtC,QAAQ,CAAC;QACP;;;;;;;WAOG;QACH,KAAK,CAAC,EAAE,OAAO,GAAG,mBAAmB,CAAA;KACtC,CAAC,CAAA;IACJ;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB,CAAC,CAAA;AA4mBF,eAAO,MAAM,OAAO,aAAa,oBAAoB,KAAQ,KAAK,CAAC,MAAM,CAsFxE,CAAA"}
package/dist/index.js CHANGED
@@ -1,14 +1,16 @@
1
1
  import { Array, Console, Data, Duration, Effect, Exit, Fiber, HashMap, HashSet, Match as M, Option, Predicate, Queue, Ref, Schema as S, Schedule, Stream, pipe, } from 'effect';
2
- import { EventFrame, RequestFrame, Response, ResponseFrame, } from 'foldkit/devtools-protocol';
2
+ import { Event as DevToolsEvent, EventFrame, RequestFrame, Response, ResponseFrame, } from 'foldkit/devtools-protocol';
3
3
  import { PreserveModelMessage, RequestModelMessage, RestoreModelMessage, } from 'foldkit/hmr-protocol';
4
4
  import { createRequire } from 'node:module';
5
5
  import { resolve } from 'node:path';
6
6
  import { WebSocketServer } from 'ws';
7
+ import { foldkitBuild } from './build.js';
7
8
  import { foldkitBuildToken } from './buildToken.js';
8
9
  import { devToolsOverlayPlugin } from './devToolsOverlay.js';
9
10
  import { foldkitSsr } from './ssr.js';
10
11
  import { foldkitViewIdentity } from './viewIdentity.js';
11
12
  export { brandDistDirectory } from './brandDist.js';
13
+ export { FoldkitBuildManifest, foldkitBuild, } from './build.js';
12
14
  export { foldkitSsr } from './ssr.js';
13
15
  export { foldkitViewIdentity, transformViewIdentity, } from './viewIdentity.js';
14
16
  // NOTE: Vite's dep optimizer scans the consumer's source for `effect`
@@ -142,10 +144,10 @@ const handleRequestModelReceived = (server, state, payload) => Exit.match(S.deco
142
144
  const handleHotUpdateFired = (state) => Ref.update(state.preservedModels, current => HashMap.map(current, entry => ({ ...entry, isHmrReload: true })));
143
145
  const handleBrowserEventFrameReceived = (state, data, client) => Exit.match(S.decodeUnknownExit(EventFrame)(data), {
144
146
  onFailure: error => Console.warn('[foldkit:devTools] failed to decode browser event frame', error),
145
- onSuccess: frame => M.value(frame.event).pipe(M.tagsExhaustive({
147
+ onSuccess: frame => DevToolsEvent.match(frame.event, {
146
148
  EventConnected: event => handleConnectedEvent(state, event, client),
147
149
  EventDisconnected: event => handleDisconnectedEvent(state, event),
148
- })),
150
+ }),
149
151
  });
150
152
  const handleConnectedEvent = (state, event, client) => Effect.gen(function* () {
151
153
  yield* Ref.update(state.connectedRuntimes, HashMap.set(event.runtime.connectionId, event.runtime));
@@ -330,6 +332,23 @@ const main = (server, events, options) => Effect.gen(function* () {
330
332
  * an array; Vite flattens nested plugin arrays, so `plugins: [foldkit()]`
331
333
  * keeps working.
332
334
  */
335
+ // The container is named once, on `ssr`, and reaches both the dev host and the
336
+ // build from there. A `build.prerender` that names its own wins, so a project
337
+ // that needs them to differ still can.
338
+ const withContainerId = (build, containerId) => {
339
+ const options = build === true ? {} : build;
340
+ if (containerId === undefined || options.prerender === undefined) {
341
+ return options;
342
+ }
343
+ const prerender = options.prerender === true ? {} : options.prerender;
344
+ if (prerender === false) {
345
+ return options;
346
+ }
347
+ return {
348
+ ...options,
349
+ prerender: { containerId, ...prerender },
350
+ };
351
+ };
333
352
  export const foldkit = (options = {}) => {
334
353
  const events = Effect.runSync(Queue.unbounded());
335
354
  // NOTE: One plugin instance can serve more than one dev server, and on
@@ -378,23 +397,26 @@ export const foldkit = (options = {}) => {
378
397
  return [];
379
398
  },
380
399
  };
381
- return options.ssr === undefined
382
- ? [
383
- foldkitBuildToken(options.buildId),
384
- foldkitViewIdentity(),
385
- devToolsOverlayPlugin(),
386
- hmrPlugin,
387
- ]
388
- : [
389
- foldkitBuildToken(options.buildId),
390
- foldkitViewIdentity(),
391
- devToolsOverlayPlugin(),
392
- hmrPlugin,
393
- foldkitSsr({
394
- ...options.ssr,
395
- ...(options.buildId === undefined
396
- ? {}
397
- : { buildId: options.buildId }),
398
- }),
399
- ];
400
+ const shared = [
401
+ foldkitBuildToken(options.buildId),
402
+ foldkitViewIdentity(),
403
+ devToolsOverlayPlugin(),
404
+ hmrPlugin,
405
+ ];
406
+ if (options.ssr === undefined) {
407
+ return shared;
408
+ }
409
+ const { build, ...ssr } = options.ssr;
410
+ const servePages = foldkitSsr({
411
+ ...ssr,
412
+ ...(options.buildId === undefined ? {} : { buildId: options.buildId }),
413
+ });
414
+ if (build === undefined || build === false) {
415
+ return [...shared, servePages];
416
+ }
417
+ return [
418
+ ...shared,
419
+ servePages,
420
+ foldkitBuild(ssr.serverEntry, withContainerId(build, ssr.containerId)),
421
+ ];
400
422
  };
package/dist/ssr.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"ssr.d.ts","sourceRoot":"","sources":["../src/ssr.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAW,MAAM,EAA+B,MAAM,MAAM,CAAA;AAIxE,0EAA0E;AAC1E,MAAM,MAAM,iBAAiB,GAAG,QAAQ,CAAC;IACvC;;;;;OAKG;IACH,WAAW,EAAE,MAAM,CAAA;IACnB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;;;;;;;;;OAYG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;IACf;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB,CAAC,CAAA;AA2rBF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,UAAU,YAAa,iBAAiB,KAAG,MA4CvD,CAAA"}
1
+ {"version":3,"file":"ssr.d.ts","sourceRoot":"","sources":["../src/ssr.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAGV,MAAM,EAGP,MAAM,MAAM,CAAA;AAIb,0EAA0E;AAC1E,MAAM,MAAM,iBAAiB,GAAG,QAAQ,CAAC;IACvC;;;;;OAKG;IACH,WAAW,EAAE,MAAM,CAAA;IACnB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;;;;;;;;;OAYG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;IACf;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB,CAAC,CAAA;AAusBF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,UAAU,YAAa,iBAAiB,KAAG,MAgEvD,CAAA"}
package/dist/ssr.js CHANGED
@@ -4,6 +4,16 @@ import { readFile } from 'node:fs/promises';
4
4
  import { resolve } from 'node:path';
5
5
  import { Readable } from 'node:stream';
6
6
  import { buildIdForCommand } from './buildToken.js';
7
+ // Whether an environment can evaluate modules in this process, which is what
8
+ // loading the server entry needs. Vite's own `isRunnableDevEnvironment` is an
9
+ // `instanceof` check against the `RunnableDevEnvironment` class of whichever
10
+ // copy of Vite the caller imported — and a plugin supporting a range of majors
11
+ // is not always imported by the copy that created the server, so that check
12
+ // reports a perfectly runnable environment as not runnable. The lazily
13
+ // constructed `runner` accessor is the shape every major agrees on, and `in`
14
+ // reads the descriptor rather than invoking the getter, so probing costs
15
+ // nothing.
16
+ const isRunnable = (environment) => 'runner' in environment;
7
17
  const isEntryModule = (loadedModule) => Predicate.isObject(loadedModule) &&
8
18
  Predicate.hasProperty(loadedModule, 'renderPage') &&
9
19
  Predicate.isFunction(loadedModule.renderPage);
@@ -477,6 +487,23 @@ export const foldkitSsr = (options) => {
477
487
  };
478
488
  },
479
489
  configureServer: server => {
490
+ // Rendering here loads the server entry through the `ssr` environment's
491
+ // module runner, which only a runnable environment has. A host plugin
492
+ // that backs that environment with its own runtime — workerd, under a
493
+ // Workers plugin — leaves it non-runnable, and that host is already the
494
+ // one serving pages: its entry imports `renderPage` and answers the
495
+ // request itself. Standing down hands those requests to it. Rendering
496
+ // them here instead would run the application in Node, without the
497
+ // bindings the deployed entry holds, while the deployment it is standing
498
+ // in for renders in workerd.
499
+ if (!isRunnable(server.environments.ssr)) {
500
+ server.config.logger.warn('[foldkit] the "ssr" environment is not runnable, so another plugin owns' +
501
+ ' server-side execution. Dev-time server rendering is off and page' +
502
+ ' requests fall through to that host, which renders through the same' +
503
+ ' server entry. Remove `ssr.serverEntry` from the foldkit plugin to' +
504
+ ' silence this.');
505
+ return;
506
+ }
480
507
  const requestUrls = new WeakMap();
481
508
  const stateByRequest = new WeakMap();
482
509
  const render = renderMiddleware(server, options, requestUrls, stateByRequest);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldkit/vite-plugin",
3
- "version": "0.19.0-canary.f656c022545d",
3
+ "version": "0.20.0",
4
4
  "description": "Vite plugin for Foldkit hot module reloading with state preservation",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,7 +17,7 @@
17
17
  ],
18
18
  "peerDependencies": {
19
19
  "effect": "4.0.0-rc.112",
20
- "foldkit": "0.154.0-canary.f656c022545d",
20
+ "foldkit": ">=0.153.0",
21
21
  "vite": "^7.0.0 || ^8.0.0"
22
22
  },
23
23
  "dependencies": {
@@ -28,13 +28,13 @@
28
28
  "@types/node": "^26.4.0",
29
29
  "@types/ws": "^8.18.1",
30
30
  "effect": "4.0.0-rc.112",
31
- "happy-dom": "^20.11.8",
31
+ "happy-dom": "^20.11.13",
32
32
  "rimraf": "^6.1.3",
33
33
  "typescript": "^7.0.2",
34
34
  "vite": "^8.2.2",
35
35
  "vite7": "npm:vite@^7.3.6",
36
36
  "vitest": "^4.1.11",
37
- "foldkit": "0.154.0-canary.f656c022545d"
37
+ "foldkit": "0.155.0"
38
38
  },
39
39
  "keywords": [
40
40
  "vite",