@checkstack/dev-server 2.1.2 → 2.2.1
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/package.json +11 -6
- package/src/dev-frontend.ts +168 -7
- package/src/dev-monaco-workers.test.ts +136 -0
- package/src/dev-monaco-workers.ts +300 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/dev-server",
|
|
3
|
-
"version": "2.1
|
|
3
|
+
"version": "2.2.1",
|
|
4
4
|
"description": "Local Checkstack dev server for plugin authors. Spawns the platform backend with the cwd plugin loaded, runs Vite for the frontend, and reloads on save. Used as a devDependency in plugin repos (run via `bun run dev` → `checkstack-dev`).",
|
|
5
5
|
"license": "Elastic-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -24,13 +24,14 @@
|
|
|
24
24
|
"lint:code": "eslint . --max-warnings 0"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@checkstack/common": "0.
|
|
27
|
+
"@checkstack/common": "0.15.0",
|
|
28
28
|
"@vitejs/plugin-react": "^6.0.2",
|
|
29
29
|
"vite": "^8.0.16"
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
32
|
"@checkstack/backend": "*",
|
|
33
|
-
"@checkstack/frontend": "*"
|
|
33
|
+
"@checkstack/frontend": "*",
|
|
34
|
+
"@checkstack/ui": "*"
|
|
34
35
|
},
|
|
35
36
|
"peerDependenciesMeta": {
|
|
36
37
|
"@checkstack/backend": {
|
|
@@ -38,13 +39,17 @@
|
|
|
38
39
|
},
|
|
39
40
|
"@checkstack/frontend": {
|
|
40
41
|
"optional": true
|
|
42
|
+
},
|
|
43
|
+
"@checkstack/ui": {
|
|
44
|
+
"optional": true
|
|
41
45
|
}
|
|
42
46
|
},
|
|
43
47
|
"devDependencies": {
|
|
44
48
|
"@checkstack/tsconfig": "0.0.7",
|
|
45
|
-
"@checkstack/scripts": "0.
|
|
46
|
-
"@checkstack/backend": "0.
|
|
47
|
-
"@checkstack/frontend": "0.
|
|
49
|
+
"@checkstack/scripts": "0.6.1",
|
|
50
|
+
"@checkstack/backend": "0.18.0",
|
|
51
|
+
"@checkstack/frontend": "0.9.1",
|
|
52
|
+
"@checkstack/ui": "1.15.1",
|
|
48
53
|
"typescript": "^5.0.0"
|
|
49
54
|
}
|
|
50
55
|
}
|
package/src/dev-frontend.ts
CHANGED
|
@@ -10,10 +10,17 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import path from "node:path";
|
|
12
12
|
import fs from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
13
14
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
14
15
|
import { createRequire } from "node:module";
|
|
15
16
|
import type { ViteDevServer } from "vite";
|
|
16
17
|
import { buildDevTailwindContent } from "./dev-frontend-css";
|
|
18
|
+
import {
|
|
19
|
+
prepareMonacoWorkers,
|
|
20
|
+
type MonacoWorkerSetup,
|
|
21
|
+
type ViteBuild,
|
|
22
|
+
} from "./dev-monaco-workers";
|
|
23
|
+
import type { MonacoViteConfig } from "@checkstack/ui/src/vite-monaco";
|
|
17
24
|
|
|
18
25
|
/**
|
|
19
26
|
* Directory of THIS module (`@checkstack/dev-server`'s installed location).
|
|
@@ -80,10 +87,8 @@ export async function startFrontendDevServer({
|
|
|
80
87
|
// Lazy-imported here to avoid Vite's eager module init when the dev
|
|
81
88
|
// server isn't actually being launched (e.g. unit tests that only
|
|
82
89
|
// exercise `pickFrontendEntry`).
|
|
83
|
-
const [{ createServer: createViteServer }, reactModule] =
|
|
84
|
-
import("vite"),
|
|
85
|
-
import("@vitejs/plugin-react"),
|
|
86
|
-
]);
|
|
90
|
+
const [{ createServer: createViteServer, build }, reactModule] =
|
|
91
|
+
await Promise.all([import("vite"), import("@vitejs/plugin-react")]);
|
|
87
92
|
const react = reactModule.default;
|
|
88
93
|
|
|
89
94
|
// Resolve the @checkstack/frontend package. In the monorepo it is
|
|
@@ -158,6 +163,29 @@ export async function startFrontendDevServer({
|
|
|
158
163
|
pluginEntry,
|
|
159
164
|
});
|
|
160
165
|
|
|
166
|
+
// Monaco / VS Code editor Vite settings (ES-module workers + the `vscode`
|
|
167
|
+
// alias) from @checkstack/frontend's shared helper, so the dev server matches
|
|
168
|
+
// the app's config instead of drifting. Without the `vscode` alias,
|
|
169
|
+
// @checkstack/ui's CodeEditor leaks a runtime `require("vscode")` into the
|
|
170
|
+
// browser. Returns `undefined` when the editor stack isn't resolvable — the
|
|
171
|
+
// dev server still starts; the editor just isn't configured.
|
|
172
|
+
const monaco = await loadMonacoViteConfig({ frontendDir, pluginCwd });
|
|
173
|
+
|
|
174
|
+
// Pre-build @checkstack/ui's Monaco language workers and redirect its
|
|
175
|
+
// `?worker&url` imports to them. In a standalone install @checkstack/ui is a
|
|
176
|
+
// pre-bundled npm dep and Vite can't process those worker imports during
|
|
177
|
+
// pre-bundling; this serves pre-built bundles instead so the CodeEditor
|
|
178
|
+
// renders. See dev-monaco-workers.ts. `undefined` (editor degrades, dev
|
|
179
|
+
// server still starts) when the editor stack or build is unavailable.
|
|
180
|
+
const monacoWorkers = monaco
|
|
181
|
+
? await loadMonacoWorkers({
|
|
182
|
+
frontendDir,
|
|
183
|
+
pluginCwd,
|
|
184
|
+
vscodeDir: monaco.resolve.alias.vscode,
|
|
185
|
+
buildFn: build,
|
|
186
|
+
})
|
|
187
|
+
: undefined;
|
|
188
|
+
|
|
161
189
|
const server = await createViteServer({
|
|
162
190
|
root: frontendDir,
|
|
163
191
|
configFile: false, // we control the config inline
|
|
@@ -192,11 +220,29 @@ export async function startFrontendDevServer({
|
|
|
192
220
|
return html.replace("/src/main.tsx", "/src/dev-main.tsx");
|
|
193
221
|
},
|
|
194
222
|
},
|
|
223
|
+
// Serves the pre-built Monaco worker bundles (no-op when absent).
|
|
224
|
+
...(monacoWorkers ? [monacoWorkers.plugin] : []),
|
|
195
225
|
],
|
|
226
|
+
// ES-module worker format for the Monaco language workers (no-op when the
|
|
227
|
+
// editor stack isn't resolvable).
|
|
228
|
+
...(monaco ? { worker: monaco.worker } : {}),
|
|
196
229
|
resolve: {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
230
|
+
// Share a single React copy between the dev shell and the plugin
|
|
231
|
+
// (including @checkstack/ui's CodeEditor) so its hooks don't hit a second
|
|
232
|
+
// React instance ("Invalid hook call"). Mirrors the app's vite.config.
|
|
233
|
+
...(monacoWorkers
|
|
234
|
+
? { dedupe: ["react", "react-dom", "react/jsx-runtime"] }
|
|
235
|
+
: {}),
|
|
236
|
+
alias: [
|
|
237
|
+
{ find: "virtual:checkstack-dev-plugin", replacement: pluginEntry },
|
|
238
|
+
// `vscode` npm-alias → real @codingame package dir (see monacoViteConfig).
|
|
239
|
+
...(monaco
|
|
240
|
+
? [{ find: "vscode", replacement: monaco.resolve.alias.vscode }]
|
|
241
|
+
: []),
|
|
242
|
+
// Redirect @checkstack/ui's `?worker&url` worker imports to the
|
|
243
|
+
// pre-built bundles served by the plugin above (see dev-monaco-workers).
|
|
244
|
+
...(monacoWorkers?.aliases ?? []),
|
|
245
|
+
],
|
|
200
246
|
},
|
|
201
247
|
// Without this, Vite tries to optimize the dev plugin's deps and
|
|
202
248
|
// chokes on workspace-resolved peers. Letting Vite skip pre-bundle
|
|
@@ -230,6 +276,121 @@ interface TailwindPreset {
|
|
|
230
276
|
[key: string]: unknown;
|
|
231
277
|
}
|
|
232
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Build the Monaco / VS Code editor Vite settings from `@checkstack/frontend`'s
|
|
281
|
+
* shared `monacoViteConfig` helper — the SAME settings the frontend's own
|
|
282
|
+
* `vite.config.ts` uses, so the dev server and the app never drift.
|
|
283
|
+
*
|
|
284
|
+
* The editor stack (`@typefox/monaco-editor-react`, `@codingame/*`) lives in
|
|
285
|
+
* `@checkstack/ui`'s dependencies, so we resolve from the plugin's installed
|
|
286
|
+
* `@checkstack/ui` location (falling back to the frontend / plugin / dev-server
|
|
287
|
+
* dirs). Returns `undefined` on any failure (e.g. `@checkstack/ui` not
|
|
288
|
+
* installed, or an editor-less plugin) — the dev server still starts; only the
|
|
289
|
+
* in-browser editor's language features are unavailable. Mirrors
|
|
290
|
+
* {@link loadDevPostcssPlugins}: a frontend-tooling hiccup never takes the dev
|
|
291
|
+
* server down.
|
|
292
|
+
*/
|
|
293
|
+
async function loadMonacoViteConfig({
|
|
294
|
+
frontendDir,
|
|
295
|
+
pluginCwd,
|
|
296
|
+
}: {
|
|
297
|
+
frontendDir: string;
|
|
298
|
+
pluginCwd: string;
|
|
299
|
+
}): Promise<MonacoViteConfig | undefined> {
|
|
300
|
+
try {
|
|
301
|
+
// Load the helper from the RESOLVED `@checkstack/ui` (a peer the plugin
|
|
302
|
+
// author installs), mirroring loadDevPostcssPlugins — never a static
|
|
303
|
+
// import, so `@checkstack/ui` stays a peer dep rather than becoming a hard
|
|
304
|
+
// runtime dependency of the dev server.
|
|
305
|
+
const helperPath = resolveFromCandidates({
|
|
306
|
+
request: "@checkstack/ui/src/vite-monaco",
|
|
307
|
+
candidateBasePaths: [frontendDir, pluginCwd, DEV_SERVER_MODULE_DIR],
|
|
308
|
+
});
|
|
309
|
+
if (!helperPath) return undefined;
|
|
310
|
+
const mod = await import(pathToFileURL(helperPath).href);
|
|
311
|
+
// Cast: dynamic `import()`'s namespace is untyped. `monacoViteConfig` is the
|
|
312
|
+
// documented export of `@checkstack/ui/src/vite-monaco`; a wrong-shaped
|
|
313
|
+
// module is caught by the surrounding try/catch (→ undefined fallback).
|
|
314
|
+
const buildConfig = mod.monacoViteConfig as (args: {
|
|
315
|
+
resolveFrom: string[];
|
|
316
|
+
}) => MonacoViteConfig;
|
|
317
|
+
return buildConfig({ resolveFrom: monacoResolveFrom({ frontendDir, pluginCwd }) });
|
|
318
|
+
} catch {
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Candidate base dirs to resolve the Monaco editor stack from. The stack
|
|
325
|
+
* (`@typefox/monaco-editor-react`, `@codingame/*`) lives in `@checkstack/ui`'s
|
|
326
|
+
* deps, so the ui package's dir comes first, falling back to the frontend /
|
|
327
|
+
* plugin / dev-server dirs.
|
|
328
|
+
*/
|
|
329
|
+
function monacoResolveFrom({
|
|
330
|
+
frontendDir,
|
|
331
|
+
pluginCwd,
|
|
332
|
+
}: {
|
|
333
|
+
frontendDir: string;
|
|
334
|
+
pluginCwd: string;
|
|
335
|
+
}): string[] {
|
|
336
|
+
const uiPkgJson = resolveFromCandidates({
|
|
337
|
+
request: "@checkstack/ui/package.json",
|
|
338
|
+
candidateBasePaths: [frontendDir, pluginCwd, DEV_SERVER_MODULE_DIR],
|
|
339
|
+
});
|
|
340
|
+
return [
|
|
341
|
+
...(uiPkgJson ? [path.dirname(uiPkgJson)] : []),
|
|
342
|
+
frontendDir,
|
|
343
|
+
pluginCwd,
|
|
344
|
+
DEV_SERVER_MODULE_DIR,
|
|
345
|
+
];
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* `node_modules/.cache` home for the pre-built Monaco workers, falling back to
|
|
350
|
+
* a tmp dir if the plugin's `node_modules` isn't writable.
|
|
351
|
+
*/
|
|
352
|
+
function monacoWorkerCacheDir({ pluginCwd }: { pluginCwd: string }): string {
|
|
353
|
+
const preferred = path.join(
|
|
354
|
+
pluginCwd,
|
|
355
|
+
"node_modules",
|
|
356
|
+
".cache",
|
|
357
|
+
"checkstack-dev-monaco",
|
|
358
|
+
);
|
|
359
|
+
try {
|
|
360
|
+
fs.mkdirSync(preferred, { recursive: true });
|
|
361
|
+
return preferred;
|
|
362
|
+
} catch {
|
|
363
|
+
const fallback = path.join(os.tmpdir(), "checkstack-dev-monaco");
|
|
364
|
+
fs.mkdirSync(fallback, { recursive: true });
|
|
365
|
+
return fallback;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Pre-build (cached) @checkstack/ui's Monaco language workers and return the
|
|
371
|
+
* Vite wiring that redirects its `?worker&url` imports to the pre-built
|
|
372
|
+
* bundles. See {@link prepareMonacoWorkers}. Returns `undefined` (editor
|
|
373
|
+
* degrades gracefully) on any failure.
|
|
374
|
+
*/
|
|
375
|
+
async function loadMonacoWorkers({
|
|
376
|
+
frontendDir,
|
|
377
|
+
pluginCwd,
|
|
378
|
+
vscodeDir,
|
|
379
|
+
buildFn,
|
|
380
|
+
}: {
|
|
381
|
+
frontendDir: string;
|
|
382
|
+
pluginCwd: string;
|
|
383
|
+
vscodeDir: string;
|
|
384
|
+
buildFn: ViteBuild;
|
|
385
|
+
}): Promise<MonacoWorkerSetup | undefined> {
|
|
386
|
+
return prepareMonacoWorkers({
|
|
387
|
+
resolveFrom: monacoResolveFrom({ frontendDir, pluginCwd }),
|
|
388
|
+
vscodeDir,
|
|
389
|
+
cacheBaseDir: monacoWorkerCacheDir({ pluginCwd }),
|
|
390
|
+
buildFn,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
233
394
|
/**
|
|
234
395
|
* Assemble the dev PostCSS plugin chain — the shared `@checkstack/frontend`
|
|
235
396
|
* Tailwind preset (theme + `tailwindcss-animate`) with a `content` glob set
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
__test,
|
|
7
|
+
prepareMonacoWorkers,
|
|
8
|
+
type ViteBuild,
|
|
9
|
+
} from "./dev-monaco-workers";
|
|
10
|
+
|
|
11
|
+
// `@codingame/*` worker entries live in `@checkstack/ui`'s deps (two levels up).
|
|
12
|
+
const UI_DIR = path.resolve(import.meta.dir, "../../ui");
|
|
13
|
+
const VSCODE_DIR = "/fake/vscode";
|
|
14
|
+
|
|
15
|
+
const tmpDirs: string[] = [];
|
|
16
|
+
function makeCacheBase(): string {
|
|
17
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ccmw-test-"));
|
|
18
|
+
tmpDirs.push(dir);
|
|
19
|
+
return dir;
|
|
20
|
+
}
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
while (tmpDirs.length) fs.rmSync(tmpDirs.pop()!, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** A build that just writes a dummy bundle where vite would, tracking calls. */
|
|
26
|
+
function fakeBuild(): { fn: ViteBuild; calls: () => number } {
|
|
27
|
+
let calls = 0;
|
|
28
|
+
const fn: ViteBuild = (config) => {
|
|
29
|
+
calls++;
|
|
30
|
+
const b = config.build;
|
|
31
|
+
if (!b?.outDir || !b.lib || typeof b.lib === "boolean") {
|
|
32
|
+
throw new Error("unexpected build config");
|
|
33
|
+
}
|
|
34
|
+
const name =
|
|
35
|
+
typeof b.lib.fileName === "function"
|
|
36
|
+
? b.lib.fileName("es", "entry")
|
|
37
|
+
: String(b.lib.fileName);
|
|
38
|
+
fs.writeFileSync(path.join(b.outDir, name), "export default {};\n");
|
|
39
|
+
return Promise.resolve();
|
|
40
|
+
};
|
|
41
|
+
return { fn, calls: () => calls };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("computeCacheKey", () => {
|
|
45
|
+
it("is deterministic for the same inputs", () => {
|
|
46
|
+
const a = __test.computeCacheKey({ vscodeDir: "/x", versions: ["1", "2"] });
|
|
47
|
+
const b = __test.computeCacheKey({ vscodeDir: "/x", versions: ["1", "2"] });
|
|
48
|
+
expect(a).toBe(b);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("changes when the vscode dir or a worker version changes", () => {
|
|
52
|
+
const base = __test.computeCacheKey({ vscodeDir: "/x", versions: ["1"] });
|
|
53
|
+
expect(__test.computeCacheKey({ vscodeDir: "/y", versions: ["1"] })).not.toBe(base);
|
|
54
|
+
expect(__test.computeCacheKey({ vscodeDir: "/x", versions: ["2"] })).not.toBe(base);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("buildAliases", () => {
|
|
59
|
+
it("redirects each `?worker&url` import to its stub", () => {
|
|
60
|
+
const aliases = __test.buildAliases("/cache");
|
|
61
|
+
expect(aliases).toHaveLength(__test.WORKERS.length);
|
|
62
|
+
for (const w of __test.WORKERS) {
|
|
63
|
+
const a = aliases.find((x) => (x.find as RegExp).test(`${w.spec}?worker&url`));
|
|
64
|
+
expect(a).toBeDefined();
|
|
65
|
+
expect(a!.replacement).toBe(__test.stubFile("/cache", w.name));
|
|
66
|
+
// Must NOT match the bare specifier without the worker query.
|
|
67
|
+
expect((a!.find as RegExp).test(w.spec)).toBe(false);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("prepareMonacoWorkers", () => {
|
|
73
|
+
it("builds once, then reuses the cache on a second call", async () => {
|
|
74
|
+
const cacheBaseDir = makeCacheBase();
|
|
75
|
+
const first = fakeBuild();
|
|
76
|
+
const setupA = await prepareMonacoWorkers({
|
|
77
|
+
resolveFrom: [UI_DIR],
|
|
78
|
+
vscodeDir: VSCODE_DIR,
|
|
79
|
+
cacheBaseDir,
|
|
80
|
+
buildFn: first.fn,
|
|
81
|
+
});
|
|
82
|
+
expect(setupA).toBeDefined();
|
|
83
|
+
expect(first.calls()).toBe(__test.WORKERS.length); // one build per worker
|
|
84
|
+
expect(setupA!.aliases).toHaveLength(__test.WORKERS.length);
|
|
85
|
+
|
|
86
|
+
// Stubs export the served worker URLs.
|
|
87
|
+
for (const w of __test.WORKERS) {
|
|
88
|
+
const stub = setupA!.aliases.find(
|
|
89
|
+
(a) => a.replacement.endsWith(`${w.name}.stub.js`),
|
|
90
|
+
)!.replacement;
|
|
91
|
+
expect(fs.readFileSync(stub, "utf8")).toContain(
|
|
92
|
+
`${__test.SERVE_PREFIX}${w.name}.worker.js`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Second call: same inputs → cache hit, build NOT invoked again.
|
|
97
|
+
const second = fakeBuild();
|
|
98
|
+
const setupB = await prepareMonacoWorkers({
|
|
99
|
+
resolveFrom: [UI_DIR],
|
|
100
|
+
vscodeDir: VSCODE_DIR,
|
|
101
|
+
cacheBaseDir,
|
|
102
|
+
buildFn: second.fn,
|
|
103
|
+
});
|
|
104
|
+
expect(setupB).toBeDefined();
|
|
105
|
+
expect(second.calls()).toBe(0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("returns undefined and leaves no ready cache when the build fails", async () => {
|
|
109
|
+
const cacheBaseDir = makeCacheBase();
|
|
110
|
+
const failing: ViteBuild = () => Promise.reject(new Error("boom"));
|
|
111
|
+
const setup = await prepareMonacoWorkers({
|
|
112
|
+
resolveFrom: [UI_DIR],
|
|
113
|
+
vscodeDir: VSCODE_DIR,
|
|
114
|
+
cacheBaseDir,
|
|
115
|
+
buildFn: failing,
|
|
116
|
+
});
|
|
117
|
+
expect(setup).toBeUndefined();
|
|
118
|
+
// No promoted cache dir was left behind (only the base remains).
|
|
119
|
+
const entries = fs
|
|
120
|
+
.readdirSync(cacheBaseDir)
|
|
121
|
+
.filter((e) => __test.isCacheReady(path.join(cacheBaseDir, e)));
|
|
122
|
+
expect(entries).toHaveLength(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("returns undefined when the editor stack can't be resolved", async () => {
|
|
126
|
+
const cacheBaseDir = makeCacheBase();
|
|
127
|
+
const { fn } = fakeBuild();
|
|
128
|
+
const setup = await prepareMonacoWorkers({
|
|
129
|
+
resolveFrom: [path.join(cacheBaseDir, "does-not-exist")],
|
|
130
|
+
vscodeDir: VSCODE_DIR,
|
|
131
|
+
cacheBaseDir,
|
|
132
|
+
buildFn: fn,
|
|
133
|
+
});
|
|
134
|
+
expect(setup).toBeUndefined();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone Monaco worker pre-build for the dev server.
|
|
3
|
+
*
|
|
4
|
+
* `@checkstack/ui`'s `CodeEditor` imports three Monaco language workers via
|
|
5
|
+
* `?worker&url`:
|
|
6
|
+
*
|
|
7
|
+
* - the generic editor worker (`@codingame/monaco-vscode-editor-api`)
|
|
8
|
+
* - the TypeScript/JavaScript language worker
|
|
9
|
+
* - the JSON language worker
|
|
10
|
+
*
|
|
11
|
+
* In the monorepo `@checkstack/ui` is a linked workspace *source* package, so
|
|
12
|
+
* Vite's worker handling processes those imports normally. In a standalone
|
|
13
|
+
* plugin it is a *pre-bundled npm dependency*, and Vite's worker plugin does
|
|
14
|
+
* NOT run during dependency pre-bundling - the optimizer then chokes on the
|
|
15
|
+
* `?worker&url` query and the dev server crashes. Serving `@checkstack/ui` as
|
|
16
|
+
* source instead breaks the CJS->ESM interop of its other deps (`ajv`,
|
|
17
|
+
* `cookie`, ...).
|
|
18
|
+
*
|
|
19
|
+
* This module breaks that deadlock: it pre-builds each worker into a
|
|
20
|
+
* self-contained ES-module bundle (a plain `vite build`, NOT the dep
|
|
21
|
+
* optimizer, so the worker query never reaches it), serves them as static
|
|
22
|
+
* files, and exposes `resolve.alias` entries that redirect each `?worker&url`
|
|
23
|
+
* import to a tiny stub returning the served URL. Because `resolve.alias` runs
|
|
24
|
+
* during pre-bundling, `@checkstack/ui` stays pre-bundled (its CJS deps keep
|
|
25
|
+
* their interop) while the worker imports resolve to the pre-built bundles.
|
|
26
|
+
*
|
|
27
|
+
* The workers are self-contained Monaco language-service code (they do not pull
|
|
28
|
+
* in `@checkstack/ui`'s app dependencies), so building them in isolation is
|
|
29
|
+
* clean.
|
|
30
|
+
*/
|
|
31
|
+
import fs from "node:fs";
|
|
32
|
+
import path from "node:path";
|
|
33
|
+
import { createHash } from "node:crypto";
|
|
34
|
+
import { createRequire } from "node:module";
|
|
35
|
+
import type { Alias, InlineConfig, Plugin } from "vite";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Bump when the build config or stub/serve contract below changes, so existing
|
|
39
|
+
* caches from older dev-server versions are invalidated.
|
|
40
|
+
*/
|
|
41
|
+
const CACHE_FORMAT = "1";
|
|
42
|
+
|
|
43
|
+
/** URL prefix the pre-built workers are served under. */
|
|
44
|
+
const SERVE_PREFIX = "/@checkstack-monaco-worker/";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The three worker entry imports `@checkstack/ui`'s `monacoTsService` pulls in.
|
|
48
|
+
* `spec` is the bare specifier exactly as written there (sans the `?worker&url`
|
|
49
|
+
* suffix); `pkg` is the owning package (used to key the cache on its version).
|
|
50
|
+
*/
|
|
51
|
+
const WORKERS = [
|
|
52
|
+
{
|
|
53
|
+
name: "editor",
|
|
54
|
+
pkg: "@codingame/monaco-vscode-editor-api",
|
|
55
|
+
spec: "@codingame/monaco-vscode-editor-api/esm/vs/editor/editor.worker.js",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: "ts",
|
|
59
|
+
pkg: "@codingame/monaco-vscode-standalone-typescript-language-features",
|
|
60
|
+
spec: "@codingame/monaco-vscode-standalone-typescript-language-features/worker",
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "json",
|
|
64
|
+
pkg: "@codingame/monaco-vscode-standalone-json-language-features",
|
|
65
|
+
spec: "@codingame/monaco-vscode-standalone-json-language-features/worker",
|
|
66
|
+
},
|
|
67
|
+
] as const;
|
|
68
|
+
|
|
69
|
+
/** Vite's `build` (injected so this module stays decoupled / unit-testable). */
|
|
70
|
+
export type ViteBuild = (config: InlineConfig) => Promise<unknown>;
|
|
71
|
+
|
|
72
|
+
export interface MonacoWorkerSetup {
|
|
73
|
+
/** `resolve.alias` entries redirecting each `?worker&url` import to a stub. */
|
|
74
|
+
aliases: Alias[];
|
|
75
|
+
/** Vite plugin that serves the pre-built worker bundles as static files. */
|
|
76
|
+
plugin: Plugin;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const escapeRegExp = (s: string) =>
|
|
80
|
+
s.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
|
81
|
+
|
|
82
|
+
/** `.../foo.worker.js` built bundle path inside a cache dir. */
|
|
83
|
+
const workerFile = (dir: string, name: string) =>
|
|
84
|
+
path.join(dir, `${name}.worker.js`);
|
|
85
|
+
/** `.../foo.stub.js` redirect stub path inside a cache dir. */
|
|
86
|
+
const stubFile = (dir: string, name: string) => path.join(dir, `${name}.stub.js`);
|
|
87
|
+
const readyMarker = (dir: string) => path.join(dir, ".ready");
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Read a package's version via its resolvable `package.json`, or `"?"` when it
|
|
91
|
+
* can't be resolved (still produces a stable, distinct cache key).
|
|
92
|
+
*/
|
|
93
|
+
function readPkgVersion({
|
|
94
|
+
pkg,
|
|
95
|
+
req,
|
|
96
|
+
paths,
|
|
97
|
+
}: {
|
|
98
|
+
pkg: string;
|
|
99
|
+
req: NodeJS.Require;
|
|
100
|
+
paths: string[];
|
|
101
|
+
}): string {
|
|
102
|
+
try {
|
|
103
|
+
const pkgJsonPath = req.resolve(`${pkg}/package.json`, { paths });
|
|
104
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8"));
|
|
105
|
+
if (
|
|
106
|
+
parsed &&
|
|
107
|
+
typeof parsed === "object" &&
|
|
108
|
+
"version" in parsed &&
|
|
109
|
+
typeof (parsed as { version: unknown }).version === "string"
|
|
110
|
+
) {
|
|
111
|
+
return (parsed as { version: string }).version;
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
// fall through
|
|
115
|
+
}
|
|
116
|
+
return "?";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Deterministic cache key over everything that affects the built output: the
|
|
121
|
+
* cache format, the resolved `vscode` alias dir, and each worker package's
|
|
122
|
+
* version. A dependency bump (or a config change via `CACHE_FORMAT`) yields a
|
|
123
|
+
* new key, so stale bundles are never reused.
|
|
124
|
+
*/
|
|
125
|
+
function computeCacheKey({
|
|
126
|
+
vscodeDir,
|
|
127
|
+
versions,
|
|
128
|
+
}: {
|
|
129
|
+
vscodeDir: string;
|
|
130
|
+
versions: string[];
|
|
131
|
+
}): string {
|
|
132
|
+
return createHash("sha256")
|
|
133
|
+
.update(JSON.stringify([CACHE_FORMAT, vscodeDir, versions]))
|
|
134
|
+
.digest("hex")
|
|
135
|
+
.slice(0, 16);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A cache dir is usable only if every worker + stub exists AND `.ready` does. */
|
|
139
|
+
function isCacheReady(dir: string): boolean {
|
|
140
|
+
if (!fs.existsSync(readyMarker(dir))) return false;
|
|
141
|
+
return WORKERS.every(
|
|
142
|
+
(w) => fs.existsSync(workerFile(dir, w.name)) && fs.existsSync(stubFile(dir, w.name)),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function buildAliases(cacheDir: string): Alias[] {
|
|
147
|
+
return WORKERS.map((w) => ({
|
|
148
|
+
// Matches the exact `?worker&url` import in monacoTsService, so the worker
|
|
149
|
+
// resolves to the stub (which exports the served URL) during pre-bundling.
|
|
150
|
+
find: new RegExp(`^${escapeRegExp(`${w.spec}?worker&url`)}$`),
|
|
151
|
+
replacement: stubFile(cacheDir, w.name),
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function buildServePlugin(cacheDir: string): Plugin {
|
|
156
|
+
return {
|
|
157
|
+
name: "checkstack-dev-monaco-workers",
|
|
158
|
+
configureServer(server) {
|
|
159
|
+
server.middlewares.use((req, res, next) => {
|
|
160
|
+
const url = req.url ?? "";
|
|
161
|
+
if (!url.startsWith(SERVE_PREFIX)) return next();
|
|
162
|
+
const name = url.slice(SERVE_PREFIX.length).replace(/\?.*$/, "");
|
|
163
|
+
const match = WORKERS.find((w) => `${w.name}.worker.js` === name);
|
|
164
|
+
const file = match ? workerFile(cacheDir, match.name) : undefined;
|
|
165
|
+
if (!file || !fs.existsSync(file)) {
|
|
166
|
+
res.statusCode = 404;
|
|
167
|
+
res.end("worker not found");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
res.setHeader("Content-Type", "text/javascript; charset=utf-8");
|
|
171
|
+
// Pre-built and content-keyed by the cache dir, so it's immutable.
|
|
172
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
173
|
+
fs.createReadStream(file).pipe(res);
|
|
174
|
+
});
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Build the three workers into `destDir` and write their redirect stubs. Each
|
|
181
|
+
* worker is a separate `vite build` in library mode (single ES module, inlined
|
|
182
|
+
* dynamic imports) with the `vscode` alias applied - identical to how the
|
|
183
|
+
* production build bundles them, never touching the dep optimizer.
|
|
184
|
+
*/
|
|
185
|
+
async function buildWorkersInto({
|
|
186
|
+
destDir,
|
|
187
|
+
entries,
|
|
188
|
+
vscodeDir,
|
|
189
|
+
buildFn,
|
|
190
|
+
}: {
|
|
191
|
+
destDir: string;
|
|
192
|
+
entries: { name: string; entry: string }[];
|
|
193
|
+
vscodeDir: string;
|
|
194
|
+
buildFn: ViteBuild;
|
|
195
|
+
}): Promise<void> {
|
|
196
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
197
|
+
for (const { name, entry } of entries) {
|
|
198
|
+
await buildFn({
|
|
199
|
+
configFile: false,
|
|
200
|
+
logLevel: "silent",
|
|
201
|
+
resolve: { alias: { vscode: vscodeDir } },
|
|
202
|
+
worker: { format: "es" },
|
|
203
|
+
build: {
|
|
204
|
+
outDir: destDir,
|
|
205
|
+
emptyOutDir: false,
|
|
206
|
+
target: "esnext",
|
|
207
|
+
minify: true,
|
|
208
|
+
lib: { entry, formats: ["es"], fileName: () => `${name}.worker.js` },
|
|
209
|
+
rollupOptions: { output: { inlineDynamicImports: true } },
|
|
210
|
+
write: true,
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
fs.writeFileSync(
|
|
214
|
+
stubFile(destDir, name),
|
|
215
|
+
`export default ${JSON.stringify(`${SERVE_PREFIX}${name}.worker.js`)};\n`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
fs.writeFileSync(readyMarker(destDir), `${Date.now()}\n`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Pre-build (or reuse a cached build of) the Monaco workers and return the Vite
|
|
223
|
+
* wiring (`resolve.alias` redirects + a static-serving plugin).
|
|
224
|
+
*
|
|
225
|
+
* Returns `undefined` on any failure (editor stack unresolvable, build error) -
|
|
226
|
+
* the dev server still starts; the editor just won't get its language workers.
|
|
227
|
+
*
|
|
228
|
+
* Caching is content-addressed and concurrency-safe: builds go to a unique
|
|
229
|
+
* temp dir and are atomically promoted to the keyed cache dir only once
|
|
230
|
+
* complete, so a crashed or racing build never yields a partial cache, and two
|
|
231
|
+
* dev servers building the same versions converge on one result.
|
|
232
|
+
*/
|
|
233
|
+
export async function prepareMonacoWorkers({
|
|
234
|
+
resolveFrom,
|
|
235
|
+
vscodeDir,
|
|
236
|
+
cacheBaseDir,
|
|
237
|
+
buildFn,
|
|
238
|
+
}: {
|
|
239
|
+
resolveFrom: string[];
|
|
240
|
+
vscodeDir: string;
|
|
241
|
+
cacheBaseDir: string;
|
|
242
|
+
buildFn: ViteBuild;
|
|
243
|
+
}): Promise<MonacoWorkerSetup | undefined> {
|
|
244
|
+
try {
|
|
245
|
+
const req = createRequire(path.join(cacheBaseDir, "noop.js"));
|
|
246
|
+
const entries = WORKERS.map((w) => ({
|
|
247
|
+
name: w.name,
|
|
248
|
+
entry: req.resolve(w.spec, { paths: resolveFrom }),
|
|
249
|
+
}));
|
|
250
|
+
const versions = WORKERS.map((w) =>
|
|
251
|
+
readPkgVersion({ pkg: w.pkg, req, paths: resolveFrom }),
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const key = computeCacheKey({ vscodeDir, versions });
|
|
255
|
+
const cacheDir = path.join(cacheBaseDir, key);
|
|
256
|
+
|
|
257
|
+
if (!isCacheReady(cacheDir)) {
|
|
258
|
+
const tmpDir = `${cacheDir}.tmp-${process.pid}-${createHash("sha256")
|
|
259
|
+
.update(`${Date.now()}:${entries.map((e) => e.entry).join(",")}`)
|
|
260
|
+
.digest("hex")
|
|
261
|
+
.slice(0, 8)}`;
|
|
262
|
+
try {
|
|
263
|
+
await buildWorkersInto({ destDir: tmpDir, entries, vscodeDir, buildFn });
|
|
264
|
+
// Another dev server may have finished the same build while we were
|
|
265
|
+
// working: prefer the existing cache and discard our temp.
|
|
266
|
+
if (isCacheReady(cacheDir)) {
|
|
267
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
268
|
+
} else {
|
|
269
|
+
try {
|
|
270
|
+
fs.renameSync(tmpDir, cacheDir);
|
|
271
|
+
} catch {
|
|
272
|
+
// Lost a promote race (dir now exists) - fine if it's ready.
|
|
273
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
274
|
+
if (!isCacheReady(cacheDir)) throw new Error("worker cache promote failed");
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
} finally {
|
|
278
|
+
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!isCacheReady(cacheDir)) return undefined;
|
|
283
|
+
return { aliases: buildAliases(cacheDir), plugin: buildServePlugin(cacheDir) };
|
|
284
|
+
} catch {
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Exposed for unit tests.
|
|
290
|
+
export const __test = {
|
|
291
|
+
WORKERS,
|
|
292
|
+
CACHE_FORMAT,
|
|
293
|
+
SERVE_PREFIX,
|
|
294
|
+
computeCacheKey,
|
|
295
|
+
isCacheReady,
|
|
296
|
+
buildAliases,
|
|
297
|
+
workerFile,
|
|
298
|
+
stubFile,
|
|
299
|
+
readyMarker,
|
|
300
|
+
};
|