@checkstack/scripts 0.1.2 → 0.2.0
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 +22 -4
- package/src/cli.ts +31 -5
- package/src/commands/create.ts +17 -0
- package/src/commands/dev-deps-resolver.test.ts +411 -0
- package/src/commands/dev-deps-resolver.ts +215 -0
- package/src/commands/dev-frontend.test.ts +148 -0
- package/src/commands/dev-frontend.ts +198 -0
- package/src/commands/dev-internals.test.ts +506 -0
- package/src/commands/dev-internals.ts +278 -0
- package/src/commands/dev-lifecycle.test.ts +327 -0
- package/src/commands/dev-lifecycle.ts +173 -0
- package/src/commands/dev-server.ts +197 -0
- package/src/commands/plugin-pack.ts +397 -0
- package/src/templates/backend/package.json.hbs +39 -12
- package/src/templates/common/package.json.hbs +32 -8
- package/src/templates/frontend/package.json.hbs +41 -13
- package/src/templates.test.ts +48 -1
- package/CHANGELOG.md +0 -89
- package/tsconfig.json +0 -6
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Walks the plugin's `package.json#dependencies` and returns the file paths
|
|
7
|
+
* of every `@checkstack/*` backend plugin module that should be co-loaded
|
|
8
|
+
* by the dev server.
|
|
9
|
+
*
|
|
10
|
+
* Why this exists: the dev server boots `core/backend` with
|
|
11
|
+
* `skipDiscovery: true` and a single manual plugin (the one under
|
|
12
|
+
* development). Real plugins almost always depend on platform plugins
|
|
13
|
+
* (`@checkstack/healthcheck-backend`, `@checkstack/notification-backend`,
|
|
14
|
+
* `@checkstack/catalog-backend`, …) — without those, the host plugin's
|
|
15
|
+
* `init()` calls into unregistered services and the boot deadlocks or
|
|
16
|
+
* crashes.
|
|
17
|
+
*
|
|
18
|
+
* Resolution rules:
|
|
19
|
+
* 1. Recursively walk `dependencies` (and `peerDependencies`) of the
|
|
20
|
+
* plugin under dev.
|
|
21
|
+
* 2. Only follow packages whose name starts with `@checkstack/`.
|
|
22
|
+
* 3. Only include packages whose `package.json#checkstack.type === "backend"`.
|
|
23
|
+
* Common-type packages provide types and are pulled in automatically
|
|
24
|
+
* by the TS importer; frontend-type packages are loaded by the Vite
|
|
25
|
+
* dev server, not the backend.
|
|
26
|
+
* 4. Skip the plugin under dev itself (its module path is passed
|
|
27
|
+
* separately as the primary).
|
|
28
|
+
* 5. Auto-include `@checkstack/queue-memory-backend` +
|
|
29
|
+
* `@checkstack/cache-memory-backend` when no other queue/cache
|
|
30
|
+
* strategy is in the resolved set, so `coreServices.queueManager` /
|
|
31
|
+
* `coreServices.cacheManager` have a registered strategy on boot.
|
|
32
|
+
* These are the cheapest, zero-config providers — fine for dev.
|
|
33
|
+
*
|
|
34
|
+
* Returns absolute paths suitable for a child process to `bun run` /
|
|
35
|
+
* `import()`. Order is *not* topological — the platform's own dependency
|
|
36
|
+
* sorter inside `loadPlugins` handles that.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
interface PackageJson {
|
|
40
|
+
name?: string;
|
|
41
|
+
dependencies?: Record<string, string>;
|
|
42
|
+
peerDependencies?: Record<string, string>;
|
|
43
|
+
checkstack?: {
|
|
44
|
+
type?: "backend" | "frontend" | "common" | "tooling";
|
|
45
|
+
pluginId?: string;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface ResolvedPlugin {
|
|
50
|
+
name: string;
|
|
51
|
+
packageDir: string;
|
|
52
|
+
/** Path to import (the package.json `main` resolved to absolute). */
|
|
53
|
+
modulePath: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve the set of backend plugins the dev server should load alongside
|
|
58
|
+
* the plugin under development.
|
|
59
|
+
*
|
|
60
|
+
* @param input.pluginDir The plugin author's repo root (the cwd of the
|
|
61
|
+
* dev command).
|
|
62
|
+
* @param input.readFile Injectable for tests; defaults to
|
|
63
|
+
* `fs.readFileSync`.
|
|
64
|
+
* @param input.resolveFrom Injectable for tests; defaults to Node's
|
|
65
|
+
* `createRequire(...).resolve(...)`.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveCorePluginDeps({
|
|
68
|
+
pluginDir,
|
|
69
|
+
readFile = (p) => fs.readFileSync(p, "utf8"),
|
|
70
|
+
resolveFrom,
|
|
71
|
+
}: {
|
|
72
|
+
pluginDir: string;
|
|
73
|
+
readFile?: (p: string) => string;
|
|
74
|
+
resolveFrom?: (from: string, request: string) => string | undefined;
|
|
75
|
+
}): ResolvedPlugin[] {
|
|
76
|
+
const pluginPkgPath = path.join(pluginDir, "package.json");
|
|
77
|
+
const pluginPkg = JSON.parse(readFile(pluginPkgPath)) as PackageJson;
|
|
78
|
+
const pluginUnderDevName = pluginPkg.name;
|
|
79
|
+
|
|
80
|
+
// Default resolver uses createRequire from the plugin's package.json so
|
|
81
|
+
// node_modules lookup matches what `bun run` would do at runtime.
|
|
82
|
+
const defaultResolve =
|
|
83
|
+
resolveFrom ??
|
|
84
|
+
((from: string, request: string): string | undefined => {
|
|
85
|
+
try {
|
|
86
|
+
const req = createRequire(from);
|
|
87
|
+
return req.resolve(request);
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const resolved = new Map<string, ResolvedPlugin>();
|
|
94
|
+
const queue: string[] = [];
|
|
95
|
+
|
|
96
|
+
const seedFromPkg = (pkg: PackageJson) => {
|
|
97
|
+
for (const block of [pkg.dependencies, pkg.peerDependencies]) {
|
|
98
|
+
if (!block) continue;
|
|
99
|
+
for (const dep of Object.keys(block)) {
|
|
100
|
+
if (!dep.startsWith("@checkstack/")) continue;
|
|
101
|
+
if (dep === pluginUnderDevName) continue;
|
|
102
|
+
queue.push(dep);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
seedFromPkg(pluginPkg);
|
|
108
|
+
|
|
109
|
+
while (queue.length > 0) {
|
|
110
|
+
const depName = queue.shift()!;
|
|
111
|
+
if (resolved.has(depName)) continue;
|
|
112
|
+
|
|
113
|
+
// Resolve the package's package.json from the plugin dir's perspective.
|
|
114
|
+
const pkgJsonPath = defaultResolve(
|
|
115
|
+
path.join(pluginDir, "package.json"),
|
|
116
|
+
`${depName}/package.json`,
|
|
117
|
+
);
|
|
118
|
+
if (!pkgJsonPath) {
|
|
119
|
+
// Dep declared but not actually installed — surface during boot,
|
|
120
|
+
// not here. The `loadPlugins` import will throw with a clear msg.
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const pkg = JSON.parse(readFile(pkgJsonPath)) as PackageJson;
|
|
125
|
+
|
|
126
|
+
// Only enqueue further deps once we've decided to (or not to) load
|
|
127
|
+
// this package — but always walk the graph. A common-type package
|
|
128
|
+
// can transitively depend on a backend-type package.
|
|
129
|
+
seedFromPkg(pkg);
|
|
130
|
+
|
|
131
|
+
if (pkg.checkstack?.type !== "backend") continue;
|
|
132
|
+
|
|
133
|
+
const packageDir = path.dirname(pkgJsonPath);
|
|
134
|
+
const main = readMain(pkg, pkgJsonPath, readFile);
|
|
135
|
+
const modulePath = path.resolve(packageDir, main);
|
|
136
|
+
|
|
137
|
+
resolved.set(depName, {
|
|
138
|
+
name: depName,
|
|
139
|
+
packageDir,
|
|
140
|
+
modulePath,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Auto-include in-memory queue + cache providers if no provider was
|
|
145
|
+
// already pulled in via the dep graph. These are the dev-mode defaults
|
|
146
|
+
// — operators wire BullMQ / Redis in production.
|
|
147
|
+
ensureProvider({
|
|
148
|
+
needle: "queue-memory-backend",
|
|
149
|
+
siblings: ["queue-bullmq-backend"],
|
|
150
|
+
resolved,
|
|
151
|
+
pluginDir,
|
|
152
|
+
readFile,
|
|
153
|
+
resolveFrom: defaultResolve,
|
|
154
|
+
});
|
|
155
|
+
ensureProvider({
|
|
156
|
+
needle: "cache-memory-backend",
|
|
157
|
+
siblings: [], // no other cache provider exists yet
|
|
158
|
+
resolved,
|
|
159
|
+
pluginDir,
|
|
160
|
+
readFile,
|
|
161
|
+
resolveFrom: defaultResolve,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return [...resolved.values()];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function readMain(
|
|
168
|
+
pkg: PackageJson,
|
|
169
|
+
pkgJsonPath: string,
|
|
170
|
+
readFile: (p: string) => string,
|
|
171
|
+
): string {
|
|
172
|
+
const raw = JSON.parse(readFile(pkgJsonPath)) as { main?: string };
|
|
173
|
+
return raw.main ?? "src/index.ts";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* If none of `siblings` is already in the resolved set, attempt to add
|
|
178
|
+
* `needle` (a known in-memory dev provider). Silently no-op if `needle`
|
|
179
|
+
* isn't installed — operators may have a different provider wired up.
|
|
180
|
+
*/
|
|
181
|
+
function ensureProvider({
|
|
182
|
+
needle,
|
|
183
|
+
siblings,
|
|
184
|
+
resolved,
|
|
185
|
+
pluginDir,
|
|
186
|
+
readFile,
|
|
187
|
+
resolveFrom,
|
|
188
|
+
}: {
|
|
189
|
+
needle: string;
|
|
190
|
+
siblings: string[];
|
|
191
|
+
resolved: Map<string, ResolvedPlugin>;
|
|
192
|
+
pluginDir: string;
|
|
193
|
+
readFile: (p: string) => string;
|
|
194
|
+
resolveFrom: (from: string, request: string) => string | undefined;
|
|
195
|
+
}): void {
|
|
196
|
+
const fqNeedle = `@checkstack/${needle}`;
|
|
197
|
+
if (resolved.has(fqNeedle)) return;
|
|
198
|
+
for (const sibling of siblings) {
|
|
199
|
+
if (resolved.has(`@checkstack/${sibling}`)) return;
|
|
200
|
+
}
|
|
201
|
+
const pkgJsonPath = resolveFrom(
|
|
202
|
+
path.join(pluginDir, "package.json"),
|
|
203
|
+
`${fqNeedle}/package.json`,
|
|
204
|
+
);
|
|
205
|
+
if (!pkgJsonPath) return;
|
|
206
|
+
const pkg = JSON.parse(readFile(pkgJsonPath)) as PackageJson;
|
|
207
|
+
if (pkg.checkstack?.type !== "backend") return;
|
|
208
|
+
const packageDir = path.dirname(pkgJsonPath);
|
|
209
|
+
const main = readMain(pkg, pkgJsonPath, readFile);
|
|
210
|
+
resolved.set(fqNeedle, {
|
|
211
|
+
name: fqNeedle,
|
|
212
|
+
packageDir,
|
|
213
|
+
modulePath: path.resolve(packageDir, main),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pickFrontendEntry } from "./dev-frontend";
|
|
4
|
+
|
|
5
|
+
const ROOT = "/plugin-author/repo";
|
|
6
|
+
|
|
7
|
+
describe("pickFrontendEntry", () => {
|
|
8
|
+
it("returns <cwd>/<main> when the cwd is itself a -frontend plugin", () => {
|
|
9
|
+
const entry = pickFrontendEntry({
|
|
10
|
+
pluginCwd: ROOT,
|
|
11
|
+
pluginPkg: {
|
|
12
|
+
main: "src/index.tsx",
|
|
13
|
+
checkstack: { type: "frontend" },
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
expect(entry).toBe(path.resolve(ROOT, "src/index.tsx"));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("falls back to src/index.tsx when no main field on a -frontend plugin", () => {
|
|
20
|
+
const entry = pickFrontendEntry({
|
|
21
|
+
pluginCwd: ROOT,
|
|
22
|
+
pluginPkg: { checkstack: { type: "frontend" } },
|
|
23
|
+
});
|
|
24
|
+
expect(entry).toBe(path.resolve(ROOT, "src/index.tsx"));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("resolves a -frontend sibling's main when called from a bundle primary", () => {
|
|
28
|
+
const siblingPkgPath =
|
|
29
|
+
"/plugin-author/repo/node_modules/@my-org/widget-frontend/package.json";
|
|
30
|
+
const entry = pickFrontendEntry({
|
|
31
|
+
pluginCwd: ROOT,
|
|
32
|
+
pluginPkg: {
|
|
33
|
+
main: "src/index.ts",
|
|
34
|
+
checkstack: {
|
|
35
|
+
type: "backend",
|
|
36
|
+
bundle: ["@my-org/widget-common", "@my-org/widget-frontend"],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
resolveFrom: (request) =>
|
|
40
|
+
request === "@my-org/widget-frontend/package.json"
|
|
41
|
+
? siblingPkgPath
|
|
42
|
+
: undefined,
|
|
43
|
+
readFile: () =>
|
|
44
|
+
JSON.stringify({
|
|
45
|
+
name: "@my-org/widget-frontend",
|
|
46
|
+
main: "dist/index.js",
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
expect(entry).toBe(
|
|
50
|
+
"/plugin-author/repo/node_modules/@my-org/widget-frontend/dist/index.js",
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("skips non-frontend siblings in the bundle list", () => {
|
|
55
|
+
let resolveCalls = 0;
|
|
56
|
+
const entry = pickFrontendEntry({
|
|
57
|
+
pluginCwd: ROOT,
|
|
58
|
+
pluginPkg: {
|
|
59
|
+
checkstack: {
|
|
60
|
+
type: "backend",
|
|
61
|
+
// Only -common siblings — no -frontend
|
|
62
|
+
bundle: ["@my-org/widget-common", "@my-org/widget-other"],
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
resolveFrom: () => {
|
|
66
|
+
resolveCalls++;
|
|
67
|
+
return "/never";
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
expect(entry).toBeUndefined();
|
|
71
|
+
expect(resolveCalls).toBe(0); // didn't even attempt a resolve
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("returns undefined when the named -frontend sibling is not installed", () => {
|
|
75
|
+
const entry = pickFrontendEntry({
|
|
76
|
+
pluginCwd: ROOT,
|
|
77
|
+
pluginPkg: {
|
|
78
|
+
checkstack: {
|
|
79
|
+
type: "backend",
|
|
80
|
+
bundle: ["@my-org/widget-frontend"],
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
resolveFrom: () => undefined,
|
|
84
|
+
});
|
|
85
|
+
expect(entry).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("tries the next -frontend sibling when the first is malformed", () => {
|
|
89
|
+
let resolveCalls = 0;
|
|
90
|
+
const fs: Record<string, string> = {
|
|
91
|
+
"/nm/@my-org/first-frontend/package.json": "not-json",
|
|
92
|
+
"/nm/@my-org/second-frontend/package.json": JSON.stringify({
|
|
93
|
+
main: "src/index.tsx",
|
|
94
|
+
}),
|
|
95
|
+
};
|
|
96
|
+
const entry = pickFrontendEntry({
|
|
97
|
+
pluginCwd: ROOT,
|
|
98
|
+
pluginPkg: {
|
|
99
|
+
checkstack: {
|
|
100
|
+
type: "backend",
|
|
101
|
+
bundle: [
|
|
102
|
+
"@my-org/first-frontend",
|
|
103
|
+
"@my-org/second-frontend",
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
resolveFrom: (request) => {
|
|
108
|
+
resolveCalls++;
|
|
109
|
+
if (request === "@my-org/first-frontend/package.json")
|
|
110
|
+
return "/nm/@my-org/first-frontend/package.json";
|
|
111
|
+
if (request === "@my-org/second-frontend/package.json")
|
|
112
|
+
return "/nm/@my-org/second-frontend/package.json";
|
|
113
|
+
return undefined;
|
|
114
|
+
},
|
|
115
|
+
readFile: (p) => {
|
|
116
|
+
const content = fs[p];
|
|
117
|
+
if (content === undefined) throw new Error(`ENOENT: ${p}`);
|
|
118
|
+
return content;
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
expect(entry).toBe("/nm/@my-org/second-frontend/src/index.tsx");
|
|
122
|
+
// Exactly one attempt per sibling
|
|
123
|
+
expect(resolveCalls).toBe(2);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("returns undefined for a backend plugin with no bundle siblings at all", () => {
|
|
127
|
+
const entry = pickFrontendEntry({
|
|
128
|
+
pluginCwd: ROOT,
|
|
129
|
+
pluginPkg: { checkstack: { type: "backend" } },
|
|
130
|
+
});
|
|
131
|
+
expect(entry).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("falls back to src/index.tsx for a sibling without a main field", () => {
|
|
135
|
+
const entry = pickFrontendEntry({
|
|
136
|
+
pluginCwd: ROOT,
|
|
137
|
+
pluginPkg: {
|
|
138
|
+
checkstack: {
|
|
139
|
+
type: "backend",
|
|
140
|
+
bundle: ["@my-org/widget-frontend"],
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
resolveFrom: () => "/nm/@my-org/widget-frontend/package.json",
|
|
144
|
+
readFile: () => JSON.stringify({ name: "@my-org/widget-frontend" }),
|
|
145
|
+
});
|
|
146
|
+
expect(entry).toBe("/nm/@my-org/widget-frontend/src/index.tsx");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spawns a Vite dev server hosting the Checkstack frontend shell with
|
|
3
|
+
* the plugin under development pre-registered via the
|
|
4
|
+
* `virtual:checkstack-dev-plugin` alias.
|
|
5
|
+
*
|
|
6
|
+
* Reuses `core/frontend`'s `App.tsx`, `dev-main.tsx`, `index.css`, and
|
|
7
|
+
* `loadPlugins()` — same code path as production. Vite proxies `/api`
|
|
8
|
+
* and `/assets/plugins` to the backend dev server (default port 3000)
|
|
9
|
+
* so the SPA can talk to the running plugin.
|
|
10
|
+
*/
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import { createRequire } from "node:module";
|
|
14
|
+
import type { ViteDevServer } from "vite";
|
|
15
|
+
|
|
16
|
+
// `vite` and `@vitejs/plugin-react` are lazily imported inside
|
|
17
|
+
// `startFrontendDevServer` so that consumers of this module which only
|
|
18
|
+
// touch `pickFrontendEntry` (notably the unit tests) don't trigger
|
|
19
|
+
// Vite's eager module init. Bun's test runner cross-mocks `fs.readFileSync`
|
|
20
|
+
// in plugin-discovery.test.ts; Vite's constants.js calls `readFileSync`
|
|
21
|
+
// at load time and trips that leaked mock. Lazy-importing keeps the test
|
|
22
|
+
// suite isolated.
|
|
23
|
+
|
|
24
|
+
interface FrontendDevOptions {
|
|
25
|
+
/** Plugin author's cwd (the package whose frontend code we're dev'ing). */
|
|
26
|
+
pluginCwd: string;
|
|
27
|
+
/** HTTP port for the Vite dev server. */
|
|
28
|
+
port: number;
|
|
29
|
+
/** Backend dev server URL — `/api` and `/assets/plugins` are proxied here. */
|
|
30
|
+
backendUrl: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function startFrontendDevServer({
|
|
34
|
+
pluginCwd,
|
|
35
|
+
port,
|
|
36
|
+
backendUrl,
|
|
37
|
+
}: FrontendDevOptions): Promise<ViteDevServer> {
|
|
38
|
+
// Lazy-imported here to avoid Vite's eager module init when the dev
|
|
39
|
+
// server isn't actually being launched (e.g. unit tests that only
|
|
40
|
+
// exercise `pickFrontendEntry`).
|
|
41
|
+
const [{ createServer: createViteServer }, reactModule] = await Promise.all([
|
|
42
|
+
import("vite"),
|
|
43
|
+
import("@vitejs/plugin-react"),
|
|
44
|
+
]);
|
|
45
|
+
const react = reactModule.default;
|
|
46
|
+
|
|
47
|
+
// Resolve the @checkstack/frontend package from the plugin author's
|
|
48
|
+
// node_modules so paths line up with what `bun install` produced. Same
|
|
49
|
+
// strategy the backend dev command uses for @checkstack/backend.
|
|
50
|
+
const req = createRequire(path.join(pluginCwd, "package.json"));
|
|
51
|
+
const frontendPkgJsonPath = req.resolve("@checkstack/frontend/package.json");
|
|
52
|
+
const frontendDir = path.dirname(frontendPkgJsonPath);
|
|
53
|
+
const indexHtmlPath = path.join(frontendDir, "index.html");
|
|
54
|
+
if (!fs.existsSync(indexHtmlPath)) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`@checkstack/frontend at ${frontendDir} has no index.html — incompatible version?`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Resolve the plugin under dev's main entry — Vite's alias maps the
|
|
61
|
+
// virtual import `virtual:checkstack-dev-plugin` to this file.
|
|
62
|
+
const pluginPkg = JSON.parse(
|
|
63
|
+
fs.readFileSync(path.join(pluginCwd, "package.json"), "utf8"),
|
|
64
|
+
) as { main?: string; checkstack?: { type?: string; bundle?: string[] } };
|
|
65
|
+
// Bundle primaries point at their own backend main, but the frontend
|
|
66
|
+
// entry lives in a sibling. Best-effort: if the cwd's checkstack.type
|
|
67
|
+
// is "frontend", use cwd; else look for a sibling -frontend package
|
|
68
|
+
// listed in `checkstack.bundle` and resolve through node_modules.
|
|
69
|
+
const pluginEntry = pickFrontendEntry({
|
|
70
|
+
pluginCwd,
|
|
71
|
+
pluginPkg,
|
|
72
|
+
resolveFrom: (request) => {
|
|
73
|
+
try {
|
|
74
|
+
return req.resolve(request);
|
|
75
|
+
} catch {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
if (!pluginEntry) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
"Could not determine the plugin's frontend entry. Either run from a `-frontend` package directly, or list a `-frontend` sibling in your primary package's `checkstack.bundle`.",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const server = await createViteServer({
|
|
87
|
+
root: frontendDir,
|
|
88
|
+
configFile: false, // we control the config inline
|
|
89
|
+
server: {
|
|
90
|
+
port,
|
|
91
|
+
strictPort: true,
|
|
92
|
+
proxy: {
|
|
93
|
+
"/api": {
|
|
94
|
+
target: backendUrl,
|
|
95
|
+
changeOrigin: true,
|
|
96
|
+
ws: true,
|
|
97
|
+
},
|
|
98
|
+
"/assets/plugins": { target: backendUrl, changeOrigin: true },
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
// Replace the production `main.tsx` entry with our `dev-main.tsx`
|
|
102
|
+
// shell. Vite will pick this up via `index.html`'s `<script>` tag,
|
|
103
|
+
// because dev-main.tsx imports `virtual:checkstack-dev-plugin`,
|
|
104
|
+
// which is aliased below to the plugin author's actual frontend
|
|
105
|
+
// entry file.
|
|
106
|
+
plugins: [
|
|
107
|
+
react(),
|
|
108
|
+
{
|
|
109
|
+
name: "checkstack-dev-entry",
|
|
110
|
+
// Replace `/src/main.tsx` references in index.html with dev-main.
|
|
111
|
+
transformIndexHtml(html: string) {
|
|
112
|
+
return html.replace("/src/main.tsx", "/src/dev-main.tsx");
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
resolve: {
|
|
117
|
+
alias: {
|
|
118
|
+
"virtual:checkstack-dev-plugin": pluginEntry,
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
// Without this, Vite tries to optimize the dev plugin's deps and
|
|
122
|
+
// chokes on workspace-resolved peers. Letting Vite skip pre-bundle
|
|
123
|
+
// for our plugin keeps live-edit fast.
|
|
124
|
+
optimizeDeps: {
|
|
125
|
+
exclude: ["virtual:checkstack-dev-plugin"],
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await server.listen();
|
|
130
|
+
const info = server.config.server;
|
|
131
|
+
console.log(
|
|
132
|
+
`🎨 Frontend dev server: http://${info.host ?? "localhost"}:${info.port ?? port}`,
|
|
133
|
+
);
|
|
134
|
+
return server;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Resolve the entry file Vite should load for the plugin's frontend.
|
|
139
|
+
*
|
|
140
|
+
* - For a `-frontend` plugin: the cwd's own `main` field.
|
|
141
|
+
* - For a bundle primary (e.g. `-backend` with `checkstack.bundle`
|
|
142
|
+
* listing a `-frontend` sibling): the sibling's `main`, resolved
|
|
143
|
+
* through node_modules.
|
|
144
|
+
*
|
|
145
|
+
* Pure: all FS lookups go through injected hooks so the test suite can
|
|
146
|
+
* drive every branch without touching disk.
|
|
147
|
+
*/
|
|
148
|
+
export function pickFrontendEntry({
|
|
149
|
+
pluginCwd,
|
|
150
|
+
pluginPkg,
|
|
151
|
+
resolveFrom,
|
|
152
|
+
readFile = (p) => fs.readFileSync(p, "utf8"),
|
|
153
|
+
}: {
|
|
154
|
+
pluginCwd: string;
|
|
155
|
+
pluginPkg: {
|
|
156
|
+
main?: string;
|
|
157
|
+
checkstack?: { type?: string; bundle?: string[] };
|
|
158
|
+
};
|
|
159
|
+
/**
|
|
160
|
+
* Optional injection. Defaults to a `createRequire` rooted at the
|
|
161
|
+
* plugin's package.json — same resolution path as `bun run` at
|
|
162
|
+
* runtime.
|
|
163
|
+
*/
|
|
164
|
+
resolveFrom?: (request: string) => string | undefined;
|
|
165
|
+
readFile?: (p: string) => string;
|
|
166
|
+
}): string | undefined {
|
|
167
|
+
if (pluginPkg.checkstack?.type === "frontend") {
|
|
168
|
+
return path.resolve(pluginCwd, pluginPkg.main ?? "src/index.tsx");
|
|
169
|
+
}
|
|
170
|
+
// Bundle primary — find a `-frontend` sibling and resolve its entry.
|
|
171
|
+
const resolver =
|
|
172
|
+
resolveFrom ??
|
|
173
|
+
((request: string): string | undefined => {
|
|
174
|
+
try {
|
|
175
|
+
return createRequire(path.join(pluginCwd, "package.json")).resolve(
|
|
176
|
+
request,
|
|
177
|
+
);
|
|
178
|
+
} catch {
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
const siblings = pluginPkg.checkstack?.bundle ?? [];
|
|
183
|
+
for (const sibling of siblings) {
|
|
184
|
+
if (!sibling.endsWith("-frontend")) continue;
|
|
185
|
+
const pkgJsonPath = resolver(`${sibling}/package.json`);
|
|
186
|
+
if (!pkgJsonPath) continue;
|
|
187
|
+
try {
|
|
188
|
+
const pkg = JSON.parse(readFile(pkgJsonPath)) as { main?: string };
|
|
189
|
+
return path.resolve(
|
|
190
|
+
path.dirname(pkgJsonPath),
|
|
191
|
+
pkg.main ?? "src/index.tsx",
|
|
192
|
+
);
|
|
193
|
+
} catch {
|
|
194
|
+
// sibling installed but malformed; try the next
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|