@nanobpm/urban 0.38.0 → 0.40.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/README.md +37 -0
- package/dist/cli.js +6 -0
- package/dist/runtime/adapters/deno.js +18 -2
- package/dist/runtime/adapters/node.js +17 -5
- package/dist/runtime/core/context.d.ts +10 -1
- package/dist/runtime/core/host.d.ts +9 -2
- package/dist/runtime/core/logger.d.ts +69 -0
- package/dist/runtime/core/logger.js +134 -0
- package/dist/runtime/core/modules/api.js +7 -1
- package/dist/runtime/core/modules/pages.js +52 -4
- package/dist/runtime/core/modules/workers.js +16 -1
- package/dist/runtime/core/runtime.d.ts +5 -0
- package/dist/runtime/core/runtime.js +7 -1
- package/dist/runtime/engine/nanosdk.d.ts +1 -1
- package/dist/runtime/index.d.ts +1 -0
- package/dist/toolkit/artifact.d.ts +11 -0
- package/dist/toolkit/artifact.js +18 -0
- package/dist/toolkit/gen.d.ts +2 -0
- package/dist/toolkit/gen.js +24 -2
- package/dist/toolkit/scaffold/operations.js +3 -1
- package/dist/toolkit/scaffold/workers.js +2 -0
- package/dist/worker.d.ts +1 -0
- package/package.json +2 -2
- package/src/cli.ts +6 -0
- package/src/runtime/adapters/deno.ts +17 -4
- package/src/runtime/adapters/node.ts +17 -7
- package/src/runtime/core/context.ts +10 -1
- package/src/runtime/core/host.ts +9 -2
- package/src/runtime/core/logger.test.ts +221 -0
- package/src/runtime/core/logger.ts +177 -0
- package/src/runtime/core/modules/actions.test.ts +2 -1
- package/src/runtime/core/modules/api.test.ts +38 -2
- package/src/runtime/core/modules/api.ts +10 -1
- package/src/runtime/core/modules/cancel.test.ts +4 -1
- package/src/runtime/core/modules/connectors.test.ts +2 -1
- package/src/runtime/core/modules/instance-tracking.test.ts +4 -1
- package/src/runtime/core/modules/pages.test.ts +31 -0
- package/src/runtime/core/modules/pages.ts +52 -4
- package/src/runtime/core/modules/surfaces.test.ts +2 -1
- package/src/runtime/core/modules/triggers.test.ts +4 -1
- package/src/runtime/core/modules/workers.test.ts +59 -2
- package/src/runtime/core/modules/workers.ts +15 -1
- package/src/runtime/core/runtime.test.ts +22 -0
- package/src/runtime/core/runtime.ts +12 -1
- package/src/runtime/devserver.test.ts +2 -0
- package/src/runtime/engine/nanosdk.ts +1 -1
- package/src/runtime/index.ts +2 -0
- package/src/toolkit/artifact.ts +19 -0
- package/src/toolkit/gen.test.ts +53 -0
- package/src/toolkit/gen.ts +27 -2
- package/src/toolkit/scaffold/operations.ts +3 -1
- package/src/toolkit/scaffold/workers.ts +2 -0
- package/src/worker.ts +1 -0
package/README.md
CHANGED
|
@@ -100,6 +100,43 @@ The runtime has a single engine client, `SdkEngineClient`, backed by one
|
|
|
100
100
|
selects the wire transport via `CAMUNDA_TRANSPORT`: `auto` (default) upgrades to
|
|
101
101
|
Falcon on a Nano server and falls back to REST elsewhere.
|
|
102
102
|
|
|
103
|
+
### Structured logging
|
|
104
|
+
|
|
105
|
+
Every worker handler and API route delegate receives an `AppApi` whose `log` is a
|
|
106
|
+
level-tagged structured logger (ADR 0061):
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
app.log.info("charge captured", { amount, currency });
|
|
110
|
+
app.log.warn("retrying", { attempt });
|
|
111
|
+
app.log.error("charge failed", { code });
|
|
112
|
+
app.log.debug("gateway response", { raw }); // hidden unless URBAN_LOG_LEVEL=debug
|
|
113
|
+
|
|
114
|
+
const orderLog = app.log.child({ orderId }); // bind context for a scope
|
|
115
|
+
orderLog.info("shipped"); // every line carries orderId
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The runtime **auto-correlates**: a worker handler's `app.log` is pre-bound to
|
|
119
|
+
`{ jobKey, jobType, processInstanceKey, elementId }` and a route delegate's to
|
|
120
|
+
`{ method, path, operationId }`, so every line you emit is tied to its job/request
|
|
121
|
+
for free.
|
|
122
|
+
|
|
123
|
+
The `UrbanApp` handle returned by `runFromEnv`/`createUrbanApp` also carries an
|
|
124
|
+
app-level `app.log` (no per-request correlation) for the entrypoint's boot/shutdown
|
|
125
|
+
lines:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
const app = await runFromEnv();
|
|
129
|
+
app.log.info("started", { httpPort: app.httpPort });
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Records are written as **NDJSON** — one JSON object per line,
|
|
133
|
+
`{"ts":…,"level":…,"msg":…, …fields}` — with `warn`/`error` on stderr and
|
|
134
|
+
`debug`/`info` on stdout. `URBAN_LOG_LEVEL` (default `info`) sets the minimum level.
|
|
135
|
+
|
|
136
|
+
> **Custom hosts:** the `HostContext.log` sink now accepts `"debug"` in addition to
|
|
137
|
+
> `"info" | "warn" | "error"`. A custom host that typed `log` with the narrower union must add a
|
|
138
|
+
> `"debug"` branch to keep satisfying the contract — a breaking change for that surface only.
|
|
139
|
+
|
|
103
140
|
### Toolkit — derive artifacts (`urban gen`)
|
|
104
141
|
|
|
105
142
|
```ts
|
package/dist/cli.js
CHANGED
|
@@ -162,6 +162,12 @@ async function cmdGen(f) {
|
|
|
162
162
|
console.log(`✔ generated ${res.artifacts.length} artifact(s):`);
|
|
163
163
|
for (const a of res.artifacts)
|
|
164
164
|
console.log(` • ${a.path}`);
|
|
165
|
+
// Stale generated files removed from nano-generated/ (renamed/removed artifacts from a prior gen).
|
|
166
|
+
if (res.swept.length > 0) {
|
|
167
|
+
console.log(`✔ removed ${res.swept.length} stale generated file(s):`);
|
|
168
|
+
for (const p of res.swept)
|
|
169
|
+
console.log(` - ${p}`);
|
|
170
|
+
}
|
|
165
171
|
// Write-once handler stubs scaffolded as part of generation (workers from the model, operation
|
|
166
172
|
// delegates from the OpenAPI spec). Existing, human-owned stubs are kept verbatim.
|
|
167
173
|
const created = [
|
|
@@ -16,6 +16,7 @@ import { existsSync } from "node:fs";
|
|
|
16
16
|
import { DatabaseSync } from "node:sqlite";
|
|
17
17
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
18
18
|
import { resolveModulePath } from "../core/module-path.js";
|
|
19
|
+
import { formatLogRecord, levelEnabled, parseLogLevel } from "../core/logger.js";
|
|
19
20
|
function sqliteParams(params) {
|
|
20
21
|
return params.map((param) => {
|
|
21
22
|
if (param === null ||
|
|
@@ -30,13 +31,28 @@ function sqliteParams(params) {
|
|
|
30
31
|
throw new TypeError(`unsupported SQLite parameter type: ${typeof param}`);
|
|
31
32
|
});
|
|
32
33
|
}
|
|
34
|
+
/** Read `URBAN_LOG_LEVEL` without requiring `--allow-env`. `Deno.env.get` throws when env access is
|
|
35
|
+
* not granted, but an app that only logs shouldn't be forced to open env permission just to emit a
|
|
36
|
+
* line — so treat a permission (or any) error as an unset value and let logging fall back to the
|
|
37
|
+
* default `info` level. Logging must stay safe by default. */
|
|
38
|
+
function readLogLevelEnv() {
|
|
39
|
+
try {
|
|
40
|
+
return Deno.env.get("URBAN_LOG_LEVEL");
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
33
46
|
export function createDenoHost(opts = {}) {
|
|
34
47
|
const cwd = opts.cwd ?? Deno.cwd();
|
|
35
48
|
const abs = (p) => (isAbsolute(p) ? p : resolve(cwd, p));
|
|
36
49
|
const log = opts.log ??
|
|
37
50
|
((level, msg, fields) => {
|
|
38
|
-
|
|
39
|
-
|
|
51
|
+
if (!levelEnabled(level, parseLogLevel(readLogLevelEnv())))
|
|
52
|
+
return;
|
|
53
|
+
// console.* appends the newline; NDJSON stays one object per line. warn/error → stderr.
|
|
54
|
+
const line = formatLogRecord(level, msg, fields, Date.now());
|
|
55
|
+
(level === "warn" || level === "error" ? console.error : console.log)(line);
|
|
40
56
|
});
|
|
41
57
|
return {
|
|
42
58
|
runtime: "deno",
|
|
@@ -18,6 +18,22 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
18
18
|
import { DatabaseSync } from "node:sqlite";
|
|
19
19
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
20
20
|
import { resolveModulePath } from "../core/module-path.js";
|
|
21
|
+
import { formatLogLine, levelEnabled, parseLogLevel } from "../core/logger.js";
|
|
22
|
+
/**
|
|
23
|
+
* The default host log sink: NDJSON to stdout (`debug`/`info`) and stderr (`warn`/`error`), gated by
|
|
24
|
+
* a minimum level read from `URBAN_LOG_LEVEL` (default `info`) per record so a level change takes
|
|
25
|
+
* effect without a restart. Writing straight to the stream (not `console.*`) keeps one JSON object
|
|
26
|
+
* per line so downstream tooling can ingest them (the Nano host does not yet parse or surface these
|
|
27
|
+
* lines — see ADR 0061).
|
|
28
|
+
*/
|
|
29
|
+
function makeNdjsonLog(minLevel) {
|
|
30
|
+
return (level, msg, fields) => {
|
|
31
|
+
if (!levelEnabled(level, parseLogLevel(minLevel())))
|
|
32
|
+
return;
|
|
33
|
+
const line = formatLogLine(level, msg, fields, Date.now());
|
|
34
|
+
(level === "warn" || level === "error" ? process.stderr : process.stdout).write(line);
|
|
35
|
+
};
|
|
36
|
+
}
|
|
21
37
|
function sqliteParams(params) {
|
|
22
38
|
return params.map((param) => {
|
|
23
39
|
if (param === null ||
|
|
@@ -84,11 +100,7 @@ function ensureConnectorHooks() {
|
|
|
84
100
|
export function createNodeHost(opts = {}) {
|
|
85
101
|
const cwd = opts.cwd ?? process.cwd();
|
|
86
102
|
const abs = (p) => (isAbsolute(p) ? p : resolve(cwd, p));
|
|
87
|
-
const log = opts.log ??
|
|
88
|
-
((level, msg, fields) => {
|
|
89
|
-
const line = fields ? `${msg} ${JSON.stringify(fields)}` : msg;
|
|
90
|
-
(level === "error" ? console.error : level === "warn" ? console.warn : console.log)(`[urban] ${line}`);
|
|
91
|
-
});
|
|
103
|
+
const log = opts.log ?? makeNdjsonLog(() => process.env.URBAN_LOG_LEVEL);
|
|
92
104
|
return {
|
|
93
105
|
runtime: "node",
|
|
94
106
|
env: (name) => process.env[name],
|
|
@@ -21,6 +21,7 @@ export interface Mounted {
|
|
|
21
21
|
describe?(): Record<string, unknown>;
|
|
22
22
|
}
|
|
23
23
|
import type { DataLayer } from "./modules/datasource.ts";
|
|
24
|
+
import type { Logger } from "./logger.ts";
|
|
24
25
|
/**
|
|
25
26
|
* The surface injected into worker/trigger/surface handlers — the app's runtime API.
|
|
26
27
|
* This is how a handler reaches the datasource (typed accessors), the engine, and host
|
|
@@ -40,5 +41,13 @@ export interface AppApi {
|
|
|
40
41
|
*/
|
|
41
42
|
sdk?: EngineSdkClient;
|
|
42
43
|
env(name: string): string | undefined;
|
|
43
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Structured logger (see {@link Logger}). Callable for back-compat
|
|
46
|
+
* (`log("info", msg, fields)`), but prefer the level methods and bound context:
|
|
47
|
+
* `app.log.info(msg, fields)`, `app.log.child({ … })`. The runtime hands worker
|
|
48
|
+
* handlers a logger already bound to the job's `{ jobKey, jobType,
|
|
49
|
+
* processInstanceKey, elementId }`, and operation delegates one bound to the
|
|
50
|
+
* request's `{ method, path, operationId }`, so lines self-correlate.
|
|
51
|
+
*/
|
|
52
|
+
log: Logger;
|
|
44
53
|
}
|
|
@@ -97,8 +97,15 @@ export interface HostContext {
|
|
|
97
97
|
* a host that cannot provide one leaves provenance capture disabled (absent-safe).
|
|
98
98
|
*/
|
|
99
99
|
createAsyncStore?<T>(): AsyncStore<T>;
|
|
100
|
-
/**
|
|
101
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Structured log sink. Adds `debug` below `info`; the host adapter owns filtering + encoding.
|
|
102
|
+
*
|
|
103
|
+
* NOTE: widening this level union to include `"debug"` is a **breaking change** for any custom
|
|
104
|
+
* `HostContext` implementation whose `log` was typed with the narrower `"info" | "warn" | "error"`
|
|
105
|
+
* union — such a host must add a `"debug"` branch to keep satisfying the contract. See the
|
|
106
|
+
* "Structured logging" custom-host note in the runtime README.
|
|
107
|
+
*/
|
|
108
|
+
log(level: "debug" | "info" | "warn" | "error", msg: string, fields?: Record<string, unknown>): void;
|
|
102
109
|
}
|
|
103
110
|
/** A job handed to a worker handler, plus the ack/fail callbacks. `In` types the process
|
|
104
111
|
* variables carried on the job; it defaults to an open record when a handler doesn't
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** Severity of a log record, from most to least verbose. */
|
|
2
|
+
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
3
|
+
/** A structured context bag attached to a log record. Values should be JSON-serializable. */
|
|
4
|
+
export type LogFields = Record<string, unknown>;
|
|
5
|
+
/**
|
|
6
|
+
* The low-level output seam a {@link Logger} writes to. The host adapter supplies it and owns
|
|
7
|
+
* encoding (NDJSON), stream routing (warn/error → stderr, else stdout) and level filtering. `fields`
|
|
8
|
+
* is the merged bag (a logger's bound context overlaid with the per-call fields).
|
|
9
|
+
*/
|
|
10
|
+
export type LogSink = (level: LogLevel, msg: string, fields?: LogFields) => void;
|
|
11
|
+
/**
|
|
12
|
+
* The logger surface injected as `AppApi.log`. Callable for back-compat (`log(level, msg, fields)`),
|
|
13
|
+
* with per-level methods and `child()` for bound context. Prefer the methods:
|
|
14
|
+
*
|
|
15
|
+
* app.log.info("charge captured", { amount, currency });
|
|
16
|
+
* const jobLog = app.log.child({ orderId }); // every line below carries orderId
|
|
17
|
+
* jobLog.warn("retrying", { attempt });
|
|
18
|
+
*/
|
|
19
|
+
export interface Logger {
|
|
20
|
+
/** Back-compat callable form. Prefer `log.info(...)` etc. */
|
|
21
|
+
(level: LogLevel, msg: string, fields?: LogFields): void;
|
|
22
|
+
debug(msg: string, fields?: LogFields): void;
|
|
23
|
+
info(msg: string, fields?: LogFields): void;
|
|
24
|
+
warn(msg: string, fields?: LogFields): void;
|
|
25
|
+
error(msg: string, fields?: LogFields): void;
|
|
26
|
+
/** A logger that merges `bindings` into every record it emits (per-call fields win on key clash). */
|
|
27
|
+
child(bindings: LogFields): Logger;
|
|
28
|
+
}
|
|
29
|
+
/** Levels ordered by increasing severity; the index is the numeric threshold used for filtering. */
|
|
30
|
+
export declare const LOG_LEVELS: readonly LogLevel[];
|
|
31
|
+
/**
|
|
32
|
+
* Parse a min-level string (e.g. from `URBAN_LOG_LEVEL`) into a {@link LogLevel}, case-insensitively,
|
|
33
|
+
* falling back to `fallback` (default `"info"`) for an absent or unrecognized value.
|
|
34
|
+
*/
|
|
35
|
+
export declare function parseLogLevel(value: string | undefined, fallback?: LogLevel): LogLevel;
|
|
36
|
+
/** True when a record at `level` should be emitted given the configured `min` level. */
|
|
37
|
+
export declare function levelEnabled(level: LogLevel, min: LogLevel): boolean;
|
|
38
|
+
/** A single structured log record, as serialized to NDJSON. Reserved keys (`ts`, `level`, `msg`)
|
|
39
|
+
* lead; the flattened `fields` follow. A field named `ts`/`level`/`msg` cannot shadow a reserved
|
|
40
|
+
* key (the reserved value wins), so correlation on those keys stays reliable. */
|
|
41
|
+
export interface LogRecord {
|
|
42
|
+
/** Epoch milliseconds when the line was emitted. */
|
|
43
|
+
ts: number;
|
|
44
|
+
level: LogLevel;
|
|
45
|
+
msg: string;
|
|
46
|
+
[field: string]: unknown;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Encode one structured log record as a single-line JSON string (no trailing newline). The reserved
|
|
50
|
+
* `ts`/`level`/`msg` keys are written first and cannot be overwritten by a same-named field. Values
|
|
51
|
+
* that don't survive `JSON.stringify` (a `bigint`, a circular object) are coerced so a bad field
|
|
52
|
+
* never throws in the logging hot path or corrupts the stream. The whole build+encode path is
|
|
53
|
+
* guarded (not just `JSON.stringify`), so a field whose enumeration itself throws (e.g. a getter or
|
|
54
|
+
* Proxy trap) still yields a clean fallback line. The record is a null-prototype object so an
|
|
55
|
+
* untrusted field key like `__proto__` sets a plain own property rather than tripping the magic
|
|
56
|
+
* prototype setter.
|
|
57
|
+
*/
|
|
58
|
+
export declare function formatLogRecord(level: LogLevel, msg: string, fields: LogFields | undefined, now: number): string;
|
|
59
|
+
/** {@link formatLogRecord} plus a trailing newline — one NDJSON line for a raw stream write. */
|
|
60
|
+
export declare function formatLogLine(level: LogLevel, msg: string, fields: LogFields | undefined, now: number): string;
|
|
61
|
+
/**
|
|
62
|
+
* Build a {@link Logger} over `sink`, optionally pre-bound to `bindings`. The returned value is a
|
|
63
|
+
* callable object: it forwards `log(level, msg, fields)` to the sink and exposes `debug/info/warn/
|
|
64
|
+
* error` plus `child()`. `bindings` is normalized into a null-prototype object (log field keys are
|
|
65
|
+
* user-controlled, so an untrusted key like `__proto__` must not trip the magic prototype setter),
|
|
66
|
+
* and `child(more)` returns a new logger whose bindings are `bindings` merged with `more` into a
|
|
67
|
+
* fresh null-prototype object, so bound context accumulates safely down a call chain.
|
|
68
|
+
*/
|
|
69
|
+
export declare function createLogger(sink: LogSink, bindings?: LogFields): Logger;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Structured logging for Urban apps. A worker handler and an API route delegate both receive an
|
|
2
|
+
// `AppApi` whose `log` is a {@link Logger}: an ergonomic, level-tagged sink that emits structured
|
|
3
|
+
// records (the host serializes them as NDJSON — one JSON object per line — to stdout/stderr).
|
|
4
|
+
//
|
|
5
|
+
// The value proposition over a bare `console.log`:
|
|
6
|
+
// • Levels: `debug` / `info` / `warn` / `error`, each `(msg, fields?)`.
|
|
7
|
+
// • Structured fields: pass a `{ … }` bag of context alongside the message rather than
|
|
8
|
+
// string-concatenating; it lands as JSON keys a log processor can filter on.
|
|
9
|
+
// • Bound context via `child(bindings)`: the runtime hands a worker handler a logger already
|
|
10
|
+
// bound to `{ jobKey, jobType, processInstanceKey, elementId }`, and an operation delegate one
|
|
11
|
+
// bound to `{ method, path, operationId }` — so every line a handler emits carries the
|
|
12
|
+
// correlation context for free. Authors can bind more with their own `child()`.
|
|
13
|
+
//
|
|
14
|
+
// This module is pure (no `node:*`, no `Deno`, no `globalThis` output): it turns level+msg+fields
|
|
15
|
+
// into a call on an injected {@link LogSink}. The host adapter (node.ts / deno.ts) owns the sink —
|
|
16
|
+
// NDJSON encoding, the stdout/stderr split, and level filtering — so the same Logger works in every
|
|
17
|
+
// runtime and is trivially unit-testable with a fake sink. Kept `node:*`-free per the ADR 0052
|
|
18
|
+
// core-purity invariant (core/purity.test.ts).
|
|
19
|
+
/** Levels ordered by increasing severity; the index is the numeric threshold used for filtering. */
|
|
20
|
+
export const LOG_LEVELS = ["debug", "info", "warn", "error"];
|
|
21
|
+
function isLogLevel(v) {
|
|
22
|
+
return v === "debug" || v === "info" || v === "warn" || v === "error";
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Parse a min-level string (e.g. from `URBAN_LOG_LEVEL`) into a {@link LogLevel}, case-insensitively,
|
|
26
|
+
* falling back to `fallback` (default `"info"`) for an absent or unrecognized value.
|
|
27
|
+
*/
|
|
28
|
+
export function parseLogLevel(value, fallback = "info") {
|
|
29
|
+
const v = value?.trim().toLowerCase();
|
|
30
|
+
return isLogLevel(v) ? v : fallback;
|
|
31
|
+
}
|
|
32
|
+
/** True when a record at `level` should be emitted given the configured `min` level. */
|
|
33
|
+
export function levelEnabled(level, min) {
|
|
34
|
+
return LOG_LEVELS.indexOf(level) >= LOG_LEVELS.indexOf(min);
|
|
35
|
+
}
|
|
36
|
+
/** Merge bound context with per-call fields (per-call wins), omitting the `fields` key entirely when
|
|
37
|
+
* the result is empty so a context-free line stays `{ level, msg }` clean. Always merges into a
|
|
38
|
+
* null-prototype object — even on the no-bindings fast path — and returns `undefined` for an empty
|
|
39
|
+
* bag: log field keys are user-controlled, so an untrusted key like `__proto__` must set a plain own
|
|
40
|
+
* property rather than trip the magic prototype setter a normal `{}` inherits from `Object.prototype`
|
|
41
|
+
* (same treatment as untrusted-key dictionaries elsewhere in this repo). Normalizing here — not just
|
|
42
|
+
* in {@link formatLogRecord} — keeps the merged bag safe for any custom {@link LogSink} that later
|
|
43
|
+
* spreads it into a plain object. */
|
|
44
|
+
function mergeFields(bindings, fields) {
|
|
45
|
+
const boundKeys = Object.keys(bindings);
|
|
46
|
+
const callKeys = fields ? Object.keys(fields) : [];
|
|
47
|
+
if (boundKeys.length === 0 && callKeys.length === 0)
|
|
48
|
+
return undefined;
|
|
49
|
+
if (boundKeys.length === 0)
|
|
50
|
+
return Object.assign(Object.create(null), fields);
|
|
51
|
+
if (callKeys.length === 0)
|
|
52
|
+
return Object.assign(Object.create(null), bindings);
|
|
53
|
+
return Object.assign(Object.create(null), bindings, fields);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Encode one structured log record as a single-line JSON string (no trailing newline). The reserved
|
|
57
|
+
* `ts`/`level`/`msg` keys are written first and cannot be overwritten by a same-named field. Values
|
|
58
|
+
* that don't survive `JSON.stringify` (a `bigint`, a circular object) are coerced so a bad field
|
|
59
|
+
* never throws in the logging hot path or corrupts the stream. The whole build+encode path is
|
|
60
|
+
* guarded (not just `JSON.stringify`), so a field whose enumeration itself throws (e.g. a getter or
|
|
61
|
+
* Proxy trap) still yields a clean fallback line. The record is a null-prototype object so an
|
|
62
|
+
* untrusted field key like `__proto__` sets a plain own property rather than tripping the magic
|
|
63
|
+
* prototype setter.
|
|
64
|
+
*/
|
|
65
|
+
export function formatLogRecord(level, msg, fields, now) {
|
|
66
|
+
try {
|
|
67
|
+
const record = Object.create(null);
|
|
68
|
+
record.ts = now;
|
|
69
|
+
record.level = level;
|
|
70
|
+
record.msg = msg;
|
|
71
|
+
if (fields) {
|
|
72
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
73
|
+
if (k === "ts" || k === "level" || k === "msg")
|
|
74
|
+
continue;
|
|
75
|
+
record[k] = v;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return JSON.stringify(record, (_key, value) => (typeof value === "bigint" ? value.toString() : value));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// A field that can't be built or serialized (circular ref, throwing getter) must not break logging.
|
|
82
|
+
return JSON.stringify({ ts: now, level, msg, fieldsError: "unserializable log fields" });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** {@link formatLogRecord} plus a trailing newline — one NDJSON line for a raw stream write. */
|
|
86
|
+
export function formatLogLine(level, msg, fields, now) {
|
|
87
|
+
return `${formatLogRecord(level, msg, fields, now)}\n`;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Build a {@link Logger} over `sink`, optionally pre-bound to `bindings`. The returned value is a
|
|
91
|
+
* callable object: it forwards `log(level, msg, fields)` to the sink and exposes `debug/info/warn/
|
|
92
|
+
* error` plus `child()`. `bindings` is normalized into a null-prototype object (log field keys are
|
|
93
|
+
* user-controlled, so an untrusted key like `__proto__` must not trip the magic prototype setter),
|
|
94
|
+
* and `child(more)` returns a new logger whose bindings are `bindings` merged with `more` into a
|
|
95
|
+
* fresh null-prototype object, so bound context accumulates safely down a call chain.
|
|
96
|
+
*/
|
|
97
|
+
export function createLogger(sink, bindings = {}) {
|
|
98
|
+
const bound = Object.assign(Object.create(null), bindings);
|
|
99
|
+
const emit = (level, msg, fields) => {
|
|
100
|
+
try {
|
|
101
|
+
sink(level, msg, mergeFields(bound, fields));
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// A log call must never throw into its caller. A hostile `fields` object (e.g. a Proxy whose
|
|
105
|
+
// `ownKeys` trap throws, defeating mergeFields before the sink's own formatLogRecord guard)
|
|
106
|
+
// or a throwing sink degrades to a best-effort, field-free line rather than break the handler.
|
|
107
|
+
try {
|
|
108
|
+
sink(level, msg, Object.assign(Object.create(null), { fieldsError: "unmergeable log fields" }));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// The sink itself is broken — nothing more we can safely do without re-throwing.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
return Object.assign(emit, {
|
|
116
|
+
debug: (msg, fields) => emit("debug", msg, fields),
|
|
117
|
+
info: (msg, fields) => emit("info", msg, fields),
|
|
118
|
+
warn: (msg, fields) => emit("warn", msg, fields),
|
|
119
|
+
error: (msg, fields) => emit("error", msg, fields),
|
|
120
|
+
child: (more) => {
|
|
121
|
+
// child() must not throw even when `more` is hostile (e.g. a Proxy whose key enumeration
|
|
122
|
+
// throws), mirroring emit()'s "logging never throws" guarantee: degrade to a child bound to
|
|
123
|
+
// just the parent context rather than let the merge bubble into the worker/route handler.
|
|
124
|
+
let childBindings = bound;
|
|
125
|
+
try {
|
|
126
|
+
childBindings = Object.assign(Object.create(null), bound, more);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// `more` could not be enumerated/merged — keep the parent bindings, drop the bad additions.
|
|
130
|
+
}
|
|
131
|
+
return createLogger(sink, childBindings);
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
}
|
|
@@ -507,8 +507,14 @@ export function mountApi(ctx, app) {
|
|
|
507
507
|
// required), so pass `undefined` to those handlers rather than leaking a parsed value through
|
|
508
508
|
// a body-less type.
|
|
509
509
|
const handlerBody = op.requestBodySchema !== undefined || op.requestBodyRequired ? body : undefined;
|
|
510
|
+
// Hand the delegate an AppApi whose `log` is bound to this request's correlation context, so its
|
|
511
|
+
// lines self-tag with `{ method, path, operationId }` without the author threading it.
|
|
512
|
+
const requestApp = {
|
|
513
|
+
...app,
|
|
514
|
+
log: app.log.child({ method, path: op.path, operationId: op.operationId }),
|
|
515
|
+
};
|
|
510
516
|
try {
|
|
511
|
-
const result = await handler({ req, params: coercedParams, query: coercedQuery, body: handlerBody },
|
|
517
|
+
const result = await handler({ req, params: coercedParams, query: coercedQuery, body: handlerBody }, requestApp);
|
|
512
518
|
if (!result)
|
|
513
519
|
return { status: 204 };
|
|
514
520
|
const status = result.status ?? 200;
|
|
@@ -560,7 +560,36 @@ function applyTheme(msg) {
|
|
|
560
560
|
}
|
|
561
561
|
}
|
|
562
562
|
}
|
|
563
|
-
|
|
563
|
+
// True when this app runs inside the Nano console's same-origin iframe (vs a
|
|
564
|
+
// standalone CLI run). Gates the theme bridge below AND in-host link navigation:
|
|
565
|
+
// embedded, a processExplorer link routes the console in place; standalone, the
|
|
566
|
+
// link keeps its native new-window behavior. The same-origin probe matters: a
|
|
567
|
+
// cross-origin framer is also window.parent, but postMessage to our own origin
|
|
568
|
+
// would never reach it and hostNavigate would still preventDefault the link —
|
|
569
|
+
// so a cross-origin embed must fall through to the native _blank anchor. Reading
|
|
570
|
+
// parent.location.origin throws under the same-origin policy for a cross-origin
|
|
571
|
+
// parent, so the try/catch is the origin gate.
|
|
572
|
+
const NANO_EMBEDDED = (() => {
|
|
573
|
+
if (!window.parent || window.parent === window) return false;
|
|
574
|
+
try {
|
|
575
|
+
return window.parent.location.origin === window.location.origin;
|
|
576
|
+
} catch {
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
})();
|
|
580
|
+
|
|
581
|
+
// Ask the framing console to navigate in-host. Returns true when a message was
|
|
582
|
+
// posted (embedded) so the caller can suppress the native anchor; false
|
|
583
|
+
// standalone so it falls through to the new-window <a>. The payload is
|
|
584
|
+
// structured (target + params), never a raw href — the console constructs its
|
|
585
|
+
// own route, so app/row data can't smuggle a path or scheme across the boundary.
|
|
586
|
+
function hostNavigate(target, params) {
|
|
587
|
+
if (!NANO_EMBEDDED) return false;
|
|
588
|
+
window.parent.postMessage({ type: "nano-navigate", target: target, params: params }, window.location.origin);
|
|
589
|
+
return true;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (NANO_EMBEDDED) {
|
|
564
593
|
window.addEventListener("message", (ev) => {
|
|
565
594
|
// Same-origin proxy (posture A): only trust a message from the framing
|
|
566
595
|
// parent AND from our own origin. ev.source alone is insufficient — a
|
|
@@ -675,8 +704,12 @@ function renderText(node) {
|
|
|
675
704
|
// non-empty key (after trimming surrounding whitespace) produces a link.
|
|
676
705
|
// Unknown link kinds fall back to plain text so an unrecognised schema
|
|
677
706
|
// can't render a broken anchor.
|
|
678
|
-
//
|
|
679
|
-
//
|
|
707
|
+
// The external linkField anchor always opens in a new tab (rel=noopener
|
|
708
|
+
// noreferrer). The processExplorer anchor does too when the app runs
|
|
709
|
+
// standalone, but when embedded in the console a plain click instead routes the
|
|
710
|
+
// host's own explorer view in place via the nano-navigate postMessage bridge
|
|
711
|
+
// (a modified/middle click keeps the new-tab behavior). Shared by the top-level
|
|
712
|
+
// grid and child grids.
|
|
680
713
|
function gridCell(col, row) {
|
|
681
714
|
const text = row[col.field] == null ? "" : String(row[col.field]);
|
|
682
715
|
if (col.linkField) {
|
|
@@ -697,7 +730,22 @@ function gridCell(col, row) {
|
|
|
697
730
|
return el(
|
|
698
731
|
"td",
|
|
699
732
|
{},
|
|
700
|
-
el("a", {
|
|
733
|
+
el("a", {
|
|
734
|
+
class: "pc-link",
|
|
735
|
+
href,
|
|
736
|
+
target: "_blank",
|
|
737
|
+
rel: "noopener noreferrer",
|
|
738
|
+
// Embedded in the console a plain primary click routes the host's
|
|
739
|
+
// explorer in place (via the nano-navigate bridge) instead of opening
|
|
740
|
+
// a new window. A non-primary button (middle/right = new-tab/context
|
|
741
|
+
// intent), a modified click (⌘/ctrl/shift/alt = new-tab intent) and
|
|
742
|
+
// any standalone run fall through to the native anchor above.
|
|
743
|
+
onclick: (ev) => {
|
|
744
|
+
if (ev.button !== 0) return;
|
|
745
|
+
if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
|
|
746
|
+
if (hostNavigate("processExplorer", { instance: keyStr })) ev.preventDefault();
|
|
747
|
+
},
|
|
748
|
+
}, text),
|
|
701
749
|
);
|
|
702
750
|
}
|
|
703
751
|
}
|
|
@@ -33,6 +33,21 @@ function hasDecisionCapableSdk(sdk) {
|
|
|
33
33
|
function isAppJobHandler(value) {
|
|
34
34
|
return typeof value === "function";
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Hand a handler an {@link AppApi} whose `log` is bound to the job's correlation context, so every
|
|
38
|
+
* line the handler emits self-tags with `{ jobKey, jobType, processInstanceKey, elementId }` without
|
|
39
|
+
* the author threading it. Undefined fields (e.g. a job dispatched without an instance) are omitted
|
|
40
|
+
* so the bound context stays clean. The rest of the API is shared by reference — only `log` is
|
|
41
|
+
* per-job.
|
|
42
|
+
*/
|
|
43
|
+
function withJobLog(app, job) {
|
|
44
|
+
const bindings = { jobKey: job.jobKey, jobType: job.jobType };
|
|
45
|
+
if (job.processInstanceKey !== undefined)
|
|
46
|
+
bindings.processInstanceKey = job.processInstanceKey;
|
|
47
|
+
if (job.elementId !== undefined)
|
|
48
|
+
bindings.elementId = job.elementId;
|
|
49
|
+
return { ...app, log: app.log.child(bindings) };
|
|
50
|
+
}
|
|
36
51
|
/**
|
|
37
52
|
* Resolve a handler for `jobType` from a loaded module, in priority order:
|
|
38
53
|
* 1. `handlers[jobType]` (a map keyed by job type — the multi-type module case)
|
|
@@ -114,7 +129,7 @@ export async function mountWorkers(ctx, app) {
|
|
|
114
129
|
throw new Error(`worker "${jobType}": ${decl.handler} exports no handler for it ` +
|
|
115
130
|
`(expected handlers["${jobType}"], a named export, or a default function)`);
|
|
116
131
|
}
|
|
117
|
-
const wrapped = (job) => runInJobContext({ instanceKey: job.processInstanceKey, elementId: job.elementId, jobType }, () => handler(job, app));
|
|
132
|
+
const wrapped = (job) => runInJobContext({ instanceKey: job.processInstanceKey, elementId: job.elementId, jobType }, () => handler(job, withJobLog(app, job)));
|
|
118
133
|
const sub = await ctx.engine.registerWorker(jobType, wrapped, {
|
|
119
134
|
workerName: `${ctx.manifest.id}:${jobType}`,
|
|
120
135
|
});
|
|
@@ -2,6 +2,7 @@ import type { EngineClient, HostContext } from "./host.ts";
|
|
|
2
2
|
import { type AppManifest } from "./manifest.ts";
|
|
3
3
|
import type { TemplateSource } from "./modules/templates.ts";
|
|
4
4
|
import { DataLayer } from "./modules/datasource.ts";
|
|
5
|
+
import { type Logger } from "./logger.ts";
|
|
5
6
|
import type { SchedulerDeps } from "./modules/scheduler.ts";
|
|
6
7
|
import { type SecurityPolicy } from "./modules/security.ts";
|
|
7
8
|
/** Resolve the HTTP port: explicit option, else $PORT, else 8090. Throws a clear
|
|
@@ -56,6 +57,10 @@ export interface UrbanApp {
|
|
|
56
57
|
stop(): Promise<void>;
|
|
57
58
|
/** A structured snapshot of what is mounted. */
|
|
58
59
|
inspect(): Record<string, unknown>;
|
|
60
|
+
/** An app-level structured {@link Logger} for the entrypoint (`main.ts`). It carries no
|
|
61
|
+
* per-request/per-job correlation (worker handlers and route delegates get a child logger bound
|
|
62
|
+
* to their job/request context via `AppApi.log`); use it for boot/shutdown lifecycle lines. */
|
|
63
|
+
readonly log: Logger;
|
|
59
64
|
/** The provisioned data layer (available after start when `data` is mounted). */
|
|
60
65
|
readonly data: DataLayer | undefined;
|
|
61
66
|
readonly security: SecurityPolicy | undefined;
|
|
@@ -8,6 +8,7 @@ import { makeRouter } from "./router.js";
|
|
|
8
8
|
import { deployModels } from "./modules/deploy.js";
|
|
9
9
|
import { provisionData, DataLayer } from "./modules/datasource.js";
|
|
10
10
|
import { installExecStore } from "./execContext.js";
|
|
11
|
+
import { createLogger } from "./logger.js";
|
|
11
12
|
import { mountWorkers } from "./modules/workers.js";
|
|
12
13
|
import { mountConnectors } from "./modules/connectors.js";
|
|
13
14
|
import { mountSurfaces } from "./modules/surfaces.js";
|
|
@@ -48,6 +49,10 @@ export async function createUrbanApp(opts) {
|
|
|
48
49
|
instanceTracking: opts.mount?.instanceTracking ?? true,
|
|
49
50
|
};
|
|
50
51
|
const ctx = { manifest, host, engine, root, templates: opts.templates };
|
|
52
|
+
// One app-level structured logger over the host sink, shared by the entrypoint (exposed as
|
|
53
|
+
// `app.log`) and the per-invocation `AppApi.log` (workers/route delegates `child()` it with their
|
|
54
|
+
// correlation context).
|
|
55
|
+
const appLog = createLogger((l, m, f) => host.log(l, m, f));
|
|
51
56
|
// Install the ambient job-execution store once per process (idempotent) so worker dispatch can
|
|
52
57
|
// stamp write-provenance onto DataLayer inserts. Absent-safe: a host without `createAsyncStore`
|
|
53
58
|
// leaves provenance capture disabled.
|
|
@@ -99,6 +104,7 @@ export async function createUrbanApp(opts) {
|
|
|
99
104
|
const app = {
|
|
100
105
|
manifest,
|
|
101
106
|
root,
|
|
107
|
+
log: appLog,
|
|
102
108
|
get data() {
|
|
103
109
|
return data;
|
|
104
110
|
},
|
|
@@ -124,7 +130,7 @@ export async function createUrbanApp(opts) {
|
|
|
124
130
|
engine,
|
|
125
131
|
sdk: engineSdk(engine),
|
|
126
132
|
env: (n) => host.env(n),
|
|
127
|
-
log:
|
|
133
|
+
log: appLog,
|
|
128
134
|
};
|
|
129
135
|
if (flags.workers) {
|
|
130
136
|
const w = await mountWorkers(ctx, api);
|
|
@@ -92,7 +92,7 @@ export interface NanoSdkClient {
|
|
|
92
92
|
stopAllWorkers?(): void | Promise<void>;
|
|
93
93
|
close?(): void | Promise<void>;
|
|
94
94
|
}
|
|
95
|
-
type Log = (level: "info" | "warn" | "error", msg: string, fields?: Record<string, unknown>) => void;
|
|
95
|
+
type Log = (level: "debug" | "info" | "warn" | "error", msg: string, fields?: Record<string, unknown>) => void;
|
|
96
96
|
/**
|
|
97
97
|
* An `EngineClient` backed entirely by a `@nanobpm/nano-sdk` client. Deploy, create,
|
|
98
98
|
* message, and user-task calls map one-to-one onto the SDK's client methods; workers
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export { collectManifestIssues, ManifestValidationError, validateManifest, } fro
|
|
|
6
6
|
export type { ValidationIssue } from "./core/validate.ts";
|
|
7
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
|
+
export type { Logger, LogLevel, LogFields } from "./core/logger.ts";
|
|
9
10
|
export { BpmnError, isBpmnError } from "./core/host.ts";
|
|
10
11
|
export { adaptConnectorHandler, mountConnectors, resolveInstalledConnectors, } from "./core/modules/connectors.ts";
|
|
11
12
|
export type { ConnectorsHandle } from "./core/modules/connectors.ts";
|
|
@@ -15,6 +15,17 @@ export interface Deriver<I> {
|
|
|
15
15
|
/** Where all derived output lands — the console's `nano-generated/` dir, so the toolkit is a
|
|
16
16
|
* drop-in for the IDE's own codegen (ADR 0053). One drift domain, gitignored, never committed. */
|
|
17
17
|
export declare const GENERATED_DIR = "nano-generated";
|
|
18
|
+
/**
|
|
19
|
+
* Files that live in `GENERATED_DIR` but are NOT produced by `urban gen`. These are materialized by
|
|
20
|
+
* a different writer — the runtime SDK shims and the `urban data` / console-host dataops path, which
|
|
21
|
+
* derives the typed wrappers from the *live* DB (something `urban gen` can't do without a running
|
|
22
|
+
* datasource). `urban gen`'s stale-file sweep must own only what it emits, so it protects these by
|
|
23
|
+
* name: it never deletes them even though it didn't (re)write them this run. Basenames, relative to
|
|
24
|
+
* `GENERATED_DIR`. Keep in sync with the emitters' filename constants (DOMAIN_BINDINGS,
|
|
25
|
+
* WORKER_BINDINGS_TS, MESSAGE_BINDINGS_TS, DOMAIN_MODEL_JSON) + the `./data-sdk.ts`/`./worker-sdk.ts`
|
|
26
|
+
* shim imports the generated wrappers reference.
|
|
27
|
+
*/
|
|
28
|
+
export declare const RUNTIME_MATERIALIZED_ARTIFACTS: readonly string[];
|
|
18
29
|
/** Stable ordering so artifact lists compare deterministically. */
|
|
19
30
|
export declare function sortArtifacts(a: DerivedArtifact[]): DerivedArtifact[];
|
|
20
31
|
/** True when `p` is an absolute path — POSIX root (`/`), a drive-letter root (`C:\` or `C:/`), or a
|
package/dist/toolkit/artifact.js
CHANGED
|
@@ -5,6 +5,24 @@
|
|
|
5
5
|
/** Where all derived output lands — the console's `nano-generated/` dir, so the toolkit is a
|
|
6
6
|
* drop-in for the IDE's own codegen (ADR 0053). One drift domain, gitignored, never committed. */
|
|
7
7
|
export const GENERATED_DIR = "nano-generated";
|
|
8
|
+
/**
|
|
9
|
+
* Files that live in `GENERATED_DIR` but are NOT produced by `urban gen`. These are materialized by
|
|
10
|
+
* a different writer — the runtime SDK shims and the `urban data` / console-host dataops path, which
|
|
11
|
+
* derives the typed wrappers from the *live* DB (something `urban gen` can't do without a running
|
|
12
|
+
* datasource). `urban gen`'s stale-file sweep must own only what it emits, so it protects these by
|
|
13
|
+
* name: it never deletes them even though it didn't (re)write them this run. Basenames, relative to
|
|
14
|
+
* `GENERATED_DIR`. Keep in sync with the emitters' filename constants (DOMAIN_BINDINGS,
|
|
15
|
+
* WORKER_BINDINGS_TS, MESSAGE_BINDINGS_TS, DOMAIN_MODEL_JSON) + the `./data-sdk.ts`/`./worker-sdk.ts`
|
|
16
|
+
* shim imports the generated wrappers reference.
|
|
17
|
+
*/
|
|
18
|
+
export const RUNTIME_MATERIALIZED_ARTIFACTS = [
|
|
19
|
+
"domain.ts",
|
|
20
|
+
"workers.ts",
|
|
21
|
+
"messages.ts",
|
|
22
|
+
"domain.json",
|
|
23
|
+
"data-sdk.ts",
|
|
24
|
+
"worker-sdk.ts",
|
|
25
|
+
];
|
|
8
26
|
/** Stable ordering so artifact lists compare deterministically. */
|
|
9
27
|
export function sortArtifacts(a) {
|
|
10
28
|
return [...a].sort((x, y) => (x.path < y.path ? -1 : x.path > y.path ? 1 : 0));
|
package/dist/toolkit/gen.d.ts
CHANGED
|
@@ -36,6 +36,8 @@ export interface GenResult {
|
|
|
36
36
|
artifacts: DerivedArtifact[];
|
|
37
37
|
/** Paths that differ from disk (only populated by `check`). */
|
|
38
38
|
drift: string[];
|
|
39
|
+
/** App-relative paths of stale files removed from `nano-generated/` (only populated in write mode). */
|
|
40
|
+
swept: string[];
|
|
39
41
|
/** True if any code-first workflow failed to import/derive. */
|
|
40
42
|
incomplete: boolean;
|
|
41
43
|
/** Per-file model-derivation errors (empty on success). */
|