@checkstack/backend-api 0.15.3 → 0.17.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/CHANGELOG.md +112 -0
- package/package.json +4 -4
- package/src/base-strategy-config.ts +19 -0
- package/src/correlation-middleware.test.ts +191 -0
- package/src/esm-script-runner.test.ts +169 -0
- package/src/esm-script-runner.ts +467 -0
- package/src/index.ts +2 -0
- package/src/rpc.ts +100 -0
- package/src/shell-script-runner.ts +175 -0
- package/src/test-utils.ts +18 -6
- package/src/types.ts +34 -0
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import { spawn, type Subprocess } from "bun";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Shared sandbox for executing user-authored TypeScript / JavaScript
|
|
10
|
+
* modules in a fresh Bun subprocess.
|
|
11
|
+
*
|
|
12
|
+
* Used by both `@checkstack/healthcheck-script-backend` (the inline
|
|
13
|
+
* health-check collector) and `@checkstack/integration-script-backend`
|
|
14
|
+
* (the script integration provider). The two had near-identical inline
|
|
15
|
+
* implementations; this module is the canonical version.
|
|
16
|
+
*
|
|
17
|
+
* Why subprocess isolation matters:
|
|
18
|
+
*
|
|
19
|
+
* Running user code via `new Function(script)` in the satellite's
|
|
20
|
+
* own process gives the user `globalThis.process`, `node:fs`, etc.
|
|
21
|
+
* — they can read every secret in `process.env` (DB URLs, signing
|
|
22
|
+
* keys, queue creds) and exfiltrate them through the result. Even
|
|
23
|
+
* `manage`-level users typically have no legitimate API for those
|
|
24
|
+
* secrets, so in-process eval is a privilege amplification.
|
|
25
|
+
*
|
|
26
|
+
* By spawning a separate Bun process with a curated `SAFE_ENV_VARS`
|
|
27
|
+
* subset, the user's script gets the full Node/Bun standard library
|
|
28
|
+
* to work with but cannot see the satellite's environment.
|
|
29
|
+
*
|
|
30
|
+
* Concurrency note: each `run()` invocation is fully isolated.
|
|
31
|
+
*
|
|
32
|
+
* - `mkdtemp` guarantees a unique directory name (POSIX-atomic).
|
|
33
|
+
* - The result-marker session id is a `randomUUID`, so each
|
|
34
|
+
* subprocess's stderr is unambiguously its own — even if user
|
|
35
|
+
* scripts happen to write text that looks like another invocation's
|
|
36
|
+
* marker.
|
|
37
|
+
* - The subprocess is launched with `cmd: [bun, runner.mjs]` whose
|
|
38
|
+
* references to the temp dir are absolute paths in env/argv. Two
|
|
39
|
+
* concurrent subprocesses can never read each other's user.mjs.
|
|
40
|
+
*
|
|
41
|
+
* Cleanup is `finally`-guaranteed: the timeout handle is cleared, any
|
|
42
|
+
* straggler subprocess is killed (idempotent on an already-exited
|
|
43
|
+
* process), and the temp dir is removed recursively — on success, on
|
|
44
|
+
* thrown error, AND on timeout.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
// =============================================================================
|
|
48
|
+
// PUBLIC TYPES
|
|
49
|
+
// =============================================================================
|
|
50
|
+
|
|
51
|
+
export interface EsmScriptRunResult {
|
|
52
|
+
/** Raw value the user script returned (default export or legacy IIFE return). */
|
|
53
|
+
result?: unknown;
|
|
54
|
+
/** Error message if the script threw or failed to load. */
|
|
55
|
+
error?: string;
|
|
56
|
+
/** Stack trace if available. */
|
|
57
|
+
stack?: string;
|
|
58
|
+
/** Anything the script wrote to stdout — caller can surface as logs. */
|
|
59
|
+
stdout: string;
|
|
60
|
+
/** Anything the script wrote to stderr (with our result-marker stripped). */
|
|
61
|
+
stderr: string;
|
|
62
|
+
/** True if the timeout fired before the subprocess exited. */
|
|
63
|
+
timedOut: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface EsmScriptRunOptions {
|
|
67
|
+
/** User-supplied script source (modern ESM with `import`/`export`, or legacy `return X;`). */
|
|
68
|
+
script: string;
|
|
69
|
+
/** Object to expose as `globalThis.context` inside the subprocess. JSON-serialised; no functions / cycles. */
|
|
70
|
+
context: unknown;
|
|
71
|
+
/** Maximum execution time in milliseconds. */
|
|
72
|
+
timeoutMs: number;
|
|
73
|
+
/**
|
|
74
|
+
* Optional virtual module name that the user's script can `import`
|
|
75
|
+
* from. We write a sibling `_helpers.mjs` in the temp dir that
|
|
76
|
+
* exports a single identity function under `helperFunctionName`, and
|
|
77
|
+
* rewrite any `from "<helperModuleName>"` import in the user source
|
|
78
|
+
* to point at that file. Skipped if either field is omitted.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* helperModuleName: "@checkstack/healthcheck"
|
|
82
|
+
* helperFunctionName: "defineHealthCheck"
|
|
83
|
+
* // editor: import { defineHealthCheck } from "@checkstack/healthcheck"
|
|
84
|
+
* // runtime: import { defineHealthCheck } from "file:///tmp/.../_helpers.mjs"
|
|
85
|
+
*/
|
|
86
|
+
helperModuleName?: string;
|
|
87
|
+
/** Name of the helper function injected as a global AND exported by the virtual module. */
|
|
88
|
+
helperFunctionName?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Injectable interface. Production code calls
|
|
93
|
+
* `defaultEsmScriptRunner.run()`; tests can pass a mock to skip the
|
|
94
|
+
* actual subprocess spawn.
|
|
95
|
+
*/
|
|
96
|
+
export interface EsmScriptRunner {
|
|
97
|
+
run(options: EsmScriptRunOptions): Promise<EsmScriptRunResult>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// =============================================================================
|
|
101
|
+
// INTERNALS
|
|
102
|
+
// =============================================================================
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Vars passed through to the subprocess. We intentionally do NOT
|
|
106
|
+
* forward the satellite's full env so backend secrets (DB URLs, API
|
|
107
|
+
* tokens, signing keys) never reach user-authored scripts. PATH / HOME
|
|
108
|
+
* / LANG / ... are kept so `node:child_process`, `node:fs`, and
|
|
109
|
+
* locale-sensitive APIs behave normally.
|
|
110
|
+
*/
|
|
111
|
+
const SAFE_ENV_VARS = [
|
|
112
|
+
"PATH",
|
|
113
|
+
"HOME",
|
|
114
|
+
"USER",
|
|
115
|
+
"LANG",
|
|
116
|
+
"LC_ALL",
|
|
117
|
+
"LC_CTYPE",
|
|
118
|
+
"TZ",
|
|
119
|
+
"TMPDIR",
|
|
120
|
+
"HOSTNAME",
|
|
121
|
+
"SHELL",
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
function pickSafeEnv(): Record<string, string> {
|
|
125
|
+
const env: Record<string, string> = {};
|
|
126
|
+
for (const key of SAFE_ENV_VARS) {
|
|
127
|
+
const value = process.env[key];
|
|
128
|
+
if (value !== undefined) {
|
|
129
|
+
env[key] = value;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return env;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// =============================================================================
|
|
136
|
+
// USER-SCRIPT NORMALISATION
|
|
137
|
+
// =============================================================================
|
|
138
|
+
|
|
139
|
+
const ESM_AT_TOP_LEVEL = /^\s*(import\s|export\s)/m;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Make the user's source loadable as an ES module.
|
|
143
|
+
*
|
|
144
|
+
* Three shapes are supported:
|
|
145
|
+
* 1. **Real module** (contains `import` / `export` at top level) — used as-is.
|
|
146
|
+
* 2. **Legacy IIFE-style** (`return X;` at top level) — wrapped in an
|
|
147
|
+
* async IIFE whose return value becomes the default export.
|
|
148
|
+
* 3. **Side-effect only** — treated as healthy unless it throws.
|
|
149
|
+
*/
|
|
150
|
+
export function normaliseUserScript(userScript: string): string {
|
|
151
|
+
if (ESM_AT_TOP_LEVEL.test(userScript)) {
|
|
152
|
+
return userScript;
|
|
153
|
+
}
|
|
154
|
+
// Trailing newline so a `// comment` on the last line doesn't swallow
|
|
155
|
+
// the closing brace.
|
|
156
|
+
return `export default await (async () => {\n${userScript}\n})();\n`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Rewrite imports of a virtual module to point at a real on-disk
|
|
161
|
+
* helper file. The user writes a clean package import in the editor
|
|
162
|
+
* (with IntelliSense from the virtual ambient module), and at runtime
|
|
163
|
+
* we redirect it to a local sibling.
|
|
164
|
+
*
|
|
165
|
+
* The regex is anchored to the literal spec position of `from "..."` /
|
|
166
|
+
* `import "..."` — it doesn't touch substrings of comments or string
|
|
167
|
+
* literals.
|
|
168
|
+
*/
|
|
169
|
+
export function rewriteHelperImports({
|
|
170
|
+
userScript,
|
|
171
|
+
helperModuleName,
|
|
172
|
+
helperUrl,
|
|
173
|
+
}: {
|
|
174
|
+
userScript: string;
|
|
175
|
+
helperModuleName: string;
|
|
176
|
+
helperUrl: string;
|
|
177
|
+
}): string {
|
|
178
|
+
const escapedName = helperModuleName.replaceAll(
|
|
179
|
+
/[.*+?^${}()|[\]\\]/g,
|
|
180
|
+
String.raw`\$&`,
|
|
181
|
+
);
|
|
182
|
+
const fromRe = new RegExp(
|
|
183
|
+
String.raw`(from\s+)(["'])${escapedName}\2`,
|
|
184
|
+
"g",
|
|
185
|
+
);
|
|
186
|
+
const sideEffectRe = new RegExp(
|
|
187
|
+
String.raw`(import\s+)(["'])${escapedName}\2`,
|
|
188
|
+
"g",
|
|
189
|
+
);
|
|
190
|
+
return userScript
|
|
191
|
+
.replaceAll(fromRe, (_match, fromKw: string) =>
|
|
192
|
+
`${fromKw}${JSON.stringify(helperUrl)}`,
|
|
193
|
+
)
|
|
194
|
+
.replaceAll(sideEffectRe, (_match, importKw: string) =>
|
|
195
|
+
`${importKw}${JSON.stringify(helperUrl)}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// =============================================================================
|
|
200
|
+
// RUNNER GENERATION
|
|
201
|
+
// =============================================================================
|
|
202
|
+
|
|
203
|
+
function buildHelperSource(helperFunctionName: string): string {
|
|
204
|
+
return `// Auto-generated. Identity helper that exists only so the editor can
|
|
205
|
+
// type-check the user's return shape. The runtime is intentionally trivial.
|
|
206
|
+
export function ${helperFunctionName}(value) { return value; }
|
|
207
|
+
`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function buildRunnerSource({
|
|
211
|
+
userScriptUrl,
|
|
212
|
+
contextJson,
|
|
213
|
+
helperFunctionName,
|
|
214
|
+
markerStart,
|
|
215
|
+
markerEnd,
|
|
216
|
+
}: {
|
|
217
|
+
userScriptUrl: string;
|
|
218
|
+
contextJson: string;
|
|
219
|
+
helperFunctionName: string | undefined;
|
|
220
|
+
markerStart: string;
|
|
221
|
+
markerEnd: string;
|
|
222
|
+
}): string {
|
|
223
|
+
const helperGlobal = helperFunctionName
|
|
224
|
+
? `globalThis.${helperFunctionName} = (value) => value;\n`
|
|
225
|
+
: "";
|
|
226
|
+
|
|
227
|
+
// `String.raw` so embedded \n / \\ in the generated source survive
|
|
228
|
+
// verbatim to the temp file — the runner needs to write a real
|
|
229
|
+
// newline at runtime, which means the file on disk needs the two
|
|
230
|
+
// characters `\n` (not a real LF).
|
|
231
|
+
return String.raw`// Auto-generated runner for an inline user-script execution.
|
|
232
|
+
// Sets up the user-facing globals, imports the user module, captures the
|
|
233
|
+
// result, and writes it back to the parent through a stderr marker.
|
|
234
|
+
|
|
235
|
+
globalThis.context = ${contextJson};
|
|
236
|
+
${helperGlobal}
|
|
237
|
+
const __markerStart = ${JSON.stringify(markerStart)};
|
|
238
|
+
const __markerEnd = ${JSON.stringify(markerEnd)};
|
|
239
|
+
|
|
240
|
+
function __emit(payload) {
|
|
241
|
+
// Single-line JSON, sandwiched between unique markers. The parent
|
|
242
|
+
// process does a lastIndexOf() to find it and is tolerant of
|
|
243
|
+
// arbitrary user output on stderr above.
|
|
244
|
+
process.stderr.write(__markerStart + JSON.stringify(payload) + __markerEnd + "\n");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
const __mod = await import(${JSON.stringify(userScriptUrl)});
|
|
249
|
+
let __result;
|
|
250
|
+
if (__mod && "default" in __mod && __mod.default !== undefined) {
|
|
251
|
+
const __def = __mod.default;
|
|
252
|
+
__result =
|
|
253
|
+
typeof __def === "function"
|
|
254
|
+
? await __def(globalThis.context)
|
|
255
|
+
: __def;
|
|
256
|
+
}
|
|
257
|
+
__emit({ ok: true, result: __result ?? null });
|
|
258
|
+
} catch (err) {
|
|
259
|
+
const __message =
|
|
260
|
+
err && typeof err === "object" && "message" in err
|
|
261
|
+
? String(err.message)
|
|
262
|
+
: String(err);
|
|
263
|
+
const __stack =
|
|
264
|
+
err && typeof err === "object" && "stack" in err
|
|
265
|
+
? String(err.stack)
|
|
266
|
+
: undefined;
|
|
267
|
+
__emit({ ok: false, error: __message, stack: __stack });
|
|
268
|
+
process.exit(1);
|
|
269
|
+
}
|
|
270
|
+
`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// =============================================================================
|
|
274
|
+
// MARKER PAYLOAD VALIDATION
|
|
275
|
+
// =============================================================================
|
|
276
|
+
|
|
277
|
+
type RunnerPayload =
|
|
278
|
+
| { ok: true; result: unknown }
|
|
279
|
+
| { ok: false; error: string; stack?: string };
|
|
280
|
+
|
|
281
|
+
function isRunnerPayload(value: unknown): value is RunnerPayload {
|
|
282
|
+
if (typeof value !== "object" || value === null) return false;
|
|
283
|
+
const v = value as Record<string, unknown>;
|
|
284
|
+
if (v.ok === true) return true;
|
|
285
|
+
if (v.ok === false && typeof v.error === "string") return true;
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// =============================================================================
|
|
290
|
+
// DEFAULT RUNNER
|
|
291
|
+
// =============================================================================
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Default runner implementation. Production code should use this; tests
|
|
295
|
+
* can substitute a mock that conforms to {@link EsmScriptRunner}.
|
|
296
|
+
*/
|
|
297
|
+
export const defaultEsmScriptRunner: EsmScriptRunner = {
|
|
298
|
+
async run({
|
|
299
|
+
script,
|
|
300
|
+
context,
|
|
301
|
+
timeoutMs,
|
|
302
|
+
helperModuleName,
|
|
303
|
+
helperFunctionName,
|
|
304
|
+
}) {
|
|
305
|
+
const sessionId = randomUUID();
|
|
306
|
+
const markerStart = `##__CS_SCRIPT_RESULT_${sessionId}_START__##`;
|
|
307
|
+
const markerEnd = `##__CS_SCRIPT_RESULT_${sessionId}_END__##`;
|
|
308
|
+
|
|
309
|
+
const tmpDir = await mkdtemp(path.join(tmpdir(), "checkstack-script-"));
|
|
310
|
+
const userScriptPath = path.join(tmpDir, "user.mjs");
|
|
311
|
+
const runnerPath = path.join(tmpDir, "runner.mjs");
|
|
312
|
+
|
|
313
|
+
const hasHelper =
|
|
314
|
+
typeof helperModuleName === "string" &&
|
|
315
|
+
helperModuleName.length > 0 &&
|
|
316
|
+
typeof helperFunctionName === "string" &&
|
|
317
|
+
helperFunctionName.length > 0;
|
|
318
|
+
const helperPath = hasHelper ? path.join(tmpDir, "_helpers.mjs") : undefined;
|
|
319
|
+
const helperUrl = helperPath ? pathToFileURL(helperPath).href : undefined;
|
|
320
|
+
|
|
321
|
+
let proc: Subprocess | undefined;
|
|
322
|
+
let timedOut = false;
|
|
323
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
324
|
+
|
|
325
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
326
|
+
timeoutHandle = setTimeout(() => {
|
|
327
|
+
timedOut = true;
|
|
328
|
+
proc?.kill();
|
|
329
|
+
reject(new Error("__TIMEOUT__"));
|
|
330
|
+
}, timeoutMs);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
// Helper module first so the user's
|
|
335
|
+
// `import { <fn> } from "<helperModuleName>"` (which we rewrite
|
|
336
|
+
// to point at this file's URL) resolves at module-evaluation time.
|
|
337
|
+
if (helperPath && helperFunctionName) {
|
|
338
|
+
await writeFile(helperPath, buildHelperSource(helperFunctionName), "utf8");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const normalisedSource = normaliseUserScript(script);
|
|
342
|
+
const userSource =
|
|
343
|
+
hasHelper && helperUrl
|
|
344
|
+
? rewriteHelperImports({
|
|
345
|
+
userScript: normalisedSource,
|
|
346
|
+
helperModuleName: helperModuleName!,
|
|
347
|
+
helperUrl,
|
|
348
|
+
})
|
|
349
|
+
: normalisedSource;
|
|
350
|
+
|
|
351
|
+
await writeFile(userScriptPath, userSource, "utf8");
|
|
352
|
+
await writeFile(
|
|
353
|
+
runnerPath,
|
|
354
|
+
buildRunnerSource({
|
|
355
|
+
userScriptUrl: pathToFileURL(userScriptPath).href,
|
|
356
|
+
contextJson: JSON.stringify(context),
|
|
357
|
+
helperFunctionName: hasHelper ? helperFunctionName : undefined,
|
|
358
|
+
markerStart,
|
|
359
|
+
markerEnd,
|
|
360
|
+
}),
|
|
361
|
+
"utf8",
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
proc = spawn({
|
|
365
|
+
cmd: [process.execPath, runnerPath],
|
|
366
|
+
env: pickSafeEnv(),
|
|
367
|
+
stdout: "pipe",
|
|
368
|
+
stderr: "pipe",
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
let stdout: string;
|
|
372
|
+
let stderr: string;
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
[stdout, stderr] = (await Promise.race([
|
|
376
|
+
Promise.all([
|
|
377
|
+
new Response(proc.stdout as ReadableStream).text(),
|
|
378
|
+
new Response(proc.stderr as ReadableStream).text(),
|
|
379
|
+
proc.exited,
|
|
380
|
+
]),
|
|
381
|
+
timeoutPromise,
|
|
382
|
+
])) as [string, string, number];
|
|
383
|
+
} catch (error) {
|
|
384
|
+
if (timedOut) {
|
|
385
|
+
return {
|
|
386
|
+
stdout: "",
|
|
387
|
+
stderr: "",
|
|
388
|
+
timedOut: true,
|
|
389
|
+
error: "Script execution timed out",
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
throw error;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Pluck the runner payload out of stderr.
|
|
396
|
+
const startIdx = stderr.lastIndexOf(markerStart);
|
|
397
|
+
const endIdx = stderr.lastIndexOf(markerEnd);
|
|
398
|
+
|
|
399
|
+
let cleanStderr = stderr;
|
|
400
|
+
let payload: RunnerPayload | undefined;
|
|
401
|
+
|
|
402
|
+
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
|
403
|
+
const jsonStr = stderr.slice(startIdx + markerStart.length, endIdx);
|
|
404
|
+
try {
|
|
405
|
+
const parsed: unknown = JSON.parse(jsonStr);
|
|
406
|
+
if (isRunnerPayload(parsed)) {
|
|
407
|
+
payload = parsed;
|
|
408
|
+
}
|
|
409
|
+
} catch {
|
|
410
|
+
// Fall through to the "no marker" branch.
|
|
411
|
+
}
|
|
412
|
+
cleanStderr = (
|
|
413
|
+
stderr.slice(0, startIdx) + stderr.slice(endIdx + markerEnd.length)
|
|
414
|
+
)
|
|
415
|
+
.replace(/\n$/, "")
|
|
416
|
+
.trim();
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (!payload) {
|
|
420
|
+
// The runner never got far enough to emit — typically a syntax
|
|
421
|
+
// error in the user module or a hard crash. Surface whatever the
|
|
422
|
+
// subprocess wrote to stderr as the error.
|
|
423
|
+
return {
|
|
424
|
+
stdout: stdout.trim(),
|
|
425
|
+
stderr: cleanStderr,
|
|
426
|
+
timedOut: false,
|
|
427
|
+
error:
|
|
428
|
+
cleanStderr.length > 0
|
|
429
|
+
? cleanStderr
|
|
430
|
+
: "Script exited without producing a result",
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (payload.ok) {
|
|
435
|
+
return {
|
|
436
|
+
result: payload.result,
|
|
437
|
+
stdout: stdout.trim(),
|
|
438
|
+
stderr: cleanStderr,
|
|
439
|
+
timedOut: false,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
error: payload.error,
|
|
445
|
+
stack: payload.stack,
|
|
446
|
+
stdout: stdout.trim(),
|
|
447
|
+
stderr: cleanStderr,
|
|
448
|
+
timedOut: false,
|
|
449
|
+
};
|
|
450
|
+
} finally {
|
|
451
|
+
// Order matters:
|
|
452
|
+
// 1. Clear the timer (otherwise a fast script leaks an
|
|
453
|
+
// event-loop handle for up to `timeoutMs`).
|
|
454
|
+
// 2. Kill any straggler subprocess. `.kill()` is idempotent on
|
|
455
|
+
// an already-exited process.
|
|
456
|
+
// 3. Remove the tempdir last — after the subprocess can no
|
|
457
|
+
// longer be touching its files.
|
|
458
|
+
if (timeoutHandle !== undefined) {
|
|
459
|
+
clearTimeout(timeoutHandle);
|
|
460
|
+
}
|
|
461
|
+
proc?.kill();
|
|
462
|
+
await rm(tmpDir, { recursive: true, force: true }).catch(() => {
|
|
463
|
+
// Best-effort. Anything left in /tmp will be reaped by the OS.
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
},
|
|
467
|
+
};
|
package/src/index.ts
CHANGED
package/src/rpc.ts
CHANGED
|
@@ -53,6 +53,21 @@ export interface RpcContext {
|
|
|
53
53
|
cacheManager: CacheManager;
|
|
54
54
|
/** Emit a hook event for cross-plugin communication */
|
|
55
55
|
emitHook: EmitHookFn;
|
|
56
|
+
/**
|
|
57
|
+
* Inbound HTTP request headers (read-only view). Populated by the
|
|
58
|
+
* `/api/*` and `/rest/*` Hono handlers in `core/backend`. Optional
|
|
59
|
+
* because non-HTTP call sites (S2S clients, tests, scheduled queue
|
|
60
|
+
* jobs) can construct an `RpcContext` without a backing request.
|
|
61
|
+
*/
|
|
62
|
+
requestHeaders?: Headers;
|
|
63
|
+
/**
|
|
64
|
+
* Mutable response headers. Middleware (e.g. `correlationMiddleware`)
|
|
65
|
+
* can set headers here, and the Hono handler that drives the oRPC
|
|
66
|
+
* `RPCHandler` / `OpenAPIHandler` merges them onto the actual
|
|
67
|
+
* `Response` after the procedure has run. Optional for the same
|
|
68
|
+
* reason as `requestHeaders`.
|
|
69
|
+
*/
|
|
70
|
+
responseHeaders?: Headers;
|
|
56
71
|
}
|
|
57
72
|
|
|
58
73
|
/** Context with authenticated real user */
|
|
@@ -461,6 +476,91 @@ export const autoAuthMiddleware = os.middleware(
|
|
|
461
476
|
},
|
|
462
477
|
);
|
|
463
478
|
|
|
479
|
+
// =============================================================================
|
|
480
|
+
// CORRELATION ID MIDDLEWARE
|
|
481
|
+
// =============================================================================
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Name of the inbound and outbound HTTP header that carries the correlation
|
|
485
|
+
* ID. Exported so dev tools, integration tests, and front-end fetch wrappers
|
|
486
|
+
* can refer to the canonical value rather than hard-coding the string.
|
|
487
|
+
*/
|
|
488
|
+
export const CORRELATION_ID_HEADER = "x-correlation-id";
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Per-request observability middleware.
|
|
492
|
+
*
|
|
493
|
+
* Behaviour:
|
|
494
|
+
* - Reads `x-correlation-id` from `context.requestHeaders` (populated by the
|
|
495
|
+
* `/api/*` and `/rest/*` Hono handlers in `core/backend`).
|
|
496
|
+
* - Generates a fresh UUID v4 via `crypto.randomUUID()` if absent. This is
|
|
497
|
+
* the ONLY generation site for correlation IDs in the platform — handlers
|
|
498
|
+
* must NOT mint new IDs on their own.
|
|
499
|
+
* - Binds `{ correlationId, pluginId, userId? }` onto a child logger via
|
|
500
|
+
* `ctx.logger.child(...)` so every subsequent log line in the request
|
|
501
|
+
* carries that metadata automatically.
|
|
502
|
+
* - Writes the ID back to `context.responseHeaders` (if available) so the
|
|
503
|
+
* outer Hono handler can echo `x-correlation-id` on the response, letting
|
|
504
|
+
* the caller correlate their own client-side trace to the server log.
|
|
505
|
+
*
|
|
506
|
+
* Note on the echo: oRPC middleware has no direct access to the outgoing
|
|
507
|
+
* `Response` object — the framework constructs it from the procedure's
|
|
508
|
+
* return value AFTER middleware has finished. We use the mutable
|
|
509
|
+
* `responseHeaders` bag on `RpcContext` as a thin write-through: the Hono
|
|
510
|
+
* route handler merges those headers onto the `Response` post-handle. When
|
|
511
|
+
* an `RpcContext` is constructed without `responseHeaders` (S2S clients,
|
|
512
|
+
* tests), the echo silently no-ops; the ID is still bound to the child
|
|
513
|
+
* logger so server-side correlation still works.
|
|
514
|
+
*
|
|
515
|
+
* Order matters: in plugin routers, `.use(correlationMiddleware)` MUST
|
|
516
|
+
* appear BEFORE `.use(autoAuthMiddleware)` so that auth failures still log
|
|
517
|
+
* with the correlation ID attached.
|
|
518
|
+
*
|
|
519
|
+
* Usage:
|
|
520
|
+
*
|
|
521
|
+
* const os = implement(myContract)
|
|
522
|
+
* .$context<RpcContext>()
|
|
523
|
+
* .use(correlationMiddleware)
|
|
524
|
+
* .use(autoAuthMiddleware);
|
|
525
|
+
*/
|
|
526
|
+
export const correlationMiddleware = os.middleware(
|
|
527
|
+
async ({ next, context }) => {
|
|
528
|
+
const incoming = context.requestHeaders?.get(CORRELATION_ID_HEADER);
|
|
529
|
+
const correlationId =
|
|
530
|
+
incoming && incoming.length > 0 ? incoming : crypto.randomUUID();
|
|
531
|
+
|
|
532
|
+
context.responseHeaders?.set(CORRELATION_ID_HEADER, correlationId);
|
|
533
|
+
|
|
534
|
+
const meta: Record<string, unknown> = {
|
|
535
|
+
correlationId,
|
|
536
|
+
pluginId: context.pluginMetadata.pluginId,
|
|
537
|
+
};
|
|
538
|
+
if (context.user && "id" in context.user) {
|
|
539
|
+
meta.userId = context.user.id;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// `child` is optional on the Logger interface so minimal test-mock
|
|
543
|
+
// loggers don't have to implement it. Production Winston loggers
|
|
544
|
+
// always do; gracefully fall back to the base logger otherwise so
|
|
545
|
+
// the middleware never breaks a request just because metadata
|
|
546
|
+
// binding wasn't possible.
|
|
547
|
+
const boundLogger = context.logger.child
|
|
548
|
+
? context.logger.child(meta)
|
|
549
|
+
: context.logger;
|
|
550
|
+
|
|
551
|
+
// Partial-merge style (no `...context` spread): oRPC merges the
|
|
552
|
+
// returned context fields onto the existing context. Spreading
|
|
553
|
+
// would widen TypeScript's inferred chain type and surface
|
|
554
|
+
// TS2883 "inferred type cannot be named" errors in downstream
|
|
555
|
+
// packages whose routers compose this middleware.
|
|
556
|
+
return next({
|
|
557
|
+
context: {
|
|
558
|
+
logger: boundLogger,
|
|
559
|
+
},
|
|
560
|
+
});
|
|
561
|
+
},
|
|
562
|
+
);
|
|
563
|
+
|
|
464
564
|
/**
|
|
465
565
|
* Extract a nested value from an object using dot notation.
|
|
466
566
|
* E.g., getNestedValue({ params: { id: "123" } }, "params.id") => "123"
|