@nanobpm/urban 0.12.0 → 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.
- 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 +11 -0
- 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 +12 -0
- 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
package/dist/cli.js
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
// urban check load + validate the manifest, report issues
|
|
5
5
|
// urban gen derive generated artifacts (migrations, worker I/O)
|
|
6
6
|
// urban run materialize + serve the app (deploy, data, workers, surfaces, triggers)
|
|
7
|
-
// urban dev like run
|
|
7
|
+
// urban dev like run, plus watch sources and hot-reload on change
|
|
8
8
|
// urban deploy deploy models only, then exit
|
|
9
9
|
//
|
|
10
10
|
// Global flags: --root <dir> (default "."), --manifest <file> (default nano.app.json),
|
|
11
11
|
// --port <n>, -h/--help, -v/--version.
|
|
12
|
-
import { collectManifestIssues, loadManifest, runFromEnv, selectHost, } from "./runtime/index.js";
|
|
12
|
+
import { collectManifestIssues, installSignalHandlers, loadManifest, runDev, runFromEnv, selectHost, } from "./runtime/index.js";
|
|
13
13
|
import { scaffold, slugify } from "create-urban-app";
|
|
14
14
|
import { createNodeGenIO, runGen } from "./toolkit/index.js";
|
|
15
15
|
import { pathToFileURL } from "node:url";
|
|
@@ -72,7 +72,7 @@ Usage:
|
|
|
72
72
|
urban check validate the manifest
|
|
73
73
|
urban gen [--check] derive artifacts (migrations, worker-io)
|
|
74
74
|
urban run materialize + serve the app
|
|
75
|
-
urban dev run
|
|
75
|
+
urban dev run + watch sources, hot-reload on change
|
|
76
76
|
urban deploy deploy models only, then exit
|
|
77
77
|
|
|
78
78
|
Global flags:
|
|
@@ -128,6 +128,11 @@ async function cmdRun(f, mount) {
|
|
|
128
128
|
console.log(`▲ ${info.name} — surfaces on :${info.httpPort ?? "n/a"} (Ctrl-C to stop)`);
|
|
129
129
|
return -1; // keep the process alive; signal handlers stop it
|
|
130
130
|
}
|
|
131
|
+
async function cmdDev(f) {
|
|
132
|
+
const dev = await runDev({ root: f.root, manifestPath: f.manifest, port: f.port });
|
|
133
|
+
installSignalHandlers(() => dev.stop());
|
|
134
|
+
return -1; // keep the process alive; signal handlers stop the dev server
|
|
135
|
+
}
|
|
131
136
|
async function cmdDeploy(f) {
|
|
132
137
|
const app = await runFromEnv({
|
|
133
138
|
root: f.root,
|
|
@@ -183,8 +188,7 @@ export async function main(argv) {
|
|
|
183
188
|
case "run":
|
|
184
189
|
return cmdRun(f);
|
|
185
190
|
case "dev":
|
|
186
|
-
|
|
187
|
-
return cmdRun(f);
|
|
191
|
+
return cmdDev(f);
|
|
188
192
|
case "deploy":
|
|
189
193
|
return cmdDeploy(f);
|
|
190
194
|
default:
|
|
@@ -2,5 +2,7 @@ import type { HostContext } from "../core/host.ts";
|
|
|
2
2
|
export interface DenoHostOptions {
|
|
3
3
|
cwd?: string;
|
|
4
4
|
log?: HostContext["log"];
|
|
5
|
+
/** See NodeHostOptions.importNonce — appended as `?v=<nonce>` to dynamic imports. */
|
|
6
|
+
importNonce?: string;
|
|
5
7
|
}
|
|
6
8
|
export declare function createDenoHost(opts?: DenoHostOptions): HostContext;
|
|
@@ -51,10 +51,42 @@ export function createDenoHost(opts = {}) {
|
|
|
51
51
|
const db = new DatabaseSync(abs(path));
|
|
52
52
|
return wrapSqlite(db);
|
|
53
53
|
},
|
|
54
|
-
importModule: (p) =>
|
|
54
|
+
importModule: (p) => {
|
|
55
|
+
const href = pathToFileURL(abs(p)).href + (opts.importNonce ? `?v=${opts.importNonce}` : "");
|
|
56
|
+
return import(__rewriteRelativeImportExtension(href));
|
|
57
|
+
},
|
|
55
58
|
async serveHttp(port, handler) {
|
|
56
59
|
return startDenoServer(port, handler);
|
|
57
60
|
},
|
|
61
|
+
watch(onChange) {
|
|
62
|
+
const w = Deno.watchFs(cwd, { recursive: true });
|
|
63
|
+
let closed = false;
|
|
64
|
+
(async () => {
|
|
65
|
+
try {
|
|
66
|
+
for await (const ev of w) {
|
|
67
|
+
for (const p of ev.paths)
|
|
68
|
+
onChange(p);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
// Deno.watchFs throws when close() is called mid-await — expected on shutdown.
|
|
73
|
+
// Any other failure (permissions, path removed, …) silently disables hot reload,
|
|
74
|
+
// so surface it instead of swallowing it.
|
|
75
|
+
if (!closed)
|
|
76
|
+
log("warn", "file watch error — hot reload may be disabled", {
|
|
77
|
+
error: String(err),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
})();
|
|
81
|
+
return {
|
|
82
|
+
close: () => {
|
|
83
|
+
if (closed)
|
|
84
|
+
return;
|
|
85
|
+
closed = true;
|
|
86
|
+
w.close();
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
},
|
|
58
90
|
now: () => Date.now(),
|
|
59
91
|
log,
|
|
60
92
|
};
|
|
@@ -4,6 +4,8 @@ export declare function isDeno(): boolean;
|
|
|
4
4
|
export interface SelectHostOptions {
|
|
5
5
|
cwd?: string;
|
|
6
6
|
log?: HostContext["log"];
|
|
7
|
+
/** Dev-only import-cache-busting nonce; forwarded to the chosen adapter. */
|
|
8
|
+
importNonce?: string;
|
|
7
9
|
}
|
|
8
10
|
/** Select and construct the host for the current runtime. */
|
|
9
11
|
export declare function selectHost(opts?: SelectHostOptions): HostContext;
|
|
@@ -3,5 +3,11 @@ export interface NodeHostOptions {
|
|
|
3
3
|
/** Base directory relative paths resolve against. Default process.cwd(). */
|
|
4
4
|
cwd?: string;
|
|
5
5
|
log?: HostContext["log"];
|
|
6
|
+
/**
|
|
7
|
+
* When set, appended as a `?v=<nonce>` query to dynamic import URLs so a changed
|
|
8
|
+
* handler/worker module is re-evaluated instead of served from the ESM cache. The
|
|
9
|
+
* dev server bumps this on every reload; production leaves it unset.
|
|
10
|
+
*/
|
|
11
|
+
importNonce?: string;
|
|
6
12
|
}
|
|
7
13
|
export declare function createNodeHost(opts?: NodeHostOptions): HostContext;
|
|
@@ -9,6 +9,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
9
9
|
return path;
|
|
10
10
|
};
|
|
11
11
|
import { readFile, readdir, stat } from "node:fs/promises";
|
|
12
|
+
import { watch as fsWatch } from "node:fs";
|
|
12
13
|
import { createServer } from "node:http";
|
|
13
14
|
import { isAbsolute, resolve } from "node:path";
|
|
14
15
|
import { pathToFileURL } from "node:url";
|
|
@@ -47,10 +48,36 @@ export function createNodeHost(opts = {}) {
|
|
|
47
48
|
const db = new DatabaseSync(abs(path));
|
|
48
49
|
return wrapNodeSqlite(db);
|
|
49
50
|
},
|
|
50
|
-
importModule: (p) =>
|
|
51
|
+
importModule: (p) => {
|
|
52
|
+
const href = pathToFileURL(abs(p)).href + (opts.importNonce ? `?v=${opts.importNonce}` : "");
|
|
53
|
+
return import(__rewriteRelativeImportExtension(href));
|
|
54
|
+
},
|
|
51
55
|
async serveHttp(port, handler) {
|
|
52
56
|
return await startNodeServer(port, handler);
|
|
53
57
|
},
|
|
58
|
+
watch(onChange) {
|
|
59
|
+
const onFsEvent = (_event, filename) => {
|
|
60
|
+
if (filename)
|
|
61
|
+
onChange(String(filename));
|
|
62
|
+
};
|
|
63
|
+
// Recursive watch is supported on macOS, Windows, and — since Node 19.1.0 — Linux.
|
|
64
|
+
// This package requires Node >=22.6, so the recursive path is available on all three;
|
|
65
|
+
// the try/catch only trips on an unusual platform, where we degrade honestly to a
|
|
66
|
+
// non-recursive root watch (and warn) rather than silently miss nested changes.
|
|
67
|
+
let w;
|
|
68
|
+
try {
|
|
69
|
+
w = fsWatch(cwd, { recursive: true }, onFsEvent);
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
log("warn", "recursive file watch unavailable — nested changes may be missed", {
|
|
73
|
+
error: String(err),
|
|
74
|
+
});
|
|
75
|
+
w = fsWatch(cwd, onFsEvent);
|
|
76
|
+
}
|
|
77
|
+
// A watcher error (e.g. the dir is removed) must not crash the dev server.
|
|
78
|
+
w.on("error", (err) => log("warn", "file watch error", { error: String(err) }));
|
|
79
|
+
return { close: () => w.close() };
|
|
80
|
+
},
|
|
54
81
|
now: () => Date.now(),
|
|
55
82
|
log,
|
|
56
83
|
};
|
|
@@ -21,6 +21,10 @@ export interface HttpServer {
|
|
|
21
21
|
readonly port: number;
|
|
22
22
|
stop(): Promise<void>;
|
|
23
23
|
}
|
|
24
|
+
/** A handle to an active filesystem watch; call close() to stop watching. */
|
|
25
|
+
export interface WatchHandle {
|
|
26
|
+
close(): void;
|
|
27
|
+
}
|
|
24
28
|
/** A tiny synchronous SQLite handle — the subset the runtime needs. */
|
|
25
29
|
export interface SqliteDb {
|
|
26
30
|
/** Execute one or more statements with no result (DDL, PRAGMA, migrations). */
|
|
@@ -58,6 +62,13 @@ export interface HostContext {
|
|
|
58
62
|
importModule(path: string): Promise<Record<string, unknown>>;
|
|
59
63
|
/** Start an HTTP server. Routing is done by the caller inside `handler`. */
|
|
60
64
|
serveHttp(port: number, handler: HttpHandler): Promise<HttpServer>;
|
|
65
|
+
/**
|
|
66
|
+
* Recursively watch the app root for file changes, invoking `onChange` with the
|
|
67
|
+
* changed path (app-root-relative when the runtime reports it that way). Optional:
|
|
68
|
+
* hosts that cannot watch omit it, and callers fall back to run-once. Returns a
|
|
69
|
+
* handle whose close() stops watching.
|
|
70
|
+
*/
|
|
71
|
+
watch?(onChange: (path: string) => void): WatchHandle;
|
|
61
72
|
/** Current wall-clock time in ms since epoch (seam for tests). */
|
|
62
73
|
now(): number;
|
|
63
74
|
/** Structured log sink. */
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { HostContext } from "./core/host.ts";
|
|
2
|
+
import type { UrbanApp } from "./core/runtime.ts";
|
|
3
|
+
/** True when a changed path should trigger a dev reload. Pure, so it is unit-tested.
|
|
4
|
+
* `manifestFile` (the app's configured manifest, default nano.app.json) always triggers,
|
|
5
|
+
* so `urban dev --manifest other.json` reloads on edits to that file too. */
|
|
6
|
+
export declare function shouldReload(rawPath: string, manifestFile?: string): boolean;
|
|
7
|
+
export interface DevOptions {
|
|
8
|
+
root?: string;
|
|
9
|
+
manifestPath?: string;
|
|
10
|
+
port?: number;
|
|
11
|
+
/** Quiet-period after the last change before reloading, in ms. Default 150. */
|
|
12
|
+
debounceMs?: number;
|
|
13
|
+
/** Log sink (default console.log). */
|
|
14
|
+
log?: (msg: string) => void;
|
|
15
|
+
}
|
|
16
|
+
/** Injectable seams so the loop is testable without a live engine or filesystem. */
|
|
17
|
+
export interface DevDeps {
|
|
18
|
+
makeHost(nonce: string): HostContext;
|
|
19
|
+
startApp(host: HostContext, o: {
|
|
20
|
+
manifestPath: string;
|
|
21
|
+
port?: number;
|
|
22
|
+
}): Promise<UrbanApp>;
|
|
23
|
+
regenerate(root: string, manifestFile: string): Promise<{
|
|
24
|
+
count: number;
|
|
25
|
+
}>;
|
|
26
|
+
now(): number;
|
|
27
|
+
}
|
|
28
|
+
export interface DevServer {
|
|
29
|
+
stop(): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Start the dev server: gen once, start the app, then watch + hot-reload. Returns a handle
|
|
33
|
+
* whose stop() tears the watcher and the running app down. Never rejects on a reload error —
|
|
34
|
+
* a bad edit logs and leaves the previous app running.
|
|
35
|
+
*/
|
|
36
|
+
export declare function runDev(opts?: DevOptions, deps?: Partial<DevDeps>): Promise<DevServer>;
|
|
@@ -0,0 +1,173 @@
|
|
|
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
|
+
import { runFromEnv } from "./run.js";
|
|
14
|
+
import { selectHost } from "./adapters/detect.js";
|
|
15
|
+
import { createNodeGenIO, runGen } from "../toolkit/index.js";
|
|
16
|
+
const SOURCE_EXT = [".bpmn", ".dmn", ".form", ".ts", ".js", ".mjs", ".sql"];
|
|
17
|
+
// Directories whose churn must never trigger a reload: generated output (gen writes here —
|
|
18
|
+
// watching it would loop forever), dependencies, VCS, and build output.
|
|
19
|
+
const IGNORE_SEGMENTS = ["nano-generated", "node_modules", ".git", "dist"];
|
|
20
|
+
/** True when a changed path should trigger a dev reload. Pure, so it is unit-tested.
|
|
21
|
+
* `manifestFile` (the app's configured manifest, default nano.app.json) always triggers,
|
|
22
|
+
* so `urban dev --manifest other.json` reloads on edits to that file too. */
|
|
23
|
+
export function shouldReload(rawPath, manifestFile = "nano.app.json") {
|
|
24
|
+
const norm = (s) => s.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
25
|
+
const p = norm(rawPath);
|
|
26
|
+
if (p === "")
|
|
27
|
+
return false;
|
|
28
|
+
for (const seg of IGNORE_SEGMENTS) {
|
|
29
|
+
if (p === seg || p.startsWith(`${seg}/`) || p.includes(`/${seg}/`))
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
// Ignore SQLite database files and their WAL/SHM/journal sidecars — they change on every
|
|
33
|
+
// write and would otherwise wedge the server in a reload loop.
|
|
34
|
+
if (/\.(db|sqlite|sqlite3)(-wal|-shm|-journal)?$/i.test(p))
|
|
35
|
+
return false;
|
|
36
|
+
const mf = norm(manifestFile);
|
|
37
|
+
const base = p.slice(p.lastIndexOf("/") + 1);
|
|
38
|
+
if (p === mf || base === mf.slice(mf.lastIndexOf("/") + 1))
|
|
39
|
+
return true;
|
|
40
|
+
return SOURCE_EXT.some((e) => p.toLowerCase().endsWith(e));
|
|
41
|
+
}
|
|
42
|
+
function defaultDeps(root) {
|
|
43
|
+
return {
|
|
44
|
+
makeHost: (nonce) => selectHost({ cwd: root, importNonce: nonce }),
|
|
45
|
+
startApp: (host, o) =>
|
|
46
|
+
// The host is anchored at `root`; runFromEnv keeps its own root at "." accordingly.
|
|
47
|
+
runFromEnv({ host, manifestPath: o.manifestPath, port: o.port, handleSignals: false }),
|
|
48
|
+
regenerate: async (r, mf) => {
|
|
49
|
+
const res = await runGen({ root: r, io: createNodeGenIO(), manifestFile: mf });
|
|
50
|
+
return { count: res.artifacts.length };
|
|
51
|
+
},
|
|
52
|
+
now: () => Date.now(),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Start the dev server: gen once, start the app, then watch + hot-reload. Returns a handle
|
|
57
|
+
* whose stop() tears the watcher and the running app down. Never rejects on a reload error —
|
|
58
|
+
* a bad edit logs and leaves the previous app running.
|
|
59
|
+
*/
|
|
60
|
+
export async function runDev(opts = {}, deps) {
|
|
61
|
+
const root = opts.root ?? ".";
|
|
62
|
+
const manifestFile = opts.manifestPath ?? "nano.app.json";
|
|
63
|
+
const debounceMs = opts.debounceMs ?? 150;
|
|
64
|
+
const log = opts.log ?? ((m) => console.log(m));
|
|
65
|
+
const d = { ...defaultDeps(root), ...deps };
|
|
66
|
+
// Monotonic reload counter, mixed into the import nonce so it is unique per reload even when
|
|
67
|
+
// two reloads land in the same millisecond (d.now() can repeat).
|
|
68
|
+
let reloadSeq = 0;
|
|
69
|
+
const startCycle = async () => {
|
|
70
|
+
const host = await prepareHost();
|
|
71
|
+
const app = await d.startApp(host, { manifestPath: manifestFile, port: opts.port });
|
|
72
|
+
return { host, app };
|
|
73
|
+
};
|
|
74
|
+
// Derive artifacts and build a fresh host (with a new import nonce). This is pure/FS work
|
|
75
|
+
// that does NOT touch the running app, so it can run *before* a reload tears the old app
|
|
76
|
+
// down — most bad edits (invalid manifest/model) fail here, leaving the app serving.
|
|
77
|
+
const prepareHost = async () => {
|
|
78
|
+
const nonce = `${d.now()}-${reloadSeq++}`;
|
|
79
|
+
const { count } = await d.regenerate(root, manifestFile);
|
|
80
|
+
if (count > 0)
|
|
81
|
+
log(` derived ${count} artifact(s)`);
|
|
82
|
+
return d.makeHost(nonce);
|
|
83
|
+
};
|
|
84
|
+
let { host, app } = await startCycle();
|
|
85
|
+
let reloading = false;
|
|
86
|
+
let queued = false;
|
|
87
|
+
let stopped = false;
|
|
88
|
+
let appRunning = true;
|
|
89
|
+
let inFlight;
|
|
90
|
+
let timer;
|
|
91
|
+
const reload = async () => {
|
|
92
|
+
try {
|
|
93
|
+
log("↻ change detected — reloading…");
|
|
94
|
+
// Regenerate + rebuild the host BEFORE stopping the running app, so a bad edit
|
|
95
|
+
// throws here and the current app keeps serving untouched.
|
|
96
|
+
const nextHost = await prepareHost();
|
|
97
|
+
if (stopped)
|
|
98
|
+
return; // shutdown began mid-prepare: leave the running app to stop()
|
|
99
|
+
await app.stop();
|
|
100
|
+
appRunning = false;
|
|
101
|
+
// Re-check after the async stop: if shutdown began while we were tearing the old
|
|
102
|
+
// app down, don't start a replacement just to have stop() immediately kill it.
|
|
103
|
+
if (stopped)
|
|
104
|
+
return;
|
|
105
|
+
app = await d.startApp(nextHost, { manifestPath: manifestFile, port: opts.port });
|
|
106
|
+
appRunning = true;
|
|
107
|
+
host = nextHost;
|
|
108
|
+
log("✔ reloaded");
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
// Keep the dev server alive on a bad edit; the next save will retry.
|
|
112
|
+
log(`✖ reload failed: ${String(err?.message ?? err)}`);
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
reloading = false;
|
|
116
|
+
if (queued && !stopped) {
|
|
117
|
+
queued = false;
|
|
118
|
+
schedule();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
// Owns the single-flight guard and `inFlight` bookkeeping: only an *actually running*
|
|
123
|
+
// reload is tracked in `inFlight`, so stop() always awaits the real in-flight reload
|
|
124
|
+
// (never an already-resolved no-op promise) before final teardown.
|
|
125
|
+
const trigger = () => {
|
|
126
|
+
if (stopped)
|
|
127
|
+
return;
|
|
128
|
+
if (reloading) {
|
|
129
|
+
queued = true; // the active reload's finally re-schedules the next cycle
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
reloading = true;
|
|
133
|
+
inFlight = reload();
|
|
134
|
+
};
|
|
135
|
+
const schedule = () => {
|
|
136
|
+
if (stopped)
|
|
137
|
+
return;
|
|
138
|
+
if (timer)
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
timer = setTimeout(() => {
|
|
141
|
+
timer = undefined;
|
|
142
|
+
trigger();
|
|
143
|
+
}, debounceMs);
|
|
144
|
+
};
|
|
145
|
+
let watcher;
|
|
146
|
+
if (host.watch) {
|
|
147
|
+
watcher = host.watch((path) => {
|
|
148
|
+
if (shouldReload(path, manifestFile))
|
|
149
|
+
schedule();
|
|
150
|
+
});
|
|
151
|
+
log("▲ dev server ready — watching for changes (Ctrl-C to stop)");
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
log("▲ dev server ready — file watching is not supported on this host; running once " +
|
|
155
|
+
"(no hot reload, Ctrl-C to stop)");
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
async stop() {
|
|
159
|
+
// Order matters: flag first so a queued/in-flight reload won't start a new app, cancel
|
|
160
|
+
// any pending debounce, stop watching, then let any in-flight reload settle before the
|
|
161
|
+
// final teardown so we stop exactly the app that is (or ends up) current.
|
|
162
|
+
stopped = true;
|
|
163
|
+
if (timer) {
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
timer = undefined;
|
|
166
|
+
}
|
|
167
|
+
watcher?.close();
|
|
168
|
+
await inFlight?.catch(() => { });
|
|
169
|
+
if (appRunning)
|
|
170
|
+
await app.stop();
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export { expandEnv, expandEnvString, loadManifest, parseManifest, workerJobType,
|
|
|
4
4
|
export type { ActionDecl, AppManifest, DataSource, DomainType, LlmBinding, SurfaceDecl, TriggerDecl, TypeField, WorkerDecl, } from "./core/manifest.ts";
|
|
5
5
|
export { collectManifestIssues, ManifestValidationError, validateManifest, } from "./core/validate.ts";
|
|
6
6
|
export type { ValidationIssue } from "./core/validate.ts";
|
|
7
|
-
export type { EngineClient, EngineJob, HostContext, HttpHandler, HttpRequest, HttpResponse, HttpServer, JobHandler, SqliteDb, WorkerSubscription, } from "./core/host.ts";
|
|
7
|
+
export type { EngineClient, EngineJob, HostContext, HttpHandler, HttpRequest, HttpResponse, HttpServer, JobHandler, SqliteDb, WatchHandle, WorkerSubscription, } from "./core/host.ts";
|
|
8
8
|
export type { AppApi, Mounted, RuntimeContext } from "./core/context.ts";
|
|
9
9
|
export { DataLayer, TypeRepo } from "./core/modules/datasource.ts";
|
|
10
10
|
export type { ProvisionedSource } from "./core/modules/datasource.ts";
|
|
@@ -25,5 +25,7 @@ export { isDeno, selectHost } from "./adapters/detect.ts";
|
|
|
25
25
|
export { createNanoSdkEngineClient, SdkEngineClient } from "./engine/nanosdk.ts";
|
|
26
26
|
export type { NanoSdkClient, NanoSdkEngineOptions, NanoSdkJobWorker, NanoSdkJobWorkerConfig, } from "./engine/nanosdk.ts";
|
|
27
27
|
export type { EngineSdkClient } from "./engine/sdk.ts";
|
|
28
|
-
export { runFromEnv } from "./run.ts";
|
|
28
|
+
export { installSignalHandlers, runFromEnv } from "./run.ts";
|
|
29
29
|
export type { RunOptions } from "./run.ts";
|
|
30
|
+
export { runDev, shouldReload } from "./devserver.ts";
|
|
31
|
+
export type { DevDeps, DevOptions, DevServer } from "./devserver.ts";
|
package/dist/runtime/index.js
CHANGED
|
@@ -18,4 +18,5 @@ export { createNodeHost } from "./adapters/node.js";
|
|
|
18
18
|
export { createDenoHost } from "./adapters/deno.js";
|
|
19
19
|
export { isDeno, selectHost } from "./adapters/detect.js";
|
|
20
20
|
export { createNanoSdkEngineClient, SdkEngineClient } from "./engine/nanosdk.js";
|
|
21
|
-
export { runFromEnv } from "./run.js";
|
|
21
|
+
export { installSignalHandlers, runFromEnv } from "./run.js";
|
|
22
|
+
export { runDev, shouldReload } from "./devserver.js";
|
package/dist/runtime/run.d.ts
CHANGED
package/dist/runtime/run.js
CHANGED
|
@@ -32,7 +32,7 @@ export async function runFromEnv(opts = {}) {
|
|
|
32
32
|
installSignalHandlers(() => app.stop());
|
|
33
33
|
return app;
|
|
34
34
|
}
|
|
35
|
-
function installSignalHandlers(stop) {
|
|
35
|
+
export function installSignalHandlers(stop) {
|
|
36
36
|
let stopping = false;
|
|
37
37
|
const onSignal = () => {
|
|
38
38
|
if (stopping)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"@nanobpm/nano-sdk": "^1.2.0",
|
|
62
62
|
"@nanobpm/workflow": "^0.7.0",
|
|
63
63
|
"bpmn-auto-layout": "^2.0.0-alpha.2",
|
|
64
|
-
"create-urban-app": "^0.
|
|
64
|
+
"create-urban-app": "^0.4.0"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
67
|
"@types/node": "^22.20.1",
|
package/src/cli.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// urban check load + validate the manifest, report issues
|
|
5
5
|
// urban gen derive generated artifacts (migrations, worker I/O)
|
|
6
6
|
// urban run materialize + serve the app (deploy, data, workers, surfaces, triggers)
|
|
7
|
-
// urban dev like run
|
|
7
|
+
// urban dev like run, plus watch sources and hot-reload on change
|
|
8
8
|
// urban deploy deploy models only, then exit
|
|
9
9
|
//
|
|
10
10
|
// Global flags: --root <dir> (default "."), --manifest <file> (default nano.app.json),
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
|
|
13
13
|
import {
|
|
14
14
|
collectManifestIssues,
|
|
15
|
+
installSignalHandlers,
|
|
15
16
|
loadManifest,
|
|
17
|
+
runDev,
|
|
16
18
|
runFromEnv,
|
|
17
19
|
selectHost,
|
|
18
20
|
} from "./runtime/index.ts";
|
|
@@ -82,7 +84,7 @@ Usage:
|
|
|
82
84
|
urban check validate the manifest
|
|
83
85
|
urban gen [--check] derive artifacts (migrations, worker-io)
|
|
84
86
|
urban run materialize + serve the app
|
|
85
|
-
urban dev run
|
|
87
|
+
urban dev run + watch sources, hot-reload on change
|
|
86
88
|
urban deploy deploy models only, then exit
|
|
87
89
|
|
|
88
90
|
Global flags:
|
|
@@ -139,6 +141,12 @@ async function cmdRun(f: Flags, mount?: Record<string, boolean>): Promise<number
|
|
|
139
141
|
return -1; // keep the process alive; signal handlers stop it
|
|
140
142
|
}
|
|
141
143
|
|
|
144
|
+
async function cmdDev(f: Flags): Promise<number> {
|
|
145
|
+
const dev = await runDev({ root: f.root, manifestPath: f.manifest, port: f.port });
|
|
146
|
+
installSignalHandlers(() => dev.stop());
|
|
147
|
+
return -1; // keep the process alive; signal handlers stop the dev server
|
|
148
|
+
}
|
|
149
|
+
|
|
142
150
|
async function cmdDeploy(f: Flags): Promise<number> {
|
|
143
151
|
const app = await runFromEnv({
|
|
144
152
|
root: f.root,
|
|
@@ -194,8 +202,7 @@ export async function main(argv: string[]): Promise<number> {
|
|
|
194
202
|
case "run":
|
|
195
203
|
return cmdRun(f);
|
|
196
204
|
case "dev":
|
|
197
|
-
|
|
198
|
-
return cmdRun(f);
|
|
205
|
+
return cmdDev(f);
|
|
199
206
|
case "deploy":
|
|
200
207
|
return cmdDeploy(f);
|
|
201
208
|
default:
|
|
@@ -18,12 +18,16 @@ interface DenoHttpServer {
|
|
|
18
18
|
finished: Promise<void>;
|
|
19
19
|
shutdown(): Promise<void>;
|
|
20
20
|
}
|
|
21
|
+
interface DenoFsWatcher extends AsyncIterable<{ kind: string; paths: string[] }> {
|
|
22
|
+
close(): void;
|
|
23
|
+
}
|
|
21
24
|
interface DenoGlobal {
|
|
22
25
|
env: { get(name: string): string | undefined };
|
|
23
26
|
cwd(): string;
|
|
24
27
|
readTextFile(path: string): Promise<string>;
|
|
25
28
|
readDir(path: string): AsyncIterable<{ name: string; isFile: boolean }>;
|
|
26
29
|
stat(path: string): Promise<unknown>;
|
|
30
|
+
watchFs(paths: string | string[], options?: { recursive?: boolean }): DenoFsWatcher;
|
|
27
31
|
serve(
|
|
28
32
|
opts: { port: number; onListen?: (a: { port: number }) => void },
|
|
29
33
|
handler: (req: Request) => Response | Promise<Response>,
|
|
@@ -34,6 +38,8 @@ declare const Deno: DenoGlobal;
|
|
|
34
38
|
export interface DenoHostOptions {
|
|
35
39
|
cwd?: string;
|
|
36
40
|
log?: HostContext["log"];
|
|
41
|
+
/** See NodeHostOptions.importNonce — appended as `?v=<nonce>` to dynamic imports. */
|
|
42
|
+
importNonce?: string;
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
export function createDenoHost(opts: DenoHostOptions = {}): HostContext {
|
|
@@ -75,10 +81,39 @@ export function createDenoHost(opts: DenoHostOptions = {}): HostContext {
|
|
|
75
81
|
const db = new DatabaseSync(abs(path));
|
|
76
82
|
return wrapSqlite(db);
|
|
77
83
|
},
|
|
78
|
-
importModule: (p) =>
|
|
84
|
+
importModule: (p) => {
|
|
85
|
+
const href =
|
|
86
|
+
pathToFileURL(abs(p)).href + (opts.importNonce ? `?v=${opts.importNonce}` : "");
|
|
87
|
+
return import(href) as Promise<Record<string, unknown>>;
|
|
88
|
+
},
|
|
79
89
|
async serveHttp(port, handler) {
|
|
80
90
|
return startDenoServer(port, handler);
|
|
81
91
|
},
|
|
92
|
+
watch(onChange) {
|
|
93
|
+
const w = Deno.watchFs(cwd, { recursive: true });
|
|
94
|
+
let closed = false;
|
|
95
|
+
(async () => {
|
|
96
|
+
try {
|
|
97
|
+
for await (const ev of w) {
|
|
98
|
+
for (const p of ev.paths) onChange(p);
|
|
99
|
+
}
|
|
100
|
+
} catch (err) {
|
|
101
|
+
// Deno.watchFs throws when close() is called mid-await — expected on shutdown.
|
|
102
|
+
// Any other failure (permissions, path removed, …) silently disables hot reload,
|
|
103
|
+
// so surface it instead of swallowing it.
|
|
104
|
+
if (!closed) log("warn", "file watch error — hot reload may be disabled", {
|
|
105
|
+
error: String(err),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
})();
|
|
109
|
+
return {
|
|
110
|
+
close: () => {
|
|
111
|
+
if (closed) return;
|
|
112
|
+
closed = true;
|
|
113
|
+
w.close();
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
},
|
|
82
117
|
now: () => Date.now(),
|
|
83
118
|
log,
|
|
84
119
|
};
|
|
@@ -14,6 +14,8 @@ export function isDeno(): boolean {
|
|
|
14
14
|
export interface SelectHostOptions {
|
|
15
15
|
cwd?: string;
|
|
16
16
|
log?: HostContext["log"];
|
|
17
|
+
/** Dev-only import-cache-busting nonce; forwarded to the chosen adapter. */
|
|
18
|
+
importNonce?: string;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
/** Select and construct the host for the current runtime. */
|
|
@@ -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,6 +70,13 @@ 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. */
|
|
@@ -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
|
+
});
|
|
@@ -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
|
+
}
|
package/src/runtime/index.ts
CHANGED
|
@@ -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";
|
package/src/runtime/run.ts
CHANGED
|
@@ -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;
|