@foldkit/vite-plugin 0.24.0-canary.e072cf5b439b → 0.24.0-canary.eb11872b6977

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,52 @@ 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
+ ## Completed build metadata
69
+
70
+ Deployment tools that run Vite in process can read `foldkit:build` through Vite's standard plugin `api` field. Await the full application build before reading:
71
+
72
+ ```typescript
73
+ import type { FoldkitBuildApi } from '@foldkit/vite-plugin'
74
+ import { createBuilder } from 'vite'
75
+
76
+ const builder = await createBuilder()
77
+ await builder.buildApp()
78
+
79
+ const plugin = builder.config.plugins.find(
80
+ plugin => plugin.name === 'foldkit:build',
81
+ )
82
+
83
+ if (plugin !== undefined) {
84
+ const api: FoldkitBuildApi | undefined = plugin.api
85
+
86
+ if (typeof api?.getBuildMetadata !== 'function') {
87
+ throw new Error('This Foldkit version does not expose build metadata')
88
+ }
89
+
90
+ const metadata = api.getBuildMetadata()
91
+ console.log(metadata.serverEntry)
92
+ console.log(metadata.manifest.prerendered)
93
+ }
94
+ ```
95
+
96
+ `FoldkitBuildApi` preserves `serverEntry` (the configured source entry) and `fetchModuleId` (the virtual fetch module). Its `getBuildMetadata()` method returns `FoldkitBuildMetadata`, exported as a Schema and inferred type:
97
+
98
+ | Field | Meaning |
99
+ | ----------------- | ------------------------------------------------------- |
100
+ | `root` | Absolute resolved application root |
101
+ | `clientDirectory` | Absolute resolved client output directory |
102
+ | `serverDirectory` | Absolute resolved server output directory |
103
+ | `serverEntry` | Absolute path to the emitted fetch handler |
104
+ | `manifest` | The version-1 data also written to `foldkit.build.json` |
105
+
106
+ The snapshot, manifest, and prerendered route array are frozen. The data can be serialized to another process. The manifest keeps its portable relative POSIX paths; the outer path fields describe the local build machine. Output paths reflect the resolved Vite environments, including overrides made by a host plugin.
107
+
108
+ A client-only build has no `foldkit:build` plugin. A present plugin without the accessor needs a Foldkit upgrade. The accessor throws before Foldkit finalizes, while another client or server build is running, or after its build fails. Always await `builder.buildApp()` successfully: another plugin can fail after Foldkit has finalized. An environment's `writeBundle` and another plugin's post-order `buildApp` hook do not establish this completion boundary.
109
+
110
+ Create a fresh Foldkit plugin set for each independent builder. The build plugin uses Vite's `sharedDuringBuild` to share captures across its environments. Concurrent builders must not reuse the same plugin object. This API does not add watch-mode support.
111
+
112
+ Prerendered pages and `foldkit.build.json` are finalized after the environment bundles. A separate deployment process that consumes an existing build can continue reading the disk manifest. An integration that runs the build in a child process can read the API there and transfer the serialized metadata in its child result.
113
+
68
114
  ## Build id
69
115
 
70
116
  The build id does not make hydration correct. It makes hydration refuse when it would otherwise be incorrect.
@@ -110,13 +156,23 @@ To include the overlay in production, list `@foldkit/devtools` in regular `depen
110
156
 
111
157
  ## DevTools MCP relay
112
158
 
113
- Pass `devToolsMcpPort` to enable the relay that exposes your running Foldkit app to AI agents via the [`@foldkit/devtools-mcp`](https://www.npmjs.com/package/@foldkit/devtools-mcp) MCP server:
159
+ During development, the plugin starts a WebSocket relay for the [`@foldkit/devtools-mcp`](https://www.npmjs.com/package/@foldkit/devtools-mcp) server. Through the relay, an AI agent can inspect a running Foldkit app and dispatch Messages.
160
+
161
+ By default, the relay uses the dev server's listener at `/__foldkit/devtools-mcp`. The plugin publishes its address to a registry private to your user, and the MCP server finds it by project. You do not need to coordinate a port between them. The registry lives under `XDG_RUNTIME_DIR` when that is set, or under the operating system's temporary directory. `FOLDKIT_DEVTOOLS_RELAY_DIRECTORY` selects another directory.
162
+
163
+ The relay follows Vite's `server.host` setting. If you expose the dev server with `--host`, a client still needs the random token in the published address to inspect a Model or dispatch a Message. The plugin will not publish that token into a registry directory owned by another user or readable by other users. It reports the problem in the console.
164
+
165
+ In middleware mode, the relay uses a free loopback port because there is no HTTP server to share. It also uses a free loopback port for HTTPS dev servers, whose self-signed certificates the MCP server cannot verify. The plugin publishes these addresses for discovery in the same way.
166
+
167
+ To use a fixed port, set `devToolsMcpPort` in your Vite config:
114
168
 
115
169
  ```typescript
116
170
  plugins: [foldkit({ devToolsMcpPort: 9988 })]
