feat(turbo-gen): wire gen realtime (channel + handler modes)
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user