@nanobpm/urban 0.96.0 → 0.96.1

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/dist/cli.d.ts CHANGED
@@ -23,4 +23,12 @@ interface Flags {
23
23
  */
24
24
  export declare function cmdData(f: Flags, readInput?: () => Promise<string>): Promise<number>;
25
25
  export declare function main(argv: string[]): Promise<number>;
26
+ /**
27
+ * Run the CLI as the process entrypoint: `main(argv)`, then drain stdio and exit with its code.
28
+ * A negative code means a long-running command (`run`, `dev`) owns the process, so it is left
29
+ * alive. Exported so the `urban` bin (`bin.mjs`) can run the CLI **in-process** via `import()`
30
+ * instead of spawning a second Node (nano-ide#589): every `urban` command, notably each
31
+ * gateway `urban data` op, used to pay two Node start-ups.
32
+ */
33
+ export declare function runCli(argv: string[]): Promise<void>;
26
34
  export {};
package/dist/cli.js CHANGED
@@ -457,24 +457,23 @@ async function flushStdio() {
457
457
  return;
458
458
  await Promise.all([flushStream(p.stdout), flushStream(p.stderr)]);
459
459
  }
460
- const proc = processGlobal();
461
- const deno = denoGlobal();
462
- const importMetaMain = Reflect.get(import.meta, "main");
463
- const argv1 = proc?.argv?.[1];
464
- const nodeMain = argv1 ? import.meta.url === pathToFileURL(argv1).href : false;
465
- if (importMetaMain === true || nodeMain) {
466
- // Node passes args via process.argv (slice off exec+script); Deno (run directly)
467
- // exposes them on Deno.args. Prefer whichever actually carries args.
468
- const fromNode = proc?.argv?.slice(2);
469
- const fromDeno = deno?.args;
470
- const argv = (fromNode && fromNode.length ? fromNode : fromDeno) ?? fromNode ?? [];
460
+ /**
461
+ * Run the CLI as the process entrypoint: `main(argv)`, then drain stdio and exit with its code.
462
+ * A negative code means a long-running command (`run`, `dev`) owns the process, so it is left
463
+ * alive. Exported so the `urban` bin (`bin.mjs`) can run the CLI **in-process** via `import()`
464
+ * instead of spawning a second Node (nano-ide#589): every `urban` command, notably each
465
+ * gateway `urban data` op, used to pay two Node start-ups.
466
+ */
467
+ export function runCli(argv) {
468
+ const proc = processGlobal();
469
+ const deno = denoGlobal();
471
470
  const exit = (code) => {
472
471
  if (proc?.exit)
473
472
  proc.exit(code);
474
473
  else if (deno?.exit)
475
474
  deno.exit(code);
476
475
  };
477
- main(argv).then(async (code) => {
476
+ return main(argv).then(async (code) => {
478
477
  if (code >= 0) {
479
478
  await flushStdio();
480
479
  exit(code);
@@ -485,3 +484,16 @@ if (importMetaMain === true || nodeMain) {
485
484
  exit(1);
486
485
  });
487
486
  }
487
+ const proc = processGlobal();
488
+ const deno = denoGlobal();
489
+ const importMetaMain = Reflect.get(import.meta, "main");
490
+ const argv1 = proc?.argv?.[1];
491
+ const nodeMain = argv1 ? import.meta.url === pathToFileURL(argv1).href : false;
492
+ if (importMetaMain === true || nodeMain) {
493
+ // Node passes args via process.argv (slice off exec+script); Deno (run directly)
494
+ // exposes them on Deno.args. Prefer whichever actually carries args.
495
+ const fromNode = proc?.argv?.slice(2);
496
+ const fromDeno = deno?.args;
497
+ const argv = (fromNode && fromNode.length ? fromNode : fromDeno) ?? fromNode ?? [];
498
+ void runCli(argv);
499
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.96.0",
3
+ "version": "0.96.1",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/bin.mjs CHANGED
@@ -3,16 +3,23 @@
3
3
  // published as compiled JS + .d.ts because Node cannot strip types for files under
4
4
  // node_modules (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), so no type-stripping flag
5
5
  // is needed here.
6
+ //
7
+ // The CLI is loaded IN-PROCESS (dynamic import + runCli) rather than spawned as a second
8
+ // Node: a spawn doubled the start-up cost of every command, and the gateway runs `urban
9
+ // data` once per data op (nano-ide#589, nano-bpm#1340). dist/cli.js only auto-runs when it
10
+ // is argv[1] itself, so importing it here does not start it twice.
6
11
 
7
- import { spawnSync } from "node:child_process";
8
- import { fileURLToPath } from "node:url";
12
+ import { fileURLToPath, pathToFileURL } from "node:url";
9
13
  import { dirname, join } from "node:path";
10
14
 
11
15
  const cli = join(dirname(fileURLToPath(import.meta.url)), "..", "dist", "cli.js");
12
16
 
13
- const r = spawnSync(process.execPath, [cli, ...process.argv.slice(2)], { stdio: "inherit" });
14
- if (r.error) {
15
- console.error(String(r.error.message ?? r.error));
17
+ let runCli;
18
+ try {
19
+ ({ runCli } = await import(pathToFileURL(cli).href));
20
+ } catch (err) {
21
+ console.error(String(err?.message ?? err));
16
22
  process.exit(1);
17
23
  }
18
- process.exit(r.status ?? 1);
24
+ // runCli owns the exit code, stdio flushing and long-running commands (run/dev).
25
+ await runCli(process.argv.slice(2));
@@ -0,0 +1,74 @@
1
+ // The `urban` bin (`bin.mjs`) must run the compiled CLI in the SAME Node process (nano-ide#589):
2
+ // the gateway spawns `urban data` per data op, and the old spawnSync bootstrap paid two Node
3
+ // start-ups for every one. Exercises the real bin against dist/ (CI builds before testing).
4
+
5
+ import { test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { spawn } from "node:child_process";
8
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { fileURLToPath, pathToFileURL } from "node:url";
12
+
13
+ const bin = fileURLToPath(new URL("./bin.mjs", import.meta.url));
14
+ const dist = fileURLToPath(new URL("../dist/cli.js", import.meta.url));
15
+
16
+ function runBin(
17
+ args: string[],
18
+ stdin: string,
19
+ env: NodeJS.ProcessEnv,
20
+ ): Promise<{ stdout: string; code: number | null }> {
21
+ return new Promise((resolve, reject) => {
22
+ const child = spawn(process.execPath, [bin, ...args], {
23
+ stdio: ["pipe", "pipe", "inherit"],
24
+ env: { ...process.env, ...env },
25
+ });
26
+ const chunks: Buffer[] = [];
27
+ child.stdout.on("data", (c: Buffer) => chunks.push(c));
28
+ child.on("error", reject);
29
+ child.on("close", (code) => resolve({ stdout: Buffer.concat(chunks).toString("utf8"), code }));
30
+ child.stdin.end(stdin);
31
+ });
32
+ }
33
+
34
+ // `bin.mjs` is the npm bin, so it always runs under Node: skip under Deno (`test:deno`), whose
35
+ // process.execPath is not Node (same as generated-lint.test.ts).
36
+ const runtimeIsNode = !("Deno" in globalThis);
37
+
38
+ test("bin.mjs runs the CLI in-process: one Node per command, reply + exit code intact", {
39
+ skip: runtimeIsNode ? false : "Node-only: spawns bin.mjs via process.execPath (Deno's execPath is not Node)",
40
+ }, async (t) => {
41
+ if (!existsSync(dist)) {
42
+ t.skip("dist/cli.js not built (run `npm run build`); CI builds before testing");
43
+ return;
44
+ }
45
+ const dir = mkdtempSync(join(tmpdir(), "urban-bin-"));
46
+ try {
47
+ // A preload every Node process runs (NODE_OPTIONS is inherited by child Nodes), recording
48
+ // its pid. The old spawnSync bootstrap would record two.
49
+ const pids = join(dir, "pids.txt");
50
+ const preload = join(dir, "preload.mjs");
51
+ writeFileSync(
52
+ preload,
53
+ `import { appendFileSync } from "node:fs";\nappendFileSync(${JSON.stringify(pids)}, process.pid + "\\n");\n`,
54
+ );
55
+ const env = { NODE_OPTIONS: `--import=${pathToFileURL(preload).href}` };
56
+
57
+ // An unreadable request: `urban data` still answers with a parseable envelope and exits 0.
58
+ const { stdout, code } = await runBin(["data", "--root", dir], "not json", env);
59
+ assert.equal(code, 0, `bin exited ${code}, expected 0`);
60
+ const reply: unknown = JSON.parse(stdout);
61
+ assert.ok(reply !== null && typeof reply === "object", "reply is not an object");
62
+ assert.equal(Reflect.get(reply, "ok"), false);
63
+ assert.match(String(Reflect.get(reply, "error")), /^bad request:/);
64
+
65
+ const started = readFileSync(pids, "utf8").trim().split("\n").filter(Boolean);
66
+ assert.equal(started.length, 1, `expected 1 Node process, got ${started.length}`);
67
+
68
+ // A failing command's non-zero exit code still propagates through the bin.
69
+ const unknown = await runBin(["no-such-command"], "", {});
70
+ assert.equal(unknown.code, 1);
71
+ } finally {
72
+ rmSync(dir, { recursive: true, force: true });
73
+ }
74
+ });
package/src/cli.ts CHANGED
@@ -472,22 +472,21 @@ async function flushStdio(): Promise<void> {
472
472
  await Promise.all([flushStream(p.stdout), flushStream(p.stderr)]);
473
473
  }
474
474
 
475
- const proc = processGlobal();
476
- const deno = denoGlobal();
477
- const importMetaMain: unknown = Reflect.get(import.meta, "main");
478
- const argv1 = proc?.argv?.[1];
479
- const nodeMain = argv1 ? import.meta.url === pathToFileURL(argv1).href : false;
480
- if (importMetaMain === true || nodeMain) {
481
- // Node passes args via process.argv (slice off exec+script); Deno (run directly)
482
- // exposes them on Deno.args. Prefer whichever actually carries args.
483
- const fromNode = proc?.argv?.slice(2);
484
- const fromDeno = deno?.args;
485
- const argv = (fromNode && fromNode.length ? fromNode : fromDeno) ?? fromNode ?? [];
475
+ /**
476
+ * Run the CLI as the process entrypoint: `main(argv)`, then drain stdio and exit with its code.
477
+ * A negative code means a long-running command (`run`, `dev`) owns the process, so it is left
478
+ * alive. Exported so the `urban` bin (`bin.mjs`) can run the CLI **in-process** via `import()`
479
+ * instead of spawning a second Node (nano-ide#589): every `urban` command, notably each
480
+ * gateway `urban data` op, used to pay two Node start-ups.
481
+ */
482
+ export function runCli(argv: string[]): Promise<void> {
483
+ const proc = processGlobal();
484
+ const deno = denoGlobal();
486
485
  const exit = (code: number) => {
487
486
  if (proc?.exit) proc.exit(code);
488
487
  else if (deno?.exit) deno.exit(code);
489
488
  };
490
- main(argv).then(
489
+ return main(argv).then(
491
490
  async (code) => {
492
491
  if (code >= 0) {
493
492
  await flushStdio();
@@ -501,3 +500,17 @@ if (importMetaMain === true || nodeMain) {
501
500
  },
502
501
  );
503
502
  }
503
+
504
+ const proc = processGlobal();
505
+ const deno = denoGlobal();
506
+ const importMetaMain: unknown = Reflect.get(import.meta, "main");
507
+ const argv1 = proc?.argv?.[1];
508
+ const nodeMain = argv1 ? import.meta.url === pathToFileURL(argv1).href : false;
509
+ if (importMetaMain === true || nodeMain) {
510
+ // Node passes args via process.argv (slice off exec+script); Deno (run directly)
511
+ // exposes them on Deno.args. Prefer whichever actually carries args.
512
+ const fromNode = proc?.argv?.slice(2);
513
+ const fromDeno = deno?.args;
514
+ const argv = (fromNode && fromNode.length ? fromNode : fromDeno) ?? fromNode ?? [];
515
+ void runCli(argv);
516
+ }