@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/dev-server",
3
- "version": "2.1.2",
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.14.1",
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.4.2",
46
- "@checkstack/backend": "0.16.2",
47
- "@checkstack/frontend": "0.7.2",
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
  }
@@ -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] = await Promise.all([
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
- alias: {
198
- "virtual:checkstack-dev-plugin": pluginEntry,
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
+ };