@nanobpm/urban 0.24.0 → 0.25.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.
@@ -1,14 +1,6 @@
1
1
  import type { HostContext } from "./host.ts";
2
2
  import type { AppManifest, Worker } from "@nanobpm/nano-app-schema";
3
3
  export type { ActionDecl, AppManifest, AppUi, ChatSurface, Connection, Data, DataSource, DomainField, DomainType, LlmBinding, Models, PagesSurface, Runtime, Security, Surfaces, TaskInboxSurface, Trigger, TriggerAction, Worker, } from "@nanobpm/nano-app-schema";
4
- declare module "@nanobpm/nano-app-schema" {
5
- interface Models {
6
- /** `{{name}}` template sources substituted into model resources at deploy time: globs, a bare
7
- * directory (scanned), or literal files. A template's name is its file stem
8
- * (`prompts/review-round.md` → `{{review-round}}`). See `deployModels`. */
9
- templates?: string[];
10
- }
11
- }
12
4
  /** The job type a worker subscribes to (schema key: `taskType`). */
13
5
  export declare function workerJobType(w: Worker): string | undefined;
14
6
  /**
@@ -131,9 +131,11 @@ export async function createUrbanApp(opts) {
131
131
  describe.connectors = c.describe?.();
132
132
  }
133
133
  const routes = [];
134
+ let hasUiSurfaces = false;
134
135
  if (flags.surfaces) {
135
136
  const s = mountSurfaces(ctx, api);
136
137
  routes.push(...s.routes);
138
+ hasUiSurfaces = s.routes.length > 0;
137
139
  describe.surfaces = s.describe();
138
140
  }
139
141
  if (flags.triggers) {
@@ -160,6 +162,23 @@ export async function createUrbanApp(opts) {
160
162
  server = await host.serveHttp(port, makeRouter(routes));
161
163
  httpPort = server.port;
162
164
  host.log("info", "urban app serving surfaces/triggers", { port: httpPort, routes: routes.length });
165
+ // ADR 0057 boot handshake: under a supervising host (the nano-bpm
166
+ // Studio sets NANOBPMN_APP_HANDSHAKE), announce the port we actually
167
+ // bound on a machine-readable stdout control line so the host can
168
+ // locate the app's UI even when the app picks its port at runtime.
169
+ // Only emitted when the app actually serves UI surfaces (a trigger- or
170
+ // worker-only app binds a port too, but it has no webview for the host
171
+ // to frame), and gated on the env var so direct terminal runs aren't
172
+ // cluttered. `=== "1"` so an app that sets the var to "0" opts out.
173
+ if (httpPort !== undefined && hasUiSurfaces && host.env("NANOBPMN_APP_HANDSHAKE") === "1") {
174
+ try {
175
+ console.log(`@@NBPM_LISTENING@@${JSON.stringify({ port: httpPort })}`);
176
+ }
177
+ catch {
178
+ // stdout may be closed / non-writable — the handshake is
179
+ // best-effort telemetry, never fatal to the app.
180
+ }
181
+ }
163
182
  }
164
183
  host.log("info", `urban app "${manifest.id}" started`, {});
165
184
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
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",
@@ -62,7 +62,7 @@
62
62
  "test:deno": "deno test -A --no-check src/"
63
63
  },
64
64
  "dependencies": {
65
- "@nanobpm/nano-app-schema": "^0.2.0",
65
+ "@nanobpm/nano-app-schema": "^0.3.0",
66
66
  "@nanobpm/nano-sdk": "^1.2.3",
67
67
  "@nanobpm/workflow": "^0.8.0",
68
68
  "bpmn-auto-layout": "^2.0.0-alpha.2",
@@ -33,20 +33,6 @@ export type {
33
33
  Worker,
34
34
  } from "@nanobpm/nano-app-schema";
35
35
 
36
- // The published `@nanobpm/nano-app-schema` does not declare `models.templates`; the manifest
37
- // validator only enforces `additionalProperties:false` at the top level (see validate.ts), so the
38
- // nested key is accepted at runtime. Augment `Models` here so the deploy path can read it typed
39
- // (no cast) until it is formalized in the schema package. (The top-level `ui` block, by contrast,
40
- // now ships natively in the schema as `AppUi` — re-exported above — so it needs no shim.)
41
- declare module "@nanobpm/nano-app-schema" {
42
- interface Models {
43
- /** `{{name}}` template sources substituted into model resources at deploy time: globs, a bare
44
- * directory (scanned), or literal files. A template's name is its file stem
45
- * (`prompts/review-round.md` → `{{review-round}}`). See `deployModels`. */
46
- templates?: string[];
47
- }
48
- }
49
-
50
36
  /** The job type a worker subscribes to (schema key: `taskType`). */
51
37
  export function workerJobType(w: Worker): string | undefined {
52
38
  return w.taskType;
@@ -324,3 +324,81 @@ test("runFromEnv anchors the host at a non-'.' root without double-prefixing pat
324
324
  await rm(dir, { recursive: true, force: true });
325
325
  }
326
326
  });
