chronos-synapse-sdk
Version:
Chronos Synapse SDK for registering jobs and reporting executions to the Chronos backend
384 lines • 14.8 kB
JavaScript
import fs from 'node:fs';
import path from 'node:path';
import { io } from 'socket.io-client';
const DEFAULT_ENDPOINT = 'http://localhost:3001';
class ChronosClient {
config;
queue = [];
timer = null;
isFlushing = false;
jobVersionById = {};
cachedAppVersion;
constructor(config) {
const resolvedEndpoint = config.endpoint ||
process.env.CHRONOS_API_URL ||
process.env.CHRONOS_ENDPOINT ||
DEFAULT_ENDPOINT;
if (!resolvedEndpoint || !config.apiKey) {
throw new Error('ChronosClient requires endpoint and apiKey');
}
this.config = {
endpoint: resolvedEndpoint.replace(/\/$/, ''),
apiKey: config.apiKey,
orgId: config.orgId || process.env.CHRONOS_ORG_ID,
appId: config.appId || process.env.CHRONOS_APP_ID,
batchSize: config.batchSize ?? 50,
flushIntervalMs: config.flushIntervalMs ?? 2000,
captureConsole: config.captureConsole ?? false,
maxLogBytes: config.maxLogBytes ?? 10000,
};
}
detectAppVersion() {
if (this.cachedAppVersion)
return this.cachedAppVersion;
// Prefer env when running under npm scripts
const envVer = process.env.npm_package_version;
if (envVer) {
this.cachedAppVersion = envVer;
return envVer;
}
try {
// Try to read nearest package.json
const pkgPath = path.join(process.cwd(), 'package.json');
const json = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
if (json?.version) {
this.cachedAppVersion = String(json.version);
return this.cachedAppVersion;
}
}
catch { }
return undefined;
}
// Note: language/snippet helpers exist only in ChronosRunner where they are used
async registerJobs(jobs) {
// Basic validation: require schedule and runMode
for (const j of jobs) {
if (!j || typeof j !== 'object')
throw new Error('registerJobs: invalid job entry');
if (!j.id || !j.name)
throw new Error('registerJobs: job id and name are required');
if (typeof j.runMode !== 'string' ||
!['once', 'recurring'].includes(j.runMode)) {
throw new Error(`registerJobs: job ${j.id} requires runMode ('once' | 'recurring')`);
}
if (typeof j.schedule !== 'string') {
throw new Error(`registerJobs: job ${j.id} requires schedule string (cron or '')`);
}
if (j.runMode === 'recurring' && j.schedule.trim() === '') {
throw new Error(`registerJobs: job ${j.id} recurring requires a non-empty cron schedule`);
}
}
const payload = {
jobs: jobs.map((j) => ({
...j,
orgId: j.orgId ?? this.config.orgId,
appId: j.appId ?? this.config.appId,
version: j.version ?? this.detectAppVersion(),
})),
};
await this.post('/api/ingest/jobs/register', payload);
// Cache job versions for auto-fill during telemetry
for (const j of jobs) {
const v = j?.version ?? this.detectAppVersion();
if (j?.id && v)
this.jobVersionById[j.id] = v;
}
}
enqueueExecution(event) {
const filled = {
...event,
codeLanguage: event.codeLanguage ?? 'javascript',
appVersion: event.appVersion ?? this.detectAppVersion(),
jobVersion: event.jobVersion ?? (this.jobVersionById[event.jobId] || event.jobVersion),
};
this.queue.push(filled);
if (!this.timer)
this.startTimer();
if (this.queue.length >= (this.config.batchSize ?? 50)) {
void this.flush();
}
}
async flush() {
if (this.isFlushing || this.queue.length === 0)
return;
this.isFlushing = true;
try {
const batch = this.queue.splice(0, this.config.batchSize ?? 50);
await this.post('/api/ingest/executions/batch', { executions: batch });
}
finally {
this.isFlushing = false;
if (this.queue.length === 0 && this.timer)
this.stopTimer();
}
}
// wrap() and express() have been removed from the public API. Use ChronosRunner for scheduled/triggered execution.
startTimer() {
if (this.timer)
return;
this.timer = setInterval(() => {
if (this.queue.length > 0)
void this.flush();
}, this.config.flushIntervalMs ?? 2000);
}
stopTimer() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
async post(path, body, attempt = 1) {
const url = `${this.config.endpoint}${path}`;
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': this.config.apiKey,
},
body: JSON.stringify(body),
});
if (!res.ok) {
if (attempt < 5 && (res.status >= 500 || res.status === 429)) {
const backoff = Math.min(1000 * Math.pow(2, attempt - 1), 8000);
await new Promise((r) => setTimeout(r, backoff));
return this.post(path, body, attempt + 1);
}
const text = await res.text().catch(() => '');
throw new Error(`Chronos post failed: ${res.status} ${text}`);
}
}
}
export class ChronosRunner {
client;
socket = null;
handlers = new Map();
connected = false;
pickUserStackFrame(stack) {
try {
const lines = String(stack || '').split(/\n+/);
const candidates = [];
const rx = /(?:at\s+.*\()?((?:file:\/\/)?[^\s)]+):(\d+):(\d+)\)?/;
for (const line of lines) {
const m = line.match(rx);
if (!m)
continue;
let p = m[1];
const ln = Number(m[2]);
if (!Number.isFinite(ln))
continue;
if (p.startsWith('file://')) {
try {
p = new URL(p).pathname;
}
catch { }
}
const pathLower = p.toLowerCase();
let score = 100;
if (pathLower.includes('node_modules'))
score -= 50;
if (pathLower.includes('/@chronos-synapse/sdk/dist') ||
pathLower.endsWith('/sdk/dist/index.js'))
score -= 40;
candidates.push({ filePath: p, lineNum: ln, score });
}
if (candidates.length === 0)
return {};
candidates.sort((a, b) => b.score - a.score);
return { filePath: candidates[0].filePath, lineNum: candidates[0].lineNum };
}
catch {
return {};
}
}
constructor(config) {
this.client = new ChronosClient(config);
}
truncate(input) {
if (typeof input !== 'string')
return input;
const limit = this.client.config?.maxLogBytes ?? 10000;
return input.length > limit ? input.slice(0, limit) : input;
}
guessLanguageFromPath(filePath) {
if (!filePath)
return undefined;
const ext = filePath.toLowerCase().split('.').pop();
if (!ext)
return undefined;
if (ext === 'ts' || ext === 'tsx')
return 'typescript';
if (['js', 'jsx', 'mjs', 'cjs'].includes(ext))
return 'javascript';
if (ext === 'py')
return 'python';
if (ext === 'sh' || ext === 'bash')
return 'bash';
return undefined;
}
extractSnippetFromError(err) {
try {
const stack = String(err?.stack || '');
const picked = this.pickUserStackFrame(stack);
if (!picked?.filePath || !picked?.lineNum)
return { snippet: undefined, file: undefined };
let filePath = picked.filePath;
const lineNum = picked.lineNum;
if (!filePath || !Number.isFinite(lineNum))
return { snippet: undefined, file: undefined };
if (filePath.startsWith('file://')) {
try {
filePath = new URL(filePath).pathname;
}
catch { }
}
const fsContent = fs.readFileSync(filePath, 'utf8');
const lines = fsContent.split(/\r?\n/);
const before = 40, after = 80;
const start = Math.max(0, lineNum - before);
const end = Math.min(lines.length, lineNum + after);
const selected = lines.slice(start, end).join('\n');
return { snippet: this.truncate(selected), file: filePath };
}
catch {
return { snippet: undefined, file: undefined };
}
}
register(jobId, handler) {
this.handlers.set(jobId, handler);
if (this.connected) {
try {
this.socket?.emit('join-job', jobId);
}
catch { }
}
}
start() {
if (this.socket)
return;
const endpoint = this.client.config.endpoint;
const url = endpoint.replace(/\/$/, '').replace(/\/$/, '');
this.socket = io(url, {
transports: ['websocket'],
extraHeaders: {
'x-api-key': this.client.config.apiKey,
},
autoConnect: true,
});
this.socket.on('connect', () => {
this.connected = true;
// Join all registered job rooms
for (const jobId of this.handlers.keys()) {
try {
this.socket?.emit('join-job', jobId);
}
catch { }
}
});
this.socket.on('disconnect', () => {
this.connected = false;
});
this.socket.on('connect_error', (err) => {
// eslint-disable-next-line no-console
console.error('ChronosRunner socket connect error:', err?.message || err);
});
this.socket.on('job:trigger', async (payload) => {
try {
const jobId = String(payload?.jobId || '');
const handler = this.handlers.get(jobId);
if (!handler)
return;
const startedAt = new Date();
const execId = `${jobId}:${startedAt.getTime()}`;
const captureConsole = Boolean(this.client.config?.captureConsole);
let capturedOut = '';
let capturedErr = '';
const origStdout = process.stdout.write;
const origStderr = process.stderr.write;
if (captureConsole) {
try {
process.stdout.write = (chunk, ...args) => {
try {
capturedOut += typeof chunk === 'string' ? chunk : String(chunk);
}
catch { }
return origStdout.call(process.stdout, chunk, ...args);
};
process.stderr.write = (chunk, ...args) => {
try {
capturedErr += typeof chunk === 'string' ? chunk : String(chunk);
}
catch { }
return origStderr.call(process.stderr, chunk, ...args);
};
}
catch { }
}
try {
await handler();
const finishedAt = new Date();
this.client.enqueueExecution({
execId,
jobId,
status: 'success',
startedAt: startedAt.toISOString(),
finishedAt: finishedAt.toISOString(),
durationMs: finishedAt.getTime() - startedAt.getTime(),
exitCode: 0,
stdout: this.truncate(capturedOut || undefined),
});
}
catch (err) {
const finishedAt = new Date();
const fromStack = this.extractSnippetFromError(err);
const codeLanguage = this.guessLanguageFromPath(fromStack.file) || 'javascript';
const stderrFull = this.truncate((capturedErr ? capturedErr + '\n\n' : '') +
(typeof err?.stack === 'string'
? err.stack
: String(err?.message || err)));
this.client.enqueueExecution({
execId,
jobId,
status: 'failed',
startedAt: startedAt.toISOString(),
finishedAt: finishedAt.toISOString(),
durationMs: finishedAt.getTime() - startedAt.getTime(),
exitCode: 1,
errorMessage: String(err?.message || err),
errorType: err?.name,
errorStack: typeof err?.stack === 'string' ? err.stack : undefined,
stderr: stderrFull,
codeSnippet: fromStack.snippet,
codeLanguage,
});
}
finally {
if (captureConsole) {
try {
process.stdout.write = origStdout;
process.stderr.write = origStderr;
}
catch { }
}
}
// Acknowledge (best-effort)
try {
this.socket?.emit('trigger:ack', { triggerId: payload?.triggerId, jobId });
}
catch { }
}
catch { }
});
}
stop() {
if (this.socket) {
try {
this.socket.disconnect();
}
catch { }
this.socket = null;
}
this.connected = false;
}
}
export default ChronosRunner;
//# sourceMappingURL=index.js.map