feat(worker): introduce the OpenWorkflow service foundation #11
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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@<version> 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 }}
|
||||
@@ -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@<version> 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]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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'
|
||||
);
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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<WorkerService> | undefined;
|
||||
let stopping: Promise<void> | undefined;
|
||||
let shutdownTimeout = 30_000;
|
||||
|
||||
function shutdown(): Promise<void> {
|
||||
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();
|
||||
}
|
||||
@@ -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<typeof import('@goto/config')>()),
|
||||
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();
|
||||
});
|
||||
@@ -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<PreparedConfiguration>;
|
||||
}
|
||||
|
||||
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<void>
|
||||
): Promise<void> {
|
||||
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();
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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<Logger, 'debug' | 'info' | 'warn' | 'error'>
|
||||
): 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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<void>();
|
||||
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();
|
||||
});
|
||||
@@ -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<WorkflowWorker, 'stop'>;
|
||||
server?: Server;
|
||||
backend?: Pick<BackendSqlite, 'stop'>;
|
||||
stopWatch?: () => void;
|
||||
}
|
||||
|
||||
export function createShutdown(resources: ShutdownResources): () => Promise<void> {
|
||||
let stopping: Promise<void> | undefined;
|
||||
return () => (stopping ??= stopResources(resources));
|
||||
}
|
||||
|
||||
async function stopResources(resources: ShutdownResources): Promise<void> {
|
||||
const failures: unknown[] = [];
|
||||
try {
|
||||
resources.stopWatch?.();
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
const serverClosed = new Promise<Error | undefined>((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];
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
export async function startWorkerService(
|
||||
options: ConfigOptions = {},
|
||||
onRestartRequired?: () => Promise<void>
|
||||
): Promise<WorkerService> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "vitest.config.ts"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
@@ -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']
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -18,6 +18,7 @@ auth:
|
||||
|
||||
database:
|
||||
path: ./data/goto.sqlite
|
||||
worker_path: ./data/worker.sqlite
|
||||
|
||||
analytics:
|
||||
enabled: true
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"development": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
|
||||
@@ -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_]*(?::-[^}]*)?\\}"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
|
||||
Generated
+71
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
Reference in new issue
Block a user