@foldkit/vite-plugin 0.26.1-canary.8c70ed904b29 → 0.26.1-canary.fe2701c2fa4b

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/README.md CHANGED
@@ -65,6 +65,34 @@ With this set, the dev server converts HTML page requests to Web `Request` value
65
65
 
66
66
  Vite retains ownership of configured proxy routes before Foldkit handles application requests. Vite's `server.cors` option applies to Vite-owned source modules, assets, and HMR. It does not add headers to application responses or answer their preflights. Preflight ownership follows `Access-Control-Request-Method`, so a preflight for an application `POST` reaches `renderPage` even when its path looks like an asset. An `OPTIONS` request without both `Origin` and `Access-Control-Request-Method` is not a preflight and also reaches `renderPage`. Define application CORS in `renderPage`, where development and the deployed host share one policy. Vite's `allowedHosts` check runs before proxy and application handling, including `OPTIONS` and methods the Web `Request` API cannot represent.
67
67
 
68
+ ## Server-rendered documents
69
+
70
+ With `ssr.build`, the client input is a script and the server entry produces the complete HTML document. Configure the script beside the server entry:
71
+
72
+ ```typescript
73
+ foldkit({
74
+ ssr: {
75
+ clientEntry: '/src/entry.ts',
76
+ serverEntry: '/src/entry.server.ts',
77
+ build: true,
78
+ },
79
+ })
80
+ ```
81
+
82
+ Import CSS from the client script. Export `renderDocument` alongside `renderPage` in the server entry:
83
+
84
+ ```typescript
85
+ export const renderDocument = Server.renderDocument
86
+ ```
87
+
88
+ `Server.renderDocument(application, assets, options)` assembles the document with the application's title, language, direction, canonical URL, and Open Graph URL. `assets` contains the emitted `entryScript`, ordered `stylesheets`, and `modulePreloads`. The plugin collects static imports and their CSS, leaves lazy imports to Vite's runtime, and supplies the same assets to request-time rendering and prerendering. The default document includes UTF-8 and viewport metadata. Wrap the helper to set a default `lang` or add trusted author-owned `head` markup, such as a favicon. Never interpolate unescaped request data into `head`.
89
+
90
+ The client build starts from a script rather than HTML. `index.html` appears in the client output only when prerendering generates `/`. The build refuses an existing root document copied from `publicDir`, emitted by another plugin, or left by an earlier build when `emptyOutDir` is disabled. Remove the source `index.html` when migrating an SSR build, move its stylesheet links into client imports, and move document tags into `renderDocument`. Build-time `transformIndexHtml` hooks do not run with a script input. In development, Vite transforms the rendered document for HMR and dev HTML hooks. `containerId` belongs only to a template-based custom host and cannot be combined with `clientEntry`.
91
+
92
+ Use a root-relative `clientEntry`, such as `/src/entry.ts`, and an absolute-path or full-URL Vite `base`. Relative bases (`''` and `'./'`) are rejected because their asset URLs would resolve differently on nested routes. `modulePreload` configuration and absolute `experimental.renderBuiltUrl` results apply to the generated document; relative and runtime `renderBuiltUrl` results are rejected.
93
+
94
+ The standalone build plugin takes `foldkitBuild(serverEntry, { clientEntry, ...options })`. Omitting `clientEntry` and `ssr.build` selects the template-based development host for a custom build pipeline. Hosts that own an HTML template can use the lower-level `injectIntoTemplate`, `toResponse`, and `handleRequest(request, { template, renderPage })` APIs.
95
+
68
96
  ## Foldkit package resolution
69
97
 
70
98
  Each module graph must load one Foldkit copy. The plugin configures Vite for that:
package/dist/build.d.ts CHANGED
@@ -15,25 +15,15 @@ export type FoldkitPrerenderOptions = Readonly<{
15
15
  * from `Request.url`, in which case it should match the published origin.
16
16
  */
17
17
  origin?: string;
18
- /**
19
- * The `id` of the placeholder element in `index.html` the rendered markup
20
- * replaces. Defaults to `'root'`, and the aggregate plugin passes whatever
21
- * `ssr.containerId` names, so a renamed container is renamed once.
22
- */
23
- containerId?: string;
24
18
  }>;
25
19
  /** How `vite build` builds a server entry and what it generates from it. */
