@nanobpm/urban 0.39.0 → 0.40.1
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/openapi/spec.d.ts +16 -1
- package/dist/openapi/spec.js +53 -12
- 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 +30 -16
- 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/derivers/api.js +33 -7
- 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/openapi/spec.test.ts +47 -0
- package/src/openapi/spec.ts +62 -13
- 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 +119 -2
- package/src/runtime/core/modules/api.ts +33 -15
- 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/derivers/api.test.ts +20 -0
- package/src/toolkit/derivers/api.ts +35 -6
- package/src/toolkit/scaffold/operations.ts +3 -1
- package/src/toolkit/scaffold/workers.ts +2 -0
- package/src/worker.ts +1 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
createLogger,
|
|
5
|
+
formatLogLine,
|
|
6
|
+
formatLogRecord,
|
|
7
|
+
levelEnabled,
|
|
8
|
+
LOG_LEVELS,
|
|
9
|
+
parseLogLevel,
|
|
10
|
+
type LogFields,
|
|
11
|
+
type LogLevel,
|
|
12
|
+
} from "./logger.ts";
|
|
13
|
+
|
|
14
|
+
type Rec = { level: LogLevel; msg: string; fields?: LogFields };
|
|
15
|
+
|
|
16
|
+
function recorder() {
|
|
17
|
+
const records: Rec[] = [];
|
|
18
|
+
const sink = (level: LogLevel, msg: string, fields?: LogFields) => {
|
|
19
|
+
records.push({ level, msg, fields });
|
|
20
|
+
};
|
|
21
|
+
return { records, sink };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test("per-level methods forward the level and message to the sink", () => {
|
|
25
|
+
const { records, sink } = recorder();
|
|
26
|
+
const log = createLogger(sink);
|
|
27
|
+
log.debug("d");
|
|
28
|
+
log.info("i");
|
|
29
|
+
log.warn("w");
|
|
30
|
+
log.error("e");
|
|
31
|
+
assert.deepEqual(
|
|
32
|
+
records.map((r) => [r.level, r.msg]),
|
|
33
|
+
[
|
|
34
|
+
["debug", "d"],
|
|
35
|
+
["info", "i"],
|
|
36
|
+
["warn", "w"],
|
|
37
|
+
["error", "e"],
|
|
38
|
+
],
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("the callable form still works for back-compat", () => {
|
|
43
|
+
const { records, sink } = recorder();
|
|
44
|
+
const log = createLogger(sink);
|
|
45
|
+
log("info", "hello", { a: 1 });
|
|
46
|
+
assert.equal(records.length, 1);
|
|
47
|
+
assert.equal(records[0].level, "info");
|
|
48
|
+
assert.equal(records[0].msg, "hello");
|
|
49
|
+
assert.deepEqual({ ...records[0].fields }, { a: 1 });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a context-free line carries no fields object", () => {
|
|
53
|
+
const { records, sink } = recorder();
|
|
54
|
+
createLogger(sink).info("plain");
|
|
55
|
+
assert.deepEqual(records, [{ level: "info", msg: "plain", fields: undefined }]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("child() bindings are merged into every record", () => {
|
|
59
|
+
const { records, sink } = recorder();
|
|
60
|
+
const log = createLogger(sink).child({ jobType: "charge", jobKey: "7" });
|
|
61
|
+
log.info("started");
|
|
62
|
+
log.warn("retrying", { attempt: 2 });
|
|
63
|
+
assert.deepEqual({ ...records[0].fields }, { jobType: "charge", jobKey: "7" });
|
|
64
|
+
assert.deepEqual({ ...records[1].fields }, { jobType: "charge", jobKey: "7", attempt: 2 });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("per-call fields win over bound context on key clash", () => {
|
|
68
|
+
const { records, sink } = recorder();
|
|
69
|
+
createLogger(sink).child({ attempt: 1 }).info("x", { attempt: 9 });
|
|
70
|
+
assert.deepEqual({ ...records[0].fields }, { attempt: 9 });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("child() accumulates bindings down a chain and does not mutate the parent", () => {
|
|
74
|
+
const { records, sink } = recorder();
|
|
75
|
+
const base = createLogger(sink).child({ app: "demo" });
|
|
76
|
+
const child = base.child({ jobKey: "1" });
|
|
77
|
+
base.info("base");
|
|
78
|
+
child.info("child");
|
|
79
|
+
assert.deepEqual({ ...records[0].fields }, { app: "demo" });
|
|
80
|
+
assert.deepEqual({ ...records[1].fields }, { app: "demo", jobKey: "1" });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("parseLogLevel is case-insensitive and falls back for unknown/absent input", () => {
|
|
84
|
+
assert.equal(parseLogLevel("DEBUG"), "debug");
|
|
85
|
+
assert.equal(parseLogLevel(" Warn "), "warn");
|
|
86
|
+
assert.equal(parseLogLevel(undefined), "info");
|
|
87
|
+
assert.equal(parseLogLevel("verbose"), "info");
|
|
88
|
+
assert.equal(parseLogLevel("verbose", "debug"), "debug");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("levelEnabled gates records below the configured minimum", () => {
|
|
92
|
+
assert.equal(levelEnabled("debug", "info"), false);
|
|
93
|
+
assert.equal(levelEnabled("info", "info"), true);
|
|
94
|
+
assert.equal(levelEnabled("error", "warn"), true);
|
|
95
|
+
assert.equal(levelEnabled("warn", "error"), false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("LOG_LEVELS is ordered from most to least verbose", () => {
|
|
99
|
+
assert.deepEqual([...LOG_LEVELS], ["debug", "info", "warn", "error"]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("formatLogRecord emits ts/level/msg plus flattened fields", () => {
|
|
103
|
+
const rec = JSON.parse(formatLogRecord("info", "charged", { amount: 10, currency: "EUR" }, 1234));
|
|
104
|
+
assert.deepEqual(rec, { ts: 1234, level: "info", msg: "charged", amount: 10, currency: "EUR" });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("formatLogLine appends exactly one trailing newline", () => {
|
|
108
|
+
const line = formatLogLine("warn", "x", undefined, 1);
|
|
109
|
+
assert.ok(line.endsWith("\n"));
|
|
110
|
+
assert.equal(line.indexOf("\n"), line.length - 1);
|
|
111
|
+
assert.deepEqual(JSON.parse(line), { ts: 1, level: "warn", msg: "x" });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("formatLogRecord reserves ts/level/msg — a same-named field cannot shadow them", () => {
|
|
115
|
+
const rec = JSON.parse(formatLogRecord("error", "real", { ts: 0, level: "info", msg: "fake", ok: true }, 999));
|
|
116
|
+
assert.deepEqual(rec, { ts: 999, level: "error", msg: "real", ok: true });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("formatLogRecord serializes a bigint field as its string form", () => {
|
|
120
|
+
const rec = JSON.parse(formatLogRecord("info", "job", { jobKey: 9007199254740993n }, 5));
|
|
121
|
+
assert.equal(rec.jobKey, "9007199254740993");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("formatLogRecord never throws on an unserializable (circular) field", () => {
|
|
125
|
+
const circular: Record<string, unknown> = {};
|
|
126
|
+
circular.self = circular;
|
|
127
|
+
const rec = JSON.parse(formatLogRecord("info", "loop", { circular }, 7));
|
|
128
|
+
assert.deepEqual(rec, { ts: 7, level: "info", msg: "loop", fieldsError: "unserializable log fields" });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("formatLogRecord never throws when enumerating a field throws (getter/proxy trap)", () => {
|
|
132
|
+
const hostile = new Proxy(
|
|
133
|
+
{},
|
|
134
|
+
{
|
|
135
|
+
ownKeys() {
|
|
136
|
+
throw new Error("trap");
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
const rec = JSON.parse(formatLogRecord("warn", "boom", hostile, 8));
|
|
141
|
+
assert.deepEqual(rec, { ts: 8, level: "warn", msg: "boom", fieldsError: "unserializable log fields" });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("formatLogRecord writes an untrusted __proto__ field key as a plain own property", () => {
|
|
145
|
+
// JSON.parse builds a genuine own "__proto__" property (unlike the `{ __proto__: … }` literal,
|
|
146
|
+
// which sets the prototype) — the untrusted-key case the null-prototype record must survive.
|
|
147
|
+
const fields: LogFields = JSON.parse('{"__proto__":{"polluted":true}}');
|
|
148
|
+
const rec = JSON.parse(formatLogRecord("info", "hardened", fields, 3));
|
|
149
|
+
assert.equal(rec.ts, 3);
|
|
150
|
+
assert.equal(rec.msg, "hardened");
|
|
151
|
+
assert.deepEqual(Object.getOwnPropertyDescriptor(rec, "__proto__")?.value, { polluted: true });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("child() with an untrusted __proto__ binding key does not pollute Object.prototype", () => {
|
|
155
|
+
const { records, sink } = recorder();
|
|
156
|
+
const bindings: LogFields = JSON.parse('{"__proto__":{"polluted":true}}');
|
|
157
|
+
createLogger(sink).child(bindings).info("x");
|
|
158
|
+
const fields = records[0].fields ?? {};
|
|
159
|
+
assert.deepEqual(Object.getOwnPropertyDescriptor(fields, "__proto__")?.value, { polluted: true });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("an untrusted __proto__ per-call field with no bound context still merges into a null-proto bag", () => {
|
|
163
|
+
// The no-bindings merge path must normalize too, so a custom sink that later spreads the bag can't
|
|
164
|
+
// be tripped by the magic prototype setter. JSON.parse builds a genuine own "__proto__" key.
|
|
165
|
+
const { records, sink } = recorder();
|
|
166
|
+
const fields: LogFields = JSON.parse('{"__proto__":{"polluted":true}}');
|
|
167
|
+
createLogger(sink).info("x", fields);
|
|
168
|
+
const merged = records[0].fields;
|
|
169
|
+
assert.ok(merged !== undefined);
|
|
170
|
+
assert.equal(Object.getPrototypeOf(merged), null);
|
|
171
|
+
assert.deepEqual(Object.getOwnPropertyDescriptor(merged, "__proto__")?.value, { polluted: true });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("a log call never throws when a hostile fields object defeats the merge", () => {
|
|
175
|
+
// A Proxy whose ownKeys trap throws breaks mergeFields before it can reach the sink; the emit guard
|
|
176
|
+
// must degrade to a field-free error line rather than propagate into the caller.
|
|
177
|
+
const { records, sink } = recorder();
|
|
178
|
+
const hostile = new Proxy(
|
|
179
|
+
{},
|
|
180
|
+
{
|
|
181
|
+
ownKeys() {
|
|
182
|
+
throw new Error("trap");
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
);
|
|
186
|
+
assert.doesNotThrow(() => createLogger(sink).info("boom", hostile));
|
|
187
|
+
assert.equal(records.length, 1);
|
|
188
|
+
assert.equal(records[0].msg, "boom");
|
|
189
|
+
assert.deepEqual({ ...records[0].fields }, { fieldsError: "unmergeable log fields" });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("a log call never throws when the sink itself throws", () => {
|
|
193
|
+
const throwingSink = () => {
|
|
194
|
+
throw new Error("sink down");
|
|
195
|
+
};
|
|
196
|
+
assert.doesNotThrow(() => createLogger(throwingSink).info("x", { a: 1 }));
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("child() never throws when the added bindings are hostile — degrades to the parent context", () => {
|
|
200
|
+
// A Proxy whose ownKeys trap throws would break the Object.assign merge in child(); like emit(),
|
|
201
|
+
// child() must not propagate into the caller. It degrades to a child bound to just the parent
|
|
202
|
+
// context, and that child still logs normally with its inherited bindings.
|
|
203
|
+
const { records, sink } = recorder();
|
|
204
|
+
const hostile = new Proxy(
|
|
205
|
+
{},
|
|
206
|
+
{
|
|
207
|
+
ownKeys() {
|
|
208
|
+
throw new Error("trap");
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
const parent = createLogger(sink).child({ orderId: "o1" });
|
|
213
|
+
let child: ReturnType<typeof parent.child> | undefined;
|
|
214
|
+
assert.doesNotThrow(() => {
|
|
215
|
+
child = parent.child(hostile);
|
|
216
|
+
});
|
|
217
|
+
child?.info("shipped");
|
|
218
|
+
assert.equal(records.length, 1);
|
|
219
|
+
assert.equal(records[0].msg, "shipped");
|
|
220
|
+
assert.deepEqual({ ...records[0].fields }, { orderId: "o1" });
|
|
221
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
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
|
+
|
|
20
|
+
/** Severity of a log record, from most to least verbose. */
|
|
21
|
+
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
22
|
+
|
|
23
|
+
/** A structured context bag attached to a log record. Values should be JSON-serializable. */
|
|
24
|
+
export type LogFields = Record<string, unknown>;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The low-level output seam a {@link Logger} writes to. The host adapter supplies it and owns
|
|
28
|
+
* encoding (NDJSON), stream routing (warn/error → stderr, else stdout) and level filtering. `fields`
|
|
29
|
+
* is the merged bag (a logger's bound context overlaid with the per-call fields).
|
|
30
|
+
*/
|
|
31
|
+
export type LogSink = (level: LogLevel, msg: string, fields?: LogFields) => void;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The logger surface injected as `AppApi.log`. Callable for back-compat (`log(level, msg, fields)`),
|
|
35
|
+
* with per-level methods and `child()` for bound context. Prefer the methods:
|
|
36
|
+
*
|
|
37
|
+
* app.log.info("charge captured", { amount, currency });
|
|
38
|
+
* const jobLog = app.log.child({ orderId }); // every line below carries orderId
|
|
39
|
+
* jobLog.warn("retrying", { attempt });
|
|
40
|
+
*/
|
|
41
|
+
export interface Logger {
|
|
42
|
+
/** Back-compat callable form. Prefer `log.info(...)` etc. */
|
|
43
|
+
(level: LogLevel, msg: string, fields?: LogFields): void;
|
|
44
|
+
debug(msg: string, fields?: LogFields): void;
|
|
45
|
+
info(msg: string, fields?: LogFields): void;
|
|
46
|
+
warn(msg: string, fields?: LogFields): void;
|
|
47
|
+
error(msg: string, fields?: LogFields): void;
|
|
48
|
+
/** A logger that merges `bindings` into every record it emits (per-call fields win on key clash). */
|
|
49
|
+
child(bindings: LogFields): Logger;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Levels ordered by increasing severity; the index is the numeric threshold used for filtering. */
|
|
53
|
+
export const LOG_LEVELS: readonly LogLevel[] = ["debug", "info", "warn", "error"];
|
|
54
|
+
|
|
55
|
+
function isLogLevel(v: unknown): v is LogLevel {
|
|
56
|
+
return v === "debug" || v === "info" || v === "warn" || v === "error";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Parse a min-level string (e.g. from `URBAN_LOG_LEVEL`) into a {@link LogLevel}, case-insensitively,
|
|
61
|
+
* falling back to `fallback` (default `"info"`) for an absent or unrecognized value.
|
|
62
|
+
*/
|
|
63
|
+
export function parseLogLevel(value: string | undefined, fallback: LogLevel = "info"): LogLevel {
|
|
64
|
+
const v = value?.trim().toLowerCase();
|
|
65
|
+
return isLogLevel(v) ? v : fallback;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** True when a record at `level` should be emitted given the configured `min` level. */
|
|
69
|
+
export function levelEnabled(level: LogLevel, min: LogLevel): boolean {
|
|
70
|
+
return LOG_LEVELS.indexOf(level) >= LOG_LEVELS.indexOf(min);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Merge bound context with per-call fields (per-call wins), omitting the `fields` key entirely when
|
|
74
|
+
* the result is empty so a context-free line stays `{ level, msg }` clean. Always merges into a
|
|
75
|
+
* null-prototype object — even on the no-bindings fast path — and returns `undefined` for an empty
|
|
76
|
+
* bag: log field keys are user-controlled, so an untrusted key like `__proto__` must set a plain own
|
|
77
|
+
* property rather than trip the magic prototype setter a normal `{}` inherits from `Object.prototype`
|
|
78
|
+
* (same treatment as untrusted-key dictionaries elsewhere in this repo). Normalizing here — not just
|
|
79
|
+
* in {@link formatLogRecord} — keeps the merged bag safe for any custom {@link LogSink} that later
|
|
80
|
+
* spreads it into a plain object. */
|
|
81
|
+
function mergeFields(bindings: LogFields, fields?: LogFields): LogFields | undefined {
|
|
82
|
+
const boundKeys = Object.keys(bindings);
|
|
83
|
+
const callKeys = fields ? Object.keys(fields) : [];
|
|
84
|
+
if (boundKeys.length === 0 && callKeys.length === 0) return undefined;
|
|
85
|
+
if (boundKeys.length === 0) return Object.assign(Object.create(null), fields);
|
|
86
|
+
if (callKeys.length === 0) return Object.assign(Object.create(null), bindings);
|
|
87
|
+
return Object.assign(Object.create(null), bindings, fields);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A single structured log record, as serialized to NDJSON. Reserved keys (`ts`, `level`, `msg`)
|
|
91
|
+
* lead; the flattened `fields` follow. A field named `ts`/`level`/`msg` cannot shadow a reserved
|
|
92
|
+
* key (the reserved value wins), so correlation on those keys stays reliable. */
|
|
93
|
+
export interface LogRecord {
|
|
94
|
+
/** Epoch milliseconds when the line was emitted. */
|
|
95
|
+
ts: number;
|
|
96
|
+
level: LogLevel;
|
|
97
|
+
msg: string;
|
|
98
|
+
[field: string]: unknown;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Encode one structured log record as a single-line JSON string (no trailing newline). The reserved
|
|
103
|
+
* `ts`/`level`/`msg` keys are written first and cannot be overwritten by a same-named field. Values
|
|
104
|
+
* that don't survive `JSON.stringify` (a `bigint`, a circular object) are coerced so a bad field
|
|
105
|
+
* never throws in the logging hot path or corrupts the stream. The whole build+encode path is
|
|
106
|
+
* guarded (not just `JSON.stringify`), so a field whose enumeration itself throws (e.g. a getter or
|
|
107
|
+
* Proxy trap) still yields a clean fallback line. The record is a null-prototype object so an
|
|
108
|
+
* untrusted field key like `__proto__` sets a plain own property rather than tripping the magic
|
|
109
|
+
* prototype setter.
|
|
110
|
+
*/
|
|
111
|
+
export function formatLogRecord(level: LogLevel, msg: string, fields: LogFields | undefined, now: number): string {
|
|
112
|
+
try {
|
|
113
|
+
const record: LogRecord = Object.create(null);
|
|
114
|
+
record.ts = now;
|
|
115
|
+
record.level = level;
|
|
116
|
+
record.msg = msg;
|
|
117
|
+
if (fields) {
|
|
118
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
119
|
+
if (k === "ts" || k === "level" || k === "msg") continue;
|
|
120
|
+
record[k] = v;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return JSON.stringify(record, (_key, value) => (typeof value === "bigint" ? value.toString() : value));
|
|
124
|
+
} catch {
|
|
125
|
+
// A field that can't be built or serialized (circular ref, throwing getter) must not break logging.
|
|
126
|
+
return JSON.stringify({ ts: now, level, msg, fieldsError: "unserializable log fields" });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** {@link formatLogRecord} plus a trailing newline — one NDJSON line for a raw stream write. */
|
|
131
|
+
export function formatLogLine(level: LogLevel, msg: string, fields: LogFields | undefined, now: number): string {
|
|
132
|
+
return `${formatLogRecord(level, msg, fields, now)}\n`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Build a {@link Logger} over `sink`, optionally pre-bound to `bindings`. The returned value is a
|
|
137
|
+
* callable object: it forwards `log(level, msg, fields)` to the sink and exposes `debug/info/warn/
|
|
138
|
+
* error` plus `child()`. `bindings` is normalized into a null-prototype object (log field keys are
|
|
139
|
+
* user-controlled, so an untrusted key like `__proto__` must not trip the magic prototype setter),
|
|
140
|
+
* and `child(more)` returns a new logger whose bindings are `bindings` merged with `more` into a
|
|
141
|
+
* fresh null-prototype object, so bound context accumulates safely down a call chain.
|
|
142
|
+
*/
|
|
143
|
+
export function createLogger(sink: LogSink, bindings: LogFields = {}): Logger {
|
|
144
|
+
const bound: LogFields = Object.assign(Object.create(null), bindings);
|
|
145
|
+
const emit = (level: LogLevel, msg: string, fields?: LogFields): void => {
|
|
146
|
+
try {
|
|
147
|
+
sink(level, msg, mergeFields(bound, fields));
|
|
148
|
+
} catch {
|
|
149
|
+
// A log call must never throw into its caller. A hostile `fields` object (e.g. a Proxy whose
|
|
150
|
+
// `ownKeys` trap throws, defeating mergeFields before the sink's own formatLogRecord guard)
|
|
151
|
+
// or a throwing sink degrades to a best-effort, field-free line rather than break the handler.
|
|
152
|
+
try {
|
|
153
|
+
sink(level, msg, Object.assign(Object.create(null), { fieldsError: "unmergeable log fields" }));
|
|
154
|
+
} catch {
|
|
155
|
+
// The sink itself is broken — nothing more we can safely do without re-throwing.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
return Object.assign(emit, {
|
|
160
|
+
debug: (msg: string, fields?: LogFields) => emit("debug", msg, fields),
|
|
161
|
+
info: (msg: string, fields?: LogFields) => emit("info", msg, fields),
|
|
162
|
+
warn: (msg: string, fields?: LogFields) => emit("warn", msg, fields),
|
|
163
|
+
error: (msg: string, fields?: LogFields) => emit("error", msg, fields),
|
|
164
|
+
child: (more: LogFields) => {
|
|
165
|
+
// child() must not throw even when `more` is hostile (e.g. a Proxy whose key enumeration
|
|
166
|
+
// throws), mirroring emit()'s "logging never throws" guarantee: degrade to a child bound to
|
|
167
|
+
// just the parent context rather than let the merge bubble into the worker/route handler.
|
|
168
|
+
let childBindings: LogFields = bound;
|
|
169
|
+
try {
|
|
170
|
+
childBindings = Object.assign(Object.create(null), bound, more);
|
|
171
|
+
} catch {
|
|
172
|
+
// `more` could not be enumerated/merged — keep the parent bindings, drop the bad additions.
|
|
173
|
+
}
|
|
174
|
+
return createLogger(sink, childBindings);
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
|
+
import { createLogger } from "../logger.ts";
|
|
2
3
|
import assert from "node:assert/strict";
|
|
3
4
|
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
4
5
|
import type { EngineClient, HostContext, HttpRequest } from "../host.ts";
|
|
@@ -42,7 +43,7 @@ function appFixture(over: Partial<AppApi> = {}): AppApi {
|
|
|
42
43
|
data,
|
|
43
44
|
engine,
|
|
44
45
|
env: () => undefined,
|
|
45
|
-
log: () => {},
|
|
46
|
+
log: createLogger(() => {}),
|
|
46
47
|
...over,
|
|
47
48
|
};
|
|
48
49
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
|
+
import { createLogger } from "../logger.ts";
|
|
2
3
|
import assert from "node:assert/strict";
|
|
3
4
|
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
4
5
|
import type { AppManifest } from "../manifest.ts";
|
|
@@ -36,7 +37,7 @@ const engine: EngineClient = {
|
|
|
36
37
|
const data = new DataLayer(new Map(), undefined, {});
|
|
37
38
|
|
|
38
39
|
function appFixture(): AppApi {
|
|
39
|
-
return { manifest: { schemaVersion: 1, id: "t", name: "T" }, data, engine, env: () => undefined, log: () => {} };
|
|
40
|
+
return { manifest: { schemaVersion: 1, id: "t", name: "T" }, data, engine, env: () => undefined, log: createLogger(() => {}) };
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
const spec = JSON.stringify({
|
|
@@ -88,6 +89,7 @@ function build(
|
|
|
88
89
|
specText = spec,
|
|
89
90
|
env: (v: string) => string | undefined = () => undefined,
|
|
90
91
|
existsPaths: string[] = [],
|
|
92
|
+
app: AppApi = appFixture(),
|
|
91
93
|
): Built {
|
|
92
94
|
const imported: string[] = [];
|
|
93
95
|
const logs: Array<{ level: string; msg: string }> = [];
|
|
@@ -114,7 +116,7 @@ function build(
|
|
|
114
116
|
const manifest: AppManifest = { schemaVersion: 1, id: "t", name: "T" };
|
|
115
117
|
if (api) Reflect.set(manifest, "api", api);
|
|
116
118
|
const ctx: RuntimeContext = { root: "/app", manifest, engine, host };
|
|
117
|
-
const handle = mountApi(ctx,
|
|
119
|
+
const handle = mountApi(ctx, app);
|
|
118
120
|
const router = makeRouter(handle.routes);
|
|
119
121
|
return { router: (r) => Promise.resolve(router(r)), imported, logs, handle };
|
|
120
122
|
}
|
|
@@ -137,6 +139,40 @@ test("no api binding → no routes (no-op for actions-only apps)", () => {
|
|
|
137
139
|
return router(req("GET", "/app/api/invoices/1")).then((res) => assert.equal(res.status, 404));
|
|
138
140
|
});
|
|
139
141
|
|
|
142
|
+
test("a delegate's app.log is bound to the request's correlation context", async () => {
|
|
143
|
+
const captured: Array<{ level: string; msg: string; fields?: Record<string, unknown> }> = [];
|
|
144
|
+
const capturingApp: AppApi = {
|
|
145
|
+
manifest: { schemaVersion: 1, id: "t", name: "T" },
|
|
146
|
+
data,
|
|
147
|
+
engine,
|
|
148
|
+
env: () => undefined,
|
|
149
|
+
log: createLogger((level, msg, fields) => {
|
|
150
|
+
captured.push({ level, msg, fields });
|
|
151
|
+
}),
|
|
152
|
+
};
|
|
153
|
+
const handler: OperationHandler = (_input, app) => {
|
|
154
|
+
app.log.info("creating invoice", { amount: 5 });
|
|
155
|
+
return { status: 201, body: { id: "i1", amount: 5 } };
|
|
156
|
+
};
|
|
157
|
+
const { router } = build(
|
|
158
|
+
{ spec: "openapi.json", dir: "operations" },
|
|
159
|
+
{ "/app/operations/createInvoice": { default: handler } },
|
|
160
|
+
spec,
|
|
161
|
+
() => undefined,
|
|
162
|
+
[],
|
|
163
|
+
capturingApp,
|
|
164
|
+
);
|
|
165
|
+
const res = await router(req("POST", "/app/api/invoices", { body: JSON.stringify({ id: "i1", amount: 5 }) }));
|
|
166
|
+
assert.equal(res.status, 201);
|
|
167
|
+
assert.equal(captured.length, 1);
|
|
168
|
+
assert.equal(captured[0].level, "info");
|
|
169
|
+
assert.equal(captured[0].msg, "creating invoice");
|
|
170
|
+
assert.deepEqual(
|
|
171
|
+
{ ...captured[0].fields },
|
|
172
|
+
{ method: "POST", path: "/invoices", operationId: "createInvoice", amount: 5 },
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
140
176
|
test("routes an operation to its delegate with validated params + body", async () => {
|
|
141
177
|
const seen: unknown[] = [];
|
|
142
178
|
const handler: OperationHandler = (input) => {
|
|
@@ -421,6 +457,87 @@ test("params/query are coerced to their schema type before reaching the delegate
|
|
|
421
457
|
assert.deepEqual(got, { params: { id: 42 }, query: { verbose: true, tags: ["x", "y"] } });
|
|
422
458
|
});
|
|
423
459
|
|
|
460
|
+
test("3.1 nullable-typed params still coerce (type: [T, null]) and array-typed repeats still group", async () => {
|
|
461
|
+
// A param declared with the OpenAPI 3.1 nullable idiom must coerce on its value type exactly like
|
|
462
|
+
// the 3.0 single-type form — otherwise a `type: ["integer","null"]` query would reach the delegate
|
|
463
|
+
// as a raw string (and an array-typed nullable would reject repeats).
|
|
464
|
+
const spec = JSON.stringify({
|
|
465
|
+
openapi: "3.1.0",
|
|
466
|
+
paths: {
|
|
467
|
+
"/things/{id}": {
|
|
468
|
+
get: {
|
|
469
|
+
operationId: "getThing",
|
|
470
|
+
parameters: [
|
|
471
|
+
{ name: "id", in: "path", required: true, schema: { type: ["integer", "null"] } },
|
|
472
|
+
{ name: "tags", in: "query", schema: { type: ["array", "null"], items: { type: "string" } } },
|
|
473
|
+
],
|
|
474
|
+
responses: { "200": {} },
|
|
475
|
+
},
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
});
|
|
479
|
+
let got: { params: Record<string, unknown>; query: Record<string, unknown> } | undefined;
|
|
480
|
+
const { router } = build(
|
|
481
|
+
{ spec: "openapi.json" },
|
|
482
|
+
{
|
|
483
|
+
"/app/operations/getThing": {
|
|
484
|
+
default: (i: { params: Record<string, unknown>; query: Record<string, unknown> }) => {
|
|
485
|
+
got = { params: i.params, query: i.query };
|
|
486
|
+
return { body: { ok: true } };
|
|
487
|
+
},
|
|
488
|
+
},
|
|
489
|
+
},
|
|
490
|
+
spec,
|
|
491
|
+
);
|
|
492
|
+
const res = await router(req("GET", "/app/api/things/7", { query: "tags=x&tags=y" }));
|
|
493
|
+
assert.equal(res.status, 200);
|
|
494
|
+
assert.deepEqual(got, { params: { id: 7 }, query: { tags: ["x", "y"] } });
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("3.1 multi-type union params coerce order-independently (type array order doesn't matter)", async () => {
|
|
498
|
+
// A 3.1 union like `["string","integer"]` must coerce numeric-looking values to numbers regardless
|
|
499
|
+
// of where `integer`/`number` sits in the array — otherwise coercion is order-dependent and a
|
|
500
|
+
// `["string","integer"]` param would never enforce numeric bounds (it'd reach the delegate as a
|
|
501
|
+
// raw string), while `["integer","string"]` would. Non-numeric values still fall back to the
|
|
502
|
+
// string arm, and a `number|boolean` union coerces each token to its natural type.
|
|
503
|
+
const spec = JSON.stringify({
|
|
504
|
+
openapi: "3.1.0",
|
|
505
|
+
paths: {
|
|
506
|
+
"/u": {
|
|
507
|
+
get: {
|
|
508
|
+
operationId: "u",
|
|
509
|
+
parameters: [
|
|
510
|
+
{ name: "a", in: "query", required: true, schema: { type: ["string", "integer"] } },
|
|
511
|
+
{ name: "b", in: "query", required: true, schema: { type: ["integer", "string"] } },
|
|
512
|
+
{ name: "c", in: "query", required: true, schema: { type: ["string", "integer"] } },
|
|
513
|
+
{ name: "d", in: "query", required: true, schema: { type: ["number", "boolean"] } },
|
|
514
|
+
{ name: "e", in: "query", required: true, schema: { type: ["number", "boolean"] } },
|
|
515
|
+
],
|
|
516
|
+
responses: { "200": {} },
|
|
517
|
+
},
|
|
518
|
+
},
|
|
519
|
+
},
|
|
520
|
+
});
|
|
521
|
+
let got: { query: Record<string, unknown> } | undefined;
|
|
522
|
+
const { router } = build(
|
|
523
|
+
{ spec: "openapi.json" },
|
|
524
|
+
{
|
|
525
|
+
"/app/operations/u": {
|
|
526
|
+
default: (i: { query: Record<string, unknown> }) => {
|
|
527
|
+
got = { query: i.query };
|
|
528
|
+
return { body: { ok: true } };
|
|
529
|
+
},
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
spec,
|
|
533
|
+
);
|
|
534
|
+
const res = await router(req("GET", "/app/api/u", { query: "a=42&b=42&c=hello&d=3.5&e=true" }));
|
|
535
|
+
assert.equal(res.status, 200);
|
|
536
|
+
// a and b both coerce to 42 despite opposite type-array order; c stays a string (only fits the
|
|
537
|
+
// union's string arm); d coerces to a number, e to a boolean.
|
|
538
|
+
assert.deepEqual(got, { query: { a: 42, b: 42, c: "hello", d: 3.5, e: true } });
|
|
539
|
+
});
|
|
540
|
+
|
|
424
541
|
test("a typeless numeric const/enum query param coerces (so its number-literal type is satisfiable)", async () => {
|
|
425
542
|
const enumSpec = JSON.stringify({
|
|
426
543
|
openapi: "3.1.0",
|
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
parseSpec,
|
|
30
30
|
resolveSchema,
|
|
31
31
|
responseSchemaForStatus,
|
|
32
|
+
schemaHasType,
|
|
33
|
+
schemaValueTypes,
|
|
32
34
|
toRouteMatcher,
|
|
33
35
|
undeclaredPathParams,
|
|
34
36
|
undeclaredSecuritySchemes,
|
|
@@ -249,24 +251,31 @@ export function apiDocsPath(manifest: unknown): string | undefined {
|
|
|
249
251
|
function coerceParam(doc: OpenApiDoc, schema: OpenApiSchema | undefined, raw: string): unknown {
|
|
250
252
|
const s = resolveSchema(doc, schema);
|
|
251
253
|
if (!s) return raw;
|
|
252
|
-
//
|
|
253
|
-
// values are all numbers or all booleans. Without
|
|
254
|
-
// param (whose generated type is a number/boolean literal)
|
|
255
|
-
// string — every request would 400 against a type the runtime
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
254
|
+
// Which value types the schema permits coercion into: the explicit `type`(s), else inferred from a
|
|
255
|
+
// typeless `const`/`enum` whose values are all numbers or all booleans. Without the inference, a
|
|
256
|
+
// typeless numeric/boolean const/enum param (whose generated type is a number/boolean literal)
|
|
257
|
+
// could never be satisfied by the wire string — every request would 400 against a type the runtime
|
|
258
|
+
// otherwise never produces.
|
|
259
|
+
let valueTypes = schemaValueTypes(s);
|
|
260
|
+
if (valueTypes.length === 0) {
|
|
261
|
+
if (typeof s.const === "number") valueTypes = ["number"];
|
|
262
|
+
else if (typeof s.const === "boolean") valueTypes = ["boolean"];
|
|
260
263
|
else if (Array.isArray(s.enum) && s.enum.length > 0) {
|
|
261
|
-
if (s.enum.every((v) => typeof v === "number"))
|
|
262
|
-
else if (s.enum.every((v) => typeof v === "boolean"))
|
|
264
|
+
if (s.enum.every((v) => typeof v === "number")) valueTypes = ["number"];
|
|
265
|
+
else if (s.enum.every((v) => typeof v === "boolean")) valueTypes = ["boolean"];
|
|
263
266
|
}
|
|
264
267
|
}
|
|
265
|
-
|
|
268
|
+
// Coerce order-independently for OpenAPI 3.1 multi-type unions: a schema that *allows* number or
|
|
269
|
+
// boolean gets that coercion attempted regardless of where it sits in the `type` array (so
|
|
270
|
+
// `["string","integer"]` and `["integer","string"]` behave identically). Each attempt falls back
|
|
271
|
+
// to the raw string when it doesn't parse, preserving a value that only fits the union's `string`
|
|
272
|
+
// arm. Number is tried before boolean so a numeric-looking value in a `number|boolean` union
|
|
273
|
+
// becomes a number (`"true"`/`"false"` aren't numeric, so they still reach the boolean arm).
|
|
274
|
+
if (valueTypes.includes("integer") || valueTypes.includes("number")) {
|
|
266
275
|
const n = Number(raw);
|
|
267
|
-
|
|
276
|
+
if (raw.trim() !== "" && !Number.isNaN(n)) return n;
|
|
268
277
|
}
|
|
269
|
-
if (
|
|
278
|
+
if (valueTypes.includes("boolean")) {
|
|
270
279
|
if (raw === "true") return true;
|
|
271
280
|
if (raw === "false") return false;
|
|
272
281
|
}
|
|
@@ -571,7 +580,7 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
|
|
|
571
580
|
if (!p.schema) continue;
|
|
572
581
|
const values = Array.isArray(val) ? val : [val];
|
|
573
582
|
const ps = resolveSchema(d, p.schema);
|
|
574
|
-
if (ps
|
|
583
|
+
if (ps && schemaHasType(ps, "array")) {
|
|
575
584
|
// Repeated keys (?tag=a&tag=b) form the array; coerce + validate each item, then the
|
|
576
585
|
// whole array against the schema's array constraints (minItems, items, ...). The coerced
|
|
577
586
|
// array is what the delegate receives.
|
|
@@ -640,8 +649,17 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
|
|
|
640
649
|
// required), so pass `undefined` to those handlers rather than leaking a parsed value through
|
|
641
650
|
// a body-less type.
|
|
642
651
|
const handlerBody = op.requestBodySchema !== undefined || op.requestBodyRequired ? body : undefined;
|
|
652
|
+
// Hand the delegate an AppApi whose `log` is bound to this request's correlation context, so its
|
|
653
|
+
// lines self-tag with `{ method, path, operationId }` without the author threading it.
|
|
654
|
+
const requestApp: AppApi = {
|
|
655
|
+
...app,
|
|
656
|
+
log: app.log.child({ method, path: op.path, operationId: op.operationId }),
|
|
657
|
+
};
|
|
643
658
|
try {
|
|
644
|
-
const result = await handler(
|
|
659
|
+
const result = await handler(
|
|
660
|
+
{ req, params: coercedParams, query: coercedQuery, body: handlerBody },
|
|
661
|
+
requestApp,
|
|
662
|
+
);
|
|
645
663
|
if (!result) return { status: 204 };
|
|
646
664
|
const status = result.status ?? 200;
|
|
647
665
|
// Optional response validation (dev by default): warn-only, never blocks the response.
|