diff --git a/.github/workflows/hub-image.yml b/.github/workflows/hub-image.yml new file mode 100644 index 00000000..9cbbc573 --- /dev/null +++ b/.github/workflows/hub-image.yml @@ -0,0 +1,22 @@ +name: Hub image + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + image: + name: Hub image smoke test + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + - run: node hub/script/check-image/index.ts diff --git a/hub/.dockerignore b/hub/.dockerignore new file mode 100644 index 00000000..d94238a8 --- /dev/null +++ b/hub/.dockerignore @@ -0,0 +1,13 @@ +.env* +!.env.example +.devcontainer +.local +.npm +node_modules +dist +coverage +storybook-static +server/generated +**/*.test.* +**/stories.tsx +.storybook diff --git a/hub/.env.example b/hub/.env.example index 98392530..6d41d671 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -1,5 +1,11 @@ # Optional API port. The Vite development proxy expects the default. PORT=3001 +HOST=127.0.0.1 + +# The image defaults to combined; development starts the API alone. +RUNTIME_MODE=api +SHUTDOWN_TIMEOUT_MS=30000 +# CLIENT_DIRECTORY=/absolute/path/to/dist/client # API logging threshold: trace, debug, info, warn, error, fatal, or silent. LOG_LEVEL=info diff --git a/hub/Dockerfile b/hub/Dockerfile new file mode 100644 index 00000000..4d94bde0 --- /dev/null +++ b/hub/Dockerfile @@ -0,0 +1,27 @@ +# syntax=docker/dockerfile:1 +FROM node:24-bookworm-slim AS build +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY package.json package-lock.json .npmrc ./ +COPY schema ./schema +COPY prisma7.config.ts ./ +RUN npm ci --engine-strict +COPY . . +RUN npm run build:runtime + +FROM node:24-bookworm-slim AS runtime +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates \ + && rm -rf /var/lib/apt/lists/* +ENV NODE_ENV=production HOST=0.0.0.0 PORT=3001 RUNTIME_MODE=combined \ + CLIENT_DIRECTORY=/app/dist/client SHUTDOWN_TIMEOUT_MS=30000 +COPY --from=build /app/package.json /app/package-lock.json /app/.npmrc ./ +COPY --from=build /app/schema ./schema +COPY --from=build /app/prisma7.config.ts ./ +COPY --from=build /app/node_modules ./node_modules +RUN npm prune --omit=dev --ignore-scripts && npm cache clean --force +COPY --from=build /app/dist ./dist +USER node +EXPOSE 3001 +CMD ["node", "dist/server/index.js"] diff --git a/hub/README.md b/hub/README.md index 47ef69db..771d0d49 100644 --- a/hub/README.md +++ b/hub/README.md @@ -2,9 +2,11 @@ Hub is being rebuilt on `build/hub/new` using TypeScript, Express, React, Vite, and Vitest. This branch lets contributors start the new app locally. It is not ready to replace the existing Hub. -The API has one route: `GET /api/health`. The frontend displays the existing Replica logo and supports light and dark themes. Existing components, features, and pages are kept in Storybook, but they are not connected to the new API. +The API has liveness and readiness routes. The frontend displays the existing Replica logo and supports light and dark themes. Existing components, features, and pages are kept in Storybook, but they are not connected to product APIs. -Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The [background-job foundation](docs/background-jobs.md) provides PostgreSQL-backed BullMQ queues and independent on-demand and scheduled workers, without product jobs yet. The database has no product models. Sign-in, saved product data, live updates, and deployment are not implemented. +Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The [background-job foundation](docs/background-jobs.md) provides PostgreSQL-backed BullMQ queues and independent on-demand and scheduled workers, without product jobs yet. The database has no product models. Sign-in, saved product data, and live updates are not implemented. + +The [production runtime](docs/deployment.md) packages the frontend, API, and workers in one image, with a separate release setup script. Use [Development](docs/development.md) to start the API and frontend. Review [Architecture](docs/architecture.md), [code architecture conventions](docs/backend-architecture.md), and [coding conventions](docs/coding-conventions.md) before adding behavior. diff --git a/hub/docs/README.md b/hub/docs/README.md index 954facb6..f8c34b03 100644 --- a/hub/docs/README.md +++ b/hub/docs/README.md @@ -6,6 +6,7 @@ Hub is being rebuilt. The [Hub overview](../README.md) describes what the baseli - [Code architecture conventions](backend-architecture.md): repositories, integrations, utilities, and domains. - [Coding conventions](coding-conventions.md): frontend layers and interface rules. - [Development](development.md): local API and frontend setup. +- [Deployment](deployment.md): production image, runtime roles, setup, and health. - [Database](database.md): Prisma schema conventions, migrations, and the shared client. - [Authentication proof](authentication.md): internal provider proof and one-use authorization attempts. - [Logging](logging.md): application events, automatic request records, and payload safety. diff --git a/hub/docs/architecture.md b/hub/docs/architecture.md index 0de7e6a3..22ea8f56 100644 --- a/hub/docs/architecture.md +++ b/hub/docs/architecture.md @@ -8,13 +8,15 @@ The team has chosen TypeScript, Express for the API, React and Vite for the fron The API responds to a health check, and the frontend displays the logo with light and dark themes. Storybook keeps the existing components and pages available for reuse. -Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The database has a provider-neutral identity transaction foundation and internal GitHub authentication proof primitives. No public authentication flow is exposed. Deployment and product APIs have not been built. This app cannot yet find repositories, import Builds, host previews, save product data, or support reviews. +Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The database has a provider-neutral identity transaction foundation and internal GitHub authentication proof primitives. No public authentication flow is exposed. Product APIs have not been built. This app cannot yet find repositories, import Builds, host previews, save product data, or support reviews. + +The [production runtime](deployment.md) serves the built frontend and API and runs both workers in one image. Deployment runs database and job setup separately before starting that image. ## Backend API code lives under `server/`. As features are added, route handlers will read requests, call domains, and return responses. The [code conventions](backend-architecture.md) explain repositories, integrations, utilities, domains, and which layers may import others. Put these directories under `server/` when they are needed; do not add empty implementations. -`GET /api/health` returns `{ "status": "ok" }`. Authentication routes are not registered. Unknown paths return a JSON 404 response; invalid JSON is rejected before routing. `createApp()` builds the Express app without starting a server. `server/index.ts` starts it at a local address. +`GET /api/health` returns `{ "status": "ok" }`; `GET /api/ready` reports operational readiness. Authentication routes are not registered. Unknown API paths return a JSON 404 response; invalid JSON is rejected before routing. `createApp()` builds the Express app without starting a server. `server/index.ts` selects a runtime role and defaults to the local API. The [database guide](database.md) defines the multi-file Prisma schema and shared client in integrations. The health route remains a process check, not a database readiness check. diff --git a/hub/docs/backend-architecture.md b/hub/docs/backend-architecture.md index c1bb39d0..a22f46f3 100644 --- a/hub/docs/backend-architecture.md +++ b/hub/docs/backend-architecture.md @@ -2,13 +2,13 @@ Organize backend functions into routes, repositories, integrations, utilities, and domains. Each layer has a clear job and rules for which other layers it may use. -| Layer | Responsibility | Directory example | -| --- | --- | --- | -| Routes | Parse HTTP requests, call backend functions, and send responses. Directories follow URL paths and HTTP methods. | `routes/api/health/get/index.ts` | -| Repositories | Functions that interact with the database. Each repository directory represents one RESTful resource. | `repositories/user/get-user/index.ts` | -| Integrations | Functions that interact with an API or SDK. Each integration directory represents one API or SDK service. | `integrations/github/get-user/index.ts` | -| Utilities | General functions reusable elsewhere in the codebase. | `utils/format-timestamp/index.ts` | -| Domains | Business logic functions that may compose repositories, integrations, and utilities. | `domains/user/get-user-profile/index.ts` | +| Layer | Responsibility | Directory example | +| ------------ | --------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | +| Routes | Parse HTTP requests, call backend functions, and send responses. Directories follow URL paths and HTTP methods. | `routes/api/health/get/index.ts` | +| Repositories | Functions that interact with the database. Each repository directory represents one RESTful resource. | `repositories/user/get-user/index.ts` | +| Integrations | Functions that interact with an API or SDK. Each integration directory represents one API or SDK service. | `integrations/github/get-user/index.ts` | +| Utilities | General functions reusable elsewhere in the codebase. | `utils/format-timestamp/index.ts` | +| Domains | Business logic functions that may compose repositories, integrations, and utilities. | `domains/user/get-user-profile/index.ts` | ## Import boundaries @@ -37,6 +37,8 @@ Export route handlers by HTTP method, such as `get()`, and alias them to descrip Add directories when their functions are needed. Do not add empty implementations just to fill out the structure. +Operational entry points belong under `server/scripts/`. They may compose backend functions and run deployment tools, but routes and domains must not import them. Setup runs separately from the API and workers. + ## Database access The [shared Prisma client](database.md#shared-client) lives in `server/integrations/prisma/index.ts`. Domains may pass it to repositories for database queries without letting repositories import integrations. Keep model definitions, naming, and migrations consistent with the [database conventions](database.md#schema-conventions). diff --git a/hub/docs/background-jobs.md b/hub/docs/background-jobs.md index 77f7650f..57f34813 100644 --- a/hub/docs/background-jobs.md +++ b/hub/docs/background-jobs.md @@ -6,9 +6,9 @@ Hub uses BullMQ's PostgreSQL backend for internal background work. The foundatio Two stable queue names separate execution capacity: -| Queue | Work | Command from `hub/` | -| --- | --- | --- | -| `on-demand` | Work requested by an application action | `npm run worker:on-demand` | +| Queue | Work | Command from `hub/` | +| ----------- | --------------------------------------------------- | -------------------------- | +| `on-demand` | Work requested by an application action | `npm run worker:on-demand` | | `scheduled` | Recurring work and work spawned by recurring sweeps | `npm run worker:scheduled` | Each command starts one Node.js process consuming only its assigned queue. The API starts neither worker. Multiple processes may consume the same queue when more capacity is needed. Separate queues do not isolate PostgreSQL, CPU, or external service quotas. @@ -17,7 +17,9 @@ Each command starts one Node.js process consuming only its assigned queue. The A `server/workers/configuration.ts` is the composition point for processors and schedules. Its empty registrations are intentional until product domains exist. Worker startup logs readiness after connecting; unknown job names fail permanently rather than disappearing. -Workers stop accepting work on `SIGINT` or `SIGTERM` and wait for active jobs. After 30 seconds, the command exits unsuccessfully rather than waiting forever. Interrupted work can be retried by BullMQ's stalled-job recovery. Deployment stop timeouts must allow this shutdown window. Long-running domain work needs its own bounded execution and interruption policy; a queue lock is not a business lock. +Worker readiness confirms initial connection only; it is not continuous consumer-health monitoring. A later connection error is logged but does not clear the combined runtime's worker-initialized signal. + +Workers stop accepting work on `SIGINT` or `SIGTERM` and wait for active jobs. After `SHUTDOWN_TIMEOUT_MS` (30000 milliseconds by default), the command exits unsuccessfully rather than waiting forever. Interrupted work can be retried by BullMQ's stalled-job recovery. Deployment stop timeouts must allow this shutdown window. Long-running domain work needs its own bounded execution and interruption policy; a queue lock is not a business lock. ## Database and setup @@ -42,7 +44,7 @@ npm run start:worker:on-demand npm run start:worker:scheduled ``` -Run the last three commands as separate services, not sequential commands in one shell. These roles can use one application image in a future Compose deployment, with PostgreSQL shared and only the web service exposed. Production frontend hosting, an application image, service health checks, and release orchestration are not implemented by this foundation. +Run the last three commands as separate services, not sequential commands in one shell. The [production image](deployment.md) instead defaults to a combined process that runs the API and both workers and coordinates their shutdown. Each queue or worker owns a small query pool and BullMQ's additional notification connections. The query pool is limited to two connections; concurrency does not increase that limit. Budget database connections across all API, producer, and worker instances. Creation functions require an explicit URL so tests and other callers cannot accidentally inherit a previously imported database client. @@ -52,14 +54,14 @@ Keep stable names, a Zod payload schema, and retry options in a job definition. Use the functions under `server/integrations/bullmq/`: -| Function | Contract | -| --- | --- | -| `migrateJobDatabase(databaseUrl)` | Explicitly migrate BullMQ's schema and close the migration connection. | -| `createJobQueue(options)` | Open a client for one named PostgreSQL queue. The caller must close it. | -| `enqueueJob(queue, definition, payload, options)` | Validate JSON payload data and enqueue work with job-specific retries and optional delay, priority, or job ID. Failure rejects the call. | -| `createJobProcessor(definition, process, shouldRetry)` | Bind typed execution to runtime payload validation. Invalid stored payloads fail permanently. | -| `createJobWorker(options)` | Construct a stopped consumer for registered processors. The caller starts and closes it. Duplicate processor names are rejected. | -| `upsertJobSchedule(queue, schedule)` | Validate and register an interval or timezone-explicit cron schedule on `scheduled`. | +| Function | Contract | +| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `migrateJobDatabase(databaseUrl)` | Explicitly migrate BullMQ's schema and close the migration connection. | +| `createJobQueue(options)` | Open a client for one named PostgreSQL queue. The caller must close it. | +| `enqueueJob(queue, definition, payload, options)` | Validate JSON payload data and enqueue work with job-specific retries and optional delay, priority, or job ID. Failure rejects the call. | +| `createJobProcessor(definition, process, shouldRetry)` | Bind typed execution to runtime payload validation. Invalid stored payloads fail permanently. | +| `createJobWorker(options)` | Construct a stopped consumer for registered processors. The caller starts and closes it. Duplicate processor names are rejected. | +| `upsertJobSchedule(queue, schedule)` | Validate and register an interval or timezone-explicit cron schedule on `scheduled`. | `runJobWorker()` owns process signals and consumer lifetime outside the integration. Integration code does not import domains, repositories, or the shared logger; worker composition injects processors and logging. diff --git a/hub/docs/deployment.md b/hub/docs/deployment.md new file mode 100644 index 00000000..591f04c1 --- /dev/null +++ b/hub/docs/deployment.md @@ -0,0 +1,44 @@ +# Deploy Hub + +The Hub image runs the frontend, API, and both workers together. It does not yet implement sign-in, artifact import, preview hosting, or reviews. Do not treat the health endpoint as proof of those product flows. + +## Image and runtime + +Build from the repository root with `docker build -t replica-hub:check hub`. The image runs as a non-root user and contains compiled application code and migration tooling. It writes no application state to its writable layer. Publish and deploy the same image digest for setup and Hub. + +Publishing is an explicit operator step, not a side effect of CI. From the approved checkout, authenticate to an authorized registry, then run `docker buildx build --platform linux/amd64 --tag YOUR_REGISTRY/replica-hub:YOUR_COMMIT --push hub`. Resolve the published digest with `docker buildx imagetools inspect YOUR_REGISTRY/replica-hub:YOUR_COMMIT` and give deployment the `registry/repository@sha256:...` reference, never the mutable tag. + +| Setting | Contract | +| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `RUNTIME_MODE` | `api` for source development; the image defaults to `combined`. `on-demand`, `scheduled`, and `setup` are separate roles using the same image. | +| `HOST`, `PORT` | Source defaults to `127.0.0.1:3001`; the image binds `0.0.0.0:3001`. | +| `DATABASE_URL` | PostgreSQL connection URL, required for combined, worker, and setup modes. Configure verified TLS for remote databases. | +| `CLIENT_DIRECTORY` | Absolute directory containing built frontend assets and `index.html`; the image uses `/app/dist/client`. Leave unset for the development API. | +| `SHUTDOWN_TIMEOUT_MS` | Request and worker drain deadline, default 30000 milliseconds. Allow another five seconds for telemetry flushing. | +| `ON_DEMAND_JOB_CONCURRENCY`, `SCHEDULED_JOB_CONCURRENCY` | Independent worker concurrency, as described in [Background jobs](background-jobs.md). | + +Configuration errors stop startup without printing credentials. Logging and optional tracing retain their existing [logging](logging.md) and [telemetry](telemetry.md) contracts. + +The runtime serves built frontend navigation and static files on the API port. Unknown `/api` endpoints remain JSON 404 responses. Missing files with extensions do not return the frontend document. + +`/api/health` is liveness. `/api/ready` returns 503 during initialization, shutdown, or a failed dependency check. In combined mode it requires both workers to initialize and a successful PostgreSQL query. Worker readiness records initialization only, not continuous consumer health: a later worker connection error can be logged while readiness still succeeds. The PostgreSQL query checks current database availability. Readiness is an operational signal, not authorization. + +On termination, combined mode stops all three roles and drains work within the configured deadline. A required role failing or stopping unexpectedly fails the container. Roles remain independently runnable for future deployments; cross-instance WebSocket delivery is not implemented. + +## Setup before release + +Run `npm run setup` from `hub/`, or invoke `node dist/server/scripts/setup/index.js` inside the image. Normal API and worker startup never runs setup. + +The script holds a PostgreSQL session advisory lock across Prisma migrations, BullMQ migrations, and schedule registration. While the lock connection is healthy, a competing setup fails without starting those steps. Losing that connection releases the lock immediately and requests termination of the current step; child termination can take up to five seconds. The lock alone cannot exclude another invocation during that interval. Setup waits for child cleanup and exits unsuccessfully; logs identify the step without exposing migration-tool diagnostics that could contain secrets. + +Only setup scripts should modify migration and schedule state during a release. `npm run jobs:setup` remains a development-only shortcut that does not acquire the full release lock. Operational entry points live under `server/scripts/`; domains do not invoke them. + +Migrations are not one transaction across both schemas. Fix the failed step and retry setup before starting the new image. Keep schema and schedule changes compatible with the previous image. Application rollback changes the image, not the database schema. + +The deployment orchestrator owns serialization of the complete release, including setup and rollout. It must retain exclusion after failure and confirm that prior setup executions and database work have stopped before allowing a retry. Do not invoke production setup outside that serialized procedure. The advisory lock is an additional check for concurrent setup, not a replacement for release serialization or a guarantee of exactly-once jobs. Release orchestration must also define old/new worker overlap. See [Background jobs](background-jobs.md#schedules-and-delivery-guarantees). + +## Scope and evidence + +CI checks the image against disposable PostgreSQL, including setup, frontend/API serving, readiness, and termination. This is not a supported local-production installation. The existing devcontainer remains the development environment. + +An actual cloud deployment must additionally verify credentials, networking, data survival, backup and restore, domain configuration, and release ordering in an authorized environment. Never commit secrets, tenant-specific parameter files, or generated service addresses. diff --git a/hub/docs/development.md b/hub/docs/development.md index 6fdb727c..9a334af8 100644 --- a/hub/docs/development.md +++ b/hub/docs/development.md @@ -1,6 +1,6 @@ # Hub development -Use the Hub devcontainer for Node.js and PostgreSQL, or run Node.js locally with the same database service. Production deployment is not configured. +Use the Hub devcontainer for Node.js and PostgreSQL, or run Node.js locally with the same database service. See [Deployment](deployment.md) for the production runtime. ## Devcontainer @@ -36,7 +36,7 @@ In a second terminal, also from `hub/`, start Vite: npm run dev ``` -Open . Vite serves the frontend and forwards `/api` requests to Express at . `GET /api/health` returns JSON. The internal proof primitives described in [Authentication proof](authentication.md) are not registered as routes. +Open . Vite serves the frontend and forwards `/api` requests to Express at . `GET /api/health` and `GET /api/ready` return JSON. The internal proof primitives described in [Authentication proof](authentication.md) are not registered as routes. The current API has no public authentication route or authentication environment settings. Both servers accept connections only from this machine. Stop each with Ctrl+C. @@ -44,7 +44,7 @@ Vite also forwards WebSocket upgrades under `/api`. The `/api/realtime` endpoint The API reads an optional `PORT` environment variable, defaulting to 3001. If you change it, update the local Vite proxy target to match. The editor forwards ports 3001, 5173, and 6006 from the devcontainer. API startup reads `.env` when present; Compose's environment takes precedence. -On `SIGINT` or `SIGTERM`, the API stops accepting requests and allows active requests up to 30 seconds to finish, then flushes [optional telemetry](telemetry.md) within five seconds. No automatic request tracing is installed. +On `SIGINT` or `SIGTERM`, the API stops accepting requests and allows active requests up to `SHUTDOWN_TIMEOUT_MS` (30000 milliseconds by default) to finish, then flushes [optional telemetry](telemetry.md) within five seconds. No automatic request tracing is installed. ## Background workers @@ -69,7 +69,7 @@ npm run build npm start ``` -The build produces `dist/client/` and `dist/server/`. `npm start` runs only the compiled API. In a separate terminal, `npm run preview` serves the built frontend for local inspection. Production frontend hosting, API routing, and deployment are not configured. +The build produces `dist/client/` and `dist/server/`. `npm start` runs only the compiled API by default. In a separate terminal, `npm run preview` serves the built frontend for local inspection. The [production image](deployment.md) serves both on one port and runs both workers. Use `npm run storybook` to view the existing components and pages at . They use sample data, not the new API. diff --git a/hub/docs/telemetry.md b/hub/docs/telemetry.md index 4a180646..e7a4df04 100644 --- a/hub/docs/telemetry.md +++ b/hub/docs/telemetry.md @@ -19,16 +19,16 @@ OTEL_TRACES_SAMPLER_ARG=1 The recipient can be a provider that accepts OTLP over HTTP/protobuf or an operator-managed OpenTelemetry Collector that forwards to other platforms. Use HTTPS for remote recipients. This integration does not include provider-specific SDKs, configure a Collector, or prove that a provider displays accepted traces. -| Variable | Contract | -| --- | --- | -| `OTEL_TRACES_EXPORTER` | `none` is the default; `otlp` enables export. Other values fail startup. | -| `OTEL_SDK_DISABLED` | `true`, ignoring case, disables telemetry regardless of other settings. With export enabled, other nonempty values must be `false`. | -| `OTEL_SERVICE_NAME` | Defaults to `replica-hub`. Use a static service label of at most 100 printable characters, not secrets or personal data. | -| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | Explicit absolute HTTP(S) URL with a request path. Credentials, query strings, and fragments are rejected. | -| `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` | Only `http/protobuf` is supported. | -| `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | Optional comma-separated URL-encoded `key=value` authentication headers. For example, `Authorization=Bearer%20example`. Keep real values private. | -| `OTEL_TRACES_SAMPLER` | `parentbased_traceidratio` is the default. `always_on` and `always_off` are also supported. Other samplers fail startup. | -| `OTEL_TRACES_SAMPLER_ARG` | A finite fraction from `0` through `1`, defaulting to `1`, controls the root sampling ratio. It is validated but has no effect for `always_on` and `always_off`. | +| Variable | Contract | +| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `OTEL_TRACES_EXPORTER` | `none` is the default; `otlp` enables export. Other values fail startup. | +| `OTEL_SDK_DISABLED` | `true`, ignoring case, disables telemetry regardless of other settings. With export enabled, other nonempty values must be `false`. | +| `OTEL_SERVICE_NAME` | Defaults to `replica-hub`. Use a static service label of at most 100 printable characters, not secrets or personal data. | +| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | Explicit absolute HTTP(S) URL with a request path. Credentials, query strings, and fragments are rejected. | +| `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` | Only `http/protobuf` is supported. | +| `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | Optional comma-separated URL-encoded `key=value` authentication headers. For example, `Authorization=Bearer%20example`. Keep real values private. | +| `OTEL_TRACES_SAMPLER` | `parentbased_traceidratio` is the default. `always_on` and `always_off` are also supported. Other samplers fail startup. | +| `OTEL_TRACES_SAMPLER_ARG` | A finite fraction from `0` through `1`, defaulting to `1`, controls the root sampling ratio. It is validated but has no effect for `always_on` and `always_off`. | Common `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_PROTOCOL`, and `OTEL_EXPORTER_OTLP_HEADERS` settings are also accepted. Trace-specific settings take precedence, even when empty. The common endpoint is a base URL; Hub appends `/v1/traces` to its path. Enabled export requires an explicit endpoint. Disabled telemetry does not validate unused export settings or create an exporter. @@ -40,7 +40,7 @@ Hub sets its own resource, sampler, batching, and context policies rather than l The API and worker entrypoints already call it before accepting requests or starting consumers. Static imports are sufficient because this foundation does not patch dependencies. `createApp()` does not initialize global telemetry or install signal handlers. -The returned `{ shutdown() }` belongs to the entrypoint, not to domains or request handlers. The entrypoint waits for API requests and WebSockets, or active worker jobs, to drain, then awaits shutdown in `finally`. API and worker drain limits are 30 seconds; telemetry shutdown adds at most five seconds. Operators must allow both windows before terminating a process. Startup failures and shutdown timeouts exit unsuccessfully. +The returned `{ shutdown() }` belongs to the entrypoint, not to domains or request handlers. The entrypoint waits for API requests and WebSockets, or active worker jobs, to drain, then awaits shutdown in `finally`. API and worker drain limits use `SHUTDOWN_TIMEOUT_MS` (30000 milliseconds by default); telemetry shutdown adds at most five seconds. Operators must allow both windows before terminating a process. Startup failures and shutdown timeouts exit unsuccessfully. Shutdown flushes queued spans and closes the SDK. It is idempotent, including when telemetry is disabled. Normal batching holds at most 256 spans, exports at most 64 per batch, and attempts export at one-second intervals with a two-second export timeout. Queue saturation or failed delivery can lose spans. Export failures produce safe static logs, not errors in the feature being traced. Shutdown failure is reported separately and rejects the shutdown call. @@ -51,10 +51,8 @@ Call `withTelemetrySpan()` around one meaningful operation in a route or domain. ```ts import { withTelemetrySpan } from "../../../integrations/opentelemetry/with-telemetry-span/index.ts" -return withTelemetrySpan( - "build.import", - { "replica.build.id": buildId }, - () => importBuild(buildId), +return withTelemetrySpan("build.import", { "replica.build.id": buildId }, () => + importBuild(buildId), ) ``` diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index 4d97a455..5a171077 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -16,7 +16,9 @@ npm run storybook:build `npm test` runs API and utility tests in Node.js and frontend tests in jsdom, which provides a browser-like environment. Neither requires PostgreSQL. To run a focused API check, use `npm test -- server/create-app/index.test.ts`. HTTP tests listen on temporary loopback ports and close their servers afterward. -The API health check proves only that Express responds. Routes that have not been built must return 404 rather than pretending to work. +The API health check proves only that Express responds. Product API routes that have not been built must return 404 rather than pretending to work. + +Run `npm run image:check` with a Docker-compatible daemon to build the production image and exercise setup, readiness, serving, and shutdown against disposable PostgreSQL. It creates uniquely named resources and removes them afterward. The image compiles the Hub runtime only; the normal `build` command also checks the frontend, test tooling, and shared extension. Installation generates Prisma Client without a database connection. Client tests check reuse across development reloads, production behavior, and errors for missing configuration. diff --git a/hub/package-lock.json b/hub/package-lock.json index 2f62433a..8ffc9044 100644 --- a/hub/package-lock.json +++ b/hub/package-lock.json @@ -24,6 +24,7 @@ "pg": "8.23.1", "pino": "10.4.0", "pino-http": "11.0.0", + "prisma": "7.10.1", "react": "19.3.0", "react-dom": "19.3.0", "ws": "8.22.0", @@ -55,7 +56,6 @@ "jiti": "2.7.0", "jsdom": "30.1.3", "prettier": "3.9.10", - "prisma": "7.10.1", "storybook": "10.6.2", "typescript": "npm:@typescript/typescript6@6.0.2", "typescript-eslint": "8.71.2", @@ -557,14 +557,12 @@ "version": "0.4.3", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz", "integrity": "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/@electric-sql/pglite-socket": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.3.tgz", "integrity": "sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==", - "devOptional": true, "license": "Apache-2.0", "bin": { "pglite-server": "dist/scripts/server.js" @@ -577,7 +575,6 @@ "version": "0.3.3", "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.3.tgz", "integrity": "sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg==", - "devOptional": true, "license": "Apache-2.0", "peerDependencies": { "@electric-sql/pglite": "0.4.3" @@ -2969,7 +2966,6 @@ "version": "7.10.1", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.10.1.tgz", "integrity": "sha512-+0swF5ELUa3P/9cIR26+gpYjjCbGE0qvEBZhV4zMDkYsrtHjZ884MQbOnWFWXj600N0sSJNnHbtBqe12yDngWg==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "c12": "3.3.4", @@ -2982,7 +2978,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=14" @@ -2998,7 +2993,6 @@ "version": "0.24.17", "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.17.tgz", "integrity": "sha512-UvdZzmpFwknnfreh6Jije84ekkYGPYEJhXG1tFzCsCfQyzJifrOo38eZc0qajzvaC6OLUOrN9ML5XfCnEZL9DA==", - "devOptional": true, "license": "ISC", "dependencies": { "@electric-sql/pglite": "0.4.3", @@ -3022,7 +3016,6 @@ "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "devOptional": true, "license": "MIT" }, "node_modules/@prisma/driver-adapter-utils": { @@ -3038,7 +3031,6 @@ "version": "7.10.1", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.10.1.tgz", "integrity": "sha512-R6/ghv1Bu36FhCdQLFof1pNtyoWoeDzJLYeTKjgqb4eGtOAJx7ZqJtcm4pzkahLfZ9rYz8AkmsMDwkyiYbXPCQ==", - "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -3052,14 +3044,12 @@ "version": "8.1.0-1.d1b3f377ca835d73fd50954a5e22128b9ebdd6b8", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-8.1.0-1.d1b3f377ca835d73fd50954a5e22128b9ebdd6b8.tgz", "integrity": "sha512-XjXmwjN2NE340RpXoXv8ZF8aNAvr0FE+7gNfkKeW6vyjXs7F8uxvEbWQoVrhioXDQoIsnNmv7r+wxvzfdEZaWA==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { "version": "7.10.1", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.10.1.tgz", "integrity": "sha512-iVnghHne5vcaobBjPVCMEjh5r/1UCj394EIGTUclasFRatpR/MQwcM2veLv5kgoFSyYP54nch/lNFCQRYcDxvg==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "7.10.1" @@ -3069,7 +3059,6 @@ "version": "7.10.1", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.10.1.tgz", "integrity": "sha512-qZezeaExLWG2ptbHOrMxdiWluKHgqEclTdJbvY6pk6+Wg7EiVkeq5U3cXz9ti8nd0bngNvsDgledLHVRg5LF1w==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "7.10.1", @@ -3081,7 +3070,6 @@ "version": "7.10.1", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.10.1.tgz", "integrity": "sha512-iVnghHne5vcaobBjPVCMEjh5r/1UCj394EIGTUclasFRatpR/MQwcM2veLv5kgoFSyYP54nch/lNFCQRYcDxvg==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "7.10.1" @@ -3091,7 +3079,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "7.2.0" @@ -3101,21 +3088,18 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/query-plan-executor": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/streams-local": { "version": "0.1.11", "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.11.tgz", "integrity": "sha512-0TcebL559MByKqTJ+SsrFIEg228iw8UCVRFckzgfRSiJqczhs+MuAgWOF9lnOIV/IVqvu+KMnFTH0eDeTQMpUg==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "ajv": "^8.12.0", @@ -3132,7 +3116,6 @@ "version": "0.33.0", "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.33.0.tgz", "integrity": "sha512-V2fX/nKEymNTrHXwfP26PGjoLStO35Ogu+ex7CFJbLrMYEcZxxZpiSNOs7px23Hk5mzLWvM5RsqG6Ka+rha+wg==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "@radix-ui/react-toggle": "1.1.10", @@ -3161,14 +3144,12 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", - "devOptional": true, "license": "MIT" }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", - "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -3184,7 +3165,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@radix-ui/react-slot": "1.2.3" @@ -3208,7 +3188,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "devOptional": true, "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" @@ -3227,7 +3206,6 @@ "version": "1.1.10", "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", @@ -3253,7 +3231,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", - "devOptional": true, "license": "MIT", "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", @@ -3273,7 +3250,6 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", - "devOptional": true, "license": "MIT", "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" @@ -3292,7 +3268,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", - "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -3611,7 +3586,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "devOptional": true, "license": "MIT" }, "node_modules/@storybook/addon-a11y": { @@ -3989,35 +3963,30 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz", "integrity": "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz", "integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/d3-delaunay": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz", "integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/d3-format": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz", "integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/d3-geo": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@types/geojson": "*" @@ -4027,7 +3996,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz", "integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==", - "devOptional": true, "license": "MIT", "dependencies": { "@types/d3-color": "*" @@ -4037,14 +4005,12 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.2.tgz", "integrity": "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==", - "devOptional": true, "license": "MIT", "dependencies": { "@types/d3-time": "*" @@ -4054,7 +4020,6 @@ "version": "3.1.7", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", - "devOptional": true, "license": "MIT", "dependencies": { "@types/d3-path": "*" @@ -4064,14 +4029,12 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz", "integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/d3-time-format": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.1.0.tgz", "integrity": "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/deep-eql": { @@ -4131,7 +4094,6 @@ "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/http-errors": { @@ -4165,7 +4127,6 @@ "version": "4.17.26", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.26.tgz", "integrity": "sha512-48NWfiY6oNcKOh47Z4h+sIvwcQR91h6KPhaYKgeMIrkT2jV82Gy90SErj60vW8jQbeMSOSbhIv3bZ7JoqPPVhA==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/mdx": { @@ -4219,7 +4180,6 @@ "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -4918,7 +4878,6 @@ "version": "4.0.1-alpha.0", "resolved": "https://registry.npmjs.org/@visx/curve/-/curve-4.0.1-alpha.0.tgz", "integrity": "sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==", - "devOptional": true, "license": "MIT", "dependencies": { "@visx/vendor": "4.0.0-alpha.0" @@ -4928,7 +4887,6 @@ "version": "4.0.1-alpha.0", "resolved": "https://registry.npmjs.org/@visx/event/-/event-4.0.1-alpha.0.tgz", "integrity": "sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g==", - "devOptional": true, "license": "MIT", "dependencies": { "@types/react": "*", @@ -4939,7 +4897,6 @@ "version": "4.0.1-alpha.0", "resolved": "https://registry.npmjs.org/@visx/grid/-/grid-4.0.1-alpha.0.tgz", "integrity": "sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg==", - "devOptional": true, "license": "MIT", "dependencies": { "@types/react": "*", @@ -4958,7 +4915,6 @@ "version": "4.0.1-alpha.0", "resolved": "https://registry.npmjs.org/@visx/group/-/group-4.0.1-alpha.0.tgz", "integrity": "sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w==", - "devOptional": true, "license": "MIT", "dependencies": { "@types/react": "*", @@ -4972,14 +4928,12 @@ "version": "4.0.1-alpha.0", "resolved": "https://registry.npmjs.org/@visx/point/-/point-4.0.1-alpha.0.tgz", "integrity": "sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg==", - "devOptional": true, "license": "MIT" }, "node_modules/@visx/responsive": { "version": "4.0.1-alpha.0", "resolved": "https://registry.npmjs.org/@visx/responsive/-/responsive-4.0.1-alpha.0.tgz", "integrity": "sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA==", - "devOptional": true, "license": "MIT", "dependencies": { "@types/lodash": "^4.17.13", @@ -4994,7 +4948,6 @@ "version": "4.0.1-alpha.0", "resolved": "https://registry.npmjs.org/@visx/scale/-/scale-4.0.1-alpha.0.tgz", "integrity": "sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A==", - "devOptional": true, "license": "MIT", "dependencies": { "@visx/vendor": "4.0.0-alpha.0" @@ -5004,7 +4957,6 @@ "version": "4.0.1-alpha.0", "resolved": "https://registry.npmjs.org/@visx/shape/-/shape-4.0.1-alpha.0.tgz", "integrity": "sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA==", - "devOptional": true, "license": "MIT", "dependencies": { "@types/lodash": "^4.17.13", @@ -5024,7 +4976,6 @@ "version": "4.0.0-alpha.0", "resolved": "https://registry.npmjs.org/@visx/vendor/-/vendor-4.0.0-alpha.0.tgz", "integrity": "sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==", - "devOptional": true, "license": "MIT and ISC", "dependencies": { "@types/d3-array": "3.0.3", @@ -5056,7 +5007,6 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz", "integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==", - "devOptional": true, "license": "ISC", "dependencies": { "internmap": "1 - 2" @@ -5248,7 +5198,6 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "devOptional": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -5330,7 +5279,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 6.0.0" @@ -5373,7 +5321,6 @@ "version": "2.10.0", "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.10.0.tgz", "integrity": "sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==", - "devOptional": true, "license": "MIT" }, "node_modules/bidi-js": { @@ -5534,7 +5481,6 @@ "version": "3.3.4", "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", - "devOptional": true, "license": "MIT", "dependencies": { "chokidar": "^5.0.0", @@ -5563,7 +5509,6 @@ "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", - "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -5667,7 +5612,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "devOptional": true, "license": "MIT", "dependencies": { "readdirp": "^5.0.0" @@ -5683,14 +5627,12 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", - "devOptional": true, "license": "MIT" }, "node_modules/confbox": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", - "devOptional": true, "license": "MIT" }, "node_modules/content-disposition": { @@ -5760,7 +5702,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "devOptional": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -5796,14 +5737,12 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/d3-array": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "devOptional": true, "license": "ISC", "dependencies": { "internmap": "1 - 2" @@ -5816,7 +5755,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "devOptional": true, "license": "ISC", "engines": { "node": ">=12" @@ -5826,7 +5764,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.2.tgz", "integrity": "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==", - "devOptional": true, "license": "ISC", "dependencies": { "delaunator": "5" @@ -5839,7 +5776,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", - "devOptional": true, "license": "ISC", "engines": { "node": ">=12" @@ -5849,7 +5785,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.0.tgz", "integrity": "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==", - "devOptional": true, "license": "ISC", "dependencies": { "d3-array": "2.5.0 - 3" @@ -5862,7 +5797,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "devOptional": true, "license": "ISC", "dependencies": { "d3-color": "1 - 3" @@ -5875,7 +5809,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "devOptional": true, "license": "ISC", "engines": { "node": ">=12" @@ -5885,7 +5818,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "devOptional": true, "license": "ISC", "dependencies": { "d3-array": "2.10.0 - 3", @@ -5902,7 +5834,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "devOptional": true, "license": "ISC", "dependencies": { "d3-path": "^3.1.0" @@ -5915,7 +5846,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "devOptional": true, "license": "ISC", "dependencies": { "d3-array": "2 - 3" @@ -5928,7 +5858,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "devOptional": true, "license": "ISC", "dependencies": { "d3-time": "1 - 3" @@ -5996,7 +5925,6 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.2.tgz", "integrity": "sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==", - "devOptional": true, "funding": [ { "type": "ko-fi", @@ -6059,14 +5987,12 @@ "version": "6.1.7", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", - "devOptional": true, "license": "MIT" }, "node_modules/delaunator": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", - "devOptional": true, "license": "ISC", "dependencies": { "robust-predicates": "^3.0.2" @@ -6095,7 +6021,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "devOptional": true, "license": "MIT" }, "node_modules/detect-libc": { @@ -6167,7 +6092,6 @@ "version": "3.20.0", "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", "integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==", - "devOptional": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -6185,7 +6109,6 @@ "version": "0.11.1", "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz", "integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==", - "devOptional": true, "license": "EPL-2.0" }, "node_modules/empathic": { @@ -6224,7 +6147,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", - "devOptional": true, "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -6653,14 +6575,12 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.3.tgz", "integrity": "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==", - "devOptional": true, "license": "MIT" }, "node_modules/fast-check": { "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", - "devOptional": true, "funding": [ { "type": "individual", @@ -6683,14 +6603,12 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", - "devOptional": true, "license": "MIT" }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "devOptional": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -6711,7 +6629,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", - "devOptional": true, "license": "MIT", "dependencies": { "fast-decode-uri-component": "^1.0.1" @@ -6721,7 +6638,6 @@ "version": "3.1.8", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", - "devOptional": true, "funding": [ { "type": "github", @@ -6787,7 +6703,6 @@ "version": "9.7.0", "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz", "integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==", - "devOptional": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -6838,7 +6753,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "devOptional": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -6897,7 +6811,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", - "devOptional": true, "license": "MIT", "dependencies": { "is-property": "^1.0.2" @@ -6950,7 +6863,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", - "devOptional": true, "license": "MIT" }, "node_modules/get-proto": { @@ -6970,7 +6882,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/giget/-/giget-3.3.1.tgz", "integrity": "sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==", - "devOptional": true, "license": "MIT", "bin": { "giget": "dist/cli.mjs" @@ -7036,21 +6947,18 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "devOptional": true, "license": "ISC" }, "node_modules/grammex": { "version": "3.1.13", "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.13.tgz", "integrity": "sha512-LnPnhOBLEJEVKS8WFDVaA397L9Kq55Q9oSITJiVLHVdhAclfUkWzQv74KhvZHKL2Q09Pb1XdsrOsZ4LfTFFTEg==", - "devOptional": true, "license": "MIT" }, "node_modules/graphmatch": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", - "devOptional": true, "license": "MIT" }, "node_modules/has-symbols": { @@ -7203,7 +7111,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "devOptional": true, "license": "ISC", "engines": { "node": ">=12" @@ -7309,7 +7216,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", - "devOptional": true, "license": "MIT" }, "node_modules/is-wsl": { @@ -7332,14 +7238,12 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "devOptional": true, "license": "ISC" }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -7419,7 +7323,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "devOptional": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -7797,14 +7700,12 @@ "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "devOptional": true, "license": "MIT" }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/loupe": { @@ -7828,7 +7729,6 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.5.tgz", "integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==", - "devOptional": true, "license": "MIT", "engines": { "bun": ">=1.0.0", @@ -8022,7 +7922,6 @@ "version": "3.24.4", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.4.tgz", "integrity": "sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==", - "devOptional": true, "license": "MIT", "dependencies": { "aws-ssl-profiles": "^1.1.2", @@ -8044,7 +7943,6 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", - "devOptional": true, "license": "MIT", "dependencies": { "lru.min": "^1.1.0" @@ -8156,7 +8054,6 @@ "version": "2.0.12", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", - "devOptional": true, "license": "MIT" }, "node_modules/on-exit-leak-free": { @@ -8363,7 +8260,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -8417,7 +8313,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "devOptional": true, "license": "MIT" }, "node_modules/pathval": { @@ -8434,7 +8329,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", - "devOptional": true, "license": "MIT" }, "node_modules/pg": { @@ -8608,7 +8502,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.3.tgz", "integrity": "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==", - "devOptional": true, "license": "MIT", "dependencies": { "confbox": "^0.3.1", @@ -8620,7 +8513,6 @@ "version": "0.3.1", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz", "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==", - "devOptional": true, "license": "MIT" }, "node_modules/postcss": { @@ -8656,7 +8548,6 @@ "version": "3.4.7", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", - "devOptional": true, "license": "Unlicense", "engines": { "node": ">=12" @@ -8757,7 +8648,6 @@ "version": "7.10.1", "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.10.1.tgz", "integrity": "sha512-GoVeErHGGtsSjsflQ1r2BA3RKTXtxNraQJz8OLkLhoLr7OgCscdx4qw4WMM4HnERYc+a1cq2VnMNbep+vOjNcQ==", - "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -8807,7 +8697,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", - "devOptional": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -8819,7 +8708,6 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "devOptional": true, "license": "ISC" }, "node_modules/proxy-addr": { @@ -8853,7 +8741,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "devOptional": true, "funding": [ { "type": "individual", @@ -8940,7 +8827,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.1.0.tgz", "integrity": "sha512-ufjkNVzbRHKcCOmTahZkmVsyc3W+MSk3jY03m+a7tGHkIsdVMG9l10/3HvFbWkkKzY5VFp3pkRsIo/UYgmFL7Q==", - "devOptional": true, "license": "MIT", "dependencies": { "defu": "^6.1.7", @@ -9004,7 +8890,6 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -9068,7 +8953,6 @@ "version": "2.33.4", "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", - "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/remeda" @@ -9078,7 +8962,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9110,7 +8993,6 @@ "version": "0.5.0", "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=10" @@ -9120,7 +9002,6 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 4" @@ -9130,7 +9011,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", - "devOptional": true, "license": "Unlicense" }, "node_modules/rolldown": { @@ -9210,7 +9090,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.2.tgz", "integrity": "sha512-tAtCGFPPP02VroDVzp4daGi6bC7jWG+41D47GKf18FeH/ubvKQHT9VmhAkvtjNOmJwqtApzWeUpEhkyEYULvmw==", - "devOptional": true, "funding": [ { "type": "github", @@ -9328,7 +9207,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "devOptional": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -9341,7 +9219,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -9423,7 +9300,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "devOptional": true, "license": "ISC", "engines": { "node": ">=14" @@ -9474,7 +9350,6 @@ "version": "1.5.2", "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.2.tgz", "integrity": "sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==", - "devOptional": true, "license": "MIT", "engines": { "bun": ">=1.0.0", @@ -9968,7 +9843,6 @@ "version": "1.4.2", "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz", "integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==", - "devOptional": true, "license": "MIT", "peerDependencies": { "typescript": ">=5" @@ -10228,7 +10102,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "devOptional": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -10356,7 +10229,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", - "devOptional": true, "license": "MIT", "dependencies": { "grammex": "^3.1.11", diff --git a/hub/package.json b/hub/package.json index c294691a..d5b338c2 100644 --- a/hub/package.json +++ b/hub/package.json @@ -13,21 +13,23 @@ "db:migrate": "prisma migrate dev", "db:deploy": "prisma migrate deploy", "db:studio": "prisma studio", - "jobs:setup": "node server/setup-jobs/index.ts", - "setup": "npm run db:deploy && npm run jobs:setup", + "jobs:setup": "node server/scripts/setup-jobs/index.ts", + "setup": "node server/scripts/setup/index.ts", "worker:on-demand": "node server/workers/index.ts on-demand", "worker:scheduled": "node server/workers/index.ts scheduled", "start:worker:on-demand": "node dist/server/workers/index.js on-demand", "start:worker:scheduled": "node dist/server/workers/index.js scheduled", - "start:jobs:setup": "node dist/server/setup-jobs/index.js", - "start:setup": "npm run db:deploy && npm run start:jobs:setup", + "start:jobs:setup": "node dist/server/scripts/setup-jobs/index.js", + "start:setup": "node dist/server/scripts/setup/index.js", "controls:build": "node script/build-controls-bridge.mjs", "controls:assets:check": "node script/build-controls-bridge.mjs --check", "controls:test": "node --test script/tests/build-controls.test.mjs", "controls:runtime:test": "node --test app/javascript/utils/prototype-controls/*.test.mjs", "controls:schema:generate": "node app/javascript/utils/prototype-controls/generate-controls-validator.mjs", "controls:schema:check": "node app/javascript/utils/prototype-controls/generate-controls-validator.mjs --check", - "build": "npm run typecheck && vite build && tsc --project tsconfig.server.json", + "build": "npm run typecheck && npm run build:runtime", + "build:runtime": "vite build && tsc --project tsconfig.server.json", + "image:check": "node script/check-image/index.ts", "dev": "npm run dev:client", "dev:client": "vite --host 127.0.0.1", "dev:api": "node --watch server/index.ts", @@ -51,31 +53,32 @@ "@opentelemetry/exporter-trace-otlp-proto": "0.300.0", "@opentelemetry/resources": "3.0.0", "@opentelemetry/sdk-trace": "3.0.0", - "@prisma/adapter-pg": "7.10.1", - "@prisma/client": "7.10.1", "@primer/octicons-react": "19.40.0", "@primer/react-brand": "0.78.0", + "@prisma/adapter-pg": "7.10.1", + "@prisma/client": "7.10.1", "bullmq": "6.4.0", "dotenv": "18.0.8", "express": "5.3.0", + "pg": "8.23.1", "pino": "10.4.0", "pino-http": "11.0.0", - "pg": "8.23.1", + "prisma": "7.10.1", "react": "19.3.0", "react-dom": "19.3.0", "ws": "8.22.0", "zod": "4.6.5" }, "devDependencies": { - "@github/copilot-sdk": "1.0.16", "@eslint/js": "10.0.1", + "@github/copilot-sdk": "1.0.16", "@storybook/addon-a11y": "10.6.1", "@storybook/addon-docs": "10.6.1", "@storybook/react-vite": "10.6.1", "@testing-library/jest-dom": "7.0.1", "@testing-library/react": "16.3.3", - "@types/jsdom": "30.0.0", "@types/express": "5.0.3", + "@types/jsdom": "30.0.0", "@types/node": "26.6.4", "@types/pg": "8.23.1", "@types/react": "19.3.0", @@ -92,7 +95,6 @@ "jiti": "2.7.0", "jsdom": "30.1.3", "prettier": "3.9.10", - "prisma": "7.10.1", "storybook": "10.6.2", "typescript": "npm:@typescript/typescript6@6.0.2", "typescript-eslint": "8.71.2", diff --git a/hub/script/check-image/index.ts b/hub/script/check-image/index.ts new file mode 100644 index 00000000..7659a8d5 --- /dev/null +++ b/hub/script/check-image/index.ts @@ -0,0 +1,121 @@ +import assert from "node:assert/strict" +import { execFileSync, spawnSync } from "node:child_process" +import { randomUUID } from "node:crypto" +import { resolve } from "node:path" + +export async function checkImage() { + const suffix = randomUUID() + const network = `replica-image-${suffix}` + const database = `${network}-database` + const application = `${network}-app` + const image = `replica-hub-check:${suffix}` + function docker(...args: string[]) { + return execFileSync("docker", args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }).trim() + } + function probe(path: string, status: number, contentType: string) { + docker( + "exec", + application, + "node", + "--input-type=module", + "-e", + `const r = await fetch("http://127.0.0.1:3001${path}", {headers: {accept: "text/html"}}); + if (r.status !== ${status} || !r.headers.get("content-type")?.startsWith("${contentType}")) process.exit(1);`, + ) + } + let networkCreated = false + let databaseCreated = false + let applicationCreated = false + let imageCreated = false + try { + execFileSync("docker", ["build", "-t", image, resolve(import.meta.dirname, "../..")], { + stdio: "inherit", + }) + imageCreated = true + docker("network", "create", network) + networkCreated = true + docker( + "run", + "-d", + "--name", + database, + "--network", + network, + "-e", + "POSTGRES_USER=replica", + "-e", + "POSTGRES_PASSWORD=replica", + "-e", + "POSTGRES_DB=replica", + "postgres:18.6-bookworm", + ) + databaseCreated = true + const deadline = Date.now() + 60_000 + while ( + spawnSync("docker", ["exec", database, "pg_isready", "-U", "replica", "-d", "replica"], { + stdio: "ignore", + }).status !== 0 + ) { + assert(Date.now() < deadline, "PostgreSQL did not become ready") + await new Promise((resolve) => setTimeout(resolve, 500)) + } + const databaseUrl = `DATABASE_URL=postgresql://replica:replica@${database}:5432/replica` + for (let attempt = 0; attempt < 2; attempt++) { + docker( + "run", + "--rm", + "--network", + network, + "-e", + databaseUrl, + image, + "node", + "dist/server/scripts/setup/index.js", + ) + } + docker("run", "-d", "--name", application, "--network", network, "-e", databaseUrl, image) + applicationCreated = true + const readyDeadline = Date.now() + 60_000 + while ( + spawnSync( + "docker", + [ + "exec", + application, + "node", + "-e", + 'fetch("http://127.0.0.1:3001/api/ready").then(r => process.exit(r.status === 200 ? 0 : 1)).catch(() => process.exit(1))', + ], + { stdio: "ignore" }, + ).status !== 0 + ) { + assert(Date.now() < readyDeadline, "Hub did not become ready") + await new Promise((resolve) => setTimeout(resolve, 500)) + } + probe("/api/health", 200, "application/json") + probe("/", 200, "text/html") + probe("/projects/example", 200, "text/html") + probe("/api/missing", 404, "application/json") + probe("/assets/missing.js", 404, "application/json") + docker("stop", "--time", "40", application) + assert.equal( + docker("inspect", "-f", "{{.State.ExitCode}}", application), + "0", + "Hub must exit cleanly after termination", + ) + console.log("Image setup, readiness, serving, and shutdown passed.") + } catch (error) { + if (applicationCreated) spawnSync("docker", ["logs", application], { stdio: "inherit" }) + throw error + } finally { + if (applicationCreated) docker("rm", "-f", application) + if (databaseCreated) docker("rm", "-fv", database) + if (networkCreated) docker("network", "rm", network) + if (imageCreated) docker("image", "rm", image) + } +} + +await checkImage() diff --git a/hub/server/create-app/index.test.ts b/hub/server/create-app/index.test.ts index 36e1f233..12068366 100644 --- a/hub/server/create-app/index.test.ts +++ b/hub/server/create-app/index.test.ts @@ -1,8 +1,10 @@ -import { once } from "node:events" import { randomUUID } from "node:crypto" +import { once } from "node:events" +import { mkdir, rm, writeFile } from "node:fs/promises" import { createServer } from "node:http" import type { AddressInfo } from "node:net" -import { describe, expect, it, vi } from "vitest" +import { join } from "node:path" +import { describe, expect, it, onTestFinished, vi } from "vitest" import { createLogger } from "../integrations/pino/create-logger" @@ -19,6 +21,88 @@ function parseRecord(line: string) { } describe("createApp()", () => { + it("serves built assets and HTML navigation without masking missing API routes or assets", async () => { + const clientDirectory = join(process.cwd(), `runtime-client-test-${randomUUID()}`) + await mkdir(join(clientDirectory, "assets"), { recursive: true }) + onTestFinished(() => rm(clientDirectory, { recursive: true, force: true })) + const html = "Built Hub" + await writeFile(join(clientDirectory, "index.html"), html) + await writeFile(join(clientDirectory, "assets", "app.js"), "export const built = true") + const server = createServer( + createApp({ clientDirectory, logger: createLogger({ level: "silent" }) }), + ) + + try { + server.listen(0, "127.0.0.1") + await once(server, "listening") + const { port } = server.address() as AddressInfo + const base = `http://127.0.0.1:${port}` + const navigation = await fetch(`${base}/projects/example`, { + headers: { accept: "text/html" }, + }) + expect(navigation.status).toBe(200) + expect(navigation.headers.get("content-type")).toMatch(/^text\/html\b/) + expect(await navigation.text()).toBe(html) + const head = await fetch(`${base}/projects/example`, { + method: "HEAD", + headers: { accept: "text/html" }, + }) + expect(head.status).toBe(200) + expect(head.headers.get("content-type")).toMatch(/^text\/html\b/) + expect(await head.text()).toBe("") + const asset = await fetch(`${base}/assets/app.js`) + expect(asset.status).toBe(200) + expect(await asset.text()).toBe("export const built = true") + + for (const { path, method, accept } of [ + { path: "/api/missing", method: "GET", accept: "text/html" }, + { path: "/assets/missing.js", method: "GET", accept: "text/html" }, + { path: "/projects/example", method: "POST", accept: "text/html" }, + { path: "/projects/example", method: "GET", accept: "application/json" }, + { path: "/projects/example", method: "GET", accept: "*/*" }, + ]) { + const missing = await fetch(`${base}${path}`, { method, headers: { accept } }) + expect.soft(missing.status, `${method} ${path} accepting ${accept}`).toBe(404) + expect.soft(missing.headers.get("content-type")).toMatch(/^application\/json\b/) + if (missing.status === 404) { + expect(await missing.json()).toEqual({ error: "Not found" }) + } + } + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) + } + }) + + it("reports unavailable dependencies safely without making process health fail", async () => { + const isReady = vi.fn<() => Promise>().mockResolvedValue(false) + const server = createServer(createApp({ isReady, logger: createLogger({ level: "silent" }) })) + + try { + server.listen(0, "127.0.0.1") + await once(server, "listening") + const { port } = server.address() as AddressInfo + const base = `http://127.0.0.1:${port}` + const unavailable = await fetch(`${base}/api/ready`) + expect(unavailable.status).toBe(503) + const health = await fetch(`${base}/api/health`) + expect(health.status).toBe(200) + expect(await health.json()).toEqual({ status: "ok" }) + + isReady.mockResolvedValue(true) + expect((await fetch(`${base}/api/ready`)).status).toBe(200) + isReady.mockRejectedValue(new Error("postgresql://test:private-readiness-marker@localhost")) + const failed = await fetch(`${base}/api/ready`) + expect(failed.status).toBe(503) + expect(await failed.text()).not.toContain("private-readiness-marker") + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) + } + }) + it("accepts a broadcast dependency without changing HTTP routes", async () => { const broadcast = vi.fn() const server = createServer(createApp({ broadcast })) diff --git a/hub/server/create-app/index.ts b/hub/server/create-app/index.ts index 300ee3e2..e742b4a1 100644 --- a/hub/server/create-app/index.ts +++ b/hub/server/create-app/index.ts @@ -1,4 +1,5 @@ import express from "express" +import { resolve } from "node:path" import { handleError } from "../integrations/express/handle-error/index.ts" import { respondNotFound } from "../integrations/express/respond-not-found/index.ts" @@ -8,13 +9,34 @@ import { routes } from "../routes/index.ts" import type { CreateAppOptions } from "./types.ts" /** Give every endpoint the same request logging and safe error responses. */ -export function createApp({ broadcast, logger }: CreateAppOptions = {}) { +export function createApp({ broadcast, logger, clientDirectory, isReady }: CreateAppOptions = {}) { const app = express() app.disable("x-powered-by") app.use(createRequestLogger(logger)) app.use(express.json()) - routes(app, { broadcast }) + routes(app, { broadcast, isReady, logger }) + app.use("/api", respondNotFound) + if (clientDirectory) { + const directory = resolve(clientDirectory) + app.use(express.static(directory, { dotfiles: "deny", index: false, redirect: false })) + app.get("/{*path}", (request, response, next) => { + const acceptsHtml = request + .get("accept") + ?.split(",") + .some((type) => type.split(";")[0]?.trim() === "text/html") + if ( + !acceptsHtml || + !request.accepts("html") || + request.path.split("/").some((part) => part.includes(".")) + ) { + next() + return + } + response.setHeader("Cache-Control", "no-cache") + response.sendFile(resolve(directory, "index.html")) + }) + } app.use(respondNotFound) app.use(handleError) diff --git a/hub/server/create-app/types.ts b/hub/server/create-app/types.ts index c38917e8..0b459758 100644 --- a/hub/server/create-app/types.ts +++ b/hub/server/create-app/types.ts @@ -5,4 +5,6 @@ import type { Broadcast } from "../integrations/ws/types.ts" export interface CreateAppOptions { broadcast?: Broadcast logger?: Logger + clientDirectory?: string + isReady?: () => Promise } diff --git a/hub/server/index.ts b/hub/server/index.ts index e1fb35ce..8f544603 100644 --- a/hub/server/index.ts +++ b/hub/server/index.ts @@ -3,20 +3,44 @@ import "dotenv/config" import { startTelemetry } from "./integrations/opentelemetry/start-telemetry/index.ts" import { logger } from "./integrations/pino/index.ts" import { runApi } from "./run-api/index.ts" - -const port = Number(process.env.PORT ?? 3001) -if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error("PORT must be an integer between 1 and 65535.") -} +import { setup } from "./scripts/setup/index.ts" +import { startServerProcesses } from "./start-server-processes/index.ts" +import { parseRuntimeConfig } from "./utils/parse-runtime-config/index.ts" +import { jobProcessors } from "./workers/configuration.ts" +import { runJobWorker } from "./workers/run-job-worker/index.ts" try { + const configuration = parseRuntimeConfig(process.env) const telemetry = startTelemetry(process.env, logger) try { - await runApi({ port, logger }) + if (configuration.mode === "setup") await setup() + else if (configuration.mode === "api") await runApi({ ...configuration, logger }) + else { + const databaseUrl = configuration.databaseUrl + if (!databaseUrl) throw new Error("This runtime mode requires DATABASE_URL.") + if (configuration.mode === "combined") { + await startServerProcesses({ ...configuration, databaseUrl, logger }) + } else { + const name = configuration.mode + await runJobWorker({ + name, + databaseUrl, + concurrency: + name === "on-demand" + ? configuration.onDemandConcurrency + : configuration.scheduledConcurrency, + shutdownTimeoutMs: configuration.shutdownTimeoutMs, + logger, + processors: jobProcessors[name], + }) + } + } } finally { await telemetry.shutdown() } } catch { - logger.fatal("Hub API failed; check API configuration, telemetry, and shutdown time.") + logger.fatal( + "Hub failed; check runtime configuration, dependencies, telemetry, and shutdown time.", + ) process.exit(1) } diff --git a/hub/server/integrations/postgres/open-database/index.ts b/hub/server/integrations/postgres/open-database/index.ts new file mode 100644 index 00000000..3b743724 --- /dev/null +++ b/hub/server/integrations/postgres/open-database/index.ts @@ -0,0 +1,10 @@ +import { Pool } from "pg" + +export function openDatabase(databaseUrl: string) { + return new Pool({ + connectionString: databaseUrl, + max: 1, + connectionTimeoutMillis: 5_000, + query_timeout: 5_000, + }) +} diff --git a/hub/server/integrations/postgres/with-setup-lock/index.db.test.ts b/hub/server/integrations/postgres/with-setup-lock/index.db.test.ts new file mode 100644 index 00000000..ae306f03 --- /dev/null +++ b/hub/server/integrations/postgres/with-setup-lock/index.db.test.ts @@ -0,0 +1,129 @@ +import { once } from "node:events" +import { Client } from "pg" +import { describe, expect, it, vi } from "vitest" + +import { getDatabaseUrl } from "../../../../test/database/get-database-url" +import { deferred } from "../../../../test/ws/deferred" + +import { withSetupLock } from "." + +describe("withSetupLock()", () => { + it("holds the lock across all setup steps and rejects a competing setup before its steps run", async () => { + const databaseUrl = getDatabaseUrl() + const entered = deferred() + const finish = deferred() + const steps: string[] = [] + const first = withSetupLock({ + databaseUrl, + run: async (signal) => { + expect(signal.aborted).toBe(false) + steps.push("migrations") + entered.resolve() + await finish.promise + steps.push("job schema") + }, + }) + void first.catch(() => {}) + + try { + await Promise.race([entered.promise, first]) + const competingSteps = vi.fn(async () => {}) + await expect(withSetupLock({ databaseUrl, run: competingSteps })).rejects.toThrow( + /setup.*(running|progress|lock)|lock.*setup/i, + ) + expect(competingSteps).not.toHaveBeenCalled() + expect(steps).toEqual(["migrations"]) + } finally { + finish.resolve() + await first + } + + expect(steps).toEqual(["migrations", "job schema"]) + const subsequentSteps = vi.fn(async () => {}) + await withSetupLock({ databaseUrl, run: subsequentSteps }) + expect(subsequentSteps).toHaveBeenCalledTimes(1) + }) + + it("releases the lock after a setup step fails so a later setup can run", async () => { + const databaseUrl = getDatabaseUrl() + const failure = new Error("Setup step failed") + await expect( + withSetupLock({ + databaseUrl, + run: () => Promise.reject(failure), + }), + ).rejects.toBe(failure) + + const retry = vi.fn(async () => {}) + await withSetupLock({ databaseUrl, run: retry }) + expect(retry).toHaveBeenCalledTimes(1) + }) + + it("aborts setup on lock connection loss and waits for cleanup before rejecting safely", async () => { + const databaseUrl = getDatabaseUrl() + const connection = new Client({ connectionString: databaseUrl }) + await connection.connect() + const entered = deferred() + const cleanup = deferred() + const controller = new AbortController() + let cleanupStarted = false + let cleanedUp = false + let settled = false + const running = withSetupLock({ + databaseUrl, + signal: controller.signal, + run: async (signal) => { + entered.resolve(signal) + await once(signal, "abort") + cleanupStarted = true + await cleanup.promise + cleanedUp = true + }, + }) + void running.then( + () => { + settled = true + }, + () => { + settled = true + }, + ) + + try { + const signal = await Promise.race([ + entered.promise, + running.then(() => { + throw new Error("Setup returned before its steps started") + }), + ]) + const result = await connection.query<{ terminated: boolean }>( + `SELECT pg_terminate_backend(pid) AS terminated + FROM pg_locks + WHERE locktype = 'advisory' AND classid = 1919242348 AND objid = 1 + AND database = (SELECT oid FROM pg_database WHERE datname = current_database()) + AND granted AND pid <> pg_backend_pid()`, + ) + expect(result.rows).toEqual([{ terminated: true }]) + await vi.waitFor(() => { + expect(signal.aborted).toBe(true) + expect(cleanupStarted).toBe(true) + }) + await new Promise((resolve) => setImmediate(resolve)) + expect(settled).toBe(false) + expect(cleanedUp).toBe(false) + + cleanup.resolve() + await expect(running).rejects.toThrow(/setup.*lost.*lock.*connection/i) + await expect(running).rejects.not.toThrow(databaseUrl) + expect(cleanedUp).toBe(true) + const retry = vi.fn(async () => {}) + await withSetupLock({ databaseUrl, run: retry }) + expect(retry).toHaveBeenCalledTimes(1) + } finally { + controller.abort() + cleanup.resolve() + await running.catch(() => {}) + await connection.end() + } + }) +}) diff --git a/hub/server/integrations/postgres/with-setup-lock/index.ts b/hub/server/integrations/postgres/with-setup-lock/index.ts new file mode 100644 index 00000000..dea0eda2 --- /dev/null +++ b/hub/server/integrations/postgres/with-setup-lock/index.ts @@ -0,0 +1,49 @@ +import { Client } from "pg" + +export async function withSetupLock({ + databaseUrl, + run, + signal, +}: { + databaseUrl: string + run: (signal: AbortSignal) => Promise + signal?: AbortSignal +}) { + const client = new Client({ connectionString: databaseUrl, connectionTimeoutMillis: 5_000 }) + const controller = new AbortController() + function abort() { + controller.abort(signal?.reason) + } + signal?.addEventListener("abort", abort, { once: true }) + let rejectConnection!: (error: Error) => void + const connectionFailed = new Promise((_resolve, reject) => { + rejectConnection = reject + }) + void connectionFailed.catch(() => {}) + client.on("error", () => { + const error = new Error("Setup lost its database lock connection.") + controller.abort(error) + rejectConnection(error) + }) + let running: Promise | undefined + try { + if (signal?.aborted) controller.abort(signal.reason) + controller.signal.throwIfAborted() + await client.connect() + const result = await client.query<{ locked: boolean }>( + "SELECT pg_try_advisory_lock(1919242348, 1) AS locked", + ) + if (!result.rows[0]?.locked) throw new Error("Another Hub setup is already running.") + controller.signal.throwIfAborted() + running = run(controller.signal) + await Promise.race([running, connectionFailed]) + } finally { + controller.abort() + signal?.removeEventListener("abort", abort) + try { + await running + } finally { + await client.end() + } + } +} diff --git a/hub/server/routes/api/ready/get/index.ts b/hub/server/routes/api/ready/get/index.ts new file mode 100644 index 00000000..d7b97e4c --- /dev/null +++ b/hub/server/routes/api/ready/get/index.ts @@ -0,0 +1,16 @@ +import type { Request, Response } from "express" +import type { Logger } from "pino" + +export async function get( + _request: Request, + response: Response, + { isReady, logger }: { isReady?: () => Promise; logger?: Logger } = {}, +): Promise { + try { + const ready = isReady ? await isReady() : true + response.status(ready ? 200 : 503).json({ status: ready ? "ok" : "unavailable" }) + } catch { + logger?.warn("Hub readiness dependency check failed") + response.status(503).json({ status: "unavailable" }) + } +} diff --git a/hub/server/routes/index.ts b/hub/server/routes/index.ts index fd887a4d..33f9e476 100644 --- a/hub/server/routes/index.ts +++ b/hub/server/routes/index.ts @@ -1,10 +1,13 @@ import type { Express } from "express" import { get as handleApiHealthGet } from "./api/health/get/index.ts" +import { get as handleApiReadyGet } from "./api/ready/get/index.ts" import type { RouteDependencies } from "./types.ts" /** Keep endpoint registration separate from the app's shared middleware. */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Product routes consume these dependencies when implemented. -export function routes(app: Express, _dependencies: RouteDependencies = {}): void { +export function routes(app: Express, { isReady, logger }: RouteDependencies = {}): void { app.get("/api/health", handleApiHealthGet) + app.get("/api/ready", (request, response) => + handleApiReadyGet(request, response, { isReady, logger }), + ) } diff --git a/hub/server/routes/types.ts b/hub/server/routes/types.ts index bab536ec..bf7f2793 100644 --- a/hub/server/routes/types.ts +++ b/hub/server/routes/types.ts @@ -1,5 +1,9 @@ +import type { Logger } from "pino" + import type { Broadcast } from "../integrations/ws/types.ts" export interface RouteDependencies { broadcast?: Broadcast + isReady?: () => Promise + logger?: Logger } diff --git a/hub/server/run-api/index.ts b/hub/server/run-api/index.ts index 441fff36..6b880c12 100644 --- a/hub/server/run-api/index.ts +++ b/hub/server/run-api/index.ts @@ -1,4 +1,6 @@ +import { access } from "node:fs/promises" import { createServer } from "node:http" +import { resolve } from "node:path" import type { Logger } from "pino" import { createApp } from "../create-app/index.ts" @@ -9,10 +11,18 @@ export async function runApi({ port, logger, shutdownTimeoutMs = 30_000, + host = "127.0.0.1", + clientDirectory, + signal, + isReady, }: { port: number logger: Logger shutdownTimeoutMs?: number + host?: string + clientDirectory?: string + signal?: AbortSignal + isReady?: () => Promise }) { if (!Number.isInteger(port) || port < 0 || port > 65535) { throw new Error("API port must be an integer between 0 and 65535.") @@ -20,13 +30,18 @@ export async function runApi({ if (!Number.isInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 1) { throw new Error("API shutdown timeout must be a positive integer.") } + if (clientDirectory) await access(resolve(clientDirectory, "index.html")) const server = createServer() + let bindingStarted = false const realtime = attachRealtimeServer({ server, path: "/api/realtime", logger }) + let stoppingRequested = false server.on( "request", createApp({ logger, broadcast: realtime.broadcast, + clientDirectory, + isReady: async () => !stoppingRequested && (isReady ? await isReady() : true), }), ) let finishBinding!: () => void @@ -37,16 +52,24 @@ export async function runApi({ server.once("error", finishBinding) let stop!: () => void const stopping = new Promise((resolve, reject) => { - stop = resolve + stop = () => { + stoppingRequested = true + resolve() + } server.once("error", reject) }) - process.once("SIGINT", stop) - process.once("SIGTERM", stop) + if (signal) signal.addEventListener("abort", stop, { once: true }) + else { + process.once("SIGINT", stop) + process.once("SIGTERM", stop) + } try { - server.listen(port, "127.0.0.1", () => { + if (signal?.aborted) return + bindingStarted = true + server.listen(port, host, () => { const address = server.address() logger.info( - { host: "127.0.0.1", port: typeof address === "object" ? address?.port : port }, + { host, port: typeof address === "object" ? address?.port : port }, "Hub API listening", ) }) @@ -54,10 +77,11 @@ export async function runApi({ } finally { process.removeListener("SIGINT", stop) process.removeListener("SIGTERM", stop) + signal?.removeEventListener("abort", stop) let timer: ReturnType | undefined async function drain() { // A stop signal can arrive before listen() has opened its socket. - await binding + if (bindingStarted) await binding await shutdownServer(server, realtime) } try { diff --git a/hub/server/scripts/setup-jobs/index.ts b/hub/server/scripts/setup-jobs/index.ts new file mode 100644 index 00000000..8cf174b7 --- /dev/null +++ b/hub/server/scripts/setup-jobs/index.ts @@ -0,0 +1,33 @@ +import "dotenv/config" +import { resolve } from "node:path" +import { pathToFileURL } from "node:url" +import { z } from "zod" + +import { createJobQueue } from "../../integrations/bullmq/create-job-queue/index.ts" +import { migrateJobDatabase } from "../../integrations/bullmq/migrate-job-database/index.ts" +import { upsertJobSchedule } from "../../integrations/bullmq/upsert-job-schedule/index.ts" +import { logger } from "../../integrations/pino/index.ts" +import { jobSchedules } from "../../workers/configuration.ts" + +export async function setupJobs() { + const databaseUrl = z.string().min(1).parse(process.env.DATABASE_URL) + const schemaVersion = await migrateJobDatabase(databaseUrl) + const queue = createJobQueue({ name: "scheduled", databaseUrl, logger }) + try { + for (const schedule of jobSchedules) { + await upsertJobSchedule(queue, schedule) + } + } finally { + await queue.close() + } + logger.info({ schemaVersion, scheduleCount: jobSchedules.length }, "Job setup completed") +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + await setupJobs() + } catch { + logger.fatal("Job setup failed; check database access, schema permissions, and schedules") + process.exitCode = 1 + } +} diff --git a/hub/server/scripts/setup/index.ts b/hub/server/scripts/setup/index.ts new file mode 100644 index 00000000..673d132a --- /dev/null +++ b/hub/server/scripts/setup/index.ts @@ -0,0 +1,74 @@ +import "dotenv/config" +import { spawn } from "node:child_process" +import { resolve } from "node:path" +import { pathToFileURL } from "node:url" + +import { logger } from "../../integrations/pino/index.ts" +import { withSetupLock } from "../../integrations/postgres/with-setup-lock/index.ts" + +export async function setup() { + const databaseUrl = process.env.DATABASE_URL + if (!databaseUrl) throw new Error("Setup requires DATABASE_URL.") + const controller = new AbortController() + function stop() { + controller.abort() + } + process.once("SIGINT", stop) + process.once("SIGTERM", stop) + try { + await withSetupLock({ + databaseUrl, + signal: controller.signal, + run: async (signal) => { + const extension = import.meta.url.endsWith(".js") ? "js" : "ts" + const jobScript = new URL(`../setup-jobs/index.${extension}`, import.meta.url) + const steps = [ + [resolve("node_modules/prisma/build/index.js"), "migrate", "deploy"], + [jobScript.pathname], + ] + for (const [index, args] of steps.entries()) { + signal.throwIfAborted() + logger.info({ step: index === 0 ? "prisma" : "jobs" }, "Hub setup step started") + // Migration tools can include database URLs in diagnostics; keep their output private. + const child = spawn(process.execPath, args, { stdio: "ignore" }) + let timeout: ReturnType | undefined + function terminate() { + child.kill("SIGTERM") + timeout = setTimeout(() => child.kill("SIGKILL"), 5_000) + } + signal.addEventListener("abort", terminate, { once: true }) + if (signal.aborted) terminate() + try { + await new Promise((resolve, reject) => { + child.once("error", reject) + child.once("close", (code) => { + if (code === 0) resolve() + else reject(new Error("Hub setup step failed; check database and schema access.")) + }) + }) + signal.throwIfAborted() + } finally { + signal.removeEventListener("abort", terminate) + clearTimeout(timeout) + } + } + }, + }) + logger.info("Hub setup completed") + } finally { + process.removeListener("SIGINT", stop) + process.removeListener("SIGTERM", stop) + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + await setup() + } catch (error) { + logger.fatal( + { errorType: error instanceof Error ? error.name : "UnknownError" }, + "Hub setup failed; check database access, migrations, and concurrent releases", + ) + process.exitCode = 1 + } +} diff --git a/hub/server/setup-jobs/index.ts b/hub/server/setup-jobs/index.ts deleted file mode 100644 index d910f46a..00000000 --- a/hub/server/setup-jobs/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -import "dotenv/config" -import { z } from "zod" - -import { createJobQueue } from "../integrations/bullmq/create-job-queue/index.ts" -import { migrateJobDatabase } from "../integrations/bullmq/migrate-job-database/index.ts" -import { upsertJobSchedule } from "../integrations/bullmq/upsert-job-schedule/index.ts" -import { logger } from "../integrations/pino/index.ts" -import { jobSchedules } from "../workers/configuration.ts" - -try { - const databaseUrl = z.string().min(1).parse(process.env.DATABASE_URL) - const schemaVersion = await migrateJobDatabase(databaseUrl) - const queue = createJobQueue({ name: "scheduled", databaseUrl, logger }) - try { - for (const schedule of jobSchedules) { - await upsertJobSchedule(queue, schedule) - } - } finally { - await queue.close() - } - logger.info({ schemaVersion, scheduleCount: jobSchedules.length }, "Job setup completed") -} catch (error) { - logger.fatal( - { errorType: error instanceof Error ? error.name : "UnknownError" }, - "Job setup failed; check database access, schema permissions, and schedule definitions", - ) - process.exit(1) -} diff --git a/hub/server/start-server-processes/index.test.ts b/hub/server/start-server-processes/index.test.ts new file mode 100644 index 00000000..f243e23d --- /dev/null +++ b/hub/server/start-server-processes/index.test.ts @@ -0,0 +1,110 @@ +import { EventEmitter } from "node:events" +import { describe, expect, it, onTestFinished, vi } from "vitest" + +import { deferred } from "../../test/ws/deferred" +import { createLogger } from "../integrations/pino/create-logger" +import { openDatabase } from "../integrations/postgres/open-database" +import { runApi } from "../run-api" +import { runJobWorker } from "../workers/run-job-worker" + +import { startServerProcesses } from "." + +vi.mock("../integrations/postgres/open-database", () => ({ openDatabase: vi.fn() })) +vi.mock("../run-api", () => ({ runApi: vi.fn() })) +vi.mock("../workers/run-job-worker", () => ({ runJobWorker: vi.fn() })) + +function arrangeRuntime() { + const query = vi.fn().mockResolvedValue({ + command: "SELECT", + rowCount: 1, + oid: 0, + fields: [], + rows: [{ "?column?": 1 }], + }) + const end = vi.fn().mockResolvedValue(undefined) + const database = Object.assign(new EventEmitter(), { query, end }) + vi.mocked(openDatabase).mockReturnValue(database as unknown as ReturnType) + vi.mocked(runApi).mockImplementation( + ({ signal }) => + new Promise((resolve) => { + signal?.addEventListener("abort", () => resolve(), { once: true }) + }), + ) + vi.mocked(runJobWorker).mockImplementation( + ({ signal }) => + new Promise((resolve) => { + signal?.addEventListener("abort", () => resolve(), { once: true }) + }), + ) + const existing = process.listeners("SIGTERM") + const running = startServerProcesses({ + port: 3001, + host: "127.0.0.1", + databaseUrl: "postgresql://test:test@localhost/runtime_test", + shutdownTimeoutMs: 100, + logger: createLogger({ level: "silent" }), + onDemandConcurrency: 2, + scheduledConcurrency: 1, + }) + void running.catch(() => {}) + function stop() { + const handler = process.listeners("SIGTERM").find((listener) => !existing.includes(listener)) + if (handler) Reflect.apply(handler, process, ["SIGTERM"]) + } + onTestFinished(async () => { + stop() + await running.catch(() => {}) + }) + return { database, query, end, running, stop } +} + +describe("startServerProcesses()", () => { + it("reports readiness only after both workers and the database are ready", async () => { + const { query, end, running, stop } = arrangeRuntime() + await vi.waitFor(() => expect(runApi).toHaveBeenCalledTimes(1)) + const api = vi.mocked(runApi).mock.calls[0]![0] + const workers = vi.mocked(runJobWorker).mock.calls.map(([options]) => options) + expect(workers.map(({ name }) => name).sort()).toEqual(["on-demand", "scheduled"]) + expect(await api.isReady?.()).toBe(false) + workers[0]!.onReady?.() + expect(await api.isReady?.()).toBe(false) + workers[1]!.onReady?.() + expect(await api.isReady?.()).toBe(true) + query.mockRejectedValueOnce(new Error("Database unavailable")) + expect(await api.isReady?.().catch(() => false)).toBe(false) + + stop() + await running + + expect(api.signal?.aborted).toBe(true) + expect(workers.every(({ signal }) => signal?.aborted)).toBe(true) + expect(await api.isReady?.()).toBe(false) + expect(end).toHaveBeenCalledTimes(1) + }) + + it.each(["api", "on-demand", "scheduled"] as const)( + "fails and aborts its peers when the required %s component exits", + async (role) => { + const exited = deferred() + const { running, end } = arrangeRuntime() + if (role === "api") { + vi.mocked(runApi).mockImplementationOnce(() => exited.promise) + } else { + const defaultWorker = vi.mocked(runJobWorker).getMockImplementation()! + vi.mocked(runJobWorker).mockImplementation((options) => + options.name === role ? exited.promise : defaultWorker(options), + ) + } + await vi.waitFor(() => expect(runApi).toHaveBeenCalledTimes(1)) + const api = vi.mocked(runApi).mock.calls[0]![0] + const workers = vi.mocked(runJobWorker).mock.calls.map(([options]) => options) + + exited.resolve() + + await expect(running).rejects.toThrow(/required|component|stopped|failed/i) + expect(api.signal?.aborted).toBe(true) + expect(workers.every(({ signal }) => signal?.aborted)).toBe(true) + expect(end).toHaveBeenCalledTimes(1) + }, + ) +}) diff --git a/hub/server/start-server-processes/index.ts b/hub/server/start-server-processes/index.ts new file mode 100644 index 00000000..196c4c6b --- /dev/null +++ b/hub/server/start-server-processes/index.ts @@ -0,0 +1,72 @@ +import type { Logger } from "pino" + +import { openDatabase } from "../integrations/postgres/open-database/index.ts" +import { runApi } from "../run-api/index.ts" +import { jobProcessors } from "../workers/configuration.ts" +import { runJobWorker } from "../workers/run-job-worker/index.ts" + +export async function startServerProcesses(options: { + port: number + host: string + databaseUrl: string + clientDirectory?: string + shutdownTimeoutMs: number + logger: Logger + onDemandConcurrency: number + scheduledConcurrency: number +}) { + const controller = new AbortController() + function stop() { + controller.abort(null) + } + process.once("SIGINT", stop) + process.once("SIGTERM", stop) + const database = openDatabase(options.databaseUrl) + const ready = new Set() + database.on("error", (error) => { + options.logger.error({ errorType: error.name }, "Hub database connection failed") + controller.abort(error) + }) + const roles: Promise[] = [] + let failed: boolean + try { + await database.query("SELECT 1") + if (!controller.signal.aborted) { + roles.push( + ...(["on-demand", "scheduled"] as const).map((name) => + runJobWorker({ + name, + databaseUrl: options.databaseUrl, + concurrency: + name === "on-demand" ? options.onDemandConcurrency : options.scheduledConcurrency, + shutdownTimeoutMs: options.shutdownTimeoutMs, + logger: options.logger, + processors: jobProcessors[name], + signal: controller.signal, + onReady: () => ready.add(name), + }), + ), + runApi({ + ...options, + signal: controller.signal, + isReady: async () => { + if (controller.signal.aborted || ready.size !== 2) return false + await database.query("SELECT 1") + return true + }, + }), + ) + await Promise.race(roles) + if (!controller.signal.aborted) throw new Error("A required Hub component stopped.") + } + } finally { + controller.abort(null) + process.removeListener("SIGINT", stop) + process.removeListener("SIGTERM", stop) + const outcomes = await Promise.allSettled(roles) + await database.end() + failed = outcomes.some((outcome) => outcome.status === "rejected") + } + if (failed) throw new Error("A required Hub component failed.") + if (controller.signal.reason instanceof Error) throw controller.signal.reason +} diff --git a/hub/server/utils/parse-runtime-config/index.test.ts b/hub/server/utils/parse-runtime-config/index.test.ts new file mode 100644 index 00000000..b72639dd --- /dev/null +++ b/hub/server/utils/parse-runtime-config/index.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest" + +import { parseRuntimeConfig } from "." + +describe("parseRuntimeConfig()", () => { + it("reads combined runtime configuration without requiring a database for API-only development", () => { + expect(parseRuntimeConfig({})).toMatchObject({ + mode: "api", + host: "127.0.0.1", + port: 3001, + }) + expect( + parseRuntimeConfig({ + RUNTIME_MODE: "combined", + HOST: "0.0.0.0", + PORT: "8080", + DATABASE_URL: "postgresql://test:test@localhost/hub", + CLIENT_DIRECTORY: "/app/client", + SHUTDOWN_TIMEOUT_MS: "10000", + ON_DEMAND_JOB_CONCURRENCY: "4", + SCHEDULED_JOB_CONCURRENCY: "2", + }), + ).toMatchObject({ + mode: "combined", + host: "0.0.0.0", + port: 8080, + databaseUrl: "postgresql://test:test@localhost/hub", + clientDirectory: "/app/client", + shutdownTimeoutMs: 10000, + onDemandConcurrency: 4, + scheduledConcurrency: 2, + }) + }) + + it.each([ + { RUNTIME_MODE: "private-config-marker" }, + { PORT: "3001private-config-marker" }, + { PORT: "" }, + { HOST: " " }, + { CLIENT_DIRECTORY: " " }, + { SHUTDOWN_TIMEOUT_MS: "0" }, + { ON_DEMAND_JOB_CONCURRENCY: "1.5" }, + { SCHEDULED_JOB_CONCURRENCY: "-1" }, + { RUNTIME_MODE: "combined" }, + { RUNTIME_MODE: "setup" }, + { DATABASE_URL: "https://test:private-config-marker@localhost/hub" }, + ])("rejects invalid configuration without exposing its values: %j", (env) => { + expect(() => parseRuntimeConfig(env)).toThrow() + try { + parseRuntimeConfig(env) + } catch (error) { + expect(String(error)).not.toContain("private-config-marker") + } + }) +}) diff --git a/hub/server/utils/parse-runtime-config/index.ts b/hub/server/utils/parse-runtime-config/index.ts new file mode 100644 index 00000000..812b0b55 --- /dev/null +++ b/hub/server/utils/parse-runtime-config/index.ts @@ -0,0 +1,50 @@ +import { z } from "zod" + +export function parseRuntimeConfig(env: NodeJS.ProcessEnv) { + const mode = z + .enum(["api", "combined", "on-demand", "scheduled", "setup"]) + .parse(env.RUNTIME_MODE ?? "api") + const port = z.coerce + .number() + .int() + .min(1) + .max(65535) + .parse(env.PORT ?? 3001) + const host = z + .string() + .trim() + .min(1) + .parse(env.HOST ?? "127.0.0.1") + const shutdownTimeoutMs = z.coerce + .number() + .int() + .min(1) + .max(300_000) + .parse(env.SHUTDOWN_TIMEOUT_MS ?? 30_000) + const concurrency = z.coerce.number().int().min(1).max(100) + const databaseUrl = mode === "api" ? env.DATABASE_URL : z.string().min(1).parse(env.DATABASE_URL) + if (databaseUrl) { + let url: URL + try { + url = new URL(databaseUrl) + } catch { + throw new Error("DATABASE_URL must be a PostgreSQL connection URL.") + } + if (!["postgres:", "postgresql:"].includes(url.protocol)) { + throw new Error("DATABASE_URL must be a PostgreSQL connection URL.") + } + } + if (env.CLIENT_DIRECTORY !== undefined && !env.CLIENT_DIRECTORY.trim()) { + throw new Error("CLIENT_DIRECTORY must be a nonempty directory.") + } + return { + mode, + port, + host, + shutdownTimeoutMs, + databaseUrl, + clientDirectory: env.CLIENT_DIRECTORY, + onDemandConcurrency: concurrency.parse(env.ON_DEMAND_JOB_CONCURRENCY ?? 1), + scheduledConcurrency: concurrency.parse(env.SCHEDULED_JOB_CONCURRENCY ?? 1), + } +} diff --git a/hub/server/workers/index.ts b/hub/server/workers/index.ts index 5f0cefd8..edfc7b1a 100644 --- a/hub/server/workers/index.ts +++ b/hub/server/workers/index.ts @@ -5,20 +5,25 @@ import { startTelemetry } from "../integrations/opentelemetry/start-telemetry/in import { logger } from "../integrations/pino/index.ts" import { jobProcessors } from "./configuration.ts" import { runJobWorker } from "./run-job-worker/index.ts" +import { parseRuntimeConfig } from "../utils/parse-runtime-config/index.ts" try { const name = z.enum(["on-demand", "scheduled"]).parse(process.argv[2]) - const databaseUrl = z.string().min(1).parse(process.env.DATABASE_URL) - const variable = name === "on-demand" ? "ON_DEMAND_JOB_CONCURRENCY" : "SCHEDULED_JOB_CONCURRENCY" - const concurrency = z.coerce - .number() - .int() - .min(1) - .max(100) - .parse(process.env[variable] ?? 1) + const configuration = parseRuntimeConfig({ ...process.env, RUNTIME_MODE: name }) + const databaseUrl = configuration.databaseUrl + if (!databaseUrl) throw new Error("Workers require DATABASE_URL.") + const concurrency = + name === "on-demand" ? configuration.onDemandConcurrency : configuration.scheduledConcurrency const telemetry = startTelemetry(process.env, logger) try { - await runJobWorker({ name, databaseUrl, concurrency, logger, processors: jobProcessors[name] }) + await runJobWorker({ + name, + databaseUrl, + concurrency, + shutdownTimeoutMs: configuration.shutdownTimeoutMs, + logger, + processors: jobProcessors[name], + }) } finally { await telemetry.shutdown() } diff --git a/hub/server/workers/run-job-worker/index.ts b/hub/server/workers/run-job-worker/index.ts index c5a91dae..d784ee22 100644 --- a/hub/server/workers/run-job-worker/index.ts +++ b/hub/server/workers/run-job-worker/index.ts @@ -5,11 +5,15 @@ import type { JobConnection, JobProcessor } from "../../integrations/bullmq/type export async function runJobWorker({ shutdownTimeoutMs = 30_000, + signal, + onReady, ...options }: JobConnection & { processors: JobProcessor[] concurrency?: number shutdownTimeoutMs?: number + signal?: AbortSignal + onReady?: () => void }) { z.number().int().positive().parse(shutdownTimeoutMs) const worker = createJobWorker(options) @@ -22,19 +26,25 @@ export async function runJobWorker({ const stopping = new Promise((resolve) => { resolveStop = resolve }) - process.once("SIGINT", stop) - process.once("SIGTERM", stop) + if (signal) signal.addEventListener("abort", stop, { once: true }) + else { + process.once("SIGINT", stop) + process.once("SIGTERM", stop) + } let running: Promise | undefined try { + if (signal?.aborted) return await Promise.race([worker.waitUntilReady(), stopping]) if (stopped) return running = worker.run() options.logger.info({ queue: options.name }, "Job worker ready") + onReady?.() await Promise.race([running, stopping]) } finally { process.removeListener("SIGINT", stop) process.removeListener("SIGTERM", stop) + signal?.removeEventListener("abort", stop) options.logger.info({ queue: options.name }, "Job worker stopping") let timer: ReturnType | undefined async function drain() { diff --git a/hub/tsconfig.json b/hub/tsconfig.json index 516108e7..3ffa384a 100644 --- a/hub/tsconfig.json +++ b/hub/tsconfig.json @@ -28,6 +28,7 @@ "include": [ "app/javascript", "server", + "script/**/*.ts", "test", ".storybook/**/*.ts", ".storybook/**/*.tsx",