@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.
- package/package.json +50 -0
- package/src/dev-deps-resolver.test.ts +411 -0
- package/src/dev-deps-resolver.ts +215 -0
- package/src/dev-frontend.test.ts +148 -0
- package/src/dev-frontend.ts +198 -0
- package/src/dev-internals.test.ts +506 -0
- package/src/dev-internals.ts +278 -0
- package/src/dev-lifecycle.test.ts +327 -0
- package/src/dev-lifecycle.ts +173 -0
- package/src/dev-server.ts +197 -0
|
@@ -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
|
+
}
|