From 1baffe212d1621fb8268c0d83ec191f77693610f Mon Sep 17 00:00:00 2001 From: zetazzz Date: Wed, 7 Jan 2026 15:00:34 +0700 Subject: [PATCH 01/12] cnc jobs up and combined server --- jobs/knative-job-fn/src/index.ts | 2 +- packages/cli/package.json | 1 + packages/cli/src/commands.ts | 4 +- packages/cli/src/commands/jobs.ts | 216 ++++++++++++++++++++++++++++++ packages/cli/src/utils/display.ts | 4 + packages/server/CHANGELOG.md | 5 + packages/server/README.md | 77 +++++++++++ packages/server/jest.config.js | 18 +++ packages/server/package.json | 56 ++++++++ packages/server/src/index.ts | 3 + packages/server/src/run.ts | 96 +++++++++++++ packages/server/src/server.ts | 165 +++++++++++++++++++++++ packages/server/src/types.ts | 39 ++++++ packages/server/tsconfig.esm.json | 9 ++ packages/server/tsconfig.json | 9 ++ pnpm-lock.yaml | 83 +++++++----- 16 files changed, 749 insertions(+), 38 deletions(-) create mode 100644 packages/cli/src/commands/jobs.ts create mode 100644 packages/server/CHANGELOG.md create mode 100644 packages/server/README.md create mode 100644 packages/server/jest.config.js create mode 100644 packages/server/package.json create mode 100644 packages/server/src/index.ts create mode 100644 packages/server/src/run.ts create mode 100644 packages/server/src/server.ts create mode 100644 packages/server/src/types.ts create mode 100644 packages/server/tsconfig.esm.json create mode 100644 packages/server/tsconfig.json diff --git a/jobs/knative-job-fn/src/index.ts b/jobs/knative-job-fn/src/index.ts index 77befd2b1..ff09e1d88 100644 --- a/jobs/knative-job-fn/src/index.ts +++ b/jobs/knative-job-fn/src/index.ts @@ -263,6 +263,6 @@ export default { res.status(200).json({ message: error.message }); }); - app.listen(port, cb); + return app.listen(port, cb); } }; diff --git a/packages/cli/package.json b/packages/cli/package.json index 733ae6c9e..26f3e32a1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -50,6 +50,7 @@ "@constructive-io/graphql-env": "workspace:^", "@constructive-io/graphql-explorer": "workspace:^", "@constructive-io/graphql-server": "workspace:^", + "@constructive-io/server": "workspace:^", "@inquirerer/utils": "^3.1.2", "@pgpmjs/core": "workspace:^", "@pgpmjs/logger": "workspace:^", diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 3d28adb56..c96f60a38 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -6,6 +6,7 @@ import { ParsedArgs } from 'minimist'; import codegen from './commands/codegen'; import explorer from './commands/explorer'; import getGraphqlSchema from './commands/get-graphql-schema'; +import jobs from './commands/jobs'; import server from './commands/server'; import { usageText } from './utils'; @@ -14,7 +15,8 @@ const createCommandMap = (): Record => { server, explorer, 'get-graphql-schema': getGraphqlSchema, - codegen + codegen, + jobs }; }; diff --git a/packages/cli/src/commands/jobs.ts b/packages/cli/src/commands/jobs.ts new file mode 100644 index 000000000..084d349b8 --- /dev/null +++ b/packages/cli/src/commands/jobs.ts @@ -0,0 +1,216 @@ +import { existsSync } from 'fs'; +import { resolve } from 'path'; +import { + CombinedServer, + CombinedServerOptions, + FunctionName, + FunctionServiceConfig +} from '@constructive-io/server'; +import { cliExitWithError, extractFirst } from '@inquirerer/utils'; +import { CLIOptions, Inquirerer, Question } from 'inquirerer'; + +const jobsUsageText = ` +Constructive Jobs: + + cnc jobs [OPTIONS] + + Start or manage Constructive jobs services. + +Subcommands: + up Start combined server (jobs runtime) + +Options: + --help, -h Show this help message + --cwd Working directory (default: current directory) + --with-graphql-server Enable GraphQL server (default: disabled; flag-only) + --with-jobs-svc Enable jobs service (default: disabled; flag-only) + --functions Comma-separated functions, optionally with ports (e.g. "fn=8080") + +Examples: + cnc jobs up + cnc jobs up --cwd /path/to/constructive + cnc jobs up --with-graphql-server --functions simple-email,send-email-link=8082 +`; + +const questions: Question[] = [ + { + name: 'withGraphqlServer', + alias: 'with-graphql-server', + message: 'Enable GraphQL server?', + type: 'confirm', + required: false, + default: false, + useDefault: true + }, + { + name: 'withJobsSvc', + alias: 'with-jobs-svc', + message: 'Enable jobs service?', + type: 'confirm', + required: false, + default: false, + useDefault: true + } +]; + +const ensureCwd = (cwd: string): string => { + const resolved = resolve(cwd); + if (!existsSync(resolved)) { + throw new Error(`Working directory does not exist: ${resolved}`); + } + process.chdir(resolved); + return resolved; +}; + +type ParsedFunctionsArg = { + mode: 'all' | 'list'; + names: string[]; + ports: Record; +}; + +const parseFunctionsArg = (value: unknown): ParsedFunctionsArg | undefined => { + if (value === undefined) return undefined; + + const values = Array.isArray(value) ? value : [value]; + + const tokens: string[] = []; + for (const value of values) { + if (value === true) { + tokens.push('all'); + continue; + } + if (value === false || value === undefined || value === null) continue; + const raw = String(value); + raw + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + .forEach((part) => tokens.push(part)); + } + + if (!tokens.length) { + return { mode: 'list', names: [], ports: {} }; + } + + const hasAll = tokens.some((token) => { + const normalized = token.trim().toLowerCase(); + return normalized === 'all' || normalized === '*'; + }); + + if (hasAll) { + if (tokens.length > 1) { + throw new Error('Use "all" without other function names.'); + } + return { mode: 'all', names: [], ports: {} }; + } + + const names: string[] = []; + const ports: Record = {}; + + for (const token of tokens) { + const trimmed = token.trim(); + if (!trimmed) continue; + + const separatorIndex = trimmed.search(/[:=]/); + if (separatorIndex === -1) { + names.push(trimmed); + continue; + } + + const name = trimmed.slice(0, separatorIndex).trim(); + const portText = trimmed.slice(separatorIndex + 1).trim(); + + if (!name) { + throw new Error(`Missing function name in "${token}".`); + } + if (!portText) { + throw new Error(`Missing port for function "${name}".`); + } + + const port = Number(portText); + if (!Number.isFinite(port) || port <= 0) { + throw new Error(`Invalid port "${portText}" for function "${name}".`); + } + + names.push(name); + ports[name] = port; + } + + const uniqueNames: string[] = []; + const seen = new Set(); + for (const name of names) { + if (seen.has(name)) continue; + seen.add(name); + uniqueNames.push(name); + } + + return { mode: 'list', names: uniqueNames, ports }; +}; + +const buildCombinedServerOptions = ( + args: Partial> +): CombinedServerOptions => { + const parsedFunctions = parseFunctionsArg(args.functions); + + let functions: CombinedServerOptions['functions']; + if (parsedFunctions) { + if (parsedFunctions.mode === 'all') { + functions = { enabled: true }; + } else if (parsedFunctions.names.length) { + const services: FunctionServiceConfig[] = parsedFunctions.names.map( + (name) => ({ + name: name as FunctionName, + port: parsedFunctions.ports[name] + }) + ); + functions = { enabled: true, services }; + } else { + functions = undefined; + } + } + + return { + graphql: { enabled: args.withGraphqlServer === true }, + jobs: { enabled: args.withJobsSvc === true }, + functions + }; +}; + +export default async ( + argv: Partial>, + prompter: Inquirerer, + _options: CLIOptions +) => { + if (argv.help || argv.h) { + console.log(jobsUsageText); + process.exit(0); + } + + const { first: subcommand, newArgv } = extractFirst(argv); + const args = newArgv as Partial>; + + if (!subcommand) { + console.log(jobsUsageText); + await cliExitWithError('No subcommand provided. Use "up".'); + return; + } + + switch (subcommand) { + case 'up': { + try { + ensureCwd((args.cwd as string) || process.cwd()); + const promptAnswers = await prompter.prompt(args, questions); + await CombinedServer(buildCombinedServerOptions(promptAnswers)); + } catch (error) { + await cliExitWithError( + `Failed to start combined server: ${(error as Error).message}` + ); + } + break; + } + + default: + console.log(jobsUsageText); + await cliExitWithError(`Unknown subcommand: ${subcommand}. Use "up".`); + } +}; diff --git a/packages/cli/src/utils/display.ts b/packages/cli/src/utils/display.ts index 5c856dd2b..f25edc5bf 100644 --- a/packages/cli/src/utils/display.ts +++ b/packages/cli/src/utils/display.ts @@ -12,6 +12,9 @@ export const usageText = ` codegen Generate TypeScript types and SDK from GraphQL schema get-graphql-schema Fetch or build GraphQL schema SDL + Jobs: + jobs up Start combined server (jobs runtime) + Global Options: -h, --help Display this help information -v, --version Display version information @@ -27,6 +30,7 @@ export const usageText = ` cnc explorer Launch GraphiQL explorer cnc codegen --schema schema.graphql Generate types from schema cnc get-graphql-schema --out schema.graphql Export schema SDL + cnc jobs up Start combined server (jobs runtime) Database Operations: For database migrations, packages, and deployment, use pgpm: diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md new file mode 100644 index 000000000..c29286903 --- /dev/null +++ b/packages/server/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial combined server for GraphQL, jobs, and functions. diff --git a/packages/server/README.md b/packages/server/README.md new file mode 100644 index 000000000..60ee95a4e --- /dev/null +++ b/packages/server/README.md @@ -0,0 +1,77 @@ +# @constructive-io/server + +

+ +

+ +

+ + + + +

+ +**Constructive Combined Server** starts GraphQL, jobs runtime, and Knative-style functions from a single entrypoint. + +## Quick Start + +### Use as SDK + +```ts +import { CombinedServer } from '@constructive-io/server'; + +await CombinedServer({ + graphql: { enabled: true }, + functions: { + enabled: true, + services: [ + { name: 'simple-email', port: 8081 }, + { name: 'send-email-link', port: 8082 } + ] + }, + jobs: { enabled: true } +}); +``` + +### Local Development (this repo) + +```bash +pnpm install +cd packages/server +pnpm dev +``` + +## Environment Configuration + +The `src/run.ts` entrypoint reads a small set of env flags for quick local orchestration: + +| Env var | Purpose | Default | +| --- | --- | --- | +| `CONSTRUCTIVE_GRAPHQL_ENABLED` | Start the GraphQL server | `true` | +| `CONSTRUCTIVE_JOBS_ENABLED` | Start the jobs runtime | `false` | +| `CONSTRUCTIVE_FUNCTIONS` | Comma-separated function list or `all` | empty | +| `CONSTRUCTIVE_FUNCTION_PORTS` | Port map (`name=port,name=port`) | none | + +Examples: + +```bash +# Start GraphQL only +CONSTRUCTIVE_GRAPHQL_ENABLED=true pnpm dev + +# Start only the simple-email function +CONSTRUCTIVE_GRAPHQL_ENABLED=false \ +CONSTRUCTIVE_FUNCTIONS=simple-email \ +CONSTRUCTIVE_FUNCTION_PORTS=simple-email=8081 \ +pnpm dev + +# Start all functions + jobs +CONSTRUCTIVE_FUNCTIONS=all \ +CONSTRUCTIVE_JOBS_ENABLED=true \ +CONSTRUCTIVE_FUNCTION_PORTS=simple-email=8081,send-email-link=8082 \ +pnpm dev +``` + +## Default Function Ports + +- `simple-email`: `8081` +- `send-email-link`: `8082` diff --git a/packages/server/jest.config.js b/packages/server/jest.config.js new file mode 100644 index 000000000..057a9420e --- /dev/null +++ b/packages/server/jest.config.js @@ -0,0 +1,18 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + babelConfig: false, + tsconfig: 'tsconfig.json', + }, + ], + }, + transformIgnorePatterns: [`/node_modules/*`], + testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + modulePathIgnorePatterns: ['dist/*'] +}; diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 000000000..c54683654 --- /dev/null +++ b/packages/server/package.json @@ -0,0 +1,56 @@ +{ + "name": "@constructive-io/server", + "version": "0.1.0", + "author": "Constructive ", + "description": "Combined Constructive server for GraphQL, jobs, and functions", + "main": "index.js", + "module": "esm/index.js", + "types": "index.d.ts", + "homepage": "https://github.com/constructive-io/constructive", + "license": "MIT", + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "repository": { + "type": "git", + "url": "https://github.com/constructive-io/constructive" + }, + "bugs": { + "url": "https://github.com/constructive-io/constructive/issues" + }, + "scripts": { + "clean": "makage clean", + "prepack": "npm run build", + "build": "makage build", + "build:dev": "makage build --dev", + "dev": "ts-node src/run.ts", + "dev:watch": "nodemon --watch src --ext ts --exec ts-node src/run.ts", + "lint": "eslint . --fix", + "test": "jest --passWithNoTests", + "test:watch": "jest --watch" + }, + "keywords": [ + "server", + "constructive", + "graphql", + "jobs", + "functions", + "orchestrator" + ], + "dependencies": { + "@constructive-io/graphql-server": "workspace:^", + "@constructive-io/graphql-types": "workspace:^", + "@constructive-io/knative-job-fn": "workspace:^", + "@constructive-io/knative-job-service": "workspace:^", + "@constructive-io/send-email-link-fn": "workspace:^", + "@constructive-io/simple-email-fn": "workspace:^", + "@pgpmjs/env": "workspace:^", + "@pgpmjs/logger": "workspace:^" + }, + "devDependencies": { + "makage": "^0.1.10", + "nodemon": "^3.1.10", + "ts-node": "^10.9.2" + } +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts new file mode 100644 index 000000000..a53263507 --- /dev/null +++ b/packages/server/src/index.ts @@ -0,0 +1,3 @@ +export * from './server'; +export * from './types'; +export * from './run'; diff --git a/packages/server/src/run.ts b/packages/server/src/run.ts new file mode 100644 index 000000000..9665dec42 --- /dev/null +++ b/packages/server/src/run.ts @@ -0,0 +1,96 @@ +#!/usr/bin/env node + +import { parseEnvBoolean } from '@pgpmjs/env'; + +import { CombinedServer } from './server'; +import { + CombinedServerOptions, + CombinedServerResult, + FunctionName, + FunctionServiceConfig +} from './types'; + +const parseList = (value?: string): string[] => { + if (!value) return []; + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +}; + +const parsePortMap = (value?: string): Record => { + if (!value) return {}; + + const trimmed = value.trim(); + if (!trimmed) return {}; + + if (trimmed.startsWith('{')) { + try { + const parsed = JSON.parse(trimmed) as Record; + return Object.entries(parsed).reduce>((acc, [key, port]) => { + const portNumber = Number(port); + if (Number.isFinite(portNumber)) { + acc[key] = portNumber; + } + return acc; + }, {}); + } catch { + return {}; + } + } + + return trimmed.split(',').reduce>((acc, pair) => { + const [rawName, rawPort] = pair.split(/[:=]/).map((item) => item.trim()); + const port = Number(rawPort); + if (rawName && Number.isFinite(port)) { + acc[rawName] = port; + } + return acc; + }, {}); +}; + +const buildFunctionsOptions = (): CombinedServerOptions['functions'] => { + const rawFunctions = (process.env.CONSTRUCTIVE_FUNCTIONS || '').trim(); + if (!rawFunctions) return undefined; + + const portMap = parsePortMap(process.env.CONSTRUCTIVE_FUNCTION_PORTS); + const normalized = rawFunctions.toLowerCase(); + + if (normalized === 'all' || normalized === '*') { + return { enabled: true }; + } + + const names = parseList(rawFunctions) as FunctionName[]; + if (!names.length) return undefined; + + const services: FunctionServiceConfig[] = names.map((name) => ({ + name, + port: portMap[name] + })); + + return { + enabled: true, + services + }; +}; + +export const buildCombinedServerOptionsFromEnv = (): CombinedServerOptions => ({ + graphql: { + enabled: parseEnvBoolean(process.env.CONSTRUCTIVE_GRAPHQL_ENABLED) ?? true + }, + jobs: { + enabled: parseEnvBoolean(process.env.CONSTRUCTIVE_JOBS_ENABLED) ?? false + }, + functions: buildFunctionsOptions() +}); + +export const startCombinedServerFromEnv = async (): Promise => + CombinedServer(buildCombinedServerOptionsFromEnv()); + +if (require.main === module) { + void startCombinedServerFromEnv().catch((error) => { + // eslint-disable-next-line no-console + console.error('Combined server failed to start:', error); + process.exit(1); + }); +} diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts new file mode 100644 index 000000000..f1be8cc92 --- /dev/null +++ b/packages/server/src/server.ts @@ -0,0 +1,165 @@ +import { GraphQLServer } from '@constructive-io/graphql-server'; +import { bootJobs } from '@constructive-io/knative-job-service/dist/run'; +import { Logger } from '@pgpmjs/logger'; +import { createRequire } from 'module'; + +import { + CombinedServerOptions, + CombinedServerResult, + FunctionName, + FunctionServiceConfig, + FunctionsOptions, + StartedFunction +} from './types'; + +type FunctionRegistryEntry = { + moduleName: string; + defaultPort: number; +}; + +const functionRegistry: Record = { + 'simple-email': { + moduleName: '@constructive-io/simple-email-fn', + defaultPort: 8081 + }, + 'send-email-link': { + moduleName: '@constructive-io/send-email-link-fn', + defaultPort: 8082 + } +}; + +const log = new Logger('combined-server'); +const requireFn = createRequire(__filename); +const functionServers = new Map(); + +const resolveFunctionEntry = (name: FunctionName): FunctionRegistryEntry => { + const entry = functionRegistry[name]; + if (!entry) { + throw new Error(`Unknown function "${name}".`); + } + return entry; +}; + +const loadFunctionApp = (moduleName: string) => { + const knativeModuleId = requireFn.resolve('@constructive-io/knative-job-fn'); + delete requireFn.cache[knativeModuleId]; + + const moduleId = requireFn.resolve(moduleName); + delete requireFn.cache[moduleId]; + + const mod = requireFn(moduleName) as { default?: { listen: (port: number, cb?: () => void) => unknown } }; + const app = mod.default ?? mod; + + if (!app || typeof (app as { listen?: unknown }).listen !== 'function') { + throw new Error(`Function module "${moduleName}" does not export a listenable app.`); + } + + return app as { listen: (port: number, cb?: () => void) => unknown }; +}; + +const shouldEnableFunctions = (options?: FunctionsOptions): boolean => { + if (!options) return false; + if (typeof options.enabled === 'boolean') return options.enabled; + return Boolean(options.services?.length); +}; + +const normalizeFunctionServices = ( + options?: FunctionsOptions +): FunctionServiceConfig[] => { + if (!shouldEnableFunctions(options)) return []; + + if (!options?.services?.length) { + return Object.keys(functionRegistry).map((name) => ({ + name: name as FunctionName + })); + } + + return options.services; +}; + +const resolveFunctionPort = (service: FunctionServiceConfig): number => { + const entry = resolveFunctionEntry(service.name); + return service.port ?? entry.defaultPort; +}; + +const ensureUniquePorts = (services: FunctionServiceConfig[]) => { + const usedPorts = new Set(); + for (const service of services) { + const port = resolveFunctionPort(service); + if (usedPorts.has(port)) { + throw new Error(`Function port ${port} is assigned more than once.`); + } + usedPorts.add(port); + } +}; + +const startFunction = async ( + service: FunctionServiceConfig +): Promise => { + const entry = resolveFunctionEntry(service.name); + const port = resolveFunctionPort(service); + const app = loadFunctionApp(entry.moduleName); + + await new Promise((resolve, reject) => { + const server = app.listen(port, () => { + log.info(`function:${service.name} listening on ${port}`); + resolve(); + }) as { on?: (event: string, cb: (err: Error) => void) => void }; + + if (server?.on) { + server.on('error', (err) => { + log.error(`function:${service.name} failed to start`, err); + reject(err); + }); + } + + functionServers.set(service.name, server); + }); + + return { name: service.name, port }; +}; + +export const startFunctions = async ( + options?: FunctionsOptions +): Promise => { + const services = normalizeFunctionServices(options); + if (!services.length) return []; + + ensureUniquePorts(services); + + const started: StartedFunction[] = []; + for (const service of services) { + started.push(await startFunction(service)); + } + + return started; +}; + +export const CombinedServer = async ( + options: CombinedServerOptions = {} +): Promise => { + const result: CombinedServerResult = { + functions: [], + jobs: false, + graphql: false + }; + + if (options.graphql?.enabled) { + log.info('starting GraphQL server'); + GraphQLServer(options.graphql.options ?? {}); + result.graphql = true; + } + + if (shouldEnableFunctions(options.functions)) { + log.info('starting functions'); + result.functions = await startFunctions(options.functions); + } + + if (options.jobs?.enabled) { + log.info('starting jobs service'); + await bootJobs(); + result.jobs = true; + } + + return result; +}; diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts new file mode 100644 index 000000000..13753afcd --- /dev/null +++ b/packages/server/src/types.ts @@ -0,0 +1,39 @@ +import { ConstructiveOptions } from '@constructive-io/graphql-types'; + +export type FunctionName = 'simple-email' | 'send-email-link'; + +export type FunctionServiceConfig = { + name: FunctionName; + port?: number; +}; + +export type FunctionsOptions = { + enabled?: boolean; + services?: FunctionServiceConfig[]; +}; + +export type JobsOptions = { + enabled?: boolean; +}; + +export type GraphqlOptions = { + enabled?: boolean; + options?: ConstructiveOptions; +}; + +export type CombinedServerOptions = { + functions?: FunctionsOptions; + jobs?: JobsOptions; + graphql?: GraphqlOptions; +}; + +export type StartedFunction = { + name: FunctionName; + port: number; +}; + +export type CombinedServerResult = { + functions: StartedFunction[]; + jobs: boolean; + graphql: boolean; +}; diff --git a/packages/server/tsconfig.esm.json b/packages/server/tsconfig.esm.json new file mode 100644 index 000000000..800d7506d --- /dev/null +++ b/packages/server/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist/esm", + "module": "es2022", + "rootDir": "src/", + "declaration": false + } +} diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 000000000..1a9d5696c --- /dev/null +++ b/packages/server/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src/" + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules", "**/*.spec.*", "**/*.test.*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0aeed8c30..df791ed8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -133,7 +133,7 @@ importers: version: 3.1.11 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.0.3)(typescript@5.9.3) + version: 10.9.2(@types/node@20.19.27)(typescript@5.9.3) publishDirectory: dist graphile/graphile-i18n: @@ -560,7 +560,7 @@ importers: version: 3.1.11 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.0.3)(typescript@5.9.3) + version: 10.9.2(@types/node@20.19.27)(typescript@5.9.3) publishDirectory: dist graphile/graphile-simple-inflector: @@ -829,7 +829,7 @@ importers: version: 3.1.11 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.0.3)(typescript@5.9.3) + version: 10.9.2(@types/node@20.19.27)(typescript@5.9.3) publishDirectory: dist graphql/gql-ast: @@ -1023,7 +1023,7 @@ importers: version: 3.1.11 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.0.3)(typescript@5.9.3) + version: 10.9.2(@types/node@20.19.27)(typescript@5.9.3) publishDirectory: dist graphql/test: @@ -1203,7 +1203,7 @@ importers: devDependencies: ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.0.3)(typescript@5.9.3) + version: 10.9.2(@types/node@20.19.27)(typescript@5.9.3) jobs/knative-job-worker: dependencies: @@ -1241,6 +1241,9 @@ importers: '@constructive-io/graphql-server': specifier: workspace:^ version: link:../../graphql/server/dist + '@constructive-io/server': + specifier: workspace:^ + version: link:../server/dist '@inquirerer/utils': specifier: ^3.1.2 version: 3.1.2 @@ -1383,6 +1386,44 @@ importers: version: 0.1.10 publishDirectory: dist + packages/server: + dependencies: + '@constructive-io/graphql-server': + specifier: workspace:^ + version: link:../../graphql/server/dist + '@constructive-io/graphql-types': + specifier: workspace:^ + version: link:../../graphql/types/dist + '@constructive-io/knative-job-fn': + specifier: workspace:^ + version: link:../../jobs/knative-job-fn + '@constructive-io/knative-job-service': + specifier: workspace:^ + version: link:../../jobs/knative-job-service + '@constructive-io/send-email-link-fn': + specifier: workspace:^ + version: link:../../functions/send-email-link + '@constructive-io/simple-email-fn': + specifier: workspace:^ + version: link:../../functions/simple-email + '@pgpmjs/env': + specifier: workspace:^ + version: link:../../pgpm/env/dist + '@pgpmjs/logger': + specifier: workspace:^ + version: link:../../pgpm/logger/dist + devDependencies: + makage: + specifier: ^0.1.10 + version: 0.1.10 + nodemon: + specifier: ^3.1.10 + version: 3.1.11 + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@20.19.27)(typescript@5.9.3) + publishDirectory: dist + packages/server-utils: dependencies: '@pgpmjs/logger': @@ -1412,7 +1453,7 @@ importers: version: 0.1.10 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.0.3)(typescript@5.9.3) + version: 10.9.2(@types/node@20.19.27)(typescript@5.9.3) publishDirectory: dist packages/url-domains: @@ -3440,9 +3481,6 @@ packages: '@types/node@20.19.27': resolution: {integrity: sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==} - '@types/node@25.0.3': - resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} - '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -7765,9 +7803,6 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - undici@7.16.0: resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} engines: {node: '>=20.18.1'} @@ -10332,10 +10367,6 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@25.0.3': - dependencies: - undici-types: 7.16.0 - '@types/normalize-package-data@2.4.4': {} '@types/pg-copy-streams@1.2.5': @@ -15537,24 +15568,6 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - ts-node@10.9.2(@types/node@25.0.3)(typescript@5.9.3): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.12 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 25.0.3 - acorn: 8.15.0 - acorn-walk: 8.3.4 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 - make-error: 1.3.6 - typescript: 5.9.3 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - tsconfig-paths@4.2.0: dependencies: json5: 2.2.3 @@ -15630,8 +15643,6 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.16.0: {} - undici@7.16.0: {} unique-filename@3.0.0: From 6230fbc8fa9084ac2afd0ece3226dd0162435511 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Thu, 8 Jan 2026 08:59:14 +0700 Subject: [PATCH 02/12] E2E Test cloud fns --- .github/workflows/jobs-e2e.yaml | 140 ++++++++++++++ packages/server/__tests__/jobs.e2e.test.ts | 215 +++++++++++++++++++++ packages/server/package.json | 4 + pnpm-lock.yaml | 135 +++++++++++++ 4 files changed, 494 insertions(+) create mode 100644 .github/workflows/jobs-e2e.yaml create mode 100644 packages/server/__tests__/jobs.e2e.test.ts diff --git a/.github/workflows/jobs-e2e.yaml b/.github/workflows/jobs-e2e.yaml new file mode 100644 index 000000000..d6173a78f --- /dev/null +++ b/.github/workflows/jobs-e2e.yaml @@ -0,0 +1,140 @@ +name: Jobs E2E +on: + push: + branches: + - main + - v1 + pull_request: + branches: + - main + - v1 + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-jobs-e2e + cancel-in-progress: true + +jobs: + jobs-e2e: + runs-on: ubuntu-latest + + env: + PGHOST: localhost + PGPORT: 5432 + PGUSER: postgres + PGPASSWORD: password + PGDATABASE: launchql + TEST_DB: launchql + TEST_GRAPHQL_URL: http://127.0.0.1:3000/graphql + TEST_GRAPHQL_HOST: admin.localhost + + services: + pg_db: + image: pyramation/pgvector:13.3-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - name: Configure Git (for tests) + run: | + git config --global user.name "CI Test User" + git config --global user.email "ci@example.com" + + - name: checkout + uses: actions/checkout@v4 + + - name: checkout constructive-db + uses: actions/checkout@v4 + with: + repository: constructive-io/constructive-db + path: constructive-db + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 10 + + - name: Install dependencies + run: pnpm install + + - name: Build packages + run: pnpm run build + + - name: Setup jobs database + run: | + PGDATABASE=postgres createdb launchql || true + pnpm --filter pgpm exec pgpm admin-users bootstrap --yes --cwd constructive-db + pnpm --filter pgpm exec pgpm admin-users add --test --yes --cwd constructive-db + pnpm --filter pgpm exec pgpm deploy --yes --database "$PGDATABASE" --package app-svc-local --cwd constructive-db + pnpm --filter pgpm exec pgpm deploy --yes --database "$PGDATABASE" --package metaschema --cwd constructive-db + pnpm --filter pgpm exec pgpm deploy --yes --database "$PGDATABASE" --package pgpm-database-jobs --cwd constructive-db + + - name: Resolve database id + run: | + DBID=$(psql -h "$PGHOST" -U "$PGUSER" -d "$PGDATABASE" -Atc "SELECT id FROM metaschema_public.database ORDER BY created_at LIMIT 1;") + if [ -z "$DBID" ]; then + echo "No database id found in metaschema_public.database" >&2 + exit 1 + fi + echo "TEST_DATABASE_ID=$DBID" >> "$GITHUB_ENV" + echo "DEFAULT_DATABASE_ID=$DBID" >> "$GITHUB_ENV" + + - name: Start combined server + env: + NODE_ENV: test + PORT: "3000" + SERVER_HOST: "127.0.0.1" + API_ENABLE_META: "false" + API_EXPOSED_SCHEMAS: "app_jobs,lql_private,lql_public,lql_roles_public,metaschema_modules_public,metaschema_public,services_public" + API_ANON_ROLE: "administrator" + API_ROLE_NAME: "administrator" + API_DEFAULT_DATABASE_ID: ${{ env.DEFAULT_DATABASE_ID }} + CONSTRUCTIVE_GRAPHQL_ENABLED: "true" + CONSTRUCTIVE_JOBS_ENABLED: "true" + CONSTRUCTIVE_FUNCTIONS: "simple-email,send-email-link" + CONSTRUCTIVE_FUNCTION_PORTS: "simple-email:8081,send-email-link:8082" + SIMPLE_EMAIL_DRY_RUN: "true" + SEND_EMAIL_LINK_DRY_RUN: "true" + LOCAL_APP_PORT: "3000" + GRAPHQL_URL: "http://127.0.0.1:3000/graphql" + META_GRAPHQL_URL: "http://127.0.0.1:3000/graphql" + GRAPHQL_HOST_HEADER: "admin.localhost" + META_GRAPHQL_HOST_HEADER: "admin.localhost" + MAILGUN_DOMAIN: "mg.constructive.io" + MAILGUN_FROM: "no-reply@mg.constructive.io" + MAILGUN_REPLY: "info@mg.constructive.io" + MAILGUN_API_KEY: "change-me-mailgun-api-key" + MAILGUN_KEY: "change-me-mailgun-api-key" + JOBS_SUPPORT_ANY: "false" + JOBS_SUPPORTED: "simple-email,send-email-link" + INTERNAL_GATEWAY_DEVELOPMENT_MAP: '{"simple-email":"http://127.0.0.1:8081","send-email-link":"http://127.0.0.1:8082"}' + INTERNAL_JOBS_CALLBACK_PORT: "8080" + JOBS_CALLBACK_BASE_URL: "http://127.0.0.1:8080/callback" + FEATURES_POSTGIS: "false" + run: | + nohup node packages/server/dist/run.js > /tmp/combined-server.log 2>&1 & + echo $! > /tmp/combined-server.pid + + - name: Test server jobs e2e + run: pnpm --filter @constructive-io/server test + + - name: Stop combined server + if: always() + run: | + if [ -f /tmp/combined-server.pid ]; then + kill "$(cat /tmp/combined-server.pid)" || true + fi diff --git a/packages/server/__tests__/jobs.e2e.test.ts b/packages/server/__tests__/jobs.e2e.test.ts new file mode 100644 index 000000000..3a39f207c --- /dev/null +++ b/packages/server/__tests__/jobs.e2e.test.ts @@ -0,0 +1,215 @@ +import supertest from 'supertest'; + +import { getConnections } from '@constructive-io/graphql-test'; + +jest.setTimeout(120000); + +const delay = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +type GraphqlClient = { + http: ReturnType; + path: string; + host?: string; +}; + +const getGraphqlClient = (): GraphqlClient => { + const rawUrl = + process.env.TEST_GRAPHQL_URL || + process.env.GRAPHQL_URL || + 'http://localhost:3000/graphql'; + const parsed = new URL(rawUrl); + const origin = `${parsed.protocol}//${parsed.host}`; + const path = + parsed.pathname === '/' ? '/graphql' : `${parsed.pathname}${parsed.search}`; + const host = process.env.TEST_GRAPHQL_HOST || process.env.GRAPHQL_HOST; + + return { + http: supertest(origin), + path, + host + }; +}; + +const sendGraphql = async ( + client: GraphqlClient, + query: string, + variables?: Record +) => { + let req = client.http + .post(client.path) + .set('Content-Type', 'application/json'); + if (client.host) { + req = req.set('Host', client.host); + } + return req.send({ query, variables }); +}; + +const addJobMutation = ` + mutation AddJob($input: AddJobInput!) { + addJob(input: $input) { + job { + id + } + } + } +`; + +const jobByIdQuery = ` + query JobById($id: BigInt!) { + job(id: $id) { + id + lastError + attempts + } + } +`; + +const unwrapGraphqlData = ( + response: supertest.Response, + label: string +): T => { + if (response.status !== 200) { + throw new Error(`${label} failed: HTTP ${response.status}`); + } + if (response.body?.errors?.length) { + throw new Error( + `${label} failed: ${response.body.errors + .map((err: { message: string }) => err.message) + .join('; ')}` + ); + } + if (!response.body?.data) { + throw new Error(`${label} returned no data`); + } + return response.body.data as T; +}; + +const getJobById = async ( + client: GraphqlClient, + jobId: string | number +) => { + const response = await sendGraphql(client, jobByIdQuery, { + id: String(jobId) + }); + const data = unwrapGraphqlData<{ job: { lastError?: string | null; attempts?: number } | null }>( + response, + 'Job query' + ); + return data.job; +}; + +const waitForJobCompletion = async ( + client: GraphqlClient, + jobId: string | number +) => { + const timeoutMs = 30000; + const started = Date.now(); + + while (Date.now() - started < timeoutMs) { + const job = await getJobById(client, jobId); + + if (!job) return; + + if (job.lastError) { + const attempts = job.attempts ?? 0; + throw new Error(`Job ${jobId} failed after ${attempts} attempt(s): ${job.lastError}`); + } + + await delay(250); + } + + throw new Error(`Job ${jobId} did not complete within ${timeoutMs}ms`); +}; + +describe('jobs e2e', () => { + let teardown: () => Promise; + let graphqlClient: GraphqlClient; + let databaseId = ''; + let pg: { oneOrNone?: (query: string, values?: unknown[]) => Promise } | undefined; + + beforeAll(async () => { + const targetDb = process.env.TEST_DB || process.env.PGDATABASE; + if (!targetDb) { + throw new Error('TEST_DB or PGDATABASE must point at the jobs database'); + } + process.env.TEST_DB = targetDb; + + ({ teardown, pg } = await getConnections( + { + schemas: ['app_jobs'], + authRole: 'administrator' + } + )); + + graphqlClient = getGraphqlClient(); + databaseId = process.env.TEST_DATABASE_ID ?? ''; + if (!databaseId && pg?.oneOrNone) { + const row = await pg.oneOrNone<{ id: string }>( + 'SELECT id FROM metaschema_public.database ORDER BY created_at LIMIT 1' + ); + databaseId = row?.id ?? ''; + } + if (!databaseId) { + throw new Error('TEST_DATABASE_ID is required or metaschema_public.database must contain a row'); + } + process.env.TEST_DATABASE_ID = databaseId; + }); + + afterAll(async () => { + if (teardown) { + await teardown(); + } + }); + + it('creates and processes a simple-email job', async () => { + const jobInput = { + dbId: databaseId, + identifier: 'simple-email', + payload: { + to: 'user@example.com', + subject: 'Jobs e2e', + html: '

