blokctl 0.6.4 → 0.6.6

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.4";
20
+ const GITHUB_REPO_RELEASE_TAG = "v0.6.6";
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,6 +262,14 @@ 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
275
  ? `${repoSource}/triggers/${primaryTrigger === "queue" ? "worker" : primaryTrigger}/template`
@@ -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,15 +384,31 @@ 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)) {
380
403
  fsExtra.copySync(triggerNodesDir, `${dirPath}/src/nodes`);
381
404
  }
382
405
  }
406
+ if (examples && !selectedTriggers.includes("http")) {
407
+ const httpNodesDir = `${repoSource}/triggers/http/src/nodes`;
408
+ if (fsExtra.existsSync(httpNodesDir)) {
409
+ fsExtra.copySync(httpNodesDir, `${dirPath}/src/nodes`);
410
+ }
411
+ }
383
412
  if (!skipPrompts) {
384
413
  s.message("Installing example workflows and nodes");
385
414
  }
@@ -414,7 +443,13 @@ export async function createProject(opts, version, currentPath = false, localRep
414
443
  fsExtra.ensureDirSync(`${dirPath}/infra/milvus`);
415
444
  fsExtra.copySync(`${repoSource}/infra/development`, `${dirPath}/infra/postgresql`);
416
445
  fsExtra.copySync(`${repoSource}/infra/milvus`, `${dirPath}/infra/milvus`);
417
- fsExtra.writeFileSync(`${dirPath}/src/Nodes.ts`, node_file);
446
+ const needsHelpers = selectedTriggers.includes("sse") || selectedTriggers.includes("websocket");
447
+ const examplesNodesContent = needsHelpers
448
+ ? node_file
449
+ .replace(`import type { NodeBase } from "@blokjs/shared";`, `import type { NodeBase } from "@blokjs/shared";\nimport { HELPER_NODES } from "@blokjs/helpers";`)
450
+ .replace(`} = {\n\t"@blokjs/api-call": ApiCall,`, `} = {\n\t...HELPER_NODES,\n\t"@blokjs/api-call": ApiCall,`)
451
+ : node_file;
452
+ fsExtra.writeFileSync(`${dirPath}/src/Nodes.ts`, examplesNodesContent);
418
453
  fsExtra.copySync(`${repoSource}/sdk`, `${dirPath}/public/sdk`);
419
454
  }
420
455
  const envExample = `${dirPath}/.env.example`;