327
+
328
+ // Captures console.log for the duration of `fn`, restoring it after.
329
+ async function captureStdout(fn: () => Promise<void>): Promise<string[]> {
330
+ const lines: string[] = [];
331
+ const original = console.log;
332
+ console.log = (...args: unknown[]) => {
333
+ lines.push(args.map((a) => (typeof a === "string" ? a : String(a))).join(" "));
334
+ };
335
+ try {
336
+ await fn();
337
+ } finally {
338
+ console.log = original;
339
+ }
340
+ return lines;
341
+ }
342
+
343
+ test("emits the ADR 0057 boot handshake when NANOBPMN_APP_HANDSHAKE is set", async () => {
344
+ const dir = await makeFixture();
345
+ const host = createNodeHost({ cwd: dir, log: () => {} });
346
+ const engine = new FakeEngine();
347
+ const app = await createUrbanApp({ host, engine, root: ".", port: 0 });
348
+ const prev = process.env.NANOBPMN_APP_HANDSHAKE;
349
+ process.env.NANOBPMN_APP_HANDSHAKE = "1";
350
+ try {
351
+ const lines = await captureStdout(() => app.start());
352
+ const handshake = lines.find((l) => l.startsWith("@@NBPM_LISTENING@@"));
353
+ assert.ok(handshake, "a @@NBPM_LISTENING@@ line was emitted");
354
+ const payload = JSON.parse(handshake!.slice("@@NBPM_LISTENING@@".length));
355
+ assert.equal(payload.port, app.httpPort, "handshake reports the actual bound port");
356
+ assert.ok(payload.port > 0);
357
+ } finally {
358
+ if (prev === undefined) delete process.env.NANOBPMN_APP_HANDSHAKE;
359
+ else process.env.NANOBPMN_APP_HANDSHAKE = prev;
360
+ await app.stop();
361
+ await rm(dir, { recursive: true, force: true });
362
+ }
363
+ });
364
+
365
+ test("does NOT emit the boot handshake without NANOBPMN_APP_HANDSHAKE", async () => {
366
+ const dir = await makeFixture();
367
+ const host = createNodeHost({ cwd: dir, log: () => {} });
368
+ const engine = new FakeEngine();
369
+ const app = await createUrbanApp({ host, engine, root: ".", port: 0 });
370
+ const prev = process.env.NANOBPMN_APP_HANDSHAKE;
371
+ delete process.env.NANOBPMN_APP_HANDSHAKE;
372
+ try {
373
+ const lines = await captureStdout(() => app.start());
374
+ assert.ok(
375
+ !lines.some((l) => l.startsWith("@@NBPM_LISTENING@@")),
376
+ "no handshake line when the supervisor env is unset (clean terminal runs)",
377
+ );
378
+ } finally {
379
+ if (prev !== undefined) process.env.NANOBPMN_APP_HANDSHAKE = prev;
380
+ await app.stop();
381
+ await rm(dir, { recursive: true, force: true });
382
+ }
383
+ });
384
+
385
+ test("treats NANOBPMN_APP_HANDSHAKE other than \"1\" as opt-out", async () => {
386
+ const dir = await makeFixture();
387
+ const host = createNodeHost({ cwd: dir, log: () => {} });
388
+ const engine = new FakeEngine();
389
+ const app = await createUrbanApp({ host, engine, root: ".", port: 0 });
390
+ const prev = process.env.NANOBPMN_APP_HANDSHAKE;
391
+ process.env.NANOBPMN_APP_HANDSHAKE = "0";
392
+ try {
393
+ const lines = await captureStdout(() => app.start());
394
+ assert.ok(
395
+ !lines.some((l) => l.startsWith("@@NBPM_LISTENING@@")),
396
+ '"0" is not the opt-in sentinel ("1"), so no handshake is emitted',
397
+ );
398
+ } finally {
399
+ if (prev === undefined) delete process.env.NANOBPMN_APP_HANDSHAKE;
400
+ else process.env.NANOBPMN_APP_HANDSHAKE = prev;
401
+ await app.stop();
402
+ await rm(dir, { recursive: true, force: true });
403
+ }
404
+ });
@@ -191,9 +191,11 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
191
191
  }
192
192
 
193
193
  const routes: Route[] = [];
194
+ let hasUiSurfaces = false;
194
195
  if (flags.surfaces) {
195
196
  const s = mountSurfaces(ctx, api);
196
197
  routes.push(...s.routes);
198
+ hasUiSurfaces = s.routes.length > 0;
197
199
  describe.surfaces = s.describe();
198
200
  }
199
201
  if (flags.triggers) {
@@ -219,6 +221,22 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
219
221
  server = await host.serveHttp(port, makeRouter(routes));
220
222
  httpPort = server.port;
221
223
  host.log("info", "urban app serving surfaces/triggers", { port: httpPort, routes: routes.length });
224
+ // ADR 0057 boot handshake: under a supervising host (the nano-bpm
225
+ // Studio sets NANOBPMN_APP_HANDSHAKE), announce the port we actually
226
+ // bound on a machine-readable stdout control line so the host can
227
+ // locate the app's UI even when the app picks its port at runtime.
228
+ // Only emitted when the app actually serves UI surfaces (a trigger- or
229
+ // worker-only app binds a port too, but it has no webview for the host
230
+ // to frame), and gated on the env var so direct terminal runs aren't
231
+ // cluttered. `=== "1"` so an app that sets the var to "0" opts out.
232
+ if (httpPort !== undefined && hasUiSurfaces && host.env("NANOBPMN_APP_HANDSHAKE") === "1") {
233
+ try {
234
+ console.log(`@@NBPM_LISTENING@@${JSON.stringify({ port: httpPort })}`);
235
+ } catch {
236
+ // stdout may be closed / non-writable — the handshake is
237
+ // best-effort telemetry, never fatal to the app.
238
+ }
239
+ }
222
240
  }
223
241
  host.log("info", `urban app "${manifest.id}" started`, {});
224
242
  } catch (err) {