@nanobpm/urban 0.11.1 → 0.13.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.
@@ -0,0 +1,199 @@
1
+ // `urban dev` — a hot-reload dev server. It runs the derivers once, starts the app, then
2
+ // watches the app root and, on a relevant source change, regenerates artifacts and restarts
3
+ // the app in-process. A per-reload import nonce busts the module cache so changed worker/
4
+ // action handlers are re-evaluated. main.ts is NOT re-run in dev (the CLI drives the runtime
5
+ // directly), so main.ts edits don't need a reload.
6
+ //
7
+ // The reload strategy is a full in-process restart: regenerate + build a fresh host, then
8
+ // stop the old app and start the new one. Regenerating first means a bad edit (invalid
9
+ // manifest/model) fails before the running app is torn down, so it keeps serving. It is the
10
+ // same start path as a fresh `urban run`, so every change kind — manifest, BPMN/DMN, forms,
11
+ // migrations/types, worker code — is picked up. A finer-grained surface remount + browser
12
+ // live-reload can layer on later behind this same seam.
13
+
14
+ import { runFromEnv } from "./run.ts";
15
+ import { selectHost } from "./adapters/detect.ts";
16
+ import type { HostContext, WatchHandle } from "./core/host.ts";
17
+ import type { UrbanApp } from "./core/runtime.ts";
18
+ import { createNodeGenIO, runGen } from "../toolkit/index.ts";
19
+
20
+ const SOURCE_EXT = [".bpmn", ".dmn", ".form", ".ts", ".js", ".mjs", ".sql"] as const;
21
+ // Directories whose churn must never trigger a reload: generated output (gen writes here —
22
+ // watching it would loop forever), dependencies, VCS, and build output.
23
+ const IGNORE_SEGMENTS = ["nano-generated", "node_modules", ".git", "dist"] as const;
24
+
25
+ /** True when a changed path should trigger a dev reload. Pure, so it is unit-tested.
26
+ * `manifestFile` (the app's configured manifest, default nano.app.json) always triggers,
27
+ * so `urban dev --manifest other.json` reloads on edits to that file too. */
28
+ export function shouldReload(rawPath: string, manifestFile = "nano.app.json"): boolean {
29
+ const norm = (s: string) => s.replace(/\\/g, "/").replace(/^\.\//, "");
30
+ const p = norm(rawPath);
31
+ if (p === "") return false;
32
+ for (const seg of IGNORE_SEGMENTS) {
33
+ if (p === seg || p.startsWith(`${seg}/`) || p.includes(`/${seg}/`)) return false;
34
+ }
35
+ // Ignore SQLite database files and their WAL/SHM/journal sidecars — they change on every
36
+ // write and would otherwise wedge the server in a reload loop.
37
+ if (/\.(db|sqlite|sqlite3)(-wal|-shm|-journal)?$/i.test(p)) return false;
38
+ const mf = norm(manifestFile);
39
+ const base = p.slice(p.lastIndexOf("/") + 1);
40
+ if (p === mf || base === mf.slice(mf.lastIndexOf("/") + 1)) return true;
41
+ return SOURCE_EXT.some((e) => p.toLowerCase().endsWith(e));
42
+ }
43
+
44
+ export interface DevOptions {
45
+ root?: string;
46
+ manifestPath?: string;
47
+ port?: number;
48
+ /** Quiet-period after the last change before reloading, in ms. Default 150. */
49
+ debounceMs?: number;
50
+ /** Log sink (default console.log). */
51
+ log?: (msg: string) => void;
52
+ }
53
+
54
+ /** Injectable seams so the loop is testable without a live engine or filesystem. */
55
+ export interface DevDeps {
56
+ makeHost(nonce: string): HostContext;
57
+ startApp(host: HostContext, o: { manifestPath: string; port?: number }): Promise<UrbanApp>;
58
+ regenerate(root: string, manifestFile: string): Promise<{ count: number }>;
59
+ now(): number;
60
+ }
61
+
62
+ function defaultDeps(root: string): DevDeps {
63
+ return {
64
+ makeHost: (nonce) => selectHost({ cwd: root, importNonce: nonce }),
65
+ startApp: (host, o) =>
66
+ // The host is anchored at `root`; runFromEnv keeps its own root at "." accordingly.
67
+ runFromEnv({ host, manifestPath: o.manifestPath, port: o.port, handleSignals: false }),
68
+ regenerate: async (r, mf) => {
69
+ const res = await runGen({ root: r, io: createNodeGenIO(), manifestFile: mf });
70
+ return { count: res.artifacts.length };
71
+ },
72
+ now: () => Date.now(),
73
+ };
74
+ }
75
+
76
+ export interface DevServer {
77
+ stop(): Promise<void>;
78
+ }
79
+
80
+ /**
81
+ * Start the dev server: gen once, start the app, then watch + hot-reload. Returns a handle
82
+ * whose stop() tears the watcher and the running app down. Never rejects on a reload error —
83
+ * a bad edit logs and leaves the previous app running.
84
+ */
85
+ export async function runDev(opts: DevOptions = {}, deps?: Partial<DevDeps>): Promise<DevServer> {
86
+ const root = opts.root ?? ".";
87
+ const manifestFile = opts.manifestPath ?? "nano.app.json";
88
+ const debounceMs = opts.debounceMs ?? 150;
89
+ const log = opts.log ?? ((m: string) => console.log(m));
90
+ const d: DevDeps = { ...defaultDeps(root), ...deps };
91
+
92
+ // Monotonic reload counter, mixed into the import nonce so it is unique per reload even when
93
+ // two reloads land in the same millisecond (d.now() can repeat).
94
+ let reloadSeq = 0;
95
+
96
+ const startCycle = async (): Promise<{ host: HostContext; app: UrbanApp }> => {
97
+ const host = await prepareHost();
98
+ const app = await d.startApp(host, { manifestPath: manifestFile, port: opts.port });
99
+ return { host, app };
100
+ };
101
+
102
+ // Derive artifacts and build a fresh host (with a new import nonce). This is pure/FS work
103
+ // that does NOT touch the running app, so it can run *before* a reload tears the old app
104
+ // down — most bad edits (invalid manifest/model) fail here, leaving the app serving.
105
+ const prepareHost = async (): Promise<HostContext> => {
106
+ const nonce = `${d.now()}-${reloadSeq++}`;
107
+ const { count } = await d.regenerate(root, manifestFile);
108
+ if (count > 0) log(` derived ${count} artifact(s)`);
109
+ return d.makeHost(nonce);
110
+ };
111
+
112
+ let { host, app } = await startCycle();
113
+
114
+ let reloading = false;
115
+ let queued = false;
116
+ let stopped = false;
117
+ let appRunning = true;
118
+ let inFlight: Promise<void> | undefined;
119
+ let timer: ReturnType<typeof setTimeout> | undefined;
120
+
121
+ const reload = async (): Promise<void> => {
122
+ try {
123
+ log("↻ change detected — reloading…");
124
+ // Regenerate + rebuild the host BEFORE stopping the running app, so a bad edit
125
+ // throws here and the current app keeps serving untouched.
126
+ const nextHost = await prepareHost();
127
+ if (stopped) return; // shutdown began mid-prepare: leave the running app to stop()
128
+ await app.stop();
129
+ appRunning = false;
130
+ // Re-check after the async stop: if shutdown began while we were tearing the old
131
+ // app down, don't start a replacement just to have stop() immediately kill it.
132
+ if (stopped) return;
133
+ app = await d.startApp(nextHost, { manifestPath: manifestFile, port: opts.port });
134
+ appRunning = true;
135
+ host = nextHost;
136
+ log("✔ reloaded");
137
+ } catch (err) {
138
+ // Keep the dev server alive on a bad edit; the next save will retry.
139
+ log(`✖ reload failed: ${String((err as Error)?.message ?? err)}`);
140
+ } finally {
141
+ reloading = false;
142
+ if (queued && !stopped) {
143
+ queued = false;
144
+ schedule();
145
+ }
146
+ }
147
+ };
148
+
149
+ // Owns the single-flight guard and `inFlight` bookkeeping: only an *actually running*
150
+ // reload is tracked in `inFlight`, so stop() always awaits the real in-flight reload
151
+ // (never an already-resolved no-op promise) before final teardown.
152
+ const trigger = (): void => {
153
+ if (stopped) return;
154
+ if (reloading) {
155
+ queued = true; // the active reload's finally re-schedules the next cycle
156
+ return;
157
+ }
158
+ reloading = true;
159
+ inFlight = reload();
160
+ };
161
+
162
+ const schedule = (): void => {
163
+ if (stopped) return;
164
+ if (timer) clearTimeout(timer);
165
+ timer = setTimeout(() => {
166
+ timer = undefined;
167
+ trigger();
168
+ }, debounceMs);
169
+ };
170
+
171
+ let watcher: WatchHandle | undefined;
172
+ if (host.watch) {
173
+ watcher = host.watch((path) => {
174
+ if (shouldReload(path, manifestFile)) schedule();
175
+ });
176
+ log("▲ dev server ready — watching for changes (Ctrl-C to stop)");
177
+ } else {
178
+ log(
179
+ "▲ dev server ready — file watching is not supported on this host; running once " +
180
+ "(no hot reload, Ctrl-C to stop)",
181
+ );
182
+ }
183
+
184
+ return {
185
+ async stop() {
186
+ // Order matters: flag first so a queued/in-flight reload won't start a new app, cancel
187
+ // any pending debounce, stop watching, then let any in-flight reload settle before the
188
+ // final teardown so we stop exactly the app that is (or ends up) current.
189
+ stopped = true;
190
+ if (timer) {
191
+ clearTimeout(timer);
192
+ timer = undefined;
193
+ }
194
+ watcher?.close();
195
+ await inFlight?.catch(() => {});
196
+ if (appRunning) await app.stop();
197
+ },
198
+ };
199
+ }
@@ -47,6 +47,7 @@ export type {
47
47
  HttpServer,
48
48
  JobHandler,
49
49
  SqliteDb,
50
+ WatchHandle,
50
51
  WorkerSubscription,
51
52
  } from "./core/host.ts";
52
53
  export type { AppApi, Mounted, RuntimeContext } from "./core/context.ts";
@@ -97,5 +98,7 @@ export type {
97
98
  NanoSdkJobWorkerConfig,
98
99
  } from "./engine/nanosdk.ts";
99
100
  export type { EngineSdkClient } from "./engine/sdk.ts";
100
- export { runFromEnv } from "./run.ts";
101
+ export { installSignalHandlers, runFromEnv } from "./run.ts";
101
102
  export type { RunOptions } from "./run.ts";
103
+ export { runDev, shouldReload } from "./devserver.ts";
104
+ export type { DevDeps, DevOptions, DevServer } from "./devserver.ts";
@@ -56,7 +56,7 @@ export async function runFromEnv(opts: RunOptions = {}): Promise<UrbanApp> {
56
56
  return app;
57
57
  }
58
58
 
59
- function installSignalHandlers(stop: () => Promise<void>): void {
59
+ export function installSignalHandlers(stop: () => Promise<void>): void {
60
60
  let stopping = false;
61
61
  const onSignal = () => {
62
62
  if (stopping) return;