117
171
  ```
118
172
 
119
- When set, the plugin opens a separate WebSocket server on the given port. The MCP server connects to it and forwards typed `Request` and `Response` frames between AI agents and your Runtime. Without `devToolsMcpPort` (the default), the relay is not started and the plugin behaves exactly as before.
173
+ Set `FOLDKIT_DEVTOOLS_MCP_PORT` to the same value for the MCP server. A fixed port opens a separate socket on every interface and does not require a token. Use this setting on platforms where directory ownership cannot be verified, including Windows, because the plugin cannot publish a relay address there.
174
+
175
+ `devToolsMcpPort: false` disables the relay. The relay does not start during Vitest runs or in production builds.
120
176
 
121
177
  See the [DevTools MCP documentation](https://foldkit.dev/ai/mcp) for setup, the available tools, and how dispatch validation works.
122
178
 
package/dist/build.d.ts CHANGED
@@ -76,6 +76,53 @@ export declare const FoldkitBuildManifest: Schema.Struct<{
76
76
  * unknown versions rather than letting the host read missing fields.
77
77
  */
78
78
  export type FoldkitBuildManifest = typeof FoldkitBuildManifest.Type;
79
+ /** Completed application build data for an in-process deployment integration. */
80
+ export declare const FoldkitBuildMetadata: Schema.Struct<{
81
+ /** Absolute resolved Vite application root. */
82
+ readonly root: Schema.String;
83
+ /** Absolute resolved browser output directory. */
84
+ readonly clientDirectory: Schema.String;
85
+ /** Absolute resolved server output directory. */
86
+ readonly serverDirectory: Schema.String;
87
+ /** Absolute path to the emitted fetch handler. */
88
+ readonly serverEntry: Schema.String;
89
+ /** Portable data also written to `foldkit.build.json`. */
90
+ readonly manifest: Schema.Struct<{
91
+ /**
92
+ * The shape of this document. A consumer decodes before reading and refuses
93
+ * a version it does not know, so a manifest written by a newer Foldkit is a
94
+ * clear refusal rather than a field silently read as undefined.
95
+ */
96
+ readonly schemaVersion: Schema.Literals<readonly [1]>;
97
+ /**
98
+ * Where the browser build was written, as a POSIX path relative to the Vite
99
+ * root. Relative and normalized so a manifest survives being moved with the
100
+ * build it describes.
101
+ */
102
+ readonly client: Schema.String;
103
+ /** Where the server build was written, on the same terms as {@link client}. */
104
+ readonly server: Schema.String;
105
+ /** The server build's entry file, relative to `server`. */
106
+ readonly serverEntry: Schema.String;
107
+ /** Every path this build generated a page for, in the order it generated. */
108
+ readonly prerendered: Schema.$Array<Schema.String>;
109
+ }>;
110
+ }>;
111
+ /** The serializable, frozen snapshot of a completed Foldkit build. */
112
+ export type FoldkitBuildMetadata = typeof FoldkitBuildMetadata.Type;
113
+ /** The `foldkit:build` plugin's public integration API. */
114
+ export type FoldkitBuildApi = Readonly<{
115
+ /** Configured application source entry. */
116
+ serverEntry: string;
117
+ /** Virtual module used to bundle the fetch handler. */
118
+ fetchModuleId: typeof FOLDKIT_FETCH_MODULE_ID;
119
+ /**
120
+ * Read after a successful `await builder.buildApp()`. Throws before Foldkit
121
+ * finalizes or after another client or server build starts. Later plugin failures
122
+ * still require callers to await the full build successfully.
123
+ */
124
+ getBuildMetadata: () => FoldkitBuildMetadata;
125
+ }>;
79
126
  export declare const manifestPath: (root: string, directory: string, pathApi?: typeof nodePath) => string;
80
127
  export declare const renderTargetFor: (clientDirectory: string, path: string, origin: string, pathApi?: typeof nodePath) => Readonly<{
81
128
  url: URL;
@@ -90,5 +137,5 @@ export declare const renderTargetFor: (clientDirectory: string, path: string, or
90
137
  * use the HTML emitted by the browser build, but the unrendered template is not
91
138
  * published with the assets. The server bundle's default export is `{ fetch }`.
92
139
  */
93
- export declare const foldkitBuild: (serverEntry: string, options?: FoldkitBuildOptions) => Plugin;
140
+ export declare const foldkitBuild: (serverEntry: string, options?: FoldkitBuildOptions) => Plugin<FoldkitBuildApi>;
94
141
  //# 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;AAG/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;AAcnE,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;AA4GD;;;;;;;;GAQG;AACH,eAAO,MAAM,YAAY,gBACV,MAAM,YACV,mBAAmB,KAC3B,MAqOF,CAAA"}
1
+ {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../src/build.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AAG/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,CAmPxB,CAAA"}
package/dist/build.js CHANGED
@@ -31,6 +31,19 @@ export const FoldkitBuildManifest = Schema.Struct({
31
31
  /** Every path this build generated a page for, in the order it generated. */
32
32
  prerendered: Schema.Array(Schema.String),
33
33
  });
34
+ /** Completed application build data for an in-process deployment integration. */
35
+ export const FoldkitBuildMetadata = Schema.Struct({
36
+ /** Absolute resolved Vite application root. */
37
+ root: Schema.String,
38
+ /** Absolute resolved browser output directory. */
39
+ clientDirectory: Schema.String,
40
+ /** Absolute resolved server output directory. */
41
+ serverDirectory: Schema.String,
42
+ /** Absolute path to the emitted fetch handler. */
43
+ serverEntry: Schema.String,
44
+ /** Portable data also written to `foldkit.build.json`. */
45
+ manifest: FoldkitBuildManifest,
46
+ });
34
47
  const MANIFEST_SCHEMA_VERSION = 1;
35
48
  // Relative and POSIX so the manifest describes a layout rather than this
36
49
  // machine: an absolute `clientOutDir` would otherwise be published verbatim and
@@ -154,26 +167,6 @@ const prerenderOptionsFrom = (prerender) => {
154
167
  }
155
168
  return prerender === true ? {} : prerender;
156
169
  };
157
- // Keyed by Vite root plus the output layout, so concurrent builds of different
158
- // projects in one process never read each other's output.
159
- //
160
- // NOTE: the registry hangs off a global symbol rather than module scope. Vite
161
- // re-bundles a config file for each environment it resolves, and every bundle
162
- // is a fresh copy of this module with its own module scope, so what the client
163
- // build recorded would be invisible to the instance that finalizes. The symbol
164
- // is one registry for the process no matter how many copies of this module it
165
- // loads.
166
- const CAPTURES = Symbol.for('foldkit/vite-plugin:build-captures');
167
- const captures = (() => {
168
- const registry = globalThis;
169
- const existing = registry[CAPTURES];
170
- if (existing instanceof Map) {
171
- return existing;
172
- }
173
- const fresh = new Map();
174
- registry[CAPTURES] = fresh;
175
- return fresh;
176
- })();
177
170
  const fetchModuleSource = (serverEntry, template, containerId) => {
178
171
  const containerLiteral = containerId === undefined ? 'undefined' : JSON.stringify(containerId);
179
172
  // NOTE: `export *` re-exports whatever the application entry actually names,
@@ -218,6 +211,8 @@ const templateForFetchModule = (capturedTemplate) => {
218
211
  * published with the assets. The server bundle's default export is `{ fetch }`.
219
212
  */
220
213
  export const foldkitBuild = (serverEntry, options = {}) => {
214
+ const state = {};
215
+ let metadata;
221
216
  const clientOutDir = options.clientOutDir ?? DEFAULT_CLIENT_OUT_DIR;
222
217
  const serverOutDir = options.serverOutDir ?? DEFAULT_SERVER_OUT_DIR;
223
218
  const prerender = prerenderOptionsFrom(options.prerender ?? false);
@@ -226,12 +221,11 @@ export const foldkitBuild = (serverEntry, options = {}) => {
226
221
  // with the build's own privileges. That module is the application's own code
227
222
  // and its dependencies, built from the configured entry, and is trusted on
228
223
  // exactly those terms. Nothing here is imported when prerendering is off.
229
- const generatePages = async (builder, template, serverDirectory, entryFileName) => {
224
+ const generatePages = async (builder, template, clientDirectory, serverDirectory, entryFileName) => {
230
225
  if (prerender === undefined) {
231
226
  return [];
232
227
  }
233
228
  const origin = prerender.origin ?? DEFAULT_PRERENDER_ORIGIN;
234
- const clientDirectory = resolve(builder.config.root, clientOutDir);
235
229
  const entryFile = resolve(serverDirectory, entryFileName);
236
230
  const contained = resolve(serverDirectory);
237
231
  if (!entryFile.startsWith(`${contained}${nodePath.sep}`)) {
@@ -258,38 +252,12 @@ export const foldkitBuild = (serverEntry, options = {}) => {
258
252
  }
259
253
  return paths;
260
254
  };
261
- const writeManifest = async (builder, serverDirectory, entryFileName, prerendered) => {
262
- const manifest = Schema.encodeSync(FoldkitBuildManifest)({
263
- schemaVersion: MANIFEST_SCHEMA_VERSION,
264
- client: manifestPath(builder.config.root, clientOutDir),
265
- server: manifestPath(builder.config.root, serverOutDir),
266
- serverEntry: entryFileName,
267
- prerendered,
268
- });
269
- await writeFile(resolve(serverDirectory, MANIFEST_FILE_NAME), `${JSON.stringify(manifest, undefined, 2)}\n`);
270
- builder.config.logger.info(` wrote ${MANIFEST_FILE_NAME}`);
271
- };
272
- // What each environment emitted, recorded as it is emitted.
273
- //
274
- // Vite resolves the config once per environment unless `sharedConfigBuild` is
275
- // on, so the plugin that finalizes is not necessarily the instance that saw a
276
- // given environment build. Keying the record by the output layout it
277
- // describes is what lets the finalizing instance read what the others
278
- // emitted, and what lets finalization work whether this plugin orchestrates
279
- // the environments or a host does.
280
- const key = [clientOutDir, serverOutDir, serverEntry].join('\u0000');
281
- const captured = (root) => {
282
- const existing = captures.get(`${root}\u0000${key}`);
283
- if (existing !== undefined) {
284
- return existing;
285
- }
286
- const fresh = {};
287
- captures.set(`${root}\u0000${key}`, fresh);
288
- return fresh;
289
- };
290
255
  const finalize = async (builder) => {
291
- const state = captured(builder.config.root);
292
- const serverDirectory = resolve(builder.config.root, serverOutDir);
256
+ metadata = undefined;
257
+ const client = environmentNamed(builder, 'client');
258
+ const server = environmentNamed(builder, 'ssr');
259
+ const clientDirectory = resolve(client.config.root, client.config.build.outDir);
260
+ const serverDirectory = resolve(server.config.root, server.config.build.outDir);
293
261
  if (state.serverEntryFile === undefined) {
294
262
  throw new Error('[foldkit] the server environment produced no entry chunk, so there is nothing to deploy or generate from.');
295
263
  }
@@ -301,15 +269,54 @@ export const foldkitBuild = (serverEntry, options = {}) => {
301
269
  }
302
270
  return state.template;
303
271
  };
304
- const prerendered = await generatePages(builder, template, serverDirectory, state.serverEntryFile);
305
- await writeManifest(builder, serverDirectory, state.serverEntryFile, prerendered);
272
+ const prerendered = await generatePages(builder, template, clientDirectory, serverDirectory, state.serverEntryFile);
273
+ const manifest = FoldkitBuildManifest.make({
274
+ schemaVersion: MANIFEST_SCHEMA_VERSION,
275
+ client: manifestPath(builder.config.root, clientDirectory),
276
+ server: manifestPath(builder.config.root, serverDirectory),
277
+ serverEntry: state.serverEntryFile,
278
+ prerendered: [...prerendered],
279
+ });
280
+ await writeFile(resolve(serverDirectory, MANIFEST_FILE_NAME), `${JSON.stringify(Schema.encodeSync(FoldkitBuildManifest)(manifest), undefined, 2)}\n`);
281
+ builder.config.logger.info(` wrote ${MANIFEST_FILE_NAME}`);
282
+ const completedMetadata = FoldkitBuildMetadata.make({
283
+ root: builder.config.root,
284
+ clientDirectory,
285
+ serverDirectory,
286
+ serverEntry: resolve(serverDirectory, state.serverEntryFile),
287
+ manifest,
288
+ });
289
+ Object.freeze(completedMetadata.manifest.prerendered);
290
+ Object.freeze(completedMetadata.manifest);
291
+ metadata = Object.freeze(completedMetadata);
306
292
  };
307
293
  return {
308
294
  name: 'foldkit:build',
309
295
  apply: 'build',
296
+ sharedDuringBuild: true,
310
297
  api: {
311
298
  serverEntry,
312
299
  fetchModuleId: FOLDKIT_FETCH_MODULE_ID,
300
+ getBuildMetadata: () => {
301
+ if (metadata === undefined) {
302
+ throw new Error('[foldkit] build metadata is not available. Read it after a successful builder.buildApp().');
303
+ }
304
+ return metadata;
305
+ },
306
+ },
307
+ buildStart: {
308
+ order: 'pre',
309
+ handler() {
310
+ if (this.environment.name === 'client') {
311
+ delete state.template;
312
+ delete state.serverEntryFile;
313
+ metadata = undefined;
314
+ }
315
+ else if (this.environment.name === 'ssr') {
316
+ delete state.serverEntryFile;
317
+ metadata = undefined;
318
+ }
319
+ },
313
320
  },
314
321
  resolveId(id) {
315
322
  if (id === FOLDKIT_FETCH_MODULE_ID) {
@@ -321,7 +328,6 @@ export const foldkitBuild = (serverEntry, options = {}) => {
321
328
  if (id !== RESOLVED_FETCH_MODULE_ID) {
322
329
  return;
323
330
  }
324
- const state = captured(this.environment.config.root);
325
331
  const template = templateForFetchModule(state.template);
326
332
  return fetchModuleSource(serverEntry, template, containerId);
327
333
  },
@@ -330,7 +336,6 @@ export const foldkitBuild = (serverEntry, options = {}) => {
330
336
  generateBundle: {
331
337
  order: 'post',
332
338
  handler(_options, bundle) {
333
- const state = captured(this.environment.config.root);
334
339
  if (this.environment.name === 'ssr') {
335
340
  state.serverEntryFile = serverEntryFile(Object.values(bundle), FETCH_CHUNK_NAME);
336
341
  return;
package/dist/index.d.ts CHANGED
@@ -2,18 +2,21 @@ import type { Plugin } from 'vite';
2
2
  import { type FoldkitBuildOptions } from './build.js';
3
3
  import { type FoldkitSsrOptions } from './ssr.js';
4
4
  export { type BrandDistResult, brandDistDirectory } from './brandDist.js';
5
- export { FOLDKIT_FETCH_MODULE_ID, FoldkitBuildManifest, type FoldkitBuildOptions, type FoldkitPrerenderOptions, foldkitBuild, } from './build.js';
5
+ export { FOLDKIT_FETCH_MODULE_ID, FoldkitBuildManifest, FoldkitBuildMetadata, type FoldkitBuildApi, type FoldkitBuildOptions, type FoldkitPrerenderOptions, foldkitBuild, } from './build.js';
6
6
  export { type FoldkitSsrOptions, foldkitSsr } from './ssr.js';
7
7
  export { type ViewIdentityTransformResult, foldkitViewIdentity, transformViewIdentity, } from './viewIdentity.js';
8
8
  /** Options for the `foldkit` Vite plugin. */
9
9
  export type FoldkitPluginOptions = Readonly<{
10
10
  /**
11
- * Port for the WebSocket server that exposes the DevTools relay to an
12
- * external MCP server. When `undefined` (the default), no MCP relay is
13
- * started. When set, the plugin listens on this port for connections from
14
- * the Foldkit DevTools MCP server.
11
+ * By default, the dev server hosts the DevTools MCP relay and publishes its
12
+ * address for the MCP server to find. Middleware and HTTPS servers use a
13
+ * separate loopback listener. Published addresses carry an access token.
14
+ *
15
+ * A number starts an unauthenticated listener on that port on every
16
+ * interface; set `FOLDKIT_DEVTOOLS_MCP_PORT` in the MCP server to match.
17
+ * `false` disables the relay. Vitest never starts it.
15
18
  */
16
- devToolsMcpPort?: number;
19
+ devToolsMcpPort?: number | false;
17
20
  /**
18
21
  * Serve server-rendered pages from the Vite dev server, and, with
19
22
  * `ssr.build`, emit a Web `fetch` handler as the server bundle. When
@@ -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;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,uBAAuB,EACvB,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;;;;;;;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;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB,CAAC,CAAA;AAonBF,eAAO,MAAM,OAAO,aAAa,oBAAoB,KAAQ,KAAK,CAAC,MAAM,CAuFxE,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AA+CA,OAAO,KAAK,EAEV,MAAM,EAIP,MAAM,MAAM,CAAA;AAOb,OAAO,EAAE,KAAK,mBAAmB,EAAgB,MAAM,YAAY,CAAA;AAInE,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;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB,CAAC,CAAA;AAw8BF,eAAO,MAAM,OAAO,aAAa,oBAAoB,KAAQ,KAAK,CAAC,MAAM,CA+FxE,CAAA"}
package/dist/index.js CHANGED
@@ -1,16 +1,22 @@
1
- import { Array, Console, Data, Duration, Effect, Exit, Fiber, HashMap, HashSet, Match, Option, Predicate, Queue, Ref, Schedule, Schema, Stream, pipe, } from 'effect';
2
- import { Event as DevToolsEvent, EventFrame, RequestFrame, Response, ResponseFrame, } from 'foldkit/devtools-protocol';
1
+ import { Array, Clock, ConfigProvider, Console, Crypto, Data, Duration, Effect, Exit, Fiber, HashMap, HashSet, Layer, Match, Option, Predicate, Queue, Ref, Schedule, Schema, Stream, pipe, } from 'effect';
2
+ import { Event as DevToolsEvent, EventFrame, RELAY_RECORD_VERSION, RequestFrame, Response, ResponseFrame, } from 'foldkit/devtools-protocol';
3
3
  import { PreserveModelMessage, RequestModelMessage, RestoreModelMessage, } from 'foldkit/model-preservation';
4
+ import { timingSafeEqual } from 'node:crypto';
5
+ import { createServer as createHttpServer, } from 'node:http';
4
6
  import { createRequire } from 'node:module';
5
7
  import { resolve } from 'node:path';
6
8
  import { WebSocketServer } from 'ws';
9
+ import * as NodeCrypto from '@effect/platform-node/NodeCrypto';
10
+ import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem';
11
+ import * as NodePath from '@effect/platform-node/NodePath';
7
12
  import { foldkitBuild } from './build.js';
8
13
  import { foldkitBuildToken } from './buildToken.js';
9
14
  import { devToolsOverlayPlugin } from './devToolsOverlay.js';
15
+ import { publishRelayRecord, retireRelayRecord } from './relayRegistry.js';
10
16
  import { foldkitSsr } from './ssr.js';
11
17
  import { foldkitViewIdentity } from './viewIdentity.js';
12
18
  export { brandDistDirectory } from './brandDist.js';
13
- export { FOLDKIT_FETCH_MODULE_ID, FoldkitBuildManifest, foldkitBuild, } from './build.js';
19
+ export { FOLDKIT_FETCH_MODULE_ID, FoldkitBuildManifest, FoldkitBuildMetadata, foldkitBuild, } from './build.js';
14
20
  export { foldkitSsr } from './ssr.js';
15
21
  export { foldkitViewIdentity, transformViewIdentity, } from './viewIdentity.js';
16
22
  // NOTE: Vite's dep optimizer scans the consumer's source for `effect`
@@ -249,18 +255,24 @@ const registerViteWsHandlers = (server, state, enqueue) => Effect.sync(() => {
249
255
  server.ws.on('foldkit:devTools:response', (data) => enqueue(Event.BrowserResponseFrameReceived({ data })));
250
256
  });
251
257
  // MCP RELAY
252
- // NOTE: Restarting a dev server briefly leaves two of them alive. Vite builds
253
- // the replacement, which binds its relay, before closing the server it
254
- // replaces, which still owns the port. The bind loses that race and has to
255
- // wait for the outgoing server to release the port, so it retries for four
256
- // seconds before reporting the port as taken.
258
+ // NOTE: Vite starts a replacement server before closing the old one. A
259
+ // configured relay port can stay occupied during the overlap, so binding
260
+ // retries for four seconds.
257
261
  const RELAY_BIND_RETRY_DELAY = Duration.millis(100);
258
262
  const RELAY_BIND_RETRY_COUNT = 40;
263
+ const RELAY_PATH = '/__foldkit/devtools-mcp';
264
+ const RELAY_LOOPBACK_HOST = '127.0.0.1';
265
+ const RELAY_CONFIGURED_PORT_HOST = 'localhost';
266
+ const RELAY_TOKEN_PARAMETER = 'token';
267
+ const RELAY_TOKEN_BYTES = 32;
259
268
  class RelayBindFailed extends Data.TaggedError('RelayBindFailed') {
260
269
  }
261
270
  const isPortInUse = (error) => Predicate.hasProperty(error, 'code') && error.code === 'EADDRINUSE';
262
- const bindMcpRelay = (port, enqueue) => Effect.callback(resume => {
263
- const wss = new WebSocketServer({ port });
271
+ const relayId = Effect.gen(function* () {
272
+ const crypto = yield* Crypto.Crypto;
273
+ return yield* crypto.randomUUIDv4.pipe(Effect.orDie);
274
+ });
275
+ const attachRelayHandlers = (wss, enqueue) => {
264
276
  wss.on('connection', client => {
265
277
  enqueue(Event.McpClientConnected({ client }));
266
278
  client.on('message', raw => enqueue(Event.McpRequestReceived({ client, raw: raw.toString() })));
@@ -269,45 +281,206 @@ const bindMcpRelay = (port, enqueue) => Effect.callback(resume => {
269
281
  console.error('[foldkit:devTools] MCP client error', error);
270
282
  });
271
283
  });
284
+ };
285
+ const parseRequestUrl = Option.liftThrowable((request) => new URL(request.url ?? '/', 'http://relay'));
286
+ const boundPort = (address) => address === null || Predicate.isString(address)
287
+ ? Option.none()
288
+ : Option.some(address.port);
289
+ const relayHasNoBoundPort = () => new Error('[foldkit:devTools] the MCP relay listener has no bound port');
290
+ const relayToken = Effect.gen(function* () {
291
+ const crypto = yield* Crypto.Crypto;
292
+ const bytes = yield* crypto.randomBytes(RELAY_TOKEN_BYTES).pipe(Effect.orDie);
293
+ return Buffer.from(bytes).toString('hex');
294
+ });
295
+ const relayUrlForLog = (url) => {
296
+ const parsed = new URL(url);
297
+ parsed.search = '';
298
+ return parsed.toString();
299
+ };
300
+ const withRelayToken = (url, token) => {
301
+ url.search = '';
302
+ url.searchParams.set(RELAY_TOKEN_PARAMETER, token);
303
+ return url.toString();
304
+ };
305
+ const requestPresentsToken = (url, token) => {
306
+ const maybePresented = Option.fromNullishOr(url.searchParams.get(RELAY_TOKEN_PARAMETER));
307
+ const expected = Buffer.from(token, 'utf8');
308
+ return Option.exists(maybePresented, presented => {
309
+ const actual = Buffer.from(presented, 'utf8');
310
+ return (expected.length === actual.length && timingSafeEqual(expected, actual));
311
+ });
312
+ };
313
+ const refuseUpgrade = (socket) => {
314
+ socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
315
+ socket.destroy();
316
+ };
317
+ // NOTE: Vite resolves `server.resolvedUrls` in a listener it prepends to
318
+ // `listening`, so they are set by the time the relay's own listener runs.
319
+ const hostedRelayUrl = (server, token) => (httpServer) => {
320
+ const maybeResolvedUrl = pipe(Option.fromNullishOr(server.resolvedUrls), Option.flatMap(resolvedUrls => Option.orElse(Array.head(resolvedUrls.local), () => Array.head(resolvedUrls.network))));
321
+ const url = new URL(pipe(maybeResolvedUrl, Option.orElse(() => Option.map(boundPort(httpServer.address()), port => `http://${RELAY_LOOPBACK_HOST}:${port}/`)), Option.getOrThrowWith(relayHasNoBoundPort)));
322
+ url.protocol = 'ws:';
323
+ url.pathname = RELAY_PATH;
324
+ return withRelayToken(url, token);
325
+ };
326
+ const loopbackRelayUrl = (token) => (httpServer) => {
327
+ const port = Option.getOrThrowWith(boundPort(httpServer.address()), relayHasNoBoundPort);
328
+ const url = new URL(`ws://${RELAY_LOOPBACK_HOST}:${port}${RELAY_PATH}`);
329
+ return withRelayToken(url, token);
330
+ };
331
+ const hostRelayOnServer = (httpServer, id, token, toUrl, enqueue) => Effect.callback(resume => {
332
+ const wss = new WebSocketServer({ noServer: true });
333
+ attachRelayHandlers(wss, enqueue);
334
+ const onUpgrade = (request, socket, head) => {
335
+ const maybeUrl = parseRequestUrl(request);
336
+ if (Option.isNone(maybeUrl)) {
337
+ socket.destroy();
338
+ return;
339
+ }
340
+ const url = maybeUrl.value;
341
+ if (url.pathname !== RELAY_PATH) {
342
+ return;
343
+ }
344
+ if (!requestPresentsToken(url, token)) {
345
+ refuseUpgrade(socket);
346
+ return;
347
+ }
348
+ wss.handleUpgrade(request, socket, head, client => {
349
+ wss.emit('connection', client, request);
350
+ });
351
+ };
352
+ httpServer.on('upgrade', onUpgrade);
353
+ const detach = () => {
354
+ httpServer.off('upgrade', onUpgrade);
355
+ httpServer.off('listening', onListening);
356
+ };
357
+ const onListening = () => {
358
+ resume(Effect.sync(() => ({ id, wss, url: toUrl(httpServer), detach })));
359
+ };
360
+ if (httpServer.listening) {
361
+ onListening();
362
+ }
363
+ else {
364
+ httpServer.once('listening', onListening);
365
+ }
366
+ return Effect.sync(detach);
367
+ });
368
+ const bindLoopbackHttpServer = Effect.callback(resume => {
369
+ const httpServer = createHttpServer();
370
+ const onBindFailed = (cause) => {
371
+ httpServer.close();
372
+ resume(Effect.fail(new RelayBindFailed({ maybePort: Option.none(), cause })));
373
+ };
374
+ httpServer.once('error', onBindFailed);
375
+ httpServer.listen(0, RELAY_LOOPBACK_HOST, () => {
376
+ httpServer.off('error', onBindFailed);
377
+ httpServer.on('error', error => {
378
+ console.error('[foldkit:devTools] MCP relay error', error);
379
+ });
380
+ resume(Effect.succeed(httpServer));
381
+ });
382
+ return Effect.sync(() => {
383
+ httpServer.close();
384
+ });
385
+ });
386
+ const bindLoopbackRelay = (id, token, enqueue) => Effect.gen(function* () {
387
+ const httpServer = yield* bindLoopbackHttpServer;
388
+ const relay = yield* hostRelayOnServer(httpServer, id, token, loopbackRelayUrl(token), enqueue);
389
+ return {
390
+ ...relay,
391
+ detach: () => {
392
+ relay.detach();
393
+ httpServer.close();
394
+ },
395
+ };
396
+ });
397
+ const bindStandaloneRelay = (port, id, enqueue) => Effect.callback(resume => {
398
+ const wss = new WebSocketServer({ port });
399
+ attachRelayHandlers(wss, enqueue);
272
400
  const onListening = () => {
273
401
  wss.off('error', onBindFailed);
274
402
  wss.on('error', error => {
275
403
  console.error('[foldkit:devTools] MCP relay error', error);
276
404
  });
277
- console.log(`[foldkit:devTools] MCP relay listening on ws://localhost:${port}`);
278
- resume(Effect.succeed(wss));
405
+ const listeningPort = Option.getOrThrowWith(boundPort(wss.address()), relayHasNoBoundPort);
406
+ resume(Effect.succeed({
407
+ id,
408
+ wss,
409
+ url: `ws://${RELAY_CONFIGURED_PORT_HOST}:${listeningPort}`,
410
+ detach: () => undefined,
411
+ }));
279
412
  };
280
413
  const onBindFailed = (cause) => {
281
414
  wss.off('listening', onListening);
282
415
  wss.close();
283
- resume(Effect.fail(new RelayBindFailed({ cause })));
416
+ resume(Effect.fail(new RelayBindFailed({ maybePort: Option.some(port), cause })));
284
417
  };
285
418
  wss.once('listening', onListening);
286
419
  wss.once('error', onBindFailed);
287
420
  });
