blokctl 0.6.4 → 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.
- package/dist/commands/create/project.js +456 -19
- 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.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
|
|
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,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.
|
|
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,
|
|
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 (
|
|
563
|
-
supervisordConfContent += generateTriggerSupervisordConfig(
|
|
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
|
-
|
|
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
|
-
${
|
|
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(
|
|
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
|
|
903
|
+
import SSEServer from "./runner/SSEServer";
|
|
730
904
|
|
|
731
905
|
export default class App {
|
|
732
|
-
private
|
|
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.
|
|
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.
|
|
925
|
+
await this.sseServer.listen();
|
|
752
926
|
this.initializer = performance.now() - this.initializer;
|
|
753
927
|
|
|
754
|
-
this.logger.log(\`
|
|
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
|
+
"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.
|
|
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",
|