feat(turbo-gen): wire gen realtime (channel + handler modes)

This commit is contained in:
2026-05-08 23:15:32 +02:00
parent e7c3e1a247
commit 4e72a16b3c

View File

@@ -416,6 +416,81 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void {
},
});
/**
* Turbo generator: `realtime`
*
* Mode `channel` scaffolds a realtime channel descriptor under
* `packages/<feature>/src/realtime/<slug>.channel.ts` (+ test) and
* re-exports it from the feature's public surface at the
* `// <gen:realtime-channels>` anchor.
* Mode `handler` scaffolds an inbound handler + DI binding at the
* `// <gen:realtime-handlers>` 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, ["// <gen:realtime-channels>"]);
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: /\/\/ <gen:realtime-channels>/,
template: `// <gen:realtime-channels>\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, [
"// <gen:realtime-handler-symbols>",
]);
assertAnchors(process.cwd(), bindProdFile, ["// <gen:realtime-handlers>"]);
assertAnchors(process.cwd(), bindDevFile, ["// <gen:realtime-handlers>"]);
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: /\/\/ <gen:realtime-handler-symbols>/,
template: `// <gen:realtime-handler-symbols>\n IOn${pascalCase(a.channelSlug)}Handler: Symbol.for("@repo/${a.feature}/on${pascalCase(a.channelSlug)}Handler"),`,
},
{
type: "modify",
path: bindProdFile,
pattern: /\/\/ <gen:realtime-handlers>/,
template: realtimeHandlerBindBlock(a),
},
{
type: "modify",
path: bindDevFile,
pattern: /\/\/ <gen:realtime-handlers>/,
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 `// <gen:realtime-handlers>
// ${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 {