jobs test

' + } + }; + + const response = await sendGraphql(graphqlClient, addJobMutation, { + input: jobInput + }); + + expect(response.status).toBe(200); + expect(response.body?.errors).toBeUndefined(); + + const jobId = response.body?.data?.addJob?.job?.id; + + expect(jobId).toBeTruthy(); + + await waitForJobCompletion(graphqlClient, jobId); + }); + + it('creates and processes a send-email-link job', async () => { + const jobInput = { + dbId: databaseId, + identifier: 'send-email-link', + payload: { + email_type: 'invite_email', + email: 'user@example.com', + invite_token: 'invite123', + sender_id: '00000000-0000-0000-0000-000000000000' + } + }; + + const response = await sendGraphql(graphqlClient, addJobMutation, { + input: jobInput + }); + + expect(response.status).toBe(200); + expect(response.body?.errors).toBeUndefined(); + + const jobId = response.body?.data?.addJob?.job?.id; + + expect(jobId).toBeTruthy(); + + await waitForJobCompletion(graphqlClient, jobId); + }); +}); diff --git a/packages/server/package.json b/packages/server/package.json index c54683654..9311cb050 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -49,8 +49,12 @@ "@pgpmjs/logger": "workspace:^" }, "devDependencies": { + "@constructive-io/graphql-test": "workspace:^", + "@types/supertest": "^6.0.3", "makage": "^0.1.10", "nodemon": "^3.1.10", + "pgsql-test": "workspace:^", + "supertest": "^7.2.2", "ts-node": "^10.9.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df791ed8d..c4a922126 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1413,12 +1413,24 @@ importers: specifier: workspace:^ version: link:../../pgpm/logger/dist devDependencies: + '@constructive-io/graphql-test': + specifier: workspace:^ + version: link:../../graphql/test/dist + '@types/supertest': + specifier: ^6.0.3 + version: 6.0.3 makage: specifier: ^0.1.10 version: 0.1.10 nodemon: specifier: ^3.1.10 version: 3.1.11 + pgsql-test: + specifier: workspace:^ + version: link:../../postgres/pgsql-test/dist + supertest: + specifier: ^7.2.2 + version: 7.2.2 ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@20.19.27)(typescript@5.9.3) @@ -2781,6 +2793,10 @@ packages: '@napi-rs/wasm-runtime@0.2.4': resolution: {integrity: sha512-9zESzOO5aDByvhIAsOy9TbpZ0Ur2AJbUI7UT73kcUTS2mxAMHOBaa1st/jAymNoCtvrit99kkzT1FZuXVcgfIQ==} + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2974,6 +2990,9 @@ packages: '@one-ini/wasm@0.1.1': resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@pgsql/types@17.6.2': resolution: {integrity: sha512-1UtbELdbqNdyOShhrVfSz3a1gDi0s9XXiQemx+6QqtsrXe62a6zOGU+vjb2GRfG5jeEokI1zBBcfD42enRv0Rw==} @@ -3394,6 +3413,9 @@ packages: '@types/content-disposition@0.5.9': resolution: {integrity: sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==} + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/cookies@0.9.2': resolution: {integrity: sha512-1AvkDdZM2dbyFybL4fxpuNCaWyv//0AwsuUk2DWeXyM1/5ZKm6W3z6mQi24RZ4l2ucY+bkSHzbDVpySqPGuV8A==} @@ -3466,6 +3488,9 @@ packages: '@types/koa@3.0.1': resolution: {integrity: sha512-VkB6WJUQSe0zBpR+Q7/YIUESGp5wPHcaXr0xueU5W0EOUWtlSbblsl+Kl31lyRQ63nIILh0e/7gXjQ09JXJIHw==} + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + '@types/minimatch@3.0.5': resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==} @@ -3517,6 +3542,12 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/superagent@8.1.9': + resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} + + '@types/supertest@6.0.3': + resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/testing-library__jest-dom@5.14.9': resolution: {integrity: sha512-FSYhIjFlfOpGSRyVoMBMuS3ws5ehFQODymf3vlI7U1K8c7PHwWwFY7VREfmsuzHSOnoKs/9/Y983ayOs7eRzqw==} @@ -3831,6 +3862,9 @@ packages: resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==} engines: {node: '>=8'} + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + asn1@0.2.6: resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} @@ -4237,6 +4271,9 @@ packages: compare-func@2.0.0: resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -4300,6 +4337,9 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + copyfiles@2.4.1: resolution: {integrity: sha512-fereAvAvxDrQDOXybk3Qu3dPbOoKoysFMWtkY3mv5BsL8//OSZVL5DCLYqgRfY5cWirgRzlC+WSrxp6Bo3eNZg==} hasBin: true @@ -4517,6 +4557,9 @@ packages: detect-node@2.1.0: resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + dicer@0.3.0: resolution: {integrity: sha512-MdceRRWqltEG2dZqO769g27N/3PXfcKl04VhYnBlo2YhH7zPi88VebsjTKclaOyiuMaGU72hTfw3VkUitGcVCA==} engines: {node: '>=4.5.0'} @@ -4969,6 +5012,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} @@ -5071,6 +5117,10 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -6237,6 +6287,10 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -7094,6 +7148,10 @@ packages: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} + qs@6.14.1: + resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} + engines: {node: '>=0.6'} + qs@6.5.3: resolution: {integrity: sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==} engines: {node: '>=0.6'} @@ -7559,6 +7617,14 @@ packages: peerDependencies: graphql: '>=0.10.0' + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -9426,6 +9492,8 @@ snapshots: '@emnapi/runtime': 1.7.1 '@tybys/wasm-util': 0.9.0 + '@noble/hashes@1.8.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -9677,6 +9745,10 @@ snapshots: '@one-ini/wasm@0.1.1': {} + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + '@pgsql/types@17.6.2': {} '@pgsql/utils@17.8.9': @@ -10255,6 +10327,8 @@ snapshots: '@types/content-disposition@0.5.9': {} + '@types/cookiejar@2.1.5': {} + '@types/cookies@0.9.2': dependencies: '@types/connect': 3.4.38 @@ -10353,6 +10427,8 @@ snapshots: '@types/koa-compose': 3.2.9 '@types/node': 20.19.27 + '@types/methods@1.1.4': {} + '@types/minimatch@3.0.5': {} '@types/minimist@1.2.5': {} @@ -10410,6 +10486,18 @@ snapshots: '@types/stack-utils@2.0.3': {} + '@types/superagent@8.1.9': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 20.19.27 + form-data: 4.0.5 + + '@types/supertest@6.0.3': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.9 + '@types/testing-library__jest-dom@5.14.9': dependencies: '@types/jest': 30.0.0 @@ -10707,6 +10795,8 @@ snapshots: arrify@2.0.1: {} + asap@2.0.6: {} + asn1@0.2.6: dependencies: safer-buffer: 2.1.2 @@ -11206,6 +11296,8 @@ snapshots: array-ify: 1.0.0 dot-prop: 5.3.0 + component-emitter@1.3.1: {} + concat-map@0.0.1: {} concat-stream@2.0.0: @@ -11284,6 +11376,8 @@ snapshots: cookie@0.7.2: {} + cookiejar@2.1.4: {} + copyfiles@2.4.1: dependencies: glob: 7.2.3 @@ -11475,6 +11569,11 @@ snapshots: detect-node@2.1.0: {} + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + dicer@0.3.0: dependencies: streamsearch: 0.1.2 @@ -11889,6 +11988,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-safe-stringify@2.1.1: {} + fast-uri@3.1.0: {} fast-xml-parser@5.2.5: @@ -12005,6 +12106,12 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + forwarded@0.2.0: {} fresh@2.0.0: {} @@ -13713,6 +13820,8 @@ snapshots: merge2@1.4.1: {} + methods@1.1.2: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -14865,6 +14974,10 @@ snapshots: dependencies: side-channel: 1.1.0 + qs@6.14.1: + dependencies: + side-channel: 1.1.0 + qs@6.5.3: {} qs@6.7.0: {} @@ -15405,6 +15518,28 @@ snapshots: - bufferutil - utf-8-validate + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3(supports-color@5.5.0) + fast-safe-stringify: 2.1.1 + form-data: 4.0.5 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.14.1 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + supports-color@5.5.0: dependencies: has-flag: 3.0.0 From 6fe4a31d618db3d478ad9c73e258a2d575b07585 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Thu, 8 Jan 2026 15:17:45 +0700 Subject: [PATCH 03/12] refactor test on combined server --- functions/send-email-link/src/index.ts | 3 +- functions/simple-email/src/index.ts | 3 +- graphql/server/src/server.ts | 102 +++++- jobs/job-scheduler/src/index.ts | 71 +++- jobs/knative-job-example/src/index.ts | 4 +- jobs/knative-job-fn/src/index.ts | 272 ++++++++------- jobs/knative-job-worker/src/index.ts | 68 +++- packages/cli/src/commands/jobs.ts | 3 +- packages/server/README.md | 5 +- packages/server/__fixtures__/jobs.seed.sql | 67 ++++ packages/server/__tests__/jobs.e2e.test.ts | 384 +++++++++++++++++++-- packages/server/package.json | 13 + packages/server/src/run.ts | 6 +- packages/server/src/server.ts | 198 +++++++++-- pnpm-lock.yaml | 92 +++++ 15 files changed, 1081 insertions(+), 210 deletions(-) create mode 100644 packages/server/__fixtures__/jobs.seed.sql diff --git a/functions/send-email-link/src/index.ts b/functions/send-email-link/src/index.ts index f7b7e61ce..886ab0e27 100644 --- a/functions/send-email-link/src/index.ts +++ b/functions/send-email-link/src/index.ts @@ -1,4 +1,4 @@ -import app from '@constructive-io/knative-job-fn'; +import { createJobApp } from '@constructive-io/knative-job-fn'; import { GraphQLClient } from 'graphql-request'; import gql from 'graphql-tag'; import { generate } from '@launchql/mjml'; @@ -6,6 +6,7 @@ import { send } from '@launchql/postmaster'; import { parseEnvBoolean } from '@pgpmjs/env'; const isDryRun = parseEnvBoolean(process.env.SEND_EMAIL_LINK_DRY_RUN) ?? false; +const app = createJobApp(); const GetUser = gql` query GetUser($userId: UUID!) { diff --git a/functions/simple-email/src/index.ts b/functions/simple-email/src/index.ts index 736423e0a..2227fbd34 100644 --- a/functions/simple-email/src/index.ts +++ b/functions/simple-email/src/index.ts @@ -1,4 +1,4 @@ -import app from '@constructive-io/knative-job-fn'; +import { createJobApp } from '@constructive-io/knative-job-fn'; import { parseEnvBoolean } from '@pgpmjs/env'; import { send as sendEmail } from '@launchql/postmaster'; @@ -26,6 +26,7 @@ const getRequiredField = ( }; const isDryRun = parseEnvBoolean(process.env.SIMPLE_EMAIL_DRY_RUN) ?? false; +const app = createJobApp(); app.post('/', async (req: any, res: any, next: any) => { try { diff --git a/graphql/server/src/server.ts b/graphql/server/src/server.ts index 70a234b75..db89c9827 100644 --- a/graphql/server/src/server.ts +++ b/graphql/server/src/server.ts @@ -1,13 +1,15 @@ import { getEnvOptions, getNodeEnv } from '@constructive-io/graphql-env'; import { Logger } from '@pgpmjs/logger'; -import { healthz, poweredBy, trustProxy } from '@pgpmjs/server-utils'; +import { healthz, poweredBy, svcCache, trustProxy } from '@pgpmjs/server-utils'; import { PgpmOptions } from '@pgpmjs/types'; import { middleware as parseDomains } from '@constructive-io/url-domains'; import express, { Express, RequestHandler } from 'express'; +import type { Server as HttpServer } from 'http'; // @ts-ignore import graphqlUpload from 'graphql-upload'; import { Pool, PoolClient } from 'pg'; -import { getPgPool } from 'pg-cache'; +import { graphileCache } from 'graphile-cache'; +import { getPgPool, pgCache } from 'pg-cache'; import requestIp from 'request-ip'; import { createApiMiddleware } from './middleware/api'; @@ -29,28 +31,34 @@ export const GraphQLServer = (rawOpts: PgpmOptions = {}) => { class Server { private app: Express; private opts: PgpmOptions; + private listenClient: PoolClient | null = null; + private listenRelease: (() => void) | null = null; + private shuttingDown = false; + private closed = false; + private httpServer: HttpServer | null = null; constructor(opts: PgpmOptions) { this.opts = getEnvOptions(opts); + const effectiveOpts = this.opts; const app = express(); - const api = createApiMiddleware(opts); - const authenticate = createAuthenticateMiddleware(opts); + const api = createApiMiddleware(effectiveOpts); + const authenticate = createAuthenticateMiddleware(effectiveOpts); // Log startup config in dev mode if (isDev()) { log.debug( - `Database: ${opts.pg?.database}@${opts.pg?.host}:${opts.pg?.port}` + `Database: ${effectiveOpts.pg?.database}@${effectiveOpts.pg?.host}:${effectiveOpts.pg?.port}` ); log.debug( - `Meta schemas: ${(opts as any).api?.metaSchemas?.join(', ') || 'default'}` + `Meta schemas: ${(effectiveOpts as any).api?.metaSchemas?.join(', ') || 'default'}` ); } healthz(app); - trustProxy(app, opts.server.trustProxy); + trustProxy(app, effectiveOpts.server.trustProxy); // Warn if a global CORS override is set in production - const fallbackOrigin = opts.server?.origin?.trim(); + const fallbackOrigin = effectiveOpts.server?.origin?.trim(); if (fallbackOrigin && process.env.NODE_ENV === 'production') { if (fallbackOrigin === '*') { log.warn( @@ -70,13 +78,13 @@ class Server { app.use(requestIp.mw()); app.use(api); app.use(authenticate); - app.use(graphile(opts)); + app.use(graphile(effectiveOpts)); app.use(flush); this.app = app; } - listen(): void { + listen(): HttpServer { const { server } = this.opts; const httpServer = this.app.listen(server?.port, server?.host, () => log.info(`listening at http://${server?.host}:${server?.port}`) @@ -90,6 +98,9 @@ class Server { } throw err; }); + + this.httpServer = httpServer; + return httpServer; } async flush(databaseId: string): Promise { @@ -101,6 +112,7 @@ class Server { } addEventListener(): void { + if (this.shuttingDown) return; const pgPool = this.getPool(); pgPool.connect(this.listenForChanges.bind(this)); } @@ -112,10 +124,20 @@ class Server { ): void { if (err) { this.error('Error connecting with notify listener', err); - setTimeout(() => this.addEventListener(), 5000); + if (!this.shuttingDown) { + setTimeout(() => this.addEventListener(), 5000); + } return; } + if (this.shuttingDown) { + release(); + return; + } + + this.listenClient = client; + this.listenRelease = release; + client.on('notification', ({ channel, payload }) => { if (channel === 'schema:update' && payload) { log.info('schema:update', payload); @@ -126,6 +148,10 @@ class Server { client.query('LISTEN "schema:update"'); client.on('error', (e) => { + if (this.shuttingDown) { + release(); + return; + } this.error('Error with database notify listener', e); release(); this.addEventListener(); @@ -134,6 +160,60 @@ class Server { this.log('connected and listening for changes...'); } + async removeEventListener(): Promise { + if (!this.listenClient || !this.listenRelease) { + return; + } + + const client = this.listenClient; + const release = this.listenRelease; + this.listenClient = null; + this.listenRelease = null; + + client.removeAllListeners('notification'); + client.removeAllListeners('error'); + + try { + await client.query('UNLISTEN "schema:update"'); + } catch { + // Ignore listener cleanup errors during shutdown. + } + + release(); + } + + async close(opts: { closeCaches?: boolean } = {}): Promise { + const { closeCaches = false } = opts; + if (this.closed) { + if (closeCaches) { + await Server.closeCaches({ closePools: true }); + } + return; + } + this.closed = true; + this.shuttingDown = true; + await this.removeEventListener(); + if (this.httpServer?.listening) { + await new Promise((resolve) => + this.httpServer!.close(() => resolve()) + ); + } + if (closeCaches) { + await Server.closeCaches({ closePools: true }); + } + } + + static async closeCaches( + opts: { closePools?: boolean } = {} + ): Promise { + const { closePools = false } = opts; + svcCache.clear(); + graphileCache.clear(); + if (closePools) { + await pgCache.close(); + } + } + log(text: string): void { log.info(text); } diff --git a/jobs/job-scheduler/src/index.ts b/jobs/job-scheduler/src/index.ts index a3d8df160..a051a16ac 100644 --- a/jobs/job-scheduler/src/index.ts +++ b/jobs/job-scheduler/src/index.ts @@ -25,6 +25,9 @@ export default class Scheduler { pgPool: Pool; jobs: Record; _initialized?: boolean; + listenClient?: PoolClient; + listenRelease?: () => void; + stopped?: boolean; constructor({ tasks, @@ -135,6 +138,7 @@ export default class Scheduler { this.jobs[id] = j as SchedulerJobHandle; } async doNext(client: PgClientLike): Promise { + if (this.stopped) return; if (!this._initialized) { return await this.initialize(client); } @@ -151,10 +155,12 @@ export default class Scheduler { : this.supportedTaskNames }); if (!job || !job.id) { - this.doNextTimer = setTimeout( - () => this.doNext(client), - this.idleDelay - ); + if (!this.stopped) { + this.doNextTimer = setTimeout( + () => this.doNext(client), + this.idleDelay + ); + } return; } const start = process.hrtime(); @@ -180,12 +186,21 @@ export default class Scheduler { } catch (fatalError: unknown) { await this.handleFatalError(client, { err, fatalError, jobId }); } - return this.doNext(client); + if (!this.stopped) { + return this.doNext(client); + } + return; } catch (err: unknown) { - this.doNextTimer = setTimeout(() => this.doNext(client), this.idleDelay); + if (!this.stopped) { + this.doNextTimer = setTimeout( + () => this.doNext(client), + this.idleDelay + ); + } } } listen() { + if (this.stopped) return; const listenForChanges = ( err: Error | null, client: PoolClient, @@ -198,9 +213,17 @@ export default class Scheduler { } // Try again in 5 seconds // should this really be done in the node process? - setTimeout(this.listen, 5000); + if (!this.stopped) { + setTimeout(this.listen, 5000); + } + return; + } + if (this.stopped) { + release(); return; } + this.listenClient = client; + this.listenRelease = release; client.on('notification', () => { log.info('a NEW scheduled JOB!'); if (this.doNextTimer) { @@ -210,12 +233,18 @@ export default class Scheduler { }); client.query('LISTEN "scheduled_jobs:insert"'); client.on('error', (e: unknown) => { + if (this.stopped) { + release(); + return; + } log.error('Error with database notify listener', e); if (e instanceof Error && e.stack) { log.debug(e.stack); } release(); - this.listen(); + if (!this.stopped) { + this.listen(); + } }); log.info( `${this.workerId} connected and looking for scheduled jobs...` @@ -224,6 +253,32 @@ export default class Scheduler { }; this.pgPool.connect(listenForChanges); } + + async stop(): Promise { + this.stopped = true; + if (this.doNextTimer) { + clearTimeout(this.doNextTimer); + this.doNextTimer = undefined; + } + Object.values(this.jobs).forEach((job) => job.cancel()); + this.jobs = {}; + + const client = this.listenClient; + const release = this.listenRelease; + this.listenClient = undefined; + this.listenRelease = undefined; + + if (client && release) { + client.removeAllListeners('notification'); + client.removeAllListeners('error'); + try { + await client.query('UNLISTEN "scheduled_jobs:insert"'); + } catch { + // Ignore listener cleanup errors during shutdown. + } + release(); + } + } } export { Scheduler }; diff --git a/jobs/knative-job-example/src/index.ts b/jobs/knative-job-example/src/index.ts index 5f8729847..84303ab19 100644 --- a/jobs/knative-job-example/src/index.ts +++ b/jobs/knative-job-example/src/index.ts @@ -1,4 +1,6 @@ -import app from '@constructive-io/knative-job-fn'; +import { createJobApp } from '@constructive-io/knative-job-fn'; + +const app = createJobApp(); app.post('/', async (req: any, res: any, next: any) => { if (req.body.throw) { diff --git a/jobs/knative-job-fn/src/index.ts b/jobs/knative-job-fn/src/index.ts index ff09e1d88..5e313e550 100644 --- a/jobs/knative-job-fn/src/index.ts +++ b/jobs/knative-job-fn/src/index.ts @@ -3,6 +3,7 @@ import bodyParser from 'body-parser'; import http from 'node:http'; import https from 'node:https'; import { URL } from 'node:url'; +import type { Server as HttpServer } from 'http'; type JobCallbackStatus = 'success' | 'error'; @@ -22,51 +23,6 @@ function getHeaders(req: any) { }; } -const app: any = express(); - -app.use(bodyParser.json()); - -// Basic request logging for all incoming job invocations. -app.use((req: any, res: any, next: any) => { - try { - // Log only the headers we care about plus a shallow body snapshot - const headers = getHeaders(req); - - let body: any; - if (req.body && typeof req.body === 'object') { - // Only log top-level keys to avoid exposing sensitive body contents. - body = { keys: Object.keys(req.body) }; - } else if (typeof req.body === 'string') { - // For string bodies, log only the length. - body = { length: req.body.length }; - } else { - body = undefined; - } - - // eslint-disable-next-line no-console - console.log('[knative-job-fn] Incoming job request', { - method: req.method, - path: req.originalUrl || req.url, - headers, - body - }); - } catch { - // best-effort logging; never block the request - } - next(); -}); - -// Echo job headers back on responses for debugging/traceability. -app.use((req: any, res: any, next: any) => { - res.set({ - 'Content-Type': 'application/json', - 'X-Worker-Id': req.get('X-Worker-Id'), - 'X-Database-Id': req.get('X-Database-Id'), - 'X-Job-Id': req.get('X-Job-Id') - }); - next(); -}); - // Normalize callback URL so it always points at the /callback endpoint. const normalizeCallbackUrl = (rawUrl: string): string => { try { @@ -171,98 +127,164 @@ const sendJobCallback = async ( } }; -// Attach per-request context and a finish hook to send success callbacks. -app.use((req: any, res: any, next: any) => { - const ctx: JobContext = { - callbackUrl: req.get('X-Callback-Url'), - workerId: req.get('X-Worker-Id'), - jobId: req.get('X-Job-Id'), - databaseId: req.get('X-Database-Id') - }; +const createJobApp = () => { + const app: any = express(); + + app.use(bodyParser.json()); - // Store on res.locals so the error middleware can also mark callbacks as sent. - res.locals = res.locals || {}; - res.locals.jobContext = ctx; - res.locals.jobCallbackSent = false; + // Basic request logging for all incoming job invocations. + app.use((req: any, res: any, next: any) => { + try { + // Log only the headers we care about plus a shallow body snapshot + const headers = getHeaders(req); + + let body: any; + if (req.body && typeof req.body === 'object') { + // Only log top-level keys to avoid exposing sensitive body contents. + body = { keys: Object.keys(req.body) }; + } else if (typeof req.body === 'string') { + // For string bodies, log only the length. + body = { length: req.body.length }; + } else { + body = undefined; + } - if (ctx.callbackUrl && ctx.workerId && ctx.jobId) { - res.on('finish', () => { - // If an error handler already sent a callback, skip. - if (res.locals.jobCallbackSent) return; - res.locals.jobCallbackSent = true; // eslint-disable-next-line no-console - console.log('[knative-job-fn] Function completed', { - workerId: ctx.workerId, - jobId: ctx.jobId, - databaseId: ctx.databaseId, - statusCode: res.statusCode + console.log('[knative-job-fn] Incoming job request', { + method: req.method, + path: req.originalUrl || req.url, + headers, + body }); - void sendJobCallback(ctx, 'success'); + } catch { + // best-effort logging; never block the request + } + next(); + }); + + // Echo job headers back on responses for debugging/traceability. + app.use((req: any, res: any, next: any) => { + res.set({ + 'Content-Type': 'application/json', + 'X-Worker-Id': req.get('X-Worker-Id'), + 'X-Database-Id': req.get('X-Database-Id'), + 'X-Job-Id': req.get('X-Job-Id') }); - } + next(); + }); - next(); -}); - -export default { - post: function (...args: any[]) { - return app.post.apply(app, args as any); - }, - listen: (port: any, cb: () => void = () => {}) => { - // NOTE Remember that Express middleware executes in order. - // You should define error handlers last, after all other middleware. - // Otherwise, your error handler won't get called - // eslint-disable-next-line no-unused-vars - app.use(async (error: any, req: any, res: any, next: any) => { - res.set({ - 'Content-Type': 'application/json', - 'X-Job-Error': true + // Attach per-request context and a finish hook to send success callbacks. + app.use((req: any, res: any, next: any) => { + const ctx: JobContext = { + callbackUrl: req.get('X-Callback-Url'), + workerId: req.get('X-Worker-Id'), + jobId: req.get('X-Job-Id'), + databaseId: req.get('X-Database-Id') + }; + + // Store on res.locals so the error middleware can also mark callbacks as sent. + res.locals = res.locals || {}; + res.locals.jobContext = ctx; + res.locals.jobCallbackSent = false; + + if (ctx.callbackUrl && ctx.workerId && ctx.jobId) { + res.on('finish', () => { + // If an error handler already sent a callback, skip. + if (res.locals.jobCallbackSent) return; + res.locals.jobCallbackSent = true; + // eslint-disable-next-line no-console + console.log('[knative-job-fn] Function completed', { + workerId: ctx.workerId, + jobId: ctx.jobId, + databaseId: ctx.databaseId, + statusCode: res.statusCode + }); + void sendJobCallback(ctx, 'success'); }); + } + + next(); + }); - // Mark job as having errored via callback, if available. - try { - const ctx: JobContext | undefined = res.locals?.jobContext; - if (ctx && !res.locals.jobCallbackSent) { - res.locals.jobCallbackSent = true; - await sendJobCallback(ctx, 'error', error?.message); + return { + post: function (...args: any[]) { + return app.post.apply(app, args as any); + }, + listen: ( + port: any, + hostOrCb?: string | (() => void), + cb: () => void = () => {} + ): HttpServer => { + // NOTE Remember that Express middleware executes in order. + // You should define error handlers last, after all other middleware. + // Otherwise, your error handler won't get called + // eslint-disable-next-line no-unused-vars + app.use(async (error: any, req: any, res: any, next: any) => { + res.set({ + 'Content-Type': 'application/json', + 'X-Job-Error': true + }); + + // Mark job as having errored via callback, if available. + try { + const ctx: JobContext | undefined = res.locals?.jobContext; + if (ctx && !res.locals.jobCallbackSent) { + res.locals.jobCallbackSent = true; + await sendJobCallback(ctx, 'error', error?.message); + } + } catch (err) { + // eslint-disable-next-line no-console + console.error('[knative-job-fn] Failed to send error callback', err); } - } catch (err) { - // eslint-disable-next-line no-console - console.error('[knative-job-fn] Failed to send error callback', err); - } - // Log the full error context for debugging. - try { - const headers = getHeaders(req); - - // Some error types (e.g. GraphQL ClientError) expose response info. - const errorDetails: any = { - message: error?.message, - name: error?.name, - stack: error?.stack - }; - - if (error?.response) { - errorDetails.response = { - status: error.response.status, - statusText: error.response.statusText, - errors: error.response.errors, - data: error.response.data + // Log the full error context for debugging. + try { + const headers = getHeaders(req); + + // Some error types (e.g. GraphQL ClientError) expose response info. + const errorDetails: any = { + message: error?.message, + name: error?.name, + stack: error?.stack }; + + if (error?.response) { + errorDetails.response = { + status: error.response.status, + statusText: error.response.statusText, + errors: error.response.errors, + data: error.response.data + }; + } + + // eslint-disable-next-line no-console + console.error('[knative-job-fn] Function error', { + headers, + path: req.originalUrl || req.url, + error: errorDetails + }); + } catch { + // never throw from the error logger } - // eslint-disable-next-line no-console - console.error('[knative-job-fn] Function error', { - headers, - path: req.originalUrl || req.url, - error: errorDetails - }); - } catch { - // never throw from the error logger - } + res.status(200).json({ message: error.message }); + }); - res.status(200).json({ message: error.message }); - }); - return app.listen(port, cb); - } + const host = typeof hostOrCb === 'string' ? hostOrCb : undefined; + const callback = typeof hostOrCb === 'function' ? hostOrCb : cb; + const onListen = () => { + callback(); + }; + const server = host + ? app.listen(port, host, onListen) + : app.listen(port, onListen); + + return server; + } + }; }; + +const defaultApp = createJobApp(); + +export { createJobApp }; +export default defaultApp; diff --git a/jobs/knative-job-worker/src/index.ts b/jobs/knative-job-worker/src/index.ts index 6599aab29..5a54e6ad8 100644 --- a/jobs/knative-job-worker/src/index.ts +++ b/jobs/knative-job-worker/src/index.ts @@ -21,6 +21,9 @@ export default class Worker { doNextTimer?: NodeJS.Timeout; pgPool: Pool; _initialized?: boolean; + listenClient?: PoolClient; + listenRelease?: () => void; + stopped?: boolean; constructor({ tasks, @@ -118,6 +121,7 @@ export default class Worker { }); } async doNext(client: PgClientLike): Promise { + if (this.stopped) return; if (!this._initialized) { return await this.initialize(client); } @@ -136,10 +140,12 @@ export default class Worker { })) as JobRow | undefined; if (!job || !job.id) { - this.doNextTimer = setTimeout( - () => this.doNext(client), - this.idleDelay - ); + if (!this.stopped) { + this.doNextTimer = setTimeout( + () => this.doNext(client), + this.idleDelay + ); + } return; } const start = process.hrtime(); @@ -164,12 +170,21 @@ export default class Worker { } catch (fatalError: unknown) { await this.handleFatalError(client, { err, fatalError, jobId }); } - return this.doNext(client); + if (!this.stopped) { + return this.doNext(client); + } + return; } catch (err: unknown) { - this.doNextTimer = setTimeout(() => this.doNext(client), this.idleDelay); + if (!this.stopped) { + this.doNextTimer = setTimeout( + () => this.doNext(client), + this.idleDelay + ); + } } } listen() { + if (this.stopped) return; const listenForChanges = ( err: Error | null, client: PoolClient, @@ -182,9 +197,17 @@ export default class Worker { } // Try again in 5 seconds // should this really be done in the node process? - setTimeout(this.listen, 5000); + if (!this.stopped) { + setTimeout(this.listen, 5000); + } return; } + if (this.stopped) { + release(); + return; + } + this.listenClient = client; + this.listenRelease = release; client.on('notification', () => { if (this.doNextTimer) { // Must be idle, do something! @@ -193,18 +216,47 @@ export default class Worker { }); client.query('LISTEN "jobs:insert"'); client.on('error', (e: unknown) => { + if (this.stopped) { + release(); + return; + } log.error('Error with database notify listener', e); if (e instanceof Error && e.stack) { log.debug(e.stack); } release(); - this.listen(); + if (!this.stopped) { + this.listen(); + } }); log.info(`${this.workerId} connected and looking for jobs...`); this.doNext(client); }; this.pgPool.connect(listenForChanges); } + + async stop(): Promise { + this.stopped = true; + if (this.doNextTimer) { + clearTimeout(this.doNextTimer); + this.doNextTimer = undefined; + } + const client = this.listenClient; + const release = this.listenRelease; + this.listenClient = undefined; + this.listenRelease = undefined; + + if (client && release) { + client.removeAllListeners('notification'); + client.removeAllListeners('error'); + try { + await client.query('UNLISTEN "jobs:insert"'); + } catch { + // Ignore listener cleanup errors during shutdown. + } + release(); + } + } } export { Worker }; diff --git a/packages/cli/src/commands/jobs.ts b/packages/cli/src/commands/jobs.ts index 084d349b8..a1442d233 100644 --- a/packages/cli/src/commands/jobs.ts +++ b/packages/cli/src/commands/jobs.ts @@ -200,7 +200,8 @@ export default async ( try { ensureCwd((args.cwd as string) || process.cwd()); const promptAnswers = await prompter.prompt(args, questions); - await CombinedServer(buildCombinedServerOptions(promptAnswers)); + const server = new CombinedServer(buildCombinedServerOptions(promptAnswers)); + await server.start(); } catch (error) { await cliExitWithError( `Failed to start combined server: ${(error as Error).message}` diff --git a/packages/server/README.md b/packages/server/README.md index 60ee95a4e..b388eebd8 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -20,7 +20,7 @@ ```ts import { CombinedServer } from '@constructive-io/server'; -await CombinedServer({ +const server = new CombinedServer({ graphql: { enabled: true }, functions: { enabled: true, @@ -31,6 +31,9 @@ await CombinedServer({ }, jobs: { enabled: true } }); + +await server.start(); +// await server.stop(); ``` ### Local Development (this repo) diff --git a/packages/server/__fixtures__/jobs.seed.sql b/packages/server/__fixtures__/jobs.seed.sql new file mode 100644 index 000000000..49705e8e8 --- /dev/null +++ b/packages/server/__fixtures__/jobs.seed.sql @@ -0,0 +1,67 @@ +BEGIN; + +CREATE SCHEMA IF NOT EXISTS app_public; + +CREATE TABLE IF NOT EXISTS app_public.users ( + id uuid PRIMARY KEY, + username text NOT NULL, + display_name text, + profile_picture jsonb +); + +INSERT INTO app_public.users (id, username, display_name, profile_picture) +VALUES ( + '00000000-0000-0000-0000-000000000000', + 'sender', + 'Sender', + '{"url":"https://example.com/avatar.png","mime":"image/png"}'::jsonb +) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO metaschema_public.database (id, name) +VALUES ('0b22e268-16d6-582b-950a-24e108688849', 'jobs-test') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO services_public.sites (id, database_id, title, logo, dbname) +VALUES ( + '11111111-1111-1111-1111-111111111111', + '0b22e268-16d6-582b-950a-24e108688849', + 'Jobs Test', + '{"url":"https://example.com/logo.png","mime":"image/png"}'::jsonb, + current_database() +) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO services_public.domains (id, database_id, site_id, domain, subdomain) +VALUES ( + '22222222-2222-2222-2222-222222222222', + '0b22e268-16d6-582b-950a-24e108688849', + '11111111-1111-1111-1111-111111111111', + 'localhost', + NULL +) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO services_public.site_themes (id, database_id, site_id, theme) +VALUES ( + '33333333-3333-3333-3333-333333333333', + '0b22e268-16d6-582b-950a-24e108688849', + '11111111-1111-1111-1111-111111111111', + '{"primary":"#335C67"}'::jsonb +) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO services_public.site_modules (id, database_id, site_id, name, data) +VALUES ( + '44444444-4444-4444-4444-444444444444', + '0b22e268-16d6-582b-950a-24e108688849', + '11111111-1111-1111-1111-111111111111', + 'legal_terms_module', + '{"emails":{"support":"support@example.com"},"company":{"name":"Constructive","nick":"Constructive","website":"https://constructive.io"}}'::jsonb +) +ON CONFLICT (id) DO NOTHING; + +GRANT USAGE ON SCHEMA app_public TO administrator; +GRANT SELECT, INSERT, UPDATE, DELETE ON app_public.users TO administrator; + +COMMIT; diff --git a/packages/server/__tests__/jobs.e2e.test.ts b/packages/server/__tests__/jobs.e2e.test.ts index 3a39f207c..2ba0d03da 100644 --- a/packages/server/__tests__/jobs.e2e.test.ts +++ b/packages/server/__tests__/jobs.e2e.test.ts @@ -1,6 +1,13 @@ +import { readFile } from 'fs/promises'; +import { createServer } from 'net'; +import { dirname, join } from 'path'; import supertest from 'supertest'; -import { getConnections } from '@constructive-io/graphql-test'; +import { PgpmInit, PgpmMigrate } from '@pgpmjs/core'; +import { getConnections, seed, type PgTestClient } from 'pgsql-test'; + +import type { CombinedServer as CombinedServerType } from '../src/server'; +import type { CombinedServerOptions, FunctionServiceConfig } from '../src/types'; jest.setTimeout(120000); @@ -13,16 +20,14 @@ type GraphqlClient = { host?: string; }; -const getGraphqlClient = (): GraphqlClient => { - const rawUrl = - process.env.TEST_GRAPHQL_URL || - process.env.GRAPHQL_URL || - 'http://localhost:3000/graphql'; +const buildGraphqlClient = ( + rawUrl: string, + host?: string +): GraphqlClient => { const parsed = new URL(rawUrl); const origin = `${parsed.protocol}//${parsed.host}`; const path = parsed.pathname === '/' ? '/graphql' : `${parsed.pathname}${parsed.search}`; - const host = process.env.TEST_GRAPHQL_HOST || process.env.GRAPHQL_HOST; return { http: supertest(origin), @@ -31,6 +36,16 @@ const getGraphqlClient = (): GraphqlClient => { }; }; +const getGraphqlClient = (): GraphqlClient => { + const rawUrl = + process.env.TEST_GRAPHQL_URL || + process.env.GRAPHQL_URL || + 'http://localhost:3000/graphql'; + const host = process.env.TEST_GRAPHQL_HOST || process.env.GRAPHQL_HOST; + + return buildGraphqlClient(rawUrl, host); +}; + const sendGraphql = async ( client: GraphqlClient, query: string, @@ -122,44 +137,361 @@ const waitForJobCompletion = async ( throw new Error(`Job ${jobId} did not complete within ${timeoutMs}ms`); }; +const seededDatabaseId = '0b22e268-16d6-582b-950a-24e108688849'; +const metaDbExtensions = ['citext', 'uuid-ossp', 'unaccent', 'pgcrypto', 'hstore']; + +const getPgpmModulePath = (pkgName: string): string => + dirname(require.resolve(`${pkgName}/pgpm.plan`)); + +const metaSeedModules = [ + getPgpmModulePath('@pgpm/verify'), + getPgpmModulePath('@pgpm/types'), + getPgpmModulePath('@pgpm/inflection'), + getPgpmModulePath('@pgpm/database-jobs'), + getPgpmModulePath('@pgpm/metaschema-schema'), + getPgpmModulePath('@pgpm/services'), + getPgpmModulePath('@pgpm/metaschema-modules') +]; + +const sql = (f: string) => join(__dirname, '..', '__fixtures__', f); + +type SeededConnections = { + db: PgTestClient; + pg: PgTestClient; + teardown: () => Promise; +}; + +type PgConfigLike = PgTestClient['config']; + +const runMetaMigrations = async (config: PgConfigLike) => { + const migrator = new PgpmMigrate(config); + for (const modulePath of metaSeedModules) { + const result = await migrator.deploy({ modulePath, usePlan: true }); + if (result.failed) { + throw new Error(`Failed to deploy ${modulePath}: ${result.failed}`); + } + } +}; + +const bootstrapAdminUsers = seed.fn(async ({ admin, config, connect }) => { + const roles = connect?.roles; + const connections = connect?.connections; + + if (!roles || !connections) { + throw new Error('Missing pgpm role or connection defaults for admin users.'); + } + + const init = new PgpmInit(config); + try { + await init.bootstrapRoles(roles); + await init.bootstrapTestRoles(roles, connections); + } finally { + await init.close(); + } + + const appUser = connections.app?.user; + if (appUser) { + await admin.grantRole(roles.administrator, appUser, config.database); + } +}); + +const deployMetaModules = seed.fn(async ({ config }) => { + await runMetaMigrations(config); +}); + +const createTestDb = async (): Promise => { + const { db, pg, teardown } = await getConnections( + { db: { extensions: metaDbExtensions } }, + [ + bootstrapAdminUsers, + deployMetaModules, + seed.sqlfile([sql('jobs.seed.sql')]) + ] + ); + + return { db, pg, teardown }; +}; + +const hasSchema = async (client: PgTestClient, schema: string) => { + const row = await client.oneOrNone<{ schema_name: string }>( + 'SELECT schema_name FROM information_schema.schemata WHERE schema_name = $1', + [schema] + ); + return Boolean(row?.schema_name); +}; + +const ensureJobsSchema = async (client: PgTestClient) => { + if (await hasSchema(client, 'app_jobs')) return; + await runMetaMigrations(client.config); + if (!(await hasSchema(client, 'app_jobs'))) { + throw new Error('app_jobs schema was not created by pgpm migrations'); + } +}; + +const getAvailablePort = (): Promise => + new Promise((resolvePort, reject) => { + const server = createServer(); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(() => reject(new Error('Failed to allocate a port'))); + return; + } + const port = address.port; + server.close((err) => { + if (err) { + reject(err); + return; + } + resolvePort(port); + }); + }); + server.on('error', reject); + }); + +const waitForReady = async ( + label: string, + check: () => Promise, + timeoutMs = 30000, + getLastError?: () => string | undefined +) => { + const started = Date.now(); + + while (Date.now() - started < timeoutMs) { + try { + if (await check()) return; + } catch { + // ignore and retry + } + await delay(500); + } + + const lastError = getLastError?.(); + if (lastError) { + throw new Error( + `${label} did not become ready within ${timeoutMs}ms. Last error: ${lastError}` + ); + } + throw new Error(`${label} did not become ready within ${timeoutMs}ms.`); +}; + +const waitForGraphql = async (client: GraphqlClient) => { + let lastError: string | undefined; + await waitForReady( + 'GraphQL server', + async () => { + const response = await sendGraphql(client, '{ __typename }'); + if (response.status !== 200) { + const detail = + response.text || + (response.body ? JSON.stringify(response.body) : undefined); + lastError = detail + ? `HTTP ${response.status}: ${detail}` + : `HTTP ${response.status}`; + return false; + } + if (response.body?.errors?.length) { + lastError = response.body.errors + .map((err: { message: string }) => err.message) + .join('; '); + return false; + } + lastError = undefined; + return true; + }, + 30000, + () => lastError + ); +}; + +const waitForCallbackServer = async (callbackUrl: string) => { + const origin = callbackUrl.replace(/\/callback$/, ''); + const http = supertest(origin); + await waitForReady('Jobs callback server', async () => { + const response = await http.post('/callback').send({}); + return response.status === 200; + }); +}; + describe('jobs e2e', () => { let teardown: () => Promise; let graphqlClient: GraphqlClient; let databaseId = ''; - let pg: { oneOrNone?: (query: string, values?: unknown[]) => Promise } | undefined; + let pg: PgTestClient | undefined; + let combinedServer: CombinedServerType | null = null; + const envSnapshot: Record = { + NODE_ENV: process.env.NODE_ENV, + TEST_DB: process.env.TEST_DB, + PGHOST: process.env.PGHOST, + PGPORT: process.env.PGPORT, + PGUSER: process.env.PGUSER, + PGPASSWORD: process.env.PGPASSWORD, + PGDATABASE: process.env.PGDATABASE, + TEST_DATABASE_ID: process.env.TEST_DATABASE_ID, + DEFAULT_DATABASE_ID: process.env.DEFAULT_DATABASE_ID, + TEST_GRAPHQL_URL: process.env.TEST_GRAPHQL_URL, + TEST_GRAPHQL_HOST: process.env.TEST_GRAPHQL_HOST, + GRAPHQL_URL: process.env.GRAPHQL_URL, + META_GRAPHQL_URL: process.env.META_GRAPHQL_URL, + SIMPLE_EMAIL_DRY_RUN: process.env.SIMPLE_EMAIL_DRY_RUN, + SEND_EMAIL_LINK_DRY_RUN: process.env.SEND_EMAIL_LINK_DRY_RUN, + LOCAL_APP_PORT: process.env.LOCAL_APP_PORT, + MAILGUN_DOMAIN: process.env.MAILGUN_DOMAIN, + MAILGUN_FROM: process.env.MAILGUN_FROM, + MAILGUN_REPLY: process.env.MAILGUN_REPLY, + MAILGUN_API_KEY: process.env.MAILGUN_API_KEY, + MAILGUN_KEY: process.env.MAILGUN_KEY, + JOBS_SUPPORT_ANY: process.env.JOBS_SUPPORT_ANY, + JOBS_SUPPORTED: process.env.JOBS_SUPPORTED, + INTERNAL_GATEWAY_DEVELOPMENT_MAP: + process.env.INTERNAL_GATEWAY_DEVELOPMENT_MAP, + INTERNAL_JOBS_CALLBACK_PORT: process.env.INTERNAL_JOBS_CALLBACK_PORT, + JOBS_CALLBACK_BASE_URL: process.env.JOBS_CALLBACK_BASE_URL, + FEATURES_POSTGIS: process.env.FEATURES_POSTGIS + }; beforeAll(async () => { - const targetDb = process.env.TEST_DB || process.env.PGDATABASE; - if (!targetDb) { - throw new Error('TEST_DB or PGDATABASE must point at the jobs database'); - } - process.env.TEST_DB = targetDb; + delete process.env.TEST_DB; + delete process.env.PGDATABASE; - ({ teardown, pg } = await getConnections( - { - schemas: ['app_jobs'], - authRole: 'administrator' - } - )); + ({ teardown, pg } = await createTestDb()); + if (!pg) { + throw new Error('Test database connection is missing'); + } + await ensureJobsSchema(pg); - graphqlClient = getGraphqlClient(); - databaseId = process.env.TEST_DATABASE_ID ?? ''; - if (!databaseId && pg?.oneOrNone) { + databaseId = seededDatabaseId; + if (pg?.oneOrNone) { const row = await pg.oneOrNone<{ id: string }>( - 'SELECT id FROM metaschema_public.database ORDER BY created_at LIMIT 1' + 'SELECT id FROM metaschema_public.database WHERE id = $1', + [databaseId] ); - databaseId = row?.id ?? ''; + if (!row?.id) { + const seedSql = await readFile(sql('jobs.seed.sql'), 'utf8'); + await pg.query(seedSql); + const seeded = await pg.oneOrNone<{ id: string }>( + 'SELECT id FROM metaschema_public.database WHERE id = $1', + [databaseId] + ); + if (!seeded?.id) { + throw new Error(`Seeded database id ${databaseId} was not found`); + } + } } - if (!databaseId) { - throw new Error('TEST_DATABASE_ID is required or metaschema_public.database must contain a row'); + + if (!pg?.config.database) { + throw new Error('Test database config is missing a database name'); } + + const ports = { + graphqlPort: await getAvailablePort(), + callbackPort: await getAvailablePort(), + simpleEmailPort: await getAvailablePort(), + sendEmailLinkPort: await getAvailablePort() + }; + + const graphqlUrl = `http://127.0.0.1:${ports.graphqlPort}/graphql`; + const callbackUrl = `http://127.0.0.1:${ports.callbackPort}/callback`; + + process.env.NODE_ENV = 'test'; + process.env.TEST_DB = pg.config.database; + process.env.PGDATABASE = pg.config.database; process.env.TEST_DATABASE_ID = databaseId; + process.env.DEFAULT_DATABASE_ID = databaseId; + process.env.TEST_GRAPHQL_URL = graphqlUrl; + process.env.GRAPHQL_URL = graphqlUrl; + process.env.META_GRAPHQL_URL = graphqlUrl; + process.env.SIMPLE_EMAIL_DRY_RUN = 'true'; + process.env.SEND_EMAIL_LINK_DRY_RUN = 'true'; + process.env.LOCAL_APP_PORT = String(ports.graphqlPort); + process.env.MAILGUN_DOMAIN = 'mg.constructive.io'; + process.env.MAILGUN_FROM = 'no-reply@mg.constructive.io'; + process.env.MAILGUN_REPLY = 'info@mg.constructive.io'; + process.env.MAILGUN_API_KEY = 'change-me-mailgun-api-key'; + process.env.MAILGUN_KEY = 'change-me-mailgun-api-key'; + process.env.JOBS_SUPPORT_ANY = 'false'; + process.env.JOBS_SUPPORTED = 'simple-email,send-email-link'; + process.env.INTERNAL_GATEWAY_DEVELOPMENT_MAP = JSON.stringify({ + 'simple-email': `http://127.0.0.1:${ports.simpleEmailPort}`, + 'send-email-link': `http://127.0.0.1:${ports.sendEmailLinkPort}` + }); + process.env.INTERNAL_JOBS_CALLBACK_PORT = String(ports.callbackPort); + process.env.JOBS_CALLBACK_BASE_URL = callbackUrl; + process.env.FEATURES_POSTGIS = 'false'; + + if (pg.config.host) process.env.PGHOST = pg.config.host; + if (pg.config.port) process.env.PGPORT = String(pg.config.port); + if (pg.config.user) process.env.PGUSER = pg.config.user; + if (pg.config.password) process.env.PGPASSWORD = pg.config.password; + + const services: FunctionServiceConfig[] = [ + { name: 'simple-email', port: ports.simpleEmailPort }, + { name: 'send-email-link', port: ports.sendEmailLinkPort } + ]; + + const combinedServerOptions: CombinedServerOptions = { + graphql: { + enabled: true, + options: { + pg: { + host: pg.config.host, + port: pg.config.port, + user: pg.config.user, + password: pg.config.password, + database: pg.config.database + }, + server: { + host: '127.0.0.1', + port: ports.graphqlPort + }, + api: { + enableMetaApi: false, + exposedSchemas: [ + 'app_jobs', + 'app_public', + 'metaschema_modules_public', + 'metaschema_public', + 'services_public' + ], + anonRole: 'administrator', + roleName: 'administrator', + defaultDatabaseId: databaseId + }, + features: { + postgis: false + } + } + }, + functions: { + enabled: true, + services + }, + jobs: { enabled: true } + }; + + const { CombinedServer } = await import('../src/server'); + combinedServer = new CombinedServer(combinedServerOptions); + await combinedServer.start(); + + graphqlClient = getGraphqlClient(); + await waitForGraphql(graphqlClient); + await waitForCallbackServer(callbackUrl); }); afterAll(async () => { + if (combinedServer) { + await combinedServer.stop(); + } if (teardown) { await teardown(); } + for (const [key, value] of Object.entries(envSnapshot)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } }); it('creates and processes a simple-email job', async () => { diff --git a/packages/server/package.json b/packages/server/package.json index 9311cb050..fe7d7da7f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -41,8 +41,13 @@ "dependencies": { "@constructive-io/graphql-server": "workspace:^", "@constructive-io/graphql-types": "workspace:^", + "@constructive-io/job-pg": "workspace:^", + "@constructive-io/job-scheduler": "workspace:^", + "@constructive-io/job-utils": "workspace:^", "@constructive-io/knative-job-fn": "workspace:^", + "@constructive-io/knative-job-server": "workspace:^", "@constructive-io/knative-job-service": "workspace:^", + "@constructive-io/knative-job-worker": "workspace:^", "@constructive-io/send-email-link-fn": "workspace:^", "@constructive-io/simple-email-fn": "workspace:^", "@pgpmjs/env": "workspace:^", @@ -50,6 +55,14 @@ }, "devDependencies": { "@constructive-io/graphql-test": "workspace:^", + "@pgpm/database-jobs": "^0.16.0", + "@pgpm/inflection": "^0.16.0", + "@pgpm/metaschema-modules": "^0.16.1", + "@pgpm/metaschema-schema": "^0.16.1", + "@pgpm/services": "^0.16.1", + "@pgpm/types": "^0.16.0", + "@pgpm/verify": "^0.16.0", + "@pgpmjs/core": "workspace:^", "@types/supertest": "^6.0.3", "makage": "^0.1.10", "nodemon": "^3.1.10", diff --git a/packages/server/src/run.ts b/packages/server/src/run.ts index 9665dec42..fa979dc69 100644 --- a/packages/server/src/run.ts +++ b/packages/server/src/run.ts @@ -84,8 +84,10 @@ export const buildCombinedServerOptionsFromEnv = (): CombinedServerOptions => ({ functions: buildFunctionsOptions() }); -export const startCombinedServerFromEnv = async (): Promise => - CombinedServer(buildCombinedServerOptionsFromEnv()); +export const startCombinedServerFromEnv = async (): Promise => { + const server = new CombinedServer(buildCombinedServerOptionsFromEnv()); + return server.start(); +}; if (require.main === module) { void startCombinedServerFromEnv().catch((error) => { diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index f1be8cc92..5c207a82a 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -1,7 +1,17 @@ -import { GraphQLServer } from '@constructive-io/graphql-server'; -import { bootJobs } from '@constructive-io/knative-job-service/dist/run'; +import { Server as GraphQLServer } from '@constructive-io/graphql-server'; +import jobServerFactory from '@constructive-io/knative-job-server'; +import Worker from '@constructive-io/knative-job-worker'; +import Scheduler from '@constructive-io/job-scheduler'; +import poolManager from '@constructive-io/job-pg'; +import { + getJobSupported, + getJobsCallbackPort, + getSchedulerHostname, + getWorkerHostname +} from '@constructive-io/job-utils'; import { Logger } from '@pgpmjs/logger'; import { createRequire } from 'module'; +import type { Server as HttpServer } from 'http'; import { CombinedServerOptions, @@ -30,7 +40,6 @@ const functionRegistry: Record = { const log = new Logger('combined-server'); const requireFn = createRequire(__filename); -const functionServers = new Map(); const resolveFunctionEntry = (name: FunctionName): FunctionRegistryEntry => { const entry = functionRegistry[name]; @@ -94,7 +103,8 @@ const ensureUniquePorts = (services: FunctionServiceConfig[]) => { }; const startFunction = async ( - service: FunctionServiceConfig + service: FunctionServiceConfig, + functionServers: Map ): Promise => { const entry = resolveFunctionEntry(service.name); const port = resolveFunctionPort(service); @@ -104,7 +114,7 @@ const startFunction = async ( const server = app.listen(port, () => { log.info(`function:${service.name} listening on ${port}`); resolve(); - }) as { on?: (event: string, cb: (err: Error) => void) => void }; + }) as HttpServer & { on?: (event: string, cb: (err: Error) => void) => void }; if (server?.on) { server.on('error', (err) => { @@ -119,8 +129,9 @@ const startFunction = async ( return { name: service.name, port }; }; -export const startFunctions = async ( - options?: FunctionsOptions +const startFunctions = async ( + options: FunctionsOptions | undefined, + functionServers: Map ): Promise => { const services = normalizeFunctionServices(options); if (!services.length) return []; @@ -129,37 +140,174 @@ export const startFunctions = async ( const started: StartedFunction[] = []; for (const service of services) { - started.push(await startFunction(service)); + started.push(await startFunction(service, functionServers)); } return started; }; -export const CombinedServer = async ( - options: CombinedServerOptions = {} -): Promise => { - const result: CombinedServerResult = { +type JobRunner = { + listen: () => void; + stop?: () => Promise | void; +}; + +const listenApp = async ( + app: { listen: (port: number, host?: string) => HttpServer }, + port: number, + host?: string +): Promise => + new Promise((resolveListen, rejectListen) => { + const server = host ? app.listen(port, host) : app.listen(port); + + const cleanup = () => { + server.off('listening', handleListen); + server.off('error', handleError); + }; + + const handleListen = () => { + cleanup(); + resolveListen(server); + }; + + const handleError = (err: Error) => { + cleanup(); + rejectListen(err); + }; + + server.once('listening', handleListen); + server.once('error', handleError); + }); + +const closeServer = async (server?: HttpServer | null): Promise => { + if (!server || !server.listening) return; + await new Promise((resolveClose, rejectClose) => { + server.close((err) => { + if (err) { + rejectClose(err); + return; + } + resolveClose(); + }); + }); +}; + +export class CombinedServer { + private options: CombinedServerOptions; + private started = false; + private result: CombinedServerResult = { functions: [], jobs: false, graphql: false }; + private graphqlServer?: GraphQLServer; + private graphqlHttpServer?: HttpServer; + private functionServers = new Map(); + private jobsHttpServer?: HttpServer; + private worker?: JobRunner; + private scheduler?: JobRunner; + private jobsPoolManager?: { close: () => Promise }; - if (options.graphql?.enabled) { - log.info('starting GraphQL server'); - GraphQLServer(options.graphql.options ?? {}); - result.graphql = true; + constructor(options: CombinedServerOptions = {}) { + this.options = options; } - if (shouldEnableFunctions(options.functions)) { - log.info('starting functions'); - result.functions = await startFunctions(options.functions); + async start(): Promise { + if (this.started) return this.result; + this.started = true; + this.result = { + functions: [], + jobs: false, + graphql: false + }; + + if (this.options.graphql?.enabled) { + log.info('starting GraphQL server'); + this.graphqlServer = new GraphQLServer( + this.options.graphql.options ?? {} + ); + this.graphqlServer.addEventListener(); + this.graphqlHttpServer = this.graphqlServer.listen(); + if (!this.graphqlHttpServer.listening) { + await new Promise((resolveListen) => { + this.graphqlHttpServer!.once('listening', () => resolveListen()); + }); + } + this.result.graphql = true; + } + + if (shouldEnableFunctions(this.options.functions)) { + log.info('starting functions'); + this.result.functions = await startFunctions( + this.options.functions, + this.functionServers + ); + } + + if (this.options.jobs?.enabled) { + log.info('starting jobs service'); + await this.startJobs(); + this.result.jobs = true; + } + + return this.result; } - if (options.jobs?.enabled) { - log.info('starting jobs service'); - await bootJobs(); - result.jobs = true; + async stop(): Promise { + if (!this.started) return; + this.started = false; + + if (this.worker?.stop) { + await this.worker.stop(); + } + if (this.scheduler?.stop) { + await this.scheduler.stop(); + } + this.worker = undefined; + this.scheduler = undefined; + + await closeServer(this.jobsHttpServer); + this.jobsHttpServer = undefined; + + if (this.jobsPoolManager) { + await this.jobsPoolManager.close(); + this.jobsPoolManager = undefined; + } + + for (const server of this.functionServers.values()) { + await closeServer(server); + } + this.functionServers.clear(); + + await closeServer(this.graphqlHttpServer); + this.graphqlHttpServer = undefined; + + if (this.graphqlServer?.close) { + await this.graphqlServer.close({ closeCaches: true }); + } + this.graphqlServer = undefined; } - return result; -}; + private async startJobs(): Promise { + const pgPool = poolManager.getPool(); + const jobsApp = jobServerFactory(pgPool); + const callbackPort = getJobsCallbackPort(); + this.jobsHttpServer = await listenApp(jobsApp, callbackPort); + + const tasks = getJobSupported(); + this.worker = new Worker({ + pgPool, + tasks, + workerId: getWorkerHostname() + }); + this.scheduler = new Scheduler({ + pgPool, + tasks, + workerId: getSchedulerHostname() + }); + + this.jobsPoolManager = poolManager; + + this.worker.listen(); + this.scheduler.listen(); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d22fe9e02..0a41d97b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1397,12 +1397,27 @@ importers: '@constructive-io/graphql-types': specifier: workspace:^ version: link:../../graphql/types/dist + '@constructive-io/job-pg': + specifier: workspace:^ + version: link:../../jobs/job-pg + '@constructive-io/job-scheduler': + specifier: workspace:^ + version: link:../../jobs/job-scheduler + '@constructive-io/job-utils': + specifier: workspace:^ + version: link:../../jobs/job-utils '@constructive-io/knative-job-fn': specifier: workspace:^ version: link:../../jobs/knative-job-fn + '@constructive-io/knative-job-server': + specifier: workspace:^ + version: link:../../jobs/knative-job-server '@constructive-io/knative-job-service': specifier: workspace:^ version: link:../../jobs/knative-job-service + '@constructive-io/knative-job-worker': + specifier: workspace:^ + version: link:../../jobs/knative-job-worker '@constructive-io/send-email-link-fn': specifier: workspace:^ version: link:../../functions/send-email-link @@ -1419,6 +1434,30 @@ importers: '@constructive-io/graphql-test': specifier: workspace:^ version: link:../../graphql/test/dist + '@pgpm/database-jobs': + specifier: ^0.16.0 + version: 0.16.0 + '@pgpm/inflection': + specifier: ^0.16.0 + version: 0.16.0 + '@pgpm/metaschema-modules': + specifier: ^0.16.1 + version: 0.16.1 + '@pgpm/metaschema-schema': + specifier: ^0.16.1 + version: 0.16.1 + '@pgpm/services': + specifier: ^0.16.1 + version: 0.16.1 + '@pgpm/types': + specifier: ^0.16.0 + version: 0.16.0 + '@pgpm/verify': + specifier: ^0.16.0 + version: 0.16.0 + '@pgpmjs/core': + specifier: workspace:^ + version: link:../../pgpm/core/dist '@types/supertest': specifier: ^6.0.3 version: 6.0.3 @@ -3155,6 +3194,27 @@ packages: '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@pgpm/database-jobs@0.16.0': + resolution: {integrity: sha512-s8I7958PlhfYXZKhYoU76R03yk6dlevjGk/Uy9uktveJkZ8C3JVsIhP6Lv4lo0SFEZCjFmXRCYpOY5xINIcX4w==} + + '@pgpm/inflection@0.16.0': + resolution: {integrity: sha512-otjWGx+KkB113Wc5I9nsvoqPhBK6zD1ON2OcXw9PQRgqU43Y9f0yZjb559dDzZwDn5XUeiZMf6il5SIvJE5NPg==} + + '@pgpm/metaschema-modules@0.16.1': + resolution: {integrity: sha512-qH0l4Xe0f0CSzXAC2nItu+ZpGliZ4eezl332HCLpI/bLkIMsmIZYlcjgiPmv7lZae+3uWbn7DQuDxeomsn5kBw==} + + '@pgpm/metaschema-schema@0.16.1': + resolution: {integrity: sha512-FwLy+z8pwfrBeQYErpcDpD55ZtB1X+Ghj6bbE28GVURBlUxmPY1llrLfKLqcA6xaKMyZ+aHOeBlKYRuyo9xdag==} + + '@pgpm/services@0.16.1': + resolution: {integrity: sha512-9wp3nstcTtsARw5cuE/x9Dwq/v7FQUPXlzjsBR/2V6z7oHBjOI8HiQ8y+tc1pnrFL1PJtcthkZKvBZbQBQJbTw==} + + '@pgpm/types@0.16.0': + resolution: {integrity: sha512-CioHCxZGQUnpLANw4aMOOq7Z6zi2SXCxJIRZ8CSBPJfJkWU1OgxX+EpSjnm4Td4bznJhOViXniLltibaaGkMPA==} + + '@pgpm/verify@0.16.0': + resolution: {integrity: sha512-uG0zTXAWGLV8wTUiLdBn+2b4AO+gtiw7sZf+TFFU8h/mVGMBTHUb9Gbsl/GL/5/0zZKOxak7cRJ5deec79KB/A==} + '@pgsql/types@17.6.2': resolution: {integrity: sha512-1UtbELdbqNdyOShhrVfSz3a1gDi0s9XXiQemx+6QqtsrXe62a6zOGU+vjb2GRfG5jeEokI1zBBcfD42enRv0Rw==} @@ -10005,6 +10065,38 @@ snapshots: dependencies: '@noble/hashes': 1.8.0 + '@pgpm/database-jobs@0.16.0': + dependencies: + '@pgpm/verify': 0.16.0 + + '@pgpm/inflection@0.16.0': + dependencies: + '@pgpm/verify': 0.16.0 + + '@pgpm/metaschema-modules@0.16.1': + dependencies: + '@pgpm/metaschema-schema': 0.16.1 + '@pgpm/services': 0.16.1 + '@pgpm/verify': 0.16.0 + + '@pgpm/metaschema-schema@0.16.1': + dependencies: + '@pgpm/database-jobs': 0.16.0 + '@pgpm/inflection': 0.16.0 + '@pgpm/types': 0.16.0 + '@pgpm/verify': 0.16.0 + + '@pgpm/services@0.16.1': + dependencies: + '@pgpm/metaschema-schema': 0.16.1 + '@pgpm/verify': 0.16.0 + + '@pgpm/types@0.16.0': + dependencies: + '@pgpm/verify': 0.16.0 + + '@pgpm/verify@0.16.0': {} + '@pgsql/types@17.6.2': {} '@pgsql/utils@17.8.11': From 14cc9ee609d5111d5a04fca1b228999f5ccfde3b Mon Sep 17 00:00:00 2001 From: zetazzz Date: Thu, 8 Jan 2026 15:52:44 +0700 Subject: [PATCH 04/12] jobs combined server tested in CI --- .github/workflows/jobs-e2e.yaml | 140 --------------------- .github/workflows/run-tests.yaml | 2 + packages/server/__tests__/jobs.e2e.test.ts | 1 - 3 files changed, 2 insertions(+), 141 deletions(-) delete mode 100644 .github/workflows/jobs-e2e.yaml diff --git a/.github/workflows/jobs-e2e.yaml b/.github/workflows/jobs-e2e.yaml deleted file mode 100644 index d6173a78f..000000000 --- a/.github/workflows/jobs-e2e.yaml +++ /dev/null @@ -1,140 +0,0 @@ -name: Jobs E2E -on: - push: - branches: - - main - - v1 - pull_request: - branches: - - main - - v1 - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }}-jobs-e2e - cancel-in-progress: true - -jobs: - jobs-e2e: - runs-on: ubuntu-latest - - env: - PGHOST: localhost - PGPORT: 5432 - PGUSER: postgres - PGPASSWORD: password - PGDATABASE: launchql - TEST_DB: launchql - TEST_GRAPHQL_URL: http://127.0.0.1:3000/graphql - TEST_GRAPHQL_HOST: admin.localhost - - services: - pg_db: - image: pyramation/pgvector:13.3-alpine - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: password - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 - - steps: - - name: Configure Git (for tests) - run: | - git config --global user.name "CI Test User" - git config --global user.email "ci@example.com" - - - name: checkout - uses: actions/checkout@v4 - - - name: checkout constructive-db - uses: actions/checkout@v4 - with: - repository: constructive-io/constructive-db - path: constructive-db - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Setup pnpm - uses: pnpm/action-setup@v2 - with: - version: 10 - - - name: Install dependencies - run: pnpm install - - - name: Build packages - run: pnpm run build - - - name: Setup jobs database - run: | - PGDATABASE=postgres createdb launchql || true - pnpm --filter pgpm exec pgpm admin-users bootstrap --yes --cwd constructive-db - pnpm --filter pgpm exec pgpm admin-users add --test --yes --cwd constructive-db - pnpm --filter pgpm exec pgpm deploy --yes --database "$PGDATABASE" --package app-svc-local --cwd constructive-db - pnpm --filter pgpm exec pgpm deploy --yes --database "$PGDATABASE" --package metaschema --cwd constructive-db - pnpm --filter pgpm exec pgpm deploy --yes --database "$PGDATABASE" --package pgpm-database-jobs --cwd constructive-db - - - name: Resolve database id - run: | - DBID=$(psql -h "$PGHOST" -U "$PGUSER" -d "$PGDATABASE" -Atc "SELECT id FROM metaschema_public.database ORDER BY created_at LIMIT 1;") - if [ -z "$DBID" ]; then - echo "No database id found in metaschema_public.database" >&2 - exit 1 - fi - echo "TEST_DATABASE_ID=$DBID" >> "$GITHUB_ENV" - echo "DEFAULT_DATABASE_ID=$DBID" >> "$GITHUB_ENV" - - - name: Start combined server - env: - NODE_ENV: test - PORT: "3000" - SERVER_HOST: "127.0.0.1" - API_ENABLE_META: "false" - API_EXPOSED_SCHEMAS: "app_jobs,lql_private,lql_public,lql_roles_public,metaschema_modules_public,metaschema_public,services_public" - API_ANON_ROLE: "administrator" - API_ROLE_NAME: "administrator" - API_DEFAULT_DATABASE_ID: ${{ env.DEFAULT_DATABASE_ID }} - CONSTRUCTIVE_GRAPHQL_ENABLED: "true" - CONSTRUCTIVE_JOBS_ENABLED: "true" - CONSTRUCTIVE_FUNCTIONS: "simple-email,send-email-link" - CONSTRUCTIVE_FUNCTION_PORTS: "simple-email:8081,send-email-link:8082" - SIMPLE_EMAIL_DRY_RUN: "true" - SEND_EMAIL_LINK_DRY_RUN: "true" - LOCAL_APP_PORT: "3000" - GRAPHQL_URL: "http://127.0.0.1:3000/graphql" - META_GRAPHQL_URL: "http://127.0.0.1:3000/graphql" - GRAPHQL_HOST_HEADER: "admin.localhost" - META_GRAPHQL_HOST_HEADER: "admin.localhost" - MAILGUN_DOMAIN: "mg.constructive.io" - MAILGUN_FROM: "no-reply@mg.constructive.io" - MAILGUN_REPLY: "info@mg.constructive.io" - MAILGUN_API_KEY: "change-me-mailgun-api-key" - MAILGUN_KEY: "change-me-mailgun-api-key" - JOBS_SUPPORT_ANY: "false" - JOBS_SUPPORTED: "simple-email,send-email-link" - INTERNAL_GATEWAY_DEVELOPMENT_MAP: '{"simple-email":"http://127.0.0.1:8081","send-email-link":"http://127.0.0.1:8082"}' - INTERNAL_JOBS_CALLBACK_PORT: "8080" - JOBS_CALLBACK_BASE_URL: "http://127.0.0.1:8080/callback" - FEATURES_POSTGIS: "false" - run: | - nohup node packages/server/dist/run.js > /tmp/combined-server.log 2>&1 & - echo $! > /tmp/combined-server.pid - - - name: Test server jobs e2e - run: pnpm --filter @constructive-io/server test - - - name: Stop combined server - if: always() - run: | - if [ -f /tmp/combined-server.pid ]; then - kill "$(cat /tmp/combined-server.pid)" || true - fi diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index 52079a4cd..79e676bf3 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -33,6 +33,8 @@ jobs: env: {} - package: packages/cli env: {} + - package: packages/server + env: {} - package: packages/client env: TEST_DATABASE_URL: postgres://postgres:password@localhost:5432/postgres diff --git a/packages/server/__tests__/jobs.e2e.test.ts b/packages/server/__tests__/jobs.e2e.test.ts index 2ba0d03da..92c610241 100644 --- a/packages/server/__tests__/jobs.e2e.test.ts +++ b/packages/server/__tests__/jobs.e2e.test.ts @@ -394,7 +394,6 @@ describe('jobs e2e', () => { const callbackUrl = `http://127.0.0.1:${ports.callbackPort}/callback`; process.env.NODE_ENV = 'test'; - process.env.TEST_DB = pg.config.database; process.env.PGDATABASE = pg.config.database; process.env.TEST_DATABASE_ID = databaseId; process.env.DEFAULT_DATABASE_ID = databaseId; From 1ad6dde71ed76b10449dd5abc7bb5468472fe45f Mon Sep 17 00:00:00 2001 From: zetazzz Date: Thu, 8 Jan 2026 18:48:11 +0700 Subject: [PATCH 05/12] refine jobs e2e test --- packages/server/__tests__/jobs.e2e.test.ts | 144 ++------------------- 1 file changed, 14 insertions(+), 130 deletions(-) diff --git a/packages/server/__tests__/jobs.e2e.test.ts b/packages/server/__tests__/jobs.e2e.test.ts index 92c610241..a240eb333 100644 --- a/packages/server/__tests__/jobs.e2e.test.ts +++ b/packages/server/__tests__/jobs.e2e.test.ts @@ -1,5 +1,3 @@ -import { readFile } from 'fs/promises'; -import { createServer } from 'net'; import { dirname, join } from 'path'; import supertest from 'supertest'; @@ -139,6 +137,10 @@ const waitForJobCompletion = async ( const seededDatabaseId = '0b22e268-16d6-582b-950a-24e108688849'; const metaDbExtensions = ['citext', 'uuid-ossp', 'unaccent', 'pgcrypto', 'hstore']; +const GRAPHQL_PORT = 3000; +const CALLBACK_PORT = 8080; +const SIMPLE_EMAIL_PORT = 8081; +const SEND_EMAIL_LINK_PORT = 8082; const getPgpmModulePath = (pkgName: string): string => dirname(require.resolve(`${pkgName}/pgpm.plan`)); @@ -212,106 +214,7 @@ const createTestDb = async (): Promise => { return { db, pg, teardown }; }; -const hasSchema = async (client: PgTestClient, schema: string) => { - const row = await client.oneOrNone<{ schema_name: string }>( - 'SELECT schema_name FROM information_schema.schemata WHERE schema_name = $1', - [schema] - ); - return Boolean(row?.schema_name); -}; - -const ensureJobsSchema = async (client: PgTestClient) => { - if (await hasSchema(client, 'app_jobs')) return; - await runMetaMigrations(client.config); - if (!(await hasSchema(client, 'app_jobs'))) { - throw new Error('app_jobs schema was not created by pgpm migrations'); - } -}; -const getAvailablePort = (): Promise => - new Promise((resolvePort, reject) => { - const server = createServer(); - server.listen(0, '127.0.0.1', () => { - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(() => reject(new Error('Failed to allocate a port'))); - return; - } - const port = address.port; - server.close((err) => { - if (err) { - reject(err); - return; - } - resolvePort(port); - }); - }); - server.on('error', reject); - }); - -const waitForReady = async ( - label: string, - check: () => Promise, - timeoutMs = 30000, - getLastError?: () => string | undefined -) => { - const started = Date.now(); - - while (Date.now() - started < timeoutMs) { - try { - if (await check()) return; - } catch { - // ignore and retry - } - await delay(500); - } - - const lastError = getLastError?.(); - if (lastError) { - throw new Error( - `${label} did not become ready within ${timeoutMs}ms. Last error: ${lastError}` - ); - } - throw new Error(`${label} did not become ready within ${timeoutMs}ms.`); -}; - -const waitForGraphql = async (client: GraphqlClient) => { - let lastError: string | undefined; - await waitForReady( - 'GraphQL server', - async () => { - const response = await sendGraphql(client, '{ __typename }'); - if (response.status !== 200) { - const detail = - response.text || - (response.body ? JSON.stringify(response.body) : undefined); - lastError = detail - ? `HTTP ${response.status}: ${detail}` - : `HTTP ${response.status}`; - return false; - } - if (response.body?.errors?.length) { - lastError = response.body.errors - .map((err: { message: string }) => err.message) - .join('; '); - return false; - } - lastError = undefined; - return true; - }, - 30000, - () => lastError - ); -}; - -const waitForCallbackServer = async (callbackUrl: string) => { - const origin = callbackUrl.replace(/\/callback$/, ''); - const http = supertest(origin); - await waitForReady('Jobs callback server', async () => { - const response = await http.post('/callback').send({}); - return response.status === 200; - }); -}; describe('jobs e2e', () => { let teardown: () => Promise; @@ -358,8 +261,6 @@ describe('jobs e2e', () => { if (!pg) { throw new Error('Test database connection is missing'); } - await ensureJobsSchema(pg); - databaseId = seededDatabaseId; if (pg?.oneOrNone) { const row = await pg.oneOrNone<{ id: string }>( @@ -367,15 +268,7 @@ describe('jobs e2e', () => { [databaseId] ); if (!row?.id) { - const seedSql = await readFile(sql('jobs.seed.sql'), 'utf8'); - await pg.query(seedSql); - const seeded = await pg.oneOrNone<{ id: string }>( - 'SELECT id FROM metaschema_public.database WHERE id = $1', - [databaseId] - ); - if (!seeded?.id) { - throw new Error(`Seeded database id ${databaseId} was not found`); - } + throw new Error(`Seeded database id ${databaseId} was not found`); } } @@ -383,15 +276,8 @@ describe('jobs e2e', () => { throw new Error('Test database config is missing a database name'); } - const ports = { - graphqlPort: await getAvailablePort(), - callbackPort: await getAvailablePort(), - simpleEmailPort: await getAvailablePort(), - sendEmailLinkPort: await getAvailablePort() - }; - - const graphqlUrl = `http://127.0.0.1:${ports.graphqlPort}/graphql`; - const callbackUrl = `http://127.0.0.1:${ports.callbackPort}/callback`; + const graphqlUrl = `http://127.0.0.1:${GRAPHQL_PORT}/graphql`; + const callbackUrl = `http://127.0.0.1:${CALLBACK_PORT}/callback`; process.env.NODE_ENV = 'test'; process.env.PGDATABASE = pg.config.database; @@ -402,7 +288,7 @@ describe('jobs e2e', () => { process.env.META_GRAPHQL_URL = graphqlUrl; process.env.SIMPLE_EMAIL_DRY_RUN = 'true'; process.env.SEND_EMAIL_LINK_DRY_RUN = 'true'; - process.env.LOCAL_APP_PORT = String(ports.graphqlPort); + process.env.LOCAL_APP_PORT = String(GRAPHQL_PORT); process.env.MAILGUN_DOMAIN = 'mg.constructive.io'; process.env.MAILGUN_FROM = 'no-reply@mg.constructive.io'; process.env.MAILGUN_REPLY = 'info@mg.constructive.io'; @@ -411,10 +297,10 @@ describe('jobs e2e', () => { process.env.JOBS_SUPPORT_ANY = 'false'; process.env.JOBS_SUPPORTED = 'simple-email,send-email-link'; process.env.INTERNAL_GATEWAY_DEVELOPMENT_MAP = JSON.stringify({ - 'simple-email': `http://127.0.0.1:${ports.simpleEmailPort}`, - 'send-email-link': `http://127.0.0.1:${ports.sendEmailLinkPort}` + 'simple-email': `http://127.0.0.1:${SIMPLE_EMAIL_PORT}`, + 'send-email-link': `http://127.0.0.1:${SEND_EMAIL_LINK_PORT}` }); - process.env.INTERNAL_JOBS_CALLBACK_PORT = String(ports.callbackPort); + process.env.INTERNAL_JOBS_CALLBACK_PORT = String(CALLBACK_PORT); process.env.JOBS_CALLBACK_BASE_URL = callbackUrl; process.env.FEATURES_POSTGIS = 'false'; @@ -424,8 +310,8 @@ describe('jobs e2e', () => { if (pg.config.password) process.env.PGPASSWORD = pg.config.password; const services: FunctionServiceConfig[] = [ - { name: 'simple-email', port: ports.simpleEmailPort }, - { name: 'send-email-link', port: ports.sendEmailLinkPort } + { name: 'simple-email', port: SIMPLE_EMAIL_PORT }, + { name: 'send-email-link', port: SEND_EMAIL_LINK_PORT } ]; const combinedServerOptions: CombinedServerOptions = { @@ -441,7 +327,7 @@ describe('jobs e2e', () => { }, server: { host: '127.0.0.1', - port: ports.graphqlPort + port: GRAPHQL_PORT }, api: { enableMetaApi: false, @@ -473,8 +359,6 @@ describe('jobs e2e', () => { await combinedServer.start(); graphqlClient = getGraphqlClient(); - await waitForGraphql(graphqlClient); - await waitForCallbackServer(callbackUrl); }); afterAll(async () => { From 309bc31aae23827ae09b827f2e0bf321965d0866 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Fri, 9 Jan 2026 09:39:45 +0700 Subject: [PATCH 06/12] use default job callback port 12345 --- packages/server/__tests__/jobs.e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/__tests__/jobs.e2e.test.ts b/packages/server/__tests__/jobs.e2e.test.ts index a240eb333..8451f186d 100644 --- a/packages/server/__tests__/jobs.e2e.test.ts +++ b/packages/server/__tests__/jobs.e2e.test.ts @@ -138,7 +138,7 @@ const waitForJobCompletion = async ( const seededDatabaseId = '0b22e268-16d6-582b-950a-24e108688849'; const metaDbExtensions = ['citext', 'uuid-ossp', 'unaccent', 'pgcrypto', 'hstore']; const GRAPHQL_PORT = 3000; -const CALLBACK_PORT = 8080; +const CALLBACK_PORT = 12345; const SIMPLE_EMAIL_PORT = 8081; const SEND_EMAIL_LINK_PORT = 8082; From 9e5c440492a2e67f24419d45b472246893ab305e Mon Sep 17 00:00:00 2001 From: zetazzz Date: Thu, 15 Jan 2026 15:39:08 +0800 Subject: [PATCH 07/12] added smtppostmaster --- packages/smtppostmaster/CHANGELOG.md | 10 + packages/smtppostmaster/README.md | 73 ++ .../smtppostmaster/__tests__/send.test.ts | 59 ++ .../smtppostmaster/__tests__/smtp-catcher.ts | 117 +++ .../smtppostmaster/__tests__/test-send.ts | 76 ++ packages/smtppostmaster/jest.config.js | 19 + packages/smtppostmaster/package.json | 50 + packages/smtppostmaster/src/index.ts | 164 ++++ packages/smtppostmaster/tsconfig.esm.json | 9 + packages/smtppostmaster/tsconfig.json | 9 + pgpm/env/src/env.ts | 2 +- pgpm/env/src/index.ts | 2 +- pnpm-lock.yaml | 896 ++++++++++++++++++ 13 files changed, 1484 insertions(+), 2 deletions(-) create mode 100644 packages/smtppostmaster/CHANGELOG.md create mode 100644 packages/smtppostmaster/README.md create mode 100644 packages/smtppostmaster/__tests__/send.test.ts create mode 100644 packages/smtppostmaster/__tests__/smtp-catcher.ts create mode 100644 packages/smtppostmaster/__tests__/test-send.ts create mode 100644 packages/smtppostmaster/jest.config.js create mode 100644 packages/smtppostmaster/package.json create mode 100644 packages/smtppostmaster/src/index.ts create mode 100644 packages/smtppostmaster/tsconfig.esm.json create mode 100644 packages/smtppostmaster/tsconfig.json diff --git a/packages/smtppostmaster/CHANGELOG.md b/packages/smtppostmaster/CHANGELOG.md new file mode 100644 index 000000000..de8f75b4b --- /dev/null +++ b/packages/smtppostmaster/CHANGELOG.md @@ -0,0 +1,10 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## 0.1.0 (2026-01-02) + +### Features + +- initial SMTP-based postmaster implementation diff --git a/packages/smtppostmaster/README.md b/packages/smtppostmaster/README.md new file mode 100644 index 000000000..834829077 --- /dev/null +++ b/packages/smtppostmaster/README.md @@ -0,0 +1,73 @@ +# @constructive-io/smtppostmaster + +SMTP-based email sender for Constructive services. This package exposes a `send` helper with the same call shape used by `@launchql/postmaster` (e.g. `{ to, subject, html, text }`). + +## Install + +```bash +pnpm add @constructive-io/smtppostmaster +``` + +## Usage + +```ts +import { send } from '@constructive-io/smtppostmaster'; + +await send({ + to: 'user@example.com', + subject: 'Welcome', + html: '

