@nanobpm/urban 0.12.0 → 0.14.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/dist/cli.js +9 -5
- package/dist/runtime/adapters/deno.d.ts +2 -0
- package/dist/runtime/adapters/deno.js +33 -1
- package/dist/runtime/adapters/detect.d.ts +2 -0
- package/dist/runtime/adapters/node.d.ts +6 -0
- package/dist/runtime/adapters/node.js +28 -1
- package/dist/runtime/core/host.d.ts +18 -3
- package/dist/runtime/core/modules/workers.d.ts +17 -2
- package/dist/runtime/devserver.d.ts +36 -0
- package/dist/runtime/devserver.js +173 -0
- package/dist/runtime/index.d.ts +4 -2
- package/dist/runtime/index.js +2 -1
- package/dist/runtime/run.d.ts +1 -0
- package/dist/runtime/run.js +1 -1
- package/package.json +2 -2
- package/src/cli.ts +11 -4
- package/src/runtime/adapters/deno.ts +36 -1
- package/src/runtime/adapters/detect.ts +2 -0
- package/src/runtime/adapters/node.ts +34 -1
- package/src/runtime/core/host.ts +19 -3
- package/src/runtime/core/modules/workers.test.ts +53 -1
- package/src/runtime/core/modules/workers.ts +19 -4
- package/src/runtime/devserver.test.ts +330 -0
- package/src/runtime/devserver.ts +199 -0
- package/src/runtime/index.ts +4 -1
- package/src/runtime/run.ts +1 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// (with deno.ts) allowed to touch a concrete runtime.
|
|
3
3
|
|
|
4
4
|
import { readFile, readdir, stat } from "node:fs/promises";
|
|
5
|
+
import { watch as fsWatch, type FSWatcher } from "node:fs";
|
|
5
6
|
import { createServer } from "node:http";
|
|
6
7
|
import { isAbsolute, resolve } from "node:path";
|
|
7
8
|
import { pathToFileURL } from "node:url";
|
|
@@ -12,12 +13,19 @@ import type {
|
|
|
12
13
|
HttpRequest,
|
|
13
14
|
HttpServer,
|
|
14
15
|
SqliteDb,
|
|
16
|
+
WatchHandle,
|
|
15
17
|
} from "../core/host.ts";
|
|
16
18
|
|
|
17
19
|
export interface NodeHostOptions {
|
|
18
20
|
/** Base directory relative paths resolve against. Default process.cwd(). */
|
|
19
21
|
cwd?: string;
|
|
20
22
|
log?: HostContext["log"];
|
|
23
|
+
/**
|
|
24
|
+
* When set, appended as a `?v=<nonce>` query to dynamic import URLs so a changed
|
|
25
|
+
* handler/worker module is re-evaluated instead of served from the ESM cache. The
|
|
26
|
+
* dev server bumps this on every reload; production leaves it unset.
|
|
27
|
+
*/
|
|
28
|
+
importNonce?: string;
|
|
21
29
|
}
|
|
22
30
|
|
|
23
31
|
export function createNodeHost(opts: NodeHostOptions = {}): HostContext {
|
|
@@ -56,10 +64,35 @@ export function createNodeHost(opts: NodeHostOptions = {}): HostContext {
|
|
|
56
64
|
const db = new DatabaseSync(abs(path));
|
|
57
65
|
return wrapNodeSqlite(db);
|
|
58
66
|
},
|
|
59
|
-
importModule: (p) =>
|
|
67
|
+
importModule: (p) => {
|
|
68
|
+
const href =
|
|
69
|
+
pathToFileURL(abs(p)).href + (opts.importNonce ? `?v=${opts.importNonce}` : "");
|
|
70
|
+
return import(href) as Promise<Record<string, unknown>>;
|
|
71
|
+
},
|
|
60
72
|
async serveHttp(port, handler) {
|
|
61
73
|
return await startNodeServer(port, handler);
|
|
62
74
|
},
|
|
75
|
+
watch(onChange) {
|
|
76
|
+
const onFsEvent = (_event: unknown, filename: string | Buffer | null) => {
|
|
77
|
+
if (filename) onChange(String(filename));
|
|
78
|
+
};
|
|
79
|
+
// Recursive watch is supported on macOS, Windows, and — since Node 19.1.0 — Linux.
|
|
80
|
+
// This package requires Node >=22.6, so the recursive path is available on all three;
|
|
81
|
+
// the try/catch only trips on an unusual platform, where we degrade honestly to a
|
|
82
|
+
// non-recursive root watch (and warn) rather than silently miss nested changes.
|
|
83
|
+
let w: FSWatcher;
|
|
84
|
+
try {
|
|
85
|
+
w = fsWatch(cwd, { recursive: true }, onFsEvent);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
log("warn", "recursive file watch unavailable — nested changes may be missed", {
|
|
88
|
+
error: String(err),
|
|
89
|
+
});
|
|
90
|
+
w = fsWatch(cwd, onFsEvent);
|
|
91
|
+
}
|
|
92
|
+
// A watcher error (e.g. the dir is removed) must not crash the dev server.
|
|
93
|
+
w.on("error", (err) => log("warn", "file watch error", { error: String(err) }));
|
|
94
|
+
return { close: () => w.close() } satisfies WatchHandle;
|
|
95
|
+
},
|
|
63
96
|
now: () => Date.now(),
|
|
64
97
|
log,
|
|
65
98
|
};
|
package/src/runtime/core/host.ts
CHANGED
|
@@ -30,6 +30,11 @@ export interface HttpServer {
|
|
|
30
30
|
stop(): Promise<void>;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** A handle to an active filesystem watch; call close() to stop watching. */
|
|
34
|
+
export interface WatchHandle {
|
|
35
|
+
close(): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
33
38
|
/** A tiny synchronous SQLite handle — the subset the runtime needs. */
|
|
34
39
|
export interface SqliteDb {
|
|
35
40
|
/** Execute one or more statements with no result (DDL, PRAGMA, migrations). */
|
|
@@ -65,19 +70,30 @@ export interface HostContext {
|
|
|
65
70
|
importModule(path: string): Promise<Record<string, unknown>>;
|
|
66
71
|
/** Start an HTTP server. Routing is done by the caller inside `handler`. */
|
|
67
72
|
serveHttp(port: number, handler: HttpHandler): Promise<HttpServer>;
|
|
73
|
+
/**
|
|
74
|
+
* Recursively watch the app root for file changes, invoking `onChange` with the
|
|
75
|
+
* changed path (app-root-relative when the runtime reports it that way). Optional:
|
|
76
|
+
* hosts that cannot watch omit it, and callers fall back to run-once. Returns a
|
|
77
|
+
* handle whose close() stops watching.
|
|
78
|
+
*/
|
|
79
|
+
watch?(onChange: (path: string) => void): WatchHandle;
|
|
68
80
|
/** Current wall-clock time in ms since epoch (seam for tests). */
|
|
69
81
|
now(): number;
|
|
70
82
|
/** Structured log sink. */
|
|
71
83
|
log(level: "info" | "warn" | "error", msg: string, fields?: Record<string, unknown>): void;
|
|
72
84
|
}
|
|
73
85
|
|
|
74
|
-
/** A job handed to a worker handler, plus the ack/fail callbacks.
|
|
75
|
-
|
|
86
|
+
/** A job handed to a worker handler, plus the ack/fail callbacks. `In` types the process
|
|
87
|
+
* variables carried on the job; it defaults to an open record when a handler doesn't
|
|
88
|
+
* declare a concrete input shape. The `object` bound (rather than `Record<string, unknown>`)
|
|
89
|
+
* lets plain `interface` shapes be used as `In` — interfaces lack the implicit index
|
|
90
|
+
* signature that a `Record` bound would demand. */
|
|
91
|
+
export interface EngineJob<In extends object = Record<string, unknown>> {
|
|
76
92
|
jobKey: string;
|
|
77
93
|
jobType: string;
|
|
78
94
|
processInstanceKey?: string;
|
|
79
95
|
elementId?: string;
|
|
80
|
-
variables:
|
|
96
|
+
variables: In;
|
|
81
97
|
}
|
|
82
98
|
|
|
83
99
|
export type JobHandler = (
|
|
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
|
|
|
3
3
|
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
4
4
|
import type { EngineJob, JobHandler, WorkerSubscription } from "../host.ts";
|
|
5
5
|
import type { AppManifest } from "../manifest.ts";
|
|
6
|
-
import { mountWorkers, sdkDecisionEvaluator } from "./workers.ts";
|
|
6
|
+
import { mountWorkers, sdkDecisionEvaluator, type AppJobHandler } from "./workers.ts";
|
|
7
7
|
|
|
8
8
|
/** A tiny engine that records registrations and can deliver a job to a handler. */
|
|
9
9
|
class MiniEngine {
|
|
@@ -156,3 +156,55 @@ test("sdkDecisionEvaluator throws a clear error when the SDK lacks evaluateDecis
|
|
|
156
156
|
const evaluate = sdkDecisionEvaluator({});
|
|
157
157
|
await assert.rejects(evaluate("risk", {}), /does not support evaluateDecision.*"risk"/);
|
|
158
158
|
});
|
|
159
|
+
|
|
160
|
+
// ── AppJobHandler generics: optional In/Out type parameters ──────────────────────────────
|
|
161
|
+
// These are primarily compile-time assertions (the suite is typechecked by `tsc --noEmit`),
|
|
162
|
+
// with a runtime smoke that a typed handler still returns its output when delivered a job.
|
|
163
|
+
test("AppJobHandler carries optional In/Out variable types", async () => {
|
|
164
|
+
// `In` is a plain interface — the `object` bound (not `Record<string, unknown>`) makes this
|
|
165
|
+
// work, since interfaces have no implicit index signature.
|
|
166
|
+
interface In {
|
|
167
|
+
prKey: string;
|
|
168
|
+
round: number;
|
|
169
|
+
summary?: string;
|
|
170
|
+
}
|
|
171
|
+
interface Out {
|
|
172
|
+
ok: boolean;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// In + Out: job.variables is typed as In, the return must be Out (or void).
|
|
176
|
+
const typed: AppJobHandler<In, Out> = async (job) => {
|
|
177
|
+
const key: string = job.variables.prKey; // typed access compiles
|
|
178
|
+
const n: number = job.variables.round;
|
|
179
|
+
return { ok: key.length > 0 && n >= 0 };
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// In only: Out defaults to an open variables map, so any plain object of completion
|
|
183
|
+
// variables may be returned.
|
|
184
|
+
const inOnly: AppJobHandler<In> = (job) => ({ echoed: job.variables.prKey });
|
|
185
|
+
|
|
186
|
+
// No parameters: fully open — the pre-generics behaviour.
|
|
187
|
+
const open: AppJobHandler = (job) => ({ count: Object.keys(job.variables).length });
|
|
188
|
+
|
|
189
|
+
// Returning nothing (void) is allowed under any parameterisation.
|
|
190
|
+
const voidReturn: AppJobHandler<In> = () => {};
|
|
191
|
+
|
|
192
|
+
const job: EngineJob<In> = {
|
|
193
|
+
jobKey: "1",
|
|
194
|
+
jobType: "pr.finalize",
|
|
195
|
+
variables: { prKey: "p1", round: 2 },
|
|
196
|
+
};
|
|
197
|
+
// The open handler defaults `In` to a record; interface-typed jobs aren't assignable to it,
|
|
198
|
+
// so hand it a plainly-typed job (the pre-generics shape).
|
|
199
|
+
const openJob: EngineJob = {
|
|
200
|
+
jobKey: "2",
|
|
201
|
+
jobType: "pr.finalize",
|
|
202
|
+
variables: { prKey: "p1", round: 2 },
|
|
203
|
+
};
|
|
204
|
+
const app = {} as AppApi;
|
|
205
|
+
|
|
206
|
+
assert.deepEqual(await typed(job, app), { ok: true });
|
|
207
|
+
assert.deepEqual(await inOnly(job, app), { echoed: "p1" });
|
|
208
|
+
assert.deepEqual(await open(openJob, app), { count: 2 });
|
|
209
|
+
assert.equal(await voidReturn(job, app), undefined);
|
|
210
|
+
});
|
|
@@ -42,11 +42,26 @@ export function sdkDecisionEvaluator(sdk: unknown): DecisionEvaluator {
|
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/**
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
/**
|
|
46
|
+
* A handler as authored by an app: the job plus the injected app API.
|
|
47
|
+
*
|
|
48
|
+
* Both type parameters are optional:
|
|
49
|
+
* - `In` types `job.variables` (the process variables the job carries).
|
|
50
|
+
* - `Out` types the completion **variables map** the handler returns to the engine.
|
|
51
|
+
* Each defaults to an open `Record<string, unknown>`, so `AppJobHandler`,
|
|
52
|
+
* `AppJobHandler<In>`, and `AppJobHandler<In, Out>` are all valid.
|
|
53
|
+
*
|
|
54
|
+
* The bound is `extends object` (not `Record<string, unknown>`) so a plain `interface In {…}`
|
|
55
|
+
* / `interface Out {…}` can be supplied — interfaces have no implicit index signature and so
|
|
56
|
+
* don't satisfy a `Record` bound. The looser bound means `Out` is not *forced* to be a plain
|
|
57
|
+
* variables map at the type level; authors are expected to return a JSON-object of completion
|
|
58
|
+
* variables (not a `Date`, `Map`, array, etc.), which is what the engine sends on job
|
|
59
|
+
* completion. Both parameters are erased at runtime — they only shape authoring-time types.
|
|
60
|
+
*/
|
|
61
|
+
export type AppJobHandler<In extends object = Record<string, unknown>, Out extends object = Record<string, unknown>> = (
|
|
62
|
+
job: EngineJob<In>,
|
|
48
63
|
app: AppApi,
|
|
49
|
-
) => Promise<
|
|
64
|
+
) => Promise<Out | void> | Out | void;
|
|
50
65
|
|
|
51
66
|
/**
|
|
52
67
|
* Resolve a handler for `jobType` from a loaded module, in priority order:
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { runDev, shouldReload, type DevDeps } from "./devserver.ts";
|
|
4
|
+
import type { HostContext } from "./core/host.ts";
|
|
5
|
+
import type { UrbanApp } from "./core/runtime.ts";
|
|
6
|
+
|
|
7
|
+
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
8
|
+
|
|
9
|
+
test("shouldReload triggers on source files and the manifest", () => {
|
|
10
|
+
for (const p of [
|
|
11
|
+
"nano.app.json",
|
|
12
|
+
"processes/greet.bpmn",
|
|
13
|
+
"decisions/route.dmn",
|
|
14
|
+
"forms/greet.form",
|
|
15
|
+
"workers/greet.ts",
|
|
16
|
+
"workers/greet.js",
|
|
17
|
+
"db/migrations/001_init.sql",
|
|
18
|
+
"./processes/greet.bpmn",
|
|
19
|
+
]) {
|
|
20
|
+
assert.equal(shouldReload(p), true, `expected reload for ${p}`);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("shouldReload ignores generated output, deps, VCS and db churn", () => {
|
|
25
|
+
for (const p of [
|
|
26
|
+
"nano-generated/greeting.schema.sql",
|
|
27
|
+
"nano-generated/urban-workers.d.ts",
|
|
28
|
+
"node_modules/left-pad/index.js",
|
|
29
|
+
".git/index",
|
|
30
|
+
"dist/index.js",
|
|
31
|
+
"db/app.db",
|
|
32
|
+
"db/app.db-wal",
|
|
33
|
+
"db/app.db-shm",
|
|
34
|
+
"db/app.sqlite-journal",
|
|
35
|
+
"README.md",
|
|
36
|
+
"notes.txt",
|
|
37
|
+
"",
|
|
38
|
+
]) {
|
|
39
|
+
assert.equal(shouldReload(p), false, `expected no reload for ${p}`);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("shouldReload honours a custom manifest filename", () => {
|
|
44
|
+
assert.equal(shouldReload("config/app.json", "config/app.json"), true, "custom manifest reloads");
|
|
45
|
+
assert.equal(shouldReload("app.config.json", "app.config.json"), true, "by basename too");
|
|
46
|
+
// A .json that is NOT the configured manifest is not a source file.
|
|
47
|
+
assert.equal(shouldReload("tsconfig.json"), false, "unrelated json ignored");
|
|
48
|
+
assert.equal(shouldReload("config/app.json"), false, "custom manifest ignored under default");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("stop() cancels a pending debounced reload", async () => {
|
|
52
|
+
const starts: string[] = [];
|
|
53
|
+
const stops: string[] = [];
|
|
54
|
+
let clock = 1;
|
|
55
|
+
let watch!: ReturnType<typeof fakeHost>;
|
|
56
|
+
const deps: DevDeps = {
|
|
57
|
+
makeHost: () => ((watch = fakeHost()), watch.host),
|
|
58
|
+
startApp: async () => {
|
|
59
|
+
const id = `a${starts.length}`;
|
|
60
|
+
starts.push(id);
|
|
61
|
+
return fakeApp(stops, id);
|
|
62
|
+
},
|
|
63
|
+
regenerate: async () => ({ count: 0 }),
|
|
64
|
+
now: () => clock++,
|
|
65
|
+
};
|
|
66
|
+
const dev = await runDev({ debounceMs: 50, log: () => {} }, deps);
|
|
67
|
+
watch.fire("workers/x.ts"); // schedules a reload 50ms out
|
|
68
|
+
await dev.stop(); // stop before the debounce fires
|
|
69
|
+
await delay(80);
|
|
70
|
+
assert.equal(starts.length, 1, "pending reload was cancelled by stop()");
|
|
71
|
+
assert.equal(watch.closed(), true, "watcher closed");
|
|
72
|
+
assert.deepEqual(stops, ["a0"], "the running app was stopped exactly once");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("stop() during an in-flight reload does not start a replacement app", async () => {
|
|
76
|
+
const starts: string[] = [];
|
|
77
|
+
const stops: string[] = [];
|
|
78
|
+
let clock = 1;
|
|
79
|
+
const hosts: ReturnType<typeof fakeHost>[] = [];
|
|
80
|
+
// Gate the boot app's stop() so we can flip shutdown mid-reload, in the window
|
|
81
|
+
// between the old app being stopped and the new one starting.
|
|
82
|
+
let releaseStop!: () => void;
|
|
83
|
+
const stopGate = new Promise<void>((r) => (releaseStop = r));
|
|
84
|
+
|
|
85
|
+
const deps: DevDeps = {
|
|
86
|
+
makeHost: () => {
|
|
87
|
+
const h = fakeHost();
|
|
88
|
+
hosts.push(h);
|
|
89
|
+
return h.host;
|
|
90
|
+
},
|
|
91
|
+
startApp: async () => {
|
|
92
|
+
const id = `a${starts.length}`;
|
|
93
|
+
starts.push(id);
|
|
94
|
+
if (id === "a0") {
|
|
95
|
+
return {
|
|
96
|
+
...fakeApp(stops, id),
|
|
97
|
+
async stop() {
|
|
98
|
+
await stopGate; // block the first reload inside app.stop()
|
|
99
|
+
stops.push(id);
|
|
100
|
+
},
|
|
101
|
+
} as UrbanApp;
|
|
102
|
+
}
|
|
103
|
+
return fakeApp(stops, id);
|
|
104
|
+
},
|
|
105
|
+
regenerate: async () => ({ count: 0 }),
|
|
106
|
+
now: () => clock++,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const dev = await runDev({ debounceMs: 5, log: () => {} }, deps);
|
|
110
|
+
hosts[0].fire("workers/x.ts"); // triggers a reload; it will block inside app.stop()
|
|
111
|
+
await delay(20);
|
|
112
|
+
const stopping = dev.stop(); // flips `stopped` while the reload is parked in app.stop()
|
|
113
|
+
releaseStop(); // let the old app finish stopping; reload must now bail, not start a0's replacement
|
|
114
|
+
await stopping;
|
|
115
|
+
|
|
116
|
+
assert.deepEqual(starts, ["a0"], "no replacement app started during teardown");
|
|
117
|
+
assert.deepEqual(stops, ["a0"], "the running app was stopped exactly once");
|
|
118
|
+
assert.equal(hosts[0].closed(), true, "watcher closed");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("import nonce is unique per reload even within the same millisecond", async () => {
|
|
122
|
+
const starts: string[] = [];
|
|
123
|
+
const stops: string[] = [];
|
|
124
|
+
const nonces: string[] = [];
|
|
125
|
+
const hosts: ReturnType<typeof fakeHost>[] = [];
|
|
126
|
+
const deps: DevDeps = {
|
|
127
|
+
makeHost: (nonce) => {
|
|
128
|
+
nonces.push(nonce);
|
|
129
|
+
const h = fakeHost();
|
|
130
|
+
hosts.push(h);
|
|
131
|
+
return h.host;
|
|
132
|
+
},
|
|
133
|
+
startApp: async () => {
|
|
134
|
+
const id = `a${starts.length}`;
|
|
135
|
+
starts.push(id);
|
|
136
|
+
return fakeApp(stops, id);
|
|
137
|
+
},
|
|
138
|
+
regenerate: async () => ({ count: 0 }),
|
|
139
|
+
now: () => 42, // frozen clock: nonce uniqueness must come from the reload counter
|
|
140
|
+
};
|
|
141
|
+
const dev = await runDev({ debounceMs: 1, log: () => {} }, deps);
|
|
142
|
+
// The watcher is registered on the boot host only; fire both changes through it.
|
|
143
|
+
hosts[0].fire("workers/a.ts");
|
|
144
|
+
await delay(15);
|
|
145
|
+
hosts[0].fire("workers/b.ts");
|
|
146
|
+
await delay(15);
|
|
147
|
+
await dev.stop();
|
|
148
|
+
assert.equal(nonces.length, 3, "boot + two reloads built three hosts");
|
|
149
|
+
assert.equal(new Set(nonces).size, 3, "every import nonce is distinct");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("a change during an in-flight reload doesn't detach stop() from it", async () => {
|
|
153
|
+
// Regression: schedule() used to overwrite `inFlight` with reload()'s already-resolved
|
|
154
|
+
// no-op promise when a reload was already running, so stop() wouldn't await the real
|
|
155
|
+
// reload and could tear down while it started a replacement app (a leak).
|
|
156
|
+
const starts: string[] = [];
|
|
157
|
+
const stops: string[] = [];
|
|
158
|
+
const hosts: ReturnType<typeof fakeHost>[] = [];
|
|
159
|
+
let clock = 1;
|
|
160
|
+
// Gate a1's start so reload #1 parks *past* the shutdown checks, inside startApp.
|
|
161
|
+
let releaseStart!: () => void;
|
|
162
|
+
const startGate = new Promise<void>((r) => (releaseStart = r));
|
|
163
|
+
|
|
164
|
+
const deps: DevDeps = {
|
|
165
|
+
makeHost: () => {
|
|
166
|
+
const h = fakeHost();
|
|
167
|
+
hosts.push(h);
|
|
168
|
+
return h.host;
|
|
169
|
+
},
|
|
170
|
+
startApp: async () => {
|
|
171
|
+
const id = `a${starts.length}`;
|
|
172
|
+
starts.push(id);
|
|
173
|
+
if (id === "a1") await startGate; // park reload #1 inside startApp
|
|
174
|
+
return fakeApp(stops, id);
|
|
175
|
+
},
|
|
176
|
+
regenerate: async () => ({ count: 0 }),
|
|
177
|
+
now: () => clock++,
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const dev = await runDev({ debounceMs: 5, log: () => {} }, deps);
|
|
181
|
+
hosts[0].fire("workers/a.ts"); // reload #1: stops a0, then parks starting a1
|
|
182
|
+
await delay(20);
|
|
183
|
+
hosts[0].fire("workers/b.ts"); // a second change lands while reload #1 is in flight
|
|
184
|
+
await delay(20);
|
|
185
|
+
|
|
186
|
+
const stopping = dev.stop(); // must await the real reload #1, not a stale resolved promise
|
|
187
|
+
releaseStart(); // let reload #1 finish starting a1
|
|
188
|
+
await stopping;
|
|
189
|
+
|
|
190
|
+
assert.deepEqual(starts, ["a0", "a1"], "reload #1 started its replacement app");
|
|
191
|
+
assert.deepEqual(stops, ["a0", "a1"], "stop() awaited reload #1 and stopped a1 (no leak)");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// A minimal fake host: runDev only calls host.watch on it.
|
|
195
|
+
function fakeHost(): { host: HostContext; fire: (p: string) => void; closed: () => boolean } {
|
|
196
|
+
let cb: ((p: string) => void) | undefined;
|
|
197
|
+
let closed = false;
|
|
198
|
+
const host = {
|
|
199
|
+
watch(onChange: (p: string) => void) {
|
|
200
|
+
cb = onChange;
|
|
201
|
+
return { close: () => (closed = true) };
|
|
202
|
+
},
|
|
203
|
+
} as unknown as HostContext;
|
|
204
|
+
return { host, fire: (p) => cb?.(p), closed: () => closed };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function fakeApp(stops: string[], id: string): UrbanApp {
|
|
208
|
+
return {
|
|
209
|
+
manifest: { id } as UrbanApp["manifest"],
|
|
210
|
+
root: ".",
|
|
211
|
+
async start() {},
|
|
212
|
+
async stop() {
|
|
213
|
+
stops.push(id);
|
|
214
|
+
},
|
|
215
|
+
inspect: () => ({ app: id }),
|
|
216
|
+
data: undefined,
|
|
217
|
+
security: undefined,
|
|
218
|
+
httpPort: undefined,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
test("runDev derives + starts once, then hot-reloads on a relevant change", async () => {
|
|
223
|
+
const gens: string[] = [];
|
|
224
|
+
const starts: string[] = [];
|
|
225
|
+
const stops: string[] = [];
|
|
226
|
+
const nonces: string[] = [];
|
|
227
|
+
const hosts: ReturnType<typeof fakeHost>[] = [];
|
|
228
|
+
let clock = 1000;
|
|
229
|
+
|
|
230
|
+
const deps: DevDeps = {
|
|
231
|
+
makeHost: (nonce) => {
|
|
232
|
+
nonces.push(nonce);
|
|
233
|
+
const h = fakeHost();
|
|
234
|
+
hosts.push(h);
|
|
235
|
+
return h.host;
|
|
236
|
+
},
|
|
237
|
+
startApp: async (_host) => {
|
|
238
|
+
const id = `app#${starts.length}`;
|
|
239
|
+
starts.push(id);
|
|
240
|
+
return fakeApp(stops, id);
|
|
241
|
+
},
|
|
242
|
+
regenerate: async (root, mf) => {
|
|
243
|
+
gens.push(`${root}:${mf}`);
|
|
244
|
+
return { count: 2 };
|
|
245
|
+
},
|
|
246
|
+
now: () => clock++,
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const logs: string[] = [];
|
|
250
|
+
const dev = await runDev(
|
|
251
|
+
{ root: ".", manifestPath: "nano.app.json", debounceMs: 5, log: (m) => logs.push(m) },
|
|
252
|
+
deps,
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
assert.deepEqual(gens, [".:nano.app.json"], "gen ran once at boot");
|
|
256
|
+
assert.deepEqual(starts, ["app#0"], "app started once at boot");
|
|
257
|
+
assert.equal(stops.length, 0, "nothing stopped yet");
|
|
258
|
+
|
|
259
|
+
// runDev registers the watcher on the boot host only; fire through it.
|
|
260
|
+
const bootWatch = hosts[0];
|
|
261
|
+
|
|
262
|
+
// An irrelevant change (the sqlite db) must NOT reload.
|
|
263
|
+
bootWatch.fire("db/app.db");
|
|
264
|
+
await delay(20);
|
|
265
|
+
assert.equal(starts.length, 1, "db churn did not reload");
|
|
266
|
+
|
|
267
|
+
// A relevant change reloads: stop old, regen, start new with a fresh nonce.
|
|
268
|
+
bootWatch.fire("processes/greet.bpmn");
|
|
269
|
+
await delay(20);
|
|
270
|
+
assert.deepEqual(stops, ["app#0"], "old app stopped");
|
|
271
|
+
assert.equal(gens.length, 2, "gen reran on reload");
|
|
272
|
+
assert.deepEqual(starts, ["app#0", "app#1"], "new app started");
|
|
273
|
+
assert.notEqual(nonces[0], nonces[1], "import nonce changed across reload");
|
|
274
|
+
|
|
275
|
+
await dev.stop();
|
|
276
|
+
assert.equal(bootWatch.closed(), true, "watcher closed on stop");
|
|
277
|
+
assert.deepEqual(stops, ["app#0", "app#1"], "current app stopped on shutdown");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("runDev coalesces a burst of changes into a single reload (debounce)", async () => {
|
|
281
|
+
const starts: string[] = [];
|
|
282
|
+
const stops: string[] = [];
|
|
283
|
+
let clock = 1;
|
|
284
|
+
let watch!: ReturnType<typeof fakeHost>;
|
|
285
|
+
|
|
286
|
+
const deps: DevDeps = {
|
|
287
|
+
makeHost: () => ((watch = fakeHost()), watch.host),
|
|
288
|
+
startApp: async () => {
|
|
289
|
+
const id = `a${starts.length}`;
|
|
290
|
+
starts.push(id);
|
|
291
|
+
return fakeApp(stops, id);
|
|
292
|
+
},
|
|
293
|
+
regenerate: async () => ({ count: 0 }),
|
|
294
|
+
now: () => clock++,
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const dev = await runDev({ debounceMs: 15, log: () => {} }, deps);
|
|
298
|
+
for (const p of ["workers/a.ts", "workers/b.ts", "forms/c.form"]) watch.fire(p);
|
|
299
|
+
await delay(40);
|
|
300
|
+
assert.equal(starts.length, 2, "three rapid edits produced exactly one reload");
|
|
301
|
+
await dev.stop();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("runDev survives a failing reload and keeps the previous app running", async () => {
|
|
305
|
+
const stops: string[] = [];
|
|
306
|
+
let boom = false;
|
|
307
|
+
let clock = 1;
|
|
308
|
+
let watch!: ReturnType<typeof fakeHost>;
|
|
309
|
+
const logs: string[] = [];
|
|
310
|
+
|
|
311
|
+
const deps: DevDeps = {
|
|
312
|
+
makeHost: () => ((watch = fakeHost()), watch.host),
|
|
313
|
+
startApp: async () => fakeApp(stops, "app"),
|
|
314
|
+
regenerate: async () => {
|
|
315
|
+
if (boom) throw new Error("bad manifest");
|
|
316
|
+
return { count: 1 };
|
|
317
|
+
},
|
|
318
|
+
now: () => clock++,
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const dev = await runDev({ debounceMs: 5, log: (m) => logs.push(m) }, deps);
|
|
322
|
+
boom = true;
|
|
323
|
+
watch.fire("nano.app.json");
|
|
324
|
+
await delay(20);
|
|
325
|
+
assert.ok(
|
|
326
|
+
logs.some((l) => l.includes("reload failed")),
|
|
327
|
+
"a failed reload is reported, not thrown",
|
|
328
|
+
);
|
|
329
|
+
await dev.stop();
|
|
330
|
+
});
|