Files
core/sdk/javascript/dever_component.mjs
T

369 lines
16 KiB
JavaScript

const VERSION = 'dever-component-1';
const MAX_BYTES = 16 * 1024 * 1024;
const MAX_DEPTH = 64;
const MAX_ELEMENTS = 65536;
const CANCEL_GRACE_MS = 1000;
const decoder = new TextDecoder('utf-8', { fatal: true });
export class ProtocolError extends Error {}
export class BusinessError extends Error {
constructor(identity, payload = null) {
super(identity);
this.identity = identity;
this.payload = payload;
}
}
// JSON.parse accepts duplicate object keys; this parser enforces the runtime's
// duplicate, depth and value budgets before returning ordinary JS values.
function parseJSON(text) {
let offset = 0;
let remaining = MAX_ELEMENTS;
const fail = () => { throw new ProtocolError('invalid component JSON'); };
const space = () => { while (offset < text.length && /[ \t\r\n]/.test(text[offset])) offset++; };
function string() {
const start = offset++;
while (offset < text.length) {
const char = text[offset++];
if (char === '"') return JSON.parse(text.slice(start, offset));
if (char === '\\') offset++;
else if (char.charCodeAt(0) < 32) fail();
}
fail();
}
function value(depth) {
if (depth > MAX_DEPTH || --remaining < 0) throw new ProtocolError('wire JSON exceeds limit');
space();
const char = text[offset];
if (char === '"') return string();
if (char === '[' || char === '{') {
if (depth === MAX_DEPTH) throw new ProtocolError('wire JSON exceeds depth limit');
offset++;
const array = char === '[';
const result = array ? [] : {};
const seen = new Set();
space();
if (text[offset] === (array ? ']' : '}')) { offset++; return result; }
for (;;) {
if (array) result.push(value(depth + 1));
else {
if (text[offset] !== '"') fail();
const key = string();
if (seen.has(key)) throw new ProtocolError('duplicate JSON field');
seen.add(key);
space();
if (text[offset++] !== ':') fail();
Object.defineProperty(result, key, { value: value(depth + 1), enumerable: true, writable: true, configurable: true });
}
space();
const end = array ? ']' : '}';
if (text[offset] === end) { offset++; return result; }
if (text[offset++] !== ',') fail();
space();
}
}
const match = /^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/.exec(text.slice(offset));
if (!match) fail();
offset += match[0].length;
if (match[0] === 'true' || match[0] === 'false' || match[0] === 'null') return JSON.parse(match[0]);
if (match[0] === '-0') return -0;
if (/^-?(?:0|[1-9]\d*)$/.test(match[0])) {
const integer = BigInt(match[0]);
return integer <= BigInt(Number.MAX_SAFE_INTEGER) && integer >= BigInt(Number.MIN_SAFE_INTEGER) ? Number(integer) : integer;
}
const number = JSON.parse(match[0]);
if (!Number.isFinite(number)) throw new ProtocolError('invalid JSON number');
return number;
}
const result = value(0);
space();
if (offset !== text.length || result === null || Array.isArray(result) || typeof result !== 'object') fail();
return result;
}
function encodeJSON(value) {
let remaining = MAX_ELEMENTS;
function encode(current, depth) {
if (depth > MAX_DEPTH || --remaining < 0) throw new ProtocolError('wire JSON exceeds limit');
if (current === null || typeof current === 'boolean') return String(current);
if (typeof current === 'string') return JSON.stringify(current);
if (typeof current === 'bigint') return current.toString();
if (typeof current === 'number') {
if (!Number.isFinite(current)) throw new ProtocolError('invalid JSON number');
return Object.is(current, -0) ? '-0' : String(current);
}
if (depth === MAX_DEPTH) throw new ProtocolError('wire JSON exceeds depth limit');
if (Array.isArray(current)) return `[${current.map(item => encode(item, depth + 1)).join(',')}]`;
if (current && typeof current === 'object') {
return `{${Object.keys(current).map(key => `${JSON.stringify(key)}:${encode(current[key], depth + 1)}`).join(',')}}`;
}
throw new ProtocolError('invalid handler response');
}
return encode(value, 0);
}
function exact(message, names) {
const actual = Object.keys(message);
if (actual.length !== names.length || names.some(name => !Object.hasOwn(message, name))) {
throw new ProtocolError('component message has missing or extra fields');
}
}
function requestID(value) {
if (!Number.isSafeInteger(value) || value < 0) throw new ProtocolError('invalid request id');
return value;
}
function names(values) {
return Array.isArray(values) && values.every(value => typeof value === 'string' && value.length) && new Set(values).size === values.length;
}
function typed(value, schema) {
switch (schema.type) {
case 'nullable': return value === null ? null : typed(value, schema.value);
case 'list':
if (!Array.isArray(value)) throw new ProtocolError('expected list');
return value.map(item => typed(item, schema.value));
case 'record': {
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(name => !Object.hasOwn(schema.fields, name))) throw new ProtocolError('invalid record fields');
const result = {};
for (const [name, field] of Object.entries(schema.fields)) {
if (!Object.hasOwn(value, name) && field.type !== 'nullable') throw new ProtocolError('missing required record field');
Object.defineProperty(result, name, { value: Object.hasOwn(value, name) ? typed(value[name], field) : null, enumerable: true });
}
return result;
}
case 'int64': case 'duration': case 'model_id': {
if ((typeof value !== 'bigint' && !Number.isSafeInteger(value)) || BigInt(value) < -(1n << 63n) || BigInt(value) >= (1n << 63n)) throw new ProtocolError('expected signed 64-bit integer');
return value;
}
case 'float64':
if (typeof value !== 'number' || !Number.isFinite(value)) throw new ProtocolError('expected finite float');
return value;
case 'bool':
if (typeof value !== 'boolean') throw new ProtocolError('expected boolean');
return value;
case 'json': return value;
case 'text': case 'id': case 'secret': case 'decimal': case 'uuid': case 'datetime': case 'date': case 'time':
if (typeof value !== 'string') throw new ProtocolError('expected text');
if (schema.type === 'decimal' && !/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(value)) throw new ProtocolError('invalid decimal');
if (schema.type === 'uuid' && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) throw new ProtocolError('invalid UUID');
if (schema.type === 'date' && (!/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(Date.parse(`${value}T00:00:00Z`)))) throw new ProtocolError('invalid Date');
if (schema.type === 'time' && !/^\d{2}:\d{2}:\d{2}(?:\.\d{3})?$/.test(value)) throw new ProtocolError('invalid Time');
if (schema.type === 'datetime' && (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(value) || Number.isNaN(Date.parse(value)))) throw new ProtocolError('invalid DateTime');
return value;
default: throw new ProtocolError('unknown worker type');
}
}
async function stopHandler(current) {
current.controller.abort();
let timeout;
try {
await Promise.race([
current.settled,
new Promise((_, reject) => {
timeout = setTimeout(() => reject(new ProtocolError('component handler did not stop after cancellation')), CANCEL_GRACE_MS);
}),
]);
} finally {
clearTimeout(timeout);
}
}
class Frames {
constructor() {
this.buffer = Buffer.alloc(0);
this.queue = [];
this.queuedBytes = 0;
this.waiter = null;
this.failed = null;
process.stdin.on('data', chunk => this.receive(chunk));
process.stdin.on('end', () => this.fail(new ProtocolError('incomplete component frame')));
process.stdin.on('error', error => this.fail(error));
}
fail(error) {
this.failed = error;
if (this.waiter) { this.waiter.reject(error); this.waiter = null; }
}
receive(chunk) {
if (this.failed) return;
this.buffer = Buffer.concat([this.buffer, chunk]);
try {
while (this.buffer.length >= 4) {
const size = this.buffer.readUInt32BE(0);
if (!size || size > MAX_BYTES) throw new ProtocolError('component frame exceeds byte limit');
if (this.buffer.length < size + 4) break;
const message = parseJSON(decoder.decode(this.buffer.subarray(4, size + 4)));
this.buffer = this.buffer.subarray(size + 4);
if (this.waiter) { this.waiter.resolve(message); this.waiter = null; }
else {
if (this.queue.length === 4 || this.queuedBytes + size > MAX_BYTES) throw new ProtocolError('component input queue exceeds limit');
this.queue.push({ message, size });
this.queuedBytes += size;
}
}
} catch (error) { this.fail(error); }
}
next() {
if (this.failed) return Promise.reject(this.failed);
if (this.queue.length) {
const next = this.queue.shift();
this.queuedBytes -= next.size;
return Promise.resolve(next.message);
}
return new Promise((resolve, reject) => { this.waiter = { resolve, reject }; });
}
async send(message) {
const body = Buffer.from(encodeJSON(message));
if (!body.length || body.length > MAX_BYTES) throw new ProtocolError('component frame exceeds byte limit');
parseJSON(decoder.decode(body));
const frame = Buffer.allocUnsafe(body.length + 4);
frame.writeUInt32BE(body.length);
body.copy(frame, 4);
await new Promise((resolve, reject) => process.stdout.write(frame, error => error ? reject(error) : resolve()));
}
}
export class Worker {
constructor(contract, decodeSetting) {
if (!contract || ![contract.port, contract.schema, contract.adapter].every(value => typeof value === 'string' && value.length) || !names(contract.operations) || !contract.operations.length || !names(contract.capabilities ?? []) || !names(contract.errors ?? []) || typeof decodeSetting !== 'function') {
throw new Error('incomplete component contract');
}
this.contract = Object.freeze({
port: contract.port, schema: contract.schema, adapter: contract.adapter,
operations: Object.freeze([...contract.operations]),
capabilities: Object.freeze([...(contract.capabilities ?? [])]),
errors: Object.freeze([...(contract.errors ?? [])]),
});
this.decodeSetting = decodeSetting;
this.handlers = new Map();
this.outputTypes = null;
this.errorTypes = null;
}
static fromManifest(manifest, handlers = null) {
const operations = manifest.operations;
if (!names(operations) || Object.keys(manifest.inputs).sort().join() !== [...operations].sort().join() || Object.keys(manifest.outputs).sort().join() !== [...operations].sort().join()) throw new Error('incomplete typed Worker contract');
const worker = new Worker({
port: manifest.port, schema: manifest.schema, adapter: manifest.adapter,
operations, capabilities: manifest.capabilities, errors: Object.keys(manifest.errors),
}, value => {
if (manifest.setting !== null) return typed(value, manifest.setting);
if (value !== null) throw new ProtocolError('unexpected setting');
return null;
});
worker.inputTypes = manifest.inputs;
worker.outputTypes = manifest.outputs;
worker.errorTypes = manifest.errors;
if (handlers !== null) {
if (Object.keys(handlers).sort().join() !== [...operations].sort().join()) throw new Error('component operations are not fully registered');
for (const [operation, handler] of Object.entries(handlers)) worker.registerHandler(operation, handler);
}
return worker;
}
registerHandler(operation, handler) {
if (!this.inputTypes || !Object.hasOwn(this.inputTypes, operation)) throw new Error('operation is not in a typed Worker contract');
this.register(operation, value => typed(value, this.inputTypes[operation]), handler);
}
register(operation, decodePayload, handler) {
if (!this.contract.operations.includes(operation) || this.handlers.has(operation) || typeof decodePayload !== 'function' || typeof handler !== 'function') throw new Error('unknown or duplicate operation');
this.handlers.set(operation, { decodePayload, handler });
}
async serve() {
const contract = this.contract;
if (this.handlers.size !== contract.operations.length) throw new Error('component operations are not fully registered');
const frames = new Frames();
const hello = await frames.next();
exact(hello, ['kind', 'version', 'port', 'schema', 'adapter', 'capabilities', 'operations', 'setting']);
if (hello.kind !== 'hello' || hello.version !== VERSION || hello.port !== contract.port || hello.schema !== contract.schema || hello.adapter !== contract.adapter || JSON.stringify(hello.capabilities) !== JSON.stringify(contract.capabilities) || JSON.stringify(hello.operations) !== JSON.stringify(contract.operations)) {
throw new ProtocolError('component handshake does not match registered contract');
}
let setting;
try { setting = this.decodeSetting(hello.setting); }
catch { throw new ProtocolError('invalid component setting'); }
await frames.send({ kind: 'ready', version: VERSION, port: contract.port, schema: contract.schema, adapter: contract.adapter, capabilities: contract.capabilities, operations: contract.operations });
let active = null;
let nextID = 1;
let lastCancelled = false;
for (;;) {
const message = await frames.next();
switch (message.kind) {
case 'health':
exact(message, ['kind', 'id']);
if (requestID(message.id) !== 0) throw new ProtocolError('invalid health id');
await frames.send(message);
break;
case 'call': {
exact(message, ['kind', 'id', 'operation', 'payload']);
const id = requestID(message.id);
const operation = this.handlers.get(message.operation);
if (id !== nextID++ || active || !operation) throw new ProtocolError('invalid component call');
let payload;
try { payload = operation.decodePayload(message.payload); }
catch { throw new ProtocolError('invalid component payload'); }
const current = { id, controller: new AbortController() };
active = current;
current.settled = Promise.resolve().then(() => operation.handler(payload, setting, current.controller.signal)).then(
async result => {
if (active !== current) return;
active = null;
lastCancelled = false;
if (this.outputTypes) result = typed(result, this.outputTypes[message.operation]);
await frames.send({ kind: 'result', id, payload: result });
},
async error => {
if (active !== current) return;
active = null;
lastCancelled = false;
if (!(error instanceof BusinessError) || !contract.errors.includes(error.identity)) throw new ProtocolError('component handler failed');
if (this.errorTypes) error.payload = typed(error.payload, this.errorTypes[error.identity]);
await frames.send({ kind: 'error', id, error: error.identity, payload: error.payload });
},
).catch(error => { frames.fail(error); });
break;
}
case 'cancel':
exact(message, ['kind', 'id']);
requestID(message.id);
if (active?.id === message.id) {
const current = active;
active = null;
await stopHandler(current);
lastCancelled = true;
await frames.send({ kind: 'error', id: message.id, error: 'dever.cancelled', payload: null });
} else if (message.id !== nextID - 1 || lastCancelled) throw new ProtocolError('unknown component cancel id');
break;
case 'shutdown':
exact(message, ['kind']);
if (active) {
const current = active;
active = null;
await stopHandler(current);
}
await frames.send({ kind: 'shutdown' });
return;
default:
throw new ProtocolError('unknown component message');
}
}
}
}
export async function main(worker) {
try { await worker.serve(); }
catch (error) {
console.error(`component worker: ${error instanceof ProtocolError ? error.message : 'internal failure'}`);
process.exit(1);
}
}