@checkstack/scripts 0.1.1 → 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.
@@ -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
+ }