UNPKG

turbowatch

Version:

Extremely fast file change detector and task orchestrator for Node.js.

263 lines 10.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.subscribe = void 0; const createSpawn_1 = require("./createSpawn"); const generateShortId_1 = require("./generateShortId"); const Logger_1 = require("./Logger"); const promises_1 = require("node:timers/promises"); const serialize_error_1 = require("serialize-error"); const log = Logger_1.Logger.child({ namespace: 'subscribe', }); /** * Creates a trigger evaluation specific abort controller that inherits the abort signal from the trigger. * This abort controller is used to abort the the task that is currently running either because the trigger * has been interrupted or because the trigger has been triggered again. */ const createAbortController = (trigger) => { const abortController = new AbortController(); trigger.abortSignal.addEventListener('abort', () => { abortController.abort(); }); return abortController; }; const runTask = async ({ taskId, abortController, trigger, firstEvent, changedFiles, }) => { var _a, _b, _c, _d; if (trigger.initialRun && firstEvent) { log.debug('%s (%s): initial run...', trigger.name, taskId); } else if (changedFiles.length > 10) { log.debug({ files: changedFiles.slice(0, 10), }, '%s (%s): %d files changed; showing first 10', trigger.name, taskId, changedFiles.length); } else { log.debug({ files: changedFiles, }, '%s (%s): %d %s changed', trigger.name, taskId, changedFiles.length, changedFiles.length === 1 ? 'file' : 'files'); } let failedAttempts = -1; while (true) { if (abortController.signal.aborted) { log.warn('%s (%s): task aborted', trigger.name, taskId); return; } failedAttempts++; if (failedAttempts > 0) { const retryFactor = (_a = trigger.retry.factor) !== null && _a !== void 0 ? _a : 2; const minTimeout = (_b = trigger.retry.minTimeout) !== null && _b !== void 0 ? _b : 1000; const maxTimeout = (_c = trigger.retry.maxTimeout) !== null && _c !== void 0 ? _c : 30000; const delay = Math.min(failedAttempts * retryFactor * minTimeout, (_d = trigger.retry.maxTimeout) !== null && _d !== void 0 ? _d : maxTimeout); log.debug('delaying retry by %dms...', delay); await (0, promises_1.setTimeout)(delay); } try { await trigger.onChange({ abortSignal: abortController === null || abortController === void 0 ? void 0 : abortController.signal, attempt: failedAttempts, files: changedFiles.map((changedFile) => { return { name: changedFile, }; }), first: firstEvent, log, spawn: (0, createSpawn_1.createSpawn)(taskId, { abortSignal: abortController === null || abortController === void 0 ? void 0 : abortController.signal, cwd: trigger.cwd, outputPrefix: trigger.outputPrefix, throttleOutput: trigger.throttleOutput, triggerHexColor: trigger.hexColor, triggerName: trigger.name, }), taskId, }); failedAttempts = 0; if (trigger.persistent) { log.debug('%s (%s): re-running because the trigger is persistent', trigger.name, taskId); continue; } return; } catch (error) { if (error.name === 'AbortError') { log.warn('%s (%s): task aborted', trigger.name, taskId); return; } log.warn({ error: (0, serialize_error_1.serializeError)(error), }, '%s (%s): routine produced an error', trigger.name, taskId); if (trigger.persistent) { log.warn('%s (%s): retrying because the trigger is persistent', trigger.name, taskId); continue; } const retriesLeft = trigger.retry.retries - failedAttempts; if (retriesLeft < 0) { throw new Error('Expected retries left to be greater than or equal to 0'); } if (retriesLeft === 0) { log.warn('%s (%s): task will not be retried; attempts exhausted', trigger.name, taskId); throw error; } if (retriesLeft > 0) { log.warn('%s (%s): retrying task %d/%d...', trigger.name, taskId, trigger.retry.retries - retriesLeft, trigger.retry.retries); continue; } throw new Error('Expected retries left to be greater than or equal to 0'); } } throw new Error('Expected while loop to be terminated by a return statement'); }; const subscribe = (trigger) => { /** * Indicates that the teardown process has been initiated. * This is used to prevent the trigger from being triggered again while the teardown process is running. */ let outerTeardownInitiated = false; /** * Stores the currently active task. */ let outerActiveTask = null; /** * Identifies the first event in a series of events. */ let outerFirstEvent = true; /** * Stores the files that have changed since the last evaluation of the trigger */ let outerChangedFiles = []; const handleSubscriptionEvent = async () => { let firstEvent = outerFirstEvent; if (outerFirstEvent) { firstEvent = true; outerFirstEvent = false; } if (outerActiveTask) { if (trigger.interruptible) { log.debug('%s (%s): aborting task', trigger.name, outerActiveTask.id); if (!outerActiveTask.abortController) { throw new Error('Expected abort controller to be set'); } outerActiveTask.abortController.abort(); log.debug('%s (%s): waiting for task to abort', trigger.name, outerActiveTask.id); if (outerActiveTask.queued) { return undefined; } outerActiveTask.queued = true; try { // Do not start a new task until the previous task has been // aborted and the shutdown routine has run to completion. await outerActiveTask.promise; } catch (_a) { // nothing to do } } else { if (trigger.persistent) { log.warn('%s (%s): ignoring event because the trigger is persistent', trigger.name, outerActiveTask.id); return undefined; } log.warn('%s (%s): waiting for task to complete', trigger.name, outerActiveTask.id); if (outerActiveTask.queued) { return undefined; } outerActiveTask.queued = true; try { await outerActiveTask.promise; } catch (_b) { // nothing to do } } } if (outerTeardownInitiated) { log.warn('teardown already initiated'); return undefined; } const changedFiles = outerChangedFiles; outerChangedFiles = []; const taskId = (0, generateShortId_1.generateShortId)(); const abortController = createAbortController(trigger); const taskPromise = runTask({ abortController, changedFiles, firstEvent, taskId, trigger, }) // eslint-disable-next-line promise/prefer-await-to-then .finally(() => { if (taskId === (outerActiveTask === null || outerActiveTask === void 0 ? void 0 : outerActiveTask.id)) { log.debug('%s (%s): completed task', trigger.name, taskId); outerActiveTask = null; } }) // eslint-disable-next-line promise/prefer-await-to-then .catch((error) => { log.warn({ error: (0, serialize_error_1.serializeError)(error), }, '%s (%s): task failed', trigger.name, taskId); }); log.debug('%s (%s): started task', trigger.name, taskId); // eslint-disable-next-line require-atomic-updates outerActiveTask = { abortController, id: taskId, promise: taskPromise, queued: false, }; return taskPromise; }; return { activeTask: outerActiveTask, expression: trigger.expression, initialRun: trigger.initialRun, persistent: trigger.persistent, teardown: async () => { if (outerTeardownInitiated) { log.warn('teardown already initiated'); return; } outerTeardownInitiated = true; if (outerActiveTask === null || outerActiveTask === void 0 ? void 0 : outerActiveTask.abortController) { await outerActiveTask.abortController.abort(); } if (trigger.onTeardown) { const taskId = (0, generateShortId_1.generateShortId)(); try { await trigger.onTeardown({ spawn: (0, createSpawn_1.createSpawn)(taskId, { outputPrefix: trigger.outputPrefix, throttleOutput: trigger.throttleOutput, triggerHexColor: trigger.hexColor, triggerName: trigger.name, }), }); } catch (error) { log.error({ error, }, 'teardown produced an error'); } } }, trigger: async (events) => { for (const event of events) { if (outerChangedFiles.includes(event.filename)) { continue; } outerChangedFiles.push(event.filename); } try { await handleSubscriptionEvent(); } catch (error) { log.error({ error, }, 'trigger produced an error'); } }, }; }; exports.subscribe = subscribe; //# sourceMappingURL=subscribe.js.map