@foldkit/vite-plugin 0.23.0 → 0.24.0-canary.63949f4e9660
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 +12 -2
- package/dist/build.d.ts +15 -33
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +31 -39
- package/dist/index.d.ts +8 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +225 -50
- package/dist/relayRegistry.d.ts +17 -0
- package/dist/relayRegistry.d.ts.map +1 -0
- package/dist/relayRegistry.js +101 -0
- package/package.json +7 -5
package/README.md
CHANGED
|
@@ -110,13 +110,23 @@ To include the overlay in production, list `@foldkit/devtools` in regular `depen
|
|
|
110
110
|
|
|
111
111
|
## DevTools MCP relay
|
|
112
112
|
|
|
113
|
-
|
|
113
|
+
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.
|
|
114
|
+
|
|
115
|
+
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.
|
|
116
|
+
|
|
117
|
+
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.
|
|
118
|
+
|
|
119
|
+
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.
|
|
120
|
+
|
|
121
|
+
To use a fixed port, set `devToolsMcpPort` in your Vite config:
|
|
114
122
|
|
|
115
123
|
```typescript
|
|
116
124
|
plugins: [foldkit({ devToolsMcpPort: 9988 })]
|
|
117
125
|
```
|
|
118
126
|
|
|
119
|
-
|
|
127
|
+
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.
|
|
128
|
+
|
|
129
|
+
`devToolsMcpPort: false` disables the relay. The relay does not start during Vitest runs or in production builds.
|
|
120
130
|
|
|
121
131
|
See the [DevTools MCP documentation](https://foldkit.dev/ai/mcp) for setup, the available tools, and how dispatch validation works.
|
|
122
132
|
|
package/dist/build.d.ts
CHANGED
|
@@ -9,9 +9,10 @@ export type FoldkitPrerenderOptions = Readonly<{
|
|
|
9
9
|
*/
|
|
10
10
|
paths?: ReadonlyArray<string>;
|
|
11
11
|
/**
|
|
12
|
-
* The origin
|
|
13
|
-
* `'https://app.example'`.
|
|
14
|
-
*
|
|
12
|
+
* The origin used for `Request.url` while generating, such as
|
|
13
|
+
* `'https://app.example'`. This option does not set canonical or Open Graph
|
|
14
|
+
* metadata. It affects those fields only when the server entry derives them
|
|
15
|
+
* from `Request.url`, in which case it should match the published origin.
|
|
15
16
|
*/
|
|
16
17
|
origin?: string;
|
|
17
18
|
/**
|
|
@@ -42,15 +43,11 @@ export type FoldkitBuildOptions = Readonly<{
|
|
|
42
43
|
/** Vite module id of the fetch handler Foldkit emits as the server entry. */
|
|
43
44
|
export declare const FOLDKIT_FETCH_MODULE_ID = "virtual:foldkit/fetch";
|
|
44
45
|
/**
|
|
45
|
-
* What
|
|
46
|
-
* deploys it.
|
|
46
|
+
* What an `ssr.build` build produced, written beside the server bundle.
|
|
47
47
|
*
|
|
48
|
-
*
|
|
49
|
-
* file
|
|
50
|
-
*
|
|
51
|
-
* one with a server wants a miss to reach it, and one with neither wants the
|
|
52
|
-
* template. Reading it here is how a deployment target gets that right without
|
|
53
|
-
* asking its user to configure it twice.
|
|
48
|
+
* An SSR host serves generated paths as files and sends requests without a
|
|
49
|
+
* matching file to the server entry. A static-only SSG host serves the files
|
|
50
|
+
* and leaves other paths as misses.
|
|
54
51
|
*/
|
|
55
52
|
export declare const FoldkitBuildManifest: Schema.Struct<{
|
|
56
53
|
/**
|
|
@@ -71,26 +68,12 @@ export declare const FoldkitBuildManifest: Schema.Struct<{
|
|
|
71
68
|
readonly serverEntry: Schema.String;
|
|
72
69
|
/** Every path this build generated a page for, in the order it generated. */
|
|
73
70
|
readonly prerendered: Schema.$Array<Schema.String>;
|
|
74
|
-
/**
|
|
75
|
-
* How a request-time host should run the server entry. Always `'fetch'`:
|
|
76
|
-
* the entry is a Web `fetch` handler, not a Node process.
|
|
77
|
-
*/
|
|
78
|
-
readonly host: Schema.optional<Schema.Literals<readonly ["fetch"]>>;
|
|
79
71
|
}>;
|
|
80
72
|
/**
|
|
81
|
-
*
|
|
82
|
-
* deploys it.
|
|
83
|
-
*
|
|
84
|
-
* A host has to decide what the asset layer does with a request that matches no
|
|
85
|
-
* file, and that answer follows from the build rather than from taste: an
|
|
86
|
-
* application with generated pages and no others wants a miss to stay a miss,
|
|
87
|
-
* one with a server wants a miss to reach it, and one with neither wants the
|
|
88
|
-
* template. Reading it here is how a deployment target gets that right without
|
|
89
|
-
* asking its user to configure it twice.
|
|
73
|
+
* The decoded shape of `foldkit.build.json`.
|
|
90
74
|
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* the shape it happens to find.
|
|
75
|
+
* A deployment host decodes the manifest before using it. The Schema rejects
|
|
76
|
+
* unknown versions rather than letting the host read missing fields.
|
|
94
77
|
*/
|
|
95
78
|
export type FoldkitBuildManifest = typeof FoldkitBuildManifest.Type;
|
|
96
79
|
export declare const manifestPath: (root: string, directory: string, pathApi?: typeof nodePath) => string;
|
|
@@ -102,11 +85,10 @@ export declare const renderTargetFor: (clientDirectory: string, path: string, or
|
|
|
102
85
|
* Builds a Web `fetch` handler alongside the browser build, and generates
|
|
103
86
|
* static HTML from the server entry, inside one `vite build`.
|
|
104
87
|
*
|
|
105
|
-
* Vite
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* same pages. The server bundle's default export is `{ fetch }`.
|
|
88
|
+
* Vite builds both environments, so a deployment target that runs `vite build`
|
|
89
|
+
* gets the browser and server bundles. The `fetch` handler and generated pages
|
|
90
|
+
* use the HTML emitted by the browser build, but the unrendered template is not
|
|
91
|
+
* published with the assets. The server bundle's default export is `{ fetch }`.
|
|
110
92
|
*/
|
|
111
93
|
export declare const foldkitBuild: (serverEntry: string, options?: FoldkitBuildOptions) => Plugin;
|
|
112
94
|
//# sourceMappingURL=build.d.ts.map
|
package/dist/build.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../src/build.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
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"}
|
package/dist/build.js
CHANGED
|
@@ -1,19 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Schema } from 'effect';
|
|
2
2
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
3
|
import nodePath, { dirname, resolve } from 'node:path';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
5
|
/** Vite module id of the fetch handler Foldkit emits as the server entry. */
|
|
6
6
|
export const FOLDKIT_FETCH_MODULE_ID = 'virtual:foldkit/fetch';
|
|
7
7
|
/**
|
|
8
|
-
* What
|
|
9
|
-
* deploys it.
|
|
8
|
+
* What an `ssr.build` build produced, written beside the server bundle.
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
* file
|
|
13
|
-
*
|
|
14
|
-
* one with a server wants a miss to reach it, and one with neither wants the
|
|
15
|
-
* template. Reading it here is how a deployment target gets that right without
|
|
16
|
-
* asking its user to configure it twice.
|
|
10
|
+
* An SSR host serves generated paths as files and sends requests without a
|
|
11
|
+
* matching file to the server entry. A static-only SSG host serves the files
|
|
12
|
+
* and leaves other paths as misses.
|
|
17
13
|
*/
|
|
18
14
|
export const FoldkitBuildManifest = Schema.Struct({
|
|
19
15
|
/**
|
|
@@ -34,11 +30,6 @@ export const FoldkitBuildManifest = Schema.Struct({
|
|
|
34
30
|
serverEntry: Schema.String,
|
|
35
31
|
/** Every path this build generated a page for, in the order it generated. */
|
|
36
32
|
prerendered: Schema.Array(Schema.String),
|
|
37
|
-
/**
|
|
38
|
-
* How a request-time host should run the server entry. Always `'fetch'`:
|
|
39
|
-
* the entry is a Web `fetch` handler, not a Node process.
|
|
40
|
-
*/
|
|
41
|
-
host: Schema.optional(Schema.Literals(['fetch'])),
|
|
42
33
|
});
|
|
43
34
|
const MANIFEST_SCHEMA_VERSION = 1;
|
|
44
35
|
// Relative and POSIX so the manifest describes a layout rather than this
|
|
@@ -59,6 +50,7 @@ export const manifestPath = (root, directory, pathApi = nodePath) => {
|
|
|
59
50
|
return related.split(pathApi.sep).join('/');
|
|
60
51
|
};
|
|
61
52
|
const MANIFEST_FILE_NAME = 'foldkit.build.json';
|
|
53
|
+
const TEMPLATE_FILE_NAME = 'index.html';
|
|
62
54
|
const DEFAULT_CLIENT_OUT_DIR = 'dist/client';
|
|
63
55
|
const DEFAULT_SERVER_OUT_DIR = 'dist/server';
|
|
64
56
|
const DEFAULT_PRERENDER_ORIGIN = 'http://localhost';
|
|
@@ -212,7 +204,7 @@ const fetchModuleSource = (serverEntry, template, containerId) => {
|
|
|
212
204
|
// page which cannot hydrate, from a build that reported success.
|
|
213
205
|
const templateForFetchModule = (capturedTemplate) => {
|
|
214
206
|
if (capturedTemplate === undefined) {
|
|
215
|
-
throw new Error(
|
|
207
|
+
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.`);
|
|
216
208
|
}
|
|
217
209
|
return capturedTemplate;
|
|
218
210
|
};
|
|
@@ -220,11 +212,10 @@ const templateForFetchModule = (capturedTemplate) => {
|
|
|
220
212
|
* Builds a Web `fetch` handler alongside the browser build, and generates
|
|
221
213
|
* static HTML from the server entry, inside one `vite build`.
|
|
222
214
|
*
|
|
223
|
-
* Vite
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
* same pages. The server bundle's default export is `{ fetch }`.
|
|
215
|
+
* Vite builds both environments, so a deployment target that runs `vite build`
|
|
216
|
+
* gets the browser and server bundles. The `fetch` handler and generated pages
|
|
217
|
+
* use the HTML emitted by the browser build, but the unrendered template is not
|
|
218
|
+
* published with the assets. The server bundle's default export is `{ fetch }`.
|
|
228
219
|
*/
|
|
229
220
|
export const foldkitBuild = (serverEntry, options = {}) => {
|
|
230
221
|
const clientOutDir = options.clientOutDir ?? DEFAULT_CLIENT_OUT_DIR;
|
|
@@ -274,7 +265,6 @@ export const foldkitBuild = (serverEntry, options = {}) => {
|
|
|
274
265
|
server: manifestPath(builder.config.root, serverOutDir),
|
|
275
266
|
serverEntry: entryFileName,
|
|
276
267
|
prerendered,
|
|
277
|
-
host: 'fetch',
|
|
278
268
|
});
|
|
279
269
|
await writeFile(resolve(serverDirectory, MANIFEST_FILE_NAME), `${JSON.stringify(manifest, undefined, 2)}\n`);
|
|
280
270
|
builder.config.logger.info(` wrote ${MANIFEST_FILE_NAME}`);
|
|
@@ -307,7 +297,7 @@ export const foldkitBuild = (serverEntry, options = {}) => {
|
|
|
307
297
|
// nothing has no use for an HTML entry and must not require one.
|
|
308
298
|
const template = () => {
|
|
309
299
|
if (state.template === undefined) {
|
|
310
|
-
throw new Error(
|
|
300
|
+
throw new Error(`[foldkit] the browser build emitted no ${TEMPLATE_FILE_NAME} to generate pages from. Prerendering needs an HTML entry.`);
|
|
311
301
|
}
|
|
312
302
|
return state.template;
|
|
313
303
|
};
|
|
@@ -318,7 +308,6 @@ export const foldkitBuild = (serverEntry, options = {}) => {
|
|
|
318
308
|
name: 'foldkit:build',
|
|
319
309
|
apply: 'build',
|
|
320
310
|
api: {
|
|
321
|
-
host: 'fetch',
|
|
322
311
|
serverEntry,
|
|
323
312
|
fetchModuleId: FOLDKIT_FETCH_MODULE_ID,
|
|
324
313
|
},
|
|
@@ -336,23 +325,26 @@ export const foldkitBuild = (serverEntry, options = {}) => {
|
|
|
336
325
|
const template = templateForFetchModule(state.template);
|
|
337
326
|
return fetchModuleSource(serverEntry, template, containerId);
|
|
338
327
|
},
|
|
339
|
-
// NOTE: `
|
|
340
|
-
//
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
if (html._tag === 'Some' && html.value.type === 'asset') {
|
|
349
|
-
state.template = String(html.value.source);
|
|
328
|
+
// NOTE: `order: 'post'` because Vite's own HTML plugin emits `index.html`
|
|
329
|
+
// from a `generateBundle` of its own; post is guaranteed to run after it.
|
|
330
|
+
generateBundle: {
|
|
331
|
+
order: 'post',
|
|
332
|
+
handler(_options, bundle) {
|
|
333
|
+
const state = captured(this.environment.config.root);
|
|
334
|
+
if (this.environment.name === 'ssr') {
|
|
335
|
+
state.serverEntryFile = serverEntryFile(Object.values(bundle), FETCH_CHUNK_NAME);
|
|
336
|
+
return;
|
|
350
337
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
338
|
+
if (this.environment.name !== 'client') {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const html = bundle[TEMPLATE_FILE_NAME];
|
|
342
|
+
if (html === undefined || html.type !== 'asset') {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
state.template = String(html.source);
|
|
346
|
+
delete bundle[TEMPLATE_FILE_NAME];
|
|
347
|
+
},
|
|
356
348
|
},
|
|
357
349
|
config: userConfig => {
|
|
358
350
|
const client = {
|
package/dist/index.d.ts
CHANGED
|
@@ -8,12 +8,15 @@ export { type ViewIdentityTransformResult, foldkitViewIdentity, transformViewIde
|
|
|
8
8
|
/** Options for the `foldkit` Vite plugin. */
|
|
9
9
|
export type FoldkitPluginOptions = Readonly<{
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
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,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,12 +1,18 @@
|
|
|
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';
|
|
@@ -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:
|
|
253
|
-
//
|
|
254
|
-
//
|
|
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
|
|
263
|
-
const
|
|
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
|
-
|
|
278
|
-
resume(Effect.succeed(
|
|
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 = (
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
|
433
|
+
return Console.error(`[foldkit:devTools] MCP relay failed to start on ${where}; continuing without the relay`, cause);
|
|
298
434
|
}
|
|
299
435
|
};
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
})
|
|
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 !==
|
|
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
|
-
|
|
359
|
-
//
|
|
360
|
-
|
|
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
|
|
366
|
-
|
|
367
|
-
if (
|
|
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
|
|
387
|
-
|
|
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:
|
|
390
|
-
//
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.24.0-canary.63949f4e9660",
|
|
4
4
|
"description": "Vite plugin for Foldkit with state-preserving live reload",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -16,25 +16,27 @@
|
|
|
16
16
|
"dist"
|
|
17
17
|
],
|
|
18
18
|
"peerDependencies": {
|
|
19
|
-
"effect": "4.0.0-rc.
|
|
20
|
-
"foldkit": "
|
|
19
|
+
"effect": "4.0.0-rc.116",
|
|
20
|
+
"foldkit": "0.163.0-canary.63949f4e9660",
|
|
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",
|
|
30
|
-
"
|
|
31
|
+
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
|
32
|
+
"effect": "4.0.0-rc.116",
|
|
31
33
|
"happy-dom": "^20.11.13",
|
|
32
34
|
"rimraf": "^6.1.3",
|
|
33
35
|
"typescript": "^7.0.2",
|
|
34
36
|
"vite": "^8.2.2",
|
|
35
37
|
"vite-host": "npm:vite@8.2.1",
|
|
36
38
|
"vitest": "^4.1.11",
|
|
37
|
-
"foldkit": "0.
|
|
39
|
+
"foldkit": "0.163.0-canary.63949f4e9660"
|
|
38
40
|
},
|
|
39
41
|
"keywords": [
|
|
40
42
|
"vite",
|