Add collaboration server skeleton (Hocuspocus) with health, container, and CI/CD (#33)
All checks were successful
CD / Build and push images (push) Successful in 2m36s
CI / Lint, typecheck, test (push) Successful in 1m50s
CI / Auth e2e pack (push) Successful in 1m58s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
All checks were successful
CD / Build and push images (push) Successful in 2m36s
CI / Lint, typecheck, test (push) Successful in 1m50s
CI / Auth e2e pack (push) Successful in 1m58s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
Bootstrap apps/collab as a Hocuspocus WebSocket server (ADR 0003): - pino JSON logging (service=collab) and shared Zod env validation (collabEnvSchema); structured connection open/close logs. - /healthz endpoint (process liveness + PostgreSQL ping) served via the onRequest hook, matching the container-internal path and the proxied /collab/healthz path; any WebSocket handshake is accepted for now (authentication arrives with #34, persistence with #35). - Dockerfile (ESM workspace build) and a compose service on the frontend and internal networks with a healthcheck; dev overlay service and a new COLLAB_PORT variable. - CD builds, pushes, and promotes the collab image; CI builds it on PRs; the smoke suite asserts /collab/healthz through the reverse proxy. - deployment.md/stages.md: proxy routing, per-stage COLLAB_PORT, checklist. Closes #33 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
49fcbc45a6
commit
8316c617d2
@ -40,6 +40,13 @@ jobs:
|
|||||||
docker push $IMAGE_BASE-api:${{ github.sha }}
|
docker push $IMAGE_BASE-api:${{ github.sha }}
|
||||||
docker push $IMAGE_BASE-api:test
|
docker push $IMAGE_BASE-api:test
|
||||||
|
|
||||||
|
- name: Build and push collab image
|
||||||
|
run: |
|
||||||
|
docker build -f apps/collab/Dockerfile --build-arg APP_VERSION=${{ github.sha }} \
|
||||||
|
-t $IMAGE_BASE-collab:${{ github.sha }} -t $IMAGE_BASE-collab:test .
|
||||||
|
docker push $IMAGE_BASE-collab:${{ github.sha }}
|
||||||
|
docker push $IMAGE_BASE-collab:test
|
||||||
|
|
||||||
deploy-test:
|
deploy-test:
|
||||||
name: Deploy to Test
|
name: Deploy to Test
|
||||||
needs: build-push
|
needs: build-push
|
||||||
@ -107,6 +114,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker buildx imagetools create -t $IMAGE_BASE-web:int $IMAGE_BASE-web:${{ github.sha }}
|
docker buildx imagetools create -t $IMAGE_BASE-web:int $IMAGE_BASE-web:${{ github.sha }}
|
||||||
docker buildx imagetools create -t $IMAGE_BASE-api:int $IMAGE_BASE-api:${{ github.sha }}
|
docker buildx imagetools create -t $IMAGE_BASE-api:int $IMAGE_BASE-api:${{ github.sha }}
|
||||||
|
docker buildx imagetools create -t $IMAGE_BASE-collab:int $IMAGE_BASE-collab:${{ github.sha }}
|
||||||
|
|
||||||
- name: Set up SSH
|
- name: Set up SSH
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@ -157,3 +157,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Build api image
|
- name: Build api image
|
||||||
run: docker build -f apps/api/Dockerfile --build-arg APP_VERSION=${{ github.sha }} -t dorfteich-api:ci .
|
run: docker build -f apps/api/Dockerfile --build-arg APP_VERSION=${{ github.sha }} -t dorfteich-api:ci .
|
||||||
|
|
||||||
|
- name: Build collab image
|
||||||
|
run: docker build -f apps/collab/Dockerfile --build-arg APP_VERSION=${{ github.sha }} -t dorfteich-collab:ci .
|
||||||
|
|||||||
26
apps/collab/Dockerfile
Normal file
26
apps/collab/Dockerfile
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# Build context is the repository root (workspace build):
|
||||||
|
# docker build -f apps/collab/Dockerfile .
|
||||||
|
|
||||||
|
FROM node:22.15-alpine AS build
|
||||||
|
WORKDIR /repo
|
||||||
|
RUN npm install -g pnpm@11
|
||||||
|
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./
|
||||||
|
COPY packages/shared ./packages/shared
|
||||||
|
COPY apps/collab ./apps/collab
|
||||||
|
RUN pnpm install --frozen-lockfile --filter @dorfteich/collab... \
|
||||||
|
&& pnpm --filter @dorfteich/shared build \
|
||||||
|
&& pnpm --filter @dorfteich/collab build \
|
||||||
|
# Self-contained production bundle (prod deps only) at /out.
|
||||||
|
&& pnpm --filter @dorfteich/collab deploy --prod --legacy /out \
|
||||||
|
&& cp -r apps/collab/dist /out/dist
|
||||||
|
|
||||||
|
FROM node:22.15-alpine
|
||||||
|
ARG APP_VERSION=0.0.0-dev
|
||||||
|
ENV NODE_ENV=production APP_VERSION=${APP_VERSION}
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build --chown=node:node /out /app
|
||||||
|
USER node
|
||||||
|
EXPOSE 3000
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||||
|
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
@ -2,14 +2,27 @@
|
|||||||
"name": "@dorfteich/collab",
|
"name": "@dorfteich/collab",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"description": "Dorfteich collaboration server (Yjs/Hocuspocus)",
|
"description": "Dorfteich collaboration server (Yjs/Hocuspocus)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"start:dev": "tsx watch src/index.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests"
|
"test": "vitest run --passWithNoTests"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dorfteich/shared": "workspace:*",
|
||||||
|
"@hocuspocus/server": "^4.3.0",
|
||||||
|
"pg": "^8.16.0",
|
||||||
|
"pino": "^9.6.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^26.1.0",
|
||||||
|
"@types/pg": "^8.11.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^3.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34
apps/collab/src/db.ts
Normal file
34
apps/collab/src/db.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { Pool } from 'pg';
|
||||||
|
|
||||||
|
import type { DatabaseProbe } from './health.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small connection pool to the same PostgreSQL the api uses (ADR 0002). The
|
||||||
|
* collab skeleton (issue #33) only needs it for the health probe; the document
|
||||||
|
* persistence hooks (issue #35) will reuse this pool for load/store.
|
||||||
|
*/
|
||||||
|
export function createPool(connectionString: string): Pool {
|
||||||
|
return new Pool({
|
||||||
|
connectionString,
|
||||||
|
max: 4,
|
||||||
|
// Fail fast on health probes instead of hanging when the db is unreachable.
|
||||||
|
connectionTimeoutMillis: 5000,
|
||||||
|
query_timeout: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Probe database reachability for the health endpoint. Never throws. */
|
||||||
|
export async function pingDatabase(pool: Pool): Promise<DatabaseProbe> {
|
||||||
|
try {
|
||||||
|
await pool.query('SELECT 1');
|
||||||
|
return { ok: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, detail: shortMessage(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortMessage(error: unknown): string {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
// Keep the health output single-line and free of any connection string.
|
||||||
|
return message.split('\n').filter(Boolean).slice(-1)[0]?.slice(0, 200) ?? 'unknown error';
|
||||||
|
}
|
||||||
44
apps/collab/src/health.test.ts
Normal file
44
apps/collab/src/health.test.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { buildHealthReport, isHealthRequest } from './health.js';
|
||||||
|
|
||||||
|
describe('buildHealthReport', () => {
|
||||||
|
it('reports ok with HTTP 200 when the database is reachable', () => {
|
||||||
|
const { httpStatus, body } = buildHealthReport('1.2.3', { ok: true });
|
||||||
|
expect(httpStatus).toBe(200);
|
||||||
|
expect(body.status).toBe('ok');
|
||||||
|
expect(body.service).toBe('collab');
|
||||||
|
expect(body.version).toBe('1.2.3');
|
||||||
|
expect(body.checks).toEqual([{ name: 'database', status: 'ok' }]);
|
||||||
|
expect(typeof body.time).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports unhealthy with HTTP 503 and the detail when the database is down', () => {
|
||||||
|
const { httpStatus, body } = buildHealthReport('1.2.3', {
|
||||||
|
ok: false,
|
||||||
|
detail: 'connection refused',
|
||||||
|
});
|
||||||
|
expect(httpStatus).toBe(503);
|
||||||
|
expect(body.status).toBe('unhealthy');
|
||||||
|
expect(body.checks).toEqual([
|
||||||
|
{ name: 'database', status: 'failed', detail: 'connection refused' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isHealthRequest', () => {
|
||||||
|
it('matches the internal and proxied health paths on GET/HEAD', () => {
|
||||||
|
expect(isHealthRequest('GET', '/healthz')).toBe(true);
|
||||||
|
expect(isHealthRequest('HEAD', '/healthz')).toBe(true);
|
||||||
|
expect(isHealthRequest('GET', '/healthz?probe=1')).toBe(true);
|
||||||
|
expect(isHealthRequest('GET', '/collab/healthz')).toBe(true);
|
||||||
|
expect(isHealthRequest('GET', '/collab/healthz/')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not match other methods, the WebSocket path, or the root', () => {
|
||||||
|
expect(isHealthRequest('POST', '/healthz')).toBe(false);
|
||||||
|
expect(isHealthRequest('GET', '/collab')).toBe(false);
|
||||||
|
expect(isHealthRequest('GET', '/')).toBe(false);
|
||||||
|
expect(isHealthRequest('GET', undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
64
apps/collab/src/health.ts
Normal file
64
apps/collab/src/health.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import type { HealthResponse } from '@dorfteich/shared';
|
||||||
|
|
||||||
|
/** Result of a single dependency probe. */
|
||||||
|
export interface DatabaseProbe {
|
||||||
|
ok: boolean;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liveness + dependency report for the collab service. Unlike the api, which
|
||||||
|
* splits pure liveness (`/healthz`) from readiness (`/readyz`), the collab
|
||||||
|
* skeleton only needs the one endpoint the compose healthcheck and pipeline
|
||||||
|
* smoke test hit, so `/healthz` also carries the database probe (issue #33).
|
||||||
|
*/
|
||||||
|
export interface CollabHealthReport {
|
||||||
|
status: 'ok' | 'unhealthy';
|
||||||
|
service: HealthResponse['service'];
|
||||||
|
version: string;
|
||||||
|
time: string;
|
||||||
|
checks: { name: 'database'; status: 'ok' | 'failed'; detail?: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the health payload and the HTTP status a monitor should see. */
|
||||||
|
export function buildHealthReport(
|
||||||
|
version: string,
|
||||||
|
database: DatabaseProbe,
|
||||||
|
): { httpStatus: 200 | 503; body: CollabHealthReport } {
|
||||||
|
const body: CollabHealthReport = {
|
||||||
|
status: database.ok ? 'ok' : 'unhealthy',
|
||||||
|
service: 'collab',
|
||||||
|
version,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
checks: [
|
||||||
|
{
|
||||||
|
name: 'database',
|
||||||
|
status: database.ok ? 'ok' : 'failed',
|
||||||
|
...(database.detail ? { detail: database.detail } : {}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return { httpStatus: database.ok ? 200 : 503, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a plain HTTP request is a health probe. Matches both the container-
|
||||||
|
* internal path (`/healthz`, used by the Docker healthcheck) and the path seen
|
||||||
|
* through the host reverse proxy, which routes `/collab*` to this service
|
||||||
|
* without stripping the prefix (deployment.md), i.e. `/collab/healthz`.
|
||||||
|
*/
|
||||||
|
export function isHealthRequest(method: string | undefined, url: string | undefined): boolean {
|
||||||
|
if (method !== undefined && method !== 'GET' && method !== 'HEAD') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const pathname = pathnameOf(url);
|
||||||
|
return pathname === '/healthz' || pathname === '/collab/healthz';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathnameOf(url: string | undefined): string {
|
||||||
|
if (!url) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
// The base only matters to parse a path-only request-target; it is discarded.
|
||||||
|
return new URL(url, 'http://collab.internal').pathname.replace(/\/+$/, '') || '/';
|
||||||
|
}
|
||||||
@ -1,3 +1,35 @@
|
|||||||
// Placeholder entry point; the Hocuspocus server arrives with milestone M3
|
import { collabEnvSchema, parseEnv } from '@dorfteich/shared';
|
||||||
// (issue #33). The package exists so workspace tooling covers it from day one.
|
|
||||||
export const SERVICE_NAME = 'collab';
|
import { createPool, pingDatabase } from './db.js';
|
||||||
|
import { createLogger } from './logger.js';
|
||||||
|
import { createCollabServer } from './server.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entry point of the collaboration server (ADR 0003). Validates the
|
||||||
|
* environment, opens a database pool for the health probe, and starts the
|
||||||
|
* Hocuspocus server. A clean shutdown closes both so the container stops fast.
|
||||||
|
*/
|
||||||
|
async function bootstrap(): Promise<void> {
|
||||||
|
// Validate configuration up front; crash with a readable list on problems.
|
||||||
|
const env = parseEnv(collabEnvSchema, process.env);
|
||||||
|
const logger = createLogger(env);
|
||||||
|
const pool = createPool(env.DATABASE_URL);
|
||||||
|
|
||||||
|
const server = createCollabServer({
|
||||||
|
version: env.APP_VERSION,
|
||||||
|
logger,
|
||||||
|
pingDatabase: () => pingDatabase(pool),
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.listen(env.PORT);
|
||||||
|
logger.info({ event: 'listen', port: env.PORT }, 'collaboration server listening');
|
||||||
|
|
||||||
|
const shutdown = (signal: NodeJS.Signals): void => {
|
||||||
|
logger.info({ event: 'shutdown', signal }, 'shutting down');
|
||||||
|
void Promise.allSettled([server.destroy(), pool.end()]).then(() => process.exit(0));
|
||||||
|
};
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
|
|||||||
15
apps/collab/src/logger.ts
Normal file
15
apps/collab/src/logger.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { pino, type Logger } from 'pino';
|
||||||
|
|
||||||
|
import type { CollabEnv } from '@dorfteich/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pino JSON logger, matching the api's structured output (ADR 0003 asks the
|
||||||
|
* collab service to log consistently with the api). Every line carries
|
||||||
|
* `service: "collab"` so logs from the two containers are easy to tell apart.
|
||||||
|
*/
|
||||||
|
export function createLogger(env: Pick<CollabEnv, 'LOG_LEVEL' | 'APP_VERSION'>): Logger {
|
||||||
|
return pino({
|
||||||
|
level: env.LOG_LEVEL,
|
||||||
|
base: { service: 'collab', version: env.APP_VERSION },
|
||||||
|
});
|
||||||
|
}
|
||||||
65
apps/collab/src/server.test.ts
Normal file
65
apps/collab/src/server.test.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { pino } from 'pino';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { createCollabServer } from './server.js';
|
||||||
|
import type { CollabHealthReport, DatabaseProbe } from './health.js';
|
||||||
|
|
||||||
|
// A silent logger; these tests assert behaviour, not log output.
|
||||||
|
const logger = pino({ enabled: false });
|
||||||
|
|
||||||
|
describe('collab server', () => {
|
||||||
|
let server: ReturnType<typeof createCollabServer>;
|
||||||
|
let baseUrl: string;
|
||||||
|
let wsUrl: string;
|
||||||
|
const probe = vi.fn<() => Promise<DatabaseProbe>>();
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = createCollabServer({ version: 'test-version', logger, pingDatabase: probe });
|
||||||
|
// Port 0 binds an ephemeral free port.
|
||||||
|
await server.listen(0);
|
||||||
|
const { port } = server.address;
|
||||||
|
baseUrl = `http://127.0.0.1:${port}`;
|
||||||
|
wsUrl = `ws://127.0.0.1:${port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await server.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves /healthz as 200 with the report when the database is reachable', async () => {
|
||||||
|
probe.mockResolvedValueOnce({ ok: true });
|
||||||
|
const res = await fetch(`${baseUrl}/healthz`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get('content-type')).toContain('application/json');
|
||||||
|
const body = (await res.json()) as CollabHealthReport;
|
||||||
|
expect(body).toMatchObject({ status: 'ok', service: 'collab', version: 'test-version' });
|
||||||
|
expect(body.checks).toEqual([{ name: 'database', status: 'ok' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves /healthz as 503 when the database probe fails', async () => {
|
||||||
|
probe.mockResolvedValueOnce({ ok: false, detail: 'boom' });
|
||||||
|
const res = await fetch(`${baseUrl}/healthz`);
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
const body = (await res.json()) as CollabHealthReport;
|
||||||
|
expect(body.status).toBe('unhealthy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves non-health HTTP requests to Hocuspocus without probing the db', async () => {
|
||||||
|
const callsBefore = probe.mock.calls.length;
|
||||||
|
const res = await fetch(`${baseUrl}/`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.text()).toContain('Hocuspocus');
|
||||||
|
expect(probe.mock.calls.length).toBe(callsBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a WebSocket handshake on the collab path', async () => {
|
||||||
|
const socket = new WebSocket(`${wsUrl}/collab`);
|
||||||
|
await expect(
|
||||||
|
new Promise<void>((resolve, reject) => {
|
||||||
|
socket.addEventListener('open', () => resolve());
|
||||||
|
socket.addEventListener('error', () => reject(new Error('handshake failed')));
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
socket.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
65
apps/collab/src/server.ts
Normal file
65
apps/collab/src/server.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { Server } from '@hocuspocus/server';
|
||||||
|
import type { Logger } from 'pino';
|
||||||
|
|
||||||
|
import { buildHealthReport, isHealthRequest, type DatabaseProbe } from './health.js';
|
||||||
|
|
||||||
|
export interface CollabServerDeps {
|
||||||
|
/** Version string surfaced in health responses (image build arg). */
|
||||||
|
version: string;
|
||||||
|
logger: Logger;
|
||||||
|
/** Probe used by the `/healthz` endpoint. Injected so it is easy to test. */
|
||||||
|
pingDatabase: () => Promise<DatabaseProbe>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Hocuspocus collaboration server (ADR 0003). This skeleton (issue #33)
|
||||||
|
* wires structured connection logging and a `/healthz` endpoint; it does not
|
||||||
|
* yet authenticate connections (issue #34) or persist documents (issue #35),
|
||||||
|
* so any WebSocket handshake is currently accepted.
|
||||||
|
*/
|
||||||
|
export function createCollabServer(deps: CollabServerDeps): Server {
|
||||||
|
const { version, logger, pingDatabase } = deps;
|
||||||
|
|
||||||
|
return new Server({
|
||||||
|
name: 'dorfteich-collab',
|
||||||
|
// Suppress Hocuspocus' ASCII start screen; we emit our own pino logs.
|
||||||
|
quiet: true,
|
||||||
|
|
||||||
|
async onConnect({ documentName, socketId }) {
|
||||||
|
logger.info(
|
||||||
|
{ event: 'connection.open', documentName, socketId },
|
||||||
|
'collaboration connection opened',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async onDisconnect({ documentName, socketId, clientsCount }) {
|
||||||
|
logger.info(
|
||||||
|
{ event: 'connection.close', documentName, socketId, clientsCount },
|
||||||
|
'collaboration connection closed',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async onRequest({ request, response }) {
|
||||||
|
if (!isHealthRequest(request.method, request.url)) {
|
||||||
|
// Not a health probe: let Hocuspocus handle the request.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const database = await pingDatabase();
|
||||||
|
const { httpStatus, body } = buildHealthReport(version, database);
|
||||||
|
if (!database.ok) {
|
||||||
|
logger.warn(
|
||||||
|
{ event: 'health.failed', detail: database.detail },
|
||||||
|
'health check failed: database unreachable',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
response.writeHead(httpStatus, { 'Content-Type': 'application/json' });
|
||||||
|
response.end(JSON.stringify(body));
|
||||||
|
|
||||||
|
// Hocuspocus sends a default 200 unless the onRequest chain rejects with
|
||||||
|
// a falsy reason; the response is already written, so stop the chain.
|
||||||
|
return Promise.reject();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,8 +1,6 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "CommonJS",
|
|
||||||
"moduleResolution": "Node",
|
|
||||||
"outDir": "dist"
|
"outDir": "dist"
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
|
|||||||
@ -23,3 +23,13 @@ test('api is live and ready', async ({ request }) => {
|
|||||||
const readyz = await request.get('/api/v1/readyz');
|
const readyz = await request.get('/api/v1/readyz');
|
||||||
expect(readyz.ok(), `readyz: ${await readyz.text()}`).toBeTruthy();
|
expect(readyz.ok(), `readyz: ${await readyz.text()}`).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('collab service is live and reachable through the proxy', async ({ request }) => {
|
||||||
|
// The reverse proxy routes /collab to the Hocuspocus container (issue #33);
|
||||||
|
// its /healthz also pings the database, so 200 proves the whole path works.
|
||||||
|
const res = await request.get('/collab/healthz');
|
||||||
|
expect(res.ok(), `collab healthz: ${await res.text()}`).toBeTruthy();
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.status).toBe('ok');
|
||||||
|
expect(body.service).toBe('collab');
|
||||||
|
});
|
||||||
|
|||||||
@ -13,10 +13,12 @@ IMAGE_PREFIX=dorfteich
|
|||||||
TAG=latest
|
TAG=latest
|
||||||
|
|
||||||
# --- ports (localhost only; the host reverse proxy routes to these) ---------
|
# --- ports (localhost only; the host reverse proxy routes to these) ---------
|
||||||
# Suggested per stage on the shared VPS: test 8100/8101, int 8110/8111,
|
# Suggested per stage on the shared VPS: test 8100/8101/8102,
|
||||||
# prod 8120/8121.
|
# int 8110/8111/8112, prod 8120/8121/8122 (web/api/collab).
|
||||||
WEB_PORT=8100
|
WEB_PORT=8100
|
||||||
API_PORT=8101
|
API_PORT=8101
|
||||||
|
# collab (Hocuspocus) WebSocket server; the proxy routes /collab here.
|
||||||
|
COLLAB_PORT=8102
|
||||||
|
|
||||||
# --- behavior ----------------------------------------------------------------
|
# --- behavior ----------------------------------------------------------------
|
||||||
# pino log level: fatal|error|warn|info|debug|trace
|
# pino log level: fatal|error|warn|info|debug|trace
|
||||||
|
|||||||
@ -50,6 +50,27 @@ services:
|
|||||||
- api-shared-modules:/repo/packages/shared/node_modules
|
- api-shared-modules:/repo/packages/shared/node_modules
|
||||||
- pnpm-store:/root/.local/share/pnpm/store
|
- pnpm-store:/root/.local/share/pnpm/store
|
||||||
|
|
||||||
|
collab:
|
||||||
|
image: node:22.15-alpine
|
||||||
|
build: !reset null
|
||||||
|
working_dir: /repo
|
||||||
|
command: sh -c "npm i -g pnpm@11 && pnpm install && pnpm --filter @dorfteich/shared build && pnpm --filter @dorfteich/collab start:dev"
|
||||||
|
environment:
|
||||||
|
NODE_ENV: development
|
||||||
|
PORT: '3000'
|
||||||
|
DATABASE_URL: postgresql://dorfteich:${POSTGRES_PASSWORD:-dorfteich}@db:5432/dorfteich
|
||||||
|
ports: !override
|
||||||
|
- '127.0.0.1:3002:3000'
|
||||||
|
volumes:
|
||||||
|
- ../..:/repo
|
||||||
|
- collab-root-modules:/repo/node_modules
|
||||||
|
- collab-app-modules:/repo/apps/collab/node_modules
|
||||||
|
- collab-shared-modules:/repo/packages/shared/node_modules
|
||||||
|
- pnpm-store:/root/.local/share/pnpm/store
|
||||||
|
# No healthcheck block here: the production image's HEALTHCHECK does not
|
||||||
|
# apply to this node:alpine dev image, and nothing depends on it.
|
||||||
|
healthcheck: !reset null
|
||||||
|
|
||||||
db:
|
db:
|
||||||
ports:
|
ports:
|
||||||
# 5434 on the host to avoid colliding with other local PostgreSQL
|
# 5434 on the host to avoid colliding with other local PostgreSQL
|
||||||
@ -71,4 +92,7 @@ volumes:
|
|||||||
api-root-modules:
|
api-root-modules:
|
||||||
api-app-modules:
|
api-app-modules:
|
||||||
api-shared-modules:
|
api-shared-modules:
|
||||||
|
collab-root-modules:
|
||||||
|
collab-app-modules:
|
||||||
|
collab-shared-modules:
|
||||||
pnpm-store:
|
pnpm-store:
|
||||||
|
|||||||
@ -67,6 +67,34 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
<<: *logging
|
<<: *logging
|
||||||
|
|
||||||
|
collab:
|
||||||
|
image: ${IMAGE_PREFIX:-dorfteich}-collab:${TAG:-latest}
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: apps/collab/Dockerfile
|
||||||
|
args:
|
||||||
|
APP_VERSION: ${TAG:-latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: '3000'
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
|
DATABASE_URL: postgresql://dorfteich:${POSTGRES_PASSWORD:?set in .env}@db:5432/dorfteich
|
||||||
|
ports:
|
||||||
|
# The host reverse proxy routes /collab here with WebSocket upgrade
|
||||||
|
# (deployment.md, deploy/stages.md).
|
||||||
|
- '127.0.0.1:${COLLAB_PORT:-8102}:3000'
|
||||||
|
networks: [frontend, internal]
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'wget -q -O /dev/null http://127.0.0.1:3000/healthz || exit 1']
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
<<: *logging
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:17.5-alpine
|
image: postgres:17.5-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@ -8,9 +8,9 @@ owner; everything else can be done by CI or a deploy user.
|
|||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
| Stage | Directory | Domain | Ports (localhost) |
|
| Stage | Directory | Domain | Ports (localhost) |
|
||||||
| ----- | ------------------------------ | ---------------------- | ------------------ |
|
| ----- | ------------------------------ | ---------------------- | ------------------------------- |
|
||||||
| Test | `/home/DOCKER/dorfteich-test/` | `test.dorfteich.cloud` | web 8100, api 8101 |
|
| Test | `/home/DOCKER/dorfteich-test/` | `test.dorfteich.cloud` | web 8100, api 8101, collab 8102 |
|
||||||
| Int | `/home/DOCKER/dorfteich-int/` | `int.dorfteich.cloud` | web 8110, api 8111 |
|
| Int | `/home/DOCKER/dorfteich-int/` | `int.dorfteich.cloud` | web 8110, api 8111, collab 8112 |
|
||||||
|
|
||||||
## 1. Stage directories **[root]**
|
## 1. Stage directories **[root]**
|
||||||
|
|
||||||
@ -26,7 +26,10 @@ each stage directory. Set per stage in `.env` (mode 600):
|
|||||||
|
|
||||||
- `POSTGRES_PASSWORD`: unique random value per stage
|
- `POSTGRES_PASSWORD`: unique random value per stage
|
||||||
- `COMPOSE_PROJECT_NAME`: `dorfteich-test` / `dorfteich-int`
|
- `COMPOSE_PROJECT_NAME`: `dorfteich-test` / `dorfteich-int`
|
||||||
- `WEB_PORT`/`API_PORT`: 8100/8101 (test), 8110/8111 (int)
|
- `WEB_PORT`/`API_PORT`/`COLLAB_PORT`: 8100/8101/8102 (test),
|
||||||
|
8110/8111/8112 (int). **`COLLAB_PORT` must be set per stage** — both
|
||||||
|
stacks share this host, so the compose default (8102) would make the
|
||||||
|
Int collab container collide with Test's; Int needs `COLLAB_PORT=8112`.
|
||||||
- `IMAGE_PREFIX=gitea.101010.cloud/stwaidele/dorfteich`
|
- `IMAGE_PREFIX=gitea.101010.cloud/stwaidele/dorfteich`
|
||||||
- `TAG`: managed by the CD pipeline (`<git-sha>` on test, `int` on int)
|
- `TAG`: managed by the CD pipeline (`<git-sha>` on test, `int` on int)
|
||||||
- `APP_BASE_URL`: `https://test.dorfteich.cloud` / `https://int.dorfteich.cloud`
|
- `APP_BASE_URL`: `https://test.dorfteich.cloud` / `https://int.dorfteich.cloud`
|
||||||
@ -62,7 +65,7 @@ test.dorfteich.cloud {
|
|||||||
reverse_proxy 127.0.0.1:8101
|
reverse_proxy 127.0.0.1:8101
|
||||||
}
|
}
|
||||||
handle /collab* {
|
handle /collab* {
|
||||||
reverse_proxy 127.0.0.1:8102 # collab service arrives with M3
|
reverse_proxy 127.0.0.1:8102 # Hocuspocus collab (issue #33); Caddy passes the WebSocket upgrade through automatically
|
||||||
}
|
}
|
||||||
handle {
|
handle {
|
||||||
reverse_proxy 127.0.0.1:8100
|
reverse_proxy 127.0.0.1:8100
|
||||||
@ -117,6 +120,7 @@ The pipeline pushes images to the Gitea container registry
|
|||||||
|
|
||||||
- [ ] `https://test.dorfteich.cloud/healthz` → `ok`
|
- [ ] `https://test.dorfteich.cloud/healthz` → `ok`
|
||||||
- [ ] `https://test.dorfteich.cloud/api/v1/readyz` → `{"status":"ok",…}`
|
- [ ] `https://test.dorfteich.cloud/api/v1/readyz` → `{"status":"ok",…}`
|
||||||
|
- [ ] `https://test.dorfteich.cloud/collab/healthz` → `{"status":"ok","service":"collab",…}`
|
||||||
- [ ] same for int
|
- [ ] same for int
|
||||||
- [ ] runner shows _online_ under Gitea → Settings → Actions → Runners
|
- [ ] runner shows _online_ under Gitea → Settings → Actions → Runners
|
||||||
- [ ] a test workflow run executes on the runner
|
- [ ] a test workflow run executes on the runner
|
||||||
|
|||||||
@ -35,6 +35,10 @@ host), routing:
|
|||||||
/media/ → api (permission-checked file streaming)
|
/media/ → api (permission-checked file streaming)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The proxy forwards `/collab` without stripping the prefix, so the collab
|
||||||
|
service answers its health probe at `/collab/healthz` externally and at
|
||||||
|
`/healthz` for the container-internal Docker healthcheck.
|
||||||
|
|
||||||
Self-hosters without a proxy can enable the optional `caddy` Compose profile
|
Self-hosters without a proxy can enable the optional `caddy` Compose profile
|
||||||
(bundled Caddy with automatic TLS).
|
(bundled Caddy with automatic TLS).
|
||||||
|
|
||||||
|
|||||||
@ -6,19 +6,25 @@ import { z } from 'zod';
|
|||||||
* and crashes with a readable list of problems instead of failing later at
|
* and crashes with a readable list of problems instead of failing later at
|
||||||
* first use.
|
* first use.
|
||||||
*/
|
*/
|
||||||
export const apiEnvSchema = z.object({
|
/** Fields every service configures the same way; keep these in sync. */
|
||||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
const nodeEnv = z.enum(['development', 'test', 'production']).default('development');
|
||||||
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
const logLevel = z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info');
|
||||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
|
||||||
/** Version shown in health responses; injected at image build time. */
|
/** Version shown in health responses; injected at image build time. */
|
||||||
APP_VERSION: z.string().default('0.0.0-dev'),
|
const appVersion = z.string().default('0.0.0-dev');
|
||||||
/** PostgreSQL connection string — required, there is no sensible default. */
|
/** PostgreSQL connection string — required, there is no sensible default. */
|
||||||
DATABASE_URL: z
|
const databaseUrl = z
|
||||||
.string()
|
.string()
|
||||||
.min(1)
|
.min(1)
|
||||||
.refine((url) => url.startsWith('postgresql://') || url.startsWith('postgres://'), {
|
.refine((url) => url.startsWith('postgresql://') || url.startsWith('postgres://'), {
|
||||||
message: 'must be a postgresql:// connection string',
|
message: 'must be a postgresql:// connection string',
|
||||||
}),
|
});
|
||||||
|
|
||||||
|
export const apiEnvSchema = z.object({
|
||||||
|
NODE_ENV: nodeEnv,
|
||||||
|
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
||||||
|
LOG_LEVEL: logLevel,
|
||||||
|
APP_VERSION: appVersion,
|
||||||
|
DATABASE_URL: databaseUrl,
|
||||||
/** Set to "false" to skip `prisma migrate deploy` at startup (tests, tooling). */
|
/** Set to "false" to skip `prisma migrate deploy` at startup (tests, tooling). */
|
||||||
MIGRATE_ON_START: z
|
MIGRATE_ON_START: z
|
||||||
.enum(['true', 'false'])
|
.enum(['true', 'false'])
|
||||||
@ -51,6 +57,21 @@ export const apiEnvSchema = z.object({
|
|||||||
|
|
||||||
export type ApiEnv = z.infer<typeof apiEnvSchema>;
|
export type ApiEnv = z.infer<typeof apiEnvSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for the collaboration server (Hocuspocus, ADR 0003). It is a
|
||||||
|
* thin real-time front-end to the same PostgreSQL database as the api; it does
|
||||||
|
* not run migrations (the api owns the schema) and needs no SMTP or uploads.
|
||||||
|
*/
|
||||||
|
export const collabEnvSchema = z.object({
|
||||||
|
NODE_ENV: nodeEnv,
|
||||||
|
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
||||||
|
LOG_LEVEL: logLevel,
|
||||||
|
APP_VERSION: appVersion,
|
||||||
|
DATABASE_URL: databaseUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CollabEnv = z.infer<typeof collabEnvSchema>;
|
||||||
|
|
||||||
export function parseEnv<Schema extends z.ZodTypeAny>(
|
export function parseEnv<Schema extends z.ZodTypeAny>(
|
||||||
schema: Schema,
|
schema: Schema,
|
||||||
env: Record<string, string | undefined>,
|
env: Record<string, string | undefined>,
|
||||||
|
|||||||
181
pnpm-lock.yaml
generated
181
pnpm-lock.yaml
generated
@ -143,7 +143,29 @@ importers:
|
|||||||
version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0)(tsx@4.23.0)
|
version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0)(tsx@4.23.0)
|
||||||
|
|
||||||
apps/collab:
|
apps/collab:
|
||||||
|
dependencies:
|
||||||
|
'@dorfteich/shared':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/shared
|
||||||
|
'@hocuspocus/server':
|
||||||
|
specifier: ^4.3.0
|
||||||
|
version: 4.3.0(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31)
|
||||||
|
pg:
|
||||||
|
specifier: ^8.16.0
|
||||||
|
version: 8.22.0
|
||||||
|
pino:
|
||||||
|
specifier: ^9.6.0
|
||||||
|
version: 9.14.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^26.1.0
|
||||||
|
version: 26.1.0
|
||||||
|
'@types/pg':
|
||||||
|
specifier: ^8.11.0
|
||||||
|
version: 8.20.0
|
||||||
|
tsx:
|
||||||
|
specifier: ^4.19.0
|
||||||
|
version: 4.23.0
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0)(tsx@4.23.0)
|
version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0)(tsx@4.23.0)
|
||||||
@ -946,6 +968,16 @@ packages:
|
|||||||
'@floating-ui/utils@0.2.11':
|
'@floating-ui/utils@0.2.11':
|
||||||
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
|
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
|
||||||
|
|
||||||
|
'@hocuspocus/common@4.3.0':
|
||||||
|
resolution: {integrity: sha512-8USsMvso01aMKOSSyvb8oTAlfES/nBM+Y74XjW5Fy5ovmOaVpoRRBrktIrEVwydrp8Cr6C66ivc0orcW/3P+Kg==}
|
||||||
|
|
||||||
|
'@hocuspocus/server@4.3.0':
|
||||||
|
resolution: {integrity: sha512-GX9ohUJAr6aIa2ewS0EPeyf3w12wLCBBSGfc76ZMOuZJZPUzDH6BJWFjkVlaVW3OmwZ5+jxdXXFUf6sAi4EIrg==}
|
||||||
|
engines: {node: '>=22'}
|
||||||
|
peerDependencies:
|
||||||
|
y-protocols: ^1.0.6
|
||||||
|
yjs: ^13.6.8
|
||||||
|
|
||||||
'@hookform/resolvers@5.4.0':
|
'@hookform/resolvers@5.4.0':
|
||||||
resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==}
|
resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -1645,6 +1677,9 @@ packages:
|
|||||||
'@types/nodemailer@8.0.1':
|
'@types/nodemailer@8.0.1':
|
||||||
resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==}
|
resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==}
|
||||||
|
|
||||||
|
'@types/pg@8.20.0':
|
||||||
|
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
|
||||||
|
|
||||||
'@types/qs@6.15.1':
|
'@types/qs@6.15.1':
|
||||||
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
|
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
|
||||||
|
|
||||||
@ -1917,6 +1952,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
async-mutex@0.5.0:
|
||||||
|
resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==}
|
||||||
|
|
||||||
asynckit@0.4.0:
|
asynckit@0.4.0:
|
||||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||||
|
|
||||||
@ -2158,6 +2196,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
crossws@0.4.9:
|
||||||
|
resolution: {integrity: sha512-iWx+1OMSG2aOHpjyf9AESOzkwsVdS49cXM9dVrI2PDhxU5l2RIWE/KG56gk4BbAnsMoycvniJ9OnOxO9LRzHVA==}
|
||||||
|
peerDependencies:
|
||||||
|
srvx: '>=0.11.5'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
srvx:
|
||||||
|
optional: true
|
||||||
|
|
||||||
cssstyle@4.6.0:
|
cssstyle@4.6.0:
|
||||||
resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
|
resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@ -2740,6 +2786,10 @@ packages:
|
|||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||||
|
|
||||||
|
kleur@4.1.5:
|
||||||
|
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
levn@0.4.1:
|
levn@0.4.1:
|
||||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@ -3040,6 +3090,40 @@ packages:
|
|||||||
perfect-debounce@1.0.0:
|
perfect-debounce@1.0.0:
|
||||||
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
||||||
|
|
||||||
|
pg-cloudflare@1.4.0:
|
||||||
|
resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==}
|
||||||
|
|
||||||
|
pg-connection-string@2.14.0:
|
||||||
|
resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==}
|
||||||
|
|
||||||
|
pg-int8@1.0.1:
|
||||||
|
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
|
||||||
|
engines: {node: '>=4.0.0'}
|
||||||
|
|
||||||
|
pg-pool@3.14.0:
|
||||||
|
resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==}
|
||||||
|
peerDependencies:
|
||||||
|
pg: '>=8.0'
|
||||||
|
|
||||||
|
pg-protocol@1.15.0:
|
||||||
|
resolution: {integrity: sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==}
|
||||||
|
|
||||||
|
pg-types@2.2.0:
|
||||||
|
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
pg@8.22.0:
|
||||||
|
resolution: {integrity: sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==}
|
||||||
|
engines: {node: '>= 16.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
pg-native: '>=3.0.1'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
pg-native:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
pgpass@1.0.5:
|
||||||
|
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
|
||||||
|
|
||||||
picocolors@1.1.1:
|
picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
@ -3117,6 +3201,22 @@ packages:
|
|||||||
resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==}
|
resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
postgres-array@2.0.0:
|
||||||
|
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
postgres-bytea@1.0.1:
|
||||||
|
resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
postgres-date@1.0.7:
|
||||||
|
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
postgres-interval@1.2.0:
|
||||||
|
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
prelude-ls@1.2.1:
|
prelude-ls@1.2.1:
|
||||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@ -3954,6 +4054,10 @@ packages:
|
|||||||
xmlchars@2.2.0:
|
xmlchars@2.2.0:
|
||||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||||
|
|
||||||
|
xtend@4.0.2:
|
||||||
|
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||||
|
engines: {node: '>=0.4'}
|
||||||
|
|
||||||
y-prosemirror@1.3.7:
|
y-prosemirror@1.3.7:
|
||||||
resolution: {integrity: sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==}
|
resolution: {integrity: sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==}
|
||||||
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
|
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
|
||||||
@ -4491,6 +4595,22 @@ snapshots:
|
|||||||
|
|
||||||
'@floating-ui/utils@0.2.11': {}
|
'@floating-ui/utils@0.2.11': {}
|
||||||
|
|
||||||
|
'@hocuspocus/common@4.3.0':
|
||||||
|
dependencies:
|
||||||
|
lib0: 0.2.117
|
||||||
|
|
||||||
|
'@hocuspocus/server@4.3.0(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31)':
|
||||||
|
dependencies:
|
||||||
|
'@hocuspocus/common': 4.3.0
|
||||||
|
async-mutex: 0.5.0
|
||||||
|
crossws: 0.4.9
|
||||||
|
kleur: 4.1.5
|
||||||
|
lib0: 0.2.117
|
||||||
|
y-protocols: 1.0.7(yjs@13.6.31)
|
||||||
|
yjs: 13.6.31
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- srvx
|
||||||
|
|
||||||
'@hookform/resolvers@5.4.0(react-hook-form@7.80.0(react@19.2.7))':
|
'@hookform/resolvers@5.4.0(react-hook-form@7.80.0(react@19.2.7))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@standard-schema/utils': 0.3.0
|
'@standard-schema/utils': 0.3.0
|
||||||
@ -5149,6 +5269,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 26.1.0
|
'@types/node': 26.1.0
|
||||||
|
|
||||||
|
'@types/pg@8.20.0':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 26.1.0
|
||||||
|
pg-protocol: 1.15.0
|
||||||
|
pg-types: 2.2.0
|
||||||
|
|
||||||
'@types/qs@6.15.1': {}
|
'@types/qs@6.15.1': {}
|
||||||
|
|
||||||
'@types/range-parser@1.2.7': {}
|
'@types/range-parser@1.2.7': {}
|
||||||
@ -5493,6 +5619,10 @@ snapshots:
|
|||||||
|
|
||||||
assertion-error@2.0.1: {}
|
assertion-error@2.0.1: {}
|
||||||
|
|
||||||
|
async-mutex@0.5.0:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
asynckit@0.4.0: {}
|
asynckit@0.4.0: {}
|
||||||
|
|
||||||
atomic-sleep@1.0.0: {}
|
atomic-sleep@1.0.0: {}
|
||||||
@ -5722,6 +5852,8 @@ snapshots:
|
|||||||
shebang-command: 2.0.0
|
shebang-command: 2.0.0
|
||||||
which: 2.0.2
|
which: 2.0.2
|
||||||
|
|
||||||
|
crossws@0.4.9: {}
|
||||||
|
|
||||||
cssstyle@4.6.0:
|
cssstyle@4.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@asamuzakjp/css-color': 3.2.0
|
'@asamuzakjp/css-color': 3.2.0
|
||||||
@ -6387,6 +6519,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
|
|
||||||
|
kleur@4.1.5: {}
|
||||||
|
|
||||||
levn@0.4.1:
|
levn@0.4.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
@ -6646,6 +6780,41 @@ snapshots:
|
|||||||
|
|
||||||
perfect-debounce@1.0.0: {}
|
perfect-debounce@1.0.0: {}
|
||||||
|
|
||||||
|
pg-cloudflare@1.4.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
pg-connection-string@2.14.0: {}
|
||||||
|
|
||||||
|
pg-int8@1.0.1: {}
|
||||||
|
|
||||||
|
pg-pool@3.14.0(pg@8.22.0):
|
||||||
|
dependencies:
|
||||||
|
pg: 8.22.0
|
||||||
|
|
||||||
|
pg-protocol@1.15.0: {}
|
||||||
|
|
||||||
|
pg-types@2.2.0:
|
||||||
|
dependencies:
|
||||||
|
pg-int8: 1.0.1
|
||||||
|
postgres-array: 2.0.0
|
||||||
|
postgres-bytea: 1.0.1
|
||||||
|
postgres-date: 1.0.7
|
||||||
|
postgres-interval: 1.2.0
|
||||||
|
|
||||||
|
pg@8.22.0:
|
||||||
|
dependencies:
|
||||||
|
pg-connection-string: 2.14.0
|
||||||
|
pg-pool: 3.14.0(pg@8.22.0)
|
||||||
|
pg-protocol: 1.15.0
|
||||||
|
pg-types: 2.2.0
|
||||||
|
pgpass: 1.0.5
|
||||||
|
optionalDependencies:
|
||||||
|
pg-cloudflare: 1.4.0
|
||||||
|
|
||||||
|
pgpass@1.0.5:
|
||||||
|
dependencies:
|
||||||
|
split2: 4.2.0
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
picomatch@4.0.4: {}
|
picomatch@4.0.4: {}
|
||||||
@ -6737,6 +6906,16 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
postgres-array@2.0.0: {}
|
||||||
|
|
||||||
|
postgres-bytea@1.0.1: {}
|
||||||
|
|
||||||
|
postgres-date@1.0.7: {}
|
||||||
|
|
||||||
|
postgres-interval@1.2.0:
|
||||||
|
dependencies:
|
||||||
|
xtend: 4.0.2
|
||||||
|
|
||||||
prelude-ls@1.2.1: {}
|
prelude-ls@1.2.1: {}
|
||||||
|
|
||||||
prettier@3.9.4: {}
|
prettier@3.9.4: {}
|
||||||
@ -7583,6 +7762,8 @@ snapshots:
|
|||||||
|
|
||||||
xmlchars@2.2.0: {}
|
xmlchars@2.2.0: {}
|
||||||
|
|
||||||
|
xtend@4.0.2: {}
|
||||||
|
|
||||||
y-prosemirror@1.3.7(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.42.0)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31):
|
y-prosemirror@1.3.7(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.42.0)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31):
|
||||||
dependencies:
|
dependencies:
|
||||||
lib0: 0.2.117
|
lib0: 0.2.117
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user