From 4e72a16b3c1d353dedf6715c50f11b94d312f324 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Fri, 8 May 2026 23:15:32 +0200 Subject: [PATCH] feat(turbo-gen): wire gen realtime (channel + handler modes) --- turbo/generators/config.ts | 228 +++++++++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) diff --git a/turbo/generators/config.ts b/turbo/generators/config.ts index 74abd8e..b9996e3 100644 --- a/turbo/generators/config.ts +++ b/turbo/generators/config.ts @@ -416,6 +416,81 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void { }, }); + /** + * Turbo generator: `realtime` + * + * Mode `channel` scaffolds a realtime channel descriptor under + * `packages//src/realtime/.channel.ts` (+ test) and + * re-exports it from the feature's public surface at the + * `// ` anchor. + * Mode `handler` scaffolds an inbound handler + DI binding at the + * `// ` anchor. + */ + plop.setGenerator("realtime", { + description: "Scaffold a realtime channel descriptor or inbound handler", + prompts: [ + { + type: "input", + name: "mode", + message: "Mode: channel | handler", + validate(input: string) { + if (!["channel", "handler"].includes(input)) + return "Must be 'channel' or 'handler'"; + return true; + }, + }, + { + type: "input", + name: "feature", + message: "Feature (kebab-case, must exist):", + validate(input: string) { + if (!/^[a-z][a-z0-9-]*$/.test(input)) return "Must be kebab-case"; + if (!existsSync(join(process.cwd(), "packages", input, "src"))) { + return `packages/${input}/src does not exist`; + } + return true; + }, + }, + { + type: "input", + name: "channelSlug", + message: "Channel slug (kebab-case, e.g. 'presence-ping'):", + validate(input: string) { + if (!/^[a-z][a-z0-9-]+$/.test(input)) return "Must be kebab-case"; + return true; + }, + }, + { + type: "input", + name: "scope", + message: + "Scope (channel mode only): public | authenticated | role:NAME | user-scoped (ignored for handler mode):", + validate(input: string, answers: { mode: string }) { + if (answers.mode === "handler") return true; + if ( + input === "public" || + input === "authenticated" || + input === "user-scoped" + ) + return true; + if (/^role:[a-z][a-z0-9_-]*$/.test(input)) return true; + return "Must be public | authenticated | role:NAME | user-scoped"; + }, + }, + ], + actions(answers) { + const a = answers as { + mode: string; + feature: string; + channelSlug: string; + scope: string; + }; + if (a.mode === "channel") return realtimeChannelActions(a); + if (a.mode === "handler") return realtimeHandlerActions(a); + throw new Error(`Unknown mode: ${a.mode}`); + }, + }); + /** * Turbo generator: `job` * @@ -745,6 +820,159 @@ function printPublishNextSteps(a: { feature: string; event: string }): string { ].join("\n"); } +function realtimeChannelActions(a: { + feature: string; + channelSlug: string; + scope: string; +}): PlopTypes.ActionType[] { + const indexPath = `packages/${a.feature}/src/index.ts`; + const scopeLiteral = renderScopeLiteral(a.scope); + return [ + () => { + assertAnchors(process.cwd(), indexPath, ["// "]); + return `Anchors verified in ${indexPath}`; + }, + { + type: "add", + path: `packages/${a.feature}/src/realtime/${a.channelSlug}.channel.ts`, + templateFile: "templates/realtime/channel/channel.ts.hbs", + data: { ...a, scopeLiteral }, + }, + { + type: "add", + path: `packages/${a.feature}/src/realtime/${a.channelSlug}.channel.test.ts`, + templateFile: "templates/realtime/channel/channel.test.ts.hbs", + data: { ...a, scopeLiteral }, + }, + { + type: "modify", + path: indexPath, + pattern: /\/\/ /, + template: `// \nexport {\n {{camelCase channelSlug}}Channel,\n {{camelCase channelSlug}}Schema,\n type {{pascalCase channelSlug}}Payload,\n} from "./realtime/{{kebabCase channelSlug}}.channel";`, + data: a, + }, + () => printChannelNextSteps(a), + ]; +} + +function renderScopeLiteral(scope: string): string { + if (scope === "public") return `"public"`; + if (scope === "authenticated") return `"authenticated"`; + if (scope.startsWith("role:")) + return `{ role: "${scope.slice("role:".length)}" }`; + if (scope === "user-scoped") + return `{ userScoped: true, template: "TODO_TEMPLATE" }`; + throw new Error(`unknown scope: ${scope}`); +} + +function printChannelNextSteps(a: { + feature: string; + channelSlug: string; +}): string { + return [ + "─────────────────────────────────────────────────────────────", + `Realtime channel ${a.feature}.${a.channelSlug} scaffolded.`, + "", + "Next steps (manual):", + ` 1. Fill in the Zod schema in packages/${a.feature}/src/realtime/${a.channelSlug}.channel.ts`, + ` 2. To broadcast: inject 'realtime: IRealtimeBroadcaster' into a use case factory and call`, + ` realtime.broadcast(${a.channelSlug}Channel, payload)`, + ` 3. To receive client-emitted messages on this channel: pnpm turbo gen realtime`, + ` and pick "handler" mode with the same channel slug.`, + ` 4. Verify: pnpm --filter @repo/${a.feature} lint typecheck test`, + "─────────────────────────────────────────────────────────────", + ].join("\n"); +} + +function realtimeHandlerActions(a: { + feature: string; + channelSlug: string; +}): PlopTypes.ActionType[] { + const symbolFile = `packages/${a.feature}/src/di/symbols.ts`; + const bindProdFile = `packages/${a.feature}/src/di/bind-production.ts`; + const bindDevFile = `packages/${a.feature}/src/di/bind-dev-seed.ts`; + return [ + () => { + assertAnchors(process.cwd(), symbolFile, [ + "// ", + ]); + assertAnchors(process.cwd(), bindProdFile, ["// "]); + assertAnchors(process.cwd(), bindDevFile, ["// "]); + return "All required anchors present"; + }, + { + type: "add", + path: `packages/${a.feature}/src/realtime/handlers/on-${a.channelSlug}.handler.ts`, + templateFile: "templates/realtime/handler/handler.ts.hbs", + data: a, + }, + { + type: "add", + path: `packages/${a.feature}/src/realtime/handlers/on-${a.channelSlug}.handler.test.ts`, + templateFile: "templates/realtime/handler/handler.test.ts.hbs", + data: a, + }, + { + type: "modify", + path: symbolFile, + pattern: /\/\/ /, + template: `// \n IOn${pascalCase(a.channelSlug)}Handler: Symbol.for("@repo/${a.feature}/on${pascalCase(a.channelSlug)}Handler"),`, + }, + { + type: "modify", + path: bindProdFile, + pattern: /\/\/ /, + template: realtimeHandlerBindBlock(a), + }, + { + type: "modify", + path: bindDevFile, + pattern: /\/\/ /, + template: realtimeHandlerBindBlock(a), + }, + () => printHandlerNextSteps(a), + ]; +} + +function realtimeHandlerBindBlock(a: { + feature: string; + channelSlug: string; +}): string { + const handlerFn = `on${pascalCase(a.channelSlug)}Handler`; + const channelConst = `${camel(a.channelSlug)}Channel`; + return `// + // ${handlerFn} — generated by gen realtime handler. Edit the handler file (not this block). + const wrapped${pascalCase(a.channelSlug)} = withSpan( + tracer, + { name: "${a.feature}.${handlerFn}", op: "realtime-handler" }, + withCapture( + logger, + { feature: "${a.feature}", layer: "realtime-handler", name: "${a.feature}.${handlerFn}" }, + ${handlerFn}(), + ), + ); + realtimeRegistry.register({ descriptor: ${channelConst}, handler: wrapped${pascalCase(a.channelSlug)} });`; +} + +function printHandlerNextSteps(a: { + feature: string; + channelSlug: string; +}): string { + return [ + "─────────────────────────────────────────────────────────────", + `Realtime handler on-${a.channelSlug} scaffolded in ${a.feature}.`, + "", + "Next steps (manual):", + ` 1. Implement the handler body in packages/${a.feature}/src/realtime/handlers/on-${a.channelSlug}.handler.ts`, + ` 2. Add the imports to bind-production.ts AND bind-dev-seed.ts:`, + ` import { ${camel(a.channelSlug)}Channel } from "../realtime/${a.channelSlug}.channel";`, + ` import { on${pascalCase(a.channelSlug)}Handler } from "../realtime/handlers/on-${a.channelSlug}.handler";`, + ` 3. If the handler needs deps, extend the factory signature and pass them in the bind block.`, + ` 4. Verify: pnpm --filter @repo/${a.feature} lint typecheck test`, + "─────────────────────────────────────────────────────────────", + ].join("\n"); +} + // Local helpers used inside the printNextSteps action — Plop's helpers aren't // available outside template strings, so we replicate the bits we need. function cap(input: string): string {