Hello from SMTP

' +}); +``` + +## Environment variables + +Required (unless noted): + +- `SMTP_HOST` (required) +- `SMTP_PORT` (optional, default: `587`) +- `SMTP_SECURE` (`true`/`false`; default: `false`, set `true` for port `465`) +- `SMTP_USER` (optional if the server allows anonymous auth) +- `SMTP_PASS` (required when `SMTP_USER` is set and auth is required) +- `SMTP_FROM` (default sender address if `from` is not passed to `send`) + +Optional: + +- `SMTP_REPLY_TO` (default reply-to address when not provided per message) +- `SMTP_REQUIRE_TLS` (`true`/`false`) +- `SMTP_TLS_REJECT_UNAUTHORIZED` (`true`/`false`, default: `true`) +- `SMTP_POOL` (`true`/`false`) +- `SMTP_MAX_CONNECTIONS` (number) +- `SMTP_MAX_MESSAGES` (number) +- `SMTP_NAME` (client hostname) +- `SMTP_LOGGER` (`true`/`false`, nodemailer transport logging) +- `SMTP_DEBUG` (`true`/`false`, nodemailer debug output) + +## Test / debug + +This package ships a small test runner you can use to validate your SMTP settings. + +```bash +SMTP_HOST=smtp.example.com \ +SMTP_PORT=587 \ +SMTP_USER=example \ +SMTP_PASS=secret \ +SMTP_FROM="no-reply@example.com" \ +SMTP_TEST_TO="you@example.com" \ +pnpm --filter "@constructive-io/smtppostmaster" test:send +``` + +To use the built-in local SMTP catcher instead of a real SMTP server: + +```bash +SMTP_TEST_USE_CATCHER=true \ +pnpm --filter "@constructive-io/smtppostmaster" test:send +``` + +Optional test overrides: + +- `SMTP_TEST_FROM` +- `SMTP_TEST_SUBJECT` +- `SMTP_TEST_TEXT` +- `SMTP_TEST_HTML` +- `SMTP_TEST_USE_CATCHER` diff --git a/packages/smtppostmaster/__tests__/send.test.ts b/packages/smtppostmaster/__tests__/send.test.ts new file mode 100644 index 000000000..72c271186 --- /dev/null +++ b/packages/smtppostmaster/__tests__/send.test.ts @@ -0,0 +1,59 @@ +import { send } from '../src/index'; +import { createSmtpCatcher } from './smtp-catcher'; + +const applyEnv = (overrides: Record) => { + const previous: Record = {}; + + Object.keys(overrides).forEach((key) => { + previous[key] = process.env[key]; + const value = overrides[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + }); + + return () => { + Object.keys(overrides).forEach((key) => { + const value = previous[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + }); + }; +}; + +describe('send', () => { + it('sends email via SMTP catcher', async () => { + const catcher = await createSmtpCatcher(); + const restoreEnv = applyEnv({ + SMTP_HOST: catcher.host, + SMTP_PORT: String(catcher.port), + SMTP_SECURE: 'false', + SMTP_FROM: 'no-reply@example.com', + SMTP_USER: undefined, + SMTP_PASS: undefined, + SMTP_REQUIRE_TLS: undefined, + SMTP_TLS_REJECT_UNAUTHORIZED: 'false' + }); + + try { + await send({ + to: 'recipient@example.com', + subject: 'SMTP postmaster test', + text: 'Hello from the SMTP test.' + }); + + const message = await catcher.waitForMessage(5000); + + expect(message.raw).toContain('Subject: SMTP postmaster test'); + expect(message.raw).toContain('Hello from the SMTP test.'); + } finally { + restoreEnv(); + await catcher.stop(); + } + }, 10000); +}); diff --git a/packages/smtppostmaster/__tests__/smtp-catcher.ts b/packages/smtppostmaster/__tests__/smtp-catcher.ts new file mode 100644 index 000000000..56c8b41cc --- /dev/null +++ b/packages/smtppostmaster/__tests__/smtp-catcher.ts @@ -0,0 +1,117 @@ +import { AddressInfo } from 'net'; +import { SMTPServer } from 'smtp-server'; +import type { SMTPServerSession } from 'smtp-server'; + +export type CapturedMessage = { + envelope: SMTPServerSession['envelope']; + raw: string; +}; + +export type SmtpCatcher = { + host: string; + port: number; + messages: CapturedMessage[]; + waitForMessage: (timeoutMs?: number) => Promise; + stop: () => Promise; +}; + +const readStream = (stream: NodeJS.ReadableStream) => + new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + + stream.on('data', (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + + stream.on('error', (error) => { + reject(error); + }); + + stream.on('end', () => { + resolve(Buffer.concat(chunks)); + }); + }); + +export const createSmtpCatcher = async (options?: { + host?: string; + port?: number; +}): Promise => { + const host = options?.host ?? '127.0.0.1'; + const messages: CapturedMessage[] = []; + + const server = new SMTPServer({ + disabledCommands: ['AUTH'], + onData(stream, session, callback) { + readStream(stream) + .then((buffer) => { + messages.push({ + envelope: session.envelope, + raw: buffer.toString('utf8') + }); + callback(); + }) + .catch((error) => callback(error)); + } + }); + + await new Promise((resolve, reject) => { + server.listen(options?.port ?? 0, host, (error?: Error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + + const addressInfo = ( + server as unknown as { server?: { address: () => AddressInfo | string | null } } + ).server?.address(); + + if (!addressInfo || typeof addressInfo === 'string') { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + throw new Error('Failed to resolve SMTP catcher port'); + } + + const waitForMessage = (timeoutMs = 2000) => + new Promise((resolve, reject) => { + if (messages.length > 0) { + resolve(messages[messages.length - 1]); + return; + } + + const interval = setInterval(() => { + if (messages.length > 0) { + clearInterval(interval); + clearTimeout(timeout); + resolve(messages[messages.length - 1]); + } + }, 20); + + const timeout = setTimeout(() => { + clearInterval(interval); + reject(new Error('Timed out waiting for SMTP message')); + }, timeoutMs); + }); + + const stop = () => + new Promise((resolve, reject) => { + server.close((error?: Error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + + return { + host, + port: addressInfo.port, + messages, + waitForMessage, + stop + }; +}; diff --git a/packages/smtppostmaster/__tests__/test-send.ts b/packages/smtppostmaster/__tests__/test-send.ts new file mode 100644 index 000000000..6d166dad5 --- /dev/null +++ b/packages/smtppostmaster/__tests__/test-send.ts @@ -0,0 +1,76 @@ +import { parseEnvBoolean } from '@pgpmjs/env'; +import { send } from '../src/index'; +import { createSmtpCatcher } from './smtp-catcher'; + +const main = async () => { + const useCatcher = parseEnvBoolean(process.env.SMTP_TEST_USE_CATCHER) ?? false; + const catcher = useCatcher ? await createSmtpCatcher() : null; + + if (catcher) { + process.env.SMTP_HOST = catcher.host; + process.env.SMTP_PORT = String(catcher.port); + process.env.SMTP_SECURE = 'false'; + process.env.SMTP_TLS_REJECT_UNAUTHORIZED = 'false'; + + if (!process.env.SMTP_FROM && !process.env.SMTP_TEST_FROM) { + process.env.SMTP_FROM = 'no-reply@example.com'; + } + } + + const to = + process.env.SMTP_TEST_TO ?? + process.env.SMTP_TO ?? + (catcher ? 'test-recipient@example.com' : undefined); + if (!to) { + throw new Error('Missing SMTP_TEST_TO (or SMTP_TO)'); + } + + const subject = process.env.SMTP_TEST_SUBJECT ?? 'SMTP postmaster test email'; + const html = + process.env.SMTP_TEST_HTML ?? + '

