diff --git a/.changeset/config.json b/.changeset/config.json index 42f3f37..dd88f3f 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -2,7 +2,7 @@ "$schema": "https://unpkg.com/@changesets/config/schema.json", "changelog": "@changesets/cli/changelog", "commit": false, - "fixed": [["@goto/web", "@goto/cli"]], + "fixed": [["@goto/web", "@goto/cli", "@goto/worker"]], "linked": [], "access": "restricted", "baseBranch": "main", diff --git a/.changeset/worker-service.md b/.changeset/worker-service.md new file mode 100644 index 0000000..bc2b830 --- /dev/null +++ b/.changeset/worker-service.md @@ -0,0 +1,7 @@ +--- +'@goto/worker': minor +'@goto/web': minor +'@goto/cli': minor +--- + +Introduce the standalone OpenWorkflow worker service with health checks, graceful shutdown, separate workflow storage, and matching CI/CD. Analytics workflows will be introduced separately. diff --git a/.github/workflows/worker.yml b/.github/workflows/worker.yml new file mode 100644 index 0000000..c8b6fc3 --- /dev/null +++ b/.github/workflows/worker.yml @@ -0,0 +1,116 @@ +name: CI/CD (Worker Service) + +on: + push: + branches: [main] + tags: ['@goto/worker@*'] + paths: + - '*' + - .changeset/** + - .github/workflows/** + - .woodpecker/** + - apps/worker/** + - packages/config/** + - tools/** + pull_request: + branches: [main] + paths: + - '*' + - .changeset/** + - .github/workflows/** + - .woodpecker/** + - apps/worker/** + - packages/config/** + - tools/** + +permissions: + contents: read + +jobs: + check: + name: Worker checks + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + version: 11.9.0 + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 26.5.0 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Check editor schema + run: pnpm schema:check + - name: Build worker and dependencies + run: pnpm --filter '@goto/worker...' build + - name: Check types + run: pnpm --filter @goto/worker check + - name: Lint worker + run: pnpm --filter @goto/worker lint + - name: Unit tests + run: pnpm --filter @goto/worker test:coverage + - name: Compiled service smoke test + run: pnpm --filter @goto/worker test:smoke + + build-push: + name: Publish worker image + if: >- + github.event_name == 'push' && + (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/@goto/worker@')) + needs: check + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 26.5.0 + - name: Check release version + if: github.ref_type == 'tag' + run: | + node --input-type=module <<'NODE' + import { readFileSync } from 'node:fs'; + const tag = process.env.GITHUB_REF_NAME ?? ''; + const version = JSON.parse(readFileSync('apps/worker/package.json', 'utf8')).version; + const stableVersion = /^(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)$/; + if (!stableVersion.test(version) || tag !== '@goto/worker@' + version) { + throw new Error('Expected a stable @goto/worker@ tag matching apps/worker/package.json'); + } + NODE + - name: Docker metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: ghcr.io/eleith/goto-worker + flavor: latest=false + tags: | + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=match,pattern=^@goto/worker@(\d+\.\d+\.\d+)$,group=1 + type=match,pattern=^@goto/worker@(\d+\.\d+)\.\d+$,group=1 + type=match,pattern=^@goto/worker@(\d+)\.\d+\.\d+$,group=1 + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push worker image + uses: docker/build-push-action@v7 + with: + context: . + file: apps/worker/Dockerfile + target: prod + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.woodpecker/worker.yml b/.woodpecker/worker.yml new file mode 100644 index 0000000..53e24f9 --- /dev/null +++ b/.woodpecker/worker.yml @@ -0,0 +1,133 @@ +workspace: + base: /workspace + path: src + +when: + - event: push + branch: main + path: + include: + - '*' + - .changeset/** + - .github/workflows/** + - .woodpecker/** + - apps/worker/** + - packages/config/** + - tools/** + - event: pull_request + path: + include: + - '*' + - .changeset/** + - .github/workflows/** + - .woodpecker/** + - apps/worker/** + - packages/config/** + - tools/** + - event: tag + ref: 'refs/tags/@goto/worker@*' + +steps: + prepare: + image: node:26.5.0-bookworm + environment: + COREPACK_HOME: /workspace/src/.pnpm-store/corepack + commands: + - npm install --global corepack@0.36.0 + - corepack enable + - corepack install --global pnpm@11.9.0 + - pnpm config set store-dir .pnpm-store + - pnpm install --frozen-lockfile + - pnpm schema:check + - pnpm --filter '@goto/worker...' build + + lint: + image: node:26.5.0-bookworm + environment: + COREPACK_HOME: /workspace/src/.pnpm-store/corepack + commands: + - npm install --global corepack@0.36.0 + - corepack enable + - pnpm --filter @goto/worker check + - pnpm --filter @goto/worker lint + + test: + image: node:26.5.0-bookworm + environment: + COREPACK_HOME: /workspace/src/.pnpm-store/corepack + commands: + - npm install --global corepack@0.36.0 + - corepack enable + - pnpm --filter @goto/worker test:coverage + - pnpm --filter @goto/worker test:smoke + + check-version-and-extract-tags: + image: node:26.5.0-bookworm + commands: + - | + node --input-type=module <<'NODE' + import { readFileSync, writeFileSync } from 'node:fs'; + const tag = process.env.CI_COMMIT_TAG ?? ''; + const version = JSON.parse(readFileSync('apps/worker/package.json', 'utf8')).version; + const stableVersion = /^(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)$/; + if (!stableVersion.test(version) || tag !== '@goto/worker@' + version) { + throw new Error('Expected a stable @goto/worker@ tag matching apps/worker/package.json'); + } + const [major, minor] = version.split('.'); + writeFileSync('.tags', [version, major + '.' + minor, major].join('\n') + '\n'); + NODE + when: + - event: tag + ref: 'refs/tags/@goto/worker@*' + + build-docker-main: + image: woodpeckerci/plugin-docker-buildx:6.0.3 + settings: + repo: git.eleith.com/eleith.private/goto-worker + username: eleith + registry: https://git.eleith.com + context: . + dockerfile: apps/worker/Dockerfile + target: prod + platforms: linux/amd64 + tags: + - latest + password: + from_secret: gitea_docker_registry + when: + - event: push + branch: main + + build-docker-tag: + image: woodpeckerci/plugin-docker-buildx:6.0.3 + settings: + repo: git.eleith.com/eleith.private/goto-worker + username: eleith + registry: https://git.eleith.com + context: . + dockerfile: apps/worker/Dockerfile + target: prod + platforms: linux/amd64 + tags_file: .tags + password: + from_secret: gitea_docker_registry + when: + - event: tag + ref: 'refs/tags/@goto/worker@*' + + notify: + image: deblan/woodpecker-email:3.3.0 + settings: + dsn: + from_secret: woodpecker_email_dsn + from: + address: + from_secret: woodpecker_email_from_address + name: + from_secret: woodpecker_email_from_name + recipients: + from_secret: woodpecker_email_recipient + recipients_only: true + when: + - event: [push, tag] + status: [failure, success] diff --git a/apps/web/docker-compose.dev.yml b/apps/web/docker-compose.dev.yml index b589d7b..01fa898 100644 --- a/apps/web/docker-compose.dev.yml +++ b/apps/web/docker-compose.dev.yml @@ -40,6 +40,41 @@ services: start_period: 60s retries: 3 + goto-worker: + image: goto-worker:dev + platform: linux/amd64 + build: + context: ../.. + dockerfile: apps/worker/Dockerfile + target: dev + args: + LOCAL_UID: ${LOCAL_UID:-1000} + LOCAL_GID: ${LOCAL_GID:-1000} + user: '${LOCAL_UID:-1000}:${LOCAL_GID:-1000}' + working_dir: /m/apps/worker + env_file: + - path: .env + required: false + environment: + NODE_ENV: development + PORT: 9000 + volumes: + - ./config:/m/apps/worker/config:ro + - ./data:/m/apps/worker/data + - ../worker/src:/m/apps/worker/src:ro + - ../../packages/config/src:/m/packages/config/src:ro + restart: unless-stopped + healthcheck: + test: + - CMD + - node + - --eval + - fetch('http://localhost:9000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1)) + interval: 30s + timeout: 3s + start_period: 10s + retries: 3 + goto-cli: image: goto-cli:dev platform: linux/amd64 diff --git a/apps/web/docker-compose.yml b/apps/web/docker-compose.yml index 2f1d28c..33f9a39 100644 --- a/apps/web/docker-compose.yml +++ b/apps/web/docker-compose.yml @@ -37,6 +37,42 @@ services: start_period: 5s retries: 3 + goto-worker: + image: goto-worker:local + platform: linux/amd64 + build: + context: ../.. + dockerfile: apps/worker/Dockerfile + target: prod + working_dir: /app + env_file: + - path: .env + required: false + environment: + NODE_ENV: production + PORT: 9000 + volumes: + - ./config:/app/config:ro + - ./data:/app/data + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=16m + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + restart: unless-stopped + healthcheck: + test: + - CMD + - node + - --eval + - fetch('http://localhost:9000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1)) + interval: 30s + timeout: 3s + start_period: 5s + retries: 3 + goto-cli: image: goto-cli:local platform: linux/amd64 diff --git a/apps/worker/Dockerfile b/apps/worker/Dockerfile new file mode 100644 index 0000000..5e6538b --- /dev/null +++ b/apps/worker/Dockerfile @@ -0,0 +1,52 @@ +# syntax=docker/dockerfile:1 +FROM node:26.5.0-alpine AS base +WORKDIR /m + +ENV CI=true \ + HOME=/home/app \ + PNPM_HOME=/home/app/.pnpm \ + XDG_DATA_HOME=/home/app/.local/share \ + XDG_CACHE_HOME=/home/app/.cache \ + XDG_CONFIG_HOME=/home/app/.config +RUN npm install -g pnpm@11.9.0 \ + && mkdir -p /home/app/store \ + && printf 'store-dir=/home/app/store\n' > /home/app/.npmrc + +FROM base AS dev +ARG LOCAL_UID=1000 +ARG LOCAL_GID=1000 + +COPY pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN --mount=type=cache,id=pnpm-store,target=/home/app/store pnpm fetch --frozen-lockfile +COPY . . +RUN --mount=type=cache,id=pnpm-store,target=/home/app/store pnpm install --offline --frozen-lockfile +RUN mkdir -p /m/apps/worker/config /m/apps/worker/data /m/packages/config/dist \ + && chown -R ${LOCAL_UID}:${LOCAL_GID} /home/app /m/apps/worker/data /m/packages/config/dist +USER ${LOCAL_UID}:${LOCAL_GID} +WORKDIR /m/apps/worker +EXPOSE 9000 +CMD ["pnpm", "run", "dev"] + +FROM base AS builder +COPY pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN --mount=type=cache,id=pnpm-store,target=/home/app/store pnpm fetch --frozen-lockfile +COPY . . +RUN --mount=type=cache,id=pnpm-store,target=/home/app/store pnpm install --offline --frozen-lockfile +RUN pnpm --filter "@goto/worker..." build + +FROM builder AS pruner +RUN --mount=type=cache,id=pnpm-store,target=/home/app/store \ + pnpm --config.hoist-workspace-packages=false --filter @goto/worker --prod deploy --legacy /pruned + +FROM node:26.5.0-alpine AS prod +WORKDIR /app +ENV NODE_ENV=production \ + PORT=9000 \ + HOST=0.0.0.0 +COPY --from=pruner /pruned/dist /app/dist +COPY --from=pruner /pruned/node_modules /app/node_modules +COPY --from=pruner /pruned/package.json /app/package.json +RUN mkdir -p /app/config /app/data && chown node:node /app/data +USER node +EXPOSE 9000 +CMD ["node", "/app/dist/index.js"] diff --git a/apps/worker/e2e/startup.mjs b/apps/worker/e2e/startup.mjs new file mode 100644 index 0000000..c6d06f1 --- /dev/null +++ b/apps/worker/e2e/startup.mjs @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict'; +import { fork } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { DatabaseSync } from 'node:sqlite'; + +async function checkStartup(mode, port = 0) { + const directory = await mkdtemp(join(tmpdir(), 'goto-worker-startup-')); + let child; + let closed; + let exited = false; + let killed = false; + let output = ''; + let message; + let deadline; + try { + await mkdir(join(directory, 'config')); + await writeFile( + join(directory, 'config/goto.yaml'), + JSON.stringify({ + site: { url: 'http://admin.example.test' }, + auth: { credentials: { username: 'smoke', password: 'private-worker-smoke-password' } }, + domains: {} + }) + ); + const source = mode === 'source'; + const entry = new URL(source ? '../src/index.ts' : '../dist/index.js', import.meta.url); + const register = fileURLToPath(new URL('../../../tools/register.mjs', import.meta.url)); + const execArgv = source ? ['--conditions=development', '--import', register] : []; + child = fork(entry, [], { + cwd: directory, + env: { + PATH: process.env.PATH, + NODE_ENV: source ? 'development' : 'production', + TZ: 'UTC', + HOST: '127.0.0.1', + PORT: String(port), + SHUTDOWN_TIMEOUT: '5' + }, + execArgv, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'] + }); + child.stdout.on('data', (chunk) => { + output = (output + chunk).slice(-50_000); + }); + child.stderr.on('data', (chunk) => { + output = (output + chunk).slice(-50_000); + }); + child.once('exit', () => { + exited = true; + }); + closed = new Promise((resolve, reject) => { + child.once('close', (code, signal) => resolve({ code, signal })); + child.once('error', reject); + }); + void closed.catch(() => {}); + child.on('message', (value) => { + message = value; + }); + const timedOut = new Promise((_, reject) => { + deadline = setTimeout(() => { + killed = true; + child.kill('SIGKILL'); + reject(new Error('Worker smoke timed out')); + }, 15_000); + }); + const finished = Promise.race([closed, timedOut]); + void finished.catch(() => {}); + + if (mode === 'occupied-port') { + assert.deepEqual(await finished, { code: 1, signal: null }); + assert.equal(message, undefined, 'Failed startup announced readiness'); + assert(output.includes('Unable to start worker service')); + } else { + const readiness = new Promise((resolve, reject) => { + child.once('message', resolve); + child.once('error', reject); + child.once('exit', () => reject(new Error('Worker exited before readiness'))); + }); + const ready = await Promise.race([ + readiness, + finished.then(() => { + throw new Error('Worker closed before readiness'); + }) + ]); + assert.equal(ready.type, 'ready'); + const response = await fetch(`http://127.0.0.1:${ready.port}/healthz`, { + signal: AbortSignal.timeout(2000) + }); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { status: 'ok' }); + assert(!existsSync(join(directory, 'data/goto.sqlite')), 'Visit database was opened'); + const database = new DatabaseSync(join(directory, 'data/worker.sqlite'), { + readOnly: true + }); + try { + assert.equal( + database.prepare('SELECT count(*) AS count FROM workflow_runs').get().count, + 0 + ); + } finally { + database.close(); + } + if (mode === 'storage-change') { + const path = join(directory, 'config/goto.yaml'); + const configuration = JSON.parse(await readFile(path, 'utf8')); + configuration.database = { worker_path: './data/restarted-worker.sqlite' }; + await writeFile(`${path}.next`, JSON.stringify(configuration)); + await rename(`${path}.next`, path); + } else { + child.kill('SIGTERM'); + } + assert.deepEqual(await finished, { code: 0, signal: null }); + if (mode === 'storage-change') { + assert(output.includes('Worker configuration change requires restart')); + assert(!existsSync(join(directory, 'data/restarted-worker.sqlite'))); + } + } + assert.equal(killed, false, 'Worker required forced termination'); + assert(!output.includes('private-worker-smoke-password')); + } finally { + clearTimeout(deadline); + if (child) { + if (!exited) child.kill('SIGKILL'); + child.stdout?.destroy(); + child.stderr?.destroy(); + await closed?.catch(() => undefined); + } + await rm(directory, { recursive: true, force: true }); + } +} + +await checkStartup('compiled'); +await checkStartup('source'); +await checkStartup('storage-change'); +const occupied = createServer(); +await new Promise((resolve, reject) => { + occupied.once('error', reject); + occupied.listen(0, '127.0.0.1', resolve); +}); +try { + const address = occupied.address(); + assert(address && typeof address !== 'string'); + await checkStartup('occupied-port', address.port); +} finally { + await new Promise((resolve) => occupied.close(resolve)); +} +console.log( + 'Compiled/source startup, config restart, empty queue, health, partial-startup cleanup, and shutdown passed' +); diff --git a/apps/worker/package.json b/apps/worker/package.json new file mode 100644 index 0000000..8cf83e4 --- /dev/null +++ b/apps/worker/package.json @@ -0,0 +1,32 @@ +{ + "name": "@goto/worker", + "version": "0.1.0", + "private": true, + "type": "module", + "files": [ + "dist" + ], + "scripts": { + "dev": "node --conditions=development --watch --import ../../tools/register.mjs src/index.ts", + "build": "tsc -p tsconfig.build.json", + "start": "node dist/index.js", + "check": "tsc --noEmit -p tsconfig.json", + "lint": "prettier --ignore-path ../../.prettierignore --check . && eslint .", + "format": "prettier --ignore-path ../../.prettierignore --write .", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "test:smoke": "node e2e/startup.mjs" + }, + "dependencies": { + "@goto/config": "workspace:*", + "openworkflow": "catalog:", + "tslog": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:", + "@vitest/coverage-v8": "catalog:", + "prettier": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts new file mode 100644 index 0000000..a94aa41 --- /dev/null +++ b/apps/worker/src/index.ts @@ -0,0 +1,55 @@ +import process from 'node:process'; +import { startWorkerService, type WorkerService } from './services/startup.js'; +import { logger } from './services/logger.js'; + +let starting: Promise | undefined; +let stopping: Promise | undefined; +let shutdownTimeout = 30_000; + +function shutdown(): Promise { + if (stopping) return stopping; + const deadline = setTimeout(() => process.exit(1), shutdownTimeout); + deadline.unref(); + stopping = (async () => { + const service = await starting?.catch(() => undefined); + await service?.stop(); + })() + .catch(() => { + process.exitCode = 1; + logger.error('Unable to shut down worker service'); + }) + .finally(() => { + clearTimeout(deadline); + if (process.connected) process.disconnect(); + }); + return stopping; +} + +process.on('SIGTERM', () => void shutdown()); +process.on('SIGINT', () => void shutdown()); +process.on('disconnect', () => void shutdown()); + +try { + const value = process.env.SHUTDOWN_TIMEOUT ?? '30'; + const seconds = Number(value); + if (!/^\d+(?:\.\d+)?$/.test(value) || !Number.isFinite(seconds) || seconds > 2_147_483.647) { + throw new Error('Invalid worker shutdown timeout'); + } + shutdownTimeout = seconds * 1000; + starting = startWorkerService({}, shutdown); + const service = await starting; + if (stopping) { + await stopping; + } else { + logger.info('Worker service started'); + if (process.send && process.connected) { + process.send({ type: 'ready', port: service.port }, (error) => { + if (error) void shutdown(); + }); + } + } +} catch { + process.exitCode = 1; + logger.error('Unable to start worker service'); + await shutdown(); +} diff --git a/apps/worker/src/services/config-reload.test.ts b/apps/worker/src/services/config-reload.test.ts new file mode 100644 index 0000000..1ad72c4 --- /dev/null +++ b/apps/worker/src/services/config-reload.test.ts @@ -0,0 +1,58 @@ +import { startConfigReloading, validateConfig } from '@goto/config'; +import { afterEach, expect, test, vi } from 'vitest'; +import { applyConfig, watchConfiguration } from './config-reload.js'; +import type { ShutdownResources } from './lifecycle.js'; + +vi.mock('./logger.js', () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } })); +vi.mock('@goto/config', async (importOriginal) => ({ + ...(await importOriginal()), + startConfigReloading: vi.fn() +})); + +function configuration(worker_path = './data/worker.sqlite') { + return validateConfig( + { + auth: { credentials: { username: 'test', password: 'config-test-password' } }, + database: { worker_path }, + domains: {} + }, + { env: {} } + ); +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +test('accepts unchanged worker storage and requests restart for a new storage path', () => { + const initial = configuration(); + const restart = vi.fn(); + expect(applyConfig(configuration(), initial, restart)).toBe(true); + expect(restart).not.toHaveBeenCalled(); + expect(applyConfig(configuration('./data/other.sqlite'), initial, restart)).toBe(false); + expect(restart).toHaveBeenCalledOnce(); +}); + +test('initial reread restart waits until the monitor stop handle is owned', async () => { + vi.useFakeTimers(); + const initial = configuration(); + const stopWatch = vi.fn(); + const resources: ShutdownResources = {}; + const shutdown = vi.fn(async () => { + resources.stopWatch?.(); + }); + const monitor = Promise.withResolvers<() => void>(); + vi.mocked(startConfigReloading).mockImplementationOnce(async (options) => { + options.onChange(configuration('./data/new.sqlite'), initial); + return monitor.promise; + }); + const source = { path: '/config/goto.yaml', initial, load: async () => initial }; + const watching = watchConfiguration(source, resources, shutdown); + await vi.runAllTimersAsync(); + expect(shutdown).not.toHaveBeenCalled(); + monitor.resolve(stopWatch); + await watching; + expect(shutdown).toHaveBeenCalledOnce(); + expect(stopWatch).toHaveBeenCalledOnce(); +}); diff --git a/apps/worker/src/services/config-reload.ts b/apps/worker/src/services/config-reload.ts new file mode 100644 index 0000000..816f0c1 --- /dev/null +++ b/apps/worker/src/services/config-reload.ts @@ -0,0 +1,51 @@ +import { startConfigReloading, type PreparedConfiguration } from '@goto/config'; +import { logger } from './logger.js'; +import type { ShutdownResources } from './lifecycle.js'; + +interface ConfigurationSource { + path: string; + initial: PreparedConfiguration; + load(path: string): Promise; +} + +export function applyConfig( + next: PreparedConfiguration, + previous: PreparedConfiguration, + onRestart: () => void +): boolean { + if (next.configuration.database.worker_path !== previous.configuration.database.worker_path) { + logger.warn('Worker configuration change requires restart'); + onRestart(); + return false; + } + logger.info('Worker configuration reloaded'); + return true; +} + +export async function watchConfiguration( + source: ConfigurationSource, + resources: ShutdownResources, + shutdown: () => Promise +): Promise { + let watchOwned = false; + let restartRequested = false; + function shutdownForRestart(): void { + void shutdown().catch(() => { + process.exitCode = 1; + logger.error('Unable to shut down worker after configuration change'); + }); + } + function requestRestart(): void { + if (restartRequested) return; + restartRequested = true; + if (watchOwned) shutdownForRestart(); + } + resources.stopWatch = await startConfigReloading({ + ...source, + onChange: (next, previous) => applyConfig(next, previous, requestRestart), + onError: () => + logger.error('Invalid worker configuration update; keeping current configuration') + }); + watchOwned = true; + if (restartRequested) shutdownForRestart(); +} diff --git a/apps/worker/src/services/engine-logger.test.ts b/apps/worker/src/services/engine-logger.test.ts new file mode 100644 index 0000000..5e7edea --- /dev/null +++ b/apps/worker/src/services/engine-logger.test.ts @@ -0,0 +1,32 @@ +import { expect, test, vi } from 'vitest'; +import { createEngineLogger } from './engine-logger.js'; + +test('keeps structured engine categories without raw messages', () => { + const log = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const logger = createEngineLogger(log); + const privateError = new Error('private error details'); + const privateInput = { password: 'private workflow input' }; + logger.debug('private debug details', privateInput); + logger.info('private workflow inputs', privateInput); + logger.warn('Worker schedule tick failed: private schedule', privateError); + logger.error('private database path', privateError, privateInput); + expect(log.debug).toHaveBeenCalledExactlyOnceWith('Workflow engine debug'); + expect(log.info).toHaveBeenCalledExactlyOnceWith('Workflow engine info'); + expect(log.warn).toHaveBeenCalledExactlyOnceWith('Workflow engine schedule poll failed'); + expect(log.error).toHaveBeenCalledExactlyOnceWith('Workflow engine error'); +}); + +test('only includes a validated execution ID and rejects a trailing newline', () => { + const log = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const logger = createEngineLogger(log); + const id = '00000000-0000-4000-8000-000000000001'; + const message = `Critical error during workflow execution for run ${id}:`; + logger.error(message); + logger.error(`${message}\n`); + expect(log.error).toHaveBeenNthCalledWith( + 1, + { workflowRunId: id }, + 'Workflow engine execution failed' + ); + expect(log.error).toHaveBeenNthCalledWith(2, 'Workflow engine execution failed'); +}); diff --git a/apps/worker/src/services/engine-logger.ts b/apps/worker/src/services/engine-logger.ts new file mode 100644 index 0000000..57dedc1 --- /dev/null +++ b/apps/worker/src/services/engine-logger.ts @@ -0,0 +1,38 @@ +import type { Logger as EngineLogger } from 'openworkflow'; +import type { Logger } from './logger.js'; + +const operationalMessages = [ + ['Worker tick failed:', 'Workflow engine worker poll failed'], + ['Worker schedule tick failed:', 'Workflow engine schedule poll failed'], + ['Heartbeat failed:', 'Workflow engine heartbeat failed'], + ['Critical error during workflow execution for run ', 'Workflow engine execution failed'] +] as const; + +const executionError = + /^Critical error during workflow execution for run ([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}):$/; + +function safeMessage(message: string, level: 'debug' | 'info' | 'warn' | 'error'): string { + if (typeof message === 'string') { + for (const [prefix, fixedMessage] of operationalMessages) { + if (message.startsWith(prefix)) return fixedMessage; + } + } + return `Workflow engine ${level}`; +} + +export function createEngineLogger( + log: Pick +): EngineLogger { + return { + debug: (message) => log.debug(safeMessage(message, 'debug')), + info: (message) => log.info(safeMessage(message, 'info')), + warn: (message) => log.warn(safeMessage(message, 'warn')), + error: (message) => { + const text = safeMessage(message, 'error'); + const match = typeof message === 'string' ? executionError.exec(message) : null; + // Reject a trailing newline accepted by the regex's end anchor. + if (match && match[0] === message) log.error({ workflowRunId: match[1] }, text); + else log.error(text); + } + }; +} diff --git a/apps/worker/src/services/health.ts b/apps/worker/src/services/health.ts new file mode 100644 index 0000000..3cfb6fc --- /dev/null +++ b/apps/worker/src/services/health.ts @@ -0,0 +1,13 @@ +import { createServer, type Server } from 'node:http'; + +export function createHealthServer(): Server { + return createServer((request, response) => { + response.setHeader('cache-control', 'no-store'); + if (request.method === 'GET' && request.url === '/healthz') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ status: 'ok' })); + return; + } + response.writeHead(404).end(); + }); +} diff --git a/apps/worker/src/services/lifecycle.test.ts b/apps/worker/src/services/lifecycle.test.ts new file mode 100644 index 0000000..1dd518a --- /dev/null +++ b/apps/worker/src/services/lifecycle.test.ts @@ -0,0 +1,49 @@ +import { expect, test, vi } from 'vitest'; +import { createShutdown } from './lifecycle.js'; + +test('shutdown is idempotent and waits for the worker before closing its backend', async () => { + const workerStopped = Promise.withResolvers(); + const workflowWorker = { stop: vi.fn(() => workerStopped.promise) }; + const backend = { stop: vi.fn(async () => {}) }; + const stopWatch = vi.fn(); + const stop = createShutdown({ workflowWorker, backend, stopWatch }); + const stopping = stop(); + expect(stop()).toBe(stopping); + expect(workflowWorker.stop).toHaveBeenCalledOnce(); + expect(stopWatch).toHaveBeenCalledOnce(); + expect(stopWatch.mock.invocationCallOrder[0]).toBeLessThan( + workflowWorker.stop.mock.invocationCallOrder[0]! + ); + expect(backend.stop).not.toHaveBeenCalled(); + workerStopped.resolve(); + await stopping; + expect(backend.stop).toHaveBeenCalledOnce(); +}); + +test('a failed monitor stop still stops the worker and closes the backend', async () => { + const failure = new Error('Monitor stop failed'); + const stopWatch = vi.fn(() => { + throw failure; + }); + const workflowWorker = { stop: vi.fn(async () => {}) }; + const backend = { stop: vi.fn(async () => {}) }; + const stop = createShutdown({ stopWatch, workflowWorker, backend }); + + await expect(stop()).rejects.toBe(failure); + expect(workflowWorker.stop).toHaveBeenCalledOnce(); + expect(backend.stop).toHaveBeenCalledOnce(); +}); + +test('a failed worker stop still closes the workflow backend', async () => { + const failure = new Error('Worker stop failed'); + const workflowWorker = { + stop: vi.fn(async () => { + throw failure; + }) + }; + const backend = { stop: vi.fn(async () => {}) }; + const stop = createShutdown({ workflowWorker, backend }); + + await expect(stop()).rejects.toBe(failure); + expect(backend.stop).toHaveBeenCalledOnce(); +}); diff --git a/apps/worker/src/services/lifecycle.ts b/apps/worker/src/services/lifecycle.ts new file mode 100644 index 0000000..31b9033 --- /dev/null +++ b/apps/worker/src/services/lifecycle.ts @@ -0,0 +1,45 @@ +import type { Server } from 'node:http'; +import type { Worker as WorkflowWorker } from 'openworkflow'; +import type { BackendSqlite } from 'openworkflow/sqlite'; + +export interface ShutdownResources { + workflowWorker?: Pick; + server?: Server; + backend?: Pick; + stopWatch?: () => void; +} + +export function createShutdown(resources: ShutdownResources): () => Promise { + let stopping: Promise | undefined; + return () => (stopping ??= stopResources(resources)); +} + +async function stopResources(resources: ShutdownResources): Promise { + const failures: unknown[] = []; + try { + resources.stopWatch?.(); + } catch (error) { + failures.push(error); + } + const serverClosed = new Promise((resolve) => { + try { + if (resources.server?.listening) resources.server.close(resolve); + else resolve(undefined); + } catch (error) { + resolve(error instanceof Error ? error : new Error('Health server shutdown failed')); + } + }); + try { + await resources.workflowWorker?.stop(); + } catch (error) { + failures.push(error); + } + const serverError = await serverClosed; + if (serverError) failures.push(serverError); + try { + await resources.backend?.stop(); + } catch (error) { + failures.push(error); + } + if (failures.length > 0) throw failures[0]; +} diff --git a/apps/worker/src/services/logger.ts b/apps/worker/src/services/logger.ts new file mode 100644 index 0000000..2b3759e --- /dev/null +++ b/apps/worker/src/services/logger.ts @@ -0,0 +1,12 @@ +import { Logger as TsLogger } from 'tslog'; + +let logType: 'pretty' | 'json' = 'pretty'; +if (process.env.NODE_ENV === 'production') logType = 'json'; + +export const logger = new TsLogger({ + name: 'goto-worker', + type: logType, + stack: { capture: 'off' } +}); + +export type Logger = typeof logger; diff --git a/apps/worker/src/services/startup.test.ts b/apps/worker/src/services/startup.test.ts new file mode 100644 index 0000000..834c387 --- /dev/null +++ b/apps/worker/src/services/startup.test.ts @@ -0,0 +1,56 @@ +import { existsSync } from 'node:fs'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Worker } from 'openworkflow'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { startWorkerService } from './startup.js'; + +let directory: string; + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'goto-worker-')); + await mkdir(join(directory, 'config')); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await rm(directory, { recursive: true, force: true }); +}); + +test('starts an empty workflow service and health server without opening visit storage', async () => { + await writeFile( + join(directory, 'config/goto.yaml'), + JSON.stringify({ + site: { url: 'http://admin.example.test' }, + auth: { credentials: { username: 'test', password: 'worker-test-only-password' } }, + database: { path: './data/visits.sqlite', worker_path: './state/worker.sqlite' }, + domains: {} + }) + ); + const start = vi.spyOn(Worker.prototype, 'start'); + const service = await startWorkerService({ + cwd: directory, + env: { HOST: '127.0.0.1', PORT: '0' } + }); + try { + expect(start).toHaveBeenCalledOnce(); + const response = await fetch(`http://127.0.0.1:${service.port}/healthz`, { + signal: AbortSignal.timeout(2000) + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ status: 'ok' }); + expect(existsSync(join(directory, 'state/worker.sqlite'))).toBe(true); + expect(existsSync(join(directory, 'data/visits.sqlite'))).toBe(false); + } finally { + await service.stop(); + } +}); + +test('invalid configuration fails before creating workflow storage', async () => { + await writeFile(join(directory, 'config/goto.yaml'), 'auth: {}\ndomains: {}\n'); + await expect(startWorkerService({ cwd: directory, env: {} })).rejects.toThrow( + 'Invalid configuration' + ); + expect(existsSync(join(directory, 'data'))).toBe(false); +}); diff --git a/apps/worker/src/services/startup.ts b/apps/worker/src/services/startup.ts new file mode 100644 index 0000000..23c7167 --- /dev/null +++ b/apps/worker/src/services/startup.ts @@ -0,0 +1,81 @@ +import { mkdir } from 'node:fs/promises'; +import { once } from 'node:events'; +import { dirname } from 'node:path'; +import { loadConfigFile, resolveConfigPath, type ConfigOptions } from '@goto/config'; +import { OpenWorkflow } from 'openworkflow'; +import { BackendSqlite } from 'openworkflow/sqlite'; +import { createEngineLogger } from './engine-logger.js'; +import { createHealthServer } from './health.js'; +import { createShutdown, type ShutdownResources } from './lifecycle.js'; +import { watchConfiguration } from './config-reload.js'; +import { logger } from './logger.js'; + +export interface WorkerService { + port: number; + stop(): Promise; +} + +export async function startWorkerService( + options: ConfigOptions = {}, + onRestartRequired?: () => Promise +): Promise { + const captured = { + env: { ...(options.env ?? process.env) }, + cwd: options.cwd ?? process.cwd() + }; + const value = captured.env.PORT ?? '9000'; + const port = Number(value); + if (!/^\d+$/.test(value) || !Number.isInteger(port) || port > 65535) { + throw new Error('Invalid worker health port'); + } + const path = resolveConfigPath(captured); + const loadConfiguration = () => loadConfigFile(path, captured); + const prepared = await loadConfiguration(); + const databasePath = prepared.configuration.database.worker_path; + const resources: ShutdownResources = {}; + const stop = createShutdown(resources); + try { + await startWorkflowWorker(databasePath, resources); + const host = captured.env.HOST ?? '0.0.0.0'; + const healthPort = await startHealthServer(host, port, resources); + const source = { path, initial: prepared, load: loadConfiguration }; + const requestRestart = onRestartRequired ?? stop; + await watchConfiguration(source, resources, requestRestart); + return { port: healthPort, stop }; + } catch (error) { + await stop(); + throw error; + } +} + +async function startWorkflowWorker( + databasePath: string, + resources: ShutdownResources +): Promise { + const directory = dirname(databasePath); + await mkdir(directory, { recursive: true }); + const backend = BackendSqlite.connect(databasePath); + resources.backend = backend; + const engineLogger = createEngineLogger(logger); + const engine = new OpenWorkflow({ backend, logger: engineLogger }); + const workflowWorker = engine.newWorker(); + resources.workflowWorker = workflowWorker; + await workflowWorker.start(); + logger.info('Workflow worker started'); +} + +async function startHealthServer( + host: string, + port: number, + resources: ShutdownResources +): Promise { + const server = createHealthServer(); + resources.server = server; + const listening = once(server, 'listening'); + server.listen({ host, port }); + await listening; + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Expected worker health address'); + logger.info({ port: address.port }, 'Worker health server listening'); + return address.port; +} diff --git a/apps/worker/tsconfig.build.json b/apps/worker/tsconfig.build.json new file mode 100644 index 0000000..88a712d --- /dev/null +++ b/apps/worker/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "src/**/*.test.ts"] +} diff --git a/apps/worker/tsconfig.json b/apps/worker/tsconfig.json new file mode 100644 index 0000000..c785ed6 --- /dev/null +++ b/apps/worker/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "vitest.config.ts"], + "exclude": ["dist"] +} diff --git a/apps/worker/vitest.config.ts b/apps/worker/vitest.config.ts new file mode 100644 index 0000000..0e42110 --- /dev/null +++ b/apps/worker/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + environment: 'node', + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts'] + } + } +}); diff --git a/examples/goto.yaml b/examples/goto.yaml index 840711f..0c3a80e 100644 --- a/examples/goto.yaml +++ b/examples/goto.yaml @@ -18,6 +18,7 @@ auth: database: path: ./data/goto.sqlite + worker_path: ./data/worker.sqlite analytics: enabled: true diff --git a/package.json b/package.json index fd08e59..68e4fd4 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "scripts": { "dev": "pnpm build:packages && pnpm --filter @goto/web dev", + "dev:worker": "pnpm --filter @goto/worker dev", "cli": "pnpm build:packages && pnpm --filter @goto/cli build && node apps/cli/dist/index.js", "schema:generate": "pnpm --filter @goto/config schema:generate", "schema:check": "pnpm --filter @goto/config schema:check", @@ -20,7 +21,7 @@ "test": "pnpm -r test", "test:coverage": "pnpm -r test:coverage", "test:e2e": "pnpm --filter @goto/web test:e2e", - "verify": "pnpm schema:check && pnpm build && pnpm check && pnpm lint && pnpm test:coverage && pnpm test:e2e", + "verify": "pnpm schema:check && pnpm build && pnpm check && pnpm lint && pnpm test:coverage && pnpm test:e2e && pnpm --filter @goto/worker test:smoke", "changeset": "changeset", "version:apps": "changeset version", "tag:apps": "changeset git-tag" diff --git a/packages/config/package.json b/packages/config/package.json index 98860d9..92440c7 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -5,6 +5,7 @@ "type": "module", "exports": { ".": { + "development": "./src/index.ts", "types": "./dist/index.d.ts", "import": "./dist/index.js" }, diff --git a/packages/config/src/config.schema.json b/packages/config/src/config.schema.json index b822e63..8767d30 100644 --- a/packages/config/src/config.schema.json +++ b/packages/config/src/config.schema.json @@ -270,6 +270,17 @@ "pattern": "(?:^|[^$])(?:\\$\\$)*\\$\\{[A-Z_][A-Z0-9_]*(?::-[^}]*)?\\}" } ] + }, + "worker_path": { + "default": "./data/worker.sqlite", + "type": "string", + "anyOf": [ + { "type": "string", "pattern": "\\S", "minLength": 1 }, + { + "type": "string", + "pattern": "(?:^|[^$])(?:\\$\\$)*\\$\\{[A-Z_][A-Z0-9_]*(?::-[^}]*)?\\}" + } + ] } } }, diff --git a/packages/config/src/load.test.ts b/packages/config/src/load.test.ts index 586e3a4..c19f16a 100644 --- a/packages/config/src/load.test.ts +++ b/packages/config/src/load.test.ts @@ -22,7 +22,7 @@ describe('configuration loading', () => { expect(configuration).toMatchObject({ version: 1, site: { url: 'http://localhost:3000', title: 'Goto' }, - database: { path: './data/goto.sqlite' }, + database: { path: './data/goto.sqlite', worker_path: './data/worker.sqlite' }, analytics: { enabled: true, retention_days: 90 }, defaults: { status_code: 302, forward_query: false, fallback: 404 } }); @@ -246,7 +246,10 @@ describe('configuration loading', () => { join(configDirectory, 'custom.yaml'), JSON.stringify({ ...configurationInput(), - database: { path: './data/custom.sqlite' } + database: { + path: './data/custom.sqlite', + worker_path: './data/custom-worker.sqlite' + } }) ); const loaded = await loadConfigFile(undefined, { @@ -254,6 +257,9 @@ describe('configuration loading', () => { env: { CONFIG_PATH: 'deployment/config/custom.yaml' } }); expect(loaded.configuration.database.path).toBe(join(root, 'deployment/data/custom.sqlite')); + expect(loaded.configuration.database.worker_path).toBe( + join(root, 'deployment/data/custom-worker.sqlite') + ); const explicit = await loadConfigFile('deployment/config/custom.yaml', { cwd: root, env: { CONFIG_PATH: 'missing.yaml' } diff --git a/packages/config/src/load.ts b/packages/config/src/load.ts index e4a9b12..b0b2efb 100644 --- a/packages/config/src/load.ts +++ b/packages/config/src/load.ts @@ -23,10 +23,10 @@ export async function loadConfigFile( throw new ConfigError(); } const prepared = parseConfig(source, options); - prepared.configuration.database.path = resolve( - resolveDeploymentRoot(configPath), - prepared.configuration.database.path - ); + const deploymentRoot = resolveDeploymentRoot(configPath); + const database = prepared.configuration.database; + database.path = resolve(deploymentRoot, database.path); + database.worker_path = resolve(deploymentRoot, database.worker_path); return prepared; } diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index 7408057..9ba05a8 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -178,7 +178,8 @@ const AuthSchema = Type.Union( const DatabaseSchema = Type.Object( { - path: Type.String({ ...nonBlankText, default: './data/goto.sqlite' }) + path: Type.String({ ...nonBlankText, default: './data/goto.sqlite' }), + worker_path: Type.String({ ...nonBlankText, default: './data/worker.sqlite' }) }, { $id: 'Database', additionalProperties: false } ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 929f64d..b28d248 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,9 @@ catalogs: kysely: specifier: 0.28.17 version: 0.28.17 + openworkflow: + specifier: 0.11.0 + version: 0.11.0 piscina: specifier: 5.3.2 version: 5.3.2 @@ -75,6 +78,9 @@ catalogs: svelte-check: specifier: 4.7.7 version: 4.7.7 + tslog: + specifier: 5.2.0 + version: 5.2.0 typescript: specifier: 6.0.3 version: 6.0.3 @@ -207,6 +213,34 @@ importers: specifier: 'catalog:' version: 4.1.7(@types/node@24.12.4)(@vitest/coverage-v8@4.1.7)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)) + apps/worker: + dependencies: + '@goto/config': + specifier: workspace:* + version: link:../../packages/config + openworkflow: + specifier: 'catalog:' + version: 0.11.0 + tslog: + specifier: 'catalog:' + version: 5.2.0 + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 24.12.4 + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.1.7(vitest@4.1.7) + prettier: + specifier: 'catalog:' + version: 3.8.3 + typescript: + specifier: 'catalog:' + version: 6.0.3 + vitest: + specifier: 'catalog:' + version: 4.1.7(@types/node@24.12.4)(@vitest/coverage-v8@4.1.7)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)) + packages/config: dependencies: '@sinclair/typebox': @@ -1206,6 +1240,10 @@ packages: resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} engines: {node: '>=22'} + cron-parser@5.10.1: + resolution: {integrity: sha512-pKRrRagItwk9rGIStcUyMxFX34x56zoX3KDf9HJ4ttwkXIK1qxK63EvXvEDmlxI9AdPJ+Y7TEKRftRlW5eSS7A==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1588,6 +1626,10 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1632,6 +1674,18 @@ packages: resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} engines: {node: '>=12.20.0'} + openworkflow@0.11.0: + resolution: {integrity: sha512-fdDfRjuNMLV+p3svZShrV5i/ZPbBS8GruWLlthk8Ezt3ueYPwVKk0mcD+knAmQ4J9RASBsY5JqiatiLtEcwgVw==} + engines: {node: '>=22.22.0'} + peerDependencies: + '@opentelemetry/api': '1' + postgres: ^3.4.9 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + postgres: + optional: true + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1839,6 +1893,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tslog@5.2.0: + resolution: {integrity: sha512-bD0FpBIFZ1OInp7uzQmEn1SJe6feTkVTCudw/27ILuISbVZtTryXqauxyCWTa1I8ZgTm9OGQ6YrJPKM+6QTeJA==} + engines: {node: '>=22'} + hasBin: true + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -2783,6 +2842,10 @@ snapshots: cookie@2.0.1: {} + cron-parser@5.10.1: + dependencies: + luxon: 3.7.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -3133,6 +3196,8 @@ snapshots: dependencies: p-locate: 5.0.0 + luxon@3.7.2: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -3169,6 +3234,10 @@ snapshots: obug@2.2.1: {} + openworkflow@0.11.0: + dependencies: + cron-parser: 5.10.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -3373,6 +3442,8 @@ snapshots: tslib@2.8.1: optional: true + tslog@5.2.0: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1ff4fb8..332b003 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -21,11 +21,13 @@ catalog: globals: 17.6.0 gunshi: 0.35.1 kysely: 0.28.17 + openworkflow: 0.11.0 piscina: 5.3.2 prettier: 3.8.3 prettier-plugin-svelte: 3.5.2 svelte: 5.57.3 svelte-check: 4.7.7 + tslog: 5.2.0 typescript: 6.0.3 typescript-eslint: 8.60.0 vite: 8.0.16 diff --git a/tools/hooks.mjs b/tools/hooks.mjs new file mode 100644 index 0000000..3946444 --- /dev/null +++ b/tools/hooks.mjs @@ -0,0 +1,14 @@ +import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +export function resolve(specifier, context, nextResolve) { + const relative = specifier.startsWith('./') || specifier.startsWith('../'); + if (relative && specifier.endsWith('.js') && context.parentURL) { + const tsSpecifier = specifier.slice(0, -3) + '.ts'; + const tsUrl = new URL(tsSpecifier, context.parentURL); + if (existsSync(fileURLToPath(tsUrl))) { + return nextResolve(tsSpecifier, context); + } + } + return nextResolve(specifier, context); +} diff --git a/tools/register.mjs b/tools/register.mjs new file mode 100644 index 0000000..18a6273 --- /dev/null +++ b/tools/register.mjs @@ -0,0 +1,5 @@ +// Development only: Node strips types; this hook resolves .js source imports to .ts. +import { registerHooks } from 'node:module'; +import { resolve } from './hooks.mjs'; + +registerHooks({ resolve });