blokctl 0.6.18 → 0.6.20
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.d.ts +3 -0
- package/dist/commands/create/project.js +94 -53
- package/dist/commands/create/utils/Examples.d.ts +3 -3
- package/dist/commands/create/utils/Examples.js +994 -323
- package/dist/commands/dev/index.js +7 -1
- package/dist/commands/runtime/add.d.ts +2 -0
- package/dist/commands/runtime/add.js +143 -0
- package/dist/commands/runtime/index.d.ts +1 -0
- package/dist/commands/runtime/index.js +43 -0
- package/dist/commands/runtime/list.d.ts +2 -0
- package/dist/commands/runtime/list.js +60 -0
- package/dist/commands/runtime/remove.d.ts +2 -0
- package/dist/commands/runtime/remove.js +114 -0
- package/dist/commands/runtime/shared.d.ts +22 -0
- package/dist/commands/runtime/shared.js +164 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/services/runtime-mutations.d.ts +7 -0
- package/dist/services/runtime-mutations.js +67 -0
- package/package.json +2 -2
- package/dist/commands/marketplace/runtime.d.ts +0 -54
- package/dist/commands/marketplace/runtime.js +0 -350
|
@@ -1,2 +1,5 @@
|
|
|
1
1
|
import type { OptionValues } from "commander";
|
|
2
2
|
export declare function createProject(opts: OptionValues, version: string, currentPath?: boolean, localRepoPath?: string): Promise<void>;
|
|
3
|
+
export declare function updateQueueProvider(triggerDestDir: string, provider: string, explicit: boolean): void;
|
|
4
|
+
export declare function getProviderDependencies(triggers: string[], pubsubProvider: string, queueProvider: string, explicitQueueProvider?: boolean): Record<string, string>;
|
|
5
|
+
export declare function getProviderEnvVars(triggers: string[], pubsubProvider: string, queueProvider: string, explicitQueueProvider?: boolean): string;
|
|
@@ -17,7 +17,16 @@ 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.20";
|
|
21
|
+
const RUNTIME_HELLO_EXAMPLES = {
|
|
22
|
+
go: "runtime-go-hello.ts",
|
|
23
|
+
rust: "runtime-rust-hello.ts",
|
|
24
|
+
java: "runtime-java-hello.ts",
|
|
25
|
+
csharp: "runtime-csharp-hello.ts",
|
|
26
|
+
php: "runtime-php-hello.ts",
|
|
27
|
+
ruby: "runtime-ruby-hello.ts",
|
|
28
|
+
python3: "runtime-python3-hello.ts",
|
|
29
|
+
};
|
|
21
30
|
fsExtra.ensureDirSync(HOME_DIR);
|
|
22
31
|
const options = {
|
|
23
32
|
baseDir: HOME_DIR,
|
|
@@ -43,6 +52,7 @@ export async function createProject(opts, version, currentPath = false, localRep
|
|
|
43
52
|
let selectedManager = opts.packageManager || "npm";
|
|
44
53
|
let pubsubProvider = opts.pubsubProvider || "gcp";
|
|
45
54
|
let queueProvider = opts.queueProvider || "kafka";
|
|
55
|
+
let explicitQueueProvider = Boolean(opts.queueProvider);
|
|
46
56
|
let detectedRuntimes = [];
|
|
47
57
|
if (!skipPrompts) {
|
|
48
58
|
console.log(figlet.textSync("blok CLI".toUpperCase(), {
|
|
@@ -168,6 +178,7 @@ export async function createProject(opts, version, currentPath = false, localRep
|
|
|
168
178
|
projectName = blokctlProject.projectName;
|
|
169
179
|
selectedTriggers = blokctlProject.triggers;
|
|
170
180
|
pubsubProvider = blokctlProject.pubsubProvider || "gcp";
|
|
181
|
+
explicitQueueProvider = blokctlProject.queueProvider != null;
|
|
171
182
|
queueProvider = blokctlProject.queueProvider || "kafka";
|
|
172
183
|
selectedRuntimeKinds = blokctlProject.runtimes;
|
|
173
184
|
selectedManager = blokctlProject.selectedManager;
|
|
@@ -334,7 +345,7 @@ export async function createProject(opts, version, currentPath = false, localRep
|
|
|
334
345
|
updatePubSubProvider(triggerDestDir, pubsubProvider);
|
|
335
346
|
}
|
|
336
347
|
else if (triggerKind === "queue" || triggerKind === "worker") {
|
|
337
|
-
updateQueueProvider(triggerDestDir, queueProvider);
|
|
348
|
+
updateQueueProvider(triggerDestDir, queueProvider, explicitQueueProvider);
|
|
338
349
|
}
|
|
339
350
|
}
|
|
340
351
|
}
|
|
@@ -382,7 +393,7 @@ export async function createProject(opts, version, currentPath = false, localRep
|
|
|
382
393
|
replaceBlokImportsInDirectory(`${dirPath}/src`);
|
|
383
394
|
const sharedNodesContent = generateSharedNodesFile(selectedTriggers, repoSource);
|
|
384
395
|
fsExtra.writeFileSync(`${dirPath}/src/Nodes.ts`, sharedNodesContent);
|
|
385
|
-
const sharedWorkflowsContent = generateSharedWorkflowsFile(selectedTriggers);
|
|
396
|
+
const sharedWorkflowsContent = generateSharedWorkflowsFile(selectedTriggers, selectedRuntimeKinds, examples);
|
|
386
397
|
fsExtra.writeFileSync(`${dirPath}/src/Workflows.ts`, sharedWorkflowsContent);
|
|
387
398
|
const triggersWithRealTemplate = new Set(["worker", "queue", "pubsub"]);
|
|
388
399
|
for (const triggerKind of selectedTriggers) {
|
|
@@ -436,11 +447,14 @@ export async function createProject(opts, version, currentPath = false, localRep
|
|
|
436
447
|
fsExtra.removeSync(`${dirPath}/public/metric`);
|
|
437
448
|
if (selectedTriggers.includes("queue") || selectedTriggers.includes("worker")) {
|
|
438
449
|
fsExtra.ensureDirSync(`${dirPath}/infra/development`);
|
|
439
|
-
fsExtra.copySync(`${repoSource}/infra/development`, `${dirPath}/infra/development
|
|
450
|
+
fsExtra.copySync(`${repoSource}/infra/development`, `${dirPath}/infra/development`, {
|
|
451
|
+
filter: (src) => !/\.dat$|schema\.sql$/.test(src),
|
|
452
|
+
});
|
|
440
453
|
}
|
|
441
454
|
if (!examples) {
|
|
442
455
|
fsExtra.removeSync(`${nodesDir}/examples`);
|
|
443
456
|
fsExtra.removeSync(`${workflowsDir}`);
|
|
457
|
+
fsExtra.removeSync(`${dirPath}/src/workflows/examples`);
|
|
444
458
|
fsExtra.ensureDirSync(`${workflowsDir}`);
|
|
445
459
|
fsExtra.ensureDirSync(`${workflowsDir}/json`);
|
|
446
460
|
fsExtra.ensureDirSync(`${workflowsDir}/yaml`);
|
|
@@ -451,7 +465,7 @@ export async function createProject(opts, version, currentPath = false, localRep
|
|
|
451
465
|
fsExtra.ensureDirSync(`${dirPath}/infra/milvus`);
|
|
452
466
|
fsExtra.copySync(`${repoSource}/infra/development`, `${dirPath}/infra/postgresql`);
|
|
453
467
|
fsExtra.copySync(`${repoSource}/infra/milvus`, `${dirPath}/infra/milvus`);
|
|
454
|
-
const needsHelpers = selectedTriggers.includes("sse") || selectedTriggers.includes("websocket");
|
|
468
|
+
const needsHelpers = selectedTriggers.includes("sse") || selectedTriggers.includes("websocket") || selectedTriggers.includes("mcp");
|
|
455
469
|
const examplesNodesContent = needsHelpers
|
|
456
470
|
? node_file
|
|
457
471
|
.replace(`import type { NodeBase } from "@blokjs/shared";`, `import type { NodeBase } from "@blokjs/shared";\nimport { HELPER_NODES } from "@blokjs/helpers";`)
|
|
@@ -459,6 +473,20 @@ export async function createProject(opts, version, currentPath = false, localRep
|
|
|
459
473
|
: node_file;
|
|
460
474
|
fsExtra.writeFileSync(`${dirPath}/src/Nodes.ts`, examplesNodesContent);
|
|
461
475
|
fsExtra.copySync(`${repoSource}/sdk`, `${dirPath}/public/sdk`);
|
|
476
|
+
const tsExamplesSrc = `${repoSource}/examples/ts-workflows`;
|
|
477
|
+
const tsExamplesDest = `${dirPath}/src/workflows/examples`;
|
|
478
|
+
if (fsExtra.existsSync(tsExamplesSrc)) {
|
|
479
|
+
fsExtra.ensureDirSync(tsExamplesDest);
|
|
480
|
+
if (selectedTriggers.includes("mcp")) {
|
|
481
|
+
fsExtra.copySync(`${tsExamplesSrc}/mcp-greeter.ts`, `${tsExamplesDest}/mcp-greeter.ts`);
|
|
482
|
+
}
|
|
483
|
+
for (const kind of selectedRuntimeKinds) {
|
|
484
|
+
const file = RUNTIME_HELLO_EXAMPLES[kind];
|
|
485
|
+
if (file && fsExtra.existsSync(`${tsExamplesSrc}/${file}`)) {
|
|
486
|
+
fsExtra.copySync(`${tsExamplesSrc}/${file}`, `${tsExamplesDest}/${file}`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
462
490
|
}
|
|
463
491
|
const envExample = `${dirPath}/.env.example`;
|
|
464
492
|
const envLocal = `${dirPath}/.env.local`;
|
|
@@ -487,7 +515,7 @@ export async function createProject(opts, version, currentPath = false, localRep
|
|
|
487
515
|
"@blokjs/trigger-websocket": "triggers/websocket",
|
|
488
516
|
"@blokjs/trigger-worker": "triggers/worker",
|
|
489
517
|
};
|
|
490
|
-
const BLOKJS_DEP_RANGE = "^0.6.
|
|
518
|
+
const BLOKJS_DEP_RANGE = "^0.6.20";
|
|
491
519
|
for (const depGroup of ["dependencies", "devDependencies", "peerDependencies"]) {
|
|
492
520
|
const deps = packageJsonContent[depGroup];
|
|
493
521
|
if (!deps)
|
|
@@ -543,7 +571,7 @@ export async function createProject(opts, version, currentPath = false, localRep
|
|
|
543
571
|
...packageJsonContent.devDependencies,
|
|
544
572
|
blokctl: blokctlRef,
|
|
545
573
|
};
|
|
546
|
-
const providerDeps = getProviderDependencies(selectedTriggers, pubsubProvider, queueProvider);
|
|
574
|
+
const providerDeps = getProviderDependencies(selectedTriggers, pubsubProvider, queueProvider, explicitQueueProvider);
|
|
547
575
|
if (Object.keys(providerDeps).length > 0) {
|
|
548
576
|
packageJsonContent.dependencies = {
|
|
549
577
|
...packageJsonContent.dependencies,
|
|
@@ -650,12 +678,12 @@ export async function createProject(opts, version, currentPath = false, localRep
|
|
|
650
678
|
const triggerEnvVars = generateTriggerEnvVars(triggerConfigs);
|
|
651
679
|
fsExtra.appendFileSync(envLocal, triggerEnvVars);
|
|
652
680
|
}
|
|
653
|
-
const providerEnvVars = getProviderEnvVars(selectedTriggers, pubsubProvider, queueProvider);
|
|
681
|
+
const providerEnvVars = getProviderEnvVars(selectedTriggers, pubsubProvider, queueProvider, explicitQueueProvider);
|
|
654
682
|
if (providerEnvVars) {
|
|
655
683
|
fsExtra.appendFileSync(envLocal, providerEnvVars);
|
|
656
684
|
}
|
|
657
685
|
if (examples) {
|
|
658
|
-
const
|
|
686
|
+
const exampleEnvLines = [
|
|
659
687
|
"",
|
|
660
688
|
"# Chat demo (--examples) — get a free OpenRouter key at https://openrouter.ai/keys",
|
|
661
689
|
"OPENROUTER_API_KEY=",
|
|
@@ -665,26 +693,15 @@ export async function createProject(opts, version, currentPath = false, localRep
|
|
|
665
693
|
"# Start one locally with: docker run --rm -p 6379:6379 redis:7-alpine",
|
|
666
694
|
"# The plain /chat demo works without Redis; only /chat-memory needs it.",
|
|
667
695
|
"REDIS_URL=redis://127.0.0.1:6379",
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
"# Stripe: copy from https://dashboard.stripe.com/webhooks (`whsec_…`).",
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
"#
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
"",
|
|
678
|
-
"# Worker fan-out demo (--examples + --triggers worker) — POST /fanout/jobs with",
|
|
679
|
-
"# `{items: [...], tenantId?: '...'}` enqueues N worker jobs onto `fanout-jobs`.",
|
|
680
|
-
"# in-memory adapter works single-process; for cross-process set BLOK_WORKER_ADAPTER",
|
|
681
|
-
"# to nats / redis / bullmq / rabbitmq / sqs / pg-boss / kafka and supply the matching",
|
|
682
|
-
"# connection env (e.g. NATS_SERVERS=nats://127.0.0.1:4222, or REDIS_URL above).",
|
|
683
|
-
"BLOK_WORKER_ADAPTER=in-memory",
|
|
684
|
-
"NATS_SERVERS=nats://127.0.0.1:4222",
|
|
685
|
-
"",
|
|
686
|
-
].join("\n");
|
|
687
|
-
fsExtra.appendFileSync(envLocal, chatEnvBlock);
|
|
696
|
+
];
|
|
697
|
+
if (selectedTriggers.includes("webhook")) {
|
|
698
|
+
exampleEnvLines.push("", "# Webhook router demo (--examples + --triggers webhook) — secrets per provider.", "# Stripe: copy from https://dashboard.stripe.com/webhooks (`whsec_…`).", "# GitHub: set in repo Settings → Webhooks → secret field.", "# Linear: workspace settings → API → Webhooks → signing secret.", "# Until set, signature verification fails with 401 — that's the gate working.", "STRIPE_WEBHOOK_SECRET=", "GITHUB_WEBHOOK_SECRET=", "LINEAR_WEBHOOK_SECRET=");
|
|
699
|
+
}
|
|
700
|
+
if (selectedTriggers.includes("worker") || selectedTriggers.includes("queue")) {
|
|
701
|
+
exampleEnvLines.push("", "# Worker fan-out demo (--examples + --triggers worker) — POST /fanout/jobs with", "# `{items: [...], tenantId?: '...'}` enqueues N worker jobs onto `fanout-jobs`.", "# in-memory adapter (BLOK_WORKER_ADAPTER above) works single-process; for", "# cross-process set it to nats / redis / bullmq / rabbitmq / sqs / pg-boss /", "# kafka and supply the matching connection env", "# (e.g. NATS_SERVERS=nats://127.0.0.1:4222, or REDIS_URL above).");
|
|
702
|
+
}
|
|
703
|
+
exampleEnvLines.push("");
|
|
704
|
+
fsExtra.appendFileSync(envLocal, exampleEnvLines.join("\n"));
|
|
688
705
|
}
|
|
689
706
|
if (examples) {
|
|
690
707
|
packageJsonContent.dependencies = {
|
|
@@ -726,6 +743,7 @@ export async function createProject(opts, version, currentPath = false, localRep
|
|
|
726
743
|
console.log(`Run the command "npm run dev" to start the development server.`);
|
|
727
744
|
console.log("\nTrigger endpoints:");
|
|
728
745
|
const httpPort = triggerConfigs.find((tc) => tc.kind === "http")?.port;
|
|
746
|
+
const brokerConsumerKinds = new Set(["worker", "queue", "pubsub"]);
|
|
729
747
|
for (const tc of triggerConfigs) {
|
|
730
748
|
if (mountedOnHttp.has(tc.kind) && httpPort !== undefined) {
|
|
731
749
|
const samplePath = tc.kind === "sse"
|
|
@@ -737,6 +755,10 @@ export async function createProject(opts, version, currentPath = false, localRep
|
|
|
737
755
|
: "/ws/echo";
|
|
738
756
|
console.log(` ${tc.label}: http://localhost:${httpPort}${samplePath} (mounted on HTTP)`);
|
|
739
757
|
}
|
|
758
|
+
else if (brokerConsumerKinds.has(tc.kind)) {
|
|
759
|
+
const provider = tc.kind === "pubsub" ? pubsubProvider : explicitQueueProvider ? queueProvider : "in-memory";
|
|
760
|
+
console.log(` ${tc.label}: consumes via ${provider} (no HTTP endpoint)`);
|
|
761
|
+
}
|
|
740
762
|
else {
|
|
741
763
|
console.log(` ${tc.label}: http://localhost:${tc.port}/health-check`);
|
|
742
764
|
}
|
|
@@ -747,19 +769,18 @@ export async function createProject(opts, version, currentPath = false, localRep
|
|
|
747
769
|
console.log(` ${rc.label}: http://localhost:${rc.port}/health`);
|
|
748
770
|
}
|
|
749
771
|
}
|
|
750
|
-
|
|
772
|
+
const workerInfraSelected = explicitQueueProvider && (selectedTriggers.includes("queue") || selectedTriggers.includes("worker"));
|
|
773
|
+
if (workerInfraSelected && queueProvider === "redis") {
|
|
751
774
|
console.log(color.cyan("\n📦 Redis Setup (for Queue trigger):"));
|
|
752
775
|
console.log(" Start Redis with Docker:");
|
|
753
776
|
console.log(color.dim(" cd infra/development"));
|
|
754
|
-
console.log(color.dim(" docker network create shared-network"));
|
|
755
777
|
console.log(color.dim(" docker compose up -d redis redis-commander"));
|
|
756
778
|
console.log(" Redis Commander UI: http://localhost:8081");
|
|
757
779
|
}
|
|
758
|
-
if (
|
|
780
|
+
if (workerInfraSelected && queueProvider === "nats") {
|
|
759
781
|
console.log(color.cyan("\n📦 NATS JetStream Setup (for Queue trigger):"));
|
|
760
782
|
console.log(" Start NATS with Docker:");
|
|
761
783
|
console.log(color.dim(" cd infra/development"));
|
|
762
|
-
console.log(color.dim(" docker network create shared-network"));
|
|
763
784
|
console.log(color.dim(" docker compose up -d nats"));
|
|
764
785
|
console.log(" NATS Monitoring: http://localhost:8222");
|
|
765
786
|
}
|
|
@@ -802,7 +823,7 @@ ${recordBody}
|
|
|
802
823
|
export default nodes;
|
|
803
824
|
`;
|
|
804
825
|
}
|
|
805
|
-
function generateSharedWorkflowsFile(triggers) {
|
|
826
|
+
function generateSharedWorkflowsFile(triggers, runtimeKinds = [], examples = false) {
|
|
806
827
|
const imports = [];
|
|
807
828
|
const workflowEntries = [];
|
|
808
829
|
for (const trigger of triggers) {
|
|
@@ -830,12 +851,27 @@ function generateSharedWorkflowsFile(triggers) {
|
|
|
830
851
|
workflowEntries.push('\t"process-job": ProcessJob,');
|
|
831
852
|
}
|
|
832
853
|
}
|
|
854
|
+
if (examples) {
|
|
855
|
+
if (triggers.includes("mcp")) {
|
|
856
|
+
imports.push('import McpGreeter from "./workflows/examples/mcp-greeter";');
|
|
857
|
+
workflowEntries.push('\t"mcp-greeter": McpGreeter,');
|
|
858
|
+
}
|
|
859
|
+
for (const kind of runtimeKinds) {
|
|
860
|
+
const file = RUNTIME_HELLO_EXAMPLES[kind];
|
|
861
|
+
if (!file)
|
|
862
|
+
continue;
|
|
863
|
+
const fileBase = file.replace(/\.ts$/, "");
|
|
864
|
+
const importName = `Runtime${kind.charAt(0).toUpperCase()}${kind.slice(1)}Hello`;
|
|
865
|
+
imports.push(`import ${importName} from "./workflows/examples/${fileBase}";`);
|
|
866
|
+
workflowEntries.push(`\t"${fileBase}": ${importName},`);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
833
869
|
const importSection = imports.length > 0 ? `${imports.join("\n")}\n` : "";
|
|
834
870
|
const entriesSection = workflowEntries.length > 0 ? workflowEntries.join("\n") : "\t// Add your workflows here";
|
|
835
|
-
return `import type { HelperResponse } from "@blokjs/helper";
|
|
871
|
+
return `import type { HelperResponse, WorkflowV2Builder } from "@blokjs/helper";
|
|
836
872
|
|
|
837
873
|
${importSection}
|
|
838
|
-
const workflows: Record<string, HelperResponse> = {
|
|
874
|
+
const workflows: Record<string, HelperResponse | WorkflowV2Builder> = {
|
|
839
875
|
${entriesSection}
|
|
840
876
|
};
|
|
841
877
|
|
|
@@ -876,7 +912,7 @@ function generateTriggerEntryFile(triggerKind, selectedTriggers = [triggerKind])
|
|
|
876
912
|
this.httpTrigger.addPreCatchAllHook(() => {
|
|
877
913
|
const registry = WorkflowRegistry.getInstance();
|
|
878
914
|
for (const [name, wf] of Object.entries(sharedWorkflows)) {
|
|
879
|
-
const w = wf as {
|
|
915
|
+
const w = wf as unknown as {
|
|
880
916
|
name?: string;
|
|
881
917
|
trigger?: { sse?: unknown; websocket?: unknown; webhook?: unknown; mcp?: unknown };
|
|
882
918
|
_config?: { name?: string; trigger?: { sse?: unknown; websocket?: unknown; webhook?: unknown; mcp?: unknown } };
|
|
@@ -1269,7 +1305,7 @@ export default class SSEServer {
|
|
|
1269
1305
|
// both keeps the registration tolerant of either authoring style.
|
|
1270
1306
|
const registry = WorkflowRegistry.getInstance();
|
|
1271
1307
|
for (const [name, wf] of Object.entries(workflows)) {
|
|
1272
|
-
const w = wf as {
|
|
1308
|
+
const w = wf as unknown as {
|
|
1273
1309
|
name?: string;
|
|
1274
1310
|
trigger?: { sse?: unknown };
|
|
1275
1311
|
_config?: { name?: string; trigger?: { sse?: unknown } };
|
|
@@ -1363,7 +1399,7 @@ export default class WSServer {
|
|
|
1363
1399
|
// also works.
|
|
1364
1400
|
const registry = WorkflowRegistry.getInstance();
|
|
1365
1401
|
for (const [name, wf] of Object.entries(workflows)) {
|
|
1366
|
-
const w = wf as {
|
|
1402
|
+
const w = wf as unknown as {
|
|
1367
1403
|
name?: string;
|
|
1368
1404
|
trigger?: { websocket?: unknown };
|
|
1369
1405
|
_config?: { name?: string; trigger?: { websocket?: unknown } };
|
|
@@ -1481,11 +1517,13 @@ function updatePubSubProvider(triggerDestDir, provider) {
|
|
|
1481
1517
|
content = content.replace(/(export default class \w+ extends PubSubTrigger \{[\s\S]*?)\n\tprotected adapter = new \w+\(\{[\s\S]*?\}\);/, `$1\n\tprotected adapter = ${config.init};`);
|
|
1482
1518
|
fsExtra.writeFileSync(serverPath, content);
|
|
1483
1519
|
}
|
|
1484
|
-
function updateQueueProvider(triggerDestDir, provider) {
|
|
1520
|
+
export function updateQueueProvider(triggerDestDir, provider, explicit) {
|
|
1485
1521
|
const serverPath = `${triggerDestDir}/runner/WorkerServer.ts`;
|
|
1486
1522
|
if (!fsExtra.existsSync(serverPath))
|
|
1487
1523
|
return;
|
|
1488
1524
|
let content = fsExtra.readFileSync(serverPath, "utf8");
|
|
1525
|
+
if (!explicit)
|
|
1526
|
+
return;
|
|
1489
1527
|
const adapterConfigs = {
|
|
1490
1528
|
kafka: {
|
|
1491
1529
|
importName: "KafkaAdapter",
|
|
@@ -1525,17 +1563,11 @@ function updateQueueProvider(triggerDestDir, provider) {
|
|
|
1525
1563
|
const config = adapterConfigs[provider];
|
|
1526
1564
|
if (!config)
|
|
1527
1565
|
return;
|
|
1528
|
-
content = content.replace(/import \{
|
|
1529
|
-
content = content.replace(/(export default class \w+ extends WorkerTrigger \{
|
|
1566
|
+
content = content.replace(/import \{ WorkerTrigger \} from ["']@blokjs\/trigger-worker["'];/, `import { ${config.importName}, WorkerTrigger } from "@blokjs/trigger-worker";`);
|
|
1567
|
+
content = content.replace(/(export default class \w+ extends WorkerTrigger \{)/, `$1\n\tprotected adapter = ${config.init};\n`);
|
|
1530
1568
|
fsExtra.writeFileSync(serverPath, content);
|
|
1531
|
-
const workflowPath = `${triggerDestDir}/workflows/messages/on-message.ts`;
|
|
1532
|
-
if (fsExtra.existsSync(workflowPath)) {
|
|
1533
|
-
let workflowContent = fsExtra.readFileSync(workflowPath, "utf8");
|
|
1534
|
-
workflowContent = workflowContent.replace(/provider: "kafka"/, `provider: "${provider}"`);
|
|
1535
|
-
fsExtra.writeFileSync(workflowPath, workflowContent);
|
|
1536
|
-
}
|
|
1537
1569
|
}
|
|
1538
|
-
function getProviderDependencies(triggers, pubsubProvider, queueProvider) {
|
|
1570
|
+
export function getProviderDependencies(triggers, pubsubProvider, queueProvider, explicitQueueProvider = false) {
|
|
1539
1571
|
const deps = {};
|
|
1540
1572
|
const pubsubProviderDeps = {
|
|
1541
1573
|
gcp: { "@google-cloud/pubsub": "^5.0.0" },
|
|
@@ -1552,12 +1584,14 @@ function getProviderDependencies(triggers, pubsubProvider, queueProvider) {
|
|
|
1552
1584
|
if (triggers.includes("pubsub") && pubsubProviderDeps[pubsubProvider]) {
|
|
1553
1585
|
Object.assign(deps, pubsubProviderDeps[pubsubProvider]);
|
|
1554
1586
|
}
|
|
1555
|
-
if (
|
|
1587
|
+
if (explicitQueueProvider &&
|
|
1588
|
+
(triggers.includes("queue") || triggers.includes("worker")) &&
|
|
1589
|
+
queueProviderDeps[queueProvider]) {
|
|
1556
1590
|
Object.assign(deps, queueProviderDeps[queueProvider]);
|
|
1557
1591
|
}
|
|
1558
1592
|
return deps;
|
|
1559
1593
|
}
|
|
1560
|
-
function getProviderEnvVars(triggers, pubsubProvider, queueProvider) {
|
|
1594
|
+
export function getProviderEnvVars(triggers, pubsubProvider, queueProvider, explicitQueueProvider = false) {
|
|
1561
1595
|
const lines = [];
|
|
1562
1596
|
const pubsubEnvVars = {
|
|
1563
1597
|
gcp: `
|
|
@@ -1599,8 +1633,15 @@ NATS_STREAM_NAME=blok-queue`,
|
|
|
1599
1633
|
if (triggers.includes("pubsub") && pubsubEnvVars[pubsubProvider]) {
|
|
1600
1634
|
lines.push(pubsubEnvVars[pubsubProvider]);
|
|
1601
1635
|
}
|
|
1602
|
-
|
|
1603
|
-
|
|
1636
|
+
const hasWorkerTrigger = triggers.includes("queue") || triggers.includes("worker");
|
|
1637
|
+
if (hasWorkerTrigger) {
|
|
1638
|
+
if (explicitQueueProvider && queueEnvVars[queueProvider]) {
|
|
1639
|
+
lines.push(queueEnvVars[queueProvider]);
|
|
1640
|
+
}
|
|
1641
|
+
else {
|
|
1642
|
+
lines.push("\n# Worker adapter — dev default. Set a provider + the matching broker env" +
|
|
1643
|
+
"\n# (KAFKA_*, NATS_SERVERS, REDIS_*, etc.) for production.\nBLOK_WORKER_ADAPTER=in-memory");
|
|
1644
|
+
}
|
|
1604
1645
|
}
|
|
1605
1646
|
return lines.join("\n");
|
|
1606
1647
|
}
|
|
@@ -13,7 +13,7 @@ declare const package_dev_dependencies: {
|
|
|
13
13
|
"@types/pg": string;
|
|
14
14
|
};
|
|
15
15
|
declare const python3_file = "\nfrom core.blok import BlokService\nfrom core.types.context import Context\nfrom core.types.blok_response import BlokResponse\nfrom core.types.global_error import GlobalError\nfrom typing import Any, Dict\nimport traceback\n\nclass Node(BlokService):\n def __init__(self):\n BlokService.__init__(self)\n self.input_schema = {}\n self.output_schema = {}\n\n async def handle(self, ctx: Context, inputs: Dict[str, Any]) -> BlokResponse:\n response = BlokResponse()\n\n try:\n response.setSuccess({ \"message\": \"Hello World from Python3!\" })\n except Exception as error:\n err = GlobalError(error)\n err.setCode(500)\n err.setName(self.name)\n\n stack_trace = traceback.format_exc()\n err.setStack(stack_trace)\n response.success = False\n response.setError(err)\n\n return response\n";
|
|
16
|
-
declare const examples_url = "\nExamples:\n1- Open \"workflow-docs.json\" in your browser at http://localhost:4000/workflow-docs\n2- Open \"db-manager.json\" in your browser at http://localhost:4000/db-manager\n3- Open \"dashboard-gen.json\" in your browser at http://localhost:4000/dashboard-gen\n4- Open \"countries.json\" in your browser at http://localhost:4000/countries\n5- Open \"chat.json\" in your browser at http://localhost:4000/chat (set OPENROUTER_API_KEY first)\n6- Open \"chat-memory.json\" in your browser at http://localhost:4000/chat-memory (needs OPENROUTER_API_KEY + Redis at REDIS_URL)\n7- Webhook router: POST /webhooks/{stripe,github,linear} with signed bodies \u2014 set the matching *_WEBHOOK_SECRET env vars (needs --triggers webhook)\n8- LLM agent w/ tool calls: open http://localhost:4000/agent \u2014 model picks between get_weather and calculate tools (needs OPENROUTER_API_KEY + Redis)\n9- Worker fan-out: POST /fanout/jobs with body '{items:[...], tenantId?:\"...\"}' to enqueue N worker jobs (needs --triggers worker; BLOK_WORKER_ADAPTER=in-memory works single-process)\n\nFor more documentation, visit src/nodes/examples/README.md. The first three examples require a PostgreSQL database to function.\n";
|
|
16
|
+
declare const examples_url = "\nExamples:\n1- Open \"workflow-docs.json\" in your browser at http://localhost:4000/workflow-docs\n2- Open \"db-manager.json\" in your browser at http://localhost:4000/db-manager\n3- Open \"dashboard-gen.json\" in your browser at http://localhost:4000/dashboard-gen\n4- Open \"countries.json\" in your browser at http://localhost:4000/countries\n5- Open \"chat.json\" in your browser at http://localhost:4000/chat (set OPENROUTER_API_KEY first)\n6- Open \"chat-memory.json\" in your browser at http://localhost:4000/chat-memory (needs OPENROUTER_API_KEY + Redis at REDIS_URL)\n7- Webhook router: POST /webhooks/{stripe,github,linear} with signed bodies \u2014 set the matching *_WEBHOOK_SECRET env vars (needs --triggers webhook)\n8- LLM agent w/ tool calls: open http://localhost:4000/agent \u2014 model picks between get_weather and calculate tools (needs OPENROUTER_API_KEY + Redis)\n9- Worker fan-out: POST /fanout/jobs with body '{items:[...], tenantId?:\"...\"}' to enqueue N worker jobs (needs --triggers worker; BLOK_WORKER_ADAPTER=in-memory works single-process)\n10- Trigger references (NOT http): workflows/json/{cron-heartbeat,pubsub-on-order,websocket-echo}.json demonstrate the cron, pubsub, and websocket triggers \u2014 read AGENTS.md \"Choosing a trigger\" to pick the right one by intent instead of defaulting to HTTP.\n\nFor more documentation, visit src/nodes/examples/README.md. The first three examples require a PostgreSQL database to function.\n";
|
|
17
17
|
declare const workflow_template = "\n{\n\t\"name\": \"My Workflow\",\n\t\"description\": \"What this workflow does\",\n\t\"version\": \"1.0.0\",\n\t\"trigger\": {\n\t\t\"http\": {\n\t\t\t\"method\": \"GET\",\n\t\t\t\"accept\": \"application/json\"\n\t\t}\n\t},\n\t\"steps\": [\n\t\t{\n\t\t\t\"id\": \"echo\",\n\t\t\t\"use\": \"@blokjs/respond\",\n\t\t\t\"inputs\": {\n\t\t\t\t\"body\": \"$.req.body\"\n\t\t\t}\n\t\t}\n\t]\n}\n";
|
|
18
18
|
declare const supervisord_nodejs = "\n[supervisord]\nnodaemon=true\n\n[program:nodejs_app]\ncommand=npm start\ndirectory=/app\nautostart=true\nautorestart=true\nstderr_logfile=/var/log/nodejs.err.log\nstdout_logfile=/var/log/nodejs.out.log\n";
|
|
19
19
|
declare const supervisord_python = "\n[program:python_app]\ncommand=python3 /app/.blok/runtimes/python3/server.py\ndirectory=/app\nautostart=true\nautorestart=true\nstderr_logfile=/var/log/python.err.log\nstdout_logfile=/var/log/python.out.log\n";
|
|
@@ -35,7 +35,7 @@ declare const php_dockerfile = "FROM php:8.2-cli-alpine AS builder\nWORKDIR /app
|
|
|
35
35
|
declare const ruby_node_file = "require_relative '../../lib/blok'\n\nmodule Blok\n module Nodes\n class {{NODE_NAME_PASCAL}}Node < Blok::NodeHandler\n def execute(ctx, config)\n # Access request body\n name = ctx.request.body.is_a?(Hash) ? ctx.request.body['name'] : nil\n name ||= 'World'\n\n # Access configuration\n prefix = config['prefix'] || 'Hello'\n\n message = \"#{prefix}, #{name}!\"\n\n # Store in context for downstream nodes\n ctx.vars['greeting'] = message\n\n # Return response\n {\n 'message' => message,\n 'timestamp' => Time.now.utc.iso8601,\n 'language' => 'Ruby'\n }\n end\n end\n end\nend\n";
|
|
36
36
|
declare const ruby_gemfile = "source 'https://rubygems.org'\n\nruby '>= 3.1'\n\ngem 'sinatra', '~> 4.0'\ngem 'puma', '~> 6.4'\ngem 'rackup', '~> 2.1'\n";
|
|
37
37
|
declare const ruby_dockerfile = "FROM ruby:3.2-alpine AS builder\nRUN apk add --no-cache build-base\nWORKDIR /app\nCOPY Gemfile Gemfile.lock ./\nRUN bundle install --without development test\n\nFROM ruby:3.2-alpine\nRUN apk --no-cache add ca-certificates wget\nWORKDIR /app\nCOPY --from=builder /usr/local/bundle /usr/local/bundle\nCOPY . .\n\nEXPOSE 8080\nENV PORT=8080\nENV RACK_ENV=production\nHEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\\n CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1\n\nCMD [\"bundle\", \"exec\", \"puma\", \"-b\", \"tcp://0.0.0.0:8080\"]\n";
|
|
38
|
-
declare const agents_md = "# Blok Project\n\nBlok is a TypeScript-first workflow orchestration framework. It executes declarative workflows (JSON or TypeScript DSL) composed of steps (nodes) that run across 8 language runtimes: NodeJS, Python3, Go, Rust, Java, C#, PHP, and Ruby.\n\n## Project Structure\n\n```\n\u251C\u2500\u2500 src/\n\u2502 \u2514\u2500\u2500 nodes/ # TypeScript node implementations\n\u251C\u2500\u2500 runtimes/ # Non-NodeJS runtime nodes (Go, Python3, etc.)\n\u2502 \u2514\u2500\u2500 {lang}/nodes/ # Language-specific node implementations\n\u251C\u2500\u2500 workflows/\n\u2502 \u251C\u2500\u2500 json/ # Workflow definitions (JSON)\n\u2502 \u251C\u2500\u2500 yaml/ # Workflow definitions (YAML)\n\u2502 \u2514\u2500\u2500 toml/ # Workflow definitions (TOML)\n\u251C\u2500\u2500 .blok/\n\u2502 \u251C\u2500\u2500 config.json # Runtime configuration (ports, start commands)\n\u2502 \u2514\u2500\u2500 runtimes/ # Auto-generated runtime scaffolds\n\u251C\u2500\u2500 .env.local # Environment variables (ports, paths)\n\u2514\u2500\u2500 supervisord.conf # Process management config\n```\n\n## Commands\n\n```bash\nnpm run dev # Start dev server (or blokctl dev for multi-runtime)\nnpm run build # Build project\nnpm test # Run tests\nblokctl create node <name> # Scaffold a new node\nblokctl create workflow <n># Scaffold a new workflow\nblokctl trace # Open Blok Studio (trace visualization)\nblokctl studio # Alias for blokctl trace\n```\n\n## Context \u2014 Critical Data Flow\n\nThe Context type is the central execution state passed through every step.\n\n```typescript\ntype Context = {\n id: string; // Unique request ID\n request: RequestContext; // Incoming request (body, headers, params, query)\n response: ResponseContext; // Current step output \u2014 OVERWRITTEN every step\n vars?: VarsContext; // Persistent variables \u2014 PERSISTS across workflow\n config: ConfigContext; // Node config (inputs resolved by Mapper)\n env?: EnvContext; // process.env access\n logger: LoggerContext;\n error: ErrorContext;\n};\n```\n\n### The Two Critical Rules\n\n**Rule 1: \\`ctx.prev\\` carries the immediately previous step's output.**\nEach step's output replaces \\`ctx.prev\\`. Use it for adjacent-step access only.\n\n**Rule 2: \\`ctx.state[id]\\` PERSISTS across the entire workflow.**\nEvery step's output is auto-stored at \\`ctx.state[<step-id>]\\` (the v2 default-store rule). Downstream steps reference it via \\`$.state.<id>\\` (TS DSL) or \\`\"$.state.<id>\"\\` / \\`\"js/ctx.state.<id>\"\\` (JSON). Opt out per step with \\`ephemeral: true\\`.\n\n### Data Flow Example\n\n```\nStep 1: id \"fetch-user\"\n \u2192 ctx.state[\"fetch-user\"] = { id: \"123\", name: \"Alice\" }\n \u2192 ctx.prev = { id: \"123\", name: \"Alice\" }\n\nStep 2: id \"transform\"\n \u2192 ctx.state[\"transform\"] = { result: \"done\" }\n \u2192 ctx.prev = { result: \"done\" } \u2190 Step 1 output GONE from prev\n \u2192 ctx.state[\"fetch-user\"] still available\n\nStep 3: id \"output\"\n \u2192 Can read ctx.state[\"fetch-user\"].name \u2190 still \"Alice\"\n```\n\n### Blueprint Mapper \u2014 Expression Resolution\n\nNode inputs support dynamic expressions resolved BEFORE node execution:\n\n```json\n{\n \"inputs\": {\n \"userId\": \"js/ctx.request.body.userId\",\n \"chain\": \"js/ctx.vars['previous-step'].chain\",\n \"previous\": \"js/ctx.response.data.result\"\n }\n}\n```\n\nAvailable in js/ expressions: \\`ctx\\` (full context), \\`data\\` (ctx.prev.data), \\`func\\` (ctx.func), \\`vars\\` (alias for ctx.state).\n\n---\n\n## Creating Nodes with defineNode\n\nUse \\`defineNode()\\` for all new nodes. Never use the legacy class-based pattern.\n\n```typescript\nimport { defineNode } from \"@blokjs/runner\";\nimport { z } from \"zod\";\n\nexport default defineNode({\n name: \"fetch-user\",\n description: \"Fetches user by ID\",\n\n input: z.object({\n userId: z.string().uuid(),\n }),\n\n output: z.object({\n user: z.object({\n id: z.string(),\n name: z.string(),\n email: z.string().email(),\n }),\n }),\n\n async execute(ctx, input) {\n const user = await fetchUser(input.userId);\n return { user };\n },\n});\n```\n\n### Key Behaviors\n\n- Zod input/output validation runs automatically\n- ZodError is mapped to GlobalError with HTTP 400\n- \\`flow: true\\` nodes return NodeBase[] for conditional execution\n- \\`contentType\\` sets response Content-Type (e.g., \"text/html\")\n- Always \\`export default defineNode(...)\\`\n\n---\n\n## Workflow Structure (JSON)\n\n```json\n{\n \"name\": \"My Workflow\",\n \"version\": \"1.0.0\",\n \"trigger\": {\n \"http\": { \"method\": \"POST\", \"path\": \"/api/process\", \"accept\": \"application/json\" }\n },\n \"steps\": [\n { \"id\": \"fetch\", \"use\": \"@blokjs/api-call\", \"inputs\": { \"url\": \"https://api.example.com\", \"method\": \"GET\" } },\n { \"id\": \"process\", \"use\": \"my-node\", \"inputs\": { \"data\": \"$.state.fetch\" } },\n { \"id\": \"go-step\", \"use\": \"chain-test\", \"type\": \"runtime.go\", \"inputs\": { \"processed\": \"$.state.process\" } }\n ]\n}\n```\n\n### Workflow Naming\n\nEvery workflow's \\`name\\` must be UNIQUE across the project. The\n\\`WorkflowRegistry\\` rejects duplicate names at boot, so a collision\nmeans only one of the colliding workflows ever registers.\n\nPrefer a dotted \\`domain.action\\` convention \u2014 \\`countries.list\\`,\n\\`users.create\\`, \\`orders.refund\\`. The typed client\n(\\`@blokjs/client\\`) and \\`blokctl gen app-types\\` nest workflows by\ntheir dotted name, so a clean name surfaces as\n\\`blok.countries.list(...)\\` instead of a quoted\n\\`blok[\"World Countries\"]\\` accessor. Duplicate names also make\n\\`gen app-types\\` report a collision and DROP one workflow from the\ngenerated \\`BlokApp\\` type.\n\n### Step Types\n\n| Type | Description |\n|------|-------------|\n| \\`module\\` | TypeScript node from registered modules |\n| \\`local\\` | TypeScript node from filesystem (NODES_PATH) |\n| \\`runtime.python3\\` | Python3 SDK container (port 9007) |\n| \\`runtime.go\\` | Go SDK container (port 9001) |\n| \\`runtime.rust\\` | Rust SDK container (port 9002) |\n| \\`runtime.java\\` | Java SDK container (port 9003) |\n| \\`runtime.csharp\\` | C# SDK container (port 9004) |\n| \\`runtime.php\\` | PHP SDK container (port 9005) |\n| \\`runtime.ruby\\` | Ruby SDK container (port 9006) |\n\n### Conditional Workflow (if-else)\n\n```json\n{\n \"nodes\": {\n \"filter\": {\n \"conditions\": [\n {\n \"type\": \"if\",\n \"condition\": \"ctx.request.query.active === \\\\\"true\\\\\"\",\n \"steps\": [{ \"name\": \"active-path\", \"node\": \"handle-active\", \"type\": \"module\" }]\n },\n {\n \"type\": \"else\",\n \"steps\": [{ \"name\": \"default-path\", \"node\": \"handle-default\", \"type\": \"module\" }]\n }\n ]\n }\n }\n}\n```\n\n---\n\n## Trigger Types\n\n| Trigger | Example Config |\n|---------|---------------|\n| \\`http\\` | \\`{ \"method\": \"GET\", \"path\": \"/\", \"accept\": \"application/json\" }\\` |\n| \\`grpc\\` | \\`{ \"service\": \"UserService\", \"method\": \"GetUser\" }\\` |\n| \\`cron\\` | \\`{ \"schedule\": \"0 * * * *\", \"timezone\": \"UTC\" }\\` |\n| \\`queue\\` | \\`{ \"provider\": \"kafka\", \"topic\": \"events\" }\\` |\n| \\`pubsub\\` | \\`{ \"provider\": \"gcp\", \"topic\": \"updates\" }\\` |\n| \\`webhook\\` | \\`{ \"source\": \"github\", \"events\": [\"push\"] }\\` |\n| \\`websocket\\` | \\`{ \"events\": [\"message\"], \"path\": \"/ws\" }\\` |\n| \\`sse\\` | \\`{ \"events\": [\"update\"], \"path\": \"/stream\" }\\` |\n| \\`worker\\` | \\`{ \"queue\": \"jobs\", \"concurrency\": 5, \"retries\": 3 }\\` |\n\n### Worker Trigger\n\nThe worker trigger processes background jobs from a queue with retry logic and concurrency control.\n\n\\`\\`\\`typescript\nWorkflow({ name: \"Process Job\", version: \"1.0.0\" })\n .addTrigger(\"worker\", { queue: \"background-jobs\" })\n .addStep({\n name: \"process\",\n node: \"my-processor\",\n type: \"module\",\n inputs: { payload: \"js/ctx.request.body\", jobId: \"js/ctx.request.params.jobId\" },\n });\n\\`\\`\\`\n\nJob context: \\`ctx.request.body\\` = payload, \\`ctx.request.params.queue\\` = queue name, \\`ctx.request.params.jobId\\` = job ID, \\`ctx.request.params.attempt\\` = attempt count, \\`ctx.vars._worker_job\\` = full metadata.\n\nAdapters: NATS JetStream (recommended), BullMQ (Redis), InMemory (dev only).\n\n### NATS JetStream\n\nRecommended queue/worker backend. Environment variables:\n\\`\\`\\`\nNATS_SERVERS=localhost:4222\nNATS_STREAM_NAME=blok-queue # or blok-worker for worker trigger\nNATS_TOKEN= # optional auth\n\\`\\`\\`\n\nQueue providers: \\`kafka\\`, \\`rabbitmq\\`, \\`sqs\\`, \\`redis\\`, \\`beanstalk\\`, \\`nats\\`\n\n### Standalone Workers (Go, Rust, Python)\n\nGo, Rust, and Python SDKs include standalone NATS workers that connect directly to NATS without the TypeScript runner:\n\n\\`\\`\\`\nWORKER_CONCURRENCY=1 # Max concurrent jobs\nWORKER_MAX_RETRIES=3 # Max delivery attempts\nWORKER_QUEUES=queue1,queue2 # Queues to consume\n\\`\\`\\`\n\n---\n\n## Testing Utilities\n\n\\`@blokjs/runner\\` provides testing utilities for nodes and workflows.\n\n### NodeTestHarness \u2014 Unit test a single node:\n\\`\\`\\`typescript\nimport { NodeTestHarness } from \"@blokjs/runner\";\nconst harness = new NodeTestHarness(myNode);\nconst result = await harness.execute({ input: \"data\" });\nharness.assertSuccess(result);\nharness.assertOutput(result, { expected: \"output\" });\n\\`\\`\\`\n\n### WorkflowTestRunner \u2014 Integration test a workflow:\n\\`\\`\\`typescript\nimport { WorkflowTestRunner } from \"@blokjs/runner\";\nconst runner = new WorkflowTestRunner({ verbose: true });\nrunner.registerNode(\"validate\", ValidateNode);\nrunner.mockNode(\"external-api\", async (input) => ({ result: \"mocked\" }));\nrunner.loadWorkflow(workflowDefinition);\nconst result = await runner.execute({ input: \"data\" });\n// result.success, result.output, result.trace, result.nodeResults\n\\`\\`\\`\n\n---\n\n## Runtime Adapter System\n\nAll non-NodeJS SDKs communicate via HTTP:\n- **POST /execute** \u2014 Execute node with context\n- **GET /health** \u2014 Health check\n\nEnvironment variables: \\`RUNTIME_{LANG}_HOST\\` / \\`RUNTIME_{LANG}_PORT\\`\n\nRuntime nodes auto-save \\`result.data\\` to \\`ctx.vars[stepName]\\`.\n\n---\n\n## Blok Studio\n\nReal-time workflow trace visualization UI.\n\n- Launch: \\`blokctl trace\\` or \\`blokctl studio\\`\n- API: \\`/__blok/runs\\`, \\`/__blok/runs/:id\\`, \\`/__blok/runs/:id/stream\\` (SSE)\n- Disable: \\`BLOK_TRACE_ENABLED=false\\`\n\n---\n\n## Do NOT\n\n- Do NOT rely on \\`ctx.response.data\\` for data from non-previous steps \u2014 it gets overwritten\n- Do NOT create class-based nodes \u2014 use \\`defineNode()\\` instead\n- Do NOT use \\`any\\` type \u2014 use \\`unknown\\` and narrow with Zod\n- Do NOT hardcode runtime ports \u2014 use environment variables\n- Do NOT skip Zod input/output schemas\n- Do NOT edit files in \\`.blok/runtimes/\\` \u2014 they are auto-generated\n\n## Do\n\n- Use \\`$.state.<id>\\` (or \\`js/ctx.state.<id>\\`) to pass data between non-adjacent steps \u2014 every step default-stores its output there\n- Opt out per step with \\`ephemeral: true\\` when the step is a side effect only\n- Use Zod schemas for all input/output validation\n- Use \\`defineNode()\\` for all new nodes\n- Handle errors via GlobalError with appropriate HTTP status codes\n- Keep nodes focused \u2014 one responsibility per node\n";
|
|
39
|
-
declare const claude_md = "# Blok Project \u2014 Claude Code Guide\n\nRead \\`AGENTS.md\\` for full architecture and API details. This file contains Claude-specific guidance.\n\n## Quick Commands\n\n\\`\\`\\`bash\nnpm run dev # Start dev server\nblokctl dev # Multi-runtime dev server\nblokctl create node <name> # Scaffold new node\nblokctl create workflow <name> # Scaffold new workflow\nblokctl trace # Open Blok Studio\nnpm test # Run tests\n\\`\\`\\`\n\n## Context Rules (Memorize These)\n\n1. **\\`ctx.prev\\` is the immediately previous step's output.** Overwritten every step.\n2. **\\`ctx.state[<id>]\\` PERSISTS across the workflow.** Every step default-stores its output there; reference via \\`$.state.<id>\\` or \\`js/ctx.state.<id>\\`. Opt out with \\`ephemeral: true\\`.\n3. **Blueprint Mapper resolves \\`$.<path>\\` and \\`js/\\` expressions BEFORE node execution.**\n\nWhen users have data flow issues, check these three things first.\n\n## Workflow Naming\n\nWorkflow \\`name\\` must be UNIQUE across the project \u2014 the\n\\`WorkflowRegistry\\` rejects duplicates at boot. Use a dotted\n\\`domain.action\\` convention (\\`countries.list\\`, \\`users.create\\`)\nso the typed client (\\`@blokjs/client\\`) and \\`blokctl gen app-types\\`\nexpose clean nested accessors like \\`blok.countries.list(...)\\`. Duplicate\nnames make \\`gen app-types\\` flag a collision and drop one workflow from\nthe generated \\`BlokApp\\` type.\n\n## Debugging Workflows\n\n1. **Verify structure**: Every step has an \\`id\\` and a \\`use\\` (v2). v1's \\`name\\` + \\`nodes{}\\` still works but is normalized at load time.\n2. **Trace data flow**: Does the target step reference the correct source id (\\`$.state.<id>\\`)? Did the source step have \\`ephemeral: true\\` accidentally?\n3. **Check runtimes**: SDK containers running? \\`GET http://localhost:{port}/health\\`\n4. **Check Studio traces**: \\`/__blok/runs/:id\\` shows step-by-step inputs/outputs/errors\n\n### Common Errors\n\n| Error | Fix |\n|-------|-----|\n| \\`Node type X not found\\` | Wrong \\`type\\` in step \u2014 use module, local, or runtime.* |\n| \\`Validation failed\\` | Zod schema mismatch \u2014 check input schema vs actual data |\n| \\`Runtime execution error\\` | SDK container not running \u2014 check health endpoint |\n| \\`ctx.state['X'] undefined\\` | Source step has \\`ephemeral: true\\`, or the id doesn't match what's referenced in \\`$.state.<id>\\` |\n| \\`set_var, which was removed in v0.5\\` | Drop \\`set_var: true\\` (it's the default) or replace \\`set_var: false\\` with \\`ephemeral: true\\`. Run \\`blokctl migrate workflows\\`. |\n\n## Generating Code\n\nAlways use \\`defineNode()\\`. Never class-based BlokService.\n\n\\`\\`\\`typescript\nimport { defineNode } from \"@blokjs/runner\";\nimport { z } from \"zod\";\n\nexport default defineNode({\n name: \"node-name\",\n description: \"What this node does\",\n input: z.object({ /* Zod schema */ }),\n output: z.object({ /* Zod schema */ }),\n async execute(ctx, input) {\n return { /* must match output schema */ };\n },\n});\n\\`\\`\\`\n\n### Checklist:\n- Zod input schema covers all inputs\n- Zod output schema matches execute() return\n- Node name matches workflow references\n- No \\`any\\` types \u2014 use \\`z.unknown()\\` if dynamic\n- \\`export default defineNode(...)\\`\n\n## Worker Workflows\n\nWorker trigger processes background jobs from a queue:\n\n\\`\\`\\`typescript\nWorkflow({ name: \"Process Job\", version: \"1.0.0\" })\n .addTrigger(\"worker\", { queue: \"background-jobs\" })\n .addStep({ name: \"process\", node: \"my-processor\", type: \"module\",\n inputs: { payload: \"js/ctx.request.body\", jobId: \"js/ctx.request.params.jobId\" } });\n\\`\\`\\`\n\nJob data: \\`ctx.request.body\\` = payload, \\`ctx.request.params.queue/jobId/attempt\\` = metadata.\nAdapters: NATS JetStream (recommended), BullMQ (Redis), InMemory (dev).\n\n## Testing\n\n\\`\\`\\`typescript\nimport { NodeTestHarness, WorkflowTestRunner } from \"@blokjs/runner\";\n\n// Unit test a node\nconst harness = new NodeTestHarness(myNode);\nconst result = await harness.execute({ input: \"data\" });\nharness.assertSuccess(result);\n\n// Integration test a workflow\nconst runner = new WorkflowTestRunner({ mockAllNodes: true });\nrunner.loadWorkflow(definition);\nconst wfResult = await runner.execute({ input: \"data\" });\n\\`\\`\\`\n\n## Blok Studio Help\n\n- Launch: \\`blokctl trace\\` or navigate to \\`/__blok\\`\n- \"No output\" \u2192 Node not returning data or Zod output validation failed\n- \"Step error\" \u2192 Expand error \u2014 check if 400 (validation) or 500 (runtime)\n- \"State not passing\" \u2192 Source step has \\`ephemeral: true\\`, OR target's \\`$.state.<id>\\` references a non-existent step id\n\n## Debugging Workers\n\n- NATS not reachable \u2192 Check \\`NATS_SERVERS\\` env var, ensure NATS is running\n- Job timeout \u2192 Increase \\`timeout\\` in trigger config or optimize node\n- Max retries exceeded \u2192 Check node errors, job moves to DLQ\n\n## Do NOT\n\n- Do NOT suggest class-based BlokService for new nodes\n- Do NOT generate code with \\`any\\` types\n- Do NOT assume \\`ctx.response.data\\` persists across steps\n- Do NOT skip Zod schemas when creating nodes\n- Do NOT edit files in \\`.blok/runtimes/\\`\n";
|
|
38
|
+
declare const agents_md = "\n# AGENTS.md \u2014 Blok Framework AI Context\n\nBlok is a **multi-trigger, multi-runtime workflow framework**. A workflow is a declarative list of steps; each step runs a node; the runner resolves data between steps and persists state. Two facts shape everything you author here:\n\n- **HTTP is ONE of 9 triggers, NOT the default.** Every workflow declares exactly one trigger. Picking `http` reflexively is the most common mistake \u2014 start with the decision table below.\n- **Nodes can be written in 8 runtimes.** TypeScript runs in-process; the other 7 (`go`, `rust`, `java`, `csharp`, `php`, `ruby`, `python3`) run as gRPC sidecar processes. A step routes to a sidecar via `type: \"runtime.<lang>\"`.\n\nThe 9 trigger types: `http`, `worker`, `cron`, `pubsub`, `sse`, `websocket`, `webhook`, `mcp`, `grpc`.\nThe 8 runtimes: `typescript` (in-process), `go`, `rust`, `java`, `csharp`, `php`, `ruby`, `python3`.\n\nThe canonical workflow form is `workflow({ name, version, trigger, steps })` from `@blokjs/helper`. The same shape works for all 9 triggers \u2014 only the `trigger:` block changes.\n\n---\n\n## 1. CHOOSING A TRIGGER (do this first, every time)\n\n**Before writing `trigger: { http: ... }`, read this table and pick by intent.**\n\n| Intent / what you're building | Trigger | Why NOT http |\n|---|---|---|\n| Respond to an HTTP/REST request; JSON API; HTML page; file download | **`http`** | \u2014 |\n| Process a background / queued / async job; offload slow work | **`worker`** | http blocks the caller; jobs need a queue + retries + DLQ |\n| Run on a schedule / recurring time-based job (nightly, hourly, cron) | **`cron`** | http only fires on a request; nothing calls it on a timer |\n| React to messages on a cloud topic/subscription (cross-service events) | **`pubsub`** | http isn't subscribed to a broker; events would be dropped |\n| Stream / push live updates one-way to a browser (tokens, progress, feed) | **`sse`** | a plain http response is one-shot; it can't keep pushing |\n| Bidirectional realtime (chat rooms, live cursors, client\u2194server messages) | **`websocket`** | http is half-duplex request/response, no server push back-channel |\n| Receive a signed provider webhook (Stripe / GitHub / Slack / Shopify / Svix / custom HMAC) | **`webhook`** | http won't verify the HMAC signature or do replay protection |\n| Expose a workflow as a tool/resource to an AI/LLM client (Cursor, Claude) | **`mcp`** | http isn't MCP; the client can't discover or call it as a tool |\n| High-throughput typed RPC between services with a proto contract | **`grpc`** | http/REST overhead is too high; no typed contract |\n\n**Tie-breakers:**\n- **One-way stream \u2192 `sse`; two-way \u2192 `websocket`.** SSE is cheaper and simpler; reach for `websocket` only when the client must send messages back over the same connection.\n- **In-process pub/sub (single Node process, HTTP+SSE chains) \u2192 the `sse` bus, NOT `pubsub`.** `pubsub` is the multi-process / multi-cloud sibling backed by an external broker.\n- **Queue consumer \u2192 `worker`, never `queue`.** `trigger.queue` is **DEAD** \u2014 it has a schema but no runtime and throws at workflow construction time. Always use `worker` (`{ worker: { queue: \"<name>\" } }`).\n\n### Read `.blok/config.json` first\n\nThe project records which triggers and runtimes were actually scaffolded in **`.blok/config.json`**. **Author for those \u2014 do not assume HTTP.** If the project was scaffolded with the worker trigger and the Go runtime, the user almost certainly wants a worker workflow and/or a Go node, not an HTTP endpoint. When in doubt, read that file and match the existing workflows under `src/workflows/`.\n\n### Same-port vs cross-process families\n\n- **Same-port family** \u2014 `http`, `sse`, `websocket`, `webhook`, `mcp` all mount on the **same Hono HTTP server / port** (default 4000) and share an in-process event bus.\n- **Cross-process family** \u2014 `worker`, `cron`, `pubsub`, `grpc` each run in their **own Node process** and coordinate via external brokers / their own ports.\n\nRegardless of kind, every trigger populates `ctx.request.{body,headers,params,query,method}`, so the workflow body is structurally identical across triggers \u2014 only the `trigger:` block differs.\n\n---\n\n## 2. THE 9 TRIGGERS\n\nEach trigger below: one-line purpose, USE-WHEN / DON'T, config shape, and a canonical `workflow({...})` example.\n\n### 2.1 HTTP \u2014 `trigger: { http: {...} }`\n\n**Purpose:** Turn a workflow into an inbound HTTP/REST endpoint. Owns the listening server (default port 4000) that sse/websocket/webhook/mcp mount onto.\n\n**USE WHEN:** synchronous request\u2192response; JSON APIs; HTML UI (`accept: \"text/html\"`); file downloads. **DON'T USE FOR:** background jobs (\u2192`worker`), scheduled work (\u2192`cron`), broker events (\u2192`pubsub`), live push (\u2192`sse`/`websocket`), signed callbacks (\u2192`webhook`).\n\n```ts\ntrigger: { http: {\n method: \"GET\"|\"POST\"|\"PUT\"|\"DELETE\"|\"PATCH\"|\"HEAD\"|\"OPTIONS\"|\"ANY\", // required; use \"ANY\" not \"*\"\n path?: string, // optional; omit \u2192 derived from file path\n accept?: string, // default \"application/json\"; \"text/html\" for UI\n headers?: Record<string,string>, // required-headers gate; missing \u2192 400 before any step\n middleware?: string[],\n // shared concurrency/scheduling: concurrencyKey, concurrencyLimit, onLimit, delay, ttl, debounce\n}}\n```\n\n```ts\nimport { workflow, $ } from \"@blokjs/helper\";\n\nexport default workflow({\n name: \"Get User\", version: \"1.0.0\",\n trigger: { http: { method: \"GET\", path: \"/users/:id\" } },\n steps: [\n { id: \"lookup\", use: \"@blokjs/api-call\",\n inputs: { url: \"js/`https://internal/users/${ctx.request.params.id}`\" } },\n { id: \"respond\", use: \"@blokjs/respond\", inputs: { body: $.state.lookup }, ephemeral: true },\n ],\n});\n```\n\n### 2.2 WORKER \u2014 `trigger: { worker: {...} }`\n\n**Purpose:** Consume background jobs from a queue, one workflow run per delivery. Runs in its own Node process. **This is the trigger to use whenever you'd reach for a queue \u2014 `queue` is dead.**\n\n**USE WHEN:** offloading slow/async work; queue consumers; fan-out job processing. **DON'T USE FOR:** synchronous responses (\u2192`http`); time schedules (\u2192`cron`); cloud fan-out topics (\u2192`pubsub`).\n\n```ts\ntrigger: { worker: {\n queue: string, // required \u2014 queue/topic/stream name\n provider?: \"in-memory\"|\"nats\"|\"bullmq\"|\"kafka\"|\"rabbitmq\"|\"sqs\"|\"redis\"|\"pg-boss\", // default in-memory\n concurrency?: number, // default 1 \u2014 concurrent jobs per process\n timeout?: number, // ms \u2014 per-attempt hard timeout\n retries?: number, // default 3 \u2014 then DLQ\n priority?: number, consumerGroup?: string, ack?: boolean,\n deadLetterQueue?: string, fromBeginning?: boolean,\n // shared concurrency/scheduling: concurrencyKey, concurrencyLimit, onLimit, delay, ttl, debounce, middleware\n}}\n```\n\n```ts\nimport { workflow } from \"@blokjs/helper\";\n\nexport default workflow({\n name: \"Process Background Job\", version: \"1.0.0\",\n trigger: { worker: { queue: \"background-jobs\" } },\n steps: [\n { id: \"process-job\", use: \"@blokjs/api-call\", type: \"module\",\n inputs: { url: \"https://example.com/process\", method: \"POST\", body: \"js/ctx.request.body\" } },\n ],\n});\n```\n\n**Worker context mapping:** `ctx.request.body` \u2192 job payload; `ctx.request.params.{queue,jobId,attempt}` \u2192 job metadata; `ctx.vars._worker_job` \u2192 full job record. Producers enqueue with `@blokjs/worker-publish`. Non-`in-memory` providers need their client as a peer dep (`nats`, `bullmq`+`ioredis`, `ioredis`, `@aws-sdk/client-sqs`, `kafkajs`, `amqplib`, `pg-boss`).\n\n### 2.3 CRON \u2014 `trigger: { cron: {...} }`\n\n**Purpose:** Run a workflow on a time schedule (standard cron expression). Dedicated process.\n\n**USE WHEN:** recurring/scheduled work \u2014 nightly cleanup, hourly polls, daily digests, periodic syncs. **DON'T USE FOR:** anything triggered by an external event or request.\n\n```ts\ntrigger: { cron: {\n schedule: string, // required \u2014 \"m h dom mon dow\", e.g. \"0 2 * * *\"\n timezone?: string, // default \"UTC\" \u2014 IANA tz e.g. \"America/New_York\"\n overlap?: boolean, // default false \u2014 allow overlapping executions\n // also: concurrencyKey, concurrencyLimit, middleware\n}}\n```\n\n```ts\nimport { workflow } from \"@blokjs/helper\";\n\nexport default workflow({\n name: \"Daily Cleanup\", version: \"1.0.0\",\n trigger: { cron: { schedule: \"0 2 * * *\", timezone: \"America/New_York\" } },\n steps: [\n { id: \"purge-stale\", use: \"@blokjs/api-call\",\n inputs: { url: \"https://api.example.com/cleanup\", method: \"POST\" } },\n ],\n});\n```\n\n`ctx.request.body` is `{}`; fire metadata is on `ctx.request.params.{schedule,firedAt}`. To serialize overlapping runs use `concurrencyKey: \"self\"`, `concurrencyLimit: 1`.\n\n### 2.4 PUBSUB \u2014 `trigger: { pubsub: {...} }`\n\n**Purpose:** Consume messages from a cloud/broker pub-sub topic, one run per delivery. Dedicated process. Fan-out (1:N) by default; competing-consumer (1-of-N) when `consumerGroup` is set.\n\n**USE WHEN:** cross-service / multi-process event handling over a real broker (GCP Pub/Sub, AWS SNS+SQS, Azure Service Bus, NATS, Redis Streams, Kafka). **DON'T USE FOR:** in-process pub/sub for HTTP+SSE chains (\u2192`sse` bus); plain job queues with competing consumers + retries (\u2192`worker`).\n\n```ts\ntrigger: { pubsub: {\n provider?: \"nats\"|\"redis-streams\"|\"kafka\"|\"gcp\"|\"aws\"|\"azure\", // default BLOK_PUBSUB_ADAPTER\n topic: string, // required \u2014 topic/subject/stream (wildcards ok: \"orders.*.created\")\n subscription?: string, // required for gcp/aws/azure; derived from consumerGroup otherwise\n consumerGroup?: string, // set \u2192 competing-consumer; unset \u2192 fan-out\n durable?: boolean, startFrom?: \"earliest\"|\"latest\"|{seq:number}|{timestamp:number},\n ack?: boolean, // default true\n maxMessages?: number, // default 10\n ackDeadline?: number, // default 30 (s)\n deadLetterTopic?: string, filter?: string,\n}}\n```\n\n```ts\nimport { workflow } from \"@blokjs/helper\";\n\nexport default workflow({\n name: \"On Order Placed\", version: \"1.0.0\",\n trigger: { pubsub: { provider: \"gcp\", topic: \"orders.placed\", subscription: \"fulfillment-svc\" } },\n steps: [\n { id: \"fulfill\", use: \"@blokjs/api-call\",\n idempotencyKey: \"js/ctx.request.params.messageId\", // dedup redeliveries\n inputs: { url: \"https://fulfillment.internal/api/orders\", method: \"POST\", body: \"js/ctx.request.body\" } },\n ],\n});\n```\n\n`messageId` on `ctx.request.params` is the natural `idempotencyKey`. Provider env vars \u2014 GCP: `GOOGLE_APPLICATION_CREDENTIALS`+`PUBSUB_PROJECT_ID`; AWS: standard credential chain (`topic`=SNS ARN, `subscription`=SQS URL); Azure: `AZURE_SERVICEBUS_CONNECTION_STRING`.\n\n### 2.5 SSE \u2014 `trigger: { sse: {...} }`\n\n**Purpose:** One-way server\u2192browser streaming via `EventSource`. Mounts on the shared HTTP port; pumps in-process bus events to connected clients.\n\n**USE WHEN:** pushing live updates one-way \u2014 token streaming from an LLM, progress feeds, notification streams. **DON'T USE FOR:** client\u2192server messages (\u2192`websocket`); one-shot JSON responses (\u2192`http`).\n\n```ts\ntrigger: { sse: {\n path?: string, // URL path; supports :params (e.g. \"/sse/chat/:sessionId\")\n events?: string[], // default [\"*\"]\n channels?: string[],\n maxConnections?: number, // default 10000\n heartbeatInterval?: number, // default 30000 ms\n retryInterval?: number, // default 3000 ms (browser reconnect hint)\n // also: concurrencyKey, concurrencyLimit\n}}\n```\n\n```ts\nimport { workflow } from \"@blokjs/helper\";\n\nexport default workflow({\n name: \"Clock Stream\", version: \"1.0.0\",\n trigger: { sse: { path: \"/sse/clock\", heartbeatInterval: 15000 } },\n steps: [\n { id: \"sub\", use: \"@blokjs/sse-subscribe\", inputs: { channels: [\"clock\"] } },\n { id: \"stream\", use: \"@blokjs/sse-stream\", inputs: { source: \"js/ctx.state.sub\" } },\n ],\n});\n```\n\nA sibling HTTP workflow publishes via `@blokjs/sse-publish`; both share the in-process bus. Cross-process needs a Redis pub/sub backplane.\n\n### 2.6 WEBSOCKET \u2014 `trigger: { websocket: {...} }`\n\n**Purpose:** Bidirectional WS connections; one workflow run per inbound message/lifecycle event. Mounts on the shared HTTP port via Hono `upgradeWebSocket`.\n\n**USE WHEN:** two-way realtime \u2014 chat rooms, live cursors, RPC-over-WS, server-pushed updates the client also writes to. **DON'T USE FOR:** one-way push (\u2192`sse` is cheaper); request/response (\u2192`http`).\n\n```ts\ntrigger: { websocket: {\n path?: string, // URL path; supports :params (e.g. \"/ws/room/:roomId\")\n events?: string[], // default [\"*\"]; supported: \"open\"|\"message\"|\"close\"|\"error\"\n rooms?: string[],\n maxConnections?: number, // default 10000\n heartbeatInterval?: number, // default 30000 ms\n messageRateLimit?: number, // default 100 msgs/sec/client\n // also: concurrencyKey, concurrencyLimit\n}}\n```\n\n```ts\nimport { workflow } from \"@blokjs/helper\";\n\nexport default workflow({\n name: \"WS Echo\", version: \"1.0.0\",\n trigger: { websocket: { path: \"/ws/echo\", events: [\"message\", \"open\", \"close\"] } },\n steps: [\n { id: \"reply\", use: \"@blokjs/ws-reply\",\n inputs: { message: \"js/({ echo: ctx.request.body, at: Date.now() })\" } },\n ],\n});\n```\n\nHelpers: `@blokjs/ws-reply` (this connection), `@blokjs/ws-broadcast` (fan-out), `@blokjs/ws-close`. Cross-process broadcast needs `BLOK_WS_BACKPLANE=redis` + `BLOK_WS_BACKPLANE_REDIS_URL`.\n\n### 2.7 WEBHOOK \u2014 `trigger: { webhook: {...} }`\n\n**Purpose:** Receive signed provider POSTs, verify the HMAC signature, apply replay protection, then dispatch. Mounts on the shared HTTP port.\n\n**USE WHEN:** receiving Stripe / GitHub / Slack / Shopify / Svix callbacks, or any HMAC-signed partner webhook via custom `signature`. **DON'T USE FOR:** unsigned inbound requests (\u2192`http`).\n\n```ts\ntrigger: { webhook: {\n provider?: \"github\"|\"stripe\"|\"slack\"|\"shopify\"|\"svix\", // pick this OR signature, not both\n path?: string, // defaults to /webhooks/<provider>\n secretEnv?: string, // env var name holding the shared secret (never inline the secret)\n events?: string[], // allowlist; out-of-scope \u2192 200 {status:\"ignored\"}\n tolerance?: number, // seconds, default 300 \u2014 clock-skew window\n idempotencyKey?: string, // e.g. \"js/ctx.request.body.id\" \u2014 replay protection\n namespace?: string, // prefix for polymorphic subworkflow dispatch\n middleware?: string[],\n signature?: { // custom HMAC for non-built-in providers\n scheme?: \"hmac-sha256\"|\"hmac-sha1\"|\"hmac-sha512\", // default sha256\n header: string, format?: string, // format default \"{hex}\"; \"{hex}\"/\"{base64}\"\n secretEnv: string, tolerance?: number, timestampHeader?: string,\n },\n}}\n```\n\n```ts\nimport { workflow } from \"@blokjs/helper\";\n\nexport default workflow({\n name: \"Stripe Webhook\", version: \"1.0.0\",\n trigger: { webhook: {\n provider: \"stripe\", namespace: \"stripe\",\n secretEnv: \"STRIPE_WEBHOOK_SECRET\", idempotencyKey: \"js/ctx.request.body.id\",\n }},\n steps: [\n { id: \"dispatch\", subworkflow: \"js/ctx.request.body.type\", // \"invoice.paid\" \u2192 \"stripe.invoice.paid\"\n inputs: { stripeEvent: \"js/ctx.request.body\" } },\n ],\n});\n```\n\nBad signature \u2192 401 with a structured `reason`; duplicate \u2192 200 `{status:\"duplicate\"}`; a workflow throw still returns 200 (senders shouldn't retry). `ctx.request.rawBody` carries the bytes the HMAC was computed against.\n\n### 2.8 MCP \u2014 `trigger: { mcp: {...} }`\n\n**Purpose:** Expose a workflow as an MCP **tool** (default) or **resource** to AI/LLM clients (Cursor, Claude Code). The tool's `inputSchema` is auto-generated from the workflow's Zod `input`. Mounts on the shared HTTP port.\n\n**USE WHEN:** giving an LLM/agent a callable tool or readable resource backed by a workflow. **DON'T USE FOR:** plain HTTP APIs for non-MCP clients (\u2192`http`).\n\n```ts\ntrigger: { mcp: {\n path?: string, // default \"/mcp\"\n serverName?: string, // default \"blok-mcp\"; workflows sharing path+serverName aggregate\n serverVersion?: string, // default \"1.0.0\"\n transports?: (\"sse\"|\"streamable-http\")[], // default both\n tool?: { name?: string, description?: string },\n resource?: { uri: string, name?: string, description?: string, mimeType?: string }, // expose as resource\n middleware?: string[],\n}}\n```\n\n**Requires a workflow-level `input:` Zod schema** \u2014 that becomes the tool's `inputSchema`:\n\n```ts\nimport { workflow, $ } from \"@blokjs/helper\";\nimport { z } from \"zod\";\n\nexport default workflow({\n name: \"search_code\", version: \"1.0.0\",\n input: z.object({ query: z.string(), limit: z.number().optional() }), // \u2192 tool inputSchema\n trigger: { mcp: { path: \"/mcp\", serverName: \"my-platform\",\n tool: { description: \"Full-text search the indexed code\" } } },\n steps: [ { id: \"search\", use: \"@my/search\", inputs: { query: $.req.body.query } } ],\n});\n```\n\nServes over SSE (`GET <path>/sse` + `POST <path>/messages`) and/or Streamable-HTTP (`<path>`).\n\n**Connecting a client** \u2014 the server mounts on the HTTP port (default 4000). Give an MCP client the URL `http://localhost:4000/mcp` (Streamable-HTTP, recommended) or `http://localhost:4000/mcp/sse` (legacy SSE):\n- **Claude Code:** `claude mcp add --transport http blok http://localhost:4000/mcp`\n- **Cursor** (`.cursor/mcp.json`): `{ \"mcpServers\": { \"blok\": { \"url\": \"http://localhost:4000/mcp\" } } }`\n- **Quick test:** `npx @modelcontextprotocol/inspector` \u2192 connect to `http://localhost:4000/mcp`\n\n`tools/call` arguments arrive as `ctx.request.body`; the final step's `ctx.response.data` is returned. Identity via the `x-user-context` header is injection-only, NOT authorization \u2014 scope access yourself.\n\n### 2.9 GRPC \u2014 `trigger: { grpc: {...} }`\n\n**Purpose:** Expose a workflow as a gRPC service method handler \u2014 typed, contract-based RPC. Dedicated process bound to a gRPC port.\n\n**USE WHEN:** high-throughput typed RPC between services with a proto contract; cross-language internal calls. **DON'T USE FOR:** browser-facing or REST APIs (\u2192`http`); async work (\u2192`worker`).\n\n> Caveat: gRPC config is **not Zod-validated** at construction. Author against the documented surface:\n\n```ts\ntrigger: { grpc: {\n service: string, // matches `service Foo {}` in the proto\n method: string, // matches `rpc Bar(...) returns (...)`\n proto: string, // path to the .proto file, relative to the workflow\n port?: number, // default 50051; all grpc workflows share one port (env GRPC_PORT)\n middleware?: string[],\n}}\n```\n\n```ts\nimport { workflow } from \"@blokjs/helper\";\n\nexport default workflow({\n name: \"GetUser\", version: \"1.0.0\",\n trigger: { grpc: { service: \"UserService\", method: \"GetUser\", proto: \"users.proto\" } },\n steps: [\n { id: \"lookup\", use: \"@blokjs/api-call\",\n inputs: { url: \"js/`https://internal/users/${ctx.request.body.userId}`\", method: \"GET\" } },\n ],\n});\n```\n\nThe request decodes into `ctx.request.body`; the final step output becomes the gRPC reply. Streaming RPCs use `@blokjs/grpc-stream`.\n\n> **`trigger.queue` is DEAD** \u2014 it has a schema but no runtime and throws at workflow construction. Use `worker`. **`manual`** has no listener (invoked programmatically only \u2014 tests / sub-workflows); not for normal authoring.\n\n---\n\n## 3. AUTHORING WORKFLOWS (v2 DSL)\n\nImport from `@blokjs/helper`: `{ workflow, $, branch, switchOn, forEach, loop, tryCatch }`. The default export is `workflow({...})` \u2014 a single object literal, no chaining, no separate `nodes{}` map.\n\n```ts\nimport { workflow, $ } from \"@blokjs/helper\";\n\nexport default workflow({\n name: \"Process Order\", // >= 3 chars\n version: \"1.0.0\", // semver x.x.x (>= 5 chars)\n trigger: { http: { method: \"POST\", path: \"/orders\" } }, // path optional \u2192 derived from file path\n steps: [\n { id: \"validate\", use: \"order-validator\", inputs: { order: $.req.body } },\n { id: \"save\", use: \"order-store\", inputs: { data: $.state.validate } },\n ],\n});\n```\n\nA regular step is `{ id, use, inputs }`. `id` is required and unique workflow-wide. `use` is the node reference. `type` is inferred from `use` (in-process `module` by default; `runtime.*` must be set explicitly).\n\n### The four context reads\n\n| Read | Resolves to | Scope |\n|---|---|---|\n| `$.state.<id>` | A prior step's stored output | Whole workflow (cross-step) |\n| `$.prev` | Immediately previous step's output | Adjacent only \u2014 overwritten every step |\n| `$.req` | Request envelope (body/headers/params/query/method/url) | Whole run |\n| `$.error` | Captured error inside a `tryCatch.catch` block | `catch` arm only \u2014 `undefined` elsewhere |\n\n`$.error` exposes `.message`, `.name`, `.stack`, `.code` (upstream HTTP status), and `.stepId`. The `$` proxy compiles to `\"js/ctx.<path>\"` strings at definition time \u2014 in JSON workflows write those strings by hand (`\"$.state.fetch\"` or `\"js/ctx.state.fetch\"`). Legacy aliases still resolve: `$.request`=`$.req`, `$.response`=`$.prev`, `$.vars`=`$.state` \u2014 prefer the canonical four.\n\n### Persistence knobs (per-step, declarative)\n\n| Knob | Effect |\n|---|---|\n| *(none)* | Store at `ctx.state[id]` (the 95% case) |\n| `as: \"name\"` | Store at `ctx.state[name]` instead of `ctx.state[id]` |\n| `spread: true` | Shallow-merge `result.data`'s top-level keys into `ctx.state` (multi-output nodes). Mutually exclusive with `as` |\n| `ephemeral: true` | Skip storage \u2014 only `$.prev` carries it to the next step (logging, audit) |\n\n**Every step's output auto-persists to `ctx.state[id]` \u2014 but ONLY on success.** A step that throws writes nothing, so `ctx.state[<id>] === undefined` is a truthful \"did this step succeed?\" check inside a `tryCatch.catch` arm.\n\n### Control-flow primitives\n\n**`branch({when, then, else})`** \u2014 `when` is a JS-expression *string* (the `$` proxy can't intercept `===`):\n\n```ts\nbranch({ id: \"route\",\n when: '$.req.method === \"POST\"',\n then: [{ id: \"create\", use: \"...\", inputs: {...} }],\n else: [{ id: \"read\", use: \"...\", inputs: {...} }] })\n```\n\n**`switchOn({id, on, cases, default?})`** \u2014 N-way branch, first match wins. `when` may be a scalar (`on === when`) or an array (`array.includes(on)`):\n\n```ts\nswitchOn({ id: \"route-by-event\", on: $.req.headers[\"x-github-event\"],\n cases: [\n { when: \"push\", do: [{ id: \"h1\", subworkflow: \"handle-push\" }] },\n { when: [\"pull_request\", \"pr_review\"], do: [{ id: \"h2\", subworkflow: \"handle-pr\" }] },\n ],\n default: [{ id: \"log\", use: \"@blokjs/log\", inputs: { message: \"unknown\" } }] })\n```\n\n**`forEach({id, in, as, do, mode?, concurrency?})`** \u2014 iterate a collection. Each iteration sets `ctx.state[as]` = item and `ctx.state[<as>Index]` = i; the loop's own slot `$.state[<id>]` is the array of each iteration's last-step output. `mode: \"parallel\"` runs with bounded `concurrency` (default 10):\n\n```ts\nforEach({ id: \"process-items\", in: $.req.body.items, as: \"item\",\n mode: \"parallel\", concurrency: 5,\n do: [{ id: \"reserve\", use: \"inventory-reserve\", inputs: { sku: $.state.item.sku } }] })\n```\n\n**`loop({id, while, do, maxIterations?})`** \u2014 while-loop, hard cap default 1000.\n**`tryCatch({id, try, catch, finally?})`** \u2014 `catch` sees `$.error`; errors in `catch` propagate (don't re-trigger `catch`); `finally` runs unconditionally.\n**`{ id, wait: { for: \"3d\" } | { until: <date> } }`** \u2014 durable pause; cannot combine with `idempotencyKey` or `retry`.\n\n### Caching, retry, sub-workflows (per-step)\n\n```ts\n{ id: \"fetch\", use: \"@blokjs/api-call\", inputs: { url: \"...\" },\n idempotencyKey: $.req.body.requestId, // cache by (workflow, step.id, key); default TTL 24h\n retry: { maxAttempts: 3, minTimeoutInMs: 500, maxTimeoutInMs: 10000, factor: 2 },\n maxDuration: \"30s\" } // per-attempt timeout; final-attempt timeout \u2192 run \"timedOut\"\n```\n\nA cache hit replays the cached result through the same `ephemeral`/`spread`/`as` rules and skips the node entirely. Override TTL with `idempotencyKeyTTL: <ms>` (0 = disabled). Default `maxAttempts: 1` = no retry.\n\n**Sub-workflow as a step:**\n\n```ts\n{ id: \"send-receipt\", subworkflow: \"send-receipt-email\",\n inputs: { user: $.state.user }, // becomes child's ctx.request.body (read via $.req.body)\n wait: true } // default: parent blocks, child response lands at state[id]\n```\n\n`wait: false` = fire-and-forget, returns `{runId, workflowName, scheduledAt}`. `subworkflow:` also accepts a `$.<path>`/`js/...` expression for polymorphic dispatch \u2014 pair with `allowList: [...]` whenever it depends on caller data. Recursion capped at 10 (`BLOK_MAX_SUBWORKFLOW_DEPTH`).\n\n### JSON workflows\n\nJSON mirrors the TS DSL one-for-one. Reference earlier outputs as `\"$.state.<id>\"` strings; use `\"ANY\"` for the wildcard method; a branch is one step with `branch: { when, then, else }`. JSON workflows live under `src/workflows/json/` (scanned recursively).\n\n---\n\n## 4. TRIGGER-LEVEL OPTIONS (across kinds)\n\nThese live on the **trigger config**, never on a step. They gate workflow entry.\n\n**Per-key concurrency gating** \u2014 `concurrencyKey` (+ optional `concurrencyLimit` default 1, `onLimit: \"throw\"|\"queue\"`):\n\n```ts\ntrigger: { http: { method: \"POST\", path: \"/render\",\n concurrencyKey: $.req.body.tenantId, concurrencyLimit: 5, onLimit: \"queue\" } }\n```\n\n`concurrencyLimit`/`onLimit`/`concurrencyLeaseMs` all require `concurrencyKey`. Denial \u2192 HTTP 429 + `Retry-After` (or 202 with `onLimit: \"queue\"`).\n\n**Scheduling** \u2014 `delay`, `ttl`, `debounce`. Durations are a number (ms) or a unit string (`\"500ms\"`,`\"30s\"`,`\"5m\"`,`\"2h\"`,`\"1d\"`):\n\n```ts\ntrigger: { http: { method: \"POST\", path: \"/welcome\", delay: \"1h\", ttl: \"2h\" } }\ntrigger: { http: { method: \"POST\", path: \"/save/:docId\",\n debounce: { key: $.req.params.docId, mode: \"trailing\", delay: \"500ms\", maxDelay: \"5s\" } } }\n```\n\nFor HTTP, `ttl` requires `delay`. Debounce modes: `trailing` (default \u2014 fire after silence) / `leading` (fire first, suppress follow-ups).\n\n**Middleware** \u2014 two forms:\n\n1. *Trigger-level chain* \u2014 ordered middleware-workflow names, run before the body on the same ctx:\n ```ts\n trigger: { http: { method: \"GET\", middleware: [\"auth-check\", \"request-id\"] } }\n ```\n2. *Defining a middleware workflow* \u2014 `workflow({ middleware: true })`. `trigger` becomes optional; it gets no public route and is referenced by `name`:\n ```ts\n export default workflow({ name: \"auth-check\", version: \"1.0.0\", middleware: true,\n steps: [ /* sets ctx.state.identity; may stop:true to short-circuit */ ] });\n ```\n\nProcess-global middleware: `WorkflowRegistry.getInstance().setGlobalMiddleware([...])` or `BLOK_GLOBAL_MIDDLEWARE=a,b`.\n\n---\n\n## 5. AUTHORING NODES\n\n### 5.1 defineNode (TypeScript, in-process)\n\nAlways `export default defineNode(...)`. Never class-based `BlokService`. Zod input/output are mandatory.\n\n```ts\nimport { defineNode } from \"@blokjs/runner\";\nimport { z } from \"zod\";\n\nexport default defineNode({\n name: \"fetch-user\",\n description: \"Fetches a user by ID\",\n input: z.object({ userId: z.string().uuid() }), // validated BEFORE execute\n output: z.object({ user: z.object({ id: z.string(), name: z.string() }) }), // validated AFTER\n async execute(ctx, input) {\n const user = await fetchUser(input.userId); // input is type-safe\n return { user }; // MUST match the output schema\n },\n});\n```\n\n- **Errors:** input failure \u2192 `GlobalError` code **400**; a plain `Error` thrown in `execute` \u2192 code **500**; a `GlobalError` you throw is preserved verbatim (custom codes like 401 survive).\n- **Never write `ctx.state` from a node** \u2014 return your output and let the runner persist it. For a genuine side-channel value, use `ctx.publish(name, value)`.\n- No `any` types \u2014 use `z.unknown()` and narrow.\n- `flow: true` nodes return `NodeBase[]`; `contentType: \"text/html\"` sets the response Content-Type.\n\nTypeScript nodes live in `src/nodes/` and are referenced by `use: \"<name>\"` (no `type` needed \u2014 `module` is the default).\n\n### 5.2 Nodes in other runtimes (gRPC sidecars)\n\nThe 7 non-TS runtimes run as long-lived gRPC sidecar processes; the TypeScript runner is the client. A step routes to a sidecar with **`type: \"runtime.<lang>\"`** and `use:` = the registered node name. The step's resolved `inputs` arrive as the node's config / typed input (NOT `ctx.request.body` \u2014 that holds the original trigger payload). The node's return value lands in `ctx.state[<step-id>]`.\n\n**Runtime nodes live in `runtimes/<lang>/nodes/`** and require that runtime to be scaffolded. Add a runtime with `blokctl runtime add <lang>` (or `blokctl create <project> --runtimes go,python3,...` at create time). Scaffold a node with `blokctl create node <name> --runtime <lang>`. Across all runtimes:\n\n- The runner speaks **gRPC only** (the legacy HTTP `/execute` path was removed in v0.5).\n- gRPC dispatch port = legacy HTTP port + 1000. **Dispatch ports:** go `10001`, rust `10002`, java `10003`, csharp `10004`, php `10005`, ruby `10006`, python3 `10007`. (Readiness/health HTTP ports are the legacy `9001`\u2013`9007`; the CLI readiness check is a **TCP connect to the gRPC port**, not `GET /health`.)\n- `blokctl dev` sets `BLOK_TRANSPORT=grpc` + `GRPC_PORT` for each sidecar. Most SDKs default to HTTP transport if you launch them by hand \u2014 always let `blokctl dev` (or the env) set gRPC, or the runner can't reach the node.\n- Generated proto stubs ship with each SDK \u2014 you do **not** regenerate them to author a node.\n- Each SDK has a **typed** contract (the equivalent of `defineNode` \u2014 validated input, typed output, reflected JSON Schema) and a lower-level untyped contract. **Prefer the typed contract.** Bad input auto-fails with `NODE_INPUT_VALIDATION` / HTTP 400 before your code runs.\n- gRPC message cap defaults to 16 MiB (`BLOK_GRPC_MAX_MESSAGE_BYTES`).\n- Don't edit `.blok/runtimes/` \u2014 those are generated copies.\n\nThe workflow step is identical regardless of runtime \u2014 only `type` changes:\n\n```ts\n{ id: \"sum\", use: \"add-numbers\", type: \"runtime.<lang>\", inputs: { a: $.req.body.a, b: $.req.body.b } }\n```\n\n#### Authoring a node in go\n\n`runtimes/go/nodes/addnumbers.go` \u2014 typed via `blok.DefineNode`:\n\n```go\npackage nodes\n\nimport blok \"github.com/nickincloud/blok-go\"\n\ntype AddNumbersInput struct {\n A int `json:\"a\"`\n B int `json:\"b\"`\n}\ntype AddNumbersOutput struct {\n Sum int `json:\"sum\"`\n}\n\nconst AddNumbersNodeName = \"add-numbers\"\n\nvar AddNumbersNode = blok.DefineNode(AddNumbersNodeName, \"Adds two integers\",\n func(_ *blok.Context, in AddNumbersInput) (AddNumbersOutput, error) {\n return AddNumbersOutput{Sum: in.A + in.B}, nil\n })\n```\n\nRegister in `runtimes/go/cmd/server/main.go`:\n\n```go\nfunc main() {\n registry := blok.NewNodeRegistry()\n registry.Register(nodes.AddNumbersNodeName, nodes.AddNumbersNode)\n registry.Use(blok.RecoveryMiddleware(), blok.LoggingMiddleware(blok.NewLogger(blok.LogLevelInfo)))\n if err := blok.ListenAndServe(registry); err != nil { log.Fatalf(\"Server error: %v\", err) }\n}\n```\n\nWorkflow step: `{ id: \"sum\", use: \"add-numbers\", type: \"runtime.go\", inputs: { a: $.req.body.a, b: $.req.body.b } }`. Errors: return a non-nil `error`, or use `blok.NewValidationError` / `blok.NewError(category)...Build()` for structured `BlokError`. Toolchain: Go 1.24+, `go mod download`, `go run ./cmd/server`.\n\n#### Authoring a node in rust\n\n`runtimes/rust/nodes/add-numbers/src/main.rs` \u2014 typed via the `TypedNode` trait:\n\n```rust\nuse async_trait::async_trait;\nuse blok::{BlokError, Context, NodeRegistry, TypedNode};\nuse schemars::JsonSchema;\nuse serde::{Deserialize, Serialize};\n\n#[derive(Deserialize, JsonSchema)]\nstruct AddInput { a: f64, b: f64 }\n\n#[derive(Serialize, JsonSchema)]\nstruct AddOutput { sum: f64 }\n\nstruct AddNumbers;\n\n#[async_trait]\nimpl TypedNode for AddNumbers {\n type Input = AddInput;\n type Output = AddOutput;\n fn name(&self) -> &str { \"add-numbers\" }\n fn description(&self) -> &str { \"Adds two numbers\" }\n async fn run(&self, _ctx: &mut Context, input: AddInput) -> Result<AddOutput, BlokError> {\n Ok(AddOutput { sum: input.a + input.b })\n }\n}\n\n#[tokio::main]\nasync fn main() {\n let mut registry = NodeRegistry::new(\"1.0.0\");\n registry.register_typed(AddNumbers); // typed nodes register via register_typed\n blok::server::serve(registry, 9002).await.unwrap();\n}\n```\n\nWorkflow step: `type: \"runtime.rust\"`. `Input`/`Output` must derive `serde` + `schemars::JsonSchema`. Errors: `BlokError::validation()/.dependency()/...build()`. Toolchain: `cargo build --release` / `cargo run`; gRPC is feature-gated \u2014 build with the `grpc` feature (or `--features full`) so the runner can dispatch.\n\n#### Authoring a node in java\n\n`runtimes/java/src/main/java/com/blok/blok/nodes/AddNumbersNode.java` \u2014 typed via `TypedNode<I, O>`:\n\n```java\npackage com.blok.blok.nodes;\n\nimport com.blok.blok.node.TypedNode;\nimport com.blok.blok.types.Context;\n\npublic final class AddNumbersNode extends TypedNode<AddNumbersNode.Input, AddNumbersNode.Output> {\n public record Input(int a, int b) {}\n public record Output(int sum) {}\n\n @Override public String name() { return \"add-numbers\"; }\n @Override public String description() { return \"Adds two integers\"; }\n @Override protected Class<Input> inputClass() { return Input.class; }\n @Override protected Class<?> outputClass() { return Output.class; }\n\n @Override protected Output run(Context ctx, Input input) {\n return new Output(input.a() + input.b());\n }\n}\n```\n\nRegister in `runtimes/java/src/main/java/com/blok/blok/Main.java`:\n\n```java\nNodeRegistry registry = new NodeRegistry();\nregistry.register(\"add-numbers\", new com.blok.blok.nodes.AddNumbersNode());\nregistry.use(new RecoveryMiddleware());\nregistry.use(new LoggingMiddleware(logger));\n```\n\nWorkflow step: `type: \"runtime.java\"`. Errors: `throw BlokError.validation().code(...).message(...).build();`. Primitive record components (`int`, `boolean`) are required in the reflected schema; boxed types are optional. Toolchain: JDK 17+ and Maven, `mvn package -q -DskipTests`, `java -jar target/blok-java-1.0.0.jar`.\n\n#### Authoring a node in csharp\n\n`runtimes/csharp/Nodes/AddNumbersNode.cs` \u2014 typed via `TypedNode<TInput, TOutput>`:\n\n```csharp\nusing System.ComponentModel.DataAnnotations;\nusing Blok.Core.Node;\nusing Blok.Core.Types;\n\nnamespace Blok.Runtime.Nodes;\n\npublic sealed record AddNumbersInput([property: Required] double A, [property: Required] double B);\npublic sealed record AddNumbersOutput(double Sum);\n\npublic sealed class AddNumbersNode : TypedNode<AddNumbersInput, AddNumbersOutput>\n{\n public override string Name => \"add-numbers\";\n public override string Description => \"Adds two numbers\";\n public override Task<AddNumbersOutput> RunAsync(Context ctx, AddNumbersInput input)\n => Task.FromResult(new AddNumbersOutput(input.A + input.B));\n}\n```\n\nRegister in `runtimes/csharp/Program.cs`:\n\n```csharp\nvar config = ServerConfig.FromEnv();\nvar registry = new NodeRegistry(config.Version);\nregistry.Register(\"add-numbers\", new AddNumbersNode());\nawait RuntimeServer.Run(registry, config);\n```\n\nWorkflow step: `type: \"runtime.csharp\"`. The wire is **camelCase** (`{ \"a\": 2, \"b\": 3 }` maps to `A`/`B`). Errors: `throw BlokError`. Toolchain: .NET 8.0+, `dotnet restore`, `dotnet run`.\n\n#### Authoring a node in php\n\n`runtimes/php/nodes/add-numbers/src/Nodes/AddNumbersNode.php` \u2014 typed via `TypedNode` (or plain `NodeHandler`):\n\n```php\n<?php\ndeclare(strict_types=1);\nnamespace Blok\\Nodes;\n\nuse Blok\\Blok\\Node\\TypedNode;\nuse Blok\\Blok\\Types\\Context;\n\nfinal class AddNumbersInput\n{\n public function __construct(public int $a, public int $b) {}\n}\n\nfinal class AddNumbersNode extends TypedNode\n{\n public function name(): string { return 'add-numbers'; }\n public function description(): string { return 'Adds two integers'; }\n protected function inputClass(): string { return AddNumbersInput::class; }\n\n protected function run(Context $ctx, object $input): mixed\n {\n /** @var AddNumbersInput $input */\n return ['sum' => $input->a + $input->b];\n }\n}\n```\n\nRegister in `runtimes/php/bin/serve.php`:\n\n```php\n$config = ServerConfig::fromEnv();\n$registry = new NodeRegistry($config->version);\n$registry->register('add-numbers', new AddNumbersNode());\n// ... wire $registry into BlokNodeRuntimeService + RoadRunner GrpcServer and $server->serve();\n```\n\nWorkflow step: `type: \"runtime.php\"`. The gRPC server is RoadRunner (`rr serve -c .rr.yaml`), which `blokctl dev` runs. Imports are `Blok\\Blok\\Node\\NodeHandler` and `Blok\\Blok\\Types\\Context`. Toolchain: PHP 8.2+, Composer, RoadRunner; `composer install`.\n\n#### Authoring a node in ruby\n\n`runtimes/ruby/nodes/add_numbers_node.rb` \u2014 typed via `Blok::Node::TypedNode`:\n\n```ruby\n# frozen_string_literal: true\nrequire \"blok\"\n\nclass AddNumbersNode < Blok::Node::TypedNode\n node_name \"add-numbers\"\n description \"Adds two numbers and returns their sum\"\n\n input do\n field :a, :number, required: true\n field :b, :number, required: true\n end\n output { field :sum, :number }\n\n def run(_ctx, input)\n { \"sum\" => input[:a] + input[:b] } # string-keyed Hash is idiomatic\n end\nend\n```\n\nRegister in `runtimes/ruby/bin/serve.rb`:\n\n```ruby\nrequire_relative \"../nodes/add_numbers_node\"\n\nconfig = Blok::Config::ServerConfig.from_env\nregistry = Blok::Node::NodeRegistry.new(config.version)\nregistry.register(\"add-numbers\", AddNumbersNode.new) # name MUST equal node_name\nregistry.use(Blok::Middleware::RecoveryMiddleware.new)\n# serve.rb routes to start_grpc under BLOK_TRANSPORT=grpc\n```\n\nWorkflow step: `type: \"runtime.ruby\"`. Field types: `:string, :integer, :number, :boolean, :array, :object`. Errors: `raise Blok::Errors::BlokError.validation(...)`. Toolchain: Ruby 3.2+, Bundler; `bundle install`.\n\n#### Authoring a node in python3\n\n`runtimes/python3/nodes/add_numbers/node.py` \u2014 typed via the `@node` decorator (Pydantic):\n\n```python\nfrom __future__ import annotations\nfrom pydantic import BaseModel, Field\nfrom blok import node, Context\n\n\nclass AddNumbersInput(BaseModel):\n a: float\n b: float = Field(0)\n\n\nclass AddNumbersOutput(BaseModel):\n sum: float\n\n\n@node(\"add-numbers\", \"Adds two numbers and returns their sum\")\ndef add_numbers(ctx: Context, input: AddNumbersInput) -> AddNumbersOutput:\n return AddNumbersOutput(sum=input.a + input.b)\n```\n\nRegistration is **manual** \u2014 importing the module runs the `@node` decorator; then flush with `register_decorated`. In `runtimes/python3/nodes/__init__.py`:\n\n```python\nfrom blok import register_decorated\nfrom . import add_numbers # noqa: F401 (runs the @node decorator)\n\ndef register_project_nodes(registry):\n return register_decorated(registry)\n```\n\n\u2026and call `register_project_nodes(registry)` from the boot path (after the SDK's `register_all(registry)`). Workflow step: `type: \"runtime.python3\"`; `use:` must match the **string in `@node(\"name\", ...)`**, not the function name. Errors: `raise BlokError.validation(...)` (or `.dependency`, `.not_found`, \u2026). Toolchain: Python 3, `pip3 install -r requirements.txt`; `@node` requires `pydantic`.\n\n> The legacy `BlokService` / `async def handle()` / `from core.blok import BlokService` Python shape **does not exist** in this SDK \u2014 ignore any example that uses it. Use `@node` (or the `NodeHandler` ABC).\n\n---\n\n## 6. RUNNING LOCALLY / INFRA\n\n```bash\nblokctl dev # full dev server: spawns selected runtimes + the runner\nblokctl create node <name> --runtime <lang> # scaffold a node (ts default; pass --runtime for sidecars)\nblokctl runtime add <lang> # add a non-TS runtime to an existing project\nblokctl trace # open Blok Studio (run traces at /__blok)\n```\n\nThe `http`/`sse`/`websocket`/`webhook`/`mcp` triggers need no external infra \u2014 they share the HTTP server. The cross-process triggers (`worker`, `pubsub`) need a broker.\n\n**For worker/pubsub, start the broker stack** with the dev compose (Redis + NATS + Postgres/Adminer):\n\n```bash\ncd infra/development && docker compose up -d nats # or: redis redis-commander\n```\n\nThe default worker adapter is `in-memory` (zero infra) \u2014 only start a broker when you set a real provider (`nats`, `redis`, `bullmq`, \u2026). Monitoring UIs from the dev compose: Adminer `:8080`, Redis Commander `:8081`, NATS monitor `:8222`. The compose declares an external `shared-network` \u2014 if the first run fails, run `docker network create shared-network`.\n\n**For Kafka / RabbitMQ / SQS / GCP-Pub/Sub emulators**, use `infra/testing/docker-compose.yml` instead \u2014 those brokers are wired there on non-standard ports with emulators, matching the provider env blocks the scaffold writes.\n\n---\n\n## 7. FOOTGUN LIST (read before authoring)\n\n1. **Never reuse a step `id`** \u2014 anywhere, including across mutually-exclusive `switch`/`branch`/`tryCatch` arms. All ids share one flat per-workflow config map; duplicates collide (last definition wins) and the matched arm silently runs with the *other* arm's inputs. If two arms must write the same downstream key, give them distinct ids and use `as: \"shared\"`.\n2. **Don't prefix `@blokjs/expr`'s `expression` input with `js/`** \u2014 that input is itself mapper-resolved, so `js/...` double-evaluates. Write plain JS: `expression: \"ctx.state.x.y\"`.\n3. **`set_var` was removed in v0.5** \u2014 the runner throws at load time if present. Drop `set_var: true` (default-store handles it); replace `set_var: false` with `ephemeral: true`.\n4. **Use `\"ANY\"`, not `\"*\"`, for the wildcard HTTP method** \u2014 `\"*\"` is accepted but warns and is auto-normalized.\n5. **`trigger.queue` is rejected at construction** \u2014 it has no runtime and would silently never run. Use `trigger.worker` (`{ worker: { queue: \"<name>\" } }`).\n6. **Workflow envelope minimums:** `name` >= 3 chars, `version` >= 5 chars (semver), `steps` must be non-empty, and a `trigger` is required unless `middleware: true`.\n7. **Every v2 step schema is `.strict()`** \u2014 a misspelled or unknown field throws at load time, not silently dropped. A trigger-only field placed on a step (`concurrencyKey`, `delay`, `ttl`, `debounce`, `concurrencyLimit`) gets a targeted error pointing you to the trigger config.\n8. **`as` and `spread` are mutually exclusive** \u2014 pick one.\n9. **`$.prev` is volatile** (only the previous step). For any non-adjacent read use `$.state.<id>`. Reading `$.state.<id>` for a step that set `ephemeral: true` returns `undefined`.\n10. **Sub-workflow `idempotencyKey` with `wait: true` caches the WHOLE child result** \u2014 a cache hit means the child (and its side effects: emails, charges) never runs. Headline pattern AND primary footgun.\n\nPlus the cross-runtime rules: **the wrong input source** (typed sidecar nodes read their step `inputs`, NOT `ctx.request.body`), **registration is explicit** for every runtime (a file in `runtimes/<lang>/nodes/` does nothing until you register it by name), and **`type: \"runtime.<lang>\"` is required** on the step or it defaults to the in-process TS path and fails with `Node type X not found`.\n\n**Production env knob worth naming:** `BLOK_MAPPER_MODE=strict` \u2014 fail-fast on `js/...` input resolution errors instead of silently passing the literal string through. Strongly recommended for production.\n\n---\n\n## 8. TESTING\n\nUse the `@blokjs/runner` testing utilities with Vitest.\n\n**Unit-test a node** with `NodeTestHarness`:\n\n```ts\nimport { NodeTestHarness } from \"@blokjs/runner\";\nimport myNode from \"../src/nodes/my-node\";\n\nconst harness = new NodeTestHarness(myNode);\nconst result = await harness.execute({ userId: \"abc-123\" });\nharness.assertSuccess(result);\nharness.assertOutput(result, { user: { id: \"abc-123\" } });\n```\n\n**Integration-test a workflow** with `WorkflowTestRunner`:\n\n```ts\nimport { WorkflowTestRunner } from \"@blokjs/runner\";\n\nconst runner = new WorkflowTestRunner({ verbose: true, mockAllNodes: true });\nrunner.registerNode(\"validate\", ValidateNode);\nrunner.mockNode(\"external-api\", async (input) => ({ result: \"mocked\" }));\nrunner.loadWorkflow(myWorkflowDefinition);\nconst result = await runner.execute({ input: \"data\" });\nexpect(result.success).toBe(true);\n```\n\n---\n\n## Do / Do NOT\n\n**Do:**\n- Read `.blok/config.json` and existing `src/workflows/` to learn which triggers + runtimes this project uses; author for those.\n- Start every workflow from the **trigger decision table** in \u00A71, not from HTTP.\n- Use `workflow({ name, version, trigger, steps })` from `@blokjs/helper`.\n- Use the typed node contract in every runtime (`defineNode` / `DefineNode` / `TypedNode` / `@node`).\n- Reference cross-step outputs with `$.state.<id>`; use `as:`/`spread:`/`ephemeral:` to shape persistence.\n- Set `type: \"runtime.<lang>\"` on every sidecar step and register the node by name.\n\n**Do NOT:**\n- Default to the HTTP trigger because it's familiar \u2014 pick by intent.\n- Emit `trigger.queue` \u2014 it throws; use `worker`.\n- Use `\"*\"` for the wildcard method (use `\"ANY\"`), or `set_var` (removed in v0.5).\n- Write class-based `BlokService` nodes, or the stale Python `BlokService`/`async def handle()` shape.\n- Write to `ctx.state` inside a node \u2014 return your output (or `ctx.publish(...)` for a side-channel value).\n- Reuse a step `id`, combine `as` + `spread`, or use `any` types.\n- Read a typed sidecar node's data from `ctx.request.body` \u2014 read the step `inputs` / typed input.\n- Edit files under `.blok/runtimes/` \u2014 they are generated.\n";
|
|
39
|
+
declare const claude_md = "\n# Blok \u2014 Claude Code Quick Reference\n\nThis is the **terse operational quick-reference**. For full architecture, every trigger's complete config + examples, and the per-runtime node templates, **read `AGENTS.md`** in this project root.\n\n## Quick Commands\n\n```bash\nblokctl dev # Full dev server (spawns trigger runtimes + runner)\nblokctl create workflow <name> # Scaffold a workflow\nblokctl create node <name> # Scaffold a TS node\nblokctl create node <name> --runtime go # Scaffold a node in another runtime (go|rust|java|csharp|php|ruby|python3)\nblokctl trace # Open Blok Studio (or visit /__blok on the running trigger)\n```\n\n---\n\n## 1. Pick the right trigger FIRST (do NOT default to HTTP)\n\nBlok has **9 trigger kinds**. HTTP is **one of nine**, not the default \u2014 it is correct only for synchronous request/response. Every workflow declares exactly **one** trigger.\n\n**Before writing any workflow:** read **`.blok/config.json`** to see which triggers and runtimes this project actually scaffolded, and author for those. If the project is a `worker`/`cron`/`pubsub` project, do not write an HTTP workflow. Match the installed triggers.\n\n### Trigger Decision Table \u2014 choose by intent\n\n| What you're building | Trigger |\n|---|---|\n| Respond to an HTTP/REST request; JSON API; HTML page; file download | **`http`** |\n| Process a background / queued / async job; offload slow work | **`worker`** |\n| Run on a schedule / recurring time-based job (nightly, hourly) | **`cron`** |\n| React to messages on a cloud topic/subscription (cross-service events) | **`pubsub`** |\n| Stream live updates one-way to a browser (tokens, progress, feed) | **`sse`** |\n| Bidirectional realtime (chat, live cursors, client\u2194server messages) | **`websocket`** |\n| Receive a signed provider webhook (Stripe / GitHub / Slack / Shopify / Svix) | **`webhook`** |\n| Expose a workflow as a tool/resource to an AI/LLM client (Cursor, Claude) | **`mcp`** |\n| High-throughput typed RPC between services with a `.proto` contract | **`grpc`** |\n\nTie-breakers: one-way stream \u2192 `sse`; two-way \u2192 `websocket`. In-process pub/sub (single Node process, HTTP+SSE) \u2192 `sse` bus, not `pubsub`. Queue consumer \u2192 **`worker`** (the `queue` kind is dead \u2014 it throws at construction; never emit `trigger: { queue: ... }`).\n\n`http`, `sse`, `websocket`, `webhook`, `mcp` share one Hono port. `worker`, `cron`, `pubsub`, `grpc` run in their own processes. Regardless of kind, the body reads `ctx.request.{body,headers,params,query,method}` identically \u2014 only the `trigger:` block changes. See `AGENTS.md` for each kind's full config + a runnable example.\n\n---\n\n## 2. Context & State (v2)\n\n**Every step's output auto-persists to `ctx.state[id]` \u2014 on success only.** A step that errors writes nothing, so `ctx.state[<id>] === undefined` is a truthful \"did it succeed?\" check inside a `tryCatch.catch` arm.\n\n**The four reads** (the `$` proxy compiles to `\"js/ctx.<path>\"` strings; in JSON write those strings by hand):\n\n| Read | Resolves to | Scope |\n|---|---|---|\n| `$.state.<id>` | A prior step's stored output | Whole workflow (cross-step) |\n| `$.prev` | Immediately previous step's output | Adjacent only \u2014 overwritten every step |\n| `$.req` | Request envelope (body/headers/params/query/method) | Whole run |\n| `$.error` | Captured error (`.message`/`.code`/`.stepId`) | `tryCatch.catch` arm only |\n\n**Persistence knobs (per-step):**\n\n| Knob | Effect |\n|---|---|\n| *(none)* | Store at `ctx.state[id]` (the 95% case) |\n| `as: \"name\"` | Store at `ctx.state[name]` instead. Mutually exclusive with `spread` |\n| `spread: true` | Shallow-merge `result.data`'s keys into `ctx.state` (multi-output nodes) |\n| `ephemeral: true` | Skip storage; only `$.prev` carries it to the next step (logging/audit) |\n\nPer-step reliability lives on the step: `idempotencyKey` (cache by `(workflow, step.id, key)`, default 24h TTL), `retry: { maxAttempts, minTimeoutInMs?, factor? }`, `maxDuration: \"30s\"`. Cross-key gating + scheduling (`concurrencyKey`, `onLimit`, `delay`, `ttl`, `debounce`, `middleware`) go on the **trigger block**, never on a step.\n\n---\n\n## 3. Generating Nodes\n\nAlways `export default defineNode(...)` (TS) \u2014 never class-based `BlokService`. Zod input/output are mandatory. Never write `ctx.state` from a node \u2014 return your output and let the runner persist it (use `ctx.publish(name, value)` for a true side-channel). No `any` types \u2014 use `z.unknown()`.\n\n```typescript\nimport { defineNode } from \"@blokjs/runner\";\nimport { z } from \"zod\";\n\nexport default defineNode({\n name: \"fetch-user\",\n description: \"Fetches a user by ID\",\n input: z.object({ userId: z.string().uuid() }), // validated BEFORE execute \u2192 400 on fail\n output: z.object({ user: z.object({ id: z.string(), name: z.string() }) }), // validated AFTER \u2192 500 on fail\n async execute(ctx, input) {\n const user = await fetchUser(input.userId); // input is type-safe\n return { user }; // MUST match the output schema\n },\n});\n```\n\n### Nodes in other runtimes\n\nA non-TS node runs in a per-language sidecar and is referenced from a step with `type: \"runtime.<lang>\"` + `use: \"<node name>\"`. Scaffold one with `blokctl create node <name> --runtime <lang>`. **Full, copy-pasteable per-runtime node templates are in `AGENTS.md`.** Nodes live under `runtimes/<lang>/nodes/`.\n\n| Runtime | Step `type` | gRPC port |\n|---|---|---|\n| Go | `runtime.go` | 10001 |\n| Rust | `runtime.rust` | 10002 |\n| Java | `runtime.java` | 10003 |\n| C# | `runtime.csharp` | 10004 |\n| PHP | `runtime.php` | 10005 |\n| Ruby | `runtime.ruby` | 10006 |\n| Python3 | `runtime.python3` | 10007 |\n\n**Inline cross-runtime example (Python3 \u2014 `@node` is the Python `defineNode`):**\n\n```python\n# runtimes/python3/nodes/add_numbers/node.py\nfrom pydantic import BaseModel, Field\nfrom blok import node, Context\n\nclass AddNumbersInput(BaseModel):\n a: float\n b: float = Field(0)\n\nclass AddNumbersOutput(BaseModel):\n sum: float\n\n@node(\"add-numbers\", \"Adds two numbers and returns their sum\")\ndef add_numbers(ctx: Context, input: AddNumbersInput) -> AddNumbersOutput:\n return AddNumbersOutput(sum=input.a + input.b)\n```\n\nRegistration is **manual** in non-TS runtimes \u2014 importing the module runs the decorator; wire it into the boot path (see `AGENTS.md`). The `use:` value must match the registered node **name** string, not the function name.\n\n---\n\n## 4. Generating Workflows\n\nCanonical form: `workflow({ name, version, trigger, steps })` from `@blokjs/helper` \u2014 one object literal, no chained builder, no separate `nodes{}` map. `name` \u2265 3 chars, `version` \u2265 5 chars (semver). Reference earlier outputs with `$.state.<id>` / `$.req.body`. Use `branch`, `switchOn`, `forEach`, `loop`, `tryCatch` (all from `@blokjs/helper`) for control flow.\n\n```typescript\nimport { workflow, $ } from \"@blokjs/helper\";\n\nexport default workflow({\n name: \"Process Order\",\n version: \"1.0.0\",\n trigger: { http: { method: \"POST\", path: \"/orders\" } }, // path optional \u2192 derived from file path\n steps: [\n { id: \"validate\", use: \"order-validator\", inputs: { order: $.req.body } },\n { id: \"save\", use: \"order-store\", inputs: { data: $.state.validate } },\n ],\n});\n```\n\n**Swap the `trigger:` block for any other kind** (body stays the same). Full configs + examples in `AGENTS.md`:\n\n```typescript\ntrigger: { worker: { queue: \"background-jobs\" } } // background jobs\ntrigger: { cron: { schedule: \"0 2 * * *\", timezone: \"America/New_York\" } } // recurring\ntrigger: { pubsub: { provider: \"gcp\", topic: \"orders.placed\", subscription: \"fulfillment-svc\" } }\ntrigger: { sse: { path: \"/sse/clock\", heartbeatInterval: 15000 } } // one-way stream\ntrigger: { websocket: { path: \"/ws/echo\", events: [\"message\", \"open\", \"close\"] } }\ntrigger: { webhook: { provider: \"stripe\", secretEnv: \"STRIPE_WEBHOOK_SECRET\", idempotencyKey: \"js/ctx.request.body.id\" } }\ntrigger: { mcp: { path: \"/mcp\", tool: { description: \"...\" } } } // needs a workflow-level input: z.object({...})\ntrigger: { grpc: { service: \"UserService\", method: \"GetUser\", proto: \"users.proto\" } }\n```\n\n**Branch:**\n\n```typescript\nimport { workflow, branch, $ } from \"@blokjs/helper\";\nbranch({ id: \"route\",\n when: '$.req.method === \"POST\"', // when is a JS-expression STRING ($ can't intercept ===)\n then: [{ id: \"create\", use: \"...\", inputs: {...} }],\n else: [{ id: \"read\", use: \"...\", inputs: {...} }] })\n```\n\n**Worker/pubsub/broker projects** need local infra. The scaffold ships an `infra/development` docker-compose with the broker stack \u2014 `cd infra/development && docker compose up -d` to start NATS/Redis (run `docker network create shared-network` once if prompted).\n\n---\n\n## 5. Common Errors\n\n| Error | Cause | Fix |\n|---|---|---|\n| `Trigger kind 'queue' has no runtime` | Used `trigger: { queue: ... }` | Use `trigger: { worker: { queue: \"<name>\" } }` |\n| `Validation failed: name must be at least 3 characters` | Workflow `name` < 3 chars / `version` < 5 chars | Lengthen name; use full semver `x.x.x` |\n| `Unrecognized key(s) in object: \"...\"` | Misspelled / unknown field \u2014 every v2 step schema is `.strict()` | Fix the spelling; trigger-only fields (`concurrencyKey`, `delay`, `ttl`, `debounce`) belong on the trigger, not a step |\n| `ctx.state['X'] is undefined` | Step X has `ephemeral: true`, or `$.state.<id>` references a typo'd id | Remove `ephemeral`, or fix the id reference |\n| `as and spread are mutually exclusive` | Step set both | Pick one |\n| `branch step is missing 'when'` | No condition string | Set `when: \"...\"` |\n| `step \"...\" uses set_var` | Legacy field (removed v0.5) | Drop `set_var: true`; replace `set_var: false` with `ephemeral: true` |\n| `node '<name>' not found in registry` (non-TS) | Node not imported/registered in the sidecar boot path | Import the module + register it; `use:` must match the registered node name |\n| `Node type X not found` | Missing runtime resolver / wrong `type` | Check `type: \"runtime.<lang>\"` and that the runtime is scaffolded |\n| `[blok][mapper] Failed to resolve ...` | A `js/...` input expression threw | Fix the expression; set `BLOK_MAPPER_MODE=strict` to fail-fast in prod |\n\n---\n\n## 6. Do NOT\n\n- Do NOT default to the HTTP trigger \u2014 read `.blok/config.json` and pick the trigger by intent (Section 1).\n- Do NOT use `trigger: { queue: ... }` \u2014 it has no runtime and throws. Use `worker`.\n- Do NOT reuse a step `id` anywhere \u2014 including across `switch`/`branch`/`tryCatch` arms (all ids share one flat map; duplicates collide silently). Use `as:` if two arms must write the same downstream key.\n- Do NOT write to `ctx.state` inside a node's `execute()` \u2014 return your output; use `ctx.publish(name, value)` for a side-channel.\n- Do NOT assume `$.prev` (or `ctx.response.data`) survives more than one step \u2014 use `$.state.<id>` for cross-step reads.\n- Do NOT prefix `@blokjs/expr`'s `expression` input with `js/` \u2014 it double-evaluates. Write plain JS: `expression: \"ctx.state.x.y\"`.\n- Do NOT use `set_var` \u2014 removed in v0.5, throws at load.\n- Do NOT use `\"*\"` for the wildcard HTTP method \u2014 use `\"ANY\"`.\n- Do NOT generate class-based `BlokService` nodes or use `any` types \u2014 always `defineNode()` (TS) / `@node` (Python) with Zod/Pydantic schemas.\n- Do NOT use ESLint/Prettier \u2014 this project uses Biome. Do NOT edit auto-generated files in `.blok/runtimes/`.\n";
|
|
40
40
|
declare const function_first_node_file = "import { defineNode } from \"@blokjs/runner\";\nimport { z } from \"zod\";\n\n/**\n * A function-first node that demonstrates the modern defineNode pattern.\n * This node is type-safe, validated, and requires 60% less boilerplate.\n */\nexport default defineNode({\n\tname: \"{{NODE_NAME}}\",\n\tdescription: \"A function-first node with Zod validation\",\n\n\t// Input schema using Zod - automatically validated\n\tinput: z.object({\n\t\tmessage: z.string().optional().default(\"Hello World\"),\n\t}),\n\n\t// Output schema using Zod - automatically validated\n\toutput: z.object({\n\t\tmessage: z.string(),\n\t\ttimestamp: z.string(),\n\t}),\n\n\t// Execute function - type-safe with inferred types from Zod schemas\n\tasync execute(ctx, input) {\n\t\t// Your business logic here\n\t\t// - ctx.vars: Access workflow variables\n\t\t// - ctx.request: Access HTTP request data\n\t\t// - ctx.logger: Log messages\n\t\t// - ctx.env: Access environment variables\n\n\t\t// Example: Store data for downstream nodes\n\t\tctx.vars[\"processed-message\"] = input.message;\n\n\t\t// Return type-safe output (validated automatically)\n\t\treturn {\n\t\t\tmessage: `Processed: ${input.message}`,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t};\n\t},\n});\n";
|
|
41
41
|
export { node_file, package_dependencies, package_dev_dependencies, python3_file, examples_url, workflow_template, supervisord_nodejs, supervisord_python, go_node_file, go_mod_file, go_dockerfile, java_node_file, java_pom_file, java_dockerfile, rust_node_file, rust_cargo_file, rust_dockerfile, csharp_node_file, csharp_csproj_file, csharp_dockerfile, php_node_file, php_composer_file, php_dockerfile, ruby_node_file, ruby_gemfile, ruby_dockerfile, function_first_node_file, agents_md, claude_md, };
|