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`
|
* Turbo generator: `job`
|
||||||
*
|
*
|
||||||
@@ -745,6 +820,159 @@ function printPublishNextSteps(a: { feature: string; event: string }): string {
|
|||||||
].join("\n");
|
].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
|
// Local helpers used inside the printNextSteps action — Plop's helpers aren't
|
||||||
// available outside template strings, so we replicate the bits we need.
|
// available outside template strings, so we replicate the bits we need.
|
||||||
function cap(input: string): string {
|
function cap(input: string): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user