blokctl 0.6.3 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,7 +17,7 @@ const exec = util.promisify(child_process.exec);
17
17
  const HOME_DIR = `${os.homedir()}/.blok`;
18
18
  const GITHUB_REPO_LOCAL = `${HOME_DIR}/blok`;
19
19
  const GITHUB_REPO_REMOTE = "https://github.com/well-prado/blok.git";
20
- const GITHUB_REPO_RELEASE_TAG = "v0.6.3";
20
+ const GITHUB_REPO_RELEASE_TAG = "v0.6.5";
21
21
  fsExtra.ensureDirSync(HOME_DIR);
22
22
  const options = {
23
23
  baseDir: HOME_DIR,
@@ -111,7 +111,12 @@ export async function createProject(opts, version, currentPath = false, localRep
111
111
  message: "Select triggers to install",
112
112
  options: [
113
113
  { label: "HTTP", value: "http", hint: "REST APIs (port 4000)" },
114
- { label: "SSE", value: "sse", hint: "Real-time push (port 4001)" },
114
+ { label: "SSE", value: "sse", hint: "Real-time server push (mounts on HTTP port)" },
115
+ {
116
+ label: "WebSocket",
117
+ value: "websocket",
118
+ hint: "Bi-directional real-time (mounts on HTTP port)",
119
+ },
115
120
  { label: "Queue", value: "queue", hint: "Kafka/RabbitMQ/SQS/Redis (port 4005)" },
116
121
  { label: "Pub/Sub", value: "pubsub", hint: "GCP/AWS/Azure messaging (port 4006)" },
117
122
  ],
@@ -257,9 +262,17 @@ export async function createProject(opts, version, currentPath = false, localRep
257
262
  fsExtra.ensureDirSync(`${dirPath}/src/nodes`);
258
263
  fsExtra.ensureDirSync(`${dirPath}/src/workflows`);
259
264
  const triggerConfigs = selectedTriggers.map((kind) => createTriggerConfig(kind));
265
+ const mountedOnHttp = new Set();
266
+ if (selectedTriggers.includes("http")) {
267
+ for (const kind of ["sse", "websocket"]) {
268
+ if (selectedTriggers.includes(kind))
269
+ mountedOnHttp.add(kind);
270
+ }
271
+ }
272
+ const spawnedTriggerConfigs = triggerConfigs.filter((tc) => !mountedOnHttp.has(tc.kind));
260
273
  const primaryTrigger = selectedTriggers[0];
261
274
  const primaryTriggerDir = primaryTrigger === "pubsub" || primaryTrigger === "queue"
262
- ? `${repoSource}/triggers/${primaryTrigger}/template`
275
+ ? `${repoSource}/triggers/${primaryTrigger === "queue" ? "worker" : primaryTrigger}/template`
263
276
  : `${repoSource}/triggers/${primaryTrigger}`;
264
277
  const baseFiles = ["package.json", "tsconfig.json", ".env.example", ".gitignore", "vitest.config.ts"];
265
278
  for (const file of baseFiles) {
@@ -326,7 +339,7 @@ export async function createProject(opts, version, currentPath = false, localRep
326
339
  }
327
340
  else {
328
341
  const triggerSrcDir = `${repoSource}/triggers/${triggerKind}/src`;
329
- if (triggerKind === "sse") {
342
+ if (triggerKind === "sse" || triggerKind === "websocket") {
330
343
  const entries = fsExtra.readdirSync(triggerSrcDir, { withFileTypes: true });
331
344
  for (const entry of entries) {
332
345
  const src = `${triggerSrcDir}/${entry.name}`;
@@ -371,9 +384,19 @@ export async function createProject(opts, version, currentPath = false, localRep
371
384
  const sharedWorkflowsContent = generateSharedWorkflowsFile(selectedTriggers);
372
385
  fsExtra.writeFileSync(`${dirPath}/src/Workflows.ts`, sharedWorkflowsContent);
373
386
  for (const triggerKind of selectedTriggers) {
374
- const entryContent = generateTriggerEntryFile(triggerKind);
387
+ const entryContent = generateTriggerEntryFile(triggerKind, selectedTriggers);
375
388
  fsExtra.writeFileSync(`${dirPath}/src/triggers/${triggerKind}/index.ts`, entryContent);
376
389
  }
390
+ if (selectedTriggers.includes("sse")) {
391
+ const sseServerDir = `${dirPath}/src/triggers/sse/runner`;
392
+ fsExtra.ensureDirSync(sseServerDir);
393
+ fsExtra.writeFileSync(`${sseServerDir}/SSEServer.ts`, generateSSEServerFile());
394
+ }
395
+ if (selectedTriggers.includes("websocket")) {
396
+ const wsServerDir = `${dirPath}/src/triggers/websocket/runner`;
397
+ fsExtra.ensureDirSync(wsServerDir);
398
+ fsExtra.writeFileSync(`${wsServerDir}/WSServer.ts`, generateWSServerFile());
399
+ }
377
400
  for (const triggerKind of selectedTriggers) {
378
401
  const triggerNodesDir = `${repoSource}/triggers/${triggerKind}/src/nodes`;
379
402
  if (fsExtra.existsSync(triggerNodesDir)) {
@@ -430,13 +453,20 @@ export async function createProject(opts, version, currentPath = false, localRep
430
453
  const workspacePackageMap = {
431
454
  "@blokjs/api-call": "nodes/web/api-call@1.0.0",
432
455
  "@blokjs/helper": "core/workflow-helper",
456
+ "@blokjs/helpers": "nodes/utility/helpers@1.0.0",
433
457
  "@blokjs/if-else": "nodes/control-flow/if-else@1.0.0",
458
+ "@blokjs/react": "nodes/web/react@1.0.0",
434
459
  "@blokjs/runner": "core/runner",
435
460
  "@blokjs/shared": "core/shared",
461
+ "@blokjs/trigger-cron": "triggers/cron",
462
+ "@blokjs/trigger-grpc": "triggers/grpc",
436
463
  "@blokjs/trigger-pubsub": "triggers/pubsub",
464
+ "@blokjs/trigger-sse": "triggers/sse",
465
+ "@blokjs/trigger-webhook": "triggers/webhook",
466
+ "@blokjs/trigger-websocket": "triggers/websocket",
437
467
  "@blokjs/trigger-worker": "triggers/worker",
438
468
  };
439
- const BLOKJS_DEP_RANGE = "^0.6.3";
469
+ const BLOKJS_DEP_RANGE = "^0.6.5";
440
470
  for (const depGroup of ["dependencies", "devDependencies", "peerDependencies"]) {
441
471
  const deps = packageJsonContent[depGroup];
442
472
  if (!deps)
@@ -510,6 +540,52 @@ export async function createProject(opts, version, currentPath = false, localRep
510
540
  ? `file:${path.resolve(repoSource, "triggers/worker")}`
511
541
  : BLOKJS_DEP_RANGE;
512
542
  }
543
+ if (selectedTriggers.includes("sse")) {
544
+ const sseDeps = {
545
+ "@blokjs/api-call": localRepoPath
546
+ ? `file:${path.resolve(repoSource, "nodes/web/api-call@1.0.0")}`
547
+ : BLOKJS_DEP_RANGE,
548
+ "@blokjs/if-else": localRepoPath
549
+ ? `file:${path.resolve(repoSource, "nodes/control-flow/if-else@1.0.0")}`
550
+ : BLOKJS_DEP_RANGE,
551
+ "@blokjs/helpers": localRepoPath
552
+ ? `file:${path.resolve(repoSource, "nodes/utility/helpers@1.0.0")}`
553
+ : BLOKJS_DEP_RANGE,
554
+ "@blokjs/trigger-sse": localRepoPath ? `file:${path.resolve(repoSource, "triggers/sse")}` : BLOKJS_DEP_RANGE,
555
+ "@hono/node-server": "^1.19.9",
556
+ hono: "^4.11.7",
557
+ uuid: "^11.1.0",
558
+ };
559
+ packageJsonContent.dependencies = {
560
+ ...packageJsonContent.dependencies,
561
+ ...sseDeps,
562
+ };
563
+ }
564
+ if (selectedTriggers.includes("websocket")) {
565
+ const wsDeps = {
566
+ "@blokjs/api-call": localRepoPath
567
+ ? `file:${path.resolve(repoSource, "nodes/web/api-call@1.0.0")}`
568
+ : BLOKJS_DEP_RANGE,
569
+ "@blokjs/if-else": localRepoPath
570
+ ? `file:${path.resolve(repoSource, "nodes/control-flow/if-else@1.0.0")}`
571
+ : BLOKJS_DEP_RANGE,
572
+ "@blokjs/helpers": localRepoPath
573
+ ? `file:${path.resolve(repoSource, "nodes/utility/helpers@1.0.0")}`
574
+ : BLOKJS_DEP_RANGE,
575
+ "@blokjs/trigger-websocket": localRepoPath
576
+ ? `file:${path.resolve(repoSource, "triggers/websocket")}`
577
+ : BLOKJS_DEP_RANGE,
578
+ "@hono/node-server": "^1.19.9",
579
+ "@hono/node-ws": "^1.3.1",
580
+ hono: "^4.11.7",
581
+ uuid: "^11.1.0",
582
+ ws: "^8.19.0",
583
+ };
584
+ packageJsonContent.dependencies = {
585
+ ...packageJsonContent.dependencies,
586
+ ...wsDeps,
587
+ };
588
+ }
513
589
  if (Object.keys(triggerPackageDeps).length > 0) {
514
590
  packageJsonContent.dependencies = {
515
591
  ...packageJsonContent.dependencies,
@@ -537,7 +613,7 @@ export async function createProject(opts, version, currentPath = false, localRep
537
613
  fsExtra.appendFileSync(envLocal, envVars);
538
614
  }
539
615
  }
540
- writeProjectConfig(dirPath, runtimeConfigs, triggerConfigs);
616
+ writeProjectConfig(dirPath, runtimeConfigs, spawnedTriggerConfigs);
541
617
  if (triggerConfigs.length > 0) {
542
618
  const triggerEnvVars = generateTriggerEnvVars(triggerConfigs);
543
619
  fsExtra.appendFileSync(envLocal, triggerEnvVars);
@@ -559,8 +635,8 @@ export async function createProject(opts, version, currentPath = false, localRep
559
635
  fsExtra.writeFileSync(packageJson, JSON.stringify(packageJsonContent, null, 2));
560
636
  const supervisordConfPath = `${dirPath}/supervisord.conf`;
561
637
  let supervisordConfContent = "[supervisord]\nnodaemon=true\n";
562
- if (triggerConfigs.length > 0) {
563
- supervisordConfContent += generateTriggerSupervisordConfig(triggerConfigs);
638
+ if (spawnedTriggerConfigs.length > 0) {
639
+ supervisordConfContent += generateTriggerSupervisordConfig(spawnedTriggerConfigs);
564
640
  }
565
641
  if (runtimeConfigs.length > 0) {
566
642
  supervisordConfContent += generateSupervisordConfig(runtimeConfigs);
@@ -585,8 +661,15 @@ export async function createProject(opts, version, currentPath = false, localRep
585
661
  console.log(`Change to the project directory: cd ${projectName}`);
586
662
  console.log(`Run the command "npm run dev" to start the development server.`);
587
663
  console.log("\nTrigger endpoints:");
664
+ const httpPort = triggerConfigs.find((tc) => tc.kind === "http")?.port;
588
665
  for (const tc of triggerConfigs) {
589
- console.log(` ${tc.label}: http://localhost:${tc.port}/health-check`);
666
+ if (mountedOnHttp.has(tc.kind) && httpPort !== undefined) {
667
+ const samplePath = tc.kind === "sse" ? "/sse/demo" : "/ws/echo";
668
+ console.log(` ${tc.label}: http://localhost:${httpPort}${samplePath} (mounted on HTTP)`);
669
+ }
670
+ else {
671
+ console.log(` ${tc.label}: http://localhost:${tc.port}/health-check`);
672
+ }
590
673
  }
591
674
  if (runtimeConfigs.length > 0) {
592
675
  console.log("\nRuntime health checks:");
@@ -625,19 +708,25 @@ export async function createProject(opts, version, currentPath = false, localRep
625
708
  function generateSharedNodesFile(triggers, _repoSource) {
626
709
  const nodeImports = new Set();
627
710
  const nodeExports = new Map();
711
+ let spreadHelperNodes = false;
628
712
  nodeImports.add('import ApiCall from "@blokjs/api-call";');
629
713
  nodeImports.add('import IfElse from "@blokjs/if-else";');
630
714
  nodeImports.add('import type { BlokService } from "@blokjs/runner";');
631
715
  nodeExports.set("@blokjs/api-call", "ApiCall");
632
716
  nodeExports.set("@blokjs/if-else", "IfElse");
717
+ if (triggers.includes("sse") || triggers.includes("websocket")) {
718
+ nodeImports.add('import { HELPER_NODES } from "@blokjs/helpers";');
719
+ spreadHelperNodes = true;
720
+ }
633
721
  const importLines = Array.from(nodeImports).join("\n");
634
722
  const exportEntries = Array.from(nodeExports.entries())
635
723
  .map(([key, value]) => `\t"${key}": ${value},`)
636
724
  .join("\n");
725
+ const recordBody = spreadHelperNodes ? `\t...HELPER_NODES,\n${exportEntries}` : exportEntries;
637
726
  return `${importLines}
638
727
 
639
728
  const nodes: Record<string, BlokService<unknown>> = {
640
- ${exportEntries}
729
+ ${recordBody}
641
730
  };
642
731
 
643
732
  export default nodes;
@@ -651,7 +740,16 @@ function generateSharedWorkflowsFile(triggers) {
651
740
  imports.push("// HTTP workflows are auto-discovered from workflows/json/");
652
741
  }
653
742
  else if (trigger === "sse") {
654
- imports.push("// SSE workflows: register in this Workflows record manually");
743
+ imports.push('import SSEStreamDemo from "./workflows/sse/events/stream-demo";');
744
+ workflowEntries.push('\t"sse-stream-demo": SSEStreamDemo,');
745
+ if (triggers.includes("http")) {
746
+ imports.push('import SSEPublishDemo from "./workflows/sse/events/publish-demo";');
747
+ workflowEntries.push('\t"sse-publish-demo": SSEPublishDemo,');
748
+ }
749
+ }
750
+ else if (trigger === "websocket") {
751
+ imports.push('import WSEchoDemo from "./workflows/websocket/events/echo-demo";');
752
+ workflowEntries.push('\t"ws-echo-demo": WSEchoDemo,');
655
753
  }
656
754
  else if (trigger === "pubsub") {
657
755
  imports.push('import OnPubSubMessage from "./workflows/pubsub/messages/on-message";');
@@ -674,11 +772,87 @@ ${entriesSection}
674
772
  export default workflows;
675
773
  `;
676
774
  }
677
- function generateTriggerEntryFile(triggerKind) {
775
+ function generateTriggerEntryFile(triggerKind, selectedTriggers = [triggerKind]) {
678
776
  if (triggerKind === "http") {
777
+ const sseAlsoSelected = selectedTriggers.includes("sse");
778
+ const wsAlsoSelected = selectedTriggers.includes("websocket");
779
+ const needsShared = sseAlsoSelected || wsAlsoSelected;
780
+ const sharedHelperImports = needsShared
781
+ ? `\nimport { NodeMap, WorkflowRegistry } from "@blokjs/runner";\nimport sharedNodes from "../../Nodes";\nimport sharedWorkflows from "../../Workflows";`
782
+ : "";
783
+ const sseImports = sseAlsoSelected ? `\nimport SSETrigger from "@blokjs/trigger-sse";` : "";
784
+ const wsImports = wsAlsoSelected ? `\nimport WebSocketTrigger from "@blokjs/trigger-websocket";` : "";
785
+ const sharedBootstrapPrelude = needsShared
786
+ ? `\n\n // Build a NodeMap from the shared Nodes record; both SSE and
787
+ // WebSocket triggers consume this via setNodeMap so they can
788
+ // resolve helper nodes (sse-subscribe, sse-stream, ws-reply,
789
+ // etc.) at workflow run time.
790
+ const subTriggerNodeMap = new NodeMap();
791
+ for (const [key, node] of Object.entries(sharedNodes)) {
792
+ subTriggerNodeMap.addNode(key, node);
793
+ }
794
+ // HttpTrigger.buildFileBasedRoutes() calls WorkflowRegistry.clear()
795
+ // during listen() and re-registers only HTTP-triggered workflows.
796
+ // SSE / WebSocket workflows aren't HTTP routes, so they're missing
797
+ // from the registry by the time the sibling trigger walks it.
798
+ // Add a preCatchAllHook BEFORE the sibling triggers register their
799
+ // hooks — preCatchAllHooks fire in insertion order, so this hook
800
+ // injects SSE + WS workflows into the cleared registry first, and
801
+ // each sibling trigger's hook then sees them and mounts routes.
802
+ this.httpTrigger.addPreCatchAllHook(() => {
803
+ const registry = WorkflowRegistry.getInstance();
804
+ for (const [name, wf] of Object.entries(sharedWorkflows)) {
805
+ const w = wf as {
806
+ name?: string;
807
+ trigger?: { sse?: unknown; websocket?: unknown };
808
+ _config?: { name?: string; trigger?: { sse?: unknown; websocket?: unknown } };
809
+ };
810
+ const triggerCfg = w._config?.trigger ?? w.trigger;
811
+ if (!triggerCfg) continue;
812
+ if (!triggerCfg.sse && !triggerCfg.websocket) continue;
813
+ const resolvedName = w._config?.name ?? w.name ?? name;
814
+ if (registry.get(resolvedName)) continue;
815
+ const kind = triggerCfg.sse ? "sse" : "websocket";
816
+ registry.register({
817
+ name: resolvedName,
818
+ source: \`\${kind}:\${name}\`,
819
+ workflow: (w._config ?? w) as unknown as Parameters<typeof registry.register>[0]["workflow"],
820
+ });
821
+ }
822
+ });`
823
+ : "";
824
+ const sseBootstrap = sseAlsoSelected
825
+ ? `\n // Mount SSE on the HTTP process's shared Hono app. SSETrigger's
826
+ // constructor takes the app + the HttpTrigger for pre-catch-all
827
+ // hook integration; SSE routes register inside that hook so
828
+ // they win Hono's first-match dispatch over HTTP's legacy
829
+ // workflow-name catch-all (\`/:workflow{.+}\`).
830
+ const sseTrigger = new SSETrigger(this.httpTrigger.getApp(), this.httpTrigger);
831
+ sseTrigger.setNodeMap({
832
+ nodes: subTriggerNodeMap,
833
+ workflows: sharedWorkflows as unknown as Parameters<typeof sseTrigger.setNodeMap>[0]["workflows"],
834
+ });
835
+ await sseTrigger.listen();`
836
+ : "";
837
+ const wsBootstrap = wsAlsoSelected
838
+ ? `\n // Mount WebSocket on the HTTP process's shared Hono app.
839
+ // WebSocketTrigger uses TWO HttpTrigger integration points:
840
+ // 1. addPreCatchAllHook — registers WS routes (Hono's upgradeWebSocket
841
+ // handler) BEFORE the legacy workflow catch-all so /ws/<path>
842
+ // upgrades cleanly.
843
+ // 2. addServerHook — attaches the WS upgrade listener to the
844
+ // http.Server returned by HttpTrigger's serve() call.
845
+ const wsTrigger = new WebSocketTrigger(this.httpTrigger.getApp(), this.httpTrigger);
846
+ wsTrigger.setNodeMap({
847
+ nodes: subTriggerNodeMap,
848
+ workflows: sharedWorkflows as unknown as Parameters<typeof wsTrigger.setNodeMap>[0]["workflows"],
849
+ });
850
+ await wsTrigger.listen();`
851
+ : "";
852
+ const fullBootstrap = `${sharedBootstrapPrelude}${sseBootstrap}${wsBootstrap}`;
679
853
  return `import { DefaultLogger } from "@blokjs/runner";
680
854
  import { type Span, metrics, trace } from "@opentelemetry/api";
681
- import HttpTrigger from "./runner/HttpTrigger";
855
+ import HttpTrigger from "./runner/HttpTrigger";${sharedHelperImports}${sseImports}${wsImports}
682
856
 
683
857
  export default class App {
684
858
  private httpTrigger: HttpTrigger = <HttpTrigger>{};
@@ -699,7 +873,7 @@ export default class App {
699
873
  }
700
874
 
701
875
  async run() {
702
- this.tracer.startActiveSpan("initialization", async (span: Span) => {
876
+ this.tracer.startActiveSpan("initialization", async (span: Span) => {${fullBootstrap}
703
877
  await this.httpTrigger.listen();
704
878
  this.initializer = performance.now() - this.initializer;
705
879
 
@@ -726,10 +900,10 @@ if (process.env.DISABLE_TRIGGER_RUN !== "true") {
726
900
  if (triggerKind === "sse") {
727
901
  return `import { DefaultLogger } from "@blokjs/runner";
728
902
  import { type Span, metrics, trace } from "@opentelemetry/api";
729
- import SSETrigger from "./SSETrigger";
903
+ import SSEServer from "./runner/SSEServer";
730
904
 
731
905
  export default class App {
732
- private sseTrigger: SSETrigger = <SSETrigger>{};
906
+ private sseServer: SSEServer = <SSEServer>{};
733
907
  protected trigger_initializer = 0;
734
908
  protected initializer = 0;
735
909
  protected tracer = trace.getTracer(
@@ -743,15 +917,59 @@ export default class App {
743
917
 
744
918
  constructor() {
745
919
  this.initializer = performance.now();
746
- this.sseTrigger = new SSETrigger();
920
+ this.sseServer = new SSEServer();
747
921
  }
748
922
 
749
923
  async run() {
750
924
  this.tracer.startActiveSpan("initialization", async (span: Span) => {
751
- await this.sseTrigger.listen();
925
+ await this.sseServer.listen();
752
926
  this.initializer = performance.now() - this.initializer;
753
927
 
754
- this.logger.log(\`Server initialized in \${(this.initializer).toFixed(2)}ms\`);
928
+ this.logger.log(\`SSE trigger initialized in \${(this.initializer).toFixed(2)}ms\`);
929
+ this.app_cold_start.record(this.initializer, {
930
+ pid: process.pid,
931
+ env: process.env.NODE_ENV,
932
+ app: process.env.APP_NAME,
933
+ });
934
+ span.end();
935
+ });
936
+ }
937
+ }
938
+
939
+ if (process.env.DISABLE_TRIGGER_RUN !== "true") {
940
+ new App().run();
941
+ }
942
+ `;
943
+ }
944
+ if (triggerKind === "websocket") {
945
+ return `import { DefaultLogger } from "@blokjs/runner";
946
+ import { type Span, metrics, trace } from "@opentelemetry/api";
947
+ import WSServer from "./runner/WSServer";
948
+
949
+ export default class App {
950
+ private wsServer: WSServer = <WSServer>{};
951
+ protected trigger_initializer = 0;
952
+ protected initializer = 0;
953
+ protected tracer = trace.getTracer(
954
+ process.env.PROJECT_NAME || "trigger-websocket-server",
955
+ process.env.PROJECT_VERSION || "0.0.1",
956
+ );
957
+ private logger = new DefaultLogger();
958
+ protected app_cold_start = metrics.getMeter("default").createGauge("initialization", {
959
+ description: "Application cold start",
960
+ });
961
+
962
+ constructor() {
963
+ this.initializer = performance.now();
964
+ this.wsServer = new WSServer();
965
+ }
966
+
967
+ async run() {
968
+ this.tracer.startActiveSpan("initialization", async (span: Span) => {
969
+ await this.wsServer.listen();
970
+ this.initializer = performance.now() - this.initializer;
971
+
972
+ this.logger.log(\`WebSocket trigger initialized in \${(this.initializer).toFixed(2)}ms\`);
755
973
  this.app_cold_start.record(this.initializer, {
756
974
  pid: process.pid,
757
975
  env: process.env.NODE_ENV,
@@ -860,6 +1078,222 @@ if (process.env.DISABLE_TRIGGER_RUN !== "true") {
860
1078
  console.log("${triggerKind} trigger not yet implemented");
861
1079
  `;
862
1080
  }
1081
+ function generateSSEServerFile() {
1082
+ return `import { serve } from "@hono/node-server";
1083
+ import { DefaultLogger, NodeMap, WorkflowRegistry } from "@blokjs/runner";
1084
+ import { Hono } from "hono";
1085
+ // Import SSETrigger from the @blokjs/trigger-sse npm package — NOT the
1086
+ // locally-copied SSETrigger.ts. The @blokjs/sse-publish helper that
1087
+ // publisher workflows use imports the in-process bus from this exact
1088
+ // module (\`@blokjs/trigger-sse\`'s \`_getSSEBus\`). If SSEServer uses a
1089
+ // different module instance (e.g. the local copy), Node treats them
1090
+ // as separate modules with separate bus singletons — events from the
1091
+ // helper would never reach subscribers on this trigger.
1092
+ import SSETrigger from "@blokjs/trigger-sse";
1093
+ import nodes from "../../../Nodes";
1094
+ import workflows from "../../../Workflows";
1095
+
1096
+ type HonoServer = ReturnType<typeof serve>;
1097
+
1098
+ /**
1099
+ * SSEServer — concrete SSE trigger implementation.
1100
+ *
1101
+ * Bootstraps an isolated Hono app for the SSE trigger process, hands
1102
+ * it to SSETrigger, populates the shared NodeMap, registers
1103
+ * SSE-triggered workflows with WorkflowRegistry, then binds an HTTP
1104
+ * listener so /<sse-path> requests reach the streamer.
1105
+ *
1106
+ * Two SSE-triggered workflows ship by default (see
1107
+ * src/workflows/sse/events/):
1108
+ * - stream-demo.ts GET /sse/demo — subscribes to the
1109
+ * in-process bus channel
1110
+ * "sse-demo" and pumps
1111
+ * events as SSE frames.
1112
+ * - publish-demo.ts POST /v07-sse-publish — publishes one event to
1113
+ * the "sse-demo" channel
1114
+ * (HTTP trigger; only
1115
+ * routable when the
1116
+ * project also includes
1117
+ * an HTTP trigger).
1118
+ *
1119
+ * Test end-to-end:
1120
+ * 1. curl -N http://localhost:4001/sse/demo
1121
+ * 2. curl -X POST http://localhost:4000/v07-sse-publish \\
1122
+ * -H 'Content-Type: application/json' \\
1123
+ * -d '{"event":"hello","data":{"msg":"world"}}'
1124
+ * 3. Watch (1) — the event arrives instantly.
1125
+ */
1126
+ export default class SSEServer {
1127
+ private readonly app: Hono = new Hono();
1128
+ private readonly trigger: SSETrigger;
1129
+ private readonly logger = new DefaultLogger();
1130
+ private httpServer: HonoServer | null = null;
1131
+
1132
+ constructor() {
1133
+ this.trigger = new SSETrigger(this.app);
1134
+
1135
+ // Populate the NodeMap with all registered nodes. SSE workflows
1136
+ // run their steps through the same runner machinery as HTTP,
1137
+ // so they need every node referenced in their step list
1138
+ // (sse-subscribe, sse-stream, plus any user-defined nodes).
1139
+ const nodeMap = new NodeMap();
1140
+ for (const [key, node] of Object.entries(nodes)) {
1141
+ nodeMap.addNode(key, node);
1142
+ }
1143
+ this.trigger.setNodeMap({
1144
+ nodes: nodeMap,
1145
+ workflows: workflows as unknown as Parameters<SSETrigger["setNodeMap"]>[0]["workflows"],
1146
+ });
1147
+
1148
+ // Push SSE-triggered workflows into the WorkflowRegistry.
1149
+ // HTTP-companion workflows (e.g., publish-demo) share the same
1150
+ // Workflows.ts but get registered by the HTTP trigger process
1151
+ // in a multi-trigger scaffold. Filtering here prevents the SSE
1152
+ // trigger from trying to mount HTTP routes.
1153
+ //
1154
+ // Note: \`workflow({...})\` from @blokjs/helper returns a frozen
1155
+ // builder { _blokV2, _config, toJson() }. The trigger config we
1156
+ // filter on lives at \`_config.trigger.sse\`, not at the top-level
1157
+ // (\`.trigger.sse\` is the v1 helper-response shape). Supporting
1158
+ // both keeps the registration tolerant of either authoring style.
1159
+ const registry = WorkflowRegistry.getInstance();
1160
+ for (const [name, wf] of Object.entries(workflows)) {
1161
+ const w = wf as {
1162
+ name?: string;
1163
+ trigger?: { sse?: unknown };
1164
+ _config?: { name?: string; trigger?: { sse?: unknown } };
1165
+ };
1166
+ const triggerCfg = w._config?.trigger ?? w.trigger;
1167
+ if (!triggerCfg?.sse) continue;
1168
+ const resolvedName = w._config?.name ?? w.name ?? name;
1169
+ registry.register({
1170
+ name: resolvedName,
1171
+ source: \`sse:\${name}\`,
1172
+ workflow: (w._config ?? w) as unknown as Parameters<typeof registry.register>[0]["workflow"],
1173
+ });
1174
+ }
1175
+ }
1176
+
1177
+ async listen(): Promise<void> {
1178
+ await this.trigger.listen();
1179
+ const port = Number(process.env.TRIGGER_SSE_PORT || process.env.PORT || 4001);
1180
+ this.httpServer = serve({ fetch: this.app.fetch, port }, () => {
1181
+ this.logger.log(\`SSE server listening on http://localhost:\${port}\`);
1182
+ });
1183
+ }
1184
+
1185
+ async stop(): Promise<void> {
1186
+ await this.trigger.stop();
1187
+ this.httpServer?.close();
1188
+ }
1189
+ }
1190
+ `;
1191
+ }
1192
+ function generateWSServerFile() {
1193
+ return `import { serve } from "@hono/node-server";
1194
+ import { DefaultLogger, NodeMap, WorkflowRegistry } from "@blokjs/runner";
1195
+ import { Hono } from "hono";
1196
+ // Import from the @blokjs/trigger-websocket npm package — NOT the
1197
+ // locally-copied WebSocketTrigger.ts. The WebSocket helper nodes
1198
+ // (@blokjs/ws-broadcast, @blokjs/ws-reply, @blokjs/ws-close) look up
1199
+ // the active trigger via the singleton accessor exported from the npm
1200
+ // package. Using the local copy would create a separate module instance
1201
+ // with a separate singleton — helpers would broadcast into a void.
1202
+ import WebSocketTrigger from "@blokjs/trigger-websocket";
1203
+ import nodes from "../../../Nodes";
1204
+ import workflows from "../../../Workflows";
1205
+
1206
+ type HonoServer = ReturnType<typeof serve>;
1207
+
1208
+ /**
1209
+ * WSServer — concrete WebSocket trigger implementation.
1210
+ *
1211
+ * Bootstraps an isolated Hono app for the WebSocket trigger process,
1212
+ * hands it to WebSocketTrigger, populates the shared NodeMap, registers
1213
+ * WS-triggered workflows with WorkflowRegistry, binds an HTTP listener,
1214
+ * and attaches the WebSocket upgrade handler to the http.Server.
1215
+ *
1216
+ * One WebSocket workflow ships by default (see src/workflows/websocket/
1217
+ * events/):
1218
+ * - echo-demo.ts GET /ws/echo — on \`connect\` greets the client
1219
+ * with \`{event:"connected"}\`; on
1220
+ * each message replies with
1221
+ * \`{event:"echo", original:<msg>}\`.
1222
+ *
1223
+ * Test end-to-end with any WS client:
1224
+ * 1. Connect: \`wscat -c ws://localhost:4002/ws/echo\`
1225
+ * 2. On connect: receive \`{"event":"connected","payload":{"ok":true}}\`
1226
+ * 3. Send anything: \`{"event":"hello","data":{"hi":"there"}}\`
1227
+ * 4. Receive: \`{"event":"echo","payload":{"original":...}}\`
1228
+ */
1229
+ export default class WSServer {
1230
+ private readonly app: Hono = new Hono();
1231
+ private readonly trigger: WebSocketTrigger;
1232
+ private readonly logger = new DefaultLogger();
1233
+ private httpServer: HonoServer | null = null;
1234
+
1235
+ constructor() {
1236
+ this.trigger = new WebSocketTrigger(this.app);
1237
+
1238
+ const nodeMap = new NodeMap();
1239
+ for (const [key, node] of Object.entries(nodes)) {
1240
+ nodeMap.addNode(key, node);
1241
+ }
1242
+ this.trigger.setNodeMap({
1243
+ nodes: nodeMap,
1244
+ workflows: workflows as unknown as Parameters<typeof this.trigger.setNodeMap>[0]["workflows"],
1245
+ });
1246
+
1247
+ // Register WS-triggered workflows in WorkflowRegistry. Same
1248
+ // rationale as SSEServer: \`workflow({...})\` returns a frozen
1249
+ // builder \`{ _blokV2, _config, toJson }\` so the actual trigger
1250
+ // config lives at \`_config.trigger.websocket\`. Tolerate both
1251
+ // shapes so v1 \`Workflow().addTrigger("websocket")\` authoring
1252
+ // also works.
1253
+ const registry = WorkflowRegistry.getInstance();
1254
+ for (const [name, wf] of Object.entries(workflows)) {
1255
+ const w = wf as {
1256
+ name?: string;
1257
+ trigger?: { websocket?: unknown };
1258
+ _config?: { name?: string; trigger?: { websocket?: unknown } };
1259
+ };
1260
+ const triggerCfg = w._config?.trigger ?? w.trigger;
1261
+ if (!triggerCfg?.websocket) continue;
1262
+ const resolvedName = w._config?.name ?? w.name ?? name;
1263
+ if (registry.get(resolvedName)) continue;
1264
+ registry.register({
1265
+ name: resolvedName,
1266
+ source: \`websocket:\${name}\`,
1267
+ workflow: (w._config ?? w) as unknown as Parameters<typeof registry.register>[0]["workflow"],
1268
+ });
1269
+ }
1270
+ }
1271
+
1272
+ async listen(): Promise<void> {
1273
+ await this.trigger.listen();
1274
+ const port = Number(process.env.TRIGGER_WEBSOCKET_PORT || process.env.PORT || 4002);
1275
+ this.httpServer = serve({ fetch: this.app.fetch, port }, () => {
1276
+ this.logger.log(\`WebSocket server listening on http://localhost:\${port}\`);
1277
+ });
1278
+ // Attach WS upgrade listener — WebSocketTrigger sets \`injectWebSocket\`
1279
+ // as a private field inside listen(); when no httpTrigger is provided
1280
+ // to its constructor, the caller must invoke this on the http.Server
1281
+ // after serve() returns.
1282
+ const triggerWithInternals = this.trigger as unknown as {
1283
+ injectWebSocket?: (server: unknown) => void;
1284
+ };
1285
+ if (typeof triggerWithInternals.injectWebSocket === "function" && this.httpServer) {
1286
+ triggerWithInternals.injectWebSocket(this.httpServer);
1287
+ }
1288
+ }
1289
+
1290
+ async stop(): Promise<void> {
1291
+ await this.trigger.stop();
1292
+ this.httpServer?.close();
1293
+ }
1294
+ }
1295
+ `;
1296
+ }
863
1297
  function replaceBlokImportsInDirectory(dirPath) {
864
1298
  const files = fsExtra.readdirSync(dirPath, { withFileTypes: true });
865
1299
  for (const file of files) {
@@ -886,6 +1320,9 @@ function fixRunnerImportPaths(triggerDestDir, triggerKind) {
886
1320
  else if (triggerKind === "sse") {
887
1321
  fileFixes.push({ file: `${triggerDestDir}/SSETrigger.ts`, up: "../../" });
888
1322
  }
1323
+ else if (triggerKind === "websocket") {
1324
+ fileFixes.push({ file: `${triggerDestDir}/WebSocketTrigger.ts`, up: "../../" });
1325
+ }
889
1326
  else if (triggerKind === "pubsub") {
890
1327
  fileFixes.push({ file: `${triggerDestDir}/runner/PubSubServer.ts`, up: "../../../" });
891
1328
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blokctl",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "author": "Deskree Technologies Inc.",
5
5
  "license": "Apache-2.0",
6
6
  "description": "cli for blok",
@@ -30,7 +30,7 @@
30
30
  "keywords": ["blokctl", "cli", "blok", "blok"],
31
31
  "dependencies": {
32
32
  "@ai-sdk/openai": "^1.3.22",
33
- "@blokjs/runner": "^0.6.3",
33
+ "@blokjs/runner": "^0.6.5",
34
34
  "@clack/prompts": "^1.0.0",
35
35
  "ai": "^4.3.16",
36
36
  "better-sqlite3": "^12.6.2",