26
20
  export type FoldkitBuildOptions = Readonly<{
21
+ /** Root-relative browser script entry resolved by Vite, such as `'/src/entry.ts'`. Import stylesheets from this module. */
22
+ clientEntry: string;
27
23
  /** Where the browser build is written. */
28
24
  clientOutDir?: string;
29
25
  /** Where the server build is written. */
30
26
  serverOutDir?: string;
31
- /**
32
- * The `id` of the empty container in `index.html` the fetch handler
33
- * replaces. Defaults to `'root'`. The aggregate plugin copies
34
- * `ssr.containerId` here.
35
- */
36
- containerId?: string;
37
27
  /**
38
28
  * Generate static HTML for a set of URLs after both builds. `true` takes the
39
29
  * paths from the entry's `prerenderPaths` export.
@@ -134,8 +124,9 @@ export declare const renderTargetFor: (clientDirectory: string, path: string, or
134
124
  *
135
125
  * Vite builds both environments, so a deployment target that runs `vite build`
136
126
  * gets the browser and server bundles. The `fetch` handler and generated pages
137
- * use the HTML emitted by the browser build, but the unrendered template is not
138
- * published with the assets. The server bundle's default export is `{ fetch }`.
127
+ * use the server entry's `renderDocument` export with the emitted browser assets.
128
+ * The browser build has a script input and emits no HTML until prerendering.
129
+ * The server bundle's default export is `{ fetch }`.
139
130
  */
140
- export declare const foldkitBuild: (serverEntry: string, options?: FoldkitBuildOptions) => Plugin<FoldkitBuildApi>;
131
+ export declare const foldkitBuild: (serverEntry: string, options: FoldkitBuildOptions) => Plugin<FoldkitBuildApi>;
141
132
  //# sourceMappingURL=build.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../src/build.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AAI/B,OAAO,QAA8B,MAAM,WAAW,CAAA;AAEtD,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;;;;;OAKG;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,0CAA0C;IAC1C,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,yCAAyC;IACzC,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,GAAG,uBAAuB,CAAA;CAC9C,CAAC,CAAA;AAEF,6EAA6E;AAC7E,eAAO,MAAM,uBAAuB,0BAA0B,CAAA;AAE9D;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB;IAC/B;;;;OAIG;;IAEH;;;;OAIG;;IAEH,+EAA+E;;IAE/E,2DAA2D;;IAE3D,6EAA6E;;EAE7E,CAAA;AAEF;;;;;GAKG;AACH,MAAM,MAAM,oBAAoB,GAAG,OAAO,oBAAoB,CAAC,IAAI,CAAA;AAEnE,iFAAiF;AACjF,eAAO,MAAM,oBAAoB;IAC/B,+CAA+C;;IAE/C,kDAAkD;;IAElD,iDAAiD;;IAEjD,kDAAkD;;IAElD,0DAA0D;;QAtC1D;;;;WAIG;;QAEH;;;;WAIG;;QAEH,+EAA+E;;QAE/E,2DAA2D;;QAE3D,6EAA6E;;;EAwB7E,CAAA;AAEF,sEAAsE;AACtE,MAAM,MAAM,oBAAoB,GAAG,OAAO,oBAAoB,CAAC,IAAI,CAAA;AAEnE,2DAA2D;AAC3D,MAAM,MAAM,eAAe,GAAG,QAAQ,CAAC;IACrC,2CAA2C;IAC3C,WAAW,EAAE,MAAM,CAAA;IACnB,uDAAuD;IACvD,aAAa,EAAE,OAAO,uBAAuB,CAAA;IAC7C;;;;OAIG;IACH,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;CAC7C,CAAC,CAAA;AAcF,eAAO,MAAM,YAAY,SACjB,MAAM,aACD,MAAM,YACR,OAAO,QAAQ,KACvB,MAQF,CAAA;AAyFD,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;AAsFD;;;;;;;;GAQG;AACH,eAAO,MAAM,YAAY,gBACV,MAAM,YACV,mBAAmB,KAC3B,MAAM,CAAC,eAAe,CAqPxB,CAAA"}
1
+ {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../src/build.ts"],"names":[],"mappings":"AAAA,OAAO,EAAa,MAAM,EAAE,MAAM,QAAQ,CAAA;AAS1C,OAAO,QAA8B,MAAM,WAAW,CAAA;AAEtD,OAAO,KAAK,EAGV,MAAM,EAGP,MAAM,MAAM,CAAA;AAEb,mEAAmE;AACnE,MAAM,MAAM,uBAAuB,GAAG,QAAQ,CAAC;IAC7C;;;OAGG;IACH,KAAK,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;IAC7B;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB,CAAC,CAAA;AAEF,4EAA4E;AAC5E,MAAM,MAAM,mBAAmB,GAAG,QAAQ,CAAC;IACzC,2HAA2H;IAC3H,WAAW,EAAE,MAAM,CAAA;IACnB,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,6EAA6E;AAC7E,eAAO,MAAM,uBAAuB,0BAA0B,CAAA;AAE9D;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB;IAC/B;;;;OAIG;;IAEH;;;;OAIG;;IAEH,+EAA+E;;IAE/E,2DAA2D;;IAE3D,6EAA6E;;EAE7E,CAAA;AAEF;;;;;GAKG;AACH,MAAM,MAAM,oBAAoB,GAAG,OAAO,oBAAoB,CAAC,IAAI,CAAA;AAEnE,iFAAiF;AACjF,eAAO,MAAM,oBAAoB;IAC/B,+CAA+C;;IAE/C,kDAAkD;;IAElD,iDAAiD;;IAEjD,kDAAkD;;IAElD,0DAA0D;;QAtC1D;;;;WAIG;;QAEH;;;;WAIG;;QAEH,+EAA+E;;QAE/E,2DAA2D;;QAE3D,6EAA6E;;;EAwB7E,CAAA;AAEF,sEAAsE;AACtE,MAAM,MAAM,oBAAoB,GAAG,OAAO,oBAAoB,CAAC,IAAI,CAAA;AAEnE,2DAA2D;AAC3D,MAAM,MAAM,eAAe,GAAG,QAAQ,CAAC;IACrC,2CAA2C;IAC3C,WAAW,EAAE,MAAM,CAAA;IACnB,uDAAuD;IACvD,aAAa,EAAE,OAAO,uBAAuB,CAAA;IAC7C;;;;OAIG;IACH,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;CAC7C,CAAC,CAAA;AAcF,eAAO,MAAM,YAAY,SACjB,MAAM,aACD,MAAM,YACR,OAAO,QAAQ,KACvB,MAQF,CAAA;AA6FD,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;AAqLD;;;;;;;;;GASG;AACH,eAAO,MAAM,YAAY,gBACV,MAAM,WACV,mBAAmB,KAC3B,MAAM,CAAC,eAAe,CAwSxB,CAAA"}
package/dist/build.js CHANGED
@@ -1,6 +1,7 @@
1
- import { Schema } from 'effect';
2
- import { randomUUID } from 'node:crypto';
3
- import { mkdir, writeFile } from 'node:fs/promises';
1
+ import { Predicate, Schema } from 'effect';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { existsSync } from 'node:fs';
4
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
5
  import nodePath, { dirname, resolve } from 'node:path';
5
6
  import { pathToFileURL } from 'node:url';
6
7
  /** Vite module id of the fetch handler Foldkit emits as the server entry. */
@@ -64,12 +65,15 @@ export const manifestPath = (root, directory, pathApi = nodePath) => {
64
65
  return related.split(pathApi.sep).join('/');
65
66
  };
66
67
  const MANIFEST_FILE_NAME = 'foldkit.build.json';
67
- const TEMPLATE_FILE_NAME = 'index.html';
68
68
  const DEFAULT_CLIENT_OUT_DIR = 'dist/client';
69
69
  const DEFAULT_SERVER_OUT_DIR = 'dist/server';
70
70
  const DEFAULT_PRERENDER_ORIGIN = 'http://localhost';
71
71
  const FETCH_CHUNK_NAME = 'fetch';
72
72
  const RESOLVED_FETCH_MODULE_ID = `\0${FOLDKIT_FETCH_MODULE_ID}`;
73
+ const CLIENT_MODULE_ID = 'virtual:foldkit/client';
74
+ const RESOLVED_CLIENT_MODULE_ID = `\0${CLIENT_MODULE_ID}`;
75
+ const CLIENT_CHUNK_NAME = 'app';
76
+ const VITE_CONSOLIDATED_CSS_ORIGINAL_NAME = 'style.css';
73
77
  // The chunk built from the configured entry, by name rather than by position.
74
78
  //
75
79
  // An SSR environment can carry more than one input, and prerendering imports
@@ -168,39 +172,104 @@ const prerenderOptionsFrom = (prerender) => {
168
172
  }
169
173
  return prerender === true ? {} : prerender;
170
174
  };
171
- const fetchModuleSource = (serverEntry, template, containerId) => {
172
- const containerLiteral = containerId === undefined ? 'undefined' : JSON.stringify(containerId);
175
+ const documentHash = (document) => createHash('sha256').update(document).digest('hex');
176
+ const collectDocumentAssets = (outputs, config) => {
177
+ const entryFile = serverEntryFile(outputs, CLIENT_CHUNK_NAME);
178
+ const chunks = new Map(outputs.flatMap(output => output.type === 'chunk' ? [[output.fileName, output]] : []));
179
+ const visited = new Set();
180
+ const stylesheets = new Set();
181
+ const modulePreloads = new Set();
182
+ const visit = (fileName) => {
183
+ if (visited.has(fileName)) {
184
+ return;
185
+ }
186
+ visited.add(fileName);
187
+ const chunk = chunks.get(fileName);
188
+ if (chunk === undefined) {
189
+ return;
190
+ }
191
+ for (const imported of chunk.imports) {
192
+ visit(imported);
193
+ }
194
+ for (const stylesheet of chunk.viteMetadata?.importedCss ?? []) {
195
+ stylesheets.add(stylesheet);
196
+ }
197
+ if (fileName !== entryFile) {
198
+ modulePreloads.add(fileName);
199
+ }
200
+ };
201
+ visit(entryFile);
202
+ if (!config.build.cssCodeSplit) {
203
+ for (const output of outputs) {
204
+ if (output.type === 'asset' &&
205
+ output.originalFileNames.includes(VITE_CONSOLIDATED_CSS_ORIGINAL_NAME)) {
206
+ stylesheets.add(output.fileName);
207
+ }
208
+ }
209
+ }
210
+ const { modulePreload } = config.build;
211
+ const preloadFiles = modulePreload === false
212
+ ? []
213
+ : (modulePreload.resolveDependencies?.(entryFile, [...modulePreloads], {
214
+ hostId: 'index.html',
215
+ hostType: 'html',
216
+ }) ?? [...modulePreloads]);
217
+ const assetUrl = (fileName) => {
218
+ if (/^([a-z]+:)?\/\//.test(fileName)) {
219
+ return fileName;
220
+ }
221
+ const builtUrl = config.experimental.renderBuiltUrl?.(fileName, {
222
+ hostId: 'index.html',
223
+ hostType: 'html',
224
+ type: 'asset',
225
+ ssr: false,
226
+ });
227
+ if (Predicate.isString(builtUrl)) {
228
+ if (!builtUrl.startsWith('/') && !/^https?:\/\//.test(builtUrl)) {
229
+ throw new Error('[foldkit] renderBuiltUrl must return an absolute URL or root-relative URL for document assets.');
230
+ }
231
+ return builtUrl;
232
+ }
233
+ if (builtUrl?.runtime !== undefined || builtUrl?.relative === true) {
234
+ throw new Error('[foldkit] document assets cannot use runtime or relative renderBuiltUrl results.');
235
+ }
236
+ return `${config.base}${encodeURI(fileName).replace(/[?#]/g, encodeURIComponent)}`;
237
+ };
238
+ return Object.freeze({
239
+ entryScript: assetUrl(entryFile),
240
+ stylesheets: Object.freeze([...stylesheets].map(assetUrl)),
241
+ modulePreloads: Object.freeze(preloadFiles.map(assetUrl)),
242
+ });
243
+ };
244
+ const fetchModuleSource = (serverEntry, assets) => {
173
245
  // NOTE: `export *` re-exports whatever the application entry actually names,
174
246
  // so a missing `prerenderPaths` is absent rather than a Vite undefined-import
175
247
  // warning.
176
248
  return `${[
177
249
  `import { handleRequest } from 'foldkit/experimental/server'`,
250
+ `import { renderDocument } from ${JSON.stringify(serverEntry)}`,
178
251
  `import * as server from ${JSON.stringify(serverEntry)}`,
179
252
  `export * from ${JSON.stringify(serverEntry)}`,
180
- `const template = ${JSON.stringify(template)}`,
181
- `const containerId = ${containerLiteral}`,
253
+ `const assets = ${JSON.stringify(assets)}`,
254
+ `Object.freeze(assets.stylesheets)`,
255
+ `Object.freeze(assets.modulePreloads)`,
256
+ `Object.freeze(assets)`,
182
257
  `export default {`,
183
258
  ` fetch(request) {`,
184
259
  ` return handleRequest(request, {`,
185
260
  ` renderPage: server.renderPage,`,
186
- ` template,`,
187
- ` containerId,`,
261
+ ` renderDocument: application => renderDocument(application, assets),`,
188
262
  ` })`,
189
263
  ` },`,
190
264
  `}`,
191
265
  ``,
192
266
  ].join('\n')}`;
193
267
  };
194
- // The template is what the browser build emitted in this same `vite build`,
195
- // never a file on disk: `dist/client/index.html` could only be the previous
196
- // build's shell with its old asset hashes, and the source `index.html` still
197
- // names `/src/entry.ts`. Either would bundle into a handler that serves a
198
- // page which cannot hydrate, from a build that reported success.
199
- const templateForFetchModule = (capturedTemplate) => {
200
- if (capturedTemplate === undefined) {
201
- throw new Error(`[foldkit] the browser build has not emitted ${TEMPLATE_FILE_NAME}, so the fetch handler has no template to render into. Build the "client" environment before "ssr", and give the client an HTML entry.`);
268
+ const assetsForFetchModule = (assets) => {
269
+ if (assets === undefined) {
270
+ throw new Error('[foldkit] the browser build has not emitted its script entry. Build the "client" environment before "ssr" so the document can reference the emitted assets.');
202
271
  }
203
- return capturedTemplate;
272
+ return assets;
204
273
  };
205
274
  /**
206
275
  * Builds a Web `fetch` handler alongside the browser build, and generates
@@ -208,21 +277,21 @@ const templateForFetchModule = (capturedTemplate) => {
208
277
  *
209
278
  * Vite builds both environments, so a deployment target that runs `vite build`
210
279
  * gets the browser and server bundles. The `fetch` handler and generated pages
211
- * use the HTML emitted by the browser build, but the unrendered template is not
212
- * published with the assets. The server bundle's default export is `{ fetch }`.
280
+ * use the server entry's `renderDocument` export with the emitted browser assets.
281
+ * The browser build has a script input and emits no HTML until prerendering.
282
+ * The server bundle's default export is `{ fetch }`.
213
283
  */
214
- export const foldkitBuild = (serverEntry, options = {}) => {
284
+ export const foldkitBuild = (serverEntry, options) => {
215
285
  const state = {};
216
286
  let metadata;
217
287
  const clientOutDir = options.clientOutDir ?? DEFAULT_CLIENT_OUT_DIR;
218
288
  const serverOutDir = options.serverOutDir ?? DEFAULT_SERVER_OUT_DIR;
219
289
  const prerender = prerenderOptionsFrom(options.prerender ?? false);
220
- const containerId = prerender?.containerId ?? options.containerId;
221
290
  // Prerendering imports the server bundle and runs it in the build process,
222
291
  // with the build's own privileges. That module is the application's own code
223
292
  // and its dependencies, built from the configured entry, and is trusted on
224
293
  // exactly those terms. Nothing here is imported when prerendering is off.
225
- const generatePages = async (builder, template, clientDirectory, serverDirectory, entryFileName) => {
294
+ const generatePages = async (builder, clientDirectory, serverDirectory, entryFileName) => {
226
295
  if (prerender === undefined) {
227
296
  return [];
228
297
  }
@@ -242,15 +311,19 @@ export const foldkitBuild = (serverEntry, options = {}) => {
242
311
  if (paths === undefined) {
243
312
  throw new Error(`[foldkit] cannot generate pages: "${entry}" exports no prerenderPaths and the build configured no paths.`);
244
313
  }
245
- const { injectIntoTemplate } = await import('foldkit/experimental/server');
314
+ if (!Predicate.isFunction(entry.renderDocument)) {
315
+ throw new Error(`[foldkit] "${entryFileName}" exports no renderDocument function.`);
316
+ }
317
+ const assets = assetsForFetchModule(state.assets);
246
318
  for (const path of paths) {
247
319
  const { url, file } = renderTargetFor(clientDirectory, path, origin);
248
320
  const result = await entry.renderPage(new Request(url));
249
- const html = injectIntoTemplate(template(), renderedApplication(path, result), prerender.containerId === undefined
250
- ? undefined
251
- : { containerId: prerender.containerId });
321
+ const html = entry.renderDocument(renderedApplication(path, result), assets);
252
322
  await mkdir(dirname(file), { recursive: true });
253
323
  await writeFile(file, html);
324
+ if (path === '/') {
325
+ state.prerenderedRootHash = documentHash(html);
326
+ }
254
327
  builder.config.logger.info(` generated ${path}`);
255
328
  }
256
329
  return paths;
@@ -264,15 +337,16 @@ export const foldkitBuild = (serverEntry, options = {}) => {
264
337
  if (state.serverEntryFile === undefined) {
265
338
  throw new Error('[foldkit] the server environment produced no entry chunk, so there is nothing to deploy or generate from.');
266
339
  }
267
- // Read only when a page is actually generated: a build that generates
268
- // nothing has no use for an HTML entry and must not require one.
269
- const template = () => {
270
- if (state.template === undefined) {
271
- throw new Error(`[foldkit] the browser build emitted no ${TEMPLATE_FILE_NAME} to generate pages from. Prerendering needs an HTML entry.`);
272
- }
273
- return state.template;
274
- };
275
- const prerendered = await generatePages(builder, template, clientDirectory, serverDirectory, state.serverEntryFile);
340
+ const rootDocumentPath = resolve(clientDirectory, 'index.html');
341
+ if (existsSync(rootDocumentPath) &&
342
+ documentHash(await readFile(rootDocumentPath, 'utf8')) !==
343
+ state.prerenderedRootHash) {
344
+ throw new Error('[foldkit] the browser output contains index.html before prerendering. Remove it from publicDir or the plugin that emits it, and clear stale output when emptyOutDir is disabled. Only prerendering may generate the root document in an ssr.build output.');
345
+ }
346
+ const prerendered = await generatePages(builder, clientDirectory, serverDirectory, state.serverEntryFile);
347
+ if (!prerendered.includes('/') && existsSync(rootDocumentPath)) {
348
+ throw new Error('[foldkit] index.html remains in the browser output, but this build did not prerender "/". Clear the previous root document before building without it.');
349
+ }
276
350
  const manifest = FoldkitBuildManifest.make({
277
351
  schemaVersion: MANIFEST_SCHEMA_VERSION,
278
352
  client: manifestPath(builder.config.root, clientDirectory),
@@ -311,9 +385,17 @@ export const foldkitBuild = (serverEntry, options = {}) => {
311
385
  order: 'pre',
312
386
  handler() {
313
387
  if (this.environment.name === 'client') {
314
- delete state.template;
388
+ delete state.assets;
315
389
  delete state.serverEntryFile;
316
390
  metadata = undefined;
391
+ const { input } = this.environment.config.build.rolldownOptions;
392
+ const inputs = Predicate.isString(input)
393
+ ? [input]
394
+ : Object.values(input ?? {});
395
+ if (!inputs.includes(CLIENT_MODULE_ID) ||
396
+ inputs.some(entry => /\.html(?:[?#]|$)/.test(entry))) {
397
+ throw new Error('[foldkit] ssr.build owns the browser script input. Configure ssr.clientEntry instead of an HTML input or a replacement client input.');
398
+ }
317
399
  }
318
400
  else if (this.environment.name === 'ssr') {
319
401
  delete state.serverEntryFile;
@@ -322,20 +404,27 @@ export const foldkitBuild = (serverEntry, options = {}) => {
322
404
  },
323
405
  },
324
406
  resolveId(id) {
407
+ if (id === CLIENT_MODULE_ID) {
408
+ return RESOLVED_CLIENT_MODULE_ID;
409
+ }
325
410
  if (id === FOLDKIT_FETCH_MODULE_ID) {
326
411
  return RESOLVED_FETCH_MODULE_ID;
327
412
  }
328
413
  return undefined;
329
414
  },
330
415
  load(id) {
416
+ if (id === RESOLVED_CLIENT_MODULE_ID) {
417
+ const { modulePreload } = this.environment.config.build;
418
+ const polyfill = modulePreload !== false && modulePreload.polyfill
419
+ ? "import 'vite/modulepreload-polyfill'\n"
420
+ : '';
421
+ return `${polyfill}import ${JSON.stringify(options.clientEntry)}\n`;
422
+ }
331
423
  if (id !== RESOLVED_FETCH_MODULE_ID) {
332
424
  return;
333
425
  }
334
- const template = templateForFetchModule(state.template);
335
- return fetchModuleSource(serverEntry, template, containerId);
426
+ return fetchModuleSource(serverEntry, assetsForFetchModule(state.assets));
336
427
  },
337
- // NOTE: `order: 'post'` because Vite's own HTML plugin emits `index.html`
338
- // from a `generateBundle` of its own; post is guaranteed to run after it.
339
428
  generateBundle: {
340
429
  order: 'post',
341
430
  handler(_options, bundle) {
@@ -346,17 +435,15 @@ export const foldkitBuild = (serverEntry, options = {}) => {
346
435
  if (this.environment.name !== 'client') {
347
436
  return;
348
437
  }
349
- const html = bundle[TEMPLATE_FILE_NAME];
350
- if (html === undefined || html.type !== 'asset') {
351
- return;
352
- }
353
- state.template = String(html.source);
354
- delete bundle[TEMPLATE_FILE_NAME];
438
+ state.assets = collectDocumentAssets(Object.values(bundle), this.environment.config);
355
439
  },
356
440
  },
357
441
  config: userConfig => {
358
442
  const client = {
359
- build: { outDir: clientOutDir },
443
+ build: {
444
+ outDir: clientOutDir,
445
+ rolldownOptions: { input: { [CLIENT_CHUNK_NAME]: CLIENT_MODULE_ID } },
446
+ },
360
447
  };
361
448
  const ssr = {
362
449
  build: {
@@ -387,6 +474,16 @@ export const foldkitBuild = (serverEntry, options = {}) => {
387
474
  }
388
475
  : { environments: { client, ssr } };
389
476
  },
477
+ configResolved(config) {
478
+ if (config.base === '' || config.base === './') {
479
+ throw new Error('[foldkit] ssr.build requires an absolute URL or root-relative base, such as "/" or "/app/". A relative base cannot locate browser assets consistently across server-rendered routes.');
480
+ }
481
+ if (!options.clientEntry?.startsWith('/') ||
482
+ options.clientEntry.startsWith('//') ||
483
+ /\.html(?:[?#]|$)/.test(options.clientEntry)) {
484
+ throw new Error("[foldkit] ssr.build requires clientEntry to name a browser script. Move stylesheet imports into that script and document markup into the server entry's renderDocument export.");
485
+ }
486
+ },
390
487
  // The composable finalization point. `order: 'post'` runs this after the
391
488
  // config-level orchestrator — the host's, or the default above — no matter
392
489
  // where this plugin sits in the plugin list, which a wrapped
@@ -1 +1 @@
1
- {"version":3,"file":"devToolsOverlay.d.ts","sourceRoot":"","sources":["../src/devToolsOverlay.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAA;AAsJlC;;;;;GAKG;AACH,eAAO,MAAM,2BAA2B,YAC7B,OAAO,GAAG,OAAO,QACpB,MAAM,KACX,OAQF,CAAA;AAED,+EAA+E;AAC/E,eAAO,MAAM,qBAAqB,QAAO,MA+DxC,CAAA"}
1
+ {"version":3,"file":"devToolsOverlay.d.ts","sourceRoot":"","sources":["../src/devToolsOverlay.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAA;AAoIlC;;;;;GAKG;AACH,eAAO,MAAM,2BAA2B,YAC7B,OAAO,GAAG,OAAO,QACpB,MAAM,KACX,OAQF,CAAA;AAED,+EAA+E;AAC/E,eAAO,MAAM,qBAAqB,QAAO,MAmExC,CAAA"}
@@ -1,26 +1,12 @@
1
- import { Array, Option, Record, Schema } from 'effect';
1
+ import { Option, Record, Schema } from 'effect';
2
2
  import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
3
  import { dirname, join, resolve, sep } from 'node:path';
4
4
  const DEV_TOOLS_PACKAGE_NAME = '@foldkit/devtools';
5
- const FOLDKIT_PACKAGE_NAME = 'foldkit';
6
5
  const DEV_TOOLS_VITE_EXPORT = './vite';
7
6
  const DEV_TOOLS_OVERLAY_MODULE_ID = 'virtual:foldkit-devtools-overlay';
8
7
  const RESOLVED_DEV_TOOLS_OVERLAY_MODULE_ID = `\0${DEV_TOOLS_OVERLAY_MODULE_ID}`;
9
8
  const DEV_TOOLS_VITE_IMPORT_SPECIFIER = `${DEV_TOOLS_PACKAGE_NAME}${DEV_TOOLS_VITE_EXPORT.slice(1)}`;
10
9
  const DEV_TOOLS_HOST_IMPORT_SPECIFIER = 'foldkit/devtools-host';
11
- // NOTE: Vite's dependency scan cannot discover this virtual module's imports.
12
- // Declaring registry-installed imports before the first request avoids a
13
- // mid-session reoptimization and page reload.
14
- const DEV_TOOLS_OVERLAY_IMPORTS = [
15
- {
16
- specifier: DEV_TOOLS_VITE_IMPORT_SPECIFIER,
17
- packageName: DEV_TOOLS_PACKAGE_NAME,
18
- },
19
- {
20
- specifier: DEV_TOOLS_HOST_IMPORT_SPECIFIER,
21
- packageName: FOLDKIT_PACKAGE_NAME,
22
- },
23
- ];
24
10
  const DEV_TOOLS_OVERLAY_MODULE_SOURCE = `
25
11
  import { overlay } from '${DEV_TOOLS_VITE_IMPORT_SPECIFIER}'
26
12
  import { __setDevToolsOverlay } from '${DEV_TOOLS_HOST_IMPORT_SPECIFIER}'
@@ -79,8 +65,7 @@ const resolvePackageDirectory = Option.liftThrowable((packageJsonPath) => realpa
79
65
  // NOTE: Vite serves linked packages from source rather than pre-bundling them.
80
66
  // A registry-installed package has a real path under `node_modules`, while a
81
67
  // workspace link resolves to its source checkout; force-including a link would
82
- // cache its current source and hide edits. Each owner is checked independently
83
- // because one package may be linked while the other is installed.
68
+ // cache its current source and hide edits.
84
69
  const isPackageResolvedIntoNodeModules = (root, packageName) => findInstalledPackageJsonPath(root, packageName).pipe(Option.flatMap(resolvePackageDirectory), Option.exists(packageDirectory => packageDirectory.split(sep).includes('node_modules')));
85
70
  const isDevToolsProductionDependency = (root) => findApplicationPackageJsonPath(root).pipe(Option.flatMap(packageJsonPath => readPackageJson(decodeApplicationPackageJson, packageJsonPath)), Option.flatMapNullishOr(packageJson => packageJson.dependencies), Option.exists(Record.has(DEV_TOOLS_PACKAGE_NAME)));
86
71
  /**
@@ -111,11 +96,19 @@ export const devToolsOverlayPlugin = () => {
111
96
  !shouldInjectDevToolsOverlay(environment.command, root)) {
112
97
  return undefined;
113
98
  }
114
- const include = DEV_TOOLS_OVERLAY_IMPORTS.filter(({ packageName }) => isPackageResolvedIntoNodeModules(root, packageName)).map(({ specifier }) => specifier);
115
- return Array.match(include, {
116
- onEmpty: () => undefined,
117
- onNonEmpty: () => ({ optimizeDeps: { include } }),
118
- });
99
+ // NOTE: Vite's dependency scan cannot discover the virtual module's
100
+ // imports, so a registry-installed `@foldkit/devtools/vite` is declared
101
+ // before the first request to avoid a mid-session reoptimization and page
102
+ // reload. `foldkit/devtools-host` is deliberately not declared. The plugin
103
+ // excludes `foldkit` and serves it from source. Vite resolves a
104
+ // force-included specifier to its pre-bundle before consulting `exclude`.
105
+ // That would give the overlay its own copy of the DevTools config.
106
+ if (isPackageResolvedIntoNodeModules(root, DEV_TOOLS_PACKAGE_NAME)) {
107
+ return { optimizeDeps: { include: [DEV_TOOLS_VITE_IMPORT_SPECIFIER] } };
108
+ }
109
+ else {
110
+ return undefined;
111
+ }
119
112
  },
120
113
  configResolved: config => {
121
114
  isInjectionEnabled = shouldInjectDevToolsOverlay(config.command, config.root);
package/dist/index.d.ts CHANGED
@@ -25,7 +25,10 @@ export type FoldkitPluginOptions = Readonly<{
25
25
  * `ssr.serverEntry`. When `undefined` (the default), the dev server
26
26
  * serves the client entry only.
27
27
  */
28
- ssr?: Omit<FoldkitSsrOptions, 'buildId' | 'quietStandDown'> & Readonly<{
28
+ ssr?: Omit<FoldkitSsrOptions, 'buildId' | 'quietStandDown' | 'clientEntry' | 'containerId'> & (Readonly<{
29
+ /** Root-relative browser script for the server entry's code-rendered document. */
30
+ clientEntry: string;
31
+ containerId?: never;
29
32
  /**
30
33
  * Build a Web `fetch` handler alongside the browser build, and generate
31
34
  * static HTML from the server entry, inside this project's own
@@ -35,8 +38,13 @@ export type FoldkitPluginOptions = Readonly<{
35
38
  *
36
39
  * When this is absent, `vite build` builds the browser bundle only.
37
40
  */
38
- build?: boolean | FoldkitBuildOptions;
39
- }>;
41
+ build?: boolean | Omit<FoldkitBuildOptions, 'clientEntry'>;
42
+ }> | Readonly<{
43
+ /** Use a custom template-based development and build pipeline. */
44
+ clientEntry?: never;
45
+ containerId?: string;
46
+ build?: false;
47
+ }>);
40
48
  /**
41
49
  * An explicit identity for the deployment this build belongs to. Foldkit
42
50
  * normally generates an opaque identity when one Vite app build coordinates
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AA6CA,OAAO,KAAK,EAEV,MAAM,EAIP,MAAM,MAAM,CAAA;AAOb,OAAO,EAAE,KAAK,mBAAmB,EAAgB,MAAM,YAAY,CAAA;AAKnE,OAAO,EAAE,KAAK,iBAAiB,EAAc,MAAM,UAAU,CAAA;AAG7D,OAAO,EAAE,KAAK,eAAe,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAA;AACzE,OAAO,EACL,uBAAuB,EACvB,oBAAoB,EACpB,oBAAoB,EACpB,KAAK,eAAe,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;;;;;;;;OAQG;IACH,eAAe,CAAC,EAAE,MAAM,GAAG,KAAK,CAAA;IAChC;;;;;;;OAOG;IACH,GAAG,CAAC,EAAE,IAAI,CAAC,iBAAiB,EAAE,SAAS,GAAG,gBAAgB,CAAC,GACzD,QAAQ,CAAC;QACP;;;;;;;;WAQG;QACH,KAAK,CAAC,EAAE,OAAO,GAAG,mBAAmB,CAAA;KACtC,CAAC,CAAA;IACJ;;;;;;;;;;;;;;;;OAgBG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB,CAAC,CAAA;AAs6BF,eAAO,MAAM,OAAO,aAAa,oBAAoB,KAAQ,KAAK,CAAC,MAAM,CAyHxE,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AA6CA,OAAO,KAAK,EAEV,MAAM,EAIP,MAAM,MAAM,CAAA;AAOb,OAAO,EAAE,KAAK,mBAAmB,EAAgB,MAAM,YAAY,CAAA;AAKnE,OAAO,EAAE,KAAK,iBAAiB,EAAc,MAAM,UAAU,CAAA;AAG7D,OAAO,EAAE,KAAK,eAAe,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAA;AACzE,OAAO,EACL,uBAAuB,EACvB,oBAAoB,EACpB,oBAAoB,EACpB,KAAK,eAAe,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;;;;;;;;OAQG;IACH,eAAe,CAAC,EAAE,MAAM,GAAG,KAAK,CAAA;IAChC;;;;;;;OAOG;IACH,GAAG,CAAC,EAAE,IAAI,CACR,iBAAiB,EACjB,SAAS,GAAG,gBAAgB,GAAG,aAAa,GAAG,aAAa,CAC7D,GACC,CACI,QAAQ,CAAC;QACP,kFAAkF;QAClF,WAAW,EAAE,MAAM,CAAA;QACnB,WAAW,CAAC,EAAE,KAAK,CAAA;QACnB;;;;;;;;WAQG;QACH,KAAK,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC,mBAAmB,EAAE,aAAa,CAAC,CAAA;KAC3D,CAAC,GACF,QAAQ,CAAC;QACP,kEAAkE;QAClE,WAAW,CAAC,EAAE,KAAK,CAAA;QACnB,WAAW,CAAC,EAAE,MAAM,CAAA;QACpB,KAAK,CAAC,EAAE,KAAK,CAAA;KACd,CAAC,CACL,CAAA;IACH;;;;;;;;;;;;;;;;OAgBG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB,CAAC,CAAA;AA64BF,eAAO,MAAM,OAAO,aAAa,oBAAoB,KAAQ,KAAK,CAAC,MAAM,CAuIxE,CAAA"}
package/dist/index.js CHANGED
@@ -474,27 +474,6 @@ const main = (server, events, options) => Effect.gen(function* () {
474
474
  * an array; Vite flattens nested plugin arrays, so `plugins: [foldkit()]`
475
475
  * keeps working.
476
476
  */
477
- // The container is named once, on `ssr`, and reaches both the dev host and the
478
- // build from there. A `build.prerender` that names its own wins, so a project
479
- // that needs them to differ still can.
480
- const withContainerId = (build, containerId) => {
481
- const options = build === true ? {} : build;
482
- if (containerId === undefined) {
483
- return options;
484
- }
485
- const withContainer = { ...options, containerId };
486
- if (options.prerender === undefined) {
487
- return withContainer;
488
- }
489
- const prerender = options.prerender === true ? {} : options.prerender;
490
- if (prerender === false) {
491
- return withContainer;
492
- }
493
- return {
494
- ...withContainer,
495
- prerender: { containerId, ...prerender },
496
- };
497
- };
498
477
  const relayRegistryLayer = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer, NodeCrypto.layer);
499
478
  export const foldkit = (options = {}) => {
500
479
  // NOTE: During a Vite restart, old and new servers overlap. Separate queues
@@ -577,6 +556,9 @@ export const foldkit = (options = {}) => {
577
556
  return shared;
578
557
  }
579
558
  const { build, ...ssr } = options.ssr;
559
+ if (ssr.clientEntry !== undefined && ssr.containerId !== undefined) {
560
+ throw new Error('[foldkit] containerId belongs to an HTML template. A clientEntry build takes its complete document from renderDocument instead.');
561
+ }
580
562
  const servePages = foldkitSsr({
581
563
  ...ssr,
582
564
  ...(options.buildId === undefined ? {} : { buildId: options.buildId }),
@@ -585,9 +567,15 @@ export const foldkit = (options = {}) => {
585
567
  if (build === undefined || build === false) {
586
568
  return [...shared, servePages];
587
569
  }
570
+ if (ssr.clientEntry === undefined) {
571
+ throw new Error('[foldkit] ssr.build requires ssr.clientEntry to name the browser script and the server entry to export renderDocument.');
572
+ }
588
573
  return [
589
574
  ...shared,
590
575
  servePages,
591
- foldkitBuild(ssr.serverEntry, withContainerId(build, ssr.containerId)),
576
+ foldkitBuild(ssr.serverEntry, {
577
+ ...(build === true ? {} : build),
578
+ clientEntry: ssr.clientEntry,
579
+ }),
592
580
  ];
593
581
  };
package/dist/ssr.d.ts CHANGED
@@ -8,6 +8,12 @@ export type FoldkitSsrOptions = Readonly<{
8
8
  * `Promise<EntryResult>`.
9
9
  */
10
10
  serverEntry: string;
11
+ /**
12
+ * Root-relative browser script entry for a code-rendered document. The server entry must
13
+ * also export `renderDocument(application, assets)`. When absent, the dev
14
+ * host uses `index.html` for a custom template-based build pipeline.
15
+ */
16
+ clientEntry?: string;
11
17
  /**
12
18
  * The `id` of the empty container element in `index.html` the rendered
13
19
  * markup replaces. Defaults to `'root'`.
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,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;IAChB;;;;OAIG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;CACzB,CAAC,CAAA;AAosBF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,UAAU,YAAa,iBAAiB,KAAG,MAgEvD,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;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;;;;;;;;;OAYG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;IACf;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB;;;;OAIG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;CACzB,CAAC,CAAA;AA6tBF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,UAAU,YAAa,iBAAiB,KAAG,MAyGvD,CAAA"}
package/dist/ssr.js CHANGED
@@ -132,19 +132,32 @@ const renderDecision = (nodeRequest, requestUrl) => {
132
132
  const renderRequest = (server, options, nodeRequest, requestUrl) => Effect.gen(function* () {
133
133
  const { pathname, search } = new URL(requestUrl);
134
134
  const route = `${pathname}${search}`;
135
- const rawTemplate = yield* Effect.promise(() => readFile(resolve(server.config.root, 'index.html'), 'utf-8'));
136
- // NOTE: the first argument tells Vite where the HTML lives, and Vite
137
- // resolves the template's relative URLs (such as a `./src/entry.ts`
138
- // script) against it. The template always lives at the site root, so
139
- // that argument must stay `/index.html` no matter which route is being
140
- // rendered. The third argument, named `originalUrl` in Vite's signature,
141
- // carries the route actually being requested.
142
- const template = yield* Effect.promise(() => server.transformIndexHtml('/index.html', rawTemplate, route));
143
135
  const loadedModule = yield* Effect.promise(() => server.ssrLoadModule(options.serverEntry));
144
136
  if (!isEntryModule(loadedModule)) {
145
137
  return yield* Effect.die(new Error(`[foldkit] '${options.serverEntry}' does not export a renderPage function, so the dev server cannot render pages.`));
146
138
  }
147
139
  const result = yield* Effect.promise(() => loadedModule.renderPage(toWebRequest(requestUrl, nodeRequest)));
140
+ if (result._tag === 'Responded') {
141
+ return result.response;
142
+ }
143
+ if (options.clientEntry !== undefined) {
144
+ if (!('renderDocument' in loadedModule) ||
145
+ !Predicate.isFunction(loadedModule.renderDocument)) {
146
+ return yield* Effect.die(new Error(`[foldkit] '${options.serverEntry}' must export renderDocument(application, assets) when clientEntry is configured.`));
147
+ }
148
+ const assets = Server.DocumentAssets.make({
149
+ entryScript: options.clientEntry,
150
+ stylesheets: [],
151
+ modulePreloads: [],
152
+ });
153
+ const document = loadedModule.renderDocument(result.application, assets);
154
+ const transformed = yield* Effect.promise(() => server.transformIndexHtml(pathname, document, route));
155
+ return Server.toResponse(() => transformed, result);
156
+ }
157
+ const rawTemplate = yield* Effect.promise(() => readFile(resolve(server.config.root, 'index.html'), 'utf-8'));
158
+ // NOTE: Vite resolves template-relative URLs against the first argument;
159
+ // the template-based host's index lives at the root for nested page requests.
160
+ const template = yield* Effect.promise(() => server.transformIndexHtml('/index.html', rawTemplate, route));
148
161
  return Server.toResponse(template, result, options.containerId === undefined
149
162
  ? {}
150
163
  : { containerId: options.containerId });
@@ -461,9 +474,32 @@ const renderMiddleware = (server, options, requestUrls, stateByRequest) => (node
461
474
  * responses. Server entry edits take effect without a restart.
462
475
  */
463
476
  export const foldkitSsr = (options) => {
477
+ let isDevelopmentHost = false;
464
478
  return {
465
479
  name: 'foldkit-ssr',
466
- config: (_config, { command, isPreview }) => {
480
+ configResolved(config) {
481
+ if (!isDevelopmentHost || options.clientEntry === undefined) {
482
+ return;
483
+ }
484
+ if (!options.clientEntry.startsWith('/') ||
485
+ options.clientEntry.startsWith('//') ||
486
+ /\.html(?:[?#]|$)/.test(options.clientEntry)) {
487
+ throw new Error('[foldkit] clientEntry must be a root-relative browser script URL, such as "/src/entry.ts".');
488
+ }
489
+ if (options.containerId !== undefined) {
490
+ throw new Error('[foldkit] containerId is only used by the template-based dev host. A code-rendered document owns its application placement.');
491
+ }
492
+ if (config.base === '' || config.base === './') {
493
+ throw new Error('[foldkit] code-rendered documents require an absolute URL or root-relative base so browser assets resolve consistently on nested routes.');
494
+ }
495
+ },
496
+ config: (config, { command, isPreview }) => {
497
+ isDevelopmentHost = command === 'serve' && isPreview !== true;
498
+ if (isDevelopmentHost &&
499
+ options.clientEntry !== undefined &&
500
+ (config.base === '' || config.base === './')) {
501
+ throw new Error('[foldkit] code-rendered documents require an absolute URL or root-relative base so browser assets resolve consistently on nested routes.');
502
+ }
467
503
  const buildId = buildIdForCommand(command, options.buildId);
468
504
  return {
469
505
  // NOTE: `vite preview` also resolves with command 'serve', but it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldkit/vite-plugin",
3
- "version": "0.26.1-canary.8c70ed904b29",
3
+ "version": "0.26.1-canary.fe2701c2fa4b",
4
4
  "description": "Vite plugin for Foldkit with state-preserving live reload",
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",
20
- "foldkit": "0.166.0-canary.8c70ed904b29",
20
+ "foldkit": "0.166.0-canary.fe2701c2fa4b",
21
21
  "vite": "^8.0.0"
22
22
  },
23
23
  "dependencies": {
@@ -38,7 +38,7 @@
38
38
  "vite": "^8.3.1",
39
39
  "vite-host": "npm:vite@8.2.1",
40
40
  "vitest": "^4.1.11",
41
- "foldkit": "0.166.0-canary.8c70ed904b29"
41
+ "foldkit": "0.166.0-canary.fe2701c2fa4b"
42
42
  },
43
43
  "keywords": [
44
44
  "vite",