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.
- package/dist/commands/create/project.js +469 -20
- package/package.json +2 -2
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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 (
|
|
563
|
-
supervisordConfContent += generateTriggerSupervisordConfig(
|
|
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
|
-
|
|
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
|
-
${
|
|
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(
|
|
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
|
|
915
|
+
import SSEServer from "./runner/SSEServer";
|
|
730
916
|
|
|
731
917
|
export default class App {
|
|
732
|
-
private
|
|
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.
|
|
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.
|
|
937
|
+
await this.sseServer.listen();
|
|
752
938
|
this.initializer = performance.now() - this.initializer;
|
|
753
939
|
|
|
754
|
-
this.logger.log(\`
|
|
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.
|
|
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.
|
|
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",
|