288
- const reportRelayBindFailed = (port, cause) => {
289
- if (isPortInUse(cause)) {
290
- return Console.error(`\n[foldkit:devTools] Port ${port} is already in use, so the DevTools MCP relay could not start.\n` +
291
- `[foldkit:devTools] This usually means another Foldkit project is already running and bound to this port.\n` +
292
- `[foldkit:devTools] Until the port is freed, agents will not be able to connect to this app via the Foldkit DevTools MCP server.\n` +
293
- `[foldkit:devTools] Stop the other project, or set a different \`devToolsMcpPort\` in this project's vite config.\n` +
294
- `[foldkit:devTools] If you change \`devToolsMcpPort\`, also set \`FOLDKIT_DEVTOOLS_MCP_PORT\` to the same value for your MCP server.\n`);
421
+ const reportRelayBindFailed = (maybePort, cause) => {
422
+ const where = Option.match(maybePort, {
423
+ onNone: () => 'an assigned loopback port',
424
+ onSome: port => `port ${port}`,
425
+ });
426
+ if (Option.isSome(maybePort) && isPortInUse(cause)) {
427
+ const port = maybePort.value;
428
+ return Console.error(`\n[foldkit:devTools] Port ${port} is in use; the MCP relay did not start.\n` +
429
+ `[foldkit:devTools] Stop the process using that port, or remove \`devToolsMcpPort\` from your Vite config to use automatic discovery.\n` +
430
+ `[foldkit:devTools] If you choose another fixed port, set \`FOLDKIT_DEVTOOLS_MCP_PORT\` to match.\n`);
295
431
  }
296
432
  else {
297
- return Console.error(`[foldkit:devTools] MCP relay failed to start on port ${port}; continuing without the relay`, cause);
433
+ return Console.error(`[foldkit:devTools] MCP relay failed to start on ${where}; continuing without the relay`, cause);
298
434
  }
299
435
  };
300
- const startMcpRelay = (port, enqueue) => Effect.acquireRelease(bindMcpRelay(port, enqueue), wss => Effect.gen(function* () {
301
- for (const client of wss.clients) {
302
- client.terminate();
303
- }
304
- wss.close();
305
- yield* Console.log('[foldkit:devTools] MCP relay stopped');
306
- })).pipe(Effect.retry({
307
- while: ({ cause }) => isPortInUse(cause),
308
- times: RELAY_BIND_RETRY_COUNT,
309
- schedule: Schedule.spaced(RELAY_BIND_RETRY_DELAY),
310
- }), Effect.catchTag('RelayBindFailed', ({ cause }) => reportRelayBindFailed(port, cause)));
436
+ const unpublishedRelayMessage = (reason) => `[foldkit:devTools] Cannot publish the MCP relay address: ${reason}. Set matching devToolsMcpPort and FOLDKIT_DEVTOOLS_MCP_PORT values for MCP access, or set FOLDKIT_DEVTOOLS_RELAY_DIRECTORY to a private directory on a platform that verifies ownership.`;
437
+ const publishRelay = (root, relay) => Effect.gen(function* () {
438
+ const startedAt = yield* Clock.currentTimeMillis;
439
+ yield* publishRelayRecord({
440
+ version: RELAY_RECORD_VERSION,
441
+ id: relay.id,
442
+ root,
443
+ url: relay.url,
444
+ pid: process.pid,
445
+ startedAt,
446
+ });
447
+ }).pipe(Effect.catchTag('RelayRegistryDirectoryRefused', ({ directory, reason }) => Console.error(unpublishedRelayMessage(`the registry directory ${directory} ${reason}`))), Effect.catch(error => Console.error(unpublishedRelayMessage('the registry could not be written'), error)));
448
+ const startMcpRelay = (server, devToolsMcpPort, enqueue) => {
449
+ const root = server.config.root;
450
+ // NOTE: The MCP server cannot verify a development HTTPS certificate, so
451
+ // HTTPS uses a separate loopback listener.
452
+ const maybeHttpServer = Option.filter(Option.fromNullishOr(server.httpServer), () => server.config.server.https === undefined);
453
+ // NOTE: A configured port keeps the previous unauthenticated behavior.
454
+ // An assigned port binds only to loopback and is found through the registry.
455
+ const acquire = Effect.gen(function* () {
456
+ const id = yield* relayId;
457
+ if (devToolsMcpPort !== undefined) {
458
+ return yield* bindStandaloneRelay(devToolsMcpPort, id, enqueue);
459
+ }
460
+ const token = yield* relayToken;
461
+ return yield* Option.match(maybeHttpServer, {
462
+ onNone: () => bindLoopbackRelay(id, token, enqueue),
463
+ onSome: httpServer => hostRelayOnServer(httpServer, id, token, hostedRelayUrl(server, token), enqueue),
464
+ });
465
+ });
466
+ return Effect.acquireRelease(acquire.pipe(Effect.tap(relay => Console.log(`[foldkit:devTools] MCP relay listening at ${relayUrlForLog(relay.url)}`)), Effect.tap(relay => publishRelay(root, relay))), relay => Effect.gen(function* () {
467
+ relay.detach();
468
+ for (const client of relay.wss.clients) {
469
+ client.terminate();
470
+ }
471
+ relay.wss.close();
472
+ yield* retireRelayRecord(root, relay.id);
473
+ yield* Console.log('[foldkit:devTools] MCP relay stopped');
474
+ })).pipe(Effect.retry({
475
+ while: ({ cause }) => isPortInUse(cause),
476
+ times: RELAY_BIND_RETRY_COUNT,
477
+ schedule: Schedule.spaced(RELAY_BIND_RETRY_DELAY),
478
+ }), Effect.catchTag('RelayBindFailed', ({ maybePort, cause }) => reportRelayBindFailed(maybePort, cause)));
479
+ };
480
+ // NOTE: A Vitest run can override its mode, but still carries Vitest plugins.
481
+ // Starting a relay there can contend with the project's dev server.
482
+ const isTestRun = (server) => server.config.mode === 'test' ||
483
+ server.config.plugins.some(plugin => plugin.name === 'vitest' || plugin.name.startsWith('vitest:'));
311
484
  // PROGRAM
312
485
  const main = (server, events, options) => Effect.gen(function* () {
313
486
  const state = yield* makeState;
@@ -320,8 +493,8 @@ const main = (server, events, options) => Effect.gen(function* () {
320
493
  // gives up on its boot-time model request in well under a second, so
321
494
  // sequencing the dispatch loop behind the bind would cost model
322
495
  // preservation whenever the port is contended.
323
- if (options.devToolsMcpPort !== undefined) {
324
- yield* Effect.forkScoped(startMcpRelay(options.devToolsMcpPort, enqueue));
496
+ if (options.devToolsMcpPort !== false && !isTestRun(server)) {
497
+ yield* Effect.forkScoped(startMcpRelay(server, options.devToolsMcpPort, enqueue));
325
498
  }
326
499
  yield* Stream.fromQueue(events).pipe(Stream.runForEach(event => dispatchEvent(server, state, event)));
327
500
  });
@@ -354,21 +527,19 @@ const withContainerId = (build, containerId) => {
354
527
  prerender: { containerId, ...prerender },
355
528
  };
356
529
  };
530
+ const relayRegistryLayer = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer, NodeCrypto.layer);
357
531
  export const foldkit = (options = {}) => {
358
- const events = Effect.runSync(Queue.unbounded());
359
- // NOTE: One plugin instance can serve more than one dev server, and on
360
- // restart Vite builds the replacement, running `configureServer` again,
361
- // before closing the server being replaced. Keying by resolved config keeps
362
- // each server's shutdown pointed at its own fiber.
363
- const mainFibers = new WeakMap();
532
+ // NOTE: During a Vite restart, old and new servers overlap. Separate queues
533
+ // and fibers keep their events and shutdowns attached to the right server.
534
+ const mainRuns = new WeakMap();
364
535
  const stopMain = (config) => Effect.suspend(() => {
365
- const fiber = mainFibers.get(config);
366
- mainFibers.delete(config);
367
- if (fiber === undefined) {
536
+ const run = mainRuns.get(config);
537
+ mainRuns.delete(config);
538
+ if (run === undefined) {
368
539
  return Effect.void;
369
540
  }
370
541
  else {
371
- return Fiber.interrupt(fiber);
542
+ return Fiber.interrupt(run.fiber);
372
543
  }
373
544
  });
374
545
  const reloadPlugin = {
@@ -383,13 +554,14 @@ export const foldkit = (options = {}) => {
383
554
  },
384
555
  }),
385
556
  configureServer: server => {
386
- const fiber = Effect.runFork(Effect.scoped(main(server, events, options)));
387
- mainFibers.set(server.config, fiber);
557
+ const events = Effect.runSync(Queue.unbounded());
558
+ // NOTE: The default ConfigProvider snapshots the environment. Create a
559
+ // fresh one so a restarted server sees current values.
560
+ const fiber = Effect.runFork(Effect.scoped(main(server, events, options)).pipe(Effect.provide(relayRegistryLayer), Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnv())));
561
+ mainRuns.set(server.config, { events, fiber });
388
562
  },
389
- // NOTE: Vite awaits `closeBundle` when the dev server closes, once per
390
- // environment plugin container. Hanging shutdown off `server.httpServer`
391
- // instead would never run in middleware mode, which is how Vitest and
392
- // other embedders run Vite, and the relay would outlive the server.
563
+ // NOTE: Middleware mode has no HTTP server to close. Vite still calls
564
+ // `closeBundle`, so relay cleanup belongs here.
393
565
  closeBundle() {
394
566
  return Effect.runPromise(stopMain(this.environment.getTopLevelConfig()));
395
567
  },
@@ -398,7 +570,10 @@ export const foldkit = (options = {}) => {
398
570
  return;
399
571
  }
400
572
  server.ws.send({ type: 'full-reload' });
401
- Queue.offerUnsafe(events, Event.HotUpdateFired());
573
+ const run = mainRuns.get(server.config);
574
+ if (run !== undefined) {
575
+ Queue.offerUnsafe(run.events, Event.HotUpdateFired());
576
+ }
402
577
  return [];
403
578
  },
404
579
  };
@@ -0,0 +1,17 @@
1
+ import { Crypto, Effect, FileSystem, Option, Path, type PlatformError } from 'effect';
2
+ import { RelayRecord } from 'foldkit/devtools-protocol';
3
+ export type RelayPublisherServices = FileSystem.FileSystem | Path.Path | Crypto.Crypto;
4
+ declare const RelayRegistryDirectoryRefused_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
5
+ readonly _tag: "RelayRegistryDirectoryRefused";
6
+ } & Readonly<A>;
7
+ export declare class RelayRegistryDirectoryRefused extends RelayRegistryDirectoryRefused_base<{
8
+ readonly directory: string;
9
+ readonly reason: string;
10
+ }> {
11
+ }
12
+ export declare const relayRegistryDirectoryRefusal: (info: FileSystem.File.Info, maybeCurrentUid: Option.Option<number>) => Option.Option<string>;
13
+ export declare const publishRelayRecord: (record: RelayRecord) => Effect.Effect<void, PlatformError.PlatformError | RelayRegistryDirectoryRefused, RelayPublisherServices>;
14
+ export declare const readRelayRecord: (root: string) => Effect.Effect<Option.Option<RelayRecord>, never, RelayPublisherServices>;
15
+ export declare const retireRelayRecord: (root: string, id: string) => Effect.Effect<void, never, RelayPublisherServices>;
16
+ export {};
17
+ //# sourceMappingURL=relayRegistry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"relayRegistry.d.ts","sourceRoot":"","sources":["../src/relayRegistry.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,MAAM,EAEN,MAAM,EAEN,UAAU,EACV,MAAM,EACN,IAAI,EACJ,KAAK,aAAa,EAEnB,MAAM,QAAQ,CAAA;AACf,OAAO,EAGL,WAAW,EACZ,MAAM,2BAA2B,CAAA;AAYlC,MAAM,MAAM,sBAAsB,GAC9B,UAAU,CAAC,UAAU,GACrB,IAAI,CAAC,IAAI,GACT,MAAM,CAAC,MAAM,CAAA;;;;AAEjB,qBAAa,6BAA8B,SAAQ,mCAEjD;IACA,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACxB,CAAC;CAAG;AA0CL,eAAO,MAAM,6BAA6B,SAClC,UAAU,CAAC,IAAI,CAAC,IAAI,mBACT,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,KACrC,MAAM,CAAC,MAAM,CAAC,MAAM,CAkBnB,CAAA;AAiCJ,eAAO,MAAM,kBAAkB,WACrB,WAAW,KAClB,MAAM,CAAC,MAAM,CACd,IAAI,EACJ,aAAa,CAAC,aAAa,GAAG,6BAA6B,EAC3D,sBAAsB,CAapB,CAAA;AAWJ,eAAO,MAAM,eAAe,SACpB,MAAM,KACX,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,WAAW,CAAC,EAAE,KAAK,EAAE,sBAAsB,CAChB,CAAA;AAE1D,eAAO,MAAM,iBAAiB,SACtB,MAAM,MACR,MAAM,KACT,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,KAAK,EAAE,sBAAsB,CAkChD,CAAA"}
@@ -0,0 +1,101 @@
1
+ import { Config, Crypto, Data, Effect, Exit, FileSystem, Option, Path, Schema, } from 'effect';
2
+ import { RELAY_REGISTRY_DIRECTORY_NAME, RELAY_REGISTRY_DIRECTORY_VARIABLE, RelayRecord, } from 'foldkit/devtools-protocol';
3
+ import { tmpdir } from 'node:os';
4
+ const RUNTIME_DIRECTORY_VARIABLE = 'XDG_RUNTIME_DIR';
5
+ const RECORD_FILE_EXTENSION = '.json';
6
+ const REGISTRY_DIRECTORY_MODE = 0o700;
7
+ const RECORD_FILE_MODE = 0o600;
8
+ const PERMISSIONS_BEYOND_OWNER = 0o077;
9
+ const PENDING_RECORD_SUFFIX = '.pending';
10
+ const RETIRING_RECORD_SUFFIX = '.retiring';
11
+ export class RelayRegistryDirectoryRefused extends Data.TaggedError('RelayRegistryDirectoryRefused') {
12
+ }
13
+ const decodeRelayRecord = Schema.decodeUnknownOption(Schema.fromJsonString(RelayRecord));
14
+ const encodeRelayRecord = Schema.encodeUnknownSync(Schema.fromJsonString(RelayRecord));
15
+ const relayRegistryDirectory = Effect.gen(function* () {
16
+ const path = yield* Path.Path;
17
+ const maybeConfigured = yield* Config.option(Config.String(RELAY_REGISTRY_DIRECTORY_VARIABLE));
18
+ const maybeRuntimeDirectory = yield* Config.option(Config.String(RUNTIME_DIRECTORY_VARIABLE));
19
+ return Option.getOrElse(maybeConfigured, () => path.join(Option.getOrElse(maybeRuntimeDirectory, tmpdir), RELAY_REGISTRY_DIRECTORY_NAME));
20
+ }).pipe(Effect.orDie);
21
+ const relayRecordPath = (root) => Effect.gen(function* () {
22
+ const path = yield* Path.Path;
23
+ const crypto = yield* Crypto.Crypto;
24
+ const directory = yield* relayRegistryDirectory;
25
+ const digest = yield* crypto
26
+ .digest('SHA-1', new TextEncoder().encode(root))
27
+ .pipe(Effect.orDie);
28
+ return path.join(directory, `${Buffer.from(digest).toString('hex')}${RECORD_FILE_EXTENSION}`);
29
+ });
30
+ export const relayRegistryDirectoryRefusal = (info, maybeCurrentUid) => Option.match(maybeCurrentUid, {
31
+ onNone: () => Option.some('has ownership that cannot be verified'),
32
+ onSome: currentUid => {
33
+ if (Option.isNone(info.uid)) {
34
+ return Option.some('has ownership that cannot be verified');
35
+ }
36
+ if (info.uid.value !== currentUid) {
37
+ return Option.some('is owned by another user');
38
+ }
39
+ if ((info.mode & PERMISSIONS_BEYOND_OWNER) !== 0) {
40
+ return Option.some('is readable or writable by other users');
41
+ }
42
+ return Option.none();
43
+ },
44
+ });
45
+ const maybeProcessUid = Option.map(Option.fromNullishOr(process.getuid), getuid => getuid());
46
+ const ensurePrivateRegistryDirectory = (directory) => Effect.gen(function* () {
47
+ const fileSystem = yield* FileSystem.FileSystem;
48
+ yield* fileSystem.makeDirectory(directory, {
49
+ recursive: true,
50
+ mode: REGISTRY_DIRECTORY_MODE,
51
+ });
52
+ const info = yield* fileSystem.stat(directory);
53
+ const maybeRefusal = relayRegistryDirectoryRefusal(info, maybeProcessUid);
54
+ if (Option.isSome(maybeRefusal)) {
55
+ return yield* Effect.fail(new RelayRegistryDirectoryRefused({
56
+ directory,
57
+ reason: maybeRefusal.value,
58
+ }));
59
+ }
60
+ });
61
+ export const publishRelayRecord = (record) => Effect.gen(function* () {
62
+ const fileSystem = yield* FileSystem.FileSystem;
63
+ const directory = yield* relayRegistryDirectory;
64
+ yield* ensurePrivateRegistryDirectory(directory);
65
+ const recordPath = yield* relayRecordPath(record.root);
66
+ const pendingPath = `${recordPath}.${record.id}${PENDING_RECORD_SUFFIX}`;
67
+ yield* fileSystem.writeFileString(pendingPath, encodeRelayRecord(record), {
68
+ mode: RECORD_FILE_MODE,
69
+ });
70
+ yield* fileSystem.rename(pendingPath, recordPath);
71
+ });
72
+ const readRelayRecordAt = (recordPath) => Effect.gen(function* () {
73
+ const fileSystem = yield* FileSystem.FileSystem;
74
+ const raw = yield* fileSystem.readFileString(recordPath);
75
+ return decodeRelayRecord(raw);
76
+ }).pipe(Effect.orElseSucceed(() => Option.none()));
77
+ export const readRelayRecord = (root) => Effect.flatMap(relayRecordPath(root), readRelayRecordAt);
78
+ export const retireRelayRecord = (root, id) => Effect.gen(function* () {
79
+ const fileSystem = yield* FileSystem.FileSystem;
80
+ const recordPath = yield* relayRecordPath(root);
81
+ const retiringPath = `${recordPath}.${id}${RETIRING_RECORD_SUFFIX}`;
82
+ const maybeRecord = yield* readRelayRecordAt(recordPath);
83
+ const isPublishedByRelay = Option.exists(maybeRecord, record => record.id === id);
84
+ if (!isPublishedByRelay) {
85
+ return;
86
+ }
87
+ // NOTE: A replacement may publish between the first read and rename. A
88
+ // hard link restores its record only if no newer record occupies the path.
89
+ const renameResult = yield* fileSystem
90
+ .rename(recordPath, retiringPath)
91
+ .pipe(Effect.exit);
92
+ if (Exit.isFailure(renameResult)) {
93
+ return;
94
+ }
95
+ const maybeRetiringRecord = yield* readRelayRecordAt(retiringPath);
96
+ const isReplacement = Option.exists(maybeRetiringRecord, record => record.id !== id);
97
+ if (isReplacement) {
98
+ yield* fileSystem.link(retiringPath, recordPath).pipe(Effect.ignore);
99
+ }
100
+ yield* fileSystem.remove(retiringPath, { force: true }).pipe(Effect.ignore);
101
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldkit/vite-plugin",
3
- "version": "0.24.0-canary.e072cf5b439b",
3
+ "version": "0.24.0-canary.eb11872b6977",
4
4
  "description": "Vite plugin for Foldkit with state-preserving live reload",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,16 +17,18 @@
17
17
  ],
18
18
  "peerDependencies": {
19
19
  "effect": "4.0.0-rc.116",
20
- "foldkit": "0.163.0-canary.e072cf5b439b",
20
+ "foldkit": "0.163.0-canary.eb11872b6977",
21
21
  "vite": "^8.0.0"
22
22
  },
23
23
  "dependencies": {
24
+ "@effect/platform-node": "4.0.0-rc.116",
24
25
  "magic-string": "^1.2.3",
25
26
  "ws": "^8.21.3"
26
27
  },
27
28
  "devDependencies": {
28
29
  "@types/node": "^26.4.0",
29
30
  "@types/ws": "^8.18.1",
31
+ "@vitejs/plugin-basic-ssl": "^2.3.0",
30
32
  "effect": "4.0.0-rc.116",
31
33
  "happy-dom": "^20.11.13",
32
34
  "rimraf": "^6.1.3",
@@ -34,7 +36,7 @@
34
36
  "vite": "^8.2.2",
35
37
  "vite-host": "npm:vite@8.2.1",
36
38
  "vitest": "^4.1.11",
37
- "foldkit": "0.163.0-canary.e072cf5b439b"
39
+ "foldkit": "0.163.0-canary.eb11872b6977"
38
40
  },
39
41
  "keywords": [
40
42
  "vite",