@@ -430,13 +465,20 @@ export async function createProject(opts, version, currentPath = false, localRep
430
465
  const workspacePackageMap = {
431
466
  "@blokjs/api-call": "nodes/web/api-call@1.0.0",
432
467
  "@blokjs/helper": "core/workflow-helper",
468
+ "@blokjs/helpers": "nodes/utility/helpers@1.0.0",
433
469
  "@blokjs/if-else": "nodes/control-flow/if-else@1.0.0",
470
+ "@blokjs/react": "nodes/web/react@1.0.0",
434
471
  "@blokjs/runner": "core/runner",
435
472
  "@blokjs/shared": "core/shared",
473
+ "@blokjs/trigger-cron": "triggers/cron",
474
+ "@blokjs/trigger-grpc": "triggers/grpc",
436
475
  "@blokjs/trigger-pubsub": "triggers/pubsub",
476
+ "@blokjs/trigger-sse": "triggers/sse",
477
+ "@blokjs/trigger-webhook": "triggers/webhook",
478
+ "@blokjs/trigger-websocket": "triggers/websocket",
437
479
  "@blokjs/trigger-worker": "triggers/worker",
438
480
  };
439
- const BLOKJS_DEP_RANGE = "^0.6.4";
481
+ const BLOKJS_DEP_RANGE = "^0.6.6";
440
482
  for (const depGroup of ["dependencies", "devDependencies", "peerDependencies"]) {
441
483
  const deps = packageJsonContent[depGroup];
442
484
  if (!deps)
@@ -510,6 +552,52 @@ export async function createProject(opts, version, currentPath = false, localRep
510
552
  ? `file:${path.resolve(repoSource, "triggers/worker")}`
511
553
  : BLOKJS_DEP_RANGE;
512
554
  }
555
+ if (selectedTriggers.includes("sse")) {
556
+ const sseDeps = {
557
+ "@blokjs/api-call": localRepoPath
558
+ ? `file:${path.resolve(repoSource, "nodes/web/api-call@1.0.0")}`
559
+ : BLOKJS_DEP_RANGE,
560
+ "@blokjs/if-else": localRepoPath
561
+ ? `file:${path.resolve(repoSource, "nodes/control-flow/if-else@1.0.0")}`
562
+ : BLOKJS_DEP_RANGE,
563
+ "@blokjs/helpers": localRepoPath
564
+ ? `file:${path.resolve(repoSource, "nodes/utility/helpers@1.0.0")}`
565
+ : BLOKJS_DEP_RANGE,
566
+ "@blokjs/trigger-sse": localRepoPath ? `file:${path.resolve(repoSource, "triggers/sse")}` : BLOKJS_DEP_RANGE,
567
+ "@hono/node-server": "^1.19.9",
568
+ hono: "^4.11.7",
569
+ uuid: "^11.1.0",
570
+ };
571
+ packageJsonContent.dependencies = {
572
+ ...packageJsonContent.dependencies,
573
+ ...sseDeps,
574
+ };
575
+ }
576
+ if (selectedTriggers.includes("websocket")) {
577
+ const wsDeps = {
578
+ "@blokjs/api-call": localRepoPath
579
+ ? `file:${path.resolve(repoSource, "nodes/web/api-call@1.0.0")}`
580
+ : BLOKJS_DEP_RANGE,
581
+ "@blokjs/if-else": localRepoPath
582
+ ? `file:${path.resolve(repoSource, "nodes/control-flow/if-else@1.0.0")}`
583
+ : BLOKJS_DEP_RANGE,
584
+ "@blokjs/helpers": localRepoPath
585
+ ? `file:${path.resolve(repoSource, "nodes/utility/helpers@1.0.0")}`
586
+ : BLOKJS_DEP_RANGE,
587
+ "@blokjs/trigger-websocket": localRepoPath
588
+ ? `file:${path.resolve(repoSource, "triggers/websocket")}`
589
+ : BLOKJS_DEP_RANGE,
590
+ "@hono/node-server": "^1.19.9",
591
+ "@hono/node-ws": "^1.3.1",
592
+ hono: "^4.11.7",
593
+ uuid: "^11.1.0",
594
+ ws: "^8.19.0",
595
+ };
596
+ packageJsonContent.dependencies = {
597
+ ...packageJsonContent.dependencies,
598
+ ...wsDeps,
599
+ };
600
+ }
513
601
  if (Object.keys(triggerPackageDeps).length > 0) {
514
602
  packageJsonContent.dependencies = {
515
603
  ...packageJsonContent.dependencies,
@@ -537,7 +625,7 @@ export async function createProject(opts, version, currentPath = false, localRep
537
625
  fsExtra.appendFileSync(envLocal, envVars);
538
626
  }
539
627
  }
540
- writeProjectConfig(dirPath, runtimeConfigs, triggerConfigs);
628
+ writeProjectConfig(dirPath, runtimeConfigs, spawnedTriggerConfigs);
541
629
  if (triggerConfigs.length > 0) {
542
630
  const triggerEnvVars = generateTriggerEnvVars(triggerConfigs);
543
631
  fsExtra.appendFileSync(envLocal, triggerEnvVars);
@@ -559,8 +647,8 @@ export async function createProject(opts, version, currentPath = false, localRep
559
647
  fsExtra.writeFileSync(packageJson, JSON.stringify(packageJsonContent, null, 2));
560
648
  const supervisordConfPath = `${dirPath}/supervisord.conf`;
561
649
  let supervisordConfContent = "[supervisord]\nnodaemon=true\n";
562
- if (triggerConfigs.length > 0) {
563
- supervisordConfContent += generateTriggerSupervisordConfig(triggerConfigs);
650
+ if (spawnedTriggerConfigs.length > 0) {
651
+ supervisordConfContent += generateTriggerSupervisordConfig(spawnedTriggerConfigs);
564
652
  }
565
653
  if (runtimeConfigs.length > 0) {
566
654
  supervisordConfContent += generateSupervisordConfig(runtimeConfigs);
@@ -585,8 +673,15 @@ export async function createProject(opts, version, currentPath = false, localRep
585
673
  console.log(`Change to the project directory: cd ${projectName}`);
586
674
  console.log(`Run the command "npm run dev" to start the development server.`);
587
675
  console.log("\nTrigger endpoints:");
676
+ const httpPort = triggerConfigs.find((tc) => tc.kind === "http")?.port;
588
677
  for (const tc of triggerConfigs) {
589
- console.log(` ${tc.label}: http://localhost:${tc.port}/health-check`);
678
+ if (mountedOnHttp.has(tc.kind) && httpPort !== undefined) {
679
+ const samplePath = tc.kind === "sse" ? "/sse/demo" : "/ws/echo";
680
+ console.log(` ${tc.label}: http://localhost:${httpPort}${samplePath} (mounted on HTTP)`);
681
+ }
682
+ else {
683
+ console.log(` ${tc.label}: http://localhost:${tc.port}/health-check`);
684
+ }
590
685
  }
591
686
  if (runtimeConfigs.length > 0) {
592
687
  console.log("\nRuntime health checks:");
@@ -625,19 +720,25 @@ export async function createProject(opts, version, currentPath = false, localRep
625
720
  function generateSharedNodesFile(triggers, _repoSource) {
626
721
  const nodeImports = new Set();
627
722
  const nodeExports = new Map();
723
+ let spreadHelperNodes = false;
628
724
  nodeImports.add('import ApiCall from "@blokjs/api-call";');
629
725
  nodeImports.add('import IfElse from "@blokjs/if-else";');
630
726
  nodeImports.add('import type { BlokService } from "@blokjs/runner";');
631
727
  nodeExports.set("@blokjs/api-call", "ApiCall");
632
728
  nodeExports.set("@blokjs/if-else", "IfElse");
729
+ if (triggers.includes("sse") || triggers.includes("websocket")) {
730
+ nodeImports.add('import { HELPER_NODES } from "@blokjs/helpers";');
731
+ spreadHelperNodes = true;
732
+ }
633
733
  const importLines = Array.from(nodeImports).join("\n");
634
734
  const exportEntries = Array.from(nodeExports.entries())
635
735
  .map(([key, value]) => `\t"${key}": ${value},`)
636
736
  .join("\n");
737
+ const recordBody = spreadHelperNodes ? `\t...HELPER_NODES,\n${exportEntries}` : exportEntries;
637
738
  return `${importLines}
638
739
 
639
740
  const nodes: Record<string, BlokService<unknown>> = {
640
- ${exportEntries}
741
+ ${recordBody}
641
742
  };
642
743
 
643
744
  export default nodes;
@@ -651,7 +752,16 @@ function generateSharedWorkflowsFile(triggers) {
651
752
  imports.push("// HTTP workflows are auto-discovered from workflows/json/");
652
753
  }
653
754
  else if (trigger === "sse") {
654
- imports.push("// SSE workflows: register in this Workflows record manually");
755
+ imports.push('import SSEStreamDemo from "./workflows/sse/events/stream-demo";');
756
+ workflowEntries.push('\t"sse-stream-demo": SSEStreamDemo,');
757
+ if (triggers.includes("http")) {
758
+ imports.push('import SSEPublishDemo from "./workflows/sse/events/publish-demo";');
759
+ workflowEntries.push('\t"sse-publish-demo": SSEPublishDemo,');
760
+ }
761
+ }
762
+ else if (trigger === "websocket") {
763
+ imports.push('import WSEchoDemo from "./workflows/websocket/events/echo-demo";');
764
+ workflowEntries.push('\t"ws-echo-demo": WSEchoDemo,');
655
765
  }
656
766
  else if (trigger === "pubsub") {
657
767
  imports.push('import OnPubSubMessage from "./workflows/pubsub/messages/on-message";');
@@ -674,11 +784,87 @@ ${entriesSection}
674
784
  export default workflows;
675
785
  `;
676
786
  }
677
- function generateTriggerEntryFile(triggerKind) {
787
+ function generateTriggerEntryFile(triggerKind, selectedTriggers = [triggerKind]) {
678
788
  if (triggerKind === "http") {
789
+ const sseAlsoSelected = selectedTriggers.includes("sse");
790
+ const wsAlsoSelected = selectedTriggers.includes("websocket");
791
+ const needsShared = sseAlsoSelected || wsAlsoSelected;
792
+ const sharedHelperImports = needsShared
793
+ ? `\nimport { NodeMap, WorkflowRegistry } from "@blokjs/runner";\nimport sharedNodes from "../../Nodes";\nimport sharedWorkflows from "../../Workflows";`
794
+ : "";
795
+ const sseImports = sseAlsoSelected ? `\nimport SSETrigger from "@blokjs/trigger-sse";` : "";
796
+ const wsImports = wsAlsoSelected ? `\nimport WebSocketTrigger from "@blokjs/trigger-websocket";` : "";
797
+ const sharedBootstrapPrelude = needsShared
798
+ ? `\n\n // Build a NodeMap from the shared Nodes record; both SSE and
799
+ // WebSocket triggers consume this via setNodeMap so they can
800
+ // resolve helper nodes (sse-subscribe, sse-stream, ws-reply,
801
+ // etc.) at workflow run time.
802
+ const subTriggerNodeMap = new NodeMap();
803
+ for (const [key, node] of Object.entries(sharedNodes)) {
804
+ subTriggerNodeMap.addNode(key, node);
805
+ }
806
+ // HttpTrigger.buildFileBasedRoutes() calls WorkflowRegistry.clear()
807
+ // during listen() and re-registers only HTTP-triggered workflows.
808
+ // SSE / WebSocket workflows aren't HTTP routes, so they're missing
809
+ // from the registry by the time the sibling trigger walks it.
810
+ // Add a preCatchAllHook BEFORE the sibling triggers register their
811
+ // hooks — preCatchAllHooks fire in insertion order, so this hook
812
+ // injects SSE + WS workflows into the cleared registry first, and
813
+ // each sibling trigger's hook then sees them and mounts routes.
814
+ this.httpTrigger.addPreCatchAllHook(() => {
815
+ const registry = WorkflowRegistry.getInstance();
816
+ for (const [name, wf] of Object.entries(sharedWorkflows)) {
817
+ const w = wf as {
818
+ name?: string;
819
+ trigger?: { sse?: unknown; websocket?: unknown };
820
+ _config?: { name?: string; trigger?: { sse?: unknown; websocket?: unknown } };
821
+ };
822
+ const triggerCfg = w._config?.trigger ?? w.trigger;
823
+ if (!triggerCfg) continue;
824
+ if (!triggerCfg.sse && !triggerCfg.websocket) continue;
825
+ const resolvedName = w._config?.name ?? w.name ?? name;
826
+ if (registry.get(resolvedName)) continue;
827
+ const kind = triggerCfg.sse ? "sse" : "websocket";
828
+ registry.register({
829
+ name: resolvedName,
830
+ source: \`\${kind}:\${name}\`,
831
+ workflow: (w._config ?? w) as unknown as Parameters<typeof registry.register>[0]["workflow"],
832
+ });
833
+ }
834
+ });`
835
+ : "";
836
+ const sseBootstrap = sseAlsoSelected
837
+ ? `\n // Mount SSE on the HTTP process's shared Hono app. SSETrigger's
838
+ // constructor takes the app + the HttpTrigger for pre-catch-all
839
+ // hook integration; SSE routes register inside that hook so
840
+ // they win Hono's first-match dispatch over HTTP's legacy
841
+ // workflow-name catch-all (\`/:workflow{.+}\`).
842
+ const sseTrigger = new SSETrigger(this.httpTrigger.getApp(), this.httpTrigger);
843
+ sseTrigger.setNodeMap({
844
+ nodes: subTriggerNodeMap,
845
+ workflows: sharedWorkflows as unknown as Parameters<typeof sseTrigger.setNodeMap>[0]["workflows"],
846
+ });
847
+ await sseTrigger.listen();`
848
+ : "";
849
+ const wsBootstrap = wsAlsoSelected
850
+ ? `\n // Mount WebSocket on the HTTP process's shared Hono app.
851
+ // WebSocketTrigger uses TWO HttpTrigger integration points:
852
+ // 1. addPreCatchAllHook — registers WS routes (Hono's upgradeWebSocket
853
+ // handler) BEFORE the legacy workflow catch-all so /ws/<path>
854
+ // upgrades cleanly.
855
+ // 2. addServerHook — attaches the WS upgrade listener to the
856
+ // http.Server returned by HttpTrigger's serve() call.
857
+ const wsTrigger = new WebSocketTrigger(this.httpTrigger.getApp(), this.httpTrigger);
858
+ wsTrigger.setNodeMap({
859
+ nodes: subTriggerNodeMap,
860
+ workflows: sharedWorkflows as unknown as Parameters<typeof wsTrigger.setNodeMap>[0]["workflows"],
861
+ });
862
+ await wsTrigger.listen();`
863
+ : "";
864
+ const fullBootstrap = `${sharedBootstrapPrelude}${sseBootstrap}${wsBootstrap}`;
679
865
  return `import { DefaultLogger } from "@blokjs/runner";
680
866
  import { type Span, metrics, trace } from "@opentelemetry/api";
681
- import HttpTrigger from "./runner/HttpTrigger";
867
+ import HttpTrigger from "./runner/HttpTrigger";${sharedHelperImports}${sseImports}${wsImports}
682
868
 
683
869
  export default class App {
684
870
  private httpTrigger: HttpTrigger = <HttpTrigger>{};
@@ -699,7 +885,7 @@ export default class App {
699
885
  }
700
886
 
701
887
  async run() {
702
- this.tracer.startActiveSpan("initialization", async (span: Span) => {
888
+ this.tracer.startActiveSpan("initialization", async (span: Span) => {${fullBootstrap}
703
889
  await this.httpTrigger.listen();
704
890
  this.initializer = performance.now() - this.initializer;
705
891
 
@@ -726,10 +912,10 @@ if (process.env.DISABLE_TRIGGER_RUN !== "true") {
726
912
  if (triggerKind === "sse") {
727
913
  return `import { DefaultLogger } from "@blokjs/runner";
728
914
  import { type Span, metrics, trace } from "@opentelemetry/api";
729
- import SSETrigger from "./SSETrigger";
915
+ import SSEServer from "./runner/SSEServer";
730
916
 
731
917
  export default class App {
732
- private sseTrigger: SSETrigger = <SSETrigger>{};
918
+ private sseServer: SSEServer = <SSEServer>{};
733
919
  protected trigger_initializer = 0;
734
920
  protected initializer = 0;
735
921
  protected tracer = trace.getTracer(
@@ -743,15 +929,59 @@ export default class App {
743
929
 
744
930
  constructor() {
745
931
  this.initializer = performance.now();
746
- this.sseTrigger = new SSETrigger();
932
+ this.sseServer = new SSEServer();
747
933
  }
748
934
 
749
935
  async run() {
750
936
  this.tracer.startActiveSpan("initialization", async (span: Span) => {
751
- await this.sseTrigger.listen();
937
+ await this.sseServer.listen();
752
938
  this.initializer = performance.now() - this.initializer;
753
939
 
754
- this.logger.log(\`Server initialized in \${(this.initializer).toFixed(2)}ms\`);
940
+ this.logger.log(\`SSE trigger initialized in \${(this.initializer).toFixed(2)}ms\`);
941
+ this.app_cold_start.record(this.initializer, {
942
+ pid: process.pid,
943
+ env: process.env.NODE_ENV,
944
+ app: process.env.APP_NAME,
945
+ });
946
+ span.end();
947
+ });
948
+ }
949
+ }
950
+
951
+ if (process.env.DISABLE_TRIGGER_RUN !== "true") {
952
+ new App().run();
953
+ }
954
+ `;
955
+ }
956
+ if (triggerKind === "websocket") {
957
+ return `import { DefaultLogger } from "@blokjs/runner";
958
+ import { type Span, metrics, trace } from "@opentelemetry/api";
959
+ import WSServer from "./runner/WSServer";
960
+
961
+ export default class App {
962
+ private wsServer: WSServer = <WSServer>{};
963
+ protected trigger_initializer = 0;
964
+ protected initializer = 0;
965
+ protected tracer = trace.getTracer(
966
+ process.env.PROJECT_NAME || "trigger-websocket-server",
967
+ process.env.PROJECT_VERSION || "0.0.1",
968
+ );
969
+ private logger = new DefaultLogger();
970
+ protected app_cold_start = metrics.getMeter("default").createGauge("initialization", {
971
+ description: "Application cold start",
972
+ });
973
+
974
+ constructor() {
975
+ this.initializer = performance.now();
976
+ this.wsServer = new WSServer();
977
+ }
978
+
979
+ async run() {
980
+ this.tracer.startActiveSpan("initialization", async (span: Span) => {
981
+ await this.wsServer.listen();
982
+ this.initializer = performance.now() - this.initializer;
983
+
984
+ this.logger.log(\`WebSocket trigger initialized in \${(this.initializer).toFixed(2)}ms\`);
755
985
  this.app_cold_start.record(this.initializer, {
756
986
  pid: process.pid,
757
987
  env: process.env.NODE_ENV,
@@ -860,6 +1090,222 @@ if (process.env.DISABLE_TRIGGER_RUN !== "true") {
860
1090
  console.log("${triggerKind} trigger not yet implemented");
861
1091
  `;
862
1092
  }
1093
+ function generateSSEServerFile() {
1094
+ return `import { serve } from "@hono/node-server";
1095
+ import { DefaultLogger, NodeMap, WorkflowRegistry } from "@blokjs/runner";
1096
+ import { Hono } from "hono";
1097
+ // Import SSETrigger from the @blokjs/trigger-sse npm package — NOT the
1098
+ // locally-copied SSETrigger.ts. The @blokjs/sse-publish helper that
1099
+ // publisher workflows use imports the in-process bus from this exact
1100
+ // module (\`@blokjs/trigger-sse\`'s \`_getSSEBus\`). If SSEServer uses a
1101
+ // different module instance (e.g. the local copy), Node treats them
1102
+ // as separate modules with separate bus singletons — events from the
1103
+ // helper would never reach subscribers on this trigger.
1104
+ import SSETrigger from "@blokjs/trigger-sse";
1105
+ import nodes from "../../../Nodes";
1106
+ import workflows from "../../../Workflows";
1107
+
1108
+ type HonoServer = ReturnType<typeof serve>;
1109
+
1110
+ /**
1111
+ * SSEServer — concrete SSE trigger implementation.
1112
+ *
1113
+ * Bootstraps an isolated Hono app for the SSE trigger process, hands
1114
+ * it to SSETrigger, populates the shared NodeMap, registers
1115
+ * SSE-triggered workflows with WorkflowRegistry, then binds an HTTP
1116
+ * listener so /<sse-path> requests reach the streamer.
1117
+ *
1118
+ * Two SSE-triggered workflows ship by default (see
1119
+ * src/workflows/sse/events/):
1120
+ * - stream-demo.ts GET /sse/demo — subscribes to the
1121
+ * in-process bus channel
1122
+ * "sse-demo" and pumps
1123
+ * events as SSE frames.
1124
+ * - publish-demo.ts POST /v07-sse-publish — publishes one event to
1125
+ * the "sse-demo" channel
1126
+ * (HTTP trigger; only
1127
+ * routable when the
1128
+ * project also includes
1129
+ * an HTTP trigger).
1130
+ *
1131
+ * Test end-to-end:
1132
+ * 1. curl -N http://localhost:4001/sse/demo
1133
+ * 2. curl -X POST http://localhost:4000/v07-sse-publish \\
1134
+ * -H 'Content-Type: application/json' \\
1135
+ * -d '{"event":"hello","data":{"msg":"world"}}'
1136
+ * 3. Watch (1) — the event arrives instantly.
1137
+ */
1138
+ export default class SSEServer {
1139
+ private readonly app: Hono = new Hono();
1140
+ private readonly trigger: SSETrigger;
1141
+ private readonly logger = new DefaultLogger();
1142
+ private httpServer: HonoServer | null = null;
1143
+
1144
+ constructor() {
1145
+ this.trigger = new SSETrigger(this.app);
1146
+
1147
+ // Populate the NodeMap with all registered nodes. SSE workflows
1148
+ // run their steps through the same runner machinery as HTTP,
1149
+ // so they need every node referenced in their step list
1150
+ // (sse-subscribe, sse-stream, plus any user-defined nodes).
1151
+ const nodeMap = new NodeMap();
1152
+ for (const [key, node] of Object.entries(nodes)) {
1153
+ nodeMap.addNode(key, node);
1154
+ }
1155
+ this.trigger.setNodeMap({
1156
+ nodes: nodeMap,
1157
+ workflows: workflows as unknown as Parameters<SSETrigger["setNodeMap"]>[0]["workflows"],
1158
+ });
1159
+
1160
+ // Push SSE-triggered workflows into the WorkflowRegistry.
1161
+ // HTTP-companion workflows (e.g., publish-demo) share the same
1162
+ // Workflows.ts but get registered by the HTTP trigger process
1163
+ // in a multi-trigger scaffold. Filtering here prevents the SSE
1164
+ // trigger from trying to mount HTTP routes.
1165
+ //
1166
+ // Note: \`workflow({...})\` from @blokjs/helper returns a frozen
1167
+ // builder { _blokV2, _config, toJson() }. The trigger config we
1168
+ // filter on lives at \`_config.trigger.sse\`, not at the top-level
1169
+ // (\`.trigger.sse\` is the v1 helper-response shape). Supporting
1170
+ // both keeps the registration tolerant of either authoring style.
1171
+ const registry = WorkflowRegistry.getInstance();
1172
+ for (const [name, wf] of Object.entries(workflows)) {
1173
+ const w = wf as {
1174
+ name?: string;
1175
+ trigger?: { sse?: unknown };
1176
+ _config?: { name?: string; trigger?: { sse?: unknown } };
1177
+ };
1178
+ const triggerCfg = w._config?.trigger ?? w.trigger;
1179
+ if (!triggerCfg?.sse) continue;
1180
+ const resolvedName = w._config?.name ?? w.name ?? name;
1181
+ registry.register({
1182
+ name: resolvedName,
1183
+ source: \`sse:\${name}\`,
1184
+ workflow: (w._config ?? w) as unknown as Parameters<typeof registry.register>[0]["workflow"],
1185
+ });
1186
+ }
1187
+ }
1188
+
1189
+ async listen(): Promise<void> {
1190
+ await this.trigger.listen();
1191
+ const port = Number(process.env.TRIGGER_SSE_PORT || process.env.PORT || 4001);
1192
+ this.httpServer = serve({ fetch: this.app.fetch, port }, () => {
1193
+ this.logger.log(\`SSE server listening on http://localhost:\${port}\`);
1194
+ });
1195
+ }
1196
+
1197
+ async stop(): Promise<void> {
1198
+ await this.trigger.stop();
1199
+ this.httpServer?.close();
1200
+ }
1201
+ }
1202
+ `;
1203
+ }
1204
+ function generateWSServerFile() {
1205
+ return `import { serve } from "@hono/node-server";
1206
+ import { DefaultLogger, NodeMap, WorkflowRegistry } from "@blokjs/runner";
1207
+ import { Hono } from "hono";
1208
+ // Import from the @blokjs/trigger-websocket npm package — NOT the
1209
+ // locally-copied WebSocketTrigger.ts. The WebSocket helper nodes
1210
+ // (@blokjs/ws-broadcast, @blokjs/ws-reply, @blokjs/ws-close) look up
1211
+ // the active trigger via the singleton accessor exported from the npm
1212
+ // package. Using the local copy would create a separate module instance
1213
+ // with a separate singleton — helpers would broadcast into a void.
1214
+ import WebSocketTrigger from "@blokjs/trigger-websocket";
1215
+ import nodes from "../../../Nodes";
1216
+ import workflows from "../../../Workflows";
1217
+
1218
+ type HonoServer = ReturnType<typeof serve>;
1219
+
1220
+ /**
1221
+ * WSServer — concrete WebSocket trigger implementation.
1222
+ *
1223
+ * Bootstraps an isolated Hono app for the WebSocket trigger process,
1224
+ * hands it to WebSocketTrigger, populates the shared NodeMap, registers
1225
+ * WS-triggered workflows with WorkflowRegistry, binds an HTTP listener,
1226
+ * and attaches the WebSocket upgrade handler to the http.Server.
1227
+ *
1228
+ * One WebSocket workflow ships by default (see src/workflows/websocket/
1229
+ * events/):
1230
+ * - echo-demo.ts GET /ws/echo — on \`connect\` greets the client
1231
+ * with \`{event:"connected"}\`; on
1232
+ * each message replies with
1233
+ * \`{event:"echo", original:<msg>}\`.
1234
+ *
1235
+ * Test end-to-end with any WS client:
1236
+ * 1. Connect: \`wscat -c ws://localhost:4002/ws/echo\`
1237
+ * 2. On connect: receive \`{"event":"connected","payload":{"ok":true}}\`
1238
+ * 3. Send anything: \`{"event":"hello","data":{"hi":"there"}}\`
1239
+ * 4. Receive: \`{"event":"echo","payload":{"original":...}}\`
1240
+ */
1241
+ export default class WSServer {
1242
+ private readonly app: Hono = new Hono();
1243
+ private readonly trigger: WebSocketTrigger;
1244
+ private readonly logger = new DefaultLogger();
1245
+ private httpServer: HonoServer | null = null;
1246
+
1247
+ constructor() {
1248
+ this.trigger = new WebSocketTrigger(this.app);
1249
+
1250
+ const nodeMap = new NodeMap();
1251
+ for (const [key, node] of Object.entries(nodes)) {
1252
+ nodeMap.addNode(key, node);
1253
+ }
1254
+ this.trigger.setNodeMap({
1255
+ nodes: nodeMap,
1256
+ workflows: workflows as unknown as Parameters<typeof this.trigger.setNodeMap>[0]["workflows"],
1257
+ });
1258
+
1259
+ // Register WS-triggered workflows in WorkflowRegistry. Same
1260
+ // rationale as SSEServer: \`workflow({...})\` returns a frozen
1261
+ // builder \`{ _blokV2, _config, toJson }\` so the actual trigger
1262
+ // config lives at \`_config.trigger.websocket\`. Tolerate both
1263
+ // shapes so v1 \`Workflow().addTrigger("websocket")\` authoring
1264
+ // also works.
1265
+ const registry = WorkflowRegistry.getInstance();
1266
+ for (const [name, wf] of Object.entries(workflows)) {
1267
+ const w = wf as {
1268
+ name?: string;
1269
+ trigger?: { websocket?: unknown };
1270
+ _config?: { name?: string; trigger?: { websocket?: unknown } };
1271
+ };
1272
+ const triggerCfg = w._config?.trigger ?? w.trigger;
1273
+ if (!triggerCfg?.websocket) continue;
1274
+ const resolvedName = w._config?.name ?? w.name ?? name;
1275
+ if (registry.get(resolvedName)) continue;
1276
+ registry.register({
1277
+ name: resolvedName,
1278
+ source: \`websocket:\${name}\`,
1279
+ workflow: (w._config ?? w) as unknown as Parameters<typeof registry.register>[0]["workflow"],
1280
+ });
1281
+ }
1282
+ }
1283
+
1284
+ async listen(): Promise<void> {
1285
+ await this.trigger.listen();
1286
+ const port = Number(process.env.TRIGGER_WEBSOCKET_PORT || process.env.PORT || 4002);
1287
+ this.httpServer = serve({ fetch: this.app.fetch, port }, () => {
1288
+ this.logger.log(\`WebSocket server listening on http://localhost:\${port}\`);
1289
+ });
1290
+ // Attach WS upgrade listener — WebSocketTrigger sets \`injectWebSocket\`
1291
+ // as a private field inside listen(); when no httpTrigger is provided
1292
+ // to its constructor, the caller must invoke this on the http.Server
1293
+ // after serve() returns.
1294
+ const triggerWithInternals = this.trigger as unknown as {
1295
+ injectWebSocket?: (server: unknown) => void;
1296
+ };
1297
+ if (typeof triggerWithInternals.injectWebSocket === "function" && this.httpServer) {
1298
+ triggerWithInternals.injectWebSocket(this.httpServer);
1299
+ }
1300
+ }
1301
+
1302
+ async stop(): Promise<void> {
1303
+ await this.trigger.stop();
1304
+ this.httpServer?.close();
1305
+ }
1306
+ }
1307
+ `;
1308
+ }
863
1309
  function replaceBlokImportsInDirectory(dirPath) {
864
1310
  const files = fsExtra.readdirSync(dirPath, { withFileTypes: true });
865
1311
  for (const file of files) {
@@ -886,6 +1332,9 @@ function fixRunnerImportPaths(triggerDestDir, triggerKind) {
886
1332
  else if (triggerKind === "sse") {
887
1333
  fileFixes.push({ file: `${triggerDestDir}/SSETrigger.ts`, up: "../../" });
888
1334
  }
1335
+ else if (triggerKind === "websocket") {
1336
+ fileFixes.push({ file: `${triggerDestDir}/WebSocketTrigger.ts`, up: "../../" });
1337
+ }
889
1338
  else if (triggerKind === "pubsub") {
890
1339
  fileFixes.push({ file: `${triggerDestDir}/runner/PubSubServer.ts`, up: "../../../" });
891
1340
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blokctl",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
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.4",
33
+ "@blokjs/runner": "^0.6.6",
34
34
  "@clack/prompts": "^1.0.0",
35
35
  "ai": "^4.3.16",
36
36
  "better-sqlite3": "^12.6.2",