@jxsuite/compiler 0.35.0 → 1.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/compiler",
3
- "version": "0.35.0",
3
+ "version": "1.1.0",
4
4
  "description": "Jx static HTML compiler, island detector, and site builder",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -19,15 +19,17 @@
19
19
  "type": "module",
20
20
  "exports": {
21
21
  ".": "./src/compiler.ts",
22
- "./content-loader": "./src/site/content-loader.ts",
23
22
  "./format-host": "./src/site/format-host.ts",
23
+ "./project-sections": "./src/site/project-sections.ts",
24
+ "./schema-command": "./src/site/schema-command.ts",
24
25
  "./context-injection": "./src/site/context-injection.ts",
25
26
  "./head-merger": "./src/site/head-merger.ts",
26
27
  "./layout-resolver": "./src/site/layout-resolver.ts",
27
28
  "./pages-discovery": "./src/site/pages-discovery.ts",
28
29
  "./site": "./src/site/site-build.ts",
29
30
  "./site-loader": "./src/site/site-loader.ts",
30
- "./types": "./src/types.ts"
31
+ "./types": "./src/types.ts",
32
+ "./db-push": "./src/site/db-push.ts"
31
33
  },
32
34
  "publishConfig": {
33
35
  "provenance": true
@@ -42,14 +44,16 @@
42
44
  "upgrade": "bunx npm-check-updates -u && bun install"
43
45
  },
44
46
  "dependencies": {
45
- "@apidevtools/json-schema-ref-parser": "^15.4.0",
46
- "@jxsuite/parser": "^0.35.1",
47
- "@jxsuite/runtime": "^0.34.2",
48
- "@jxsuite/schema": "^0.35.0",
47
+ "@apidevtools/json-schema-ref-parser": "^15.5.0",
48
+ "@jxsuite/create": "^1.1.0",
49
+ "@jxsuite/runtime": "^1.1.0",
50
+ "@jxsuite/schema": "^1.1.0",
49
51
  "sharp": "^0.35.3"
50
52
  },
51
53
  "devDependencies": {
52
54
  "@happy-dom/global-registrator": "^20.10.6",
55
+ "@jxsuite/connector": "^0.3.0",
56
+ "@jxsuite/parser": "^1.1.0",
53
57
  "@types/mdast": "^4.0.4"
54
58
  }
55
59
  }
package/src/cli.ts CHANGED
@@ -2,12 +2,13 @@
2
2
  /**
3
3
  * Jx — Unified CLI for the Jx platform
4
4
  *
5
- * Commands: jx build [project-root] [--verbose] [--no-clean]
5
+ * Commands: jx build [project-root] [--verbose] [--no-clean] jx schema [project-root] jx validate
6
+ * [project-root]
6
7
  *
7
8
  * @module jx-cli
8
9
  */
9
10
 
10
- import { resolve } from "node:path";
11
+ import { relative, resolve } from "node:path";
11
12
 
12
13
  // The CLI body runs inside an async function rather than via top-level await: when a test pulls this
13
14
  // Entry in with a dynamic import(), Bun's test runtime drops the continuation after a top-level await
