@checkstack/dev-server 1.0.2 → 2.1.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 +7 -7
- package/src/dev-frontend-css.test.ts +71 -0
- package/src/dev-frontend-css.ts +72 -0
- package/src/dev-frontend.test.ts +126 -1
- package/src/dev-frontend.ts +263 -16
- package/src/dev-server.ts +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/dev-server",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "2.1.0",
|
|
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,9 +24,9 @@
|
|
|
24
24
|
"lint:code": "eslint . --max-warnings 0"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@checkstack/common": "0.
|
|
28
|
-
"@vitejs/plugin-react": "^6.0.
|
|
29
|
-
"vite": "^8.0.
|
|
27
|
+
"@checkstack/common": "0.12.0",
|
|
28
|
+
"@vitejs/plugin-react": "^6.0.2",
|
|
29
|
+
"vite": "^8.0.16"
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
32
|
"@checkstack/backend": "*",
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@checkstack/tsconfig": "0.0.7",
|
|
45
|
-
"@checkstack/scripts": "0.3.
|
|
46
|
-
"@checkstack/backend": "0.
|
|
47
|
-
"@checkstack/frontend": "0.6.
|
|
45
|
+
"@checkstack/scripts": "0.3.4",
|
|
46
|
+
"@checkstack/backend": "0.15.0",
|
|
47
|
+
"@checkstack/frontend": "0.6.7",
|
|
48
48
|
"typescript": "^5.0.0"
|
|
49
49
|
}
|
|
50
50
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { buildDevTailwindContent } from "./dev-frontend-css";
|
|
4
|
+
|
|
5
|
+
const FRONTEND = "/nm/@checkstack/frontend";
|
|
6
|
+
const SRC_GLOB = "**/*.{js,ts,jsx,tsx}";
|
|
7
|
+
|
|
8
|
+
describe("buildDevTailwindContent", () => {
|
|
9
|
+
it("always covers the dev shell (frontend index.html + src)", () => {
|
|
10
|
+
const globs = buildDevTailwindContent({
|
|
11
|
+
frontendDir: FRONTEND,
|
|
12
|
+
pluginCwd: "/ws/packages/widget-backend",
|
|
13
|
+
pluginEntry: "/ws/packages/widget-frontend/src/index.tsx",
|
|
14
|
+
});
|
|
15
|
+
expect(globs).toContain(path.join(FRONTEND, "index.html"));
|
|
16
|
+
expect(globs).toContain(path.join(FRONTEND, "src", SRC_GLOB));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("includes the plugin's own sources (so custom classes compile)", () => {
|
|
20
|
+
const pluginCwd = "/ws/packages/widget-backend";
|
|
21
|
+
const pluginEntry = "/ws/packages/widget-frontend/src/index.tsx";
|
|
22
|
+
const globs = buildDevTailwindContent({
|
|
23
|
+
frontendDir: FRONTEND,
|
|
24
|
+
pluginCwd,
|
|
25
|
+
pluginEntry,
|
|
26
|
+
});
|
|
27
|
+
// The directory the entry lives in (a -frontend sibling) is scanned, so
|
|
28
|
+
// an author's custom utility classes in those .tsx files compile.
|
|
29
|
+
expect(globs).toContain(
|
|
30
|
+
path.join("/ws/packages/widget-frontend/src", SRC_GLOB),
|
|
31
|
+
);
|
|
32
|
+
// The dev cwd's own src is also scanned (covers the type === "frontend"
|
|
33
|
+
// case where cwd IS the frontend package).
|
|
34
|
+
expect(globs).toContain(path.join(pluginCwd, "src", SRC_GLOB));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("scans the cwd's own src when the plugin IS a -frontend package", () => {
|
|
38
|
+
const cwd = "/ws/packages/widget-frontend";
|
|
39
|
+
const globs = buildDevTailwindContent({
|
|
40
|
+
frontendDir: FRONTEND,
|
|
41
|
+
pluginCwd: cwd,
|
|
42
|
+
pluginEntry: path.join(cwd, "src/index.tsx"),
|
|
43
|
+
});
|
|
44
|
+
expect(globs).toContain(path.join(cwd, "src", SRC_GLOB));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("appends extra source dirs (e.g. @checkstack/ui) when provided", () => {
|
|
48
|
+
const globs = buildDevTailwindContent({
|
|
49
|
+
frontendDir: FRONTEND,
|
|
50
|
+
pluginCwd: "/ws/p/widget-backend",
|
|
51
|
+
pluginEntry: "/ws/p/widget-frontend/src/index.tsx",
|
|
52
|
+
extraSourceDirs: ["/nm/@checkstack/ui/src", "/nm/@checkstack/foo/src"],
|
|
53
|
+
});
|
|
54
|
+
expect(globs).toContain(path.join("/nm/@checkstack/ui/src", SRC_GLOB));
|
|
55
|
+
expect(globs).toContain(path.join("/nm/@checkstack/foo/src", SRC_GLOB));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("de-duplicates overlapping globs (cwd === entry package)", () => {
|
|
59
|
+
const cwd = "/ws/packages/widget-frontend";
|
|
60
|
+
const globs = buildDevTailwindContent({
|
|
61
|
+
frontendDir: FRONTEND,
|
|
62
|
+
pluginCwd: cwd,
|
|
63
|
+
// entry directly under cwd/src → entry dir scan and cwd/src scan coincide
|
|
64
|
+
pluginEntry: path.join(cwd, "src/index.tsx"),
|
|
65
|
+
});
|
|
66
|
+
const cwdSrcGlob = path.join(cwd, "src", SRC_GLOB);
|
|
67
|
+
expect(globs.filter((g) => g === cwdSrcGlob)).toHaveLength(1);
|
|
68
|
+
// No duplicates anywhere.
|
|
69
|
+
expect(new Set(globs).size).toBe(globs.length);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tailwind content-glob assembly for the standalone dev server's inline
|
|
3
|
+
* Vite/PostCSS pipeline.
|
|
4
|
+
*
|
|
5
|
+
* The dev server roots Vite at the installed `@checkstack/frontend`
|
|
6
|
+
* directory and applies the shared `@checkstack/frontend/tailwind-preset`
|
|
7
|
+
* (theme + `tailwindcss-animate`). The preset carries NO `content`, so we
|
|
8
|
+
* supply an explicit, ABSOLUTE content-glob set here. The load-bearing
|
|
9
|
+
* part is that it reaches the plugin under development's OWN source tree —
|
|
10
|
+
* otherwise a plugin author's custom Tailwind utility classes (anything
|
|
11
|
+
* outside the precompiled `@checkstack/ui` components) never compile into
|
|
12
|
+
* the dev CSS. We also cover the dev shell and any extra source dirs (e.g.
|
|
13
|
+
* the shared UI library) so their classes generate too.
|
|
14
|
+
*
|
|
15
|
+
* Pure: takes only resolved directory inputs and returns globs. No FS, no
|
|
16
|
+
* Tailwind, no Vite — so the unit suite can assert the glob shape without
|
|
17
|
+
* touching disk.
|
|
18
|
+
*/
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
|
|
21
|
+
export interface DevTailwindContentInput {
|
|
22
|
+
/** Installed `@checkstack/frontend` root (Vite `root`). */
|
|
23
|
+
frontendDir: string;
|
|
24
|
+
/** The plugin author's package directory (the dev `cwd`). */
|
|
25
|
+
pluginCwd: string;
|
|
26
|
+
/**
|
|
27
|
+
* Absolute path to the plugin's resolved frontend entry file (from
|
|
28
|
+
* `pickFrontendEntry`). Its package directory is scanned so the author's
|
|
29
|
+
* classes are picked up even when the entry lives in a `-frontend`
|
|
30
|
+
* sibling rather than `pluginCwd` itself.
|
|
31
|
+
*/
|
|
32
|
+
pluginEntry: string;
|
|
33
|
+
/**
|
|
34
|
+
* Resolved `src` directories of additional frontend packages whose
|
|
35
|
+
* components the dev shell renders (e.g. `@checkstack/ui`). Best-effort:
|
|
36
|
+
* callers pass whatever resolved cleanly; missing ones are omitted.
|
|
37
|
+
*/
|
|
38
|
+
extraSourceDirs?: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const SOURCE_GLOB = "**/*.{js,ts,jsx,tsx}";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build the absolute Tailwind `content` glob list for the dev pipeline.
|
|
45
|
+
* De-duplicated; order is dev-shell first, then extras, then the plugin's
|
|
46
|
+
* own sources (which are the whole point of this function).
|
|
47
|
+
*/
|
|
48
|
+
export function buildDevTailwindContent({
|
|
49
|
+
frontendDir,
|
|
50
|
+
pluginCwd,
|
|
51
|
+
pluginEntry,
|
|
52
|
+
extraSourceDirs = [],
|
|
53
|
+
}: DevTailwindContentInput): string[] {
|
|
54
|
+
const globs: string[] = [
|
|
55
|
+
// Dev shell.
|
|
56
|
+
path.join(frontendDir, "index.html"),
|
|
57
|
+
path.join(frontendDir, "src", SOURCE_GLOB),
|
|
58
|
+
// Extra source dirs (e.g. @checkstack/ui).
|
|
59
|
+
...extraSourceDirs.map((dir) => path.join(dir, SOURCE_GLOB)),
|
|
60
|
+
// The plugin's own sources — the load-bearing part. Cover the directory
|
|
61
|
+
// the entry file lives in (the `-frontend` package, possibly a sibling:
|
|
62
|
+
// e.g. `packages/widget-frontend/src/index.tsx` → scan that whole `src`
|
|
63
|
+
// tree) and the dev `cwd`'s own `src` (they differ for a bundle
|
|
64
|
+
// primary, and we want the author's classes whichever one holds the
|
|
65
|
+
// components).
|
|
66
|
+
path.join(path.dirname(pluginEntry), SOURCE_GLOB),
|
|
67
|
+
path.join(pluginCwd, "src", SOURCE_GLOB),
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
// De-dupe while preserving order.
|
|
71
|
+
return [...new Set(globs)];
|
|
72
|
+
}
|
package/src/dev-frontend.test.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { describe, it, expect } from "bun:test";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
pickFrontendEntry,
|
|
5
|
+
resolveFromCandidates,
|
|
6
|
+
findSiblingPackageDir,
|
|
7
|
+
} from "./dev-frontend";
|
|
4
8
|
|
|
5
9
|
const ROOT = "/plugin-author/repo";
|
|
6
10
|
|
|
@@ -145,4 +149,125 @@ describe("pickFrontendEntry", () => {
|
|
|
145
149
|
});
|
|
146
150
|
expect(entry).toBe("/nm/@my-org/widget-frontend/src/index.tsx");
|
|
147
151
|
});
|
|
152
|
+
|
|
153
|
+
it("falls back to a filesystem-sibling scan when node_modules resolution misses (standalone workspace)", () => {
|
|
154
|
+
// Mirrors the standalone scaffold: backend at packages/widget-backend,
|
|
155
|
+
// frontend at packages/widget-frontend — the latter only referenced via
|
|
156
|
+
// checkstack.bundle, never installed as a dependency, so require.resolve
|
|
157
|
+
// can never find it. The dev server must still pick it up.
|
|
158
|
+
const pluginCwd = "/ws/packages/widget-backend";
|
|
159
|
+
const entry = pickFrontendEntry({
|
|
160
|
+
pluginCwd,
|
|
161
|
+
pluginPkg: {
|
|
162
|
+
main: "src/index.ts",
|
|
163
|
+
checkstack: {
|
|
164
|
+
type: "backend",
|
|
165
|
+
bundle: ["@scope/widget-common", "@scope/widget-frontend"],
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
// node_modules resolution always misses for the workspace sibling.
|
|
169
|
+
resolveFrom: () => undefined,
|
|
170
|
+
findSiblingDir: (name) =>
|
|
171
|
+
name === "@scope/widget-frontend"
|
|
172
|
+
? "/ws/packages/widget-frontend/package.json"
|
|
173
|
+
: undefined,
|
|
174
|
+
readFile: () => JSON.stringify({ main: "src/index.tsx" }),
|
|
175
|
+
});
|
|
176
|
+
expect(entry).toBe("/ws/packages/widget-frontend/src/index.tsx");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("prefers node_modules resolution over the sibling-dir scan", () => {
|
|
180
|
+
let scanned = false;
|
|
181
|
+
const entry = pickFrontendEntry({
|
|
182
|
+
pluginCwd: ROOT,
|
|
183
|
+
pluginPkg: {
|
|
184
|
+
checkstack: { type: "backend", bundle: ["@my-org/widget-frontend"] },
|
|
185
|
+
},
|
|
186
|
+
resolveFrom: () => "/nm/@my-org/widget-frontend/package.json",
|
|
187
|
+
findSiblingDir: () => {
|
|
188
|
+
scanned = true;
|
|
189
|
+
return undefined;
|
|
190
|
+
},
|
|
191
|
+
readFile: () => JSON.stringify({ main: "src/index.tsx" }),
|
|
192
|
+
});
|
|
193
|
+
expect(entry).toBe("/nm/@my-org/widget-frontend/src/index.tsx");
|
|
194
|
+
expect(scanned).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("resolveFromCandidates", () => {
|
|
199
|
+
it("returns undefined for an unresolvable request without throwing", () => {
|
|
200
|
+
// The load-bearing behaviour for #251 bug 2: a missing module must NOT
|
|
201
|
+
// surface a (non-Error) throw to the caller. A real bun/node resolve of
|
|
202
|
+
// a guaranteed-absent specifier exercises the actual throw path.
|
|
203
|
+
const result = resolveFromCandidates({
|
|
204
|
+
request: "@checkstack/definitely-not-a-real-package-251/package.json",
|
|
205
|
+
candidateBasePaths: ["/no/such/base", process.cwd()],
|
|
206
|
+
});
|
|
207
|
+
expect(result).toBeUndefined();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("resolves a real package from the cwd candidate", () => {
|
|
211
|
+
const result = resolveFromCandidates({
|
|
212
|
+
request: "vite/package.json",
|
|
213
|
+
candidateBasePaths: [import.meta.dir],
|
|
214
|
+
});
|
|
215
|
+
expect(result).toBeDefined();
|
|
216
|
+
expect(result).toContain("vite");
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe("findSiblingPackageDir", () => {
|
|
221
|
+
const PLUGIN = "/ws/packages/widget-backend";
|
|
222
|
+
|
|
223
|
+
it("finds the sibling package.json by matching its name", () => {
|
|
224
|
+
const found = findSiblingPackageDir({
|
|
225
|
+
pluginCwd: PLUGIN,
|
|
226
|
+
siblingName: "@scope/widget-frontend",
|
|
227
|
+
listDir: (dir) => {
|
|
228
|
+
expect(dir).toBe("/ws/packages");
|
|
229
|
+
return ["widget-backend", "widget-common", "widget-frontend"];
|
|
230
|
+
},
|
|
231
|
+
readFile: (p) =>
|
|
232
|
+
JSON.stringify({
|
|
233
|
+
name: `@scope/${path.basename(path.dirname(p))}`,
|
|
234
|
+
}),
|
|
235
|
+
});
|
|
236
|
+
expect(found).toBe("/ws/packages/widget-frontend/package.json");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("returns undefined when no sibling matches", () => {
|
|
240
|
+
const found = findSiblingPackageDir({
|
|
241
|
+
pluginCwd: PLUGIN,
|
|
242
|
+
siblingName: "@scope/widget-frontend",
|
|
243
|
+
listDir: () => ["widget-backend", "widget-common"],
|
|
244
|
+
readFile: (p) =>
|
|
245
|
+
JSON.stringify({ name: `@scope/${path.basename(path.dirname(p))}` }),
|
|
246
|
+
});
|
|
247
|
+
expect(found).toBeUndefined();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("skips entries with unreadable or malformed package.json", () => {
|
|
251
|
+
const found = findSiblingPackageDir({
|
|
252
|
+
pluginCwd: PLUGIN,
|
|
253
|
+
siblingName: "@scope/widget-frontend",
|
|
254
|
+
listDir: () => ["broken", "widget-frontend"],
|
|
255
|
+
readFile: (p) => {
|
|
256
|
+
if (p.includes("broken")) throw new Error("ENOENT");
|
|
257
|
+
return JSON.stringify({ name: "@scope/widget-frontend" });
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
expect(found).toBe("/ws/packages/widget-frontend/package.json");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("returns undefined when the parent directory cannot be listed", () => {
|
|
264
|
+
const found = findSiblingPackageDir({
|
|
265
|
+
pluginCwd: PLUGIN,
|
|
266
|
+
siblingName: "@scope/widget-frontend",
|
|
267
|
+
listDir: () => {
|
|
268
|
+
throw new Error("ENOENT");
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
expect(found).toBeUndefined();
|
|
272
|
+
});
|
|
148
273
|
});
|
package/src/dev-frontend.ts
CHANGED
|
@@ -10,8 +10,50 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import path from "node:path";
|
|
12
12
|
import fs from "node:fs";
|
|
13
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
13
14
|
import { createRequire } from "node:module";
|
|
14
15
|
import type { ViteDevServer } from "vite";
|
|
16
|
+
import { buildDevTailwindContent } from "./dev-frontend-css";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Directory of THIS module (`@checkstack/dev-server`'s installed location).
|
|
20
|
+
* `@checkstack/frontend`, `vite`, and `@vitejs/plugin-react` are
|
|
21
|
+
* dev-server's own (peer/runtime) dependencies, so under a published
|
|
22
|
+
* `bun install` layout they are reachable from here even though they are
|
|
23
|
+
* NOT reachable from the plugin author's package — the plugin only
|
|
24
|
+
* declares `@checkstack/dev-server`, never `@checkstack/frontend` or Vite
|
|
25
|
+
* directly. See {@link resolveFromCandidates}.
|
|
26
|
+
*/
|
|
27
|
+
const DEV_SERVER_MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve a package request from the FIRST base path under which it is
|
|
31
|
+
* reachable, returning the resolved path or `undefined`.
|
|
32
|
+
*
|
|
33
|
+
* Bun's `createRequire(...).resolve()` throws a value that is NOT an
|
|
34
|
+
* `instanceof Error` for an unresolvable specifier (a `ResolveError`-like
|
|
35
|
+
* object), so a bare `try/catch` that rethrows leaks a non-Error up the
|
|
36
|
+
* stack — which is exactly why the caller's `extractErrorMessage` used to
|
|
37
|
+
* print the useless `"An error occurred"` fallback. We swallow the throw
|
|
38
|
+
* per-candidate here and let the caller decide what to do with a clean
|
|
39
|
+
* `undefined`.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveFromCandidates({
|
|
42
|
+
request,
|
|
43
|
+
candidateBasePaths,
|
|
44
|
+
}: {
|
|
45
|
+
request: string;
|
|
46
|
+
candidateBasePaths: string[];
|
|
47
|
+
}): string | undefined {
|
|
48
|
+
for (const base of candidateBasePaths) {
|
|
49
|
+
try {
|
|
50
|
+
return createRequire(path.join(base, "package.json")).resolve(request);
|
|
51
|
+
} catch {
|
|
52
|
+
// not reachable from this base — try the next
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
15
57
|
|
|
16
58
|
// `vite` and `@vitejs/plugin-react` are lazily imported inside
|
|
17
59
|
// `startFrontendDevServer` so that consumers of this module which only
|
|
@@ -44,11 +86,24 @@ export async function startFrontendDevServer({
|
|
|
44
86
|
]);
|
|
45
87
|
const react = reactModule.default;
|
|
46
88
|
|
|
47
|
-
// Resolve the @checkstack/frontend package
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
89
|
+
// Resolve the @checkstack/frontend package. In the monorepo it is
|
|
90
|
+
// reachable from the plugin's own package; under a PUBLISHED
|
|
91
|
+
// `bun install`, the plugin only depends on @checkstack/dev-server, so
|
|
92
|
+
// @checkstack/frontend (dev-server's peer dep) is reachable from THIS
|
|
93
|
+
// module's install location, not the plugin's. Try the plugin first so a
|
|
94
|
+
// plugin that pins its own @checkstack/frontend wins, then fall back to
|
|
95
|
+
// dev-server's location. (Before this fallback, the resolve threw a
|
|
96
|
+
// non-Error under Bun and the dev server logged "An error occurred" and
|
|
97
|
+
// ran backend-only — #251 bug 2.)
|
|
98
|
+
const frontendPkgJsonPath = resolveFromCandidates({
|
|
99
|
+
request: "@checkstack/frontend/package.json",
|
|
100
|
+
candidateBasePaths: [pluginCwd, DEV_SERVER_MODULE_DIR],
|
|
101
|
+
});
|
|
102
|
+
if (!frontendPkgJsonPath) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
"Could not resolve @checkstack/frontend. It ships as a dependency of @checkstack/dev-server; ensure your plugin's dev dependencies are installed (`bun install`).",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
52
107
|
const frontendDir = path.dirname(frontendPkgJsonPath);
|
|
53
108
|
const indexHtmlPath = path.join(frontendDir, "index.html");
|
|
54
109
|
if (!fs.existsSync(indexHtmlPath)) {
|
|
@@ -59,23 +114,30 @@ export async function startFrontendDevServer({
|
|
|
59
114
|
|
|
60
115
|
// Resolve the plugin under dev's main entry — Vite's alias maps the
|
|
61
116
|
// virtual import `virtual:checkstack-dev-plugin` to this file.
|
|
117
|
+
// Cast: `JSON.parse` is typed `any`; we read only these optional fields
|
|
118
|
+
// and every consumer treats them as optional, so an unexpected shape
|
|
119
|
+
// narrows to `undefined` rather than crashing.
|
|
62
120
|
const pluginPkg = JSON.parse(
|
|
63
121
|
fs.readFileSync(path.join(pluginCwd, "package.json"), "utf8"),
|
|
64
122
|
) as { main?: string; checkstack?: { type?: string; bundle?: string[] } };
|
|
65
123
|
// Bundle primaries point at their own backend main, but the frontend
|
|
66
124
|
// entry lives in a sibling. Best-effort: if the cwd's checkstack.type
|
|
67
125
|
// is "frontend", use cwd; else look for a sibling -frontend package
|
|
68
|
-
// listed in `checkstack.bundle
|
|
126
|
+
// listed in `checkstack.bundle`. We resolve the sibling from the plugin
|
|
127
|
+
// first, then from dev-server's location (published layout), and finally
|
|
128
|
+
// fall back to scanning sibling directories — a standalone workspace's
|
|
129
|
+
// own `-frontend` package lives at `packages/<base>-frontend` and is NOT
|
|
130
|
+
// a node_modules entry (nothing depends on it as an npm package; it is
|
|
131
|
+
// only referenced via `checkstack.bundle`), so `require.resolve` can
|
|
132
|
+
// never find it. See {@link findSiblingPackageDir}.
|
|
69
133
|
const pluginEntry = pickFrontendEntry({
|
|
70
134
|
pluginCwd,
|
|
71
135
|
pluginPkg,
|
|
72
|
-
resolveFrom: (request) =>
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
},
|
|
136
|
+
resolveFrom: (request) =>
|
|
137
|
+
resolveFromCandidates({
|
|
138
|
+
request,
|
|
139
|
+
candidateBasePaths: [pluginCwd, DEV_SERVER_MODULE_DIR],
|
|
140
|
+
}),
|
|
79
141
|
});
|
|
80
142
|
if (!pluginEntry) {
|
|
81
143
|
throw new Error(
|
|
@@ -83,6 +145,19 @@ export async function startFrontendDevServer({
|
|
|
83
145
|
);
|
|
84
146
|
}
|
|
85
147
|
|
|
148
|
+
// Build the Tailwind/PostCSS pipeline for the dev shell. `@checkstack/
|
|
149
|
+
// frontend` now ships the Tailwind toolchain as RUNTIME deps and exports
|
|
150
|
+
// a `tailwind-preset` subpath, so we can assemble the pipeline from a
|
|
151
|
+
// published install. The key win over the frontend's own config: we
|
|
152
|
+
// inject the plugin under development's source globs into `content`, so a
|
|
153
|
+
// plugin author's custom utility classes compile in dev. Returns
|
|
154
|
+
// `undefined` (→ Vite default PostCSS) if the toolchain can't be loaded.
|
|
155
|
+
const postcssPlugins = await loadDevPostcssPlugins({
|
|
156
|
+
frontendDir,
|
|
157
|
+
pluginCwd,
|
|
158
|
+
pluginEntry,
|
|
159
|
+
});
|
|
160
|
+
|
|
86
161
|
const server = await createViteServer({
|
|
87
162
|
root: frontendDir,
|
|
88
163
|
configFile: false, // we control the config inline
|
|
@@ -98,6 +173,11 @@ export async function startFrontendDevServer({
|
|
|
98
173
|
"/assets/plugins": { target: backendUrl, changeOrigin: true },
|
|
99
174
|
},
|
|
100
175
|
},
|
|
176
|
+
// Explicit PostCSS chain (Tailwind preset + plugin globs + autoprefixer)
|
|
177
|
+
// when it could be assembled — overrides Vite's auto-discovery of the
|
|
178
|
+
// frontend's own `postcss.config.js`, whose Tailwind `content` globs are
|
|
179
|
+
// relative to the dev cwd and never reach the plugin under development.
|
|
180
|
+
...(postcssPlugins ? { css: { postcss: { plugins: postcssPlugins } } } : {}),
|
|
101
181
|
// Replace the production `main.tsx` entry with our `dev-main.tsx`
|
|
102
182
|
// shell. Vite will pick this up via `index.html`'s `<script>` tag,
|
|
103
183
|
// because dev-main.tsx imports `virtual:checkstack-dev-plugin`,
|
|
@@ -134,13 +214,163 @@ export async function startFrontendDevServer({
|
|
|
134
214
|
return server;
|
|
135
215
|
}
|
|
136
216
|
|
|
217
|
+
/**
|
|
218
|
+
* The Vite config types `css.postcss` as a `string | (ProcessOptions & {
|
|
219
|
+
* plugins?: PostCSS.AcceptedPlugin[] })`. We pull the plugin element type
|
|
220
|
+
* straight out of that shape so the helper stays free of `any` without
|
|
221
|
+
* depending on the `postcss` types package directly (we only depend on
|
|
222
|
+
* `vite`, which re-exposes them).
|
|
223
|
+
*/
|
|
224
|
+
type ViteCss = NonNullable<import("vite").UserConfig["css"]>;
|
|
225
|
+
type ViteCssPostcssObject = Extract<ViteCss["postcss"], { plugins?: unknown }>;
|
|
226
|
+
type PostcssPlugin = NonNullable<ViteCssPostcssObject["plugins"]>[number];
|
|
227
|
+
|
|
228
|
+
/** The fields we read off `@checkstack/frontend`'s tailwind preset. */
|
|
229
|
+
interface TailwindPreset {
|
|
230
|
+
[key: string]: unknown;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Assemble the dev PostCSS plugin chain — the shared `@checkstack/frontend`
|
|
235
|
+
* Tailwind preset (theme + `tailwindcss-animate`) with a `content` glob set
|
|
236
|
+
* that reaches the plugin author's OWN sources, plus autoprefixer.
|
|
237
|
+
*
|
|
238
|
+
* Returns `undefined` when the chain cannot be built (e.g. an older
|
|
239
|
+
* `@checkstack/frontend` without the preset/toolchain, or `tailwindcss` not
|
|
240
|
+
* resolvable). In that case the caller falls back to Vite's default PostCSS
|
|
241
|
+
* auto-discovery — the dev server still starts. This mirrors the rest of
|
|
242
|
+
* this module: a CSS-pipeline hiccup must never take the dev server down.
|
|
243
|
+
*/
|
|
244
|
+
async function loadDevPostcssPlugins({
|
|
245
|
+
frontendDir,
|
|
246
|
+
pluginCwd,
|
|
247
|
+
pluginEntry,
|
|
248
|
+
}: {
|
|
249
|
+
frontendDir: string;
|
|
250
|
+
pluginCwd: string;
|
|
251
|
+
pluginEntry: string;
|
|
252
|
+
}): Promise<PostcssPlugin[] | undefined> {
|
|
253
|
+
try {
|
|
254
|
+
// `tailwindcss` + `autoprefixer` ship as `@checkstack/frontend`'s
|
|
255
|
+
// RUNTIME deps, so they are reachable from the frontend root in any
|
|
256
|
+
// layout (monorepo or published install).
|
|
257
|
+
const frontendRequire = createRequire(
|
|
258
|
+
path.join(frontendDir, "package.json"),
|
|
259
|
+
);
|
|
260
|
+
const tailwindModule = await import(
|
|
261
|
+
pathToFileURL(frontendRequire.resolve("tailwindcss")).href
|
|
262
|
+
);
|
|
263
|
+
const autoprefixerModule = await import(
|
|
264
|
+
pathToFileURL(frontendRequire.resolve("autoprefixer")).href
|
|
265
|
+
);
|
|
266
|
+
// Cast: dynamic `import()` of CJS/ESM packages is typed `any` for the
|
|
267
|
+
// namespace, so `.default` is untyped. `tailwindcss` and `autoprefixer`
|
|
268
|
+
// are the canonical PostCSS-plugin factories; we assert the call
|
|
269
|
+
// signatures we actually use. A wrong-shaped module is caught by the
|
|
270
|
+
// surrounding try/catch (falls back to Vite's default PostCSS).
|
|
271
|
+
const tailwindcss = tailwindModule.default as (
|
|
272
|
+
config: TailwindPreset & { content: string[] },
|
|
273
|
+
) => PostcssPlugin;
|
|
274
|
+
const autoprefixer = autoprefixerModule.default as () => PostcssPlugin;
|
|
275
|
+
|
|
276
|
+
// The shared theme + tailwindcss-animate, exported as a public subpath
|
|
277
|
+
// so we don't reach into frontend internals. It carries no `content`.
|
|
278
|
+
const presetPath = frontendRequire.resolve(
|
|
279
|
+
"@checkstack/frontend/tailwind-preset",
|
|
280
|
+
);
|
|
281
|
+
const presetModule = await import(pathToFileURL(presetPath).href);
|
|
282
|
+
// Cast: the preset is a JS module typed `any`; we spread it into the
|
|
283
|
+
// tailwind config and only add `content`, so the loose shape suffices.
|
|
284
|
+
const preset = (presetModule.default ?? presetModule) as TailwindPreset;
|
|
285
|
+
|
|
286
|
+
// Best-effort: include the shared UI library so its source classes (the
|
|
287
|
+
// dev shell renders `@checkstack/ui` components) compile too. Its
|
|
288
|
+
// resolved package dir is `<pkg>/src`.
|
|
289
|
+
const extraSourceDirs: string[] = [];
|
|
290
|
+
const uiPkgJson = resolveFromCandidates({
|
|
291
|
+
request: "@checkstack/ui/package.json",
|
|
292
|
+
candidateBasePaths: [frontendDir, pluginCwd, DEV_SERVER_MODULE_DIR],
|
|
293
|
+
});
|
|
294
|
+
if (uiPkgJson) {
|
|
295
|
+
extraSourceDirs.push(path.join(path.dirname(uiPkgJson), "src"));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const content = buildDevTailwindContent({
|
|
299
|
+
frontendDir,
|
|
300
|
+
pluginCwd,
|
|
301
|
+
pluginEntry,
|
|
302
|
+
extraSourceDirs,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
return [tailwindcss({ ...preset, content }), autoprefixer()];
|
|
306
|
+
} catch {
|
|
307
|
+
// Any failure → fall back to Vite's default PostCSS discovery.
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Find the `package.json` of a bundle sibling that lives as a FILESYSTEM
|
|
314
|
+
* sibling of the plugin (not a node_modules entry).
|
|
315
|
+
*
|
|
316
|
+
* A standalone scaffold lays the trio out as `packages/<base>-common`,
|
|
317
|
+
* `packages/<base>-backend`, `packages/<base>-frontend`. The `-frontend`
|
|
318
|
+
* package is referenced only via `checkstack.bundle` — nothing `depends`
|
|
319
|
+
* on it as an npm package — so bun never links it into any
|
|
320
|
+
* `node_modules`, and `require.resolve("@scope/<base>-frontend")` cannot
|
|
321
|
+
* find it from anywhere. We instead scan the directories next to the
|
|
322
|
+
* plugin's own directory for one whose `package.json#name` matches.
|
|
323
|
+
*
|
|
324
|
+
* Pure: every FS touch is injected so the unit suite can drive it without
|
|
325
|
+
* disk.
|
|
326
|
+
*/
|
|
327
|
+
export function findSiblingPackageDir({
|
|
328
|
+
pluginCwd,
|
|
329
|
+
siblingName,
|
|
330
|
+
listDir = (dir) =>
|
|
331
|
+
fs
|
|
332
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
333
|
+
.filter((e) => e.isDirectory())
|
|
334
|
+
.map((e) => e.name),
|
|
335
|
+
readFile = (p) => fs.readFileSync(p, "utf8"),
|
|
336
|
+
}: {
|
|
337
|
+
pluginCwd: string;
|
|
338
|
+
siblingName: string;
|
|
339
|
+
/** List the immediate subdirectory names of a directory. */
|
|
340
|
+
listDir?: (dir: string) => string[];
|
|
341
|
+
readFile?: (p: string) => string;
|
|
342
|
+
}): string | undefined {
|
|
343
|
+
const parent = path.dirname(pluginCwd);
|
|
344
|
+
let entries: string[];
|
|
345
|
+
try {
|
|
346
|
+
entries = listDir(parent);
|
|
347
|
+
} catch {
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
for (const name of entries) {
|
|
351
|
+
const candidate = path.join(parent, name, "package.json");
|
|
352
|
+
try {
|
|
353
|
+
// Cast: `JSON.parse` is typed `any`; we only compare the optional
|
|
354
|
+
// `name` string. A malformed/unexpected shape simply won't match.
|
|
355
|
+
const pkg = JSON.parse(readFile(candidate)) as { name?: string };
|
|
356
|
+
if (pkg.name === siblingName) return candidate;
|
|
357
|
+
} catch {
|
|
358
|
+
// not a package dir, unreadable, or malformed — skip
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
363
|
+
|
|
137
364
|
/**
|
|
138
365
|
* Resolve the entry file Vite should load for the plugin's frontend.
|
|
139
366
|
*
|
|
140
367
|
* - For a `-frontend` plugin: the cwd's own `main` field.
|
|
141
368
|
* - For a bundle primary (e.g. `-backend` with `checkstack.bundle`
|
|
142
|
-
* listing a `-frontend` sibling): the sibling's `main
|
|
143
|
-
* through node_modules
|
|
369
|
+
* listing a `-frontend` sibling): the sibling's `main`. Resolved
|
|
370
|
+
* through node_modules when the sibling is an installed package, or —
|
|
371
|
+
* for a standalone workspace where the sibling is only a filesystem
|
|
372
|
+
* neighbour — by scanning sibling directories (see
|
|
373
|
+
* {@link findSiblingPackageDir}).
|
|
144
374
|
*
|
|
145
375
|
* Pure: all FS lookups go through injected hooks so the test suite can
|
|
146
376
|
* drive every branch without touching disk.
|
|
@@ -149,6 +379,7 @@ export function pickFrontendEntry({
|
|
|
149
379
|
pluginCwd,
|
|
150
380
|
pluginPkg,
|
|
151
381
|
resolveFrom,
|
|
382
|
+
findSiblingDir,
|
|
152
383
|
readFile = (p) => fs.readFileSync(p, "utf8"),
|
|
153
384
|
}: {
|
|
154
385
|
pluginCwd: string;
|
|
@@ -162,6 +393,12 @@ export function pickFrontendEntry({
|
|
|
162
393
|
* runtime.
|
|
163
394
|
*/
|
|
164
395
|
resolveFrom?: (request: string) => string | undefined;
|
|
396
|
+
/**
|
|
397
|
+
* Optional injection: locate a bundle sibling that lives as a filesystem
|
|
398
|
+
* neighbour rather than a node_modules entry. Defaults to
|
|
399
|
+
* {@link findSiblingPackageDir} rooted at the plugin's directory.
|
|
400
|
+
*/
|
|
401
|
+
findSiblingDir?: (siblingName: string) => string | undefined;
|
|
165
402
|
readFile?: (p: string) => string;
|
|
166
403
|
}): string | undefined {
|
|
167
404
|
if (pluginPkg.checkstack?.type === "frontend") {
|
|
@@ -179,12 +416,22 @@ export function pickFrontendEntry({
|
|
|
179
416
|
return undefined;
|
|
180
417
|
}
|
|
181
418
|
});
|
|
419
|
+
const siblingDirFinder =
|
|
420
|
+
findSiblingDir ??
|
|
421
|
+
((siblingName: string): string | undefined =>
|
|
422
|
+
findSiblingPackageDir({ pluginCwd, siblingName, readFile }));
|
|
182
423
|
const siblings = pluginPkg.checkstack?.bundle ?? [];
|
|
183
424
|
for (const sibling of siblings) {
|
|
184
425
|
if (!sibling.endsWith("-frontend")) continue;
|
|
185
|
-
|
|
426
|
+
// node_modules resolution first (monorepo / installed sibling), then a
|
|
427
|
+
// filesystem-neighbour scan (standalone workspace whose -frontend is a
|
|
428
|
+
// sibling package, not a dependency).
|
|
429
|
+
const pkgJsonPath =
|
|
430
|
+
resolver(`${sibling}/package.json`) ?? siblingDirFinder(sibling);
|
|
186
431
|
if (!pkgJsonPath) continue;
|
|
187
432
|
try {
|
|
433
|
+
// Cast: `JSON.parse` is typed `any`; we read only the optional
|
|
434
|
+
// `main` field and default it when absent.
|
|
188
435
|
const pkg = JSON.parse(readFile(pkgJsonPath)) as { main?: string };
|
|
189
436
|
return path.resolve(
|
|
190
437
|
path.dirname(pkgJsonPath),
|
package/src/dev-server.ts
CHANGED
|
@@ -165,7 +165,7 @@ export async function runDevServer(rawArgs: string[]): Promise<number> {
|
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
function printHelp(): void {
|
|
168
|
-
console.log(String.raw`Usage: checkstack-
|
|
168
|
+
console.log(String.raw`Usage: checkstack-dev [options]
|
|
169
169
|
|
|
170
170
|
Spins up a local Checkstack dev server with the current directory's
|
|
171
171
|
plugin loaded. Same boot code as production — the only differences are:
|
|
@@ -173,6 +173,10 @@ plugin loaded. Same boot code as production — the only differences are:
|
|
|
173
173
|
* a synthetic dev auth grants every access rule (no login)
|
|
174
174
|
* the backend restarts on changes under ./src
|
|
175
175
|
|
|
176
|
+
This binary ships in the @checkstack/dev-server package. Add it as a
|
|
177
|
+
devDependency and wire "dev": "checkstack-dev" in your package.json, or
|
|
178
|
+
run it directly via: bunx @checkstack/dev-server
|
|
179
|
+
|
|
176
180
|
Options:
|
|
177
181
|
--cwd <dir> Plugin directory (default: process.cwd())
|
|
178
182
|
--port <num> Backend HTTP port (default: 3000 or $PORT)
|