Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
# Hocuspocus realtime-collaboration server image.
#
# Built from the monorepo root (build context = repository root) so the
# Docker layer cache can reuse pnpm install layers shared with the api /
# worker / web images. Final image is `node:20-bookworm-slim` running
# `apps/hocuspocus/dist/index.js` on port 1234.
FROM --platform=linux/amd64 node:20-bookworm-slim AS base
ENV NODE_ENV="production"
ENV CI="true"
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl curl \
&& rm -rf /var/lib/apt/lists/* \
&& corepack enable \
&& corepack prepare pnpm@10.33.0 --activate
FROM base AS deps
WORKDIR /myapp
ADD package.json pnpm-lock.yaml pnpm-workspace.yaml ./
ADD prisma prisma
ADD apps apps
ADD packages packages
RUN pnpm install --frozen-lockfile --prod=false
FROM base AS production-deps
WORKDIR /myapp
COPY --from=deps /myapp/node_modules /myapp/node_modules
ADD package.json pnpm-lock.yaml pnpm-workspace.yaml ./
ADD apps apps
ADD packages packages
RUN pnpm prune --prod
FROM base AS build
WORKDIR /myapp
COPY --from=deps /myapp/node_modules /myapp/node_modules
COPY --from=deps /myapp/apps /myapp/apps
COPY --from=deps /myapp/packages /myapp/packages
ADD package.json pnpm-lock.yaml pnpm-workspace.yaml ./
ADD prisma prisma
ADD prisma.config.ts prisma.config.ts
ADD tsconfig.json tsconfig.json
ADD packages/db packages/db
RUN pnpm exec prisma generate
RUN pnpm --filter @toolctrl/db build
ADD . .
RUN pnpm --filter @toolctrl/hocuspocus build
FROM base AS hocuspocus
ARG APP_REVISION=unknown
ENV NODE_ENV="production"
ENV HOCUSPOCUS_PORT="1234"
ENV HOCUSPOCUS_HOST="0.0.0.0"
ENV APP_REVISION="${APP_REVISION}"
LABEL org.opencontainers.image.revision="${APP_REVISION}"
EXPOSE 1234
WORKDIR /myapp
COPY --from=production-deps /myapp/node_modules /myapp/node_modules
COPY --from=build /myapp/package.json /myapp/package.json
COPY --from=build /myapp/pnpm-lock.yaml /myapp/pnpm-lock.yaml
COPY --from=build /myapp/pnpm-workspace.yaml /myapp/pnpm-workspace.yaml
COPY --from=build /myapp/tsconfig.json /myapp/tsconfig.json
COPY --from=build /myapp/apps/hocuspocus/package.json /myapp/apps/hocuspocus/package.json
COPY --from=deps /myapp/apps/hocuspocus/node_modules /myapp/apps/hocuspocus/node_modules
COPY --from=build /myapp/apps/hocuspocus/dist /myapp/apps/hocuspocus/dist
COPY --from=build /myapp/packages/db /myapp/packages/db
COPY --from=deps /myapp/packages/db/node_modules /myapp/packages/db/node_modules
COPY --from=build /myapp/packages/db/generated/prisma/client /myapp/packages/db/generated/prisma/client
COPY --from=build /myapp/packages/logger /myapp/packages/logger
COPY --from=deps /myapp/packages/logger/node_modules /myapp/packages/logger/node_modules
COPY --from=build /myapp/prisma /myapp/prisma
COPY --from=build /myapp/prisma.config.ts /myapp/prisma.config.ts
USER node
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD node -e "require('net').connect(${HOCUSPOCUS_PORT:-1234}, '127.0.0.1').on('connect', () => process.exit(0)).on('error', () => process.exit(1))"
CMD ["node", "apps/hocuspocus/dist/index.js"]
@@ -0,0 +1,31 @@
{
"name": "@toolctrl/hocuspocus",
"version": "0.0.0",
"private": true,
"type": "module",
"sideEffects": false,
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"build": "node scripts/build.mjs",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run src"
},
"dependencies": {
"@hocuspocus/extension-database": "4.0.0",
"@hocuspocus/extension-logger": "4.0.0",
"@hocuspocus/server": "4.0.0",
"@toolctrl/db": "workspace:*",
"@toolctrl/logger": "workspace:*",
"jsonwebtoken": "^9.0.0",
"y-protocols": "^1.0.6",
"yjs": "13.6.30"
},
"devDependencies": {
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^25.0.0",
"tsx": "^4.6.2",
"typescript": "^5.3.2",
"vitest": "^3.2.4"
}
}
@@ -0,0 +1,72 @@
import { mkdir } from "node:fs/promises";
import { builtinModules } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { build } from "esbuild";
const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const entryPoint = path.join(appDir, "src/index.ts");
const outputFile = path.join(appDir, "dist/index.js");
const nodeBuiltins = new Set([...builtinModules, ...builtinModules.map((moduleName) => `node:${moduleName}`)]);
const externalizeRuntimeDependencies = {
name: "externalize-runtime-dependencies",
setup(buildContext) {
buildContext.onResolve({ filter: /.*/ }, (args) => {
if (nodeBuiltins.has(args.path)) {
return { path: args.path, external: true };
}
if (isExternalRuntimePackage(args.path)) {
return { path: args.path, external: true };
}
return undefined;
});
},
};
await mkdir(path.dirname(outputFile), { recursive: true });
await build({
entryPoints: [entryPoint],
outfile: outputFile,
bundle: true,
platform: "node",
format: "esm",
target: "node20",
sourcemap: true,
banner: {
js: 'import { createRequire as __toolctrlCreateRequire } from "node:module"; const require = __toolctrlCreateRequire(import.meta.url);',
},
plugins: [externalizeRuntimeDependencies],
logLevel: "warning",
});
function isBarePackageSpecifier(specifier) {
return !specifier.startsWith(".") && !specifier.startsWith("/") && !specifier.startsWith("#");
}
function isExternalRuntimePackage(specifier) {
if (!isBarePackageSpecifier(specifier)) {
return false;
}
// Hocuspocus + Y.js + Prisma + native pino are required at runtime
// and never bundled — they ship as part of node_modules.
return (
specifier.startsWith("@hocuspocus/") ||
specifier === "yjs" ||
specifier.startsWith("yjs/") ||
specifier === "y-protocols" ||
specifier.startsWith("y-protocols/") ||
specifier === "jsonwebtoken" ||
specifier.startsWith("jsonwebtoken/") ||
specifier === "@prisma/client" ||
specifier.startsWith("@prisma/client/") ||
specifier === "@prisma/adapter-pg" ||
specifier.startsWith("@prisma/adapter-pg/") ||
specifier === "pino" ||
specifier.startsWith("pino/") ||
specifier === "ws" ||
specifier === "lib0" ||
specifier.startsWith("lib0/")
);
}
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { getHocuspocusRuntimeConfig } from "./config.js";
describe("getHocuspocusRuntimeConfig", () => {
it("uses production-safe defaults when optional env is absent", () => {
expect(getHocuspocusRuntimeConfig({})).toEqual({
port: 1234,
host: "0.0.0.0",
authSecret: null,
maxPayloadBytes: 10 * 1024 * 1024,
});
});
it("prefers Hocuspocus-specific env over generic process settings", () => {
expect(
getHocuspocusRuntimeConfig({
HOCUSPOCUS_PORT: "4321",
HOCUSPOCUS_HOST: " 127.0.0.1 ",
HOCUSPOCUS_AUTH_SECRET: " hocuspocus-secret ",
HOCUSPOCUS_SECRET: "legacy-secret",
HOCUSPOCUS_MAX_PAYLOAD_BYTES: "2048",
PORT: "9999",
})
).toEqual({
port: 4321,
host: "127.0.0.1",
authSecret: "hocuspocus-secret",
maxPayloadBytes: 2048,
});
});
it("falls back to legacy auth secret and generic port", () => {
expect(
getHocuspocusRuntimeConfig({
PORT: "2345",
HOCUSPOCUS_SECRET: " legacy-secret ",
})
).toEqual({
port: 2345,
host: "0.0.0.0",
authSecret: "legacy-secret",
maxPayloadBytes: 10 * 1024 * 1024,
});
});
it("treats blank host and blank secrets as absent", () => {
expect(
getHocuspocusRuntimeConfig({
HOCUSPOCUS_HOST: " ",
HOCUSPOCUS_AUTH_SECRET: " ",
HOCUSPOCUS_SECRET: " ",
})
).toMatchObject({
host: "0.0.0.0",
authSecret: null,
});
});
});
@@ -0,0 +1,20 @@
/**
* Runtime configuration for the Hocuspocus realtime-collaboration server.
*
* Reads from env on boot; never references env after init so unit tests can
* override via the optional argument.
*/
export type HocuspocusRuntimeConfig = {
readonly port: number;
readonly host: string;
readonly authSecret: string | null;
readonly maxPayloadBytes: number;
};
export function getHocuspocusRuntimeConfig(env: NodeJS.ProcessEnv = process.env): HocuspocusRuntimeConfig {
const port = Number(env.HOCUSPOCUS_PORT ?? env.PORT ?? 1234);
const host = env.HOCUSPOCUS_HOST?.trim() || "0.0.0.0";
const authSecret = env.HOCUSPOCUS_AUTH_SECRET?.trim() || env.HOCUSPOCUS_SECRET?.trim() || null;
const maxPayloadBytes = Number(env.HOCUSPOCUS_MAX_PAYLOAD_BYTES ?? 10 * 1024 * 1024);
return Object.freeze({ port, host, authSecret, maxPayloadBytes });
}
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import jwt from "jsonwebtoken";
import { parseDocumentName, verifyCollabToken } from "./auth.js";
const SECRET = "test-secret-please-rotate-in-prod-min-32-bytes";
function signToken(claims: Record<string, unknown>, expiresInSeconds = 60): string {
return jwt.sign(claims, SECRET, { algorithm: "HS256", expiresIn: expiresInSeconds });
}
describe("parseDocumentName", () => {
it("splits a well-formed name into three parts", () => {
expect(parseDocumentName("tenant_abc:kb-article:art_123")).toEqual({
tenantId: "tenant_abc",
resourceType: "kb-article",
resourceId: "art_123",
});
});
it("preserves colons inside the resourceId", () => {
expect(parseDocumentName("t:kb:foo:bar:baz")).toEqual({
tenantId: "t",
resourceType: "kb",
resourceId: "foo:bar:baz",
});
});
it("returns null for malformed names", () => {
expect(parseDocumentName("")).toBeNull();
expect(parseDocumentName("tenant_only")).toBeNull();
expect(parseDocumentName("tenant:type")).toBeNull();
expect(parseDocumentName(":kb:art")).toBeNull();
});
});
describe("verifyCollabToken", () => {
const docName = "tenant_abc:kb-article:art_123";
it("accepts a valid token whose claims match the document name", () => {
const token = signToken({ userId: "user_1", tenantId: "tenant_abc", resourceType: "kb-article", resourceId: "art_123" });
const ctx = verifyCollabToken(token, docName, { secret: SECRET });
expect(ctx).toEqual({ userId: "user_1", tenantId: "tenant_abc", resourceType: "kb-article", resourceId: "art_123" });
});
it("accepts `sub` as a userId fallback", () => {
const token = signToken({ sub: "user_2", tenantId: "tenant_abc", resourceType: "kb-article", resourceId: "art_123" });
expect(verifyCollabToken(token, docName, { secret: SECRET })?.userId).toBe("user_2");
});
it("refuses tokens signed with a different secret", () => {
const token = jwt.sign({ userId: "u", tenantId: "tenant_abc", resourceType: "kb-article", resourceId: "art_123" }, "other-secret", { algorithm: "HS256" });
expect(verifyCollabToken(token, docName, { secret: SECRET })).toBeNull();
});
it("refuses expired tokens", () => {
const token = signToken({ userId: "u", tenantId: "tenant_abc", resourceType: "kb-article", resourceId: "art_123" }, -120);
expect(verifyCollabToken(token, docName, { secret: SECRET, clockTolerance: 0 })).toBeNull();
});
it("refuses cross-tenant token reuse", () => {
const token = signToken({ userId: "u", tenantId: "OTHER", resourceType: "kb-article", resourceId: "art_123" });
expect(verifyCollabToken(token, docName, { secret: SECRET })).toBeNull();
});
it("refuses token whose resourceType differs from the document", () => {
const token = signToken({ userId: "u", tenantId: "tenant_abc", resourceType: "crm-note", resourceId: "art_123" });
expect(verifyCollabToken(token, docName, { secret: SECRET })).toBeNull();
});
it("refuses token whose resourceId differs from the document", () => {
const token = signToken({ userId: "u", tenantId: "tenant_abc", resourceType: "kb-article", resourceId: "other" });
expect(verifyCollabToken(token, docName, { secret: SECRET })).toBeNull();
});
it("refuses tokens with no userId / sub claim", () => {
const token = signToken({ tenantId: "tenant_abc", resourceType: "kb-article", resourceId: "art_123" });
expect(verifyCollabToken(token, docName, { secret: SECRET })).toBeNull();
});
it("refuses missing or empty token", () => {
expect(verifyCollabToken("", docName, { secret: SECRET })).toBeNull();
});
});
@@ -0,0 +1,119 @@
/**
* Hocuspocus authentication hook.
*
* Verifies a short-lived JWT issued by the web app's collab-token endpoint.
* Claims:
* { sub: userId, tenantId, resourceType, resourceId, exp }
*
* Refuses the connection (Hocuspocus closes the WebSocket with a 403-style
* close-code) when:
* - HOCUSPOCUS_AUTH_SECRET is unset on the server side
* - the JWT is missing, malformed, expired, or signed with the wrong secret
* - the JWT's tenantId / resourceType / resourceId do not match the
* documentName (`${tenantId}:${resourceType}:${resourceId}`) the client
* is requesting — defence-in-depth against cross-tenant doc access.
*
* Returns a context object stored on the Hocuspocus connection: persistence
* + permission-revoke handlers downstream pull tenantId from this context.
*
* Refs: docs/improvement-plan/05-business-features/realtime-collab.md
*/
import { Extension, onAuthenticatePayload } from "@hocuspocus/server";
import jwt from "jsonwebtoken";
export type HocuspocusConnectionContext = {
readonly userId: string;
readonly tenantId: string;
readonly resourceType: string;
readonly resourceId: string;
};
export type AuthExtensionOptions = {
readonly secret: string;
/** Optional clock-skew tolerance in seconds; defaults to 30s. */
readonly clockTolerance?: number;
};
/**
* Parse `tenantId:resourceType:resourceId` document-name into its parts.
* Returns `null` for malformed names — caller refuses the connection.
*/
export function parseDocumentName(documentName: string): {
tenantId: string;
resourceType: string;
resourceId: string;
} | null {
if (typeof documentName !== "string" || documentName.length === 0) return null;
// resourceId may itself contain `:` (e.g. namespaced slugs); split into 3 parts only.
const parts = documentName.split(":");
if (parts.length < 3) return null;
const [tenantId, resourceType, ...rest] = parts;
const resourceId = rest.join(":");
if (!tenantId || !resourceType || !resourceId) return null;
return { tenantId, resourceType, resourceId };
}
type TokenClaims = {
sub?: string;
userId?: string;
tenantId?: string;
resourceType?: string;
resourceId?: string;
};
/**
* Verify the JWT + check claim/document name parity. Returns the connection
* context on success or `null` on any failure (caller throws to refuse).
*/
export function verifyCollabToken(
token: string,
documentName: string,
options: AuthExtensionOptions,
): HocuspocusConnectionContext | null {
if (!token) return null;
const parsed = parseDocumentName(documentName);
if (!parsed) return null;
let claims: TokenClaims;
try {
claims = jwt.verify(token, options.secret, {
algorithms: ["HS256"],
clockTolerance: options.clockTolerance ?? 30,
}) as TokenClaims;
} catch {
return null;
}
const userId = claims.userId ?? claims.sub;
if (!userId || typeof userId !== "string") return null;
if (claims.tenantId !== parsed.tenantId) return null;
if (claims.resourceType !== parsed.resourceType) return null;
if (claims.resourceId !== parsed.resourceId) return null;
return {
userId,
tenantId: parsed.tenantId,
resourceType: parsed.resourceType,
resourceId: parsed.resourceId,
};
}
/**
* Hocuspocus extension hook. Wired in `apps/hocuspocus/src/index.ts`.
*
* When `secret` is empty/null, the extension refuses every connection — this
* is the safe default if HOCUSPOCUS_AUTH_SECRET is unset on the server side
* (mis-configured prod deploy should not silently fall through to anonymous
* collaboration).
*/
export function createAuthExtension(options: AuthExtensionOptions): Extension {
return {
async onAuthenticate(data: onAuthenticatePayload) {
const context = verifyCollabToken(data.token, data.documentName, options);
if (!context) {
throw new Error("hocuspocus: authentication refused");
}
return context satisfies HocuspocusConnectionContext;
},
} as Extension;
}
@@ -0,0 +1,125 @@
import { describe, expect, it, vi } from "vitest";
import { fetchCollabDoc, storeCollabDoc, type DatabaseExtensionDeps } from "./database.js";
type StubCollabRow = {
tenantId: string;
resourceType: string;
resourceId: string;
ydocState: Buffer;
version: number;
lastUpdatedAt: Date;
};
function createStubDb() {
const rows = new Map<string, StubCollabRow>();
const findUnique = vi.fn(async ({ where }: { where: { tenantId_resourceType_resourceId: { tenantId: string; resourceType: string; resourceId: string } }; select?: unknown }) => {
const { tenantId, resourceType, resourceId } = where.tenantId_resourceType_resourceId;
const key = `${tenantId}:${resourceType}:${resourceId}`;
const row = rows.get(key);
return row ? { ydocState: row.ydocState } : null;
});
type UpsertArgs = {
where: { tenantId_resourceType_resourceId: { tenantId: string; resourceType: string; resourceId: string } };
create: StubCollabRow;
update: { ydocState: Buffer; version: { increment: number }; lastUpdatedAt: Date };
};
const upsert = vi.fn(async ({ where, create, update }: UpsertArgs) => {
const { tenantId, resourceType, resourceId } = where.tenantId_resourceType_resourceId;
const key = `${tenantId}:${resourceType}:${resourceId}`;
const existing = rows.get(key);
if (existing) {
existing.ydocState = update.ydocState;
existing.version += update.version.increment;
existing.lastUpdatedAt = update.lastUpdatedAt;
return existing;
}
rows.set(key, { ...create });
return create;
});
return {
db: { collabDoc: { findUnique, upsert } },
rows,
};
}
function buildDeps(): { deps: DatabaseExtensionDeps; rows: Map<string, StubCollabRow> } {
const stub = createStubDb();
return {
rows: stub.rows,
deps: {
db: stub.db as unknown as DatabaseExtensionDeps["db"],
maxPayloadBytes: 1024,
clock: () => new Date("2026-05-17T12:00:00Z"),
},
};
}
describe("fetchCollabDoc", () => {
it("returns null when no row exists for the document", async () => {
const { deps } = buildDeps();
const result = await fetchCollabDoc("t:kb-article:art_1", deps);
expect(result).toBeNull();
});
it("returns null for malformed document names", async () => {
const { deps } = buildDeps();
expect(await fetchCollabDoc("malformed", deps)).toBeNull();
});
it("returns a Uint8Array when the row exists", async () => {
const { deps, rows } = buildDeps();
rows.set("t:kb-article:art_1", {
tenantId: "t",
resourceType: "kb-article",
resourceId: "art_1",
ydocState: Buffer.from([1, 2, 3, 4]),
version: 7,
lastUpdatedAt: new Date(),
});
const result = await fetchCollabDoc("t:kb-article:art_1", deps);
expect(result).toBeInstanceOf(Uint8Array);
expect(Array.from(result ?? [])).toEqual([1, 2, 3, 4]);
});
});
describe("storeCollabDoc", () => {
it("inserts a new row on first store", async () => {
const { deps, rows } = buildDeps();
await storeCollabDoc("t:kb-article:art_1", new Uint8Array([9, 9, 9]), deps);
const row = rows.get("t:kb-article:art_1");
expect(row).toBeDefined();
expect(row?.version).toBe(1);
expect(Array.from(row?.ydocState ?? new Uint8Array())).toEqual([9, 9, 9]);
});
it("increments version on subsequent stores", async () => {
const { deps, rows } = buildDeps();
await storeCollabDoc("t:kb-article:art_1", new Uint8Array([1]), deps);
await storeCollabDoc("t:kb-article:art_1", new Uint8Array([2]), deps);
await storeCollabDoc("t:kb-article:art_1", new Uint8Array([3]), deps);
expect(rows.get("t:kb-article:art_1")?.version).toBe(3);
expect(Array.from(rows.get("t:kb-article:art_1")?.ydocState ?? new Uint8Array())).toEqual([3]);
});
it("refuses payloads exceeding maxPayloadBytes", async () => {
const { deps } = buildDeps();
const large = new Uint8Array(2048);
await expect(storeCollabDoc("t:kb:art_1", large, deps)).rejects.toThrow(/exceeds HOCUSPOCUS_MAX_PAYLOAD_BYTES/);
});
it("is a no-op for malformed document names", async () => {
const { deps, rows } = buildDeps();
await storeCollabDoc("malformed", new Uint8Array([1]), deps);
expect(rows.size).toBe(0);
});
it("isolates tenants — same resourceType/Id under different tenant gets its own row", async () => {
const { deps, rows } = buildDeps();
await storeCollabDoc("tenant_a:kb:art_1", new Uint8Array([1]), deps);
await storeCollabDoc("tenant_b:kb:art_1", new Uint8Array([2]), deps);
expect(rows.size).toBe(2);
expect(Array.from(rows.get("tenant_a:kb:art_1")?.ydocState ?? new Uint8Array())).toEqual([1]);
expect(Array.from(rows.get("tenant_b:kb:art_1")?.ydocState ?? new Uint8Array())).toEqual([2]);
});
});
@@ -0,0 +1,103 @@
/**
* Hocuspocus database extension persisting Y.js docs to Postgres
* (`CollabDoc` table).
*
* `fetch({ documentName })` → Loads BLOB from Postgres on connect; returns
* `null` when no row exists (Hocuspocus then
* initialises a fresh in-memory Y.Doc).
* `store({ documentName, state })` → Upserts the BLOB, bumps `version`,
* refreshes `lastUpdatedAt`. Debounced by
* Hocuspocus per the server-level
* `debounce` / `maxDebounce` settings.
*
* The DocumentName is parsed via `parseDocumentName()` (see auth.ts) and is
* authoritative for the `(tenantId, resourceType, resourceId)` triple. Auth
* has already verified that the connection has access to this triple; here
* we just persist the bytes.
*
* Refs: docs/improvement-plan/05-business-features/realtime-collab.md
*/
import { Database } from "@hocuspocus/extension-database";
import type { Extension } from "@hocuspocus/server";
import type { DbClient } from "@toolctrl/db/client.server";
import { parseDocumentName } from "./auth.js";
export type DatabaseExtensionDeps = {
/** Prisma client (write-able). */
readonly db: Pick<DbClient, "collabDoc">;
/** Maximum bytes we will store per doc — protects from runaway state-bloat. */
readonly maxPayloadBytes: number;
/**
* Optional clock hook for deterministic tests. Defaults to `() => new Date()`.
*/
readonly clock?: () => Date;
};
/**
* Internal `fetch` implementation — exported for unit-testing.
*/
export async function fetchCollabDoc(documentName: string, deps: DatabaseExtensionDeps): Promise<Uint8Array | null> {
const parsed = parseDocumentName(documentName);
if (!parsed) return null;
const row = await deps.db.collabDoc.findUnique({
where: {
tenantId_resourceType_resourceId: {
tenantId: parsed.tenantId,
resourceType: parsed.resourceType,
resourceId: parsed.resourceId,
},
},
select: { ydocState: true },
});
if (!row) return null;
// Prisma returns `Buffer` for Bytes columns — wrap to Uint8Array for Y.js.
return new Uint8Array(row.ydocState);
}
/**
* Internal `store` implementation — exported for unit-testing.
*/
export async function storeCollabDoc(documentName: string, state: Uint8Array, deps: DatabaseExtensionDeps): Promise<void> {
const parsed = parseDocumentName(documentName);
if (!parsed) return;
if (state.byteLength > deps.maxPayloadBytes) {
throw new Error(
`hocuspocus: refusing to persist Y.js state of ${state.byteLength} bytes — exceeds HOCUSPOCUS_MAX_PAYLOAD_BYTES=${deps.maxPayloadBytes}`,
);
}
const now = (deps.clock ?? (() => new Date()))();
const buffer = Buffer.from(state);
await deps.db.collabDoc.upsert({
where: {
tenantId_resourceType_resourceId: {
tenantId: parsed.tenantId,
resourceType: parsed.resourceType,
resourceId: parsed.resourceId,
},
},
create: {
tenantId: parsed.tenantId,
resourceType: parsed.resourceType,
resourceId: parsed.resourceId,
ydocState: buffer,
version: 1,
lastUpdatedAt: now,
},
update: {
ydocState: buffer,
version: { increment: 1 },
lastUpdatedAt: now,
},
});
}
/**
* Build the Hocuspocus database extension wired to the Prisma client.
*/
export function createDatabaseExtension(deps: DatabaseExtensionDeps): Extension {
return new Database({
fetch: async ({ documentName }) => fetchCollabDoc(documentName, deps),
store: async ({ documentName, state }) => storeCollabDoc(documentName, state, deps),
}) as unknown as Extension;
}
@@ -0,0 +1,145 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
const listen = vi.fn(async () => undefined);
class MockServer {
static instances: MockServer[] = [];
readonly options: unknown;
readonly listen = listen;
constructor(options: unknown) {
this.options = options;
MockServer.instances.push(this);
}
}
class MockLogger {
readonly options: unknown;
constructor(options: unknown) {
this.options = options;
}
}
return {
MockLogger,
MockServer,
authExtension: { name: "auth-extension" },
createAuthExtension: vi.fn(),
createDatabaseExtension: vi.fn(),
databaseExtension: { name: "database-extension" },
dbClient: { collabDoc: {} },
getHocuspocusRuntimeConfig: vi.fn(),
getWriteDbClient: vi.fn(),
hocuspocusLogger: {
error: vi.fn(),
info: vi.fn(),
},
listen,
};
});
vi.mock("@hocuspocus/server", () => ({ Server: mocks.MockServer }));
vi.mock("@hocuspocus/extension-logger", () => ({ Logger: mocks.MockLogger }));
vi.mock("@toolctrl/db/client.server", () => ({ getWriteDbClient: mocks.getWriteDbClient }));
vi.mock("./config.js", () => ({ getHocuspocusRuntimeConfig: mocks.getHocuspocusRuntimeConfig }));
vi.mock("./extensions/auth.js", () => ({ createAuthExtension: mocks.createAuthExtension }));
vi.mock("./extensions/database.js", () => ({ createDatabaseExtension: mocks.createDatabaseExtension }));
vi.mock("./logger.js", () => ({ hocuspocusLogger: mocks.hocuspocusLogger }));
const packageJsonPath = resolve(dirname(fileURLToPath(import.meta.url)), "../package.json");
const processExit = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
function packageScripts(): Record<string, string> {
return JSON.parse(readFileSync(packageJsonPath, "utf8")).scripts;
}
describe("hocuspocus entrypoint", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mocks.MockServer.instances.length = 0;
mocks.listen.mockResolvedValue(undefined);
mocks.createAuthExtension.mockReturnValue(mocks.authExtension);
mocks.createDatabaseExtension.mockReturnValue(mocks.databaseExtension);
mocks.getWriteDbClient.mockReturnValue(mocks.dbClient);
mocks.getHocuspocusRuntimeConfig.mockReturnValue({
authSecret: "collab-secret",
host: "127.0.0.1",
maxPayloadBytes: 4096,
port: 4321,
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("production start script runs compiled Node output without tsx", () => {
const scripts = packageScripts();
expect(scripts.start).toBe("node dist/index.js");
expect(scripts.start).not.toContain("tsx");
});
it("boots a standalone Hocuspocus server with auth and database extensions", async () => {
await import("./index.js");
expect(mocks.createAuthExtension).toHaveBeenCalledWith({ secret: "collab-secret" });
expect(mocks.createDatabaseExtension).toHaveBeenCalledWith({
db: mocks.dbClient,
maxPayloadBytes: 4096,
});
expect(mocks.MockServer.instances).toHaveLength(1);
expect(mocks.MockServer.instances[0]?.options).toMatchObject({
address: "127.0.0.1",
debounce: 2000,
maxDebounce: 10000,
name: "toolctrl-hocuspocus",
port: 4321,
quiet: true,
timeout: 30000,
});
expect(mocks.MockServer.instances[0]?.listen).toHaveBeenCalledTimes(1);
expect(mocks.hocuspocusLogger.info).toHaveBeenCalledWith({ port: 4321, host: "127.0.0.1" }, "hocuspocus listening");
});
it("wires Hocuspocus extension logs into the app logger", async () => {
await import("./index.js");
const options = mocks.MockServer.instances[0]?.options as { extensions: Array<{ options?: { log?: (message: string) => void } }> };
const loggerExtension = options.extensions[0];
loggerExtension?.options?.log?.("connected");
expect(mocks.hocuspocusLogger.info).toHaveBeenCalledWith({ msg: "connected" }, "hocuspocus");
});
it("logs and exits when auth secret is missing", async () => {
mocks.getHocuspocusRuntimeConfig.mockReturnValue({
authSecret: null,
host: "0.0.0.0",
maxPayloadBytes: 1024,
port: 1234,
});
await import("./index.js");
expect(mocks.hocuspocusLogger.error).toHaveBeenCalledWith("HOCUSPOCUS_AUTH_SECRET is not set — refusing to boot");
expect(processExit).toHaveBeenCalledWith(1);
});
it("logs startup failures and exits non-zero", async () => {
const error = new Error("listen failed");
mocks.listen.mockRejectedValueOnce(error);
await import("./index.js");
expect(mocks.hocuspocusLogger.error).toHaveBeenCalledWith({ err: error }, "hocuspocus failed to start");
expect(processExit).toHaveBeenCalledWith(1);
});
});
@@ -0,0 +1,58 @@
/**
* Hocuspocus realtime-collaboration server entrypoint.
*
* Runs as a standalone Node service (separate container) — see
* docs/features/realtime-collab.md. Persistence + auth + cleanup
* extensions are wired in subsequent commits; this entrypoint is the
* smallest possible bootable shell so the container scaffold can land
* before persistence + auth concerns.
*
* Refs: docs/improvement-plan/05-business-features/realtime-collab.md
*/
import { Server } from "@hocuspocus/server";
import { Logger } from "@hocuspocus/extension-logger";
import { getWriteDbClient } from "@toolctrl/db/client.server";
import { getHocuspocusRuntimeConfig } from "./config.js";
import { createAuthExtension } from "./extensions/auth.js";
import { createDatabaseExtension } from "./extensions/database.js";
import { hocuspocusLogger } from "./logger.js";
async function main(): Promise<void> {
const config = getHocuspocusRuntimeConfig();
if (!config.authSecret) {
hocuspocusLogger.error("HOCUSPOCUS_AUTH_SECRET is not set — refusing to boot");
process.exit(1);
}
const server = new Server({
name: "toolctrl-hocuspocus",
port: config.port,
address: config.host,
timeout: 30_000,
debounce: 2_000,
maxDebounce: 10_000,
quiet: true,
extensions: [
new Logger({
log: (message) => {
hocuspocusLogger.info({ msg: message }, "hocuspocus");
},
}),
createAuthExtension({ secret: config.authSecret }),
createDatabaseExtension({
db: getWriteDbClient(),
maxPayloadBytes: config.maxPayloadBytes,
}),
],
});
await server.listen();
hocuspocusLogger.info({ port: config.port, host: config.host }, "hocuspocus listening");
}
main().catch((error) => {
hocuspocusLogger.error({ err: error }, "hocuspocus failed to start");
process.exit(1);
});
@@ -0,0 +1,3 @@
import { createChildLogger, defaultLogger } from "@toolctrl/logger";
export const hocuspocusLogger = createChildLogger(defaultLogger, { component: "hocuspocus" });
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist",
"types": ["node"]
},
"include": ["src/**/*.ts"]
}