@checkstack/dev-server 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,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
+ }