@@ -21,20 +22,89 @@ async function main() {
21
22
  console.log(`Usage: jx <command> [options]
22
23
 
23
24
  Commands:
24
- build [root] Build a Jx site to dist/
25
+ build [root] Build a Jx site to dist/
26
+ dev [root] Start the dev server (requires @jxsuite/server and Bun)
27
+ preview [root] Serve an already-built dist/ directory
28
+ schema [root] Generate project.schema.json + document.schema.json from project.json#/extensions
29
+ validate [root] Validate project.json against its generated project.schema.json
30
+ db push [root] Sync the data section's tables to their connections (additive-only)
25
31
 
26
32
  Options:
27
33
  --verbose Print detailed build progress
28
- --no-clean Don't clean outDir before building`);
34
+ --no-clean Don't clean outDir before building
35
+ --port <n> dev/preview: port to listen on (default 3000 / 4173)
36
+ --dry-run db push: print the statements without executing them
37
+ --connection db push: restrict to one connection name`);
29
38
  process.exit(0);
30
39
  }
31
40
 
32
- if (command === "build") {
33
- const rest = args.slice(1);
34
- const flags = new Set(rest.filter((a) => a.startsWith("--")));
35
- const positional = rest.find((a) => !a.startsWith("--"));
41
+ // `db push` is a two-word command; normalize it before the flag/positional split.
42
+ const isDb = command === "db";
43
+ const dbSubcommand = isDb ? args[1] : null;
44
+ const rest = args.slice(isDb ? 2 : 1);
45
+ const flags = new Set<string>();
46
+ const positionals: string[] = [];
47
+ let connectionArg: string | undefined;
48
+ let portArg: number | undefined;
49
+ for (let index = 0; index < rest.length; index += 1) {
50
+ const arg = rest[index]!;
51
+ if (arg === "--connection") {
52
+ index += 1;
53
+ connectionArg = rest[index];
54
+ } else if (arg === "--port") {
55
+ index += 1;
56
+ const parsed = Math.trunc(Number(rest[index] ?? ""));
57
+ if (Number.isFinite(parsed) && parsed >= 0) {
58
+ portArg = parsed;
59
+ }
60
+ } else if (arg.startsWith("--")) {
61
+ flags.add(arg);
62
+ } else {
63
+ positionals.push(arg);
64
+ }
65
+ }
66
+ const [positional] = positionals;
67
+ const projectRoot = resolve(positional ?? ".");
36
68
 
37
- const projectRoot = resolve(positional ?? ".");
69
+ if (isDb) {
70
+ if (dbSubcommand !== "push") {
71
+ console.error(
72
+ `Unknown db subcommand: ${dbSubcommand ?? "(none)"}\nUsage: jx db push [root] [--dry-run] [--connection <name>]`,
73
+ );
74
+ process.exit(1);
75
+ }
76
+ const dryRun = flags.has("--dry-run");
77
+ try {
78
+ const { dbPush } = await import("./site/db-push.ts");
79
+ const { results, bindingsPatched, wranglerPath } = await dbPush(projectRoot, {
80
+ dryRun,
81
+ ...(connectionArg === undefined ? {} : { connection: connectionArg }),
82
+ });
83
+ for (const result of results) {
84
+ const mode = result.applied ? "applied" : "dry-run";
85
+ console.log(
86
+ `${result.connection} (${result.provider}) — ${result.tables.length} table(s), ` +
87
+ `${result.statements.length} statement(s) [${mode}]`,
88
+ );
89
+ for (const statement of result.statements) {
90
+ console.log(` ${statement}`);
91
+ }
92
+ for (const warning of result.warnings) {
93
+ console.warn(` warning: ${warning}`);
94
+ }
95
+ }
96
+ if (bindingsPatched && wranglerPath) {
97
+ console.log(`Updated bindings in ${relative(projectRoot, wranglerPath)}`);
98
+ }
99
+ } catch (error) {
100
+ const err = error as Error;
101
+ console.error(`db push failed: ${err.message}`);
102
+ process.exit(1);
103
+ }
104
+ return;
105
+ }
106
+
107
+ if (command === "build") {
38
108
  const verbose = flags.has("--verbose");
39
109
  const clean = !flags.has("--no-clean");
40
110
 
@@ -58,6 +128,59 @@ Options:
58
128
  console.error(`Build failed: ${err.message}`);
59
129
  process.exit(1);
60
130
  }
131
+ } else if (command === "dev") {
132
+ try {
133
+ const { runDev } = await import("./site/dev-command.ts");
134
+ runDev(projectRoot, portArg);
135
+ } catch (error) {
136
+ const err = error as Error;
137
+ console.error(err.message);
138
+ process.exit(1);
139
+ }
140
+ } else if (command === "preview") {
141
+ const { existsSync } = await import("node:fs");
142
+ const distDir = resolve(projectRoot, "dist");
143
+ if (!existsSync(distDir)) {
144
+ console.error(`No build output at ${distDir} — run \`jx build\` first.`);
145
+ process.exit(1);
146
+ }
147
+ const { startPreviewServer } = await import("./site/preview-server.ts");
148
+ const port = portArg ?? 4173;
149
+ startPreviewServer(distDir, port);
150
+ console.log(`Previewing ${distDir} at http://localhost:${port}`);
151
+ } else if (command === "schema") {
152
+ try {
153
+ const { writeProjectSchemas } = await import("./site/schema-command.ts");
154
+ const { projectSchemaPath, documentSchemaPath } = await writeProjectSchemas(projectRoot);
155
+ console.log(`Wrote ${relative(projectRoot, projectSchemaPath)}`);
156
+ console.log(`Wrote ${relative(projectRoot, documentSchemaPath)}`);
157
+ } catch (error) {
158
+ const err = error as Error;
159
+ console.error(`Schema generation failed: ${err.message}`);
160
+ process.exit(1);
161
+ }
162
+ } else if (command === "validate") {
163
+ try {
164
+ const { validateProjectFile } = await import("@jxsuite/schema/validate-project");
165
+ const { valid, errors } = await validateProjectFile(projectRoot);
166
+ if (valid) {
167
+ console.log(`project.json is valid (${projectRoot})`);
168
+ } else {
169
+ console.error(`project.json is INVALID (${projectRoot}):`);
170
+ for (const error of errors ?? []) {
171
+ const { instancePath, message } = (error ?? {}) as {
172
+ instancePath?: string;
173
+ message?: string;
174
+ };
175
+ console.error(` - ${instancePath || "/"}: ${message ?? JSON.stringify(error)}`);
176
+ }
177
+ process.exit(1);
178
+ }
179
+ } catch (error) {
180
+ const err = error as Error;
181
+ console.error(`Validation failed: ${err.message}`);
182
+ process.exit(1);
183
+ }
61
184
  } else {
62
185
  console.error(`Unknown command: ${command}\nRun "jx --help" for usage.`);
63
186
  process.exit(1);
package/src/shared.ts CHANGED
@@ -8,11 +8,14 @@
8
8
 
9
9
  import { RESERVED_KEYS, camelToKebab, toCSSText } from "@jxsuite/runtime";
10
10
  import { evaluateExpression, isMutating } from "@jxsuite/runtime/expression";
11
+ import { runStatements } from "@jxsuite/runtime/statements";
11
12
  import {
12
13
  childrenContainArray,
14
+ hasStructuredBody,
13
15
  isExpressionDef,
14
16
  isFunctionDef,
15
17
  isMappedArray,
18
+ isNamedFormulaDef,
16
19
  isPrototypeDef,
17
20
  isRef,
18
21
  isSchemaOnlyDef as isSchemaOnly,
@@ -35,6 +38,28 @@ import type {
35
38
  // Re-export runtime utilities used by submodules
36
39
  export { RESERVED_KEYS, camelToKebab, toCSSText } from "@jxsuite/runtime";
37
40
  export { compileExpression, evaluateExpression, isMutating } from "@jxsuite/runtime/expression";
41
+ export { compileStatements, runStatements } from "@jxsuite/runtime/statements";
42
+
43
+ /**
44
+ * Emit the JS source of a named formula's scope callable: positional arguments are mapped onto the
45
+ * declared parameter names (honoring CemParameter defaults) as the `_args` object the compiled
46
+ * body's `$args/` refs read from. Call sites stay positional, matching the interpreter.
47
+ */
48
+ export function emitFormulaFn(def: { parameters?: unknown[] }, compiledBody: string): string {
49
+ const entries: string[] = [];
50
+ for (const [i, p] of (def.parameters ?? []).entries()) {
51
+ const name = typeof p === "string" ? p : ((p as { name?: string } | null)?.name ?? "");
52
+ if (!name) {
53
+ continue;
54
+ }
55
+ const hasDefault = typeof p === "object" && p !== null && "default" in p;
56
+ const arg = hasDefault
57
+ ? `_a[${i}] === undefined ? ${JSON.stringify((p as { default?: unknown }).default)} : _a[${i}]`
58
+ : `_a[${i}]`;
59
+ entries.push(`${JSON.stringify(name)}: ${arg}`);
60
+ }
61
+ return `(..._a) => { const _args = { ${entries.join(", ")} }; return ${compiledBody}; }`;
62
+ }
38
63
  export type { ExpressionNode } from "@jxsuite/runtime/expression";
39
64
 
40
65
  // CDN defaults
@@ -283,7 +308,24 @@ export function buildInitialScope(
283
308
  }
284
309
  if (isExpressionDef(def)) {
285
310
  const node = def.$expression as ExpressionNode;
286
- if (isMutating(node.operator)) {
311
+ if (isNamedFormulaDef(def)) {
312
+ // Named formula: callable, positional args mapped onto its declared parameters.
313
+ const params = def.parameters as (string | { name?: string; default?: unknown })[];
314
+ setOwnScopeValue(scope, key, (...argValues: unknown[]) => {
315
+ const args: Record<string, unknown> = {};
316
+ for (const [i, p] of params.entries()) {
317
+ const name = typeof p === "string" ? p : (p?.name ?? "");
318
+ if (!name) {
319
+ continue;
320
+ }
321
+ args[name] =
322
+ argValues[i] === undefined && typeof p === "object" && p !== null && "default" in p
323
+ ? p.default
324
+ : argValues[i];
325
+ }
326
+ return evaluateExpression(node, scope, null, { args });
327
+ });
328
+ } else if (isMutating(node.operator)) {
287
329
  setOwnScopeValue(scope, key, (s: Record<string, unknown>, event: Event) =>
288
330
  evaluateExpression(node, s, event),
289
331
  );
@@ -293,12 +335,19 @@ export function buildInitialScope(
293
335
  continue;
294
336
  }
295
337
  if (isFunctionDef(def)) {
296
- if (def.body) {
338
+ if (hasStructuredBody(def)) {
339
+ // Structured body (spec §20): a side-effecting handler in the build-time scope.
340
+ const { body } = def;
341
+ setOwnScopeValue(scope, key, (s: Record<string, unknown>, event?: Event) => {
342
+ void runStatements(body, s ?? scope, event ?? null);
343
+ });
344
+ } else if (typeof def.body === "string") {
297
345
  const names = def.parameters ? paramNames(def.parameters) : (def.arguments ?? []);
298
- const fn = new Function("state", ...names, def.body) as (
346
+ const { body } = def;
347
+ const fn = new Function("state", ...names, body) as (
299
348
  state: Record<string, unknown>,
300
349
  ) => unknown;
301
- if (def.body.includes("return")) {
350
+ if (body.includes("return")) {
302
351
  defineLazyScopeValue(scope, key, () => fn(scope));
303
352
  } else {
304
353
  setOwnScopeValue(scope, key, fn);
@@ -4,9 +4,6 @@
4
4
  * Injects project-level and page-level context variables into a page's state before compilation.
5
5
  * These are available as $site.* and $page.* in template expressions.
6
6
  *
7
- * Also resolves ContentCollection and ContentEntry $prototype entries against loaded content types
8
- * (Phase 2, spec §6.4).
9
- *
10
7
  * Per site-architecture spec §10: $site.name — from project.json name $site.url — from project.json
11
8
  * url $site.state.* — site-wide reactive state $page.url — current page URL path $page.title — page
12
9
  * title $page.params — dynamic route parameters (if any)
@@ -19,7 +16,6 @@ import type {
19
16
  JxStateDefinition,
20
17
  ProjectConfig,
21
18
  } from "@jxsuite/schema/types";
22
- import type { ContentLoaderEntry } from "@jxsuite/parser/types";
23
19
  import type { SiteRoute } from "../types.ts";
24
20
 
25
21
  /**
@@ -28,7 +24,6 @@ import type { SiteRoute } from "../types.ts";
28
24
  * @param {JxDocument} doc - The page document (mutated)
29
25
  * @param {ProjectConfig} projectConfig - Loaded project configuration
30
26
  * @param {SiteRoute} route - The resolved route for this page
31
- * @param {Map<string, ContentLoaderEntry[]>} [contentTypes] - Loaded content types
32
27
  * @param {string | null} [projectRoot] - Absolute path to the project root (for import rebasing)
33
28
  * @returns {JxDocument} The mutated document
34
29
  */
@@ -36,7 +31,6 @@ export function injectContext(
36
31
  doc: JxDocument,
37
32
  projectConfig: ProjectConfig,
38
33
  route: SiteRoute,
39
- _contentTypes = new Map<string, ContentLoaderEntry[]>(),
40
34
  projectRoot: string | null = null,
41
35
  ) {
42
36
  if (!doc.state) {
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Db-push — `jx db push [root] [--dry-run] [--connection <name>]` (plan Part 4a).
3
+ *
4
+ * Registry-driven: every `connections` entry resolves to its connector class (by
5
+ * `connector.provider`) and the additive schema sync runs through the class's `deploySchema`
6
+ * capability with the connection's tables from the `data` section. The environment is process.env
7
+ * merged under `<root>/.dev.vars` (wrangler convention, specs/extensions.md §13). After a non-dry
8
+ * apply, each connector's `bindings` fragment is deep-merged into wrangler.jsonc via
9
+ * `applyBindingFragments` (user keys preserved).
10
+ */
11
+
12
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { resolve } from "node:path";
14
+ import { applyBindingFragments } from "@jxsuite/create/scaffold";
15
+ import { buildProjectExtensionRegistry } from "./format-host.ts";
16
+ import { loadProjectConfig } from "./site-loader.ts";
17
+ import type { FormatEntry } from "@jxsuite/schema/format-registry";
18
+
19
+ export interface DbPushOptions {
20
+ dryRun?: boolean;
21
+ /** Restrict the push to one connection name. */
22
+ connection?: string;
23
+ }
24
+
25
+ export interface ConnectionPushResult {
26
+ connection: string;
27
+ provider: string;
28
+ tables: string[];
29
+ statements: string[];
30
+ warnings: string[];
31
+ applied: boolean;
32
+ }
33
+
34
+ export interface DbPushResult {
35
+ results: ConnectionPushResult[];
36
+ /** True when wrangler.jsonc was rewritten with binding fragments. */
37
+ bindingsPatched: boolean;
38
+ wranglerPath: string | null;
39
+ }
40
+
41
+ interface DeployResultLike {
42
+ statements?: string[];
43
+ warnings?: string[];
44
+ applied?: boolean;
45
+ }
46
+
47
+ /**
48
+ * Push the project's table schemas to their connections' databases.
49
+ *
50
+ * @param {string} projectRoot - Absolute path to the project directory
51
+ * @param {DbPushOptions} [options]
52
+ * @returns {Promise<DbPushResult>}
53
+ */
54
+ export async function dbPush(
55
+ projectRoot: string,
56
+ options: DbPushOptions = {},
57
+ ): Promise<DbPushResult> {
58
+ const { dryRun = false, connection: only } = options;
59
+ const { config } = loadProjectConfig(projectRoot);
60
+ const registry = await buildProjectExtensionRegistry(projectRoot, config);
61
+
62
+ const connections = (config.connections ?? {}) as Record<string, Record<string, unknown>>;
63
+ const tables = (config.data ?? {}) as Record<string, { connection?: string }>;
64
+
65
+ const names = Object.keys(connections).filter((name) => !only || name === only);
66
+ if (only && names.length === 0) {
67
+ throw new Error(`Unknown connection "${only}" (no such connections entry in project.json)`);
68
+ }
69
+ if (names.length === 0) {
70
+ throw new Error(
71
+ `project.json has no "connections" section — nothing to push. Declare a connection and ` +
72
+ `its tables in the "data" section first.`,
73
+ );
74
+ }
75
+
76
+ const env: Record<string, unknown> = {
77
+ ...process.env,
78
+ ...readDevVars(projectRoot),
79
+ JX_PROJECT_ROOT: projectRoot,
80
+ };
81
+
82
+ const results: ConnectionPushResult[] = [];
83
+ const fragments: Record<string, unknown>[] = [];
84
+ for (const name of names) {
85
+ const connection: Record<string, unknown> = { ...connections[name], $name: name };
86
+ const provider = String(connection.provider ?? "");
87
+ const entry = connectorByProvider(registry.connectors(), provider);
88
+ if (!entry) {
89
+ throw new Error(
90
+ `Connection "${name}" names provider "${provider}", but no enabled extension provides ` +
91
+ `it (enable the extension in project.json "extensions")`,
92
+ );
93
+ }
94
+ const connectionTables: Record<string, unknown> = {};
95
+ for (const [tableName, def] of Object.entries(tables)) {
96
+ if (def.connection === name) {
97
+ connectionTables[tableName] = def;
98
+ }
99
+ }
100
+
101
+ const deployed = (await entry.call("deploySchema", connectionTables, connection, {
102
+ dryRun,
103
+ env,
104
+ })) as DeployResultLike;
105
+ results.push({
106
+ applied: deployed.applied ?? !dryRun,
107
+ connection: name,
108
+ provider,
109
+ statements: deployed.statements ?? [],
110
+ tables: Object.keys(connectionTables),
111
+ warnings: deployed.warnings ?? [],
112
+ });
113
+
114
+ if (entry.capabilities.bindings) {
115
+ const fragment = (await entry.call("bindings", connection)) as Record<string, unknown>;
116
+ if (fragment && Object.keys(fragment).length > 0) {
117
+ fragments.push(fragment);
118
+ }
119
+ }
120
+ }
121
+
122
+ // Apply binding fragments to wrangler.jsonc (skipped on dry runs and non-wrangler projects).
123
+ const wranglerPath = resolve(projectRoot, "wrangler.jsonc");
124
+ let bindingsPatched = false;
125
+ if (!dryRun && fragments.length > 0 && existsSync(wranglerPath)) {
126
+ const existing = readFileSync(wranglerPath, "utf8");
127
+ const { content, patched } = applyBindingFragments(existing, fragments);
128
+ if (patched && content !== existing) {
129
+ writeFileSync(wranglerPath, content, "utf8");
130
+ bindingsPatched = true;
131
+ }
132
+ }
133
+
134
+ return { bindingsPatched, results, wranglerPath: existsSync(wranglerPath) ? wranglerPath : null };
135
+ }
136
+
137
+ /** Find the connector class serving a provider id. */
138
+ function connectorByProvider(entries: FormatEntry[], provider: string): FormatEntry | undefined {
139
+ return entries.find((entry) => entry.connector?.provider === provider);
140
+ }
141
+
142
+ /**
143
+ * Parse `<root>/.dev.vars` (wrangler convention): KEY=VALUE lines, `#` comments, optional
144
+ * single/double quotes. Kept deliberately tiny; the dev server has its own copy in
145
+ * `@jxsuite/server` (dev-vars.ts) — the compiler cannot depend on the server package.
146
+ *
147
+ * @param {string} projectRoot
148
+ * @returns {Record<string, string>}
149
+ */
150
+ export function readDevVars(projectRoot: string): Record<string, string> {
151
+ const path = resolve(projectRoot, ".dev.vars");
152
+ if (!existsSync(path)) {
153
+ return {};
154
+ }
155
+ const out: Record<string, string> = {};
156
+ for (const rawLine of readFileSync(path, "utf8").split("\n")) {
157
+ const line = rawLine.trim();
158
+ if (line === "" || line.startsWith("#")) {
159
+ continue;
160
+ }
161
+ const eq = line.indexOf("=");
162
+ if (eq === -1) {
163
+ continue;
164
+ }
165
+ const key = line.slice(0, eq).trim();
166
+ let value = line.slice(eq + 1).trim();
167
+ if (
168
+ (value.startsWith('"') && value.endsWith('"')) ||
169
+ (value.startsWith("'") && value.endsWith("'"))
170
+ ) {
171
+ value = value.slice(1, -1);
172
+ }
173
+ if (key !== "") {
174
+ out[key] = value;
175
+ }
176
+ }
177
+ return out;
178
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Dev command — `jx dev` resolves the project's @jxsuite/server dev entry and spawns it under Bun.
3
+ *
4
+ * The compiler cannot depend on @jxsuite/server (the server already depends on the compiler), and
5
+ * the dev server is Bun-native while the jx bin runs under Node — so the command is pure resolution
6
+ * + spawn: the entry module is resolved from the PROJECT's node_modules and executed with Bun.
7
+ *
8
+ * @module dev-command
9
+ */
10
+
11
+ import { createRequire } from "node:module";
12
+ import { join } from "node:path";
13
+ import { spawn } from "node:child_process";
14
+ import type { ChildProcess } from "node:child_process";
15
+
16
+ /**
17
+ * Resolve the project's `@jxsuite/server/dev` entry module.
18
+ *
19
+ * @param {string} projectRoot - Absolute project root
20
+ * @returns {string} Absolute path to the dev entry module
21
+ * @throws {Error} With an install hint when the package is not present
22
+ */
23
+ export function resolveDevEntry(projectRoot: string): string {
24
+ try {
25
+ const projectRequire = createRequire(join(projectRoot, "package.json"));
26
+ return projectRequire.resolve("@jxsuite/server/dev");
27
+ } catch {
28
+ throw new Error(
29
+ `@jxsuite/server is not installed in ${projectRoot}. ` +
30
+ `Add it to devDependencies (e.g. \`bun add -d @jxsuite/server\`) and retry.`,
31
+ );
32
+ }
33
+ }
34
+
35
+ /** The slice of `process` runDev touches — injectable for tests. */
36
+ export interface ProcessLike {
37
+ on: (event: string, handler: () => void) => unknown;
38
+ exitCode?: number | string | undefined;
39
+ }
40
+
41
+ /**
42
+ * Spawn the dev server under Bun with inherited stdio.
43
+ *
44
+ * @param {string} projectRoot - Absolute project root
45
+ * @param {number | undefined} port - Optional port override
46
+ * @param {typeof spawn} [spawnImpl] - Injectable for tests
47
+ * @param {ProcessLike} [proc] - Injectable for tests
48
+ * @returns {ChildProcess} The spawned Bun process
49
+ */
50
+ export function runDev(
51
+ projectRoot: string,
52
+ port: number | undefined,
53
+ spawnImpl: typeof spawn = spawn,
54
+ proc: ProcessLike = process as unknown as ProcessLike,
55
+ ): ChildProcess {
56
+ const entry = resolveDevEntry(projectRoot);
57
+ const args = [entry, "--root", projectRoot];
58
+ if (port !== undefined) {
59
+ args.push("--port", String(port));
60
+ }
61
+ const child = spawnImpl("bun", args, { stdio: "inherit" });
62
+ child.on("error", (error: NodeJS.ErrnoException) => {
63
+ if (error.code === "ENOENT") {
64
+ console.error(
65
+ "The Jx dev server runs on Bun, which was not found on your PATH. " +
66
+ "Install it from https://bun.sh and retry.",
67
+ );
68
+ } else {
69
+ console.error(`Failed to start the dev server: ${error.message}`);
70
+ }
71
+ proc.exitCode = 1;
72
+ });
73
+ // Ctrl+C reaches the child through the shared process group; also mirror explicit kills.
74
+ proc.on("SIGINT", () => {
75
+ child.kill("SIGINT");
76
+ });
77
+ proc.on("SIGTERM", () => {
78
+ child.kill("SIGTERM");
79
+ });
80
+ child.on("exit", (code) => {
81
+ proc.exitCode = code ?? 0;
82
+ });
83
+ return child;
84
+ }