This is a test email from @constructive-io/smtppostmaster.

'; + const text = + process.env.SMTP_TEST_TEXT ?? + 'This is a test email from @constructive-io/smtppostmaster.'; + const from = process.env.SMTP_TEST_FROM; + + const start = Date.now(); + + try { + const info = await send({ + to, + subject, + html, + text, + ...(from ? { from } : {}) + }); + + if (catcher) { + const message = await catcher.waitForMessage(5000); + // eslint-disable-next-line no-console + console.log('[smtppostmaster] Captured test email', { + envelope: message.envelope, + preview: message.raw.slice(0, 200) + }); + } + + // eslint-disable-next-line no-console + console.log('[smtppostmaster] Sent test email', { + messageId: info.messageId, + response: info.response, + accepted: info.accepted, + rejected: info.rejected, + timeMs: Date.now() - start + }); + } finally { + if (catcher) { + await catcher.stop(); + } + } +}; + +main().catch((error) => { + // eslint-disable-next-line no-console + console.error('[smtppostmaster] Failed to send test email', error); + process.exitCode = 1; +}); diff --git a/packages/smtppostmaster/jest.config.js b/packages/smtppostmaster/jest.config.js new file mode 100644 index 000000000..b4fe7f95a --- /dev/null +++ b/packages/smtppostmaster/jest.config.js @@ -0,0 +1,19 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + babelConfig: false, + tsconfig: 'tsconfig.json' + } + ] + }, + transformIgnorePatterns: ['/node_modules/*'], + testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + modulePathIgnorePatterns: ['dist/*'], + testPathIgnorePatterns: ['__tests__/smtp-catcher.ts', '__tests__/test-send.ts'] +}; diff --git a/packages/smtppostmaster/package.json b/packages/smtppostmaster/package.json new file mode 100644 index 000000000..71c1df1eb --- /dev/null +++ b/packages/smtppostmaster/package.json @@ -0,0 +1,50 @@ +{ + "name": "@constructive-io/smtppostmaster", + "version": "0.1.0", + "author": "Constructive ", + "description": "SMTP email sender for Constructive", + "main": "index.js", + "module": "esm/index.js", + "types": "index.d.ts", + "homepage": "https://github.com/constructive-io/constructive", + "license": "MIT", + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "repository": { + "type": "git", + "url": "https://github.com/constructive-io/constructive" + }, + "bugs": { + "url": "https://github.com/constructive-io/constructive/issues" + }, + "scripts": { + "clean": "makage clean", + "prepack": "npm run build", + "build": "makage build", + "build:dev": "makage build --dev", + "lint": "eslint . --fix", + "test": "jest --passWithNoTests", + "test:watch": "jest --watch", + "test:send": "ts-node __tests__/test-send.ts", + "test:send:dev": "ts-node __tests__/test-send.ts" + }, + "dependencies": { + "@pgpmjs/env": "workspace:^", + "nodemailer": "^6.9.13" + }, + "devDependencies": { + "@types/nodemailer": "^7.0.5", + "@types/smtp-server": "^3.5.12", + "makage": "^0.1.10", + "smtp-server": "^3.14.0", + "ts-node": "^10.9.2" + }, + "keywords": [ + "smtp", + "email", + "nodemailer", + "postmaster" + ] +} diff --git a/packages/smtppostmaster/src/index.ts b/packages/smtppostmaster/src/index.ts new file mode 100644 index 000000000..b29399654 --- /dev/null +++ b/packages/smtppostmaster/src/index.ts @@ -0,0 +1,164 @@ +import nodemailer, { SendMailOptions } from 'nodemailer'; +import SMTPTransport from 'nodemailer/lib/smtp-transport'; +import { parseEnvBoolean, parseEnvNumber } from '@pgpmjs/env'; + +type SendInput = { + to: string | string[]; + subject: string; + html?: string; + text?: string; + from?: string; + cc?: string | string[]; + bcc?: string | string[]; + replyTo?: string; + headers?: Record; + attachments?: SendMailOptions['attachments']; +}; + +type TransportConfig = SMTPTransport.Options & { + logger?: boolean; + debug?: boolean; + pool?: boolean; + maxConnections?: number; + maxMessages?: number; +}; + +const parseNumberFromEnv = (name: string) => { + const raw = process.env[name]; + if (raw === undefined || raw === '') { + return undefined; + } + + const parsed = parseEnvNumber(raw); + if (parsed === undefined) { + throw new Error(`${name} must be a number`); + } + + return parsed; +}; + +const buildTransportOptions = (): TransportConfig => { + const host = process.env.SMTP_HOST; + if (!host) { + throw new Error('Missing SMTP_HOST'); + } + + const portFromEnv = parseNumberFromEnv('SMTP_PORT'); + const secureFromEnv = parseEnvBoolean(process.env.SMTP_SECURE); + + const resolvedPort = portFromEnv ?? (secureFromEnv ? 465 : 587); + const resolvedSecure = secureFromEnv ?? resolvedPort === 465; + + const user = process.env.SMTP_USER; + const pass = process.env.SMTP_PASS; + const auth = user + ? { + user, + pass: pass ?? '' + } + : undefined; + + const requireTLS = parseEnvBoolean(process.env.SMTP_REQUIRE_TLS); + const tlsRejectUnauthorized = parseEnvBoolean(process.env.SMTP_TLS_REJECT_UNAUTHORIZED); + const pool = parseEnvBoolean(process.env.SMTP_POOL); + const maxConnections = parseNumberFromEnv('SMTP_MAX_CONNECTIONS'); + const maxMessages = parseNumberFromEnv('SMTP_MAX_MESSAGES'); + const name = process.env.SMTP_NAME; + const logger = parseEnvBoolean(process.env.SMTP_LOGGER); + const debug = parseEnvBoolean(process.env.SMTP_DEBUG); + + const options: TransportConfig = { + host, + port: resolvedPort, + secure: resolvedSecure + }; + + if (auth) { + options.auth = auth; + } + + if (requireTLS !== undefined) { + options.requireTLS = requireTLS; + } + + if (tlsRejectUnauthorized !== undefined) { + options.tls = { + rejectUnauthorized: tlsRejectUnauthorized + }; + } + + if (pool !== undefined) { + options.pool = pool; + } + + if (maxConnections !== undefined) { + options.maxConnections = maxConnections; + } + + if (maxMessages !== undefined) { + options.maxMessages = maxMessages; + } + + if (name) { + options.name = name; + } + + if (logger !== undefined) { + options.logger = logger; + } + + if (debug !== undefined) { + options.debug = debug; + } + + return options; +}; + +let transport: nodemailer.Transporter | undefined; + +const getTransport = () => { + if (!transport) { + transport = nodemailer.createTransport(buildTransportOptions()); + } + + return transport; +}; + +const resolveFrom = (from?: string) => { + const resolved = from ?? process.env.SMTP_FROM; + if (!resolved) { + throw new Error('Missing from address. Set SMTP_FROM or pass from in send().'); + } + return resolved; +}; + +export const send = async (options: SendInput) => { + if (!options.to) { + throw new Error('Missing "to"'); + } + + if (!options.subject) { + throw new Error('Missing "subject"'); + } + + if (!options.html && !options.text) { + throw new Error('Missing "html" or "text"'); + } + + const mailOptions: SendMailOptions = { + to: options.to, + subject: options.subject, + html: options.html, + text: options.text, + from: resolveFrom(options.from), + cc: options.cc, + bcc: options.bcc, + replyTo: options.replyTo ?? process.env.SMTP_REPLY_TO, + headers: options.headers, + attachments: options.attachments + }; + + return getTransport().sendMail(mailOptions); +}; + +export type { SendInput as SendOptions }; diff --git a/packages/smtppostmaster/tsconfig.esm.json b/packages/smtppostmaster/tsconfig.esm.json new file mode 100644 index 000000000..800d7506d --- /dev/null +++ b/packages/smtppostmaster/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist/esm", + "module": "es2022", + "rootDir": "src/", + "declaration": false + } +} diff --git a/packages/smtppostmaster/tsconfig.json b/packages/smtppostmaster/tsconfig.json new file mode 100644 index 000000000..1a9d5696c --- /dev/null +++ b/packages/smtppostmaster/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src/" + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules", "**/*.spec.*", "**/*.test.*"] +} diff --git a/pgpm/env/src/env.ts b/pgpm/env/src/env.ts index e2dead9a0..b7298ad70 100644 --- a/pgpm/env/src/env.ts +++ b/pgpm/env/src/env.ts @@ -1,6 +1,6 @@ import { PgpmOptions, BucketProvider } from '@pgpmjs/types'; -const parseEnvNumber = (val?: string): number | undefined => { +export const parseEnvNumber = (val?: string): number | undefined => { const num = Number(val); return !isNaN(num) ? num : undefined; }; diff --git a/pgpm/env/src/index.ts b/pgpm/env/src/index.ts index 7a5473ec8..66fb144f2 100644 --- a/pgpm/env/src/index.ts +++ b/pgpm/env/src/index.ts @@ -10,7 +10,7 @@ export { resolveWorkspaceByType } from './config'; export type { WorkspaceType } from './config'; -export { getEnvVars, getNodeEnv, parseEnvBoolean } from './env'; +export { getEnvVars, getNodeEnv, parseEnvBoolean, parseEnvNumber } from './env'; export { walkUp, mergeArraysUnique } from './utils'; export type { PgpmOptions, PgTestConnectionOptions, DeploymentOptions } from '@pgpmjs/types'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7537fe2cf..355478a27 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1516,6 +1516,32 @@ importers: version: 10.9.2(@types/node@20.19.27)(typescript@5.9.3) publishDirectory: dist + packages/smtppostmaster: + dependencies: + '@pgpmjs/env': + specifier: workspace:^ + version: link:../../pgpm/env/dist + nodemailer: + specifier: ^6.9.13 + version: 6.10.1 + devDependencies: + '@types/nodemailer': + specifier: ^7.0.5 + version: 7.0.5 + '@types/smtp-server': + specifier: ^3.5.12 + version: 3.5.12 + makage: + specifier: ^0.1.10 + version: 0.1.10 + smtp-server: + specifier: ^3.14.0 + version: 3.18.0 + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@20.19.27)(typescript@5.9.3) + publishDirectory: dist + packages/url-domains: dependencies: express: @@ -2184,14 +2210,26 @@ packages: resolution: {integrity: sha512-ol8Sw37AToBWb6PjRuT/Wu40SrrZSA0N4F7U3yTkjUNX0lirfO1VFLZ0hZtZplVJv8GNPITbiczxQ8VjxESXxg==} engines: {node: '>=18.0.0'} + '@aws-sdk/client-sesv2@3.969.0': + resolution: {integrity: sha512-YnBJRtueyNAeKJvRNBVAeH9fh5X8KmMa4fp1Zn1Hex0G5bRKm0aUdS4i+p5cOIpCyBV9hyLGGkaCBDC4Han7aw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-sso@3.958.0': resolution: {integrity: sha512-6qNCIeaMzKzfqasy2nNRuYnMuaMebCcCPP4J2CVGkA8QYMbIVKPlkn9bpB20Vxe6H/r3jtCCLQaOJjVTx/6dXg==} engines: {node: '>=18.0.0'} + '@aws-sdk/client-sso@3.969.0': + resolution: {integrity: sha512-Qn0Uz6o15q2S+1E6OpwRKmaAMoT4LktEn+Oibk28qb2Mne+emaDawhZXahOJb/wFw5lN2FEH7XoiSNenNNUmCw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.957.0': resolution: {integrity: sha512-DrZgDnF1lQZv75a52nFWs6MExihJF2GZB6ETZRqr6jMwhrk2kbJPUtvgbifwcL7AYmVqHQDJBrR/MqkwwFCpiw==} engines: {node: '>=18.0.0'} + '@aws-sdk/core@3.969.0': + resolution: {integrity: sha512-qqmQt4z5rEK1OYVkVkboWgy/58CC5QaQ7oy0tvLe3iri/mfZbgJkA+pkwQyRP827DfCBZ3W7Ki9iwSa+B2U7uQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/crc64-nvme@3.957.0': resolution: {integrity: sha512-qSwSfI+qBU9HDsd6/4fM9faCxYJx2yDuHtj+NVOQ6XYDWQzFab/hUdwuKZ77Pi6goLF1pBZhJ2azaC2w7LbnTA==} engines: {node: '>=18.0.0'} @@ -2200,34 +2238,66 @@ packages: resolution: {integrity: sha512-475mkhGaWCr+Z52fOOVb/q2VHuNvqEDixlYIkeaO6xJ6t9qR0wpLt4hOQaR6zR1wfZV0SlE7d8RErdYq/PByog==} engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-env@3.969.0': + resolution: {integrity: sha512-yS96heH5XDUqS3qQNcdObKKMOqZaivuNInMVRpRli48aXW8fX1M3fY67K/Onlqa3Wxu6WfDc3ZGF52SywdLvbg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.957.0': resolution: {integrity: sha512-8dS55QHRxXgJlHkEYaCGZIhieCs9NU1HU1BcqQ4RfUdSsfRdxxktqUKgCnBnOOn0oD3PPA8cQOCAVgIyRb3Rfw==} engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-http@3.969.0': + resolution: {integrity: sha512-QCEFxBiUYFUW5VG6k8jKhT4luZndpC7uUY4u1olwt+OnJrl3N2yC7oS34isVBa3ioXZ4A0YagbXTa/3mXUhlAA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.958.0': resolution: {integrity: sha512-u7twvZa1/6GWmPBZs6DbjlegCoNzNjBsMS/6fvh5quByYrcJr/uLd8YEr7S3UIq4kR/gSnHqcae7y2nL2bqZdg==} engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-ini@3.969.0': + resolution: {integrity: sha512-lsXyTDkUrZPxjr0XruZrqdcHY9zHcIuoY3TOCQEm23VTc8Np2BenTtjGAIexkL3ar69K4u3FVLQroLpmFxeXqA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.958.0': resolution: {integrity: sha512-sDwtDnBSszUIbzbOORGh5gmXGl9aK25+BHb4gb1aVlqB+nNL2+IUEJA62+CE55lXSH8qXF90paivjK8tOHTwPA==} engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-login@3.969.0': + resolution: {integrity: sha512-bIRFDf54qIUFFLTZNYt40d6EseNeK9w80dHEs7BVEAWoS23c9+MSqkdg/LJBBK9Kgy01vRmjiedfBZN+jGypLw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.958.0': resolution: {integrity: sha512-vdoZbNG2dt66I7EpN3fKCzi6fp9xjIiwEA/vVVgqO4wXCGw8rKPIdDUus4e13VvTr330uQs2W0UNg/7AgtquEQ==} engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-node@3.969.0': + resolution: {integrity: sha512-lImMjcy/5SGDIBk7PFJCqFO4rFuapKCvo1z2PidD3Cbz2D7wsJnyqUNQIp5Ix0Xc3/uAYG9zXI9kgaMf1dspIQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.957.0': resolution: {integrity: sha512-/KIz9kadwbeLy6SKvT79W81Y+hb/8LMDyeloA2zhouE28hmne+hLn0wNCQXAAupFFlYOAtZR2NTBs7HBAReJlg==} engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-process@3.969.0': + resolution: {integrity: sha512-2qQkM0rwd8Hl9nIHtUaqT8Z/djrulovqx/wBHsbRKaISwc2fiT3De1Lk1jx34Jzrz/dTHAMJJi+cML1N4Lk3kw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.958.0': resolution: {integrity: sha512-CBYHJ5ufp8HC4q+o7IJejCUctJXWaksgpmoFpXerbjAso7/Fg7LLUu9inXVOxlHKLlvYekDXjIUBXDJS2WYdgg==} engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-sso@3.969.0': + resolution: {integrity: sha512-JHqXw9Ct3dtZB86/zGFJYWyodr961GyIrqTBhV0brrZFPvcinM9abDSK58jt6GNBM2lqfMCvXL6I4ahNsMdkrg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.958.0': resolution: {integrity: sha512-dgnvwjMq5Y66WozzUzxNkCFap+umHUtqMMKlr8z/vl9NYMLem/WUbWNpFFOVFWquXikc+ewtpBMR4KEDXfZ+KA==} engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-web-identity@3.969.0': + resolution: {integrity: sha512-mKCZtqrs3ts3YmIjT4NFlYgT2Oe6syW0nX5m2l7iyrFrLXw26Zo3rx29DjGzycPdJHZZvsIy5y6yqChDuF65ng==} + engines: {node: '>=20.0.0'} + '@aws-sdk/lib-storage@3.958.0': resolution: {integrity: sha512-cd8CTiJ165ep2DKTc2PHHhVCxDn3byv10BXMGn+lkDY3KwMoatcgZ1uhFWCBuJvsCUnSExqGouJN/Q0qgjkWtg==} engines: {node: '>=18.0.0'} @@ -2250,6 +2320,10 @@ packages: resolution: {integrity: sha512-BBgKawVyfQZglEkNTuBBdC3azlyqNXsvvN4jPkWAiNYcY0x1BasaJFl+7u/HisfULstryweJq/dAvIZIxzlZaA==} engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-host-header@3.969.0': + resolution: {integrity: sha512-AWa4rVsAfBR4xqm7pybQ8sUNJYnjyP/bJjfAw34qPuh3M9XrfGbAHG0aiAfQGrBnmS28jlO6Kz69o+c6PRw1dw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-location-constraint@3.957.0': resolution: {integrity: sha512-y8/W7TOQpmDJg/fPYlqAhwA4+I15LrS7TwgUEoxogtkD8gfur9wFMRLT8LCyc9o4NMEcAnK50hSb4+wB0qv6tQ==} engines: {node: '>=18.0.0'} @@ -2258,14 +2332,26 @@ packages: resolution: {integrity: sha512-w1qfKrSKHf9b5a8O76yQ1t69u6NWuBjr5kBX+jRWFx/5mu6RLpqERXRpVJxfosbep7k3B+DSB5tZMZ82GKcJtQ==} engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-logger@3.969.0': + resolution: {integrity: sha512-xwrxfip7Y2iTtCMJ+iifN1E1XMOuhxIHY9DreMCvgdl4r7+48x2S1bCYPWH3eNY85/7CapBWdJ8cerpEl12sQQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-recursion-detection@3.957.0': resolution: {integrity: sha512-D2H/WoxhAZNYX+IjkKTdOhOkWQaK0jjJrDBj56hKjU5c9ltQiaX/1PqJ4dfjHntEshJfu0w+E6XJ+/6A6ILBBA==} engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-recursion-detection@3.969.0': + resolution: {integrity: sha512-2r3PuNquU3CcS1Am4vn/KHFwLi8QFjMdA/R+CRDXT4AFO/0qxevF/YStW3gAKntQIgWgQV8ZdEtKAoJvLI4UWg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-sdk-s3@3.957.0': resolution: {integrity: sha512-5B2qY2nR2LYpxoQP0xUum5A1UNvH2JQpLHDH1nWFNF/XetV7ipFHksMxPNhtJJ6ARaWhQIDXfOUj0jcnkJxXUg==} engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-sdk-s3@3.969.0': + resolution: {integrity: sha512-xjcyZrbtvVaqkmjkhmqX+16Wf7zFVS/cYnNFu/JyG6ekkIxSXEAjptNwSEDzlAiLzf0Hf6dYj5erLZYGa40eWg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-ssec@3.957.0': resolution: {integrity: sha512-qwkmrK0lizdjNt5qxl4tHYfASh8DFpHXM1iDVo+qHe+zuslfMqQEGRkzxS8tJq/I+8F0c6v3IKOveKJAfIvfqQ==} engines: {node: '>=18.0.0'} @@ -2274,34 +2360,66 @@ packages: resolution: {integrity: sha512-50vcHu96XakQnIvlKJ1UoltrFODjsq2KvtTgHiPFteUS884lQnK5VC/8xd1Msz/1ONpLMzdCVproCQqhDTtMPQ==} engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-user-agent@3.969.0': + resolution: {integrity: sha512-Y6WkW8QQ2X9jG9HNBWyzp5KlJOCtLqX8VIvGLoGc2wXdZH7dgOy62uFhkfnHbgfiel6fkNYaycjGx/yyxi0JLQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.958.0': resolution: {integrity: sha512-/KuCcS8b5TpQXkYOrPLYytrgxBhv81+5pChkOlhegbeHttjM69pyUpQVJqyfDM/A7wPLnDrzCAnk4zaAOkY0Nw==} engines: {node: '>=18.0.0'} + '@aws-sdk/nested-clients@3.969.0': + resolution: {integrity: sha512-MJrejgODxVYZjQjSpPLJkVuxnbrue1x1R8+as3anT5V/wk9Qc/Pf5B1IFjM3Ak6uOtzuRYNY4auOvcg4U8twDA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/region-config-resolver@3.957.0': resolution: {integrity: sha512-V8iY3blh8l2iaOqXWW88HbkY5jDoWjH56jonprG/cpyqqCnprvpMUZWPWYJoI8rHRf2bqzZeql1slxG6EnKI7A==} engines: {node: '>=18.0.0'} + '@aws-sdk/region-config-resolver@3.969.0': + resolution: {integrity: sha512-scj9OXqKpcjJ4jsFLtqYWz3IaNvNOQTFFvEY8XMJXTv+3qF5I7/x9SJtKzTRJEBF3spjzBUYPtGFbs9sj4fisQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/signature-v4-multi-region@3.957.0': resolution: {integrity: sha512-t6UfP1xMUigMMzHcb7vaZcjv7dA2DQkk9C/OAP1dKyrE0vb4lFGDaTApi17GN6Km9zFxJthEMUbBc7DL0hq1Bg==} engines: {node: '>=18.0.0'} + '@aws-sdk/signature-v4-multi-region@3.969.0': + resolution: {integrity: sha512-pv8BEQOlUzK+ww8ZfXZOnDzLfPO5+O7puBFtU1fE8CdCAQ/RP/B1XY3hxzW9Xs0dax7graYKnY8wd8ooYy7vBw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.958.0': resolution: {integrity: sha512-UCj7lQXODduD1myNJQkV+LYcGYJ9iiMggR8ow8Hva1g3A/Na5imNXzz6O67k7DAee0TYpy+gkNw+SizC6min8Q==} engines: {node: '>=18.0.0'} + '@aws-sdk/token-providers@3.969.0': + resolution: {integrity: sha512-ucs6QczPkvGinbGmhMlPCQnagGJ+xsM6itsSWlJzxo9YsP6jR75cBU8pRdaM7nEbtCDnrUHf8W9g3D2Hd9mgVA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.957.0': resolution: {integrity: sha512-wzWC2Nrt859ABk6UCAVY/WYEbAd7FjkdrQL6m24+tfmWYDNRByTJ9uOgU/kw9zqLCAwb//CPvrJdhqjTznWXAg==} engines: {node: '>=18.0.0'} + '@aws-sdk/types@3.969.0': + resolution: {integrity: sha512-7IIzM5TdiXn+VtgPdVLjmE6uUBUtnga0f4RiSEI1WW10RPuNvZ9U+pL3SwDiRDAdoGrOF9tSLJOFZmfuwYuVYQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-arn-parser@3.957.0': resolution: {integrity: sha512-Aj6m+AyrhWyg8YQ4LDPg2/gIfGHCEcoQdBt5DeSFogN5k9mmJPOJ+IAmNSWmWRjpOxEy6eY813RNDI6qS97M0g==} engines: {node: '>=18.0.0'} + '@aws-sdk/util-arn-parser@3.968.0': + resolution: {integrity: sha512-gqqvYcitIIM2K4lrDX9de9YvOfXBcVdxfT/iLnvHJd4YHvSXlt+gs+AsL4FfPCxG4IG9A+FyulP9Sb1MEA75vw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-endpoints@3.957.0': resolution: {integrity: sha512-xwF9K24mZSxcxKS3UKQFeX/dPYkEps9wF1b+MGON7EvnbcucrJGyQyK1v1xFPn1aqXkBTFi+SZaMRx5E5YCVFw==} engines: {node: '>=18.0.0'} + '@aws-sdk/util-endpoints@3.969.0': + resolution: {integrity: sha512-H2x2UwYiA1pHg40jE+OCSc668W9GXRShTiCWy1UPKtZKREbQ63Mgd7NAj+bEMsZUSCdHywqmSsLqKM9IcqQ3Bg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-locate-window@3.957.0': resolution: {integrity: sha512-nhmgKHnNV9K+i9daumaIz8JTLsIIML9PE/HUks5liyrjUzenjW/aHoc7WJ9/Td/gPZtayxFnXQSJRb/fDlBuJw==} engines: {node: '>=18.0.0'} @@ -2309,6 +2427,9 @@ packages: '@aws-sdk/util-user-agent-browser@3.957.0': resolution: {integrity: sha512-exueuwxef0lUJRnGaVkNSC674eAiWU07ORhxBnevFFZEKisln+09Qrtw823iyv5I1N8T+wKfh95xvtWQrNKNQw==} + '@aws-sdk/util-user-agent-browser@3.969.0': + resolution: {integrity: sha512-bpJGjuKmFr0rA6UKUCmN8D19HQFMLXMx5hKBXqBlPFdalMhxJSjcxzX9DbQh0Fn6bJtxCguFmRGOBdQqNOt49g==} + '@aws-sdk/util-user-agent-node@3.957.0': resolution: {integrity: sha512-ycbYCwqXk4gJGp0Oxkzf2KBeeGBdTxz559D41NJP8FlzSej1Gh7Rk40Zo6AyTfsNWkrl/kVi1t937OIzC5t+9Q==} engines: {node: '>=18.0.0'} @@ -2318,10 +2439,23 @@ packages: aws-crt: optional: true + '@aws-sdk/util-user-agent-node@3.969.0': + resolution: {integrity: sha512-D11ZuXNXdUMv8XTthMx+LPzkYNQAeQ68FnCTGnFLgLpnR8hVTeZMBBKjQ77wYGzWDk/csHKdCy697gU1On5KjA==} + engines: {node: '>=20.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + '@aws-sdk/xml-builder@3.957.0': resolution: {integrity: sha512-Ai5iiQqS8kJ5PjzMhWcLKN0G2yasAkvpnPlq2EnqlIMdB48HsizElt62qcktdxp4neRMyGkFq4NzgmDbXnhRiA==} engines: {node: '>=18.0.0'} + '@aws-sdk/xml-builder@3.969.0': + resolution: {integrity: sha512-BSe4Lx/qdRQQdX8cSSI7Et20vqBspzAjBy8ZmXVoyLkol3y4sXBXzn+BiLtR+oh60ExQn6o2DU4QjdOZbXaKIQ==} + engines: {node: '>=20.0.0'} + '@aws/lambda-invoke-store@0.2.2': resolution: {integrity: sha512-C0NBLsIqzDIae8HFw9YIrIBsbc0xTiOtt7fAukGPnqQ/+zZNaq+4jhuccltK0QuWHBnNm/a6kLIRA6GFiM10eg==} engines: {node: '>=18.0.0'} @@ -3308,6 +3442,10 @@ packages: resolution: {integrity: sha512-rzMY6CaKx2qxrbYbqjXWS0plqEy7LOdKHS0bg4ixJ6aoGDPNUcLWk/FRNuCILh7GKLG9TFUXYYeQQldMBBwuyw==} engines: {node: '>=18.0.0'} + '@smithy/abort-controller@4.2.8': + resolution: {integrity: sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==} + engines: {node: '>=18.0.0'} + '@smithy/chunked-blob-reader-native@4.2.1': resolution: {integrity: sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ==} engines: {node: '>=18.0.0'} @@ -3320,14 +3458,26 @@ packages: resolution: {integrity: sha512-HAGoUAFYsUkoSckuKbCPayECeMim8pOu+yLy1zOxt1sifzEbrsRpYa+mKcMdiHKMeiqOibyPG0sFJnmaV/OGEg==} engines: {node: '>=18.0.0'} + '@smithy/config-resolver@4.4.6': + resolution: {integrity: sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==} + engines: {node: '>=18.0.0'} + '@smithy/core@3.20.0': resolution: {integrity: sha512-WsSHCPq/neD5G/MkK4csLI5Y5Pkd9c1NMfpYEKeghSGaD4Ja1qLIohRQf2D5c1Uy5aXp76DeKHkzWZ9KAlHroQ==} engines: {node: '>=18.0.0'} + '@smithy/core@3.20.5': + resolution: {integrity: sha512-0Tz77Td8ynHaowXfOdrD0F1IH4tgWGUhwmLwmpFyTbr+U9WHXNNp9u/k2VjBXGnSe7BwjBERRpXsokGTXzNjhA==} + engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.2.7': resolution: {integrity: sha512-CmduWdCiILCRNbQWFR0OcZlUPVtyE49Sr8yYL0rZQ4D/wKxiNzBNS/YHemvnbkIWj623fplgkexUd/c9CAKdoA==} engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.2.8': + resolution: {integrity: sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==} + engines: {node: '>=18.0.0'} + '@smithy/eventstream-codec@4.2.7': resolution: {integrity: sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ==} engines: {node: '>=18.0.0'} @@ -3352,6 +3502,10 @@ packages: resolution: {integrity: sha512-h/Fi+o7mti4n8wx1SR6UHWLaakwHRx29sizvp8OOm7iqwKGFneT06GCSFhml6Bha5BT6ot5pj3CYZnCHhGC2Rg==} engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.3.9': + resolution: {integrity: sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==} + engines: {node: '>=18.0.0'} + '@smithy/hash-blob-browser@4.2.8': resolution: {integrity: sha512-07InZontqsM1ggTCPSRgI7d8DirqRrnpL7nIACT4PW0AWrgDiHhjGZzbAE5UtRSiU0NISGUYe7/rri9ZeWyDpw==} engines: {node: '>=18.0.0'} @@ -3360,6 +3514,10 @@ packages: resolution: {integrity: sha512-PU/JWLTBCV1c8FtB8tEFnY4eV1tSfBc7bDBADHfn1K+uRbPgSJ9jnJp0hyjiFN2PMdPzxsf1Fdu0eo9fJ760Xw==} engines: {node: '>=18.0.0'} + '@smithy/hash-node@4.2.8': + resolution: {integrity: sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==} + engines: {node: '>=18.0.0'} + '@smithy/hash-stream-node@4.2.7': resolution: {integrity: sha512-ZQVoAwNYnFMIbd4DUc517HuwNelJUY6YOzwqrbcAgCnVn+79/OK7UjwA93SPpdTOpKDVkLIzavWm/Ck7SmnDPQ==} engines: {node: '>=18.0.0'} @@ -3368,6 +3526,10 @@ packages: resolution: {integrity: sha512-ncvgCr9a15nPlkhIUx3CU4d7E7WEuVJOV7fS7nnK2hLtPK9tYRBkMHQbhXU1VvvKeBm/O0x26OEoBq+ngFpOEQ==} engines: {node: '>=18.0.0'} + '@smithy/invalid-dependency@4.2.8': + resolution: {integrity: sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==} + engines: {node: '>=18.0.0'} + '@smithy/is-array-buffer@2.2.0': resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} engines: {node: '>=14.0.0'} @@ -3384,70 +3546,138 @@ packages: resolution: {integrity: sha512-GszfBfCcvt7kIbJ41LuNa5f0wvQCHhnGx/aDaZJCCT05Ld6x6U2s0xsc/0mBFONBZjQJp2U/0uSJ178OXOwbhg==} engines: {node: '>=18.0.0'} + '@smithy/middleware-content-length@4.2.8': + resolution: {integrity: sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==} + engines: {node: '>=18.0.0'} + '@smithy/middleware-endpoint@4.4.1': resolution: {integrity: sha512-gpLspUAoe6f1M6H0u4cVuFzxZBrsGZmjx2O9SigurTx4PbntYa4AJ+o0G0oGm1L2oSX6oBhcGHwrfJHup2JnJg==} engines: {node: '>=18.0.0'} + '@smithy/middleware-endpoint@4.4.6': + resolution: {integrity: sha512-dpq3bHqbEOBqGBjRVHVFP3eUSPpX0BYtg1D5d5Irgk6orGGAuZfY22rC4sErhg+ZfY/Y0kPqm1XpAmDZg7DeuA==} + engines: {node: '>=18.0.0'} + '@smithy/middleware-retry@4.4.17': resolution: {integrity: sha512-MqbXK6Y9uq17h+4r0ogu/sBT6V/rdV+5NvYL7ZV444BKfQygYe8wAhDrVXagVebN6w2RE0Fm245l69mOsPGZzg==} engines: {node: '>=18.0.0'} + '@smithy/middleware-retry@4.4.22': + resolution: {integrity: sha512-vwWDMaObSMjw6WCC/3Ae9G7uul5Sk95jr07CDk1gkIMpaDic0phPS1MpVAZ6+YkF7PAzRlpsDjxPwRlh/S11FQ==} + engines: {node: '>=18.0.0'} + '@smithy/middleware-serde@4.2.8': resolution: {integrity: sha512-8rDGYen5m5+NV9eHv9ry0sqm2gI6W7mc1VSFMtn6Igo25S507/HaOX9LTHAS2/J32VXD0xSzrY0H5FJtOMS4/w==} engines: {node: '>=18.0.0'} + '@smithy/middleware-serde@4.2.9': + resolution: {integrity: sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==} + engines: {node: '>=18.0.0'} + '@smithy/middleware-stack@4.2.7': resolution: {integrity: sha512-bsOT0rJ+HHlZd9crHoS37mt8qRRN/h9jRve1SXUhVbkRzu0QaNYZp1i1jha4n098tsvROjcwfLlfvcFuJSXEsw==} engines: {node: '>=18.0.0'} + '@smithy/middleware-stack@4.2.8': + resolution: {integrity: sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==} + engines: {node: '>=18.0.0'} + '@smithy/node-config-provider@4.3.7': resolution: {integrity: sha512-7r58wq8sdOcrwWe+klL9y3bc4GW1gnlfnFOuL7CXa7UzfhzhxKuzNdtqgzmTV+53lEp9NXh5hY/S4UgjLOzPfw==} engines: {node: '>=18.0.0'} + '@smithy/node-config-provider@4.3.8': + resolution: {integrity: sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==} + engines: {node: '>=18.0.0'} + '@smithy/node-http-handler@4.4.7': resolution: {integrity: sha512-NELpdmBOO6EpZtWgQiHjoShs1kmweaiNuETUpuup+cmm/xJYjT4eUjfhrXRP4jCOaAsS3c3yPsP3B+K+/fyPCQ==} engines: {node: '>=18.0.0'} + '@smithy/node-http-handler@4.4.8': + resolution: {integrity: sha512-q9u+MSbJVIJ1QmJ4+1u+cERXkrhuILCBDsJUBAW1MPE6sFonbCNaegFuwW9ll8kh5UdyY3jOkoOGlc7BesoLpg==} + engines: {node: '>=18.0.0'} + '@smithy/property-provider@4.2.7': resolution: {integrity: sha512-jmNYKe9MGGPoSl/D7JDDs1C8b3dC8f/w78LbaVfoTtWy4xAd5dfjaFG9c9PWPihY4ggMQNQSMtzU77CNgAJwmA==} engines: {node: '>=18.0.0'} + '@smithy/property-provider@4.2.8': + resolution: {integrity: sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==} + engines: {node: '>=18.0.0'} + '@smithy/protocol-http@5.3.7': resolution: {integrity: sha512-1r07pb994I20dD/c2seaZhoCuNYm0rWrvBxhCQ70brNh11M5Ml2ew6qJVo0lclB3jMIXirD4s2XRXRe7QEi0xA==} engines: {node: '>=18.0.0'} + '@smithy/protocol-http@5.3.8': + resolution: {integrity: sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==} + engines: {node: '>=18.0.0'} + '@smithy/querystring-builder@4.2.7': resolution: {integrity: sha512-eKONSywHZxK4tBxe2lXEysh8wbBdvDWiA+RIuaxZSgCMmA0zMgoDpGLJhnyj+c0leOQprVnXOmcB4m+W9Rw7sg==} engines: {node: '>=18.0.0'} + '@smithy/querystring-builder@4.2.8': + resolution: {integrity: sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==} + engines: {node: '>=18.0.0'} + '@smithy/querystring-parser@4.2.7': resolution: {integrity: sha512-3X5ZvzUHmlSTHAXFlswrS6EGt8fMSIxX/c3Rm1Pni3+wYWB6cjGocmRIoqcQF9nU5OgGmL0u7l9m44tSUpfj9w==} engines: {node: '>=18.0.0'} + '@smithy/querystring-parser@4.2.8': + resolution: {integrity: sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==} + engines: {node: '>=18.0.0'} + '@smithy/service-error-classification@4.2.7': resolution: {integrity: sha512-YB7oCbukqEb2Dlh3340/8g8vNGbs/QsNNRms+gv3N2AtZz9/1vSBx6/6tpwQpZMEJFs7Uq8h4mmOn48ZZ72MkA==} engines: {node: '>=18.0.0'} + '@smithy/service-error-classification@4.2.8': + resolution: {integrity: sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==} + engines: {node: '>=18.0.0'} + '@smithy/shared-ini-file-loader@4.4.2': resolution: {integrity: sha512-M7iUUff/KwfNunmrgtqBfvZSzh3bmFgv/j/t1Y1dQ+8dNo34br1cqVEqy6v0mYEgi0DkGO7Xig0AnuOaEGVlcg==} engines: {node: '>=18.0.0'} + '@smithy/shared-ini-file-loader@4.4.3': + resolution: {integrity: sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==} + engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.3.7': resolution: {integrity: sha512-9oNUlqBlFZFOSdxgImA6X5GFuzE7V2H7VG/7E70cdLhidFbdtvxxt81EHgykGK5vq5D3FafH//X+Oy31j3CKOg==} engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.3.8': + resolution: {integrity: sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==} + engines: {node: '>=18.0.0'} + '@smithy/smithy-client@4.10.2': resolution: {integrity: sha512-D5z79xQWpgrGpAHb054Fn2CCTQZpog7JELbVQ6XAvXs5MNKWf28U9gzSBlJkOyMl9LA1TZEjRtwvGXfP0Sl90g==} engines: {node: '>=18.0.0'} + '@smithy/smithy-client@4.10.7': + resolution: {integrity: sha512-Uznt0I9z3os3Z+8pbXrOSCTXCA6vrjyN7Ub+8l2pRDum44vLv8qw0qGVkJN0/tZBZotaEFHrDPKUoPNueTr5Vg==} + engines: {node: '>=18.0.0'} + '@smithy/types@4.11.0': resolution: {integrity: sha512-mlrmL0DRDVe3mNrjTcVcZEgkFmufITfUAPBEA+AHYiIeYyJebso/He1qLbP3PssRe22KUzLRpQSdBPbXdgZ2VA==} engines: {node: '>=18.0.0'} + '@smithy/types@4.12.0': + resolution: {integrity: sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==} + engines: {node: '>=18.0.0'} + '@smithy/url-parser@4.2.7': resolution: {integrity: sha512-/RLtVsRV4uY3qPWhBDsjwahAtt3x2IsMGnP5W1b2VZIe+qgCqkLxI1UOHDZp1Q1QSOrdOR32MF3Ph2JfWT1VHg==} engines: {node: '>=18.0.0'} + '@smithy/url-parser@4.2.8': + resolution: {integrity: sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==} + engines: {node: '>=18.0.0'} + '@smithy/util-base64@4.3.0': resolution: {integrity: sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==} engines: {node: '>=18.0.0'} @@ -3476,14 +3706,26 @@ packages: resolution: {integrity: sha512-/eiSP3mzY3TsvUOYMeL4EqUX6fgUOj2eUOU4rMMgVbq67TiRLyxT7Xsjxq0bW3OwuzK009qOwF0L2OgJqperAQ==} engines: {node: '>=18.0.0'} + '@smithy/util-defaults-mode-browser@4.3.21': + resolution: {integrity: sha512-DtmVJarzqtjghtGjCw/PFJolcJkP7GkZgy+hWTAN3YLXNH+IC82uMoMhFoC3ZtIz5mOgCm5+hOGi1wfhVYgrxw==} + engines: {node: '>=18.0.0'} + '@smithy/util-defaults-mode-node@4.2.19': resolution: {integrity: sha512-3a4+4mhf6VycEJyHIQLypRbiwG6aJvbQAeRAVXydMmfweEPnLLabRbdyo/Pjw8Rew9vjsh5WCdhmDaHkQnhhhA==} engines: {node: '>=18.0.0'} + '@smithy/util-defaults-mode-node@4.2.24': + resolution: {integrity: sha512-JelBDKPAVswVY666rezBvY6b0nF/v9TXjUbNwDNAyme7qqKYEX687wJv0uze8lBIZVbg30wlWnlYfVSjjpKYFA==} + engines: {node: '>=18.0.0'} + '@smithy/util-endpoints@3.2.7': resolution: {integrity: sha512-s4ILhyAvVqhMDYREeTS68R43B1V5aenV5q/V1QpRQJkCXib5BPRo4s7uNdzGtIKxaPHCfU/8YkvPAEvTpxgspg==} engines: {node: '>=18.0.0'} + '@smithy/util-endpoints@3.2.8': + resolution: {integrity: sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==} + engines: {node: '>=18.0.0'} + '@smithy/util-hex-encoding@4.2.0': resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==} engines: {node: '>=18.0.0'} @@ -3492,10 +3734,22 @@ packages: resolution: {integrity: sha512-i1IkpbOae6NvIKsEeLLM9/2q4X+M90KV3oCFgWQI4q0Qz+yUZvsr+gZPdAEAtFhWQhAHpTsJO8DRJPuwVyln+w==} engines: {node: '>=18.0.0'} + '@smithy/util-middleware@4.2.8': + resolution: {integrity: sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==} + engines: {node: '>=18.0.0'} + '@smithy/util-retry@4.2.7': resolution: {integrity: sha512-SvDdsQyF5CIASa4EYVT02LukPHVzAgUA4kMAuZ97QJc2BpAqZfA4PINB8/KOoCXEw9tsuv/jQjMeaHFvxdLNGg==} engines: {node: '>=18.0.0'} + '@smithy/util-retry@4.2.8': + resolution: {integrity: sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-stream@4.5.10': + resolution: {integrity: sha512-jbqemy51UFSZSp2y0ZmRfckmrzuKww95zT9BYMmuJ8v3altGcqjwoV1tzpOwuHaKrwQrCjIzOib499ymr2f98g==} + engines: {node: '>=18.0.0'} + '@smithy/util-stream@4.5.8': resolution: {integrity: sha512-ZnnBhTapjM0YPGUSmOs0Mcg/Gg87k503qG4zU2v/+Js2Gu+daKOJMeqcQns8ajepY8tgzzfYxl6kQyZKml6O2w==} engines: {node: '>=18.0.0'} @@ -3731,6 +3985,9 @@ packages: '@types/node@20.19.27': resolution: {integrity: sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==} + '@types/nodemailer@7.0.5': + resolution: {integrity: sha512-7WtR4MFJUNN2UFy0NIowBRJswj5KXjXDhlZY43Hmots5eGu5q/dTeFd/I6GgJA/qj3RqO6dDy4SvfcV3fOVeIA==} + '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -3764,6 +4021,9 @@ packages: '@types/shelljs@0.8.17': resolution: {integrity: sha512-IDksKYmQA2W9MkQjiyptbMmcQx+8+Ol6b7h6dPU5S05JyiQDSb/nZKnrMrZqGwgV6VkVdl6/SPCKPDlMRvqECg==} + '@types/smtp-server@3.5.12': + resolution: {integrity: sha512-IBemrqI6nzvbgwE41Lnd4v4Yf1Kc7F1UHjk1GFBLNhLcI/Zop1ggHQ8g7Y8QYc6jGVgzWQcsa0MBNcGnDY9UGw==} + '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -4186,6 +4446,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base32.js@0.1.0: + resolution: {integrity: sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==} + engines: {node: '>=0.12.0'} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -5791,6 +6055,9 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + ipv6-normalize@1.0.1: + resolution: {integrity: sha512-Bm6H79i01DjgGTCWjUuCjJ6QDo1HB96PT/xCYuyJUP9WFbVDrLSbG4EZCvOCun2rNswZb0c3e4Jt/ws795esHA==} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} @@ -6848,6 +7115,14 @@ packages: node-schedule@1.3.2: resolution: {integrity: sha512-GIND2pHMHiReSZSvS6dpZcDH7pGPGFfWBIEud6S00Q8zEIzAs9ommdyRK1ZbQt8y1LyZsJYZgPnyi7gpU2lcdw==} + nodemailer@6.10.1: + resolution: {integrity: sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==} + engines: {node: '>=6.0.0'} + + nodemailer@7.0.11: + resolution: {integrity: sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==} + engines: {node: '>=6.0.0'} + nodemon@3.1.11: resolution: {integrity: sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==} engines: {node: '>=10'} @@ -7370,6 +7645,10 @@ packages: pstree.remy@1.1.8: resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -7697,6 +7976,10 @@ packages: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + smtp-server@3.18.0: + resolution: {integrity: sha512-xINTnh0H8JDAKOAGSnFX8mgXB/L4Oz8dG4P0EgKAzJEszngxEEx4vOys+yNpsUc6yIyTKS8m2BcIffq4Htma/w==} + engines: {node: '>=18.18.0'} + socks-proxy-agent@4.0.2: resolution: {integrity: sha512-NT6syHhI9LmuEMSK6Kd2V7gNv5KFZoLE7V5udWmn0de+3Mkj3UMA/AJPLyeNUVmElCurSHtUdM3ETpR3z770Wg==} engines: {node: '>= 6'} @@ -8485,6 +8768,51 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/client-sesv2@3.969.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.969.0 + '@aws-sdk/credential-provider-node': 3.969.0 + '@aws-sdk/middleware-host-header': 3.969.0 + '@aws-sdk/middleware-logger': 3.969.0 + '@aws-sdk/middleware-recursion-detection': 3.969.0 + '@aws-sdk/middleware-user-agent': 3.969.0 + '@aws-sdk/region-config-resolver': 3.969.0 + '@aws-sdk/signature-v4-multi-region': 3.969.0 + '@aws-sdk/types': 3.969.0 + '@aws-sdk/util-endpoints': 3.969.0 + '@aws-sdk/util-user-agent-browser': 3.969.0 + '@aws-sdk/util-user-agent-node': 3.969.0 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.20.5 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.6 + '@smithy/middleware-retry': 4.4.22 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.10.7 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.21 + '@smithy/util-defaults-mode-node': 4.2.24 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/client-sso@3.958.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -8528,6 +8856,49 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/client-sso@3.969.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.969.0 + '@aws-sdk/middleware-host-header': 3.969.0 + '@aws-sdk/middleware-logger': 3.969.0 + '@aws-sdk/middleware-recursion-detection': 3.969.0 + '@aws-sdk/middleware-user-agent': 3.969.0 + '@aws-sdk/region-config-resolver': 3.969.0 + '@aws-sdk/types': 3.969.0 + '@aws-sdk/util-endpoints': 3.969.0 + '@aws-sdk/util-user-agent-browser': 3.969.0 + '@aws-sdk/util-user-agent-node': 3.969.0 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.20.5 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.6 + '@smithy/middleware-retry': 4.4.22 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.10.7 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.21 + '@smithy/util-defaults-mode-node': 4.2.24 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/core@3.957.0': dependencies: '@aws-sdk/types': 3.957.0 @@ -8544,6 +8915,22 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 + '@aws-sdk/core@3.969.0': + dependencies: + '@aws-sdk/types': 3.969.0 + '@aws-sdk/xml-builder': 3.969.0 + '@smithy/core': 3.20.5 + '@smithy/node-config-provider': 4.3.8 + '@smithy/property-provider': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/signature-v4': 5.3.8 + '@smithy/smithy-client': 4.10.7 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + '@aws-sdk/crc64-nvme@3.957.0': dependencies: '@smithy/types': 4.11.0 @@ -8557,6 +8944,14 @@ snapshots: '@smithy/types': 4.11.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.969.0': + dependencies: + '@aws-sdk/core': 3.969.0 + '@aws-sdk/types': 3.969.0 + '@smithy/property-provider': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.957.0': dependencies: '@aws-sdk/core': 3.957.0 @@ -8570,6 +8965,19 @@ snapshots: '@smithy/util-stream': 4.5.8 tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.969.0': + dependencies: + '@aws-sdk/core': 3.969.0 + '@aws-sdk/types': 3.969.0 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/node-http-handler': 4.4.8 + '@smithy/property-provider': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.10.7 + '@smithy/types': 4.12.0 + '@smithy/util-stream': 4.5.10 + tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.958.0': dependencies: '@aws-sdk/core': 3.957.0 @@ -8589,6 +8997,25 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-ini@3.969.0': + dependencies: + '@aws-sdk/core': 3.969.0 + '@aws-sdk/credential-provider-env': 3.969.0 + '@aws-sdk/credential-provider-http': 3.969.0 + '@aws-sdk/credential-provider-login': 3.969.0 + '@aws-sdk/credential-provider-process': 3.969.0 + '@aws-sdk/credential-provider-sso': 3.969.0 + '@aws-sdk/credential-provider-web-identity': 3.969.0 + '@aws-sdk/nested-clients': 3.969.0 + '@aws-sdk/types': 3.969.0 + '@smithy/credential-provider-imds': 4.2.8 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-login@3.958.0': dependencies: '@aws-sdk/core': 3.957.0 @@ -8602,6 +9029,19 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-login@3.969.0': + dependencies: + '@aws-sdk/core': 3.969.0 + '@aws-sdk/nested-clients': 3.969.0 + '@aws-sdk/types': 3.969.0 + '@smithy/property-provider': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-node@3.958.0': dependencies: '@aws-sdk/credential-provider-env': 3.957.0 @@ -8619,6 +9059,23 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-node@3.969.0': + dependencies: + '@aws-sdk/credential-provider-env': 3.969.0 + '@aws-sdk/credential-provider-http': 3.969.0 + '@aws-sdk/credential-provider-ini': 3.969.0 + '@aws-sdk/credential-provider-process': 3.969.0 + '@aws-sdk/credential-provider-sso': 3.969.0 + '@aws-sdk/credential-provider-web-identity': 3.969.0 + '@aws-sdk/types': 3.969.0 + '@smithy/credential-provider-imds': 4.2.8 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-process@3.957.0': dependencies: '@aws-sdk/core': 3.957.0 @@ -8628,6 +9085,15 @@ snapshots: '@smithy/types': 4.11.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.969.0': + dependencies: + '@aws-sdk/core': 3.969.0 + '@aws-sdk/types': 3.969.0 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.958.0': dependencies: '@aws-sdk/client-sso': 3.958.0 @@ -8641,6 +9107,19 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-sso@3.969.0': + dependencies: + '@aws-sdk/client-sso': 3.969.0 + '@aws-sdk/core': 3.969.0 + '@aws-sdk/token-providers': 3.969.0 + '@aws-sdk/types': 3.969.0 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-web-identity@3.958.0': dependencies: '@aws-sdk/core': 3.957.0 @@ -8653,6 +9132,18 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-web-identity@3.969.0': + dependencies: + '@aws-sdk/core': 3.969.0 + '@aws-sdk/nested-clients': 3.969.0 + '@aws-sdk/types': 3.969.0 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/lib-storage@3.958.0(@aws-sdk/client-s3@3.958.0)': dependencies: '@aws-sdk/client-s3': 3.958.0 @@ -8705,6 +9196,13 @@ snapshots: '@smithy/types': 4.11.0 tslib: 2.8.1 + '@aws-sdk/middleware-host-header@3.969.0': + dependencies: + '@aws-sdk/types': 3.969.0 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@aws-sdk/middleware-location-constraint@3.957.0': dependencies: '@aws-sdk/types': 3.957.0 @@ -8717,6 +9215,12 @@ snapshots: '@smithy/types': 4.11.0 tslib: 2.8.1 + '@aws-sdk/middleware-logger@3.969.0': + dependencies: + '@aws-sdk/types': 3.969.0 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@aws-sdk/middleware-recursion-detection@3.957.0': dependencies: '@aws-sdk/types': 3.957.0 @@ -8725,6 +9229,14 @@ snapshots: '@smithy/types': 4.11.0 tslib: 2.8.1 + '@aws-sdk/middleware-recursion-detection@3.969.0': + dependencies: + '@aws-sdk/types': 3.969.0 + '@aws/lambda-invoke-store': 0.2.2 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@aws-sdk/middleware-sdk-s3@3.957.0': dependencies: '@aws-sdk/core': 3.957.0 @@ -8742,6 +9254,23 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 + '@aws-sdk/middleware-sdk-s3@3.969.0': + dependencies: + '@aws-sdk/core': 3.969.0 + '@aws-sdk/types': 3.969.0 + '@aws-sdk/util-arn-parser': 3.968.0 + '@smithy/core': 3.20.5 + '@smithy/node-config-provider': 4.3.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/signature-v4': 5.3.8 + '@smithy/smithy-client': 4.10.7 + '@smithy/types': 4.12.0 + '@smithy/util-config-provider': 4.2.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-stream': 4.5.10 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + '@aws-sdk/middleware-ssec@3.957.0': dependencies: '@aws-sdk/types': 3.957.0 @@ -8758,6 +9287,16 @@ snapshots: '@smithy/types': 4.11.0 tslib: 2.8.1 + '@aws-sdk/middleware-user-agent@3.969.0': + dependencies: + '@aws-sdk/core': 3.969.0 + '@aws-sdk/types': 3.969.0 + '@aws-sdk/util-endpoints': 3.969.0 + '@smithy/core': 3.20.5 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@aws-sdk/nested-clients@3.958.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -8801,6 +9340,49 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/nested-clients@3.969.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.969.0 + '@aws-sdk/middleware-host-header': 3.969.0 + '@aws-sdk/middleware-logger': 3.969.0 + '@aws-sdk/middleware-recursion-detection': 3.969.0 + '@aws-sdk/middleware-user-agent': 3.969.0 + '@aws-sdk/region-config-resolver': 3.969.0 + '@aws-sdk/types': 3.969.0 + '@aws-sdk/util-endpoints': 3.969.0 + '@aws-sdk/util-user-agent-browser': 3.969.0 + '@aws-sdk/util-user-agent-node': 3.969.0 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.20.5 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.6 + '@smithy/middleware-retry': 4.4.22 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.10.7 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.21 + '@smithy/util-defaults-mode-node': 4.2.24 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/region-config-resolver@3.957.0': dependencies: '@aws-sdk/types': 3.957.0 @@ -8809,6 +9391,14 @@ snapshots: '@smithy/types': 4.11.0 tslib: 2.8.1 + '@aws-sdk/region-config-resolver@3.969.0': + dependencies: + '@aws-sdk/types': 3.969.0 + '@smithy/config-resolver': 4.4.6 + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@aws-sdk/signature-v4-multi-region@3.957.0': dependencies: '@aws-sdk/middleware-sdk-s3': 3.957.0 @@ -8818,6 +9408,15 @@ snapshots: '@smithy/types': 4.11.0 tslib: 2.8.1 + '@aws-sdk/signature-v4-multi-region@3.969.0': + dependencies: + '@aws-sdk/middleware-sdk-s3': 3.969.0 + '@aws-sdk/types': 3.969.0 + '@smithy/protocol-http': 5.3.8 + '@smithy/signature-v4': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@aws-sdk/token-providers@3.958.0': dependencies: '@aws-sdk/core': 3.957.0 @@ -8830,15 +9429,36 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/token-providers@3.969.0': + dependencies: + '@aws-sdk/core': 3.969.0 + '@aws-sdk/nested-clients': 3.969.0 + '@aws-sdk/types': 3.969.0 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/types@3.957.0': dependencies: '@smithy/types': 4.11.0 tslib: 2.8.1 + '@aws-sdk/types@3.969.0': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@aws-sdk/util-arn-parser@3.957.0': dependencies: tslib: 2.8.1 + '@aws-sdk/util-arn-parser@3.968.0': + dependencies: + tslib: 2.8.1 + '@aws-sdk/util-endpoints@3.957.0': dependencies: '@aws-sdk/types': 3.957.0 @@ -8847,6 +9467,14 @@ snapshots: '@smithy/util-endpoints': 3.2.7 tslib: 2.8.1 + '@aws-sdk/util-endpoints@3.969.0': + dependencies: + '@aws-sdk/types': 3.969.0 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-endpoints': 3.2.8 + tslib: 2.8.1 + '@aws-sdk/util-locate-window@3.957.0': dependencies: tslib: 2.8.1 @@ -8858,6 +9486,13 @@ snapshots: bowser: 2.13.1 tslib: 2.8.1 + '@aws-sdk/util-user-agent-browser@3.969.0': + dependencies: + '@aws-sdk/types': 3.969.0 + '@smithy/types': 4.12.0 + bowser: 2.13.1 + tslib: 2.8.1 + '@aws-sdk/util-user-agent-node@3.957.0': dependencies: '@aws-sdk/middleware-user-agent': 3.957.0 @@ -8866,12 +9501,26 @@ snapshots: '@smithy/types': 4.11.0 tslib: 2.8.1 + '@aws-sdk/util-user-agent-node@3.969.0': + dependencies: + '@aws-sdk/middleware-user-agent': 3.969.0 + '@aws-sdk/types': 3.969.0 + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@aws-sdk/xml-builder@3.957.0': dependencies: '@smithy/types': 4.11.0 fast-xml-parser: 5.2.5 tslib: 2.8.1 + '@aws-sdk/xml-builder@3.969.0': + dependencies: + '@smithy/types': 4.12.0 + fast-xml-parser: 5.2.5 + tslib: 2.8.1 + '@aws/lambda-invoke-store@0.2.2': {} '@babel/code-frame@7.27.1': @@ -10191,6 +10840,11 @@ snapshots: '@smithy/types': 4.11.0 tslib: 2.8.1 + '@smithy/abort-controller@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/chunked-blob-reader-native@4.2.1': dependencies: '@smithy/util-base64': 4.3.0 @@ -10209,6 +10863,15 @@ snapshots: '@smithy/util-middleware': 4.2.7 tslib: 2.8.1 + '@smithy/config-resolver@4.4.6': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-config-provider': 4.2.0 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + tslib: 2.8.1 + '@smithy/core@3.20.0': dependencies: '@smithy/middleware-serde': 4.2.8 @@ -10222,6 +10885,19 @@ snapshots: '@smithy/uuid': 1.1.0 tslib: 2.8.1 + '@smithy/core@3.20.5': + dependencies: + '@smithy/middleware-serde': 4.2.9 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-stream': 4.5.10 + '@smithy/util-utf8': 4.2.0 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 + '@smithy/credential-provider-imds@4.2.7': dependencies: '@smithy/node-config-provider': 4.3.7 @@ -10230,6 +10906,14 @@ snapshots: '@smithy/url-parser': 4.2.7 tslib: 2.8.1 + '@smithy/credential-provider-imds@4.2.8': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/property-provider': 4.2.8 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + tslib: 2.8.1 + '@smithy/eventstream-codec@4.2.7': dependencies: '@aws-crypto/crc32': 5.2.0 @@ -10268,6 +10952,14 @@ snapshots: '@smithy/util-base64': 4.3.0 tslib: 2.8.1 + '@smithy/fetch-http-handler@5.3.9': + dependencies: + '@smithy/protocol-http': 5.3.8 + '@smithy/querystring-builder': 4.2.8 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + tslib: 2.8.1 + '@smithy/hash-blob-browser@4.2.8': dependencies: '@smithy/chunked-blob-reader': 5.2.0 @@ -10282,6 +10974,13 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 + '@smithy/hash-node@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + '@smithy/hash-stream-node@4.2.7': dependencies: '@smithy/types': 4.11.0 @@ -10293,6 +10992,11 @@ snapshots: '@smithy/types': 4.11.0 tslib: 2.8.1 + '@smithy/invalid-dependency@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/is-array-buffer@2.2.0': dependencies: tslib: 2.8.1 @@ -10313,6 +11017,12 @@ snapshots: '@smithy/types': 4.11.0 tslib: 2.8.1 + '@smithy/middleware-content-length@4.2.8': + dependencies: + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/middleware-endpoint@4.4.1': dependencies: '@smithy/core': 3.20.0 @@ -10324,6 +11034,17 @@ snapshots: '@smithy/util-middleware': 4.2.7 tslib: 2.8.1 + '@smithy/middleware-endpoint@4.4.6': + dependencies: + '@smithy/core': 3.20.5 + '@smithy/middleware-serde': 4.2.9 + '@smithy/node-config-provider': 4.3.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-middleware': 4.2.8 + tslib: 2.8.1 + '@smithy/middleware-retry@4.4.17': dependencies: '@smithy/node-config-provider': 4.3.7 @@ -10336,17 +11057,40 @@ snapshots: '@smithy/uuid': 1.1.0 tslib: 2.8.1 + '@smithy/middleware-retry@4.4.22': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/service-error-classification': 4.2.8 + '@smithy/smithy-client': 4.10.7 + '@smithy/types': 4.12.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 + '@smithy/middleware-serde@4.2.8': dependencies: '@smithy/protocol-http': 5.3.7 '@smithy/types': 4.11.0 tslib: 2.8.1 + '@smithy/middleware-serde@4.2.9': + dependencies: + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/middleware-stack@4.2.7': dependencies: '@smithy/types': 4.11.0 tslib: 2.8.1 + '@smithy/middleware-stack@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/node-config-provider@4.3.7': dependencies: '@smithy/property-provider': 4.2.7 @@ -10354,6 +11098,13 @@ snapshots: '@smithy/types': 4.11.0 tslib: 2.8.1 + '@smithy/node-config-provider@4.3.8': + dependencies: + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/node-http-handler@4.4.7': dependencies: '@smithy/abort-controller': 4.2.7 @@ -10362,36 +11113,74 @@ snapshots: '@smithy/types': 4.11.0 tslib: 2.8.1 + '@smithy/node-http-handler@4.4.8': + dependencies: + '@smithy/abort-controller': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/querystring-builder': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/property-provider@4.2.7': dependencies: '@smithy/types': 4.11.0 tslib: 2.8.1 + '@smithy/property-provider@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/protocol-http@5.3.7': dependencies: '@smithy/types': 4.11.0 tslib: 2.8.1 + '@smithy/protocol-http@5.3.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/querystring-builder@4.2.7': dependencies: '@smithy/types': 4.11.0 '@smithy/util-uri-escape': 4.2.0 tslib: 2.8.1 + '@smithy/querystring-builder@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + '@smithy/util-uri-escape': 4.2.0 + tslib: 2.8.1 + '@smithy/querystring-parser@4.2.7': dependencies: '@smithy/types': 4.11.0 tslib: 2.8.1 + '@smithy/querystring-parser@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/service-error-classification@4.2.7': dependencies: '@smithy/types': 4.11.0 + '@smithy/service-error-classification@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + '@smithy/shared-ini-file-loader@4.4.2': dependencies: '@smithy/types': 4.11.0 tslib: 2.8.1 + '@smithy/shared-ini-file-loader@4.4.3': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/signature-v4@5.3.7': dependencies: '@smithy/is-array-buffer': 4.2.0 @@ -10403,6 +11192,17 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 + '@smithy/signature-v4@5.3.8': + dependencies: + '@smithy/is-array-buffer': 4.2.0 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-hex-encoding': 4.2.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-uri-escape': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + '@smithy/smithy-client@4.10.2': dependencies: '@smithy/core': 3.20.0 @@ -10413,16 +11213,36 @@ snapshots: '@smithy/util-stream': 4.5.8 tslib: 2.8.1 + '@smithy/smithy-client@4.10.7': + dependencies: + '@smithy/core': 3.20.5 + '@smithy/middleware-endpoint': 4.4.6 + '@smithy/middleware-stack': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-stream': 4.5.10 + tslib: 2.8.1 + '@smithy/types@4.11.0': dependencies: tslib: 2.8.1 + '@smithy/types@4.12.0': + dependencies: + tslib: 2.8.1 + '@smithy/url-parser@4.2.7': dependencies: '@smithy/querystring-parser': 4.2.7 '@smithy/types': 4.11.0 tslib: 2.8.1 + '@smithy/url-parser@4.2.8': + dependencies: + '@smithy/querystring-parser': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/util-base64@4.3.0': dependencies: '@smithy/util-buffer-from': 4.2.0 @@ -10458,6 +11278,13 @@ snapshots: '@smithy/types': 4.11.0 tslib: 2.8.1 + '@smithy/util-defaults-mode-browser@4.3.21': + dependencies: + '@smithy/property-provider': 4.2.8 + '@smithy/smithy-client': 4.10.7 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/util-defaults-mode-node@4.2.19': dependencies: '@smithy/config-resolver': 4.4.5 @@ -10468,12 +11295,28 @@ snapshots: '@smithy/types': 4.11.0 tslib: 2.8.1 + '@smithy/util-defaults-mode-node@4.2.24': + dependencies: + '@smithy/config-resolver': 4.4.6 + '@smithy/credential-provider-imds': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/property-provider': 4.2.8 + '@smithy/smithy-client': 4.10.7 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/util-endpoints@3.2.7': dependencies: '@smithy/node-config-provider': 4.3.7 '@smithy/types': 4.11.0 tslib: 2.8.1 + '@smithy/util-endpoints@3.2.8': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/util-hex-encoding@4.2.0': dependencies: tslib: 2.8.1 @@ -10483,12 +11326,34 @@ snapshots: '@smithy/types': 4.11.0 tslib: 2.8.1 + '@smithy/util-middleware@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/util-retry@4.2.7': dependencies: '@smithy/service-error-classification': 4.2.7 '@smithy/types': 4.11.0 tslib: 2.8.1 + '@smithy/util-retry@4.2.8': + dependencies: + '@smithy/service-error-classification': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-stream@4.5.10': + dependencies: + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/node-http-handler': 4.4.8 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-hex-encoding': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + '@smithy/util-stream@4.5.8': dependencies: '@smithy/fetch-http-handler': 5.3.8 @@ -10795,6 +11660,13 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/nodemailer@7.0.5': + dependencies: + '@aws-sdk/client-sesv2': 3.969.0 + '@types/node': 20.19.27 + transitivePeerDependencies: + - aws-crt + '@types/normalize-package-data@2.4.4': {} '@types/pg-copy-streams@1.2.5': @@ -10836,6 +11708,13 @@ snapshots: '@types/node': 20.19.27 glob: 11.1.0 + '@types/smtp-server@3.5.12': + dependencies: + '@types/node': 20.19.27 + '@types/nodemailer': 7.0.5 + transitivePeerDependencies: + - aws-crt + '@types/stack-utils@2.0.3': {} '@types/superagent@8.1.9': @@ -11294,6 +12173,8 @@ snapshots: balanced-match@1.0.2: {} + base32.js@0.1.0: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.9.11: {} @@ -13013,6 +13894,8 @@ snapshots: ipaddr.js@1.9.1: {} + ipv6-normalize@1.0.1: {} + is-arrayish@0.2.1: {} is-binary-path@2.1.0: @@ -14698,6 +15581,10 @@ snapshots: long-timeout: 0.1.1 sorted-array-functions: 1.3.0 + nodemailer@6.10.1: {} + + nodemailer@7.0.11: {} + nodemon@3.1.11: dependencies: chokidar: 3.6.0 @@ -15352,6 +16239,8 @@ snapshots: pstree.remy@1.1.8: {} + punycode.js@2.3.1: {} + punycode@2.3.1: {} pure-rand@6.1.0: {} @@ -15711,6 +16600,13 @@ snapshots: smart-buffer@4.2.0: {} + smtp-server@3.18.0: + dependencies: + base32.js: 0.1.0 + ipv6-normalize: 1.0.1 + nodemailer: 7.0.11 + punycode.js: 2.3.1 + socks-proxy-agent@4.0.2: dependencies: agent-base: 4.2.1 From f9af4474ddb53e1e84363aff8b34a41f1281cdaa Mon Sep 17 00:00:00 2001 From: zetazzz Date: Thu, 15 Jan 2026 16:00:58 +0800 Subject: [PATCH 08/12] switch of smtp email in cloud functions --- functions/send-email-link/README.md | 13 +++++++++-- functions/send-email-link/package.json | 1 + functions/send-email-link/src/index.ts | 7 ++++-- functions/simple-email/README.md | 30 ++++++++++++++++++++------ functions/simple-email/package.json | 1 + functions/simple-email/src/index.ts | 7 ++++-- pnpm-lock.yaml | 6 ++++++ 7 files changed, 52 insertions(+), 13 deletions(-) diff --git a/functions/send-email-link/README.md b/functions/send-email-link/README.md index 13f7d9e8f..c94f515ce 100644 --- a/functions/send-email-link/README.md +++ b/functions/send-email-link/README.md @@ -85,13 +85,22 @@ Recommended / optional: - `LOCAL_APP_PORT` Optional port suffix for localhost-style hosts (e.g. `3000`). When the resolved hostname is `localhost` / `*.localhost` and `SEND_EMAIL_LINK_DRY_RUN=true`, links are generated as `http://localhost:LOCAL_APP_PORT/...`. Ignored for non-local hostnames and in production. -Email delivery (used by `@launchql/postmaster`): +Email delivery (default: `@launchql/postmaster`): -- Typically Mailgun or another provider; consult `@launchql/postmaster` docs. A common pattern is: +- Set `EMAIL_SEND_USE_SMTP=true` to switch to `@constructive-io/smtppostmaster` (SMTP). Otherwise it uses `@launchql/postmaster`. + +- Mailgun or another provider; consult `@launchql/postmaster` docs. A common pattern is: - `MAILGUN_API_KEY` - `MAILGUN_DOMAIN` - `MAILGUN_FROM` +- SMTP variables when `EMAIL_SEND_USE_SMTP=true`: + - `SMTP_HOST` + - `SMTP_PORT` + - `SMTP_USER` + - `SMTP_PASS` + - `SMTP_FROM` + ## Building locally From the repo root: diff --git a/functions/send-email-link/package.json b/functions/send-email-link/package.json index cc8b0e7b1..487237af4 100644 --- a/functions/send-email-link/package.json +++ b/functions/send-email-link/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@constructive-io/knative-job-fn": "workspace:^", + "@constructive-io/smtppostmaster": "workspace:^", "@launchql/mjml": "0.1.1", "@launchql/postmaster": "0.1.4", "@launchql/styled-email": "0.1.0", diff --git a/functions/send-email-link/src/index.ts b/functions/send-email-link/src/index.ts index 886ab0e27..0f557e26a 100644 --- a/functions/send-email-link/src/index.ts +++ b/functions/send-email-link/src/index.ts @@ -2,10 +2,12 @@ import { createJobApp } from '@constructive-io/knative-job-fn'; import { GraphQLClient } from 'graphql-request'; import gql from 'graphql-tag'; import { generate } from '@launchql/mjml'; -import { send } from '@launchql/postmaster'; +import { send as sendPostmaster } from '@launchql/postmaster'; +import { send as sendSmtp } from '@constructive-io/smtppostmaster'; import { parseEnvBoolean } from '@pgpmjs/env'; const isDryRun = parseEnvBoolean(process.env.SEND_EMAIL_LINK_DRY_RUN) ?? false; +const useSmtp = parseEnvBoolean(process.env.EMAIL_SEND_USE_SMTP) ?? false; const app = createJobApp(); const GetUser = gql` @@ -282,7 +284,8 @@ export const sendEmailLink = async ( link }); } else { - await send({ + const sendEmail = useSmtp ? sendSmtp : sendPostmaster; + await sendEmail({ to: params.email, subject, html diff --git a/functions/simple-email/README.md b/functions/simple-email/README.md index ebe2d922a..e1e2de114 100644 --- a/functions/simple-email/README.md +++ b/functions/simple-email/README.md @@ -3,9 +3,9 @@ Simple Knative-compatible email function used with the Constructive jobs system. This function is intentionally minimal: it reads an email payload from the job -body and **logs it only** (dry‑run mode). It does **not** send any email. This -is useful while wiring up jobs and Knative without needing a real mail -provider configured. +body and **logs it only** in dry‑run mode. When not in dry‑run, it sends via +the configured email provider. This is useful while wiring up jobs and Knative +without needing a real mail provider configured. ## Expected job payload @@ -62,10 +62,26 @@ callback for the worker. ## Environment variables -This function does **not** depend on any GraphQL or email provider -configuration. There are currently **no required environment variables** for -its core behavior; it will simply log the email payload and return a successful -response. +Email provider configuration is only required when not running in dry‑run mode. + +Optional: + +- `SIMPLE_EMAIL_DRY_RUN` (`true`/`false`): log only, skip send. +- `EMAIL_SEND_USE_SMTP` (`true`/`false`): use SMTP (`@constructive-io/smtppostmaster`). + +Mailgun (`@launchql/postmaster`) env vars when `EMAIL_SEND_USE_SMTP` is false: + +- `MAILGUN_API_KEY` +- `MAILGUN_DOMAIN` +- `MAILGUN_FROM` + +SMTP env vars when `EMAIL_SEND_USE_SMTP` is true: + +- `SMTP_HOST` +- `SMTP_PORT` +- `SMTP_USER` +- `SMTP_PASS` +- `SMTP_FROM` ## Building locally diff --git a/functions/simple-email/package.json b/functions/simple-email/package.json index 230177ed7..a7ca7f359 100644 --- a/functions/simple-email/package.json +++ b/functions/simple-email/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@constructive-io/knative-job-fn": "workspace:^", + "@constructive-io/smtppostmaster": "workspace:^", "@launchql/postmaster": "0.1.4", "@pgpmjs/env": "workspace:^" } diff --git a/functions/simple-email/src/index.ts b/functions/simple-email/src/index.ts index 2227fbd34..8970d6c02 100644 --- a/functions/simple-email/src/index.ts +++ b/functions/simple-email/src/index.ts @@ -1,6 +1,7 @@ import { createJobApp } from '@constructive-io/knative-job-fn'; +import { send as sendSmtp } from '@constructive-io/smtppostmaster'; +import { send as sendPostmaster } from '@launchql/postmaster'; import { parseEnvBoolean } from '@pgpmjs/env'; -import { send as sendEmail } from '@launchql/postmaster'; type SimpleEmailPayload = { to: string; @@ -26,6 +27,7 @@ const getRequiredField = ( }; const isDryRun = parseEnvBoolean(process.env.SIMPLE_EMAIL_DRY_RUN) ?? false; +const useSmtp = parseEnvBoolean(process.env.EMAIL_SEND_USE_SMTP) ?? false; const app = createJobApp(); app.post('/', async (req: any, res: any, next: any) => { @@ -42,7 +44,7 @@ app.post('/', async (req: any, res: any, next: any) => { throw new Error("Either 'html' or 'text' must be provided"); } - const fromEnv = process.env.MAILGUN_FROM; + const fromEnv = useSmtp ? process.env.SMTP_FROM : process.env.MAILGUN_FROM; const from = isNonEmptyString(payload.from) ? payload.from : isNonEmptyString(fromEnv) @@ -67,6 +69,7 @@ app.post('/', async (req: any, res: any, next: any) => { console.log('[simple-email] DRY RUN email (no send)', logContext); } else { // Send via the Postmaster package (Mailgun or configured provider) + const sendEmail = useSmtp ? sendSmtp : sendPostmaster; await sendEmail({ to, subject, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 355478a27..3c15757d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,6 +73,9 @@ importers: '@constructive-io/knative-job-fn': specifier: workspace:^ version: link:../../jobs/knative-job-fn + '@constructive-io/smtppostmaster': + specifier: workspace:^ + version: link:../../packages/smtppostmaster/dist '@launchql/mjml': specifier: 0.1.1 version: 0.1.1(@babel/core@7.28.5)(encoding@0.1.13)(react-dom@19.2.3(react@19.2.3))(react-is@18.3.1)(react@19.2.3) @@ -97,6 +100,9 @@ importers: '@constructive-io/knative-job-fn': specifier: workspace:^ version: link:../../jobs/knative-job-fn + '@constructive-io/smtppostmaster': + specifier: workspace:^ + version: link:../../packages/smtppostmaster/dist '@launchql/postmaster': specifier: 0.1.4 version: 0.1.4 From c0d1b486d3de98cf5dae34b5fb1fc11d3fd0f73b Mon Sep 17 00:00:00 2001 From: zetazzz Date: Thu, 15 Jan 2026 21:47:03 +0800 Subject: [PATCH 09/12] fixed logger printing objs format --- pgpm/logger/src/logger.ts | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/pgpm/logger/src/logger.ts b/pgpm/logger/src/logger.ts index f24cf2585..57a5ef243 100644 --- a/pgpm/logger/src/logger.ts +++ b/pgpm/logger/src/logger.ts @@ -22,6 +22,24 @@ const hasAnsi = (text: string): boolean => { return typeof text === 'string' && /\u001b\[\d+m/.test(text); }; +const safeStringify = (obj: unknown): string => { + try { + return JSON.stringify(obj, null, 2); + } catch { + return '[Unserializable Object]'; + } +}; + +const formatArg = (arg: unknown): unknown => { + if (typeof arg === 'string') return arg; + if (arg instanceof Error) return arg.stack ?? arg.message; + if (typeof arg === 'bigint') return arg.toString(); + if (typeof arg === 'object' || typeof arg === 'function') { + return safeStringify(arg); + } + return arg; +}; + // Parse LOG_LEVEL from environment let globalLogLevel: LogLevel = (process.env.LOG_LEVEL?.toLowerCase() as LogLevel) ?? 'info'; @@ -102,9 +120,12 @@ export class Logger { const color = levelColors[level]; const prefix = color(`${level.toUpperCase()}:`); - const formattedArgs = args.map(arg => - typeof arg === 'string' && !hasAnsi(arg) ? color(arg) : arg - ); + const formattedArgs = args.map(arg => { + const normalized = formatArg(arg); + return typeof normalized === 'string' && !hasAnsi(normalized) + ? color(normalized) + : normalized; + }); const stream = level === 'error' ? process.stderr : process.stdout; const outputParts = showTimestamp From 601b8850d92ea217b54f13b2575dbb40cae29869 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Thu, 15 Jan 2026 21:54:23 +0800 Subject: [PATCH 10/12] add comments --- packages/server/__tests__/jobs.e2e.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/server/__tests__/jobs.e2e.test.ts b/packages/server/__tests__/jobs.e2e.test.ts index 8451f186d..9b90725e1 100644 --- a/packages/server/__tests__/jobs.e2e.test.ts +++ b/packages/server/__tests__/jobs.e2e.test.ts @@ -137,6 +137,7 @@ const waitForJobCompletion = async ( const seededDatabaseId = '0b22e268-16d6-582b-950a-24e108688849'; const metaDbExtensions = ['citext', 'uuid-ossp', 'unaccent', 'pgcrypto', 'hstore']; +// Ports are fixed, if these're occupied, then the test just feel free to fail. const GRAPHQL_PORT = 3000; const CALLBACK_PORT = 12345; const SIMPLE_EMAIL_PORT = 8081; From b5b60311bce1ee965399cb389963a0998be2e8ed Mon Sep 17 00:00:00 2001 From: zetazzz Date: Thu, 15 Jan 2026 21:55:22 +0800 Subject: [PATCH 11/12] update test comments --- packages/server/__tests__/jobs.e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/__tests__/jobs.e2e.test.ts b/packages/server/__tests__/jobs.e2e.test.ts index 9b90725e1..1492adaa1 100644 --- a/packages/server/__tests__/jobs.e2e.test.ts +++ b/packages/server/__tests__/jobs.e2e.test.ts @@ -137,7 +137,7 @@ const waitForJobCompletion = async ( const seededDatabaseId = '0b22e268-16d6-582b-950a-24e108688849'; const metaDbExtensions = ['citext', 'uuid-ossp', 'unaccent', 'pgcrypto', 'hstore']; -// Ports are fixed, if these're occupied, then the test just feel free to fail. +// Ports are fixed by test design, if these're occupied, then the test just feel free to fail. const GRAPHQL_PORT = 3000; const CALLBACK_PORT = 12345; const SIMPLE_EMAIL_PORT = 8081; From 67124f18c80df24bc6c11b4c46ca2f10907d08f5 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Thu, 15 Jan 2026 22:51:08 +0800 Subject: [PATCH 12/12] apply loggers --- functions/send-email-link/package.json | 1 + functions/send-email-link/src/index.ts | 8 ++++---- functions/simple-email/package.json | 3 ++- functions/simple-email/src/index.ts | 11 +++++------ jobs/job-pg/package.json | 1 + jobs/job-pg/src/index.ts | 13 +++++++------ jobs/job-worker/package.json | 1 + jobs/job-worker/src/index.ts | 21 +++++++++++---------- jobs/job-worker/src/run.ts | 7 +++++-- jobs/knative-job-fn/package.json | 1 + jobs/knative-job-fn/src/index.ts | 21 +++++++++------------ jobs/knative-job-server/package.json | 1 + jobs/knative-job-server/src/index.ts | 21 +++++++++++---------- jobs/knative-job-server/src/run.ts | 5 +++-- jobs/knative-job-service/package.json | 1 + jobs/knative-job-service/src/run.ts | 18 ++++++++---------- packages/server/src/run.ts | 6 ++++-- pgpm/logger/src/index.ts | 2 +- pnpm-lock.yaml | 21 +++++++++++++++++++++ 19 files changed, 97 insertions(+), 66 deletions(-) diff --git a/functions/send-email-link/package.json b/functions/send-email-link/package.json index 0533b6b9b..7057d5d2d 100644 --- a/functions/send-email-link/package.json +++ b/functions/send-email-link/package.json @@ -20,6 +20,7 @@ "@launchql/postmaster": "0.1.4", "@launchql/styled-email": "0.1.0", "@pgpmjs/env": "workspace:^", + "@pgpmjs/logger": "workspace:^", "graphql-request": "^7.1.2", "graphql-tag": "^2.12.6" } diff --git a/functions/send-email-link/src/index.ts b/functions/send-email-link/src/index.ts index 0f557e26a..b980de65c 100644 --- a/functions/send-email-link/src/index.ts +++ b/functions/send-email-link/src/index.ts @@ -5,9 +5,11 @@ import { generate } from '@launchql/mjml'; import { send as sendPostmaster } from '@launchql/postmaster'; import { send as sendSmtp } from '@constructive-io/smtppostmaster'; import { parseEnvBoolean } from '@pgpmjs/env'; +import { createLogger } from '@pgpmjs/logger'; const isDryRun = parseEnvBoolean(process.env.SEND_EMAIL_LINK_DRY_RUN) ?? false; const useSmtp = parseEnvBoolean(process.env.EMAIL_SEND_USE_SMTP) ?? false; +const logger = createLogger('send-email-link'); const app = createJobApp(); const GetUser = gql` @@ -276,8 +278,7 @@ export const sendEmailLink = async ( }); if (isDryRun) { - // eslint-disable-next-line no-console - console.log('[send-email-link] DRY RUN email (skipping send)', { + logger.info('DRY RUN email (skipping send)', { email_type: params.email_type, email: params.email, subject, @@ -334,7 +335,6 @@ if (require.main === module) { const port = Number(process.env.PORT ?? 8080); // @constructive-io/knative-job-fn exposes a .listen method that delegates to the Express app (app as any).listen(port, () => { - // eslint-disable-next-line no-console - console.log(`[send-email-link] listening on port ${port}`); + logger.info(`listening on port ${port}`); }); } diff --git a/functions/simple-email/package.json b/functions/simple-email/package.json index 963e971f3..5a09b5437 100644 --- a/functions/simple-email/package.json +++ b/functions/simple-email/package.json @@ -17,6 +17,7 @@ "@constructive-io/knative-job-fn": "workspace:^", "@constructive-io/smtppostmaster": "workspace:^", "@launchql/postmaster": "0.1.4", - "@pgpmjs/env": "workspace:^" + "@pgpmjs/env": "workspace:^", + "@pgpmjs/logger": "workspace:^" } } diff --git a/functions/simple-email/src/index.ts b/functions/simple-email/src/index.ts index 8970d6c02..5d6e31609 100644 --- a/functions/simple-email/src/index.ts +++ b/functions/simple-email/src/index.ts @@ -2,6 +2,7 @@ import { createJobApp } from '@constructive-io/knative-job-fn'; import { send as sendSmtp } from '@constructive-io/smtppostmaster'; import { send as sendPostmaster } from '@launchql/postmaster'; import { parseEnvBoolean } from '@pgpmjs/env'; +import { createLogger } from '@pgpmjs/logger'; type SimpleEmailPayload = { to: string; @@ -28,6 +29,7 @@ const getRequiredField = ( const isDryRun = parseEnvBoolean(process.env.SIMPLE_EMAIL_DRY_RUN) ?? false; const useSmtp = parseEnvBoolean(process.env.EMAIL_SEND_USE_SMTP) ?? false; +const logger = createLogger('simple-email'); const app = createJobApp(); app.post('/', async (req: any, res: any, next: any) => { @@ -65,8 +67,7 @@ app.post('/', async (req: any, res: any, next: any) => { }; if (isDryRun) { - // eslint-disable-next-line no-console - console.log('[simple-email] DRY RUN email (no send)', logContext); + logger.info('DRY RUN email (no send)', logContext); } else { // Send via the Postmaster package (Mailgun or configured provider) const sendEmail = useSmtp ? sendSmtp : sendPostmaster; @@ -79,8 +80,7 @@ app.post('/', async (req: any, res: any, next: any) => { ...(replyTo && { replyTo }) }); - // eslint-disable-next-line no-console - console.log('[simple-email] Sent email', logContext); + logger.info('Sent email', logContext); } res.status(200).json({ complete: true }); @@ -97,7 +97,6 @@ if (require.main === module) { const port = Number(process.env.PORT ?? 8080); // @constructive-io/knative-job-fn exposes a .listen method that delegates to the underlying Express app (app as any).listen(port, () => { - // eslint-disable-next-line no-console - console.log(`[simple-email] listening on port ${port}`); + logger.info(`listening on port ${port}`); }); } diff --git a/jobs/job-pg/package.json b/jobs/job-pg/package.json index d31407696..9592b4672 100644 --- a/jobs/job-pg/package.json +++ b/jobs/job-pg/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@constructive-io/job-utils": "workspace:^", + "@pgpmjs/logger": "workspace:^", "pg": "8.16.3" } } diff --git a/jobs/job-pg/src/index.ts b/jobs/job-pg/src/index.ts index 45e41aa20..10e5530c3 100644 --- a/jobs/job-pg/src/index.ts +++ b/jobs/job-pg/src/index.ts @@ -1,7 +1,6 @@ -/* eslint-disable no-console */ - import { Pool, PoolConfig } from 'pg'; import { getJobPgConfig } from '@constructive-io/job-utils'; +import { createLogger } from '@pgpmjs/logger'; // k8s only does SIGINT // other events are bad for babel-watch @@ -33,6 +32,8 @@ function once unknown>( const pgPoolConfig: PoolConfig = getJobPgConfig() as PoolConfig; +const logger = createLogger('job-pg'); + const end = (pool: Pool): void => { try { // Pool has internal state flags, but they are not part of the public type @@ -41,13 +42,13 @@ const end = (pool: Pool): void => { ending?: boolean; }; if (state.ended || state.ending) { - console.error( + logger.error( 'DO NOT CLOSE pool, why are you trying to call end() when already ended?' ); return; } void pool.end(); - console.log('successfully closed pool.'); + logger.info('successfully closed pool.'); } catch (e) { process.stderr.write(String(e)); } @@ -68,7 +69,7 @@ class PoolManager { this._closed = false; const closeOnce = once(async () => { - console.log('closing pg pool manager...'); + logger.info('closing pg pool manager...'); await this.close(); }, this); @@ -89,7 +90,7 @@ class PoolManager { if (this._closed) return; for (const [fn, context, args] of this.callbacks) { - console.log('closing fn', fn.name); + logger.info('closing fn', fn.name); await fn.apply(context, args); } diff --git a/jobs/job-worker/package.json b/jobs/job-worker/package.json index 1d5fe4373..ac5fd0d34 100644 --- a/jobs/job-worker/package.json +++ b/jobs/job-worker/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@constructive-io/job-utils": "workspace:^", + "@pgpmjs/logger": "workspace:^", "pg": "8.16.3" } } diff --git a/jobs/job-worker/src/index.ts b/jobs/job-worker/src/index.ts index 6e3598e00..c22847108 100644 --- a/jobs/job-worker/src/index.ts +++ b/jobs/job-worker/src/index.ts @@ -2,6 +2,7 @@ import pg from 'pg'; import type { Pool, PoolClient } from 'pg'; import * as jobs from '@constructive-io/job-utils'; import type { PgClientLike } from '@constructive-io/job-utils'; +import { createLogger } from '@pgpmjs/logger'; const pgPoolConfig = { connectionString: jobs.getJobConnectionString() @@ -37,7 +38,7 @@ export type TaskHandler = ( job: JobRow ) => Promise | void; -/* eslint-disable no-console */ +const logger = createLogger('job-worker'); export default class Worker { tasks: Record; @@ -75,7 +76,7 @@ export default class Worker { this.doNextTimer = undefined; this.pgPool = pgPool; const close = () => { - console.log('closing connection...'); + logger.info('closing connection...'); this.close(); }; process.once('SIGTERM', close); @@ -97,21 +98,21 @@ export default class Worker { jobId: JobRow['id']; }) { const when = err ? `after failure '${err.message}'` : 'after success'; - console.error( + logger.error( `Failed to release job '${jobId}' ${when}; committing seppuku` ); - console.error(fatalError); + logger.error(fatalError); process.exit(1); } async handleError( client: PgClientLike, { err, job, duration }: { err: Error; job: JobRow; duration: string } ) { - console.error( + logger.error( `Failed task ${job.id} (${job.task_identifier}) with error ${err.message} (${duration}ms)`, { err, stack: err.stack } ); - console.error(err.stack); + logger.error(err.stack); await jobs.failJob(client, { workerId: this.workerId, jobId: job.id, @@ -122,7 +123,7 @@ export default class Worker { client: PgClientLike, { job, duration }: { job: JobRow; duration: string } ) { - console.log( + logger.info( `Completed task ${job.id} (${job.task_identifier}) with success (${duration}ms)` ); await jobs.completeJob(client, { workerId: this.workerId, jobId: job.id }); @@ -192,7 +193,7 @@ export default class Worker { release: () => void ) => { if (err) { - console.error('Error connecting with notify listener', err); + logger.error('Error connecting with notify listener', err); // Try again in 5 seconds // should this really be done in the node process? setTimeout(this.listen, 5000); @@ -206,11 +207,11 @@ export default class Worker { }); client.query('LISTEN "jobs:insert"'); client.on('error', (e: unknown) => { - console.error('Error with database notify listener', e); + logger.error('Error with database notify listener', e); release(); this.listen(); }); - console.log(`${this.workerId} connected and looking for jobs...`); + logger.info(`${this.workerId} connected and looking for jobs...`); this.doNext(client); }; this.pgPool.connect(listenForChanges); diff --git a/jobs/job-worker/src/run.ts b/jobs/job-worker/src/run.ts index 358d44938..64a0631b2 100644 --- a/jobs/job-worker/src/run.ts +++ b/jobs/job-worker/src/run.ts @@ -1,4 +1,7 @@ import Worker, { WorkerContext, JobRow } from './index'; +import { createLogger } from '@pgpmjs/logger'; + +const logger = createLogger('job-worker-run'); const worker = new Worker({ tasks: { @@ -6,9 +9,9 @@ const worker = new Worker({ { pgPool, workerId }: WorkerContext, job: JobRow ) => { - console.log('hello'); + logger.info('hello'); await pgPool.query('select 1'); - console.log(JSON.stringify(job, null, 2)); + logger.info(JSON.stringify(job, null, 2)); } } }); diff --git a/jobs/knative-job-fn/package.json b/jobs/knative-job-fn/package.json index e1efcc176..43e358c33 100644 --- a/jobs/knative-job-fn/package.json +++ b/jobs/knative-job-fn/package.json @@ -28,6 +28,7 @@ "url": "https://github.com/constructive-io/jobs/issues" }, "dependencies": { + "@pgpmjs/logger": "workspace:^", "body-parser": "1.19.0", "express": "5.2.1" } diff --git a/jobs/knative-job-fn/src/index.ts b/jobs/knative-job-fn/src/index.ts index 5e313e550..641a149c3 100644 --- a/jobs/knative-job-fn/src/index.ts +++ b/jobs/knative-job-fn/src/index.ts @@ -4,6 +4,7 @@ import http from 'node:http'; import https from 'node:https'; import { URL } from 'node:url'; import type { Server as HttpServer } from 'http'; +import { createLogger } from '@pgpmjs/logger'; type JobCallbackStatus = 'success' | 'error'; @@ -108,8 +109,7 @@ const sendJobCallback = async ( } try { - // eslint-disable-next-line no-console - console.log('[knative-job-fn] Sending job callback', { + logger.info('Sending job callback', { status, target: normalizeCallbackUrl(callbackUrl), workerId, @@ -118,8 +118,7 @@ const sendJobCallback = async ( }); await postJson(target, headers, body); } catch (err) { - // eslint-disable-next-line no-console - console.error('[knative-job-fn] Failed to POST job callback', { + logger.error('Failed to POST job callback', { target, status, err @@ -127,6 +126,8 @@ const sendJobCallback = async ( } }; +const logger = createLogger('knative-job-fn'); + const createJobApp = () => { const app: any = express(); @@ -149,8 +150,7 @@ const createJobApp = () => { body = undefined; } - // eslint-disable-next-line no-console - console.log('[knative-job-fn] Incoming job request', { + logger.info('Incoming job request', { method: req.method, path: req.originalUrl || req.url, headers, @@ -192,8 +192,7 @@ const createJobApp = () => { // If an error handler already sent a callback, skip. if (res.locals.jobCallbackSent) return; res.locals.jobCallbackSent = true; - // eslint-disable-next-line no-console - console.log('[knative-job-fn] Function completed', { + logger.info('Function completed', { workerId: ctx.workerId, jobId: ctx.jobId, databaseId: ctx.databaseId, @@ -233,8 +232,7 @@ const createJobApp = () => { await sendJobCallback(ctx, 'error', error?.message); } } catch (err) { - // eslint-disable-next-line no-console - console.error('[knative-job-fn] Failed to send error callback', err); + logger.error('Failed to send error callback', err); } // Log the full error context for debugging. @@ -257,8 +255,7 @@ const createJobApp = () => { }; } - // eslint-disable-next-line no-console - console.error('[knative-job-fn] Function error', { + logger.error('Function error', { headers, path: req.originalUrl || req.url, error: errorDetails diff --git a/jobs/knative-job-server/package.json b/jobs/knative-job-server/package.json index d6733bcdc..dac3262cb 100644 --- a/jobs/knative-job-server/package.json +++ b/jobs/knative-job-server/package.json @@ -33,6 +33,7 @@ "dependencies": { "@constructive-io/job-pg": "workspace:^", "@constructive-io/job-utils": "workspace:^", + "@pgpmjs/logger": "workspace:^", "body-parser": "1.19.0", "express": "5.2.1", "pg": "8.16.3" diff --git a/jobs/knative-job-server/src/index.ts b/jobs/knative-job-server/src/index.ts index 540f4322a..08b53bdac 100644 --- a/jobs/knative-job-server/src/index.ts +++ b/jobs/knative-job-server/src/index.ts @@ -3,6 +3,7 @@ import bodyParser from 'body-parser'; import type { Pool, PoolClient } from 'pg'; import * as jobs from '@constructive-io/job-utils'; import poolManager from '@constructive-io/job-pg'; +import { createLogger } from '@pgpmjs/logger'; type JobRequestBody = { message?: string; @@ -34,6 +35,8 @@ type WithClientHandler = ( next: NextFn ) => Promise; +const logger = createLogger('knative-job-server'); + export default (pgPool: Pool = poolManager.getPool()) => { const app = express(); app.use(bodyParser.json()); @@ -56,15 +59,14 @@ export default (pgPool: Pool = poolManager.getPool()) => { const jobIdHeader = req.get('X-Job-Id'); if (!workerId || !jobIdHeader) { - console.log( - 'server: missing worker/job headers on completion callback', + logger.warn('missing worker/job headers on completion callback', { workerId, jobIdHeader } ); res.status(400).json({ error: 'Missing X-Worker-Id or X-Job-Id header' }); return; } - console.log(`server: Completed task ${jobIdHeader} with success`); + logger.info(`Completed task ${jobIdHeader} with success`); await jobs.completeJob(client, { workerId, jobId: jobIdHeader }); res @@ -80,8 +82,7 @@ export default (pgPool: Pool = poolManager.getPool()) => { const jobIdHeader = req.get('X-Job-Id'); if (!workerId || !jobIdHeader) { - console.log( - 'server: missing worker/job headers on failure callback', + logger.warn('missing worker/job headers on failure callback', { workerId, jobIdHeader } ); res.status(400).json({ error: 'Missing X-Worker-Id or X-Job-Id header' }); @@ -90,8 +91,8 @@ export default (pgPool: Pool = poolManager.getPool()) => { const errorMessage = req.body.error || req.body.message || 'UNKNOWN_ERROR'; - console.log( - `server: Failed task ${jobIdHeader} with error: \n${errorMessage}\n\n` + logger.error( + `Failed task ${jobIdHeader} with error: \n${errorMessage}\n\n` ); await jobs.failJob(client, { @@ -107,9 +108,9 @@ export default (pgPool: Pool = poolManager.getPool()) => { const jobId = req.get('X-Job-Id'); if (typeof jobId === 'undefined') { - console.log('server: undefined JOB, what is this? healthcheck?'); - console.log(req.url); - console.log(req.originalUrl); + logger.warn('undefined JOB, what is this? healthcheck?'); + logger.debug(req.url); + logger.debug(req.originalUrl); return res.status(200).json('OK'); } diff --git a/jobs/knative-job-server/src/run.ts b/jobs/knative-job-server/src/run.ts index 5eed56f4e..d8cd896b3 100755 --- a/jobs/knative-job-server/src/run.ts +++ b/jobs/knative-job-server/src/run.ts @@ -3,11 +3,12 @@ import server from './index'; import poolManager from '@constructive-io/job-pg'; import { getJobsCallbackPort } from '@constructive-io/job-utils'; +import { createLogger } from '@pgpmjs/logger'; +const logger = createLogger('knative-job-server'); const pgPool = poolManager.getPool(); const port = getJobsCallbackPort(); server(pgPool).listen(port, () => { - // eslint-disable-next-line no-console - console.log(`listening ON ${port}`); + logger.info(`listening ON ${port}`); }); diff --git a/jobs/knative-job-service/package.json b/jobs/knative-job-service/package.json index 8dcbb2867..7baf2bcc3 100644 --- a/jobs/knative-job-service/package.json +++ b/jobs/knative-job-service/package.json @@ -41,6 +41,7 @@ "@constructive-io/job-utils": "workspace:^", "@constructive-io/knative-job-server": "workspace:^", "@constructive-io/knative-job-worker": "workspace:^", + "@pgpmjs/logger": "workspace:^", "async-retry": "1.3.1", "pg": "8.16.3" } diff --git a/jobs/knative-job-service/src/run.ts b/jobs/knative-job-service/src/run.ts index 33caf9737..38d0f8d67 100755 --- a/jobs/knative-job-service/src/run.ts +++ b/jobs/knative-job-service/src/run.ts @@ -14,17 +14,18 @@ import { getJobSupported, getJobsCallbackPort, } from '@constructive-io/job-utils'; +import { createLogger } from '@pgpmjs/logger'; + +const logger = createLogger('knative-job-service'); export const startJobsServices = () => { - // eslint-disable-next-line no-console - console.log('starting jobs services...'); + logger.info('starting jobs services...'); const pgPool = poolManager.getPool(); const app = server(pgPool); const callbackPort = getJobsCallbackPort(); const httpServer = app.listen(callbackPort, () => { - // eslint-disable-next-line no-console - console.log(`[cb] listening ON ${callbackPort}`); + logger.info(`listening ON ${callbackPort}`); const tasks = getJobSupported(); @@ -48,8 +49,7 @@ export const startJobsServices = () => { }; export const waitForJobsPrereqs = async (): Promise => { - // eslint-disable-next-line no-console - console.log('waiting for jobs prereqs'); + logger.info('waiting for jobs prereqs'); let client: Client | null = null; try { const cfg = getJobPgConfig(); @@ -64,8 +64,7 @@ export const waitForJobsPrereqs = async (): Promise => { const schema = getJobSchema(); await client.query(`SELECT * FROM "${schema}".jobs LIMIT 1;`); } catch (error) { - // eslint-disable-next-line no-console - console.log(error); + logger.error(error); throw new Error('jobs server boot failed...'); } finally { if (client) { @@ -75,8 +74,7 @@ export const waitForJobsPrereqs = async (): Promise => { }; export const bootJobs = async (): Promise => { - // eslint-disable-next-line no-console - console.log('attempting to boot jobs'); + logger.info('attempting to boot jobs'); await retry( async () => { await waitForJobsPrereqs(); diff --git a/packages/server/src/run.ts b/packages/server/src/run.ts index fa979dc69..a81f2c5e6 100644 --- a/packages/server/src/run.ts +++ b/packages/server/src/run.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { parseEnvBoolean } from '@pgpmjs/env'; +import { createLogger } from '@pgpmjs/logger'; import { CombinedServer } from './server'; import { @@ -84,6 +85,8 @@ export const buildCombinedServerOptionsFromEnv = (): CombinedServerOptions => ({ functions: buildFunctionsOptions() }); +const logger = createLogger('combined-server'); + export const startCombinedServerFromEnv = async (): Promise => { const server = new CombinedServer(buildCombinedServerOptionsFromEnv()); return server.start(); @@ -91,8 +94,7 @@ export const startCombinedServerFromEnv = async (): Promise { - // eslint-disable-next-line no-console - console.error('Combined server failed to start:', error); + logger.error('Combined server failed to start:', error); process.exit(1); }); } diff --git a/pgpm/logger/src/index.ts b/pgpm/logger/src/index.ts index 0a9e39b0e..1e62119de 100644 --- a/pgpm/logger/src/index.ts +++ b/pgpm/logger/src/index.ts @@ -1 +1 @@ -export { Logger } from './logger'; \ No newline at end of file +export { Logger, createLogger } from './logger'; \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad6b4aa74..006c3b227 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,6 +88,9 @@ importers: '@pgpmjs/env': specifier: workspace:^ version: link:../../pgpm/env/dist + '@pgpmjs/logger': + specifier: workspace:^ + version: link:../../pgpm/logger/dist graphql-request: specifier: ^7.1.2 version: 7.4.0(graphql@15.10.1) @@ -109,6 +112,9 @@ importers: '@pgpmjs/env': specifier: workspace:^ version: link:../../pgpm/env/dist + '@pgpmjs/logger': + specifier: workspace:^ + version: link:../../pgpm/logger/dist graphile/graphile-cache: dependencies: @@ -1166,6 +1172,9 @@ importers: '@constructive-io/job-utils': specifier: workspace:^ version: link:../job-utils + '@pgpmjs/logger': + specifier: workspace:^ + version: link:../../pgpm/logger/dist pg: specifier: 8.16.3 version: 8.16.3 @@ -1208,6 +1217,9 @@ importers: '@constructive-io/job-utils': specifier: workspace:^ version: link:../job-utils + '@pgpmjs/logger': + specifier: workspace:^ + version: link:../../pgpm/logger/dist pg: specifier: 8.16.3 version: 8.16.3 @@ -1220,6 +1232,9 @@ importers: jobs/knative-job-fn: dependencies: + '@pgpmjs/logger': + specifier: workspace:^ + version: link:../../pgpm/logger/dist body-parser: specifier: 1.19.0 version: 1.19.0 @@ -1235,6 +1250,9 @@ importers: '@constructive-io/job-utils': specifier: workspace:^ version: link:../job-utils + '@pgpmjs/logger': + specifier: workspace:^ + version: link:../../pgpm/logger/dist body-parser: specifier: 1.19.0 version: 1.19.0 @@ -1262,6 +1280,9 @@ importers: '@constructive-io/knative-job-worker': specifier: workspace:^ version: link:../knative-job-worker + '@pgpmjs/logger': + specifier: workspace:^ + version: link:../../pgpm/logger/dist async-retry: specifier: 1.3.1 version: 1.3.1