Spaces:
Runtime error
Runtime error
File size: 12,819 Bytes
a6b96c2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 | 'use strict';
/**
* Command Routing Hub β issue #3788, simplified in #175, typed in #176, observability in #177.
*
* A pure-result dispatch hub that centralizes CJS routing,
* the error taxonomy, and the no-throw contract that all command-family routers
* currently duplicate independently.
*
* Design:
* createHub({ cjsRegistry, manifest }) -> hub
* hub.dispatch({ family, subcommand, args, cwd, raw }) -> Result
*
* Result = { ok: true, data }
* | { ok: false, kind: 'UnknownCommand', command: string }
* | { ok: false, kind: 'InvalidArgs', arg: string, reason: string }
* | { ok: false, kind: 'HandlerRefusal', reason: string }
* | { ok: false, kind: 'HandlerFailure', message: string, cause?: Error }
*
* Invariants:
* - Hub always routes through CJS handlers. There is no SDK path (#175).
* - Hub never prints to stdout/stderr, never calls process.exit.
* - Hub never throws β all internal throws are caught and converted to
* { ok: false, kind: 'HandlerFailure', message, cause }.
* - The kind taxonomy is closed. Callers switch on ERROR_KINDS values.
* - Each error variant carries ONLY its own typed payload (#176).
* No cross-variant `message`/`details` escape hatches.
*
* ADR-457 build-at-publish: the hand-written bin/lib/command-routing-hub.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour from
* the prior hand-written .cjs; only types are added.
*/
const event_cjs_1 = require("./observability/event.cjs");
// eslint-disable-next-line @typescript-eslint/no-require-imports
const observabilityLogger = require("./observability/logger.cjs");
const { createNoOpLogger } = observabilityLogger;
// βββ Error kind constants βββββββββββββββββββββββββββββββββββββββββββββββββββββ
/**
* Closed error-kind enum. Export as a frozen object so callers can switch on
* ERROR_KINDS.UnknownCommand etc. without relying on bare string literals.
*
* #175: SdkLoadFailed and SdkDispatchFailed removed β Hub is CJS-only.
* #176: Field renamed errorKind β kind; payloads are typed per variant.
*
* @readonly
*/
const ERROR_KINDS = Object.freeze({
/** The requested family/subcommand combination is not present in the manifest. */
UnknownCommand: 'UnknownCommand',
/** The handler rejected the supplied arguments before executing. */
InvalidArgs: 'InvalidArgs',
/** A CJS handler returned an explicit refusal (e.g. unsupported subcommand). */
HandlerRefusal: 'HandlerRefusal',
/** A handler threw an unexpected exception. */
HandlerFailure: 'HandlerFailure',
});
// βββ Internal helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
/**
* Safe JSON serialisation that never throws.
*/
function _safeJson(value) {
try {
return JSON.stringify(value);
}
catch {
return String(value);
}
}
// βββ Typed-payload factories (#176) ββββββββββββββββββββββββββββββββββββββββββ
// Each factory returns a frozen discriminated-union variant for its kind.
// No cross-variant fields bleed between variants.
// Finding 3: all factory returns are Object.freeze'd so callers cannot mutate
// the variant invariant.
function makeUnknownCommand(command) {
return Object.freeze({ ok: false, kind: ERROR_KINDS.UnknownCommand, command });
}
function makeInvalidArgs(arg, reason) {
return Object.freeze({ ok: false, kind: ERROR_KINDS.InvalidArgs, arg, reason });
}
function makeHandlerRefusal(reason) {
return Object.freeze({ ok: false, kind: ERROR_KINDS.HandlerRefusal, reason });
}
/**
* @param message - Human-readable description of the failure.
* @param cause - The original thrown Error, when available.
* Non-Error values (strings, plain objects, etc.) are wrapped in an Error
* with `.thrown` set to the original value. null/undefined β no cause field.
*/
function makeHandlerFailure(message, cause) {
const obj = { ok: false, kind: ERROR_KINDS.HandlerFailure, message };
if (cause != null) {
if (cause instanceof Error) {
obj.cause = cause;
}
else {
// Finding 4: wrap non-Error cause so downstream .cause.stack never silently returns undefined
const wrapper = new Error('non-Error cause: ' + _safeJson(cause));
wrapper.thrown = cause;
obj.cause = wrapper;
}
}
return Object.freeze(obj);
}
// βββ Handler-return shape validator (Finding 1) βββββββββββββββββββββββββββββββ
/**
* Required payload fields per ok:false kind.
* `required` β fields that MUST be present (non-undefined) for the variant to be valid.
* `allowed` β the complete set of allowed fields (including ok, kind).
*/
const _VARIANT_SCHEMA = {
UnknownCommand: {
required: ['command'],
allowed: new Set(['ok', 'kind', 'command']),
},
InvalidArgs: {
required: ['arg', 'reason'],
allowed: new Set(['ok', 'kind', 'arg', 'reason']),
},
HandlerRefusal: {
required: ['reason'],
allowed: new Set(['ok', 'kind', 'reason']),
},
HandlerFailure: {
required: ['message'],
allowed: new Set(['ok', 'kind', 'message', 'cause']),
},
};
/**
* Validates a handler-returned { ok: false, ... } result against the typed schema.
*
* Returns null if valid, or a string describing the contract violation.
*/
function _validateErrResult(result) {
const { kind } = result;
const schema = _VARIANT_SCHEMA[kind];
// Unknown kind β not in the closed enum
if (!schema) {
return `handler returned unknown kind '${String(kind)}': expected one of ${Object.keys(_VARIANT_SCHEMA).join(', ')}`;
}
// Missing required fields
for (const field of schema.required) {
if (result[field] === undefined) {
return (`handler returned malformed Result variant: ` +
`kind '${String(kind)}' requires field '${field}' but it is missing. ` +
`got: ${_safeJson(result)}`);
}
}
// Extraneous fields outside the typed payload
for (const key of Object.keys(result)) {
if (!schema.allowed.has(key)) {
return (`handler returned malformed Result variant: ` +
`kind '${String(kind)}' does not allow field '${key}'. ` +
`expected fields: ${[...schema.allowed].join(', ')}. ` +
`got: ${_safeJson(result)}`);
}
}
return null; // valid
}
/**
* Safe stringify for logger-failure warnings β avoids circular-ref crashes.
*/
function _safeJsonForWarn(value) {
try {
return JSON.stringify(value);
}
catch {
return String(value);
}
}
/**
* Construct a CommandRoutingHub.
*/
function createHub({ cjsRegistry, manifest, logger } = {}) {
const _cjsRegistry = cjsRegistry;
const _manifest = manifest;
// Default to no-op so callers that don't inject a logger get pure-silent behaviour.
// Consumers can opt into the reference impl by importing createDefaultLogger.
const _logger = (logger && typeof logger.onEvent === 'function')
? logger
: createNoOpLogger();
/**
* Normalise a HubResult into the DispatchEvent result shape.
*
* HubResult ok path: { ok: true, data } β { kind: 'ok', data }
* HubResult err paths: { ok: false, kind, ...payload } β { kind, ...payload }
*/
function _normaliseResult(hubResult) {
if (hubResult.ok) {
return { kind: 'ok', data: hubResult.data };
}
// err variant: already has kind + typed payload
// Double-cast through unknown to satisfy strict index-signature check.
return hubResult;
}
/**
* Emit a DispatchEvent to the injected logger.
* Logger errors NEVER propagate β they are caught and emitted as a warn line to stderr.
*/
function _notifyLogger(command, args, hubResult, parentTraceId) {
try {
const eventResult = _normaliseResult(hubResult);
const event = (0, event_cjs_1.makeDispatchEvent)({ command, args, result: eventResult, parentTraceId });
_logger.onEvent(event);
}
catch (logErr) {
// Logger must never break dispatch. Emit a degraded warn line.
try {
process.stderr.write(_safeJsonForWarn({
level: 'warn',
source: 'DispatchLogger',
message: 'logger.onEvent failed: ' + String(logErr?.message || logErr),
}) + '\n');
}
catch {
// If even stderr.write fails, swallow silently β dispatch result is returned below.
}
}
}
/**
* Dispatch a command through the hub.
*/
function dispatch(req) {
const { family, subcommand, args = [], parentTraceId } = req || {};
const command = subcommand ? `${family} ${subcommand}` : String(family);
let result;
try {
result = _dispatch(req);
}
catch (err) {
if (err instanceof Error) {
result = makeHandlerFailure(err.message, err);
}
else {
// Finding 2: preserve non-Error throwables via a wrapper Error with .thrown
const wrapper = new Error('non-Error thrown: ' + _safeJson(err));
wrapper.thrown = err;
result = makeHandlerFailure(String(err), wrapper);
}
}
_notifyLogger(command, args, result, parentTraceId);
return result;
}
function _dispatch(req) {
const { family, subcommand, args = [], cwd, raw } = req;
// ββ manifest check ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
if (_manifest) {
const knownSubcommands = _manifest[family];
if (!knownSubcommands) {
return makeUnknownCommand(String(family));
}
if (subcommand && !knownSubcommands.includes(subcommand)) {
return makeUnknownCommand(`${family} ${subcommand}`);
}
}
return _dispatchCjs({ family, subcommand, args, cwd, raw });
}
function _dispatchCjs({ family, subcommand, args, cwd, raw }) {
if (!_cjsRegistry) {
return makeUnknownCommand(String(family));
}
const familyHandlers = _cjsRegistry[family];
if (!familyHandlers) {
return makeUnknownCommand(String(family));
}
const handler = subcommand ? familyHandlers[subcommand] : familyHandlers[''];
if (typeof handler !== 'function') {
return makeUnknownCommand(subcommand ? `${family} ${subcommand}` : String(family));
}
// Invoke the handler. It must return a HubResult or throw.
// If it throws, the outer try/catch in dispatch() catches it.
const result = handler({ family, subcommand, args, cwd, raw });
// If the handler returned a HubResult, validate ok:false variants against the typed schema.
if (result && typeof result === 'object' && 'ok' in result) {
if (!result.ok) {
// Finding 1: runtime-validate ok:false variant shape; coerce malformed to HandlerFailure
const violation = _validateErrResult(result);
if (violation !== null) {
return makeHandlerFailure('handler returned malformed Result variant: ' + violation,
// eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-plus-operands
new Error('expected ' + (result['kind'] ?? '<no kind>') + ', got ' + _safeJson(result)));
}
}
return result;
}
// If the handler returned nothing (undefined), treat as success with no data.
if (result === undefined || result === null) {
return { ok: true, data: null };
}
// Any other return value is treated as the data payload.
return { ok: true, data: result };
}
return { dispatch };
}
module.exports = {
createHub,
ERROR_KINDS,
makeUnknownCommand,
makeInvalidArgs,
makeHandlerRefusal,
makeHandlerFailure,
};
|