@foldkit/vite-plugin 0.24.0 → 0.25.0-canary.9f851d747bdd
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 +70 -17
- package/dist/build.d.ts +48 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +66 -58
- package/dist/buildToken.d.ts +6 -14
- package/dist/buildToken.d.ts.map +1 -1
- package/dist/buildToken.js +153 -64
- package/dist/foldkitPackages.d.ts +12 -0
- package/dist/foldkitPackages.d.ts.map +1 -0
- package/dist/foldkitPackages.js +37 -0
- package/dist/index.d.ts +22 -23
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +254 -89
- package/dist/relayRegistry.d.ts +17 -0
- package/dist/relayRegistry.d.ts.map +1 -0
- package/dist/relayRegistry.js +101 -0
- package/package.json +12 -9
package/dist/index.js
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
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 {
|
|
5
|
-
import {
|
|
4
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
5
|
+
import { createServer as createHttpServer, } from 'node:http';
|
|
6
6
|
import { WebSocketServer } from 'ws';
|
|
7
|
+
import * as NodeCrypto from '@effect/platform-node/NodeCrypto';
|
|
8
|
+
import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem';
|
|
9
|
+
import * as NodePath from '@effect/platform-node/NodePath';
|
|
7
10
|
import { foldkitBuild } from './build.js';
|
|
8
11
|
import { foldkitBuildToken } from './buildToken.js';
|
|
9
12
|
import { devToolsOverlayPlugin } from './devToolsOverlay.js';
|
|
13
|
+
import { resolveInstalledFoldkitPackages } from './foldkitPackages.js';
|
|
14
|
+
import { publishRelayRecord, retireRelayRecord } from './relayRegistry.js';
|
|
10
15
|
import { foldkitSsr } from './ssr.js';
|
|
11
16
|
import { foldkitViewIdentity } from './viewIdentity.js';
|
|
12
17
|
export { brandDistDirectory } from './brandDist.js';
|
|
13
|
-
export { FOLDKIT_FETCH_MODULE_ID, FoldkitBuildManifest, foldkitBuild, } from './build.js';
|
|
18
|
+
export { FOLDKIT_FETCH_MODULE_ID, FoldkitBuildManifest, FoldkitBuildMetadata, foldkitBuild, } from './build.js';
|
|
14
19
|
export { foldkitSsr } from './ssr.js';
|
|
15
20
|
export { foldkitViewIdentity, transformViewIdentity, } from './viewIdentity.js';
|
|
16
21
|
// NOTE: Vite's dep optimizer scans the consumer's source for `effect`
|
|
@@ -65,37 +70,6 @@ const FORCE_INCLUDED_EFFECT_NAMESPACES = [
|
|
|
65
70
|
'effect/SubscriptionRef',
|
|
66
71
|
'effect/Types',
|
|
67
72
|
];
|
|
68
|
-
// NOTE: a duplicate `foldkit` instance is its own hazard. If a bundler
|
|
69
|
-
// resolves `foldkit` (or a foldkit-consuming package like `@foldkit/ui`) to
|
|
70
|
-
// more than one copy, the copies get distinct Schema and tagged-message
|
|
71
|
-
// identities (decode and tag matching fail across the boundary) and separate
|
|
72
|
-
// module-level singleton state. `resolve.dedupe` (below) collapses every
|
|
73
|
-
// installed Foldkit package to one resolved copy.
|
|
74
|
-
const FOLDKIT_SINGLETON_PACKAGES = [
|
|
75
|
-
'foldkit',
|
|
76
|
-
'@foldkit/ui',
|
|
77
|
-
'@foldkit/devtools',
|
|
78
|
-
];
|
|
79
|
-
// NOTE: `@foldkit/ui` and `@foldkit/devtools` are optional, so dedupe only
|
|
80
|
-
// the ones the consumer installed. An installed ESM package resolves to
|
|
81
|
-
// ERR_PACKAGE_PATH_NOT_EXPORTED rather than succeeding, so a missing package
|
|
82
|
-
// is signalled only by MODULE_NOT_FOUND.
|
|
83
|
-
const resolveInstalledFoldkitPackages = (root) => {
|
|
84
|
-
// NOTE: `root` (Vite's `config.root`) can be relative at config-hook time,
|
|
85
|
-
// and createRequire requires an absolute path; `resolve` normalizes it.
|
|
86
|
-
const requireFromRoot = createRequire(resolve(root, 'noop.js'));
|
|
87
|
-
return Array.filter(FOLDKIT_SINGLETON_PACKAGES, packageName => {
|
|
88
|
-
try {
|
|
89
|
-
requireFromRoot.resolve(packageName);
|
|
90
|
-
return true;
|
|
91
|
-
}
|
|
92
|
-
catch (error) {
|
|
93
|
-
return !(error instanceof Error &&
|
|
94
|
-
Predicate.hasProperty(error, 'code') &&
|
|
95
|
-
error.code === 'MODULE_NOT_FOUND');
|
|
96
|
-
}
|
|
97
|
-
});
|
|
98
|
-
};
|
|
99
73
|
const Event = Data.taggedEnum();
|
|
100
74
|
const makeState = Effect.gen(function* () {
|
|
101
75
|
const preservedModels = yield* Ref.make(HashMap.empty());
|
|
@@ -249,18 +223,24 @@ const registerViteWsHandlers = (server, state, enqueue) => Effect.sync(() => {
|
|
|
249
223
|
server.ws.on('foldkit:devTools:response', (data) => enqueue(Event.BrowserResponseFrameReceived({ data })));
|
|
250
224
|
});
|
|
251
225
|
// 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.
|
|
226
|
+
// NOTE: Vite starts a replacement server before closing the old one. A
|
|
227
|
+
// configured relay port can stay occupied during the overlap, so binding
|
|
228
|
+
// retries for four seconds.
|
|
257
229
|
const RELAY_BIND_RETRY_DELAY = Duration.millis(100);
|
|
258
230
|
const RELAY_BIND_RETRY_COUNT = 40;
|
|
231
|
+
const RELAY_PATH = '/__foldkit/devtools-mcp';
|
|
232
|
+
const RELAY_LOOPBACK_HOST = '127.0.0.1';
|
|
233
|
+
const RELAY_CONFIGURED_PORT_HOST = 'localhost';
|
|
234
|
+
const RELAY_TOKEN_PARAMETER = 'token';
|
|
235
|
+
const RELAY_TOKEN_BYTES = 32;
|
|
259
236
|
class RelayBindFailed extends Data.TaggedError('RelayBindFailed') {
|
|
260
237
|
}
|
|
261
238
|
const isPortInUse = (error) => Predicate.hasProperty(error, 'code') && error.code === 'EADDRINUSE';
|
|
262
|
-
const
|
|
263
|
-
const
|
|
239
|
+
const relayId = Effect.gen(function* () {
|
|
240
|
+
const crypto = yield* Crypto.Crypto;
|
|
241
|
+
return yield* crypto.randomUUIDv4.pipe(Effect.orDie);
|
|
242
|
+
});
|
|
243
|
+
const attachRelayHandlers = (wss, enqueue) => {
|
|
264
244
|
wss.on('connection', client => {
|
|
265
245
|
enqueue(Event.McpClientConnected({ client }));
|
|
266
246
|
client.on('message', raw => enqueue(Event.McpRequestReceived({ client, raw: raw.toString() })));
|
|
@@ -269,45 +249,206 @@ const bindMcpRelay = (port, enqueue) => Effect.callback(resume => {
|
|
|
269
249
|
console.error('[foldkit:devTools] MCP client error', error);
|
|
270
250
|
});
|
|
271
251
|
});
|
|
252
|
+
};
|
|
253
|
+
const parseRequestUrl = Option.liftThrowable((request) => new URL(request.url ?? '/', 'http://relay'));
|
|
254
|
+
const boundPort = (address) => address === null || Predicate.isString(address)
|
|
255
|
+
? Option.none()
|
|
256
|
+
: Option.some(address.port);
|
|
257
|
+
const relayHasNoBoundPort = () => new Error('[foldkit:devTools] the MCP relay listener has no bound port');
|
|
258
|
+
const relayToken = Effect.gen(function* () {
|
|
259
|
+
const crypto = yield* Crypto.Crypto;
|
|
260
|
+
const bytes = yield* crypto.randomBytes(RELAY_TOKEN_BYTES).pipe(Effect.orDie);
|
|
261
|
+
return Buffer.from(bytes).toString('hex');
|
|
262
|
+
});
|
|
263
|
+
const relayUrlForLog = (url) => {
|
|
264
|
+
const parsed = new URL(url);
|
|
265
|
+
parsed.search = '';
|
|
266
|
+
return parsed.toString();
|
|
267
|
+
};
|
|
268
|
+
const withRelayToken = (url, token) => {
|
|
269
|
+
url.search = '';
|
|
270
|
+
url.searchParams.set(RELAY_TOKEN_PARAMETER, token);
|
|
271
|
+
return url.toString();
|
|
272
|
+
};
|
|
273
|
+
const requestPresentsToken = (url, token) => {
|
|
274
|
+
const maybePresented = Option.fromNullishOr(url.searchParams.get(RELAY_TOKEN_PARAMETER));
|
|
275
|
+
const expected = Buffer.from(token, 'utf8');
|
|
276
|
+
return Option.exists(maybePresented, presented => {
|
|
277
|
+
const actual = Buffer.from(presented, 'utf8');
|
|
278
|
+
return (expected.length === actual.length && timingSafeEqual(expected, actual));
|
|
279
|
+
});
|
|
280
|
+
};
|
|
281
|
+
const refuseUpgrade = (socket) => {
|
|
282
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
|
283
|
+
socket.destroy();
|
|
284
|
+
};
|
|
285
|
+
// NOTE: Vite resolves `server.resolvedUrls` in a listener it prepends to
|
|
286
|
+
// `listening`, so they are set by the time the relay's own listener runs.
|
|
287
|
+
const hostedRelayUrl = (server, token) => (httpServer) => {
|
|
288
|
+
const maybeResolvedUrl = pipe(Option.fromNullishOr(server.resolvedUrls), Option.flatMap(resolvedUrls => Option.orElse(Array.head(resolvedUrls.local), () => Array.head(resolvedUrls.network))));
|
|
289
|
+
const url = new URL(pipe(maybeResolvedUrl, Option.orElse(() => Option.map(boundPort(httpServer.address()), port => `http://${RELAY_LOOPBACK_HOST}:${port}/`)), Option.getOrThrowWith(relayHasNoBoundPort)));
|
|
290
|
+
url.protocol = 'ws:';
|
|
291
|
+
url.pathname = RELAY_PATH;
|
|
292
|
+
return withRelayToken(url, token);
|
|
293
|
+
};
|
|
294
|
+
const loopbackRelayUrl = (token) => (httpServer) => {
|
|
295
|
+
const port = Option.getOrThrowWith(boundPort(httpServer.address()), relayHasNoBoundPort);
|
|
296
|
+
const url = new URL(`ws://${RELAY_LOOPBACK_HOST}:${port}${RELAY_PATH}`);
|
|
297
|
+
return withRelayToken(url, token);
|
|
298
|
+
};
|
|
299
|
+
const hostRelayOnServer = (httpServer, id, token, toUrl, enqueue) => Effect.callback(resume => {
|
|
300
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
301
|
+
attachRelayHandlers(wss, enqueue);
|
|
302
|
+
const onUpgrade = (request, socket, head) => {
|
|
303
|
+
const maybeUrl = parseRequestUrl(request);
|
|
304
|
+
if (Option.isNone(maybeUrl)) {
|
|
305
|
+
socket.destroy();
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const url = maybeUrl.value;
|
|
309
|
+
if (url.pathname !== RELAY_PATH) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (!requestPresentsToken(url, token)) {
|
|
313
|
+
refuseUpgrade(socket);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
wss.handleUpgrade(request, socket, head, client => {
|
|
317
|
+
wss.emit('connection', client, request);
|
|
318
|
+
});
|
|
319
|
+
};
|
|
320
|
+
httpServer.on('upgrade', onUpgrade);
|
|
321
|
+
const detach = () => {
|
|
322
|
+
httpServer.off('upgrade', onUpgrade);
|
|
323
|
+
httpServer.off('listening', onListening);
|
|
324
|
+
};
|
|
325
|
+
const onListening = () => {
|
|
326
|
+
resume(Effect.sync(() => ({ id, wss, url: toUrl(httpServer), detach })));
|
|
327
|
+
};
|
|
328
|
+
if (httpServer.listening) {
|
|
329
|
+
onListening();
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
httpServer.once('listening', onListening);
|
|
333
|
+
}
|
|
334
|
+
return Effect.sync(detach);
|
|
335
|
+
});
|
|
336
|
+
const bindLoopbackHttpServer = Effect.callback(resume => {
|
|
337
|
+
const httpServer = createHttpServer();
|
|
338
|
+
const onBindFailed = (cause) => {
|
|
339
|
+
httpServer.close();
|
|
340
|
+
resume(Effect.fail(new RelayBindFailed({ maybePort: Option.none(), cause })));
|
|
341
|
+
};
|
|
342
|
+
httpServer.once('error', onBindFailed);
|
|
343
|
+
httpServer.listen(0, RELAY_LOOPBACK_HOST, () => {
|
|
344
|
+
httpServer.off('error', onBindFailed);
|
|
345
|
+
httpServer.on('error', error => {
|
|
346
|
+
console.error('[foldkit:devTools] MCP relay error', error);
|
|
347
|
+
});
|
|
348
|
+
resume(Effect.succeed(httpServer));
|
|
349
|
+
});
|
|
350
|
+
return Effect.sync(() => {
|
|
351
|
+
httpServer.close();
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
const bindLoopbackRelay = (id, token, enqueue) => Effect.gen(function* () {
|
|
355
|
+
const httpServer = yield* bindLoopbackHttpServer;
|
|
356
|
+
const relay = yield* hostRelayOnServer(httpServer, id, token, loopbackRelayUrl(token), enqueue);
|
|
357
|
+
return {
|
|
358
|
+
...relay,
|
|
359
|
+
detach: () => {
|
|
360
|
+
relay.detach();
|
|
361
|
+
httpServer.close();
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
});
|
|
365
|
+
const bindStandaloneRelay = (port, id, enqueue) => Effect.callback(resume => {
|
|
366
|
+
const wss = new WebSocketServer({ port });
|
|
367
|
+
attachRelayHandlers(wss, enqueue);
|
|
272
368
|
const onListening = () => {
|
|
273
369
|
wss.off('error', onBindFailed);
|
|
274
370
|
wss.on('error', error => {
|
|
275
371
|
console.error('[foldkit:devTools] MCP relay error', error);
|
|
276
372
|
});
|
|
277
|
-
|
|
278
|
-
resume(Effect.succeed(
|
|
373
|
+
const listeningPort = Option.getOrThrowWith(boundPort(wss.address()), relayHasNoBoundPort);
|
|
374
|
+
resume(Effect.succeed({
|
|
375
|
+
id,
|
|
376
|
+
wss,
|
|
377
|
+
url: `ws://${RELAY_CONFIGURED_PORT_HOST}:${listeningPort}`,
|
|
378
|
+
detach: () => undefined,
|
|
379
|
+
}));
|
|
279
380
|
};
|
|
280
381
|
const onBindFailed = (cause) => {
|
|
281
382
|
wss.off('listening', onListening);
|
|
282
383
|
wss.close();
|
|
283
|
-
resume(Effect.fail(new RelayBindFailed({ cause })));
|
|
384
|
+
resume(Effect.fail(new RelayBindFailed({ maybePort: Option.some(port), cause })));
|
|
284
385
|
};
|
|
285
386
|
wss.once('listening', onListening);
|
|
286
387
|
wss.once('error', onBindFailed);
|
|
287
388
|
});
|
|
288
|
-
const reportRelayBindFailed = (
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
389
|
+
const reportRelayBindFailed = (maybePort, cause) => {
|
|
390
|
+
const where = Option.match(maybePort, {
|
|
391
|
+
onNone: () => 'an assigned loopback port',
|
|
392
|
+
onSome: port => `port ${port}`,
|
|
393
|
+
});
|
|
394
|
+
if (Option.isSome(maybePort) && isPortInUse(cause)) {
|
|
395
|
+
const port = maybePort.value;
|
|
396
|
+
return Console.error(`\n[foldkit:devTools] Port ${port} is in use; the MCP relay did not start.\n` +
|
|
397
|
+
`[foldkit:devTools] Stop the process using that port, or remove \`devToolsMcpPort\` from your Vite config to use automatic discovery.\n` +
|
|
398
|
+
`[foldkit:devTools] If you choose another fixed port, set \`FOLDKIT_DEVTOOLS_MCP_PORT\` to match.\n`);
|
|
295
399
|
}
|
|
296
400
|
else {
|
|
297
|
-
return Console.error(`[foldkit:devTools] MCP relay failed to start on
|
|
401
|
+
return Console.error(`[foldkit:devTools] MCP relay failed to start on ${where}; continuing without the relay`, cause);
|
|
298
402
|
}
|
|
299
403
|
};
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
})
|
|
404
|
+
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.`;
|
|
405
|
+
const publishRelay = (root, relay) => Effect.gen(function* () {
|
|
406
|
+
const startedAt = yield* Clock.currentTimeMillis;
|
|
407
|
+
yield* publishRelayRecord({
|
|
408
|
+
version: RELAY_RECORD_VERSION,
|
|
409
|
+
id: relay.id,
|
|
410
|
+
root,
|
|
411
|
+
url: relay.url,
|
|
412
|
+
pid: process.pid,
|
|
413
|
+
startedAt,
|
|
414
|
+
});
|
|
415
|
+
}).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)));
|
|
416
|
+
const startMcpRelay = (server, devToolsMcpPort, enqueue) => {
|
|
417
|
+
const root = server.config.root;
|
|
418
|
+
// NOTE: The MCP server cannot verify a development HTTPS certificate, so
|
|
419
|
+
// HTTPS uses a separate loopback listener.
|
|
420
|
+
const maybeHttpServer = Option.filter(Option.fromNullishOr(server.httpServer), () => server.config.server.https === undefined);
|
|
421
|
+
// NOTE: A configured port keeps the previous unauthenticated behavior.
|
|
422
|
+
// An assigned port binds only to loopback and is found through the registry.
|
|
423
|
+
const acquire = Effect.gen(function* () {
|
|
424
|
+
const id = yield* relayId;
|
|
425
|
+
if (devToolsMcpPort !== undefined) {
|
|
426
|
+
return yield* bindStandaloneRelay(devToolsMcpPort, id, enqueue);
|
|
427
|
+
}
|
|
428
|
+
const token = yield* relayToken;
|
|
429
|
+
return yield* Option.match(maybeHttpServer, {
|
|
430
|
+
onNone: () => bindLoopbackRelay(id, token, enqueue),
|
|
431
|
+
onSome: httpServer => hostRelayOnServer(httpServer, id, token, hostedRelayUrl(server, token), enqueue),
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
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* () {
|
|
435
|
+
relay.detach();
|
|
436
|
+
for (const client of relay.wss.clients) {
|
|
437
|
+
client.terminate();
|
|
438
|
+
}
|
|
439
|
+
relay.wss.close();
|
|
440
|
+
yield* retireRelayRecord(root, relay.id);
|
|
441
|
+
yield* Console.log('[foldkit:devTools] MCP relay stopped');
|
|
442
|
+
})).pipe(Effect.retry({
|
|
443
|
+
while: ({ cause }) => isPortInUse(cause),
|
|
444
|
+
times: RELAY_BIND_RETRY_COUNT,
|
|
445
|
+
schedule: Schedule.spaced(RELAY_BIND_RETRY_DELAY),
|
|
446
|
+
}), Effect.catchTag('RelayBindFailed', ({ maybePort, cause }) => reportRelayBindFailed(maybePort, cause)));
|
|
447
|
+
};
|
|
448
|
+
// NOTE: A Vitest run can override its mode, but still carries Vitest plugins.
|
|
449
|
+
// Starting a relay there can contend with the project's dev server.
|
|
450
|
+
const isTestRun = (server) => server.config.mode === 'test' ||
|
|
451
|
+
server.config.plugins.some(plugin => plugin.name === 'vitest' || plugin.name.startsWith('vitest:'));
|
|
311
452
|
// PROGRAM
|
|
312
453
|
const main = (server, events, options) => Effect.gen(function* () {
|
|
313
454
|
const state = yield* makeState;
|
|
@@ -320,8 +461,8 @@ const main = (server, events, options) => Effect.gen(function* () {
|
|
|
320
461
|
// gives up on its boot-time model request in well under a second, so
|
|
321
462
|
// sequencing the dispatch loop behind the bind would cost model
|
|
322
463
|
// preservation whenever the port is contended.
|
|
323
|
-
if (options.devToolsMcpPort !==
|
|
324
|
-
yield* Effect.forkScoped(startMcpRelay(options.devToolsMcpPort, enqueue));
|
|
464
|
+
if (options.devToolsMcpPort !== false && !isTestRun(server)) {
|
|
465
|
+
yield* Effect.forkScoped(startMcpRelay(server, options.devToolsMcpPort, enqueue));
|
|
325
466
|
}
|
|
326
467
|
yield* Stream.fromQueue(events).pipe(Stream.runForEach(event => dispatchEvent(server, state, event)));
|
|
327
468
|
});
|
|
@@ -354,42 +495,38 @@ const withContainerId = (build, containerId) => {
|
|
|
354
495
|
prerender: { containerId, ...prerender },
|
|
355
496
|
};
|
|
356
497
|
};
|
|
498
|
+
const relayRegistryLayer = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer, NodeCrypto.layer);
|
|
357
499
|
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();
|
|
500
|
+
// NOTE: During a Vite restart, old and new servers overlap. Separate queues
|
|
501
|
+
// and fibers keep their events and shutdowns attached to the right server.
|
|
502
|
+
const mainRuns = new WeakMap();
|
|
364
503
|
const stopMain = (config) => Effect.suspend(() => {
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
if (
|
|
504
|
+
const run = mainRuns.get(config);
|
|
505
|
+
mainRuns.delete(config);
|
|
506
|
+
if (run === undefined) {
|
|
368
507
|
return Effect.void;
|
|
369
508
|
}
|
|
370
509
|
else {
|
|
371
|
-
return Fiber.interrupt(fiber);
|
|
510
|
+
return Fiber.interrupt(run.fiber);
|
|
372
511
|
}
|
|
373
512
|
});
|
|
374
513
|
const reloadPlugin = {
|
|
375
514
|
name: 'foldkit',
|
|
376
515
|
apply: 'serve',
|
|
377
|
-
config:
|
|
516
|
+
config: () => ({
|
|
378
517
|
optimizeDeps: {
|
|
379
518
|
include: [...FORCE_INCLUDED_EFFECT_NAMESPACES],
|
|
380
519
|
},
|
|
381
|
-
resolve: {
|
|
382
|
-
dedupe: resolveInstalledFoldkitPackages(userConfig.root ?? process.cwd()),
|
|
383
|
-
},
|
|
384
520
|
}),
|
|
385
521
|
configureServer: server => {
|
|
386
|
-
const
|
|
387
|
-
|
|
522
|
+
const events = Effect.runSync(Queue.unbounded());
|
|
523
|
+
// NOTE: The default ConfigProvider snapshots the environment. Create a
|
|
524
|
+
// fresh one so a restarted server sees current values.
|
|
525
|
+
const fiber = Effect.runFork(Effect.scoped(main(server, events, options)).pipe(Effect.provide(relayRegistryLayer), Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnv())));
|
|
526
|
+
mainRuns.set(server.config, { events, fiber });
|
|
388
527
|
},
|
|
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.
|
|
528
|
+
// NOTE: Middleware mode has no HTTP server to close. Vite still calls
|
|
529
|
+
// `closeBundle`, so relay cleanup belongs here.
|
|
393
530
|
closeBundle() {
|
|
394
531
|
return Effect.runPromise(stopMain(this.environment.getTopLevelConfig()));
|
|
395
532
|
},
|
|
@@ -398,12 +535,40 @@ export const foldkit = (options = {}) => {
|
|
|
398
535
|
return;
|
|
399
536
|
}
|
|
400
537
|
server.ws.send({ type: 'full-reload' });
|
|
401
|
-
|
|
538
|
+
const run = mainRuns.get(server.config);
|
|
539
|
+
if (run !== undefined) {
|
|
540
|
+
Queue.offerUnsafe(run.events, Event.HotUpdateFired());
|
|
541
|
+
}
|
|
402
542
|
return [];
|
|
403
543
|
},
|
|
404
544
|
};
|
|
545
|
+
const resolutionPlugin = {
|
|
546
|
+
name: 'foldkit:resolution',
|
|
547
|
+
config: userConfig => {
|
|
548
|
+
const singletonPackages = resolveInstalledFoldkitPackages(userConfig.root ?? process.cwd());
|
|
549
|
+
return {
|
|
550
|
+
optimizeDeps: {
|
|
551
|
+
exclude: ['foldkit'],
|
|
552
|
+
},
|
|
553
|
+
resolve: {
|
|
554
|
+
dedupe: singletonPackages,
|
|
555
|
+
},
|
|
556
|
+
ssr: {
|
|
557
|
+
noExternal: singletonPackages,
|
|
558
|
+
},
|
|
559
|
+
environments: {
|
|
560
|
+
ssr: {
|
|
561
|
+
resolve: {
|
|
562
|
+
noExternal: singletonPackages,
|
|
563
|
+
},
|
|
564
|
+
},
|
|
565
|
+
},
|
|
566
|
+
};
|
|
567
|
+
},
|
|
568
|
+
};
|
|
405
569
|
const shared = [
|
|
406
|
-
|
|
570
|
+
resolutionPlugin,
|
|
571
|
+
...foldkitBuildToken(options.buildId),
|
|
407
572
|
foldkitViewIdentity(),
|
|
408
573
|
devToolsOverlayPlugin(),
|
|
409
574
|
reloadPlugin,
|
|
@@ -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.25.0-canary.9f851d747bdd",
|
|
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,28 @@
|
|
|
16
16
|
"dist"
|
|
17
17
|
],
|
|
18
18
|
"peerDependencies": {
|
|
19
|
-
"effect": "4.0.0
|
|
20
|
-
"foldkit": "
|
|
19
|
+
"effect": "4.0.0",
|
|
20
|
+
"foldkit": "0.164.0-canary.9f851d747bdd",
|
|
21
21
|
"vite": "^8.0.0"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"
|
|
24
|
+
"@effect/platform-node": "4.0.0",
|
|
25
|
+
"@effect/platform-node-shared": "4.0.0",
|
|
26
|
+
"magic-string": "^1.4.2",
|
|
25
27
|
"ws": "^8.21.3"
|
|
26
28
|
},
|
|
27
29
|
"devDependencies": {
|
|
28
|
-
"@types/node": "
|
|
30
|
+
"@types/node": "26.4.0",
|
|
29
31
|
"@types/ws": "^8.18.1",
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
+
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
|
33
|
+
"effect": "4.0.0",
|
|
34
|
+
"happy-dom": "^20.14.5",
|
|
32
35
|
"rimraf": "^6.1.3",
|
|
33
36
|
"typescript": "^7.0.2",
|
|
34
|
-
"vite": "^8.
|
|
37
|
+
"vite": "^8.3.1",
|
|
35
38
|
"vite-host": "npm:vite@8.2.1",
|
|
36
39
|
"vitest": "^4.1.11",
|
|
37
|
-
"foldkit": "0.
|
|
40
|
+
"foldkit": "0.164.0-canary.9f851d747bdd"
|
|
38
41
|
},
|
|
39
42
|
"keywords": [
|
|
40
43
|
"vite",
|