blokctl 0.6.19 → 0.6.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/create/project.js +52 -13
- package/dist/commands/create/utils/Examples.d.ts +3 -3
- package/dist/commands/create/utils/Examples.js +987 -328
- package/dist/commands/gen/appTypes.js +40 -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
|
@@ -74,6 +74,7 @@ Examples:
|
|
|
74
74
|
7- Webhook router: POST /webhooks/{stripe,github,linear} with signed bodies — set the matching *_WEBHOOK_SECRET env vars (needs --triggers webhook)
|
|
75
75
|
8- LLM agent w/ tool calls: open http://localhost:4000/agent — model picks between get_weather and calculate tools (needs OPENROUTER_API_KEY + Redis)
|
|
76
76
|
9- 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)
|
|
77
|
+
10- Trigger references (NOT http): workflows/json/{cron-heartbeat,pubsub-on-order,websocket-echo}.json demonstrate the cron, pubsub, and websocket triggers — read AGENTS.md "Choosing a trigger" to pick the right one by intent instead of defaulting to HTTP.
|
|
77
78
|
|
|
78
79
|
For more documentation, visit src/nodes/examples/README.md. The first three examples require a PostgreSQL database to function.
|
|
79
80
|
`;
|
|
@@ -611,470 +612,1128 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\
|
|
|
611
612
|
|
|
612
613
|
CMD ["bundle", "exec", "puma", "-b", "tcp://0.0.0.0:8080"]
|
|
613
614
|
`;
|
|
614
|
-
const agents_md =
|
|
615
|
+
const agents_md = `
|
|
616
|
+
# AGENTS.md — Blok Framework AI Context
|
|
615
617
|
|
|
616
|
-
Blok is a
|
|
618
|
+
Blok 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:
|
|
617
619
|
|
|
618
|
-
|
|
620
|
+
- **HTTP is ONE of 9 triggers, NOT the default.** Every workflow declares exactly one trigger. Picking \`http\` reflexively is the most common mistake — start with the decision table below.
|
|
621
|
+
- **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>"\`.
|
|
619
622
|
|
|
623
|
+
The 9 trigger types: \`http\`, \`worker\`, \`cron\`, \`pubsub\`, \`sse\`, \`websocket\`, \`webhook\`, \`mcp\`, \`grpc\`.
|
|
624
|
+
The 8 runtimes: \`typescript\` (in-process), \`go\`, \`rust\`, \`java\`, \`csharp\`, \`php\`, \`ruby\`, \`python3\`.
|
|
625
|
+
|
|
626
|
+
The canonical workflow form is \`workflow({ name, version, trigger, steps })\` from \`@blokjs/helper\`. The same shape works for all 9 triggers — only the \`trigger:\` block changes.
|
|
627
|
+
|
|
628
|
+
---
|
|
629
|
+
|
|
630
|
+
## 1. CHOOSING A TRIGGER (do this first, every time)
|
|
631
|
+
|
|
632
|
+
**Before writing \`trigger: { http: ... }\`, read this table and pick by intent.**
|
|
633
|
+
|
|
634
|
+
| Intent / what you're building | Trigger | Why NOT http |
|
|
635
|
+
|---|---|---|
|
|
636
|
+
| Respond to an HTTP/REST request; JSON API; HTML page; file download | **\`http\`** | — |
|
|
637
|
+
| Process a background / queued / async job; offload slow work | **\`worker\`** | http blocks the caller; jobs need a queue + retries + DLQ |
|
|
638
|
+
| Run on a schedule / recurring time-based job (nightly, hourly, cron) | **\`cron\`** | http only fires on a request; nothing calls it on a timer |
|
|
639
|
+
| React to messages on a cloud topic/subscription (cross-service events) | **\`pubsub\`** | http isn't subscribed to a broker; events would be dropped |
|
|
640
|
+
| 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 |
|
|
641
|
+
| Bidirectional realtime (chat rooms, live cursors, client↔server messages) | **\`websocket\`** | http is half-duplex request/response, no server push back-channel |
|
|
642
|
+
| Receive a signed provider webhook (Stripe / GitHub / Slack / Shopify / Svix / custom HMAC) | **\`webhook\`** | http won't verify the HMAC signature or do replay protection |
|
|
643
|
+
| 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 |
|
|
644
|
+
| High-throughput typed RPC between services with a proto contract | **\`grpc\`** | http/REST overhead is too high; no typed contract |
|
|
645
|
+
|
|
646
|
+
**Tie-breakers:**
|
|
647
|
+
- **One-way stream → \`sse\`; two-way → \`websocket\`.** SSE is cheaper and simpler; reach for \`websocket\` only when the client must send messages back over the same connection.
|
|
648
|
+
- **In-process pub/sub (single Node process, HTTP+SSE chains) → the \`sse\` bus, NOT \`pubsub\`.** \`pubsub\` is the multi-process / multi-cloud sibling backed by an external broker.
|
|
649
|
+
- **Queue consumer → \`worker\`, never \`queue\`.** \`trigger.queue\` is **DEAD** — it has a schema but no runtime and throws at workflow construction time. Always use \`worker\` (\`{ worker: { queue: "<name>" } }\`).
|
|
650
|
+
|
|
651
|
+
### Read \`.blok/config.json\` first
|
|
652
|
+
|
|
653
|
+
The project records which triggers and runtimes were actually scaffolded in **\`.blok/config.json\`**. **Author for those — 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/\`.
|
|
654
|
+
|
|
655
|
+
### Same-port vs cross-process families
|
|
656
|
+
|
|
657
|
+
- **Same-port family** — \`http\`, \`sse\`, \`websocket\`, \`webhook\`, \`mcp\` all mount on the **same Hono HTTP server / port** (default 4000) and share an in-process event bus.
|
|
658
|
+
- **Cross-process family** — \`worker\`, \`cron\`, \`pubsub\`, \`grpc\` each run in their **own Node process** and coordinate via external brokers / their own ports.
|
|
659
|
+
|
|
660
|
+
Regardless of kind, every trigger populates \`ctx.request.{body,headers,params,query,method}\`, so the workflow body is structurally identical across triggers — only the \`trigger:\` block differs.
|
|
661
|
+
|
|
662
|
+
---
|
|
663
|
+
|
|
664
|
+
## 2. THE 9 TRIGGERS
|
|
665
|
+
|
|
666
|
+
Each trigger below: one-line purpose, USE-WHEN / DON'T, config shape, and a canonical \`workflow({...})\` example.
|
|
667
|
+
|
|
668
|
+
### 2.1 HTTP — \`trigger: { http: {...} }\`
|
|
669
|
+
|
|
670
|
+
**Purpose:** Turn a workflow into an inbound HTTP/REST endpoint. Owns the listening server (default port 4000) that sse/websocket/webhook/mcp mount onto.
|
|
671
|
+
|
|
672
|
+
**USE WHEN:** synchronous request→response; JSON APIs; HTML UI (\`accept: "text/html"\`); file downloads. **DON'T USE FOR:** background jobs (→\`worker\`), scheduled work (→\`cron\`), broker events (→\`pubsub\`), live push (→\`sse\`/\`websocket\`), signed callbacks (→\`webhook\`).
|
|
673
|
+
|
|
674
|
+
\`\`\`ts
|
|
675
|
+
trigger: { http: {
|
|
676
|
+
method: "GET"|"POST"|"PUT"|"DELETE"|"PATCH"|"HEAD"|"OPTIONS"|"ANY", // required; use "ANY" not "*"
|
|
677
|
+
path?: string, // optional; omit → derived from file path
|
|
678
|
+
accept?: string, // default "application/json"; "text/html" for UI
|
|
679
|
+
headers?: Record<string,string>, // required-headers gate; missing → 400 before any step
|
|
680
|
+
middleware?: string[],
|
|
681
|
+
// shared concurrency/scheduling: concurrencyKey, concurrencyLimit, onLimit, delay, ttl, debounce
|
|
682
|
+
}}
|
|
683
|
+
\`\`\`
|
|
684
|
+
|
|
685
|
+
\`\`\`ts
|
|
686
|
+
import { workflow, $ } from "@blokjs/helper";
|
|
687
|
+
|
|
688
|
+
export default workflow({
|
|
689
|
+
name: "Get User", version: "1.0.0",
|
|
690
|
+
trigger: { http: { method: "GET", path: "/users/:id" } },
|
|
691
|
+
steps: [
|
|
692
|
+
{ id: "lookup", use: "@blokjs/api-call",
|
|
693
|
+
inputs: { url: "js/\`https://internal/users/\${ctx.request.params.id}\`" } },
|
|
694
|
+
{ id: "respond", use: "@blokjs/respond", inputs: { body: $.state.lookup }, ephemeral: true },
|
|
695
|
+
],
|
|
696
|
+
});
|
|
620
697
|
\`\`\`
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
698
|
+
|
|
699
|
+
### 2.2 WORKER — \`trigger: { worker: {...} }\`
|
|
700
|
+
|
|
701
|
+
**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 — \`queue\` is dead.**
|
|
702
|
+
|
|
703
|
+
**USE WHEN:** offloading slow/async work; queue consumers; fan-out job processing. **DON'T USE FOR:** synchronous responses (→\`http\`); time schedules (→\`cron\`); cloud fan-out topics (→\`pubsub\`).
|
|
704
|
+
|
|
705
|
+
\`\`\`ts
|
|
706
|
+
trigger: { worker: {
|
|
707
|
+
queue: string, // required — queue/topic/stream name
|
|
708
|
+
provider?: "in-memory"|"nats"|"bullmq"|"kafka"|"rabbitmq"|"sqs"|"redis"|"pg-boss", // default in-memory
|
|
709
|
+
concurrency?: number, // default 1 — concurrent jobs per process
|
|
710
|
+
timeout?: number, // ms — per-attempt hard timeout
|
|
711
|
+
retries?: number, // default 3 — then DLQ
|
|
712
|
+
priority?: number, consumerGroup?: string, ack?: boolean,
|
|
713
|
+
deadLetterQueue?: string, fromBeginning?: boolean,
|
|
714
|
+
// shared concurrency/scheduling: concurrencyKey, concurrencyLimit, onLimit, delay, ttl, debounce, middleware
|
|
715
|
+
}}
|
|
634
716
|
\`\`\`
|
|
635
717
|
|
|
636
|
-
|
|
718
|
+
\`\`\`ts
|
|
719
|
+
import { workflow } from "@blokjs/helper";
|
|
637
720
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
721
|
+
export default workflow({
|
|
722
|
+
name: "Process Background Job", version: "1.0.0",
|
|
723
|
+
trigger: { worker: { queue: "background-jobs" } },
|
|
724
|
+
steps: [
|
|
725
|
+
{ id: "process-job", use: "@blokjs/api-call", type: "module",
|
|
726
|
+
inputs: { url: "https://example.com/process", method: "POST", body: "js/ctx.request.body" } },
|
|
727
|
+
],
|
|
728
|
+
});
|
|
646
729
|
\`\`\`
|
|
647
730
|
|
|
648
|
-
|
|
731
|
+
**Worker context mapping:** \`ctx.request.body\` → job payload; \`ctx.request.params.{queue,jobId,attempt}\` → job metadata; \`ctx.vars._worker_job\` → 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\`).
|
|
649
732
|
|
|
650
|
-
|
|
733
|
+
### 2.3 CRON — \`trigger: { cron: {...} }\`
|
|
651
734
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
}
|
|
735
|
+
**Purpose:** Run a workflow on a time schedule (standard cron expression). Dedicated process.
|
|
736
|
+
|
|
737
|
+
**USE WHEN:** recurring/scheduled work — nightly cleanup, hourly polls, daily digests, periodic syncs. **DON'T USE FOR:** anything triggered by an external event or request.
|
|
738
|
+
|
|
739
|
+
\`\`\`ts
|
|
740
|
+
trigger: { cron: {
|
|
741
|
+
schedule: string, // required — "m h dom mon dow", e.g. "0 2 * * *"
|
|
742
|
+
timezone?: string, // default "UTC" — IANA tz e.g. "America/New_York"
|
|
743
|
+
overlap?: boolean, // default false — allow overlapping executions
|
|
744
|
+
// also: concurrencyKey, concurrencyLimit, middleware
|
|
745
|
+
}}
|
|
746
|
+
\`\`\`
|
|
747
|
+
|
|
748
|
+
\`\`\`ts
|
|
749
|
+
import { workflow } from "@blokjs/helper";
|
|
750
|
+
|
|
751
|
+
export default workflow({
|
|
752
|
+
name: "Daily Cleanup", version: "1.0.0",
|
|
753
|
+
trigger: { cron: { schedule: "0 2 * * *", timezone: "America/New_York" } },
|
|
754
|
+
steps: [
|
|
755
|
+
{ id: "purge-stale", use: "@blokjs/api-call",
|
|
756
|
+
inputs: { url: "https://api.example.com/cleanup", method: "POST" } },
|
|
757
|
+
],
|
|
758
|
+
});
|
|
663
759
|
\`\`\`
|
|
664
760
|
|
|
665
|
-
|
|
761
|
+
\`ctx.request.body\` is \`{}\`; fire metadata is on \`ctx.request.params.{schedule,firedAt}\`. To serialize overlapping runs use \`concurrencyKey: "self"\`, \`concurrencyLimit: 1\`.
|
|
666
762
|
|
|
667
|
-
|
|
668
|
-
Each step's output replaces \\\`ctx.prev\\\`. Use it for adjacent-step access only.
|
|
763
|
+
### 2.4 PUBSUB — \`trigger: { pubsub: {...} }\`
|
|
669
764
|
|
|
670
|
-
**
|
|
671
|
-
Every 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\\\`.
|
|
765
|
+
**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.
|
|
672
766
|
|
|
673
|
-
|
|
767
|
+
**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 (→\`sse\` bus); plain job queues with competing consumers + retries (→\`worker\`).
|
|
674
768
|
|
|
769
|
+
\`\`\`ts
|
|
770
|
+
trigger: { pubsub: {
|
|
771
|
+
provider?: "nats"|"redis-streams"|"kafka"|"gcp"|"aws"|"azure", // default BLOK_PUBSUB_ADAPTER
|
|
772
|
+
topic: string, // required — topic/subject/stream (wildcards ok: "orders.*.created")
|
|
773
|
+
subscription?: string, // required for gcp/aws/azure; derived from consumerGroup otherwise
|
|
774
|
+
consumerGroup?: string, // set → competing-consumer; unset → fan-out
|
|
775
|
+
durable?: boolean, startFrom?: "earliest"|"latest"|{seq:number}|{timestamp:number},
|
|
776
|
+
ack?: boolean, // default true
|
|
777
|
+
maxMessages?: number, // default 10
|
|
778
|
+
ackDeadline?: number, // default 30 (s)
|
|
779
|
+
deadLetterTopic?: string, filter?: string,
|
|
780
|
+
}}
|
|
675
781
|
\`\`\`
|
|
676
|
-
Step 1: id "fetch-user"
|
|
677
|
-
→ ctx.state["fetch-user"] = { id: "123", name: "Alice" }
|
|
678
|
-
→ ctx.prev = { id: "123", name: "Alice" }
|
|
679
782
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
→ ctx.prev = { result: "done" } ← Step 1 output GONE from prev
|
|
683
|
-
→ ctx.state["fetch-user"] still available
|
|
783
|
+
\`\`\`ts
|
|
784
|
+
import { workflow } from "@blokjs/helper";
|
|
684
785
|
|
|
685
|
-
|
|
686
|
-
|
|
786
|
+
export default workflow({
|
|
787
|
+
name: "On Order Placed", version: "1.0.0",
|
|
788
|
+
trigger: { pubsub: { provider: "gcp", topic: "orders.placed", subscription: "fulfillment-svc" } },
|
|
789
|
+
steps: [
|
|
790
|
+
{ id: "fulfill", use: "@blokjs/api-call",
|
|
791
|
+
idempotencyKey: "js/ctx.request.params.messageId", // dedup redeliveries
|
|
792
|
+
inputs: { url: "https://fulfillment.internal/api/orders", method: "POST", body: "js/ctx.request.body" } },
|
|
793
|
+
],
|
|
794
|
+
});
|
|
687
795
|
\`\`\`
|
|
688
796
|
|
|
689
|
-
|
|
797
|
+
\`messageId\` on \`ctx.request.params\` is the natural \`idempotencyKey\`. Provider env vars — GCP: \`GOOGLE_APPLICATION_CREDENTIALS\`+\`PUBSUB_PROJECT_ID\`; AWS: standard credential chain (\`topic\`=SNS ARN, \`subscription\`=SQS URL); Azure: \`AZURE_SERVICEBUS_CONNECTION_STRING\`.
|
|
690
798
|
|
|
691
|
-
|
|
799
|
+
### 2.5 SSE — \`trigger: { sse: {...} }\`
|
|
692
800
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
801
|
+
**Purpose:** One-way server→browser streaming via \`EventSource\`. Mounts on the shared HTTP port; pumps in-process bus events to connected clients.
|
|
802
|
+
|
|
803
|
+
**USE WHEN:** pushing live updates one-way — token streaming from an LLM, progress feeds, notification streams. **DON'T USE FOR:** client→server messages (→\`websocket\`); one-shot JSON responses (→\`http\`).
|
|
804
|
+
|
|
805
|
+
\`\`\`ts
|
|
806
|
+
trigger: { sse: {
|
|
807
|
+
path?: string, // URL path; supports :params (e.g. "/sse/chat/:sessionId")
|
|
808
|
+
events?: string[], // default ["*"]
|
|
809
|
+
channels?: string[],
|
|
810
|
+
maxConnections?: number, // default 10000
|
|
811
|
+
heartbeatInterval?: number, // default 30000 ms
|
|
812
|
+
retryInterval?: number, // default 3000 ms (browser reconnect hint)
|
|
813
|
+
// also: concurrencyKey, concurrencyLimit
|
|
814
|
+
}}
|
|
815
|
+
\`\`\`
|
|
816
|
+
|
|
817
|
+
\`\`\`ts
|
|
818
|
+
import { workflow } from "@blokjs/helper";
|
|
819
|
+
|
|
820
|
+
export default workflow({
|
|
821
|
+
name: "Clock Stream", version: "1.0.0",
|
|
822
|
+
trigger: { sse: { path: "/sse/clock", heartbeatInterval: 15000 } },
|
|
823
|
+
steps: [
|
|
824
|
+
{ id: "sub", use: "@blokjs/sse-subscribe", inputs: { channels: ["clock"] } },
|
|
825
|
+
{ id: "stream", use: "@blokjs/sse-stream", inputs: { source: "js/ctx.state.sub" } },
|
|
826
|
+
],
|
|
827
|
+
});
|
|
828
|
+
\`\`\`
|
|
829
|
+
|
|
830
|
+
A sibling HTTP workflow publishes via \`@blokjs/sse-publish\`; both share the in-process bus. Cross-process needs a Redis pub/sub backplane.
|
|
831
|
+
|
|
832
|
+
### 2.6 WEBSOCKET — \`trigger: { websocket: {...} }\`
|
|
833
|
+
|
|
834
|
+
**Purpose:** Bidirectional WS connections; one workflow run per inbound message/lifecycle event. Mounts on the shared HTTP port via Hono \`upgradeWebSocket\`.
|
|
835
|
+
|
|
836
|
+
**USE WHEN:** two-way realtime — chat rooms, live cursors, RPC-over-WS, server-pushed updates the client also writes to. **DON'T USE FOR:** one-way push (→\`sse\` is cheaper); request/response (→\`http\`).
|
|
837
|
+
|
|
838
|
+
\`\`\`ts
|
|
839
|
+
trigger: { websocket: {
|
|
840
|
+
path?: string, // URL path; supports :params (e.g. "/ws/room/:roomId")
|
|
841
|
+
events?: string[], // default ["*"]; supported: "open"|"message"|"close"|"error"
|
|
842
|
+
rooms?: string[],
|
|
843
|
+
maxConnections?: number, // default 10000
|
|
844
|
+
heartbeatInterval?: number, // default 30000 ms
|
|
845
|
+
messageRateLimit?: number, // default 100 msgs/sec/client
|
|
846
|
+
// also: concurrencyKey, concurrencyLimit
|
|
847
|
+
}}
|
|
848
|
+
\`\`\`
|
|
849
|
+
|
|
850
|
+
\`\`\`ts
|
|
851
|
+
import { workflow } from "@blokjs/helper";
|
|
852
|
+
|
|
853
|
+
export default workflow({
|
|
854
|
+
name: "WS Echo", version: "1.0.0",
|
|
855
|
+
trigger: { websocket: { path: "/ws/echo", events: ["message", "open", "close"] } },
|
|
856
|
+
steps: [
|
|
857
|
+
{ id: "reply", use: "@blokjs/ws-reply",
|
|
858
|
+
inputs: { message: "js/({ echo: ctx.request.body, at: Date.now() })" } },
|
|
859
|
+
],
|
|
860
|
+
});
|
|
701
861
|
\`\`\`
|
|
702
862
|
|
|
703
|
-
|
|
863
|
+
Helpers: \`@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\`.
|
|
864
|
+
|
|
865
|
+
### 2.7 WEBHOOK — \`trigger: { webhook: {...} }\`
|
|
866
|
+
|
|
867
|
+
**Purpose:** Receive signed provider POSTs, verify the HMAC signature, apply replay protection, then dispatch. Mounts on the shared HTTP port.
|
|
868
|
+
|
|
869
|
+
**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 (→\`http\`).
|
|
870
|
+
|
|
871
|
+
\`\`\`ts
|
|
872
|
+
trigger: { webhook: {
|
|
873
|
+
provider?: "github"|"stripe"|"slack"|"shopify"|"svix", // pick this OR signature, not both
|
|
874
|
+
path?: string, // defaults to /webhooks/<provider>
|
|
875
|
+
secretEnv?: string, // env var name holding the shared secret (never inline the secret)
|
|
876
|
+
events?: string[], // allowlist; out-of-scope → 200 {status:"ignored"}
|
|
877
|
+
tolerance?: number, // seconds, default 300 — clock-skew window
|
|
878
|
+
idempotencyKey?: string, // e.g. "js/ctx.request.body.id" — replay protection
|
|
879
|
+
namespace?: string, // prefix for polymorphic subworkflow dispatch
|
|
880
|
+
middleware?: string[],
|
|
881
|
+
signature?: { // custom HMAC for non-built-in providers
|
|
882
|
+
scheme?: "hmac-sha256"|"hmac-sha1"|"hmac-sha512", // default sha256
|
|
883
|
+
header: string, format?: string, // format default "{hex}"; "{hex}"/"{base64}"
|
|
884
|
+
secretEnv: string, tolerance?: number, timestampHeader?: string,
|
|
885
|
+
},
|
|
886
|
+
}}
|
|
887
|
+
\`\`\`
|
|
888
|
+
|
|
889
|
+
\`\`\`ts
|
|
890
|
+
import { workflow } from "@blokjs/helper";
|
|
891
|
+
|
|
892
|
+
export default workflow({
|
|
893
|
+
name: "Stripe Webhook", version: "1.0.0",
|
|
894
|
+
trigger: { webhook: {
|
|
895
|
+
provider: "stripe", namespace: "stripe",
|
|
896
|
+
secretEnv: "STRIPE_WEBHOOK_SECRET", idempotencyKey: "js/ctx.request.body.id",
|
|
897
|
+
}},
|
|
898
|
+
steps: [
|
|
899
|
+
{ id: "dispatch", subworkflow: "js/ctx.request.body.type", // "invoice.paid" → "stripe.invoice.paid"
|
|
900
|
+
inputs: { stripeEvent: "js/ctx.request.body" } },
|
|
901
|
+
],
|
|
902
|
+
});
|
|
903
|
+
\`\`\`
|
|
904
|
+
|
|
905
|
+
Bad signature → 401 with a structured \`reason\`; duplicate → 200 \`{status:"duplicate"}\`; a workflow throw still returns 200 (senders shouldn't retry). \`ctx.request.rawBody\` carries the bytes the HMAC was computed against.
|
|
906
|
+
|
|
907
|
+
### 2.8 MCP — \`trigger: { mcp: {...} }\`
|
|
908
|
+
|
|
909
|
+
**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.
|
|
910
|
+
|
|
911
|
+
**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 (→\`http\`).
|
|
912
|
+
|
|
913
|
+
\`\`\`ts
|
|
914
|
+
trigger: { mcp: {
|
|
915
|
+
path?: string, // default "/mcp"
|
|
916
|
+
serverName?: string, // default "blok-mcp"; workflows sharing path+serverName aggregate
|
|
917
|
+
serverVersion?: string, // default "1.0.0"
|
|
918
|
+
transports?: ("sse"|"streamable-http")[], // default both
|
|
919
|
+
tool?: { name?: string, description?: string },
|
|
920
|
+
resource?: { uri: string, name?: string, description?: string, mimeType?: string }, // expose as resource
|
|
921
|
+
middleware?: string[],
|
|
922
|
+
}}
|
|
923
|
+
\`\`\`
|
|
924
|
+
|
|
925
|
+
**Requires a workflow-level \`input:\` Zod schema** — that becomes the tool's \`inputSchema\`:
|
|
926
|
+
|
|
927
|
+
\`\`\`ts
|
|
928
|
+
import { workflow, $ } from "@blokjs/helper";
|
|
929
|
+
import { z } from "zod";
|
|
930
|
+
|
|
931
|
+
export default workflow({
|
|
932
|
+
name: "search_code", version: "1.0.0",
|
|
933
|
+
input: z.object({ query: z.string(), limit: z.number().optional() }), // → tool inputSchema
|
|
934
|
+
trigger: { mcp: { path: "/mcp", serverName: "my-platform",
|
|
935
|
+
tool: { description: "Full-text search the indexed code" } } },
|
|
936
|
+
steps: [ { id: "search", use: "@my/search", inputs: { query: $.req.body.query } } ],
|
|
937
|
+
});
|
|
938
|
+
\`\`\`
|
|
939
|
+
|
|
940
|
+
Serves over SSE (\`GET <path>/sse\` + \`POST <path>/messages\`) and/or Streamable-HTTP (\`<path>\`).
|
|
941
|
+
|
|
942
|
+
**Connecting a client** — 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):
|
|
943
|
+
- **Claude Code:** \`claude mcp add --transport http blok http://localhost:4000/mcp\`
|
|
944
|
+
- **Cursor** (\`.cursor/mcp.json\`): \`{ "mcpServers": { "blok": { "url": "http://localhost:4000/mcp" } } }\`
|
|
945
|
+
- **Quick test:** \`npx @modelcontextprotocol/inspector\` → connect to \`http://localhost:4000/mcp\`
|
|
946
|
+
|
|
947
|
+
\`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 — scope access yourself.
|
|
948
|
+
|
|
949
|
+
### 2.9 GRPC — \`trigger: { grpc: {...} }\`
|
|
950
|
+
|
|
951
|
+
**Purpose:** Expose a workflow as a gRPC service method handler — typed, contract-based RPC. Dedicated process bound to a gRPC port.
|
|
952
|
+
|
|
953
|
+
**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 (→\`http\`); async work (→\`worker\`).
|
|
954
|
+
|
|
955
|
+
> Caveat: gRPC config is **not Zod-validated** at construction. Author against the documented surface:
|
|
956
|
+
|
|
957
|
+
\`\`\`ts
|
|
958
|
+
trigger: { grpc: {
|
|
959
|
+
service: string, // matches \`service Foo {}\` in the proto
|
|
960
|
+
method: string, // matches \`rpc Bar(...) returns (...)\`
|
|
961
|
+
proto: string, // path to the .proto file, relative to the workflow
|
|
962
|
+
port?: number, // default 50051; all grpc workflows share one port (env GRPC_PORT)
|
|
963
|
+
middleware?: string[],
|
|
964
|
+
}}
|
|
965
|
+
\`\`\`
|
|
966
|
+
|
|
967
|
+
\`\`\`ts
|
|
968
|
+
import { workflow } from "@blokjs/helper";
|
|
969
|
+
|
|
970
|
+
export default workflow({
|
|
971
|
+
name: "GetUser", version: "1.0.0",
|
|
972
|
+
trigger: { grpc: { service: "UserService", method: "GetUser", proto: "users.proto" } },
|
|
973
|
+
steps: [
|
|
974
|
+
{ id: "lookup", use: "@blokjs/api-call",
|
|
975
|
+
inputs: { url: "js/\`https://internal/users/\${ctx.request.body.userId}\`", method: "GET" } },
|
|
976
|
+
],
|
|
977
|
+
});
|
|
978
|
+
\`\`\`
|
|
979
|
+
|
|
980
|
+
The request decodes into \`ctx.request.body\`; the final step output becomes the gRPC reply. Streaming RPCs use \`@blokjs/grpc-stream\`.
|
|
981
|
+
|
|
982
|
+
> **\`trigger.queue\` is DEAD** — it has a schema but no runtime and throws at workflow construction. Use \`worker\`. **\`manual\`** has no listener (invoked programmatically only — tests / sub-workflows); not for normal authoring.
|
|
704
983
|
|
|
705
984
|
---
|
|
706
985
|
|
|
707
|
-
##
|
|
986
|
+
## 3. AUTHORING WORKFLOWS (v2 DSL)
|
|
708
987
|
|
|
709
|
-
|
|
988
|
+
Import from \`@blokjs/helper\`: \`{ workflow, $, branch, switchOn, forEach, loop, tryCatch }\`. The default export is \`workflow({...})\` — a single object literal, no chaining, no separate \`nodes{}\` map.
|
|
710
989
|
|
|
711
|
-
\`\`\`
|
|
990
|
+
\`\`\`ts
|
|
991
|
+
import { workflow, $ } from "@blokjs/helper";
|
|
992
|
+
|
|
993
|
+
export default workflow({
|
|
994
|
+
name: "Process Order", // >= 3 chars
|
|
995
|
+
version: "1.0.0", // semver x.x.x (>= 5 chars)
|
|
996
|
+
trigger: { http: { method: "POST", path: "/orders" } }, // path optional → derived from file path
|
|
997
|
+
steps: [
|
|
998
|
+
{ id: "validate", use: "order-validator", inputs: { order: $.req.body } },
|
|
999
|
+
{ id: "save", use: "order-store", inputs: { data: $.state.validate } },
|
|
1000
|
+
],
|
|
1001
|
+
});
|
|
1002
|
+
\`\`\`
|
|
1003
|
+
|
|
1004
|
+
A 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).
|
|
1005
|
+
|
|
1006
|
+
### The four context reads
|
|
1007
|
+
|
|
1008
|
+
| Read | Resolves to | Scope |
|
|
1009
|
+
|---|---|---|
|
|
1010
|
+
| \`$.state.<id>\` | A prior step's stored output | Whole workflow (cross-step) |
|
|
1011
|
+
| \`$.prev\` | Immediately previous step's output | Adjacent only — overwritten every step |
|
|
1012
|
+
| \`$.req\` | Request envelope (body/headers/params/query/method/url) | Whole run |
|
|
1013
|
+
| \`$.error\` | Captured error inside a \`tryCatch.catch\` block | \`catch\` arm only — \`undefined\` elsewhere |
|
|
1014
|
+
|
|
1015
|
+
\`$.error\` exposes \`.message\`, \`.name\`, \`.stack\`, \`.code\` (upstream HTTP status), and \`.stepId\`. The \`$\` proxy compiles to \`"js/ctx.<path>"\` strings at definition time — 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\` — prefer the canonical four.
|
|
1016
|
+
|
|
1017
|
+
### Persistence knobs (per-step, declarative)
|
|
1018
|
+
|
|
1019
|
+
| Knob | Effect |
|
|
1020
|
+
|---|---|
|
|
1021
|
+
| *(none)* | Store at \`ctx.state[id]\` (the 95% case) |
|
|
1022
|
+
| \`as: "name"\` | Store at \`ctx.state[name]\` instead of \`ctx.state[id]\` |
|
|
1023
|
+
| \`spread: true\` | Shallow-merge \`result.data\`'s top-level keys into \`ctx.state\` (multi-output nodes). Mutually exclusive with \`as\` |
|
|
1024
|
+
| \`ephemeral: true\` | Skip storage — only \`$.prev\` carries it to the next step (logging, audit) |
|
|
1025
|
+
|
|
1026
|
+
**Every step's output auto-persists to \`ctx.state[id]\` — 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.
|
|
1027
|
+
|
|
1028
|
+
### Control-flow primitives
|
|
1029
|
+
|
|
1030
|
+
**\`branch({when, then, else})\`** — \`when\` is a JS-expression *string* (the \`$\` proxy can't intercept \`===\`):
|
|
1031
|
+
|
|
1032
|
+
\`\`\`ts
|
|
1033
|
+
branch({ id: "route",
|
|
1034
|
+
when: '$.req.method === "POST"',
|
|
1035
|
+
then: [{ id: "create", use: "...", inputs: {...} }],
|
|
1036
|
+
else: [{ id: "read", use: "...", inputs: {...} }] })
|
|
1037
|
+
\`\`\`
|
|
1038
|
+
|
|
1039
|
+
**\`switchOn({id, on, cases, default?})\`** — N-way branch, first match wins. \`when\` may be a scalar (\`on === when\`) or an array (\`array.includes(on)\`):
|
|
1040
|
+
|
|
1041
|
+
\`\`\`ts
|
|
1042
|
+
switchOn({ id: "route-by-event", on: $.req.headers["x-github-event"],
|
|
1043
|
+
cases: [
|
|
1044
|
+
{ when: "push", do: [{ id: "h1", subworkflow: "handle-push" }] },
|
|
1045
|
+
{ when: ["pull_request", "pr_review"], do: [{ id: "h2", subworkflow: "handle-pr" }] },
|
|
1046
|
+
],
|
|
1047
|
+
default: [{ id: "log", use: "@blokjs/log", inputs: { message: "unknown" } }] })
|
|
1048
|
+
\`\`\`
|
|
1049
|
+
|
|
1050
|
+
**\`forEach({id, in, as, do, mode?, concurrency?})\`** — 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):
|
|
1051
|
+
|
|
1052
|
+
\`\`\`ts
|
|
1053
|
+
forEach({ id: "process-items", in: $.req.body.items, as: "item",
|
|
1054
|
+
mode: "parallel", concurrency: 5,
|
|
1055
|
+
do: [{ id: "reserve", use: "inventory-reserve", inputs: { sku: $.state.item.sku } }] })
|
|
1056
|
+
\`\`\`
|
|
1057
|
+
|
|
1058
|
+
**\`loop({id, while, do, maxIterations?})\`** — while-loop, hard cap default 1000.
|
|
1059
|
+
**\`tryCatch({id, try, catch, finally?})\`** — \`catch\` sees \`$.error\`; errors in \`catch\` propagate (don't re-trigger \`catch\`); \`finally\` runs unconditionally.
|
|
1060
|
+
**\`{ id, wait: { for: "3d" } | { until: <date> } }\`** — durable pause; cannot combine with \`idempotencyKey\` or \`retry\`.
|
|
1061
|
+
|
|
1062
|
+
### Caching, retry, sub-workflows (per-step)
|
|
1063
|
+
|
|
1064
|
+
\`\`\`ts
|
|
1065
|
+
{ id: "fetch", use: "@blokjs/api-call", inputs: { url: "..." },
|
|
1066
|
+
idempotencyKey: $.req.body.requestId, // cache by (workflow, step.id, key); default TTL 24h
|
|
1067
|
+
retry: { maxAttempts: 3, minTimeoutInMs: 500, maxTimeoutInMs: 10000, factor: 2 },
|
|
1068
|
+
maxDuration: "30s" } // per-attempt timeout; final-attempt timeout → run "timedOut"
|
|
1069
|
+
\`\`\`
|
|
1070
|
+
|
|
1071
|
+
A 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.
|
|
1072
|
+
|
|
1073
|
+
**Sub-workflow as a step:**
|
|
1074
|
+
|
|
1075
|
+
\`\`\`ts
|
|
1076
|
+
{ id: "send-receipt", subworkflow: "send-receipt-email",
|
|
1077
|
+
inputs: { user: $.state.user }, // becomes child's ctx.request.body (read via $.req.body)
|
|
1078
|
+
wait: true } // default: parent blocks, child response lands at state[id]
|
|
1079
|
+
\`\`\`
|
|
1080
|
+
|
|
1081
|
+
\`wait: false\` = fire-and-forget, returns \`{runId, workflowName, scheduledAt}\`. \`subworkflow:\` also accepts a \`$.<path>\`/\`js/...\` expression for polymorphic dispatch — pair with \`allowList: [...]\` whenever it depends on caller data. Recursion capped at 10 (\`BLOK_MAX_SUBWORKFLOW_DEPTH\`).
|
|
1082
|
+
|
|
1083
|
+
### JSON workflows
|
|
1084
|
+
|
|
1085
|
+
JSON 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).
|
|
1086
|
+
|
|
1087
|
+
---
|
|
1088
|
+
|
|
1089
|
+
## 4. TRIGGER-LEVEL OPTIONS (across kinds)
|
|
1090
|
+
|
|
1091
|
+
These live on the **trigger config**, never on a step. They gate workflow entry.
|
|
1092
|
+
|
|
1093
|
+
**Per-key concurrency gating** — \`concurrencyKey\` (+ optional \`concurrencyLimit\` default 1, \`onLimit: "throw"|"queue"\`):
|
|
1094
|
+
|
|
1095
|
+
\`\`\`ts
|
|
1096
|
+
trigger: { http: { method: "POST", path: "/render",
|
|
1097
|
+
concurrencyKey: $.req.body.tenantId, concurrencyLimit: 5, onLimit: "queue" } }
|
|
1098
|
+
\`\`\`
|
|
1099
|
+
|
|
1100
|
+
\`concurrencyLimit\`/\`onLimit\`/\`concurrencyLeaseMs\` all require \`concurrencyKey\`. Denial → HTTP 429 + \`Retry-After\` (or 202 with \`onLimit: "queue"\`).
|
|
1101
|
+
|
|
1102
|
+
**Scheduling** — \`delay\`, \`ttl\`, \`debounce\`. Durations are a number (ms) or a unit string (\`"500ms"\`,\`"30s"\`,\`"5m"\`,\`"2h"\`,\`"1d"\`):
|
|
1103
|
+
|
|
1104
|
+
\`\`\`ts
|
|
1105
|
+
trigger: { http: { method: "POST", path: "/welcome", delay: "1h", ttl: "2h" } }
|
|
1106
|
+
trigger: { http: { method: "POST", path: "/save/:docId",
|
|
1107
|
+
debounce: { key: $.req.params.docId, mode: "trailing", delay: "500ms", maxDelay: "5s" } } }
|
|
1108
|
+
\`\`\`
|
|
1109
|
+
|
|
1110
|
+
For HTTP, \`ttl\` requires \`delay\`. Debounce modes: \`trailing\` (default — fire after silence) / \`leading\` (fire first, suppress follow-ups).
|
|
1111
|
+
|
|
1112
|
+
**Middleware** — two forms:
|
|
1113
|
+
|
|
1114
|
+
1. *Trigger-level chain* — ordered middleware-workflow names, run before the body on the same ctx:
|
|
1115
|
+
\`\`\`ts
|
|
1116
|
+
trigger: { http: { method: "GET", middleware: ["auth-check", "request-id"] } }
|
|
1117
|
+
\`\`\`
|
|
1118
|
+
2. *Defining a middleware workflow* — \`workflow({ middleware: true })\`. \`trigger\` becomes optional; it gets no public route and is referenced by \`name\`:
|
|
1119
|
+
\`\`\`ts
|
|
1120
|
+
export default workflow({ name: "auth-check", version: "1.0.0", middleware: true,
|
|
1121
|
+
steps: [ /* sets ctx.state.identity; may stop:true to short-circuit */ ] });
|
|
1122
|
+
\`\`\`
|
|
1123
|
+
|
|
1124
|
+
Process-global middleware: \`WorkflowRegistry.getInstance().setGlobalMiddleware([...])\` or \`BLOK_GLOBAL_MIDDLEWARE=a,b\`.
|
|
1125
|
+
|
|
1126
|
+
---
|
|
1127
|
+
|
|
1128
|
+
## 5. AUTHORING NODES
|
|
1129
|
+
|
|
1130
|
+
### 5.1 defineNode (TypeScript, in-process)
|
|
1131
|
+
|
|
1132
|
+
Always \`export default defineNode(...)\`. Never class-based \`BlokService\`. Zod input/output are mandatory.
|
|
1133
|
+
|
|
1134
|
+
\`\`\`ts
|
|
712
1135
|
import { defineNode } from "@blokjs/runner";
|
|
713
1136
|
import { z } from "zod";
|
|
714
1137
|
|
|
715
1138
|
export default defineNode({
|
|
716
1139
|
name: "fetch-user",
|
|
717
|
-
description: "Fetches user by ID",
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
userId: z.string().uuid(),
|
|
721
|
-
}),
|
|
722
|
-
|
|
723
|
-
output: z.object({
|
|
724
|
-
user: z.object({
|
|
725
|
-
id: z.string(),
|
|
726
|
-
name: z.string(),
|
|
727
|
-
email: z.string().email(),
|
|
728
|
-
}),
|
|
729
|
-
}),
|
|
730
|
-
|
|
1140
|
+
description: "Fetches a user by ID",
|
|
1141
|
+
input: z.object({ userId: z.string().uuid() }), // validated BEFORE execute
|
|
1142
|
+
output: z.object({ user: z.object({ id: z.string(), name: z.string() }) }), // validated AFTER
|
|
731
1143
|
async execute(ctx, input) {
|
|
732
|
-
const user = await fetchUser(input.userId);
|
|
733
|
-
return { user };
|
|
1144
|
+
const user = await fetchUser(input.userId); // input is type-safe
|
|
1145
|
+
return { user }; // MUST match the output schema
|
|
734
1146
|
},
|
|
735
1147
|
});
|
|
736
1148
|
\`\`\`
|
|
737
1149
|
|
|
738
|
-
|
|
1150
|
+
- **Errors:** input failure → \`GlobalError\` code **400**; a plain \`Error\` thrown in \`execute\` → code **500**; a \`GlobalError\` you throw is preserved verbatim (custom codes like 401 survive).
|
|
1151
|
+
- **Never write \`ctx.state\` from a node** — return your output and let the runner persist it. For a genuine side-channel value, use \`ctx.publish(name, value)\`.
|
|
1152
|
+
- No \`any\` types — use \`z.unknown()\` and narrow.
|
|
1153
|
+
- \`flow: true\` nodes return \`NodeBase[]\`; \`contentType: "text/html"\` sets the response Content-Type.
|
|
739
1154
|
|
|
740
|
-
|
|
741
|
-
- ZodError is mapped to GlobalError with HTTP 400
|
|
742
|
-
- \\\`flow: true\\\` nodes return NodeBase[] for conditional execution
|
|
743
|
-
- \\\`contentType\\\` sets response Content-Type (e.g., "text/html")
|
|
744
|
-
- Always \\\`export default defineNode(...)\\\`
|
|
1155
|
+
TypeScript nodes live in \`src/nodes/\` and are referenced by \`use: "<name>"\` (no \`type\` needed — \`module\` is the default).
|
|
745
1156
|
|
|
746
|
-
|
|
1157
|
+
### 5.2 Nodes in other runtimes (gRPC sidecars)
|
|
1158
|
+
|
|
1159
|
+
The 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\` — that holds the original trigger payload). The node's return value lands in \`ctx.state[<step-id>]\`.
|
|
1160
|
+
|
|
1161
|
+
**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:
|
|
1162
|
+
|
|
1163
|
+
- The runner speaks **gRPC only** (the legacy HTTP \`/execute\` path was removed in v0.5).
|
|
1164
|
+
- 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\`–\`9007\`; the CLI readiness check is a **TCP connect to the gRPC port**, not \`GET /health\`.)
|
|
1165
|
+
- \`blokctl dev\` sets \`BLOK_TRANSPORT=grpc\` + \`GRPC_PORT\` for each sidecar. Most SDKs default to HTTP transport if you launch them by hand — always let \`blokctl dev\` (or the env) set gRPC, or the runner can't reach the node.
|
|
1166
|
+
- Generated proto stubs ship with each SDK — you do **not** regenerate them to author a node.
|
|
1167
|
+
- Each SDK has a **typed** contract (the equivalent of \`defineNode\` — 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.
|
|
1168
|
+
- gRPC message cap defaults to 16 MiB (\`BLOK_GRPC_MAX_MESSAGE_BYTES\`).
|
|
1169
|
+
- Don't edit \`.blok/runtimes/\` — those are generated copies.
|
|
1170
|
+
|
|
1171
|
+
The workflow step is identical regardless of runtime — only \`type\` changes:
|
|
1172
|
+
|
|
1173
|
+
\`\`\`ts
|
|
1174
|
+
{ id: "sum", use: "add-numbers", type: "runtime.<lang>", inputs: { a: $.req.body.a, b: $.req.body.b } }
|
|
1175
|
+
\`\`\`
|
|
1176
|
+
|
|
1177
|
+
#### Authoring a node in go
|
|
1178
|
+
|
|
1179
|
+
\`runtimes/go/nodes/addnumbers.go\` — typed via \`blok.DefineNode\`:
|
|
1180
|
+
|
|
1181
|
+
\`\`\`go
|
|
1182
|
+
package nodes
|
|
1183
|
+
|
|
1184
|
+
import blok "github.com/nickincloud/blok-go"
|
|
1185
|
+
|
|
1186
|
+
type AddNumbersInput struct {
|
|
1187
|
+
A int \`json:"a"\`
|
|
1188
|
+
B int \`json:"b"\`
|
|
1189
|
+
}
|
|
1190
|
+
type AddNumbersOutput struct {
|
|
1191
|
+
Sum int \`json:"sum"\`
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
const AddNumbersNodeName = "add-numbers"
|
|
1195
|
+
|
|
1196
|
+
var AddNumbersNode = blok.DefineNode(AddNumbersNodeName, "Adds two integers",
|
|
1197
|
+
func(_ *blok.Context, in AddNumbersInput) (AddNumbersOutput, error) {
|
|
1198
|
+
return AddNumbersOutput{Sum: in.A + in.B}, nil
|
|
1199
|
+
})
|
|
1200
|
+
\`\`\`
|
|
1201
|
+
|
|
1202
|
+
Register in \`runtimes/go/cmd/server/main.go\`:
|
|
1203
|
+
|
|
1204
|
+
\`\`\`go
|
|
1205
|
+
func main() {
|
|
1206
|
+
registry := blok.NewNodeRegistry()
|
|
1207
|
+
registry.Register(nodes.AddNumbersNodeName, nodes.AddNumbersNode)
|
|
1208
|
+
registry.Use(blok.RecoveryMiddleware(), blok.LoggingMiddleware(blok.NewLogger(blok.LogLevelInfo)))
|
|
1209
|
+
if err := blok.ListenAndServe(registry); err != nil { log.Fatalf("Server error: %v", err) }
|
|
1210
|
+
}
|
|
1211
|
+
\`\`\`
|
|
1212
|
+
|
|
1213
|
+
Workflow 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\`.
|
|
1214
|
+
|
|
1215
|
+
#### Authoring a node in rust
|
|
1216
|
+
|
|
1217
|
+
\`runtimes/rust/nodes/add-numbers/src/main.rs\` — typed via the \`TypedNode\` trait:
|
|
1218
|
+
|
|
1219
|
+
\`\`\`rust
|
|
1220
|
+
use async_trait::async_trait;
|
|
1221
|
+
use blok::{BlokError, Context, NodeRegistry, TypedNode};
|
|
1222
|
+
use schemars::JsonSchema;
|
|
1223
|
+
use serde::{Deserialize, Serialize};
|
|
1224
|
+
|
|
1225
|
+
#[derive(Deserialize, JsonSchema)]
|
|
1226
|
+
struct AddInput { a: f64, b: f64 }
|
|
1227
|
+
|
|
1228
|
+
#[derive(Serialize, JsonSchema)]
|
|
1229
|
+
struct AddOutput { sum: f64 }
|
|
1230
|
+
|
|
1231
|
+
struct AddNumbers;
|
|
1232
|
+
|
|
1233
|
+
#[async_trait]
|
|
1234
|
+
impl TypedNode for AddNumbers {
|
|
1235
|
+
type Input = AddInput;
|
|
1236
|
+
type Output = AddOutput;
|
|
1237
|
+
fn name(&self) -> &str { "add-numbers" }
|
|
1238
|
+
fn description(&self) -> &str { "Adds two numbers" }
|
|
1239
|
+
async fn run(&self, _ctx: &mut Context, input: AddInput) -> Result<AddOutput, BlokError> {
|
|
1240
|
+
Ok(AddOutput { sum: input.a + input.b })
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
#[tokio::main]
|
|
1245
|
+
async fn main() {
|
|
1246
|
+
let mut registry = NodeRegistry::new("1.0.0");
|
|
1247
|
+
registry.register_typed(AddNumbers); // typed nodes register via register_typed
|
|
1248
|
+
blok::server::serve(registry, 9002).await.unwrap();
|
|
1249
|
+
}
|
|
1250
|
+
\`\`\`
|
|
1251
|
+
|
|
1252
|
+
Workflow 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 — build with the \`grpc\` feature (or \`--features full\`) so the runner can dispatch.
|
|
1253
|
+
|
|
1254
|
+
#### Authoring a node in java
|
|
1255
|
+
|
|
1256
|
+
\`runtimes/java/src/main/java/com/blok/blok/nodes/AddNumbersNode.java\` — typed via \`TypedNode<I, O>\`:
|
|
1257
|
+
|
|
1258
|
+
\`\`\`java
|
|
1259
|
+
package com.blok.blok.nodes;
|
|
1260
|
+
|
|
1261
|
+
import com.blok.blok.node.TypedNode;
|
|
1262
|
+
import com.blok.blok.types.Context;
|
|
1263
|
+
|
|
1264
|
+
public final class AddNumbersNode extends TypedNode<AddNumbersNode.Input, AddNumbersNode.Output> {
|
|
1265
|
+
public record Input(int a, int b) {}
|
|
1266
|
+
public record Output(int sum) {}
|
|
1267
|
+
|
|
1268
|
+
@Override public String name() { return "add-numbers"; }
|
|
1269
|
+
@Override public String description() { return "Adds two integers"; }
|
|
1270
|
+
@Override protected Class<Input> inputClass() { return Input.class; }
|
|
1271
|
+
@Override protected Class<?> outputClass() { return Output.class; }
|
|
1272
|
+
|
|
1273
|
+
@Override protected Output run(Context ctx, Input input) {
|
|
1274
|
+
return new Output(input.a() + input.b());
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
\`\`\`
|
|
1278
|
+
|
|
1279
|
+
Register in \`runtimes/java/src/main/java/com/blok/blok/Main.java\`:
|
|
1280
|
+
|
|
1281
|
+
\`\`\`java
|
|
1282
|
+
NodeRegistry registry = new NodeRegistry();
|
|
1283
|
+
registry.register("add-numbers", new com.blok.blok.nodes.AddNumbersNode());
|
|
1284
|
+
registry.use(new RecoveryMiddleware());
|
|
1285
|
+
registry.use(new LoggingMiddleware(logger));
|
|
1286
|
+
\`\`\`
|
|
1287
|
+
|
|
1288
|
+
Workflow 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\`.
|
|
1289
|
+
|
|
1290
|
+
#### Authoring a node in csharp
|
|
1291
|
+
|
|
1292
|
+
\`runtimes/csharp/Nodes/AddNumbersNode.cs\` — typed via \`TypedNode<TInput, TOutput>\`:
|
|
1293
|
+
|
|
1294
|
+
\`\`\`csharp
|
|
1295
|
+
using System.ComponentModel.DataAnnotations;
|
|
1296
|
+
using Blok.Core.Node;
|
|
1297
|
+
using Blok.Core.Types;
|
|
1298
|
+
|
|
1299
|
+
namespace Blok.Runtime.Nodes;
|
|
747
1300
|
|
|
748
|
-
|
|
1301
|
+
public sealed record AddNumbersInput([property: Required] double A, [property: Required] double B);
|
|
1302
|
+
public sealed record AddNumbersOutput(double Sum);
|
|
749
1303
|
|
|
750
|
-
|
|
1304
|
+
public sealed class AddNumbersNode : TypedNode<AddNumbersInput, AddNumbersOutput>
|
|
751
1305
|
{
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
},
|
|
757
|
-
"steps": [
|
|
758
|
-
{ "id": "fetch", "use": "@blokjs/api-call", "inputs": { "url": "https://api.example.com", "method": "GET" } },
|
|
759
|
-
{ "id": "process", "use": "my-node", "inputs": { "data": "$.state.fetch" } },
|
|
760
|
-
{ "id": "go-step", "use": "chain-test", "type": "runtime.go", "inputs": { "processed": "$.state.process" } }
|
|
761
|
-
]
|
|
1306
|
+
public override string Name => "add-numbers";
|
|
1307
|
+
public override string Description => "Adds two numbers";
|
|
1308
|
+
public override Task<AddNumbersOutput> RunAsync(Context ctx, AddNumbersInput input)
|
|
1309
|
+
=> Task.FromResult(new AddNumbersOutput(input.A + input.B));
|
|
762
1310
|
}
|
|
763
1311
|
\`\`\`
|
|
764
1312
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
| \\\`module\\\` | TypeScript node from registered modules |
|
|
790
|
-
| \\\`local\\\` | TypeScript node from filesystem (NODES_PATH) |
|
|
791
|
-
| \\\`runtime.python3\\\` | Python3 SDK container (port 9007) |
|
|
792
|
-
| \\\`runtime.go\\\` | Go SDK container (port 9001) |
|
|
793
|
-
| \\\`runtime.rust\\\` | Rust SDK container (port 9002) |
|
|
794
|
-
| \\\`runtime.java\\\` | Java SDK container (port 9003) |
|
|
795
|
-
| \\\`runtime.csharp\\\` | C# SDK container (port 9004) |
|
|
796
|
-
| \\\`runtime.php\\\` | PHP SDK container (port 9005) |
|
|
797
|
-
| \\\`runtime.ruby\\\` | Ruby SDK container (port 9006) |
|
|
798
|
-
|
|
799
|
-
### Conditional Workflow (if-else)
|
|
800
|
-
|
|
801
|
-
\`\`\`json
|
|
1313
|
+
Register in \`runtimes/csharp/Program.cs\`:
|
|
1314
|
+
|
|
1315
|
+
\`\`\`csharp
|
|
1316
|
+
var config = ServerConfig.FromEnv();
|
|
1317
|
+
var registry = new NodeRegistry(config.Version);
|
|
1318
|
+
registry.Register("add-numbers", new AddNumbersNode());
|
|
1319
|
+
await RuntimeServer.Run(registry, config);
|
|
1320
|
+
\`\`\`
|
|
1321
|
+
|
|
1322
|
+
Workflow 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\`.
|
|
1323
|
+
|
|
1324
|
+
#### Authoring a node in php
|
|
1325
|
+
|
|
1326
|
+
\`runtimes/php/nodes/add-numbers/src/Nodes/AddNumbersNode.php\` — typed via \`TypedNode\` (or plain \`NodeHandler\`):
|
|
1327
|
+
|
|
1328
|
+
\`\`\`php
|
|
1329
|
+
<?php
|
|
1330
|
+
declare(strict_types=1);
|
|
1331
|
+
namespace Blok\\Nodes;
|
|
1332
|
+
|
|
1333
|
+
use Blok\\Blok\\Node\\TypedNode;
|
|
1334
|
+
use Blok\\Blok\\Types\\Context;
|
|
1335
|
+
|
|
1336
|
+
final class AddNumbersInput
|
|
802
1337
|
{
|
|
803
|
-
|
|
1338
|
+
public function __construct(public int $a, public int $b) {}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
final class AddNumbersNode extends TypedNode
|
|
1342
|
+
{
|
|
1343
|
+
public function name(): string { return 'add-numbers'; }
|
|
1344
|
+
public function description(): string { return 'Adds two integers'; }
|
|
1345
|
+
protected function inputClass(): string { return AddNumbersInput::class; }
|
|
1346
|
+
|
|
1347
|
+
protected function run(Context $ctx, object $input): mixed
|
|
804
1348
|
{
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
"when": "ctx.request.query.active === \\\\"true\\\\"",
|
|
808
|
-
"then": [{ "id": "active-path", "use": "handle-active", "type": "module" }],
|
|
809
|
-
"else": [{ "id": "default-path", "use": "handle-default", "type": "module" }]
|
|
810
|
-
}
|
|
1349
|
+
/** @var AddNumbersInput $input */
|
|
1350
|
+
return ['sum' => $input->a + $input->b];
|
|
811
1351
|
}
|
|
812
|
-
]
|
|
813
1352
|
}
|
|
814
1353
|
\`\`\`
|
|
815
1354
|
|
|
816
|
-
|
|
1355
|
+
Register in \`runtimes/php/bin/serve.php\`:
|
|
817
1356
|
|
|
818
|
-
|
|
1357
|
+
\`\`\`php
|
|
1358
|
+
$config = ServerConfig::fromEnv();
|
|
1359
|
+
$registry = new NodeRegistry($config->version);
|
|
1360
|
+
$registry->register('add-numbers', new AddNumbersNode());
|
|
1361
|
+
// ... wire $registry into BlokNodeRuntimeService + RoadRunner GrpcServer and $server->serve();
|
|
1362
|
+
\`\`\`
|
|
819
1363
|
|
|
820
|
-
|
|
821
|
-
|---------|---------------|
|
|
822
|
-
| \\\`http\\\` | \\\`{ "method": "GET", "path": "/", "accept": "application/json" }\\\` |
|
|
823
|
-
| \\\`grpc\\\` | \\\`{ "service": "UserService", "method": "GetUser" }\\\` |
|
|
824
|
-
| \\\`cron\\\` | \\\`{ "schedule": "0 * * * *", "timezone": "UTC" }\\\` |
|
|
825
|
-
| \\\`pubsub\\\` | \\\`{ "provider": "gcp", "topic": "updates" }\\\` |
|
|
826
|
-
| \\\`webhook\\\` | \\\`{ "source": "github", "events": ["push"] }\\\` |
|
|
827
|
-
| \\\`websocket\\\` | \\\`{ "events": ["message"], "path": "/ws" }\\\` |
|
|
828
|
-
| \\\`sse\\\` | \\\`{ "events": ["update"], "path": "/stream" }\\\` |
|
|
829
|
-
| \\\`worker\\\` | \\\`{ "queue": "jobs", "concurrency": 5, "retries": 3 }\\\` |
|
|
1364
|
+
Workflow 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\`.
|
|
830
1365
|
|
|
831
|
-
|
|
1366
|
+
#### Authoring a node in ruby
|
|
832
1367
|
|
|
833
|
-
|
|
1368
|
+
\`runtimes/ruby/nodes/add_numbers_node.rb\` — typed via \`Blok::Node::TypedNode\`:
|
|
834
1369
|
|
|
835
|
-
|
|
836
|
-
|
|
1370
|
+
\`\`\`ruby
|
|
1371
|
+
# frozen_string_literal: true
|
|
1372
|
+
require "blok"
|
|
1373
|
+
|
|
1374
|
+
class AddNumbersNode < Blok::Node::TypedNode
|
|
1375
|
+
node_name "add-numbers"
|
|
1376
|
+
description "Adds two numbers and returns their sum"
|
|
1377
|
+
|
|
1378
|
+
input do
|
|
1379
|
+
field :a, :number, required: true
|
|
1380
|
+
field :b, :number, required: true
|
|
1381
|
+
end
|
|
1382
|
+
output { field :sum, :number }
|
|
1383
|
+
|
|
1384
|
+
def run(_ctx, input)
|
|
1385
|
+
{ "sum" => input[:a] + input[:b] } # string-keyed Hash is idiomatic
|
|
1386
|
+
end
|
|
1387
|
+
end
|
|
1388
|
+
\`\`\`
|
|
1389
|
+
|
|
1390
|
+
Register in \`runtimes/ruby/bin/serve.rb\`:
|
|
1391
|
+
|
|
1392
|
+
\`\`\`ruby
|
|
1393
|
+
require_relative "../nodes/add_numbers_node"
|
|
1394
|
+
|
|
1395
|
+
config = Blok::Config::ServerConfig.from_env
|
|
1396
|
+
registry = Blok::Node::NodeRegistry.new(config.version)
|
|
1397
|
+
registry.register("add-numbers", AddNumbersNode.new) # name MUST equal node_name
|
|
1398
|
+
registry.use(Blok::Middleware::RecoveryMiddleware.new)
|
|
1399
|
+
# serve.rb routes to start_grpc under BLOK_TRANSPORT=grpc
|
|
1400
|
+
\`\`\`
|
|
1401
|
+
|
|
1402
|
+
Workflow 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\`.
|
|
1403
|
+
|
|
1404
|
+
#### Authoring a node in python3
|
|
1405
|
+
|
|
1406
|
+
\`runtimes/python3/nodes/add_numbers/node.py\` — typed via the \`@node\` decorator (Pydantic):
|
|
1407
|
+
|
|
1408
|
+
\`\`\`python
|
|
1409
|
+
from __future__ import annotations
|
|
1410
|
+
from pydantic import BaseModel, Field
|
|
1411
|
+
from blok import node, Context
|
|
1412
|
+
|
|
1413
|
+
|
|
1414
|
+
class AddNumbersInput(BaseModel):
|
|
1415
|
+
a: float
|
|
1416
|
+
b: float = Field(0)
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
class AddNumbersOutput(BaseModel):
|
|
1420
|
+
sum: float
|
|
837
1421
|
|
|
838
|
-
export default workflow({
|
|
839
|
-
name: "Process Job",
|
|
840
|
-
version: "1.0.0",
|
|
841
|
-
trigger: { worker: { queue: "background-jobs", concurrency: 5, retries: 3 } },
|
|
842
|
-
steps: [
|
|
843
|
-
{
|
|
844
|
-
id: "process",
|
|
845
|
-
use: "my-processor",
|
|
846
|
-
inputs: { payload: $.req.body, jobId: $.req.params.jobId },
|
|
847
|
-
},
|
|
848
|
-
],
|
|
849
|
-
});
|
|
850
|
-
\\\`\\\`\\\`
|
|
851
1422
|
|
|
852
|
-
|
|
1423
|
+
@node("add-numbers", "Adds two numbers and returns their sum")
|
|
1424
|
+
def add_numbers(ctx: Context, input: AddNumbersInput) -> AddNumbersOutput:
|
|
1425
|
+
return AddNumbersOutput(sum=input.a + input.b)
|
|
1426
|
+
\`\`\`
|
|
1427
|
+
|
|
1428
|
+
Registration is **manual** — importing the module runs the \`@node\` decorator; then flush with \`register_decorated\`. In \`runtimes/python3/nodes/__init__.py\`:
|
|
1429
|
+
|
|
1430
|
+
\`\`\`python
|
|
1431
|
+
from blok import register_decorated
|
|
1432
|
+
from . import add_numbers # noqa: F401 (runs the @node decorator)
|
|
1433
|
+
|
|
1434
|
+
def register_project_nodes(registry):
|
|
1435
|
+
return register_decorated(registry)
|
|
1436
|
+
\`\`\`
|
|
1437
|
+
|
|
1438
|
+
…and 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\`, …). Toolchain: Python 3, \`pip3 install -r requirements.txt\`; \`@node\` requires \`pydantic\`.
|
|
1439
|
+
|
|
1440
|
+
> The legacy \`BlokService\` / \`async def handle()\` / \`from core.blok import BlokService\` Python shape **does not exist** in this SDK — ignore any example that uses it. Use \`@node\` (or the \`NodeHandler\` ABC).
|
|
1441
|
+
|
|
1442
|
+
---
|
|
1443
|
+
|
|
1444
|
+
## 6. RUNNING LOCALLY / INFRA
|
|
1445
|
+
|
|
1446
|
+
\`\`\`bash
|
|
1447
|
+
blokctl dev # full dev server: spawns selected runtimes + the runner
|
|
1448
|
+
blokctl create node <name> --runtime <lang> # scaffold a node (ts default; pass --runtime for sidecars)
|
|
1449
|
+
blokctl runtime add <lang> # add a non-TS runtime to an existing project
|
|
1450
|
+
blokctl trace # open Blok Studio (run traces at /__blok)
|
|
1451
|
+
\`\`\`
|
|
1452
|
+
|
|
1453
|
+
The \`http\`/\`sse\`/\`websocket\`/\`webhook\`/\`mcp\` triggers need no external infra — they share the HTTP server. The cross-process triggers (\`worker\`, \`pubsub\`) need a broker.
|
|
853
1454
|
|
|
854
|
-
|
|
1455
|
+
**For worker/pubsub, start the broker stack** with the dev compose (Redis + NATS + Postgres/Adminer):
|
|
855
1456
|
|
|
856
|
-
|
|
1457
|
+
\`\`\`bash
|
|
1458
|
+
cd infra/development && docker compose up -d nats # or: redis redis-commander
|
|
1459
|
+
\`\`\`
|
|
857
1460
|
|
|
858
|
-
|
|
859
|
-
\\\`\\\`\\\`
|
|
860
|
-
NATS_SERVERS=localhost:4222
|
|
861
|
-
NATS_STREAM_NAME=blok-queue # or blok-worker for worker trigger
|
|
862
|
-
NATS_TOKEN= # optional auth
|
|
863
|
-
\\\`\\\`\\\`
|
|
1461
|
+
The default worker adapter is \`in-memory\` (zero infra) — only start a broker when you set a real provider (\`nats\`, \`redis\`, \`bullmq\`, …). Monitoring UIs from the dev compose: Adminer \`:8080\`, Redis Commander \`:8081\`, NATS monitor \`:8222\`. The compose declares an external \`shared-network\` — if the first run fails, run \`docker network create shared-network\`.
|
|
864
1462
|
|
|
865
|
-
|
|
1463
|
+
**For Kafka / RabbitMQ / SQS / GCP-Pub/Sub emulators**, use \`infra/testing/docker-compose.yml\` instead — those brokers are wired there on non-standard ports with emulators, matching the provider env blocks the scaffold writes.
|
|
866
1464
|
|
|
867
|
-
|
|
1465
|
+
---
|
|
868
1466
|
|
|
869
|
-
|
|
1467
|
+
## 7. FOOTGUN LIST (read before authoring)
|
|
870
1468
|
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
1469
|
+
1. **Never reuse a step \`id\`** — 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"\`.
|
|
1470
|
+
2. **Don't prefix \`@blokjs/expr\`'s \`expression\` input with \`js/\`** — that input is itself mapper-resolved, so \`js/...\` double-evaluates. Write plain JS: \`expression: "ctx.state.x.y"\`.
|
|
1471
|
+
3. **\`set_var\` was removed in v0.5** — the runner throws at load time if present. Drop \`set_var: true\` (default-store handles it); replace \`set_var: false\` with \`ephemeral: true\`.
|
|
1472
|
+
4. **Use \`"ANY"\`, not \`"*"\`, for the wildcard HTTP method** — \`"*"\` is accepted but warns and is auto-normalized.
|
|
1473
|
+
5. **\`trigger.queue\` is rejected at construction** — it has no runtime and would silently never run. Use \`trigger.worker\` (\`{ worker: { queue: "<name>" } }\`).
|
|
1474
|
+
6. **Workflow envelope minimums:** \`name\` >= 3 chars, \`version\` >= 5 chars (semver), \`steps\` must be non-empty, and a \`trigger\` is required unless \`middleware: true\`.
|
|
1475
|
+
7. **Every v2 step schema is \`.strict()\`** — 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.
|
|
1476
|
+
8. **\`as\` and \`spread\` are mutually exclusive** — pick one.
|
|
1477
|
+
9. **\`$.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\`.
|
|
1478
|
+
10. **Sub-workflow \`idempotencyKey\` with \`wait: true\` caches the WHOLE child result** — a cache hit means the child (and its side effects: emails, charges) never runs. Headline pattern AND primary footgun.
|
|
1479
|
+
|
|
1480
|
+
Plus 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\`.
|
|
1481
|
+
|
|
1482
|
+
**Production env knob worth naming:** \`BLOK_MAPPER_MODE=strict\` — fail-fast on \`js/...\` input resolution errors instead of silently passing the literal string through. Strongly recommended for production.
|
|
876
1483
|
|
|
877
1484
|
---
|
|
878
1485
|
|
|
879
|
-
##
|
|
1486
|
+
## 8. TESTING
|
|
1487
|
+
|
|
1488
|
+
Use the \`@blokjs/runner\` testing utilities with Vitest.
|
|
880
1489
|
|
|
881
|
-
|
|
1490
|
+
**Unit-test a node** with \`NodeTestHarness\`:
|
|
882
1491
|
|
|
883
|
-
|
|
884
|
-
\\\`\\\`\\\`typescript
|
|
1492
|
+
\`\`\`ts
|
|
885
1493
|
import { NodeTestHarness } from "@blokjs/runner";
|
|
1494
|
+
import myNode from "../src/nodes/my-node";
|
|
1495
|
+
|
|
886
1496
|
const harness = new NodeTestHarness(myNode);
|
|
887
|
-
const result = await harness.execute({
|
|
1497
|
+
const result = await harness.execute({ userId: "abc-123" });
|
|
888
1498
|
harness.assertSuccess(result);
|
|
889
|
-
harness.assertOutput(result, {
|
|
890
|
-
|
|
1499
|
+
harness.assertOutput(result, { user: { id: "abc-123" } });
|
|
1500
|
+
\`\`\`
|
|
891
1501
|
|
|
892
|
-
|
|
893
|
-
|
|
1502
|
+
**Integration-test a workflow** with \`WorkflowTestRunner\`:
|
|
1503
|
+
|
|
1504
|
+
\`\`\`ts
|
|
894
1505
|
import { WorkflowTestRunner } from "@blokjs/runner";
|
|
895
|
-
|
|
1506
|
+
|
|
1507
|
+
const runner = new WorkflowTestRunner({ verbose: true, mockAllNodes: true });
|
|
896
1508
|
runner.registerNode("validate", ValidateNode);
|
|
897
1509
|
runner.mockNode("external-api", async (input) => ({ result: "mocked" }));
|
|
898
|
-
runner.loadWorkflow(
|
|
1510
|
+
runner.loadWorkflow(myWorkflowDefinition);
|
|
899
1511
|
const result = await runner.execute({ input: "data" });
|
|
900
|
-
|
|
901
|
-
|
|
1512
|
+
expect(result.success).toBe(true);
|
|
1513
|
+
\`\`\`
|
|
902
1514
|
|
|
903
1515
|
---
|
|
904
1516
|
|
|
905
|
-
##
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
-
|
|
909
|
-
- **
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
1517
|
+
## Do / Do NOT
|
|
1518
|
+
|
|
1519
|
+
**Do:**
|
|
1520
|
+
- Read \`.blok/config.json\` and existing \`src/workflows/\` to learn which triggers + runtimes this project uses; author for those.
|
|
1521
|
+
- Start every workflow from the **trigger decision table** in §1, not from HTTP.
|
|
1522
|
+
- Use \`workflow({ name, version, trigger, steps })\` from \`@blokjs/helper\`.
|
|
1523
|
+
- Use the typed node contract in every runtime (\`defineNode\` / \`DefineNode\` / \`TypedNode\` / \`@node\`).
|
|
1524
|
+
- Reference cross-step outputs with \`$.state.<id>\`; use \`as:\`/\`spread:\`/\`ephemeral:\` to shape persistence.
|
|
1525
|
+
- Set \`type: "runtime.<lang>"\` on every sidecar step and register the node by name.
|
|
1526
|
+
|
|
1527
|
+
**Do NOT:**
|
|
1528
|
+
- Default to the HTTP trigger because it's familiar — pick by intent.
|
|
1529
|
+
- Emit \`trigger.queue\` — it throws; use \`worker\`.
|
|
1530
|
+
- Use \`"*"\` for the wildcard method (use \`"ANY"\`), or \`set_var\` (removed in v0.5).
|
|
1531
|
+
- Write class-based \`BlokService\` nodes, or the stale Python \`BlokService\`/\`async def handle()\` shape.
|
|
1532
|
+
- Write to \`ctx.state\` inside a node — return your output (or \`ctx.publish(...)\` for a side-channel value).
|
|
1533
|
+
- Reuse a step \`id\`, combine \`as\` + \`spread\`, or use \`any\` types.
|
|
1534
|
+
- Read a typed sidecar node's data from \`ctx.request.body\` — read the step \`inputs\` / typed input.
|
|
1535
|
+
- Edit files under \`.blok/runtimes/\` — they are generated.
|
|
1536
|
+
`;
|
|
1537
|
+
const claude_md = `
|
|
1538
|
+
# Blok — Claude Code Quick Reference
|
|
916
1539
|
|
|
917
|
-
|
|
1540
|
+
This 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.
|
|
918
1541
|
|
|
919
|
-
|
|
1542
|
+
## Quick Commands
|
|
920
1543
|
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
1544
|
+
\`\`\`bash
|
|
1545
|
+
blokctl dev # Full dev server (spawns trigger runtimes + runner)
|
|
1546
|
+
blokctl create workflow <name> # Scaffold a workflow
|
|
1547
|
+
blokctl create node <name> # Scaffold a TS node
|
|
1548
|
+
blokctl create node <name> --runtime go # Scaffold a node in another runtime (go|rust|java|csharp|php|ruby|python3)
|
|
1549
|
+
blokctl trace # Open Blok Studio (or visit /__blok on the running trigger)
|
|
1550
|
+
\`\`\`
|
|
924
1551
|
|
|
925
1552
|
---
|
|
926
1553
|
|
|
927
|
-
##
|
|
1554
|
+
## 1. Pick the right trigger FIRST (do NOT default to HTTP)
|
|
928
1555
|
|
|
929
|
-
|
|
930
|
-
- Do NOT create class-based nodes — use \\\`defineNode()\\\` instead
|
|
931
|
-
- Do NOT use \\\`any\\\` type — use \\\`unknown\\\` and narrow with Zod
|
|
932
|
-
- Do NOT hardcode runtime ports — use environment variables
|
|
933
|
-
- Do NOT skip Zod input/output schemas
|
|
934
|
-
- Do NOT edit files in \\\`.blok/runtimes/\\\` — they are auto-generated
|
|
1556
|
+
Blok has **9 trigger kinds**. HTTP is **one of nine**, not the default — it is correct only for synchronous request/response. Every workflow declares exactly **one** trigger.
|
|
935
1557
|
|
|
936
|
-
|
|
1558
|
+
**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.
|
|
937
1559
|
|
|
938
|
-
|
|
939
|
-
- Opt out per step with \\\`ephemeral: true\\\` when the step is a side effect only
|
|
940
|
-
- Use Zod schemas for all input/output validation
|
|
941
|
-
- Use \\\`defineNode()\\\` for all new nodes
|
|
942
|
-
- Handle errors via GlobalError with appropriate HTTP status codes
|
|
943
|
-
- Keep nodes focused — one responsibility per node
|
|
944
|
-
`;
|
|
945
|
-
const claude_md = `# Blok Project — Claude Code Guide
|
|
1560
|
+
### Trigger Decision Table — choose by intent
|
|
946
1561
|
|
|
947
|
-
|
|
1562
|
+
| What you're building | Trigger |
|
|
1563
|
+
|---|---|
|
|
1564
|
+
| Respond to an HTTP/REST request; JSON API; HTML page; file download | **\`http\`** |
|
|
1565
|
+
| Process a background / queued / async job; offload slow work | **\`worker\`** |
|
|
1566
|
+
| Run on a schedule / recurring time-based job (nightly, hourly) | **\`cron\`** |
|
|
1567
|
+
| React to messages on a cloud topic/subscription (cross-service events) | **\`pubsub\`** |
|
|
1568
|
+
| Stream live updates one-way to a browser (tokens, progress, feed) | **\`sse\`** |
|
|
1569
|
+
| Bidirectional realtime (chat, live cursors, client↔server messages) | **\`websocket\`** |
|
|
1570
|
+
| Receive a signed provider webhook (Stripe / GitHub / Slack / Shopify / Svix) | **\`webhook\`** |
|
|
1571
|
+
| Expose a workflow as a tool/resource to an AI/LLM client (Cursor, Claude) | **\`mcp\`** |
|
|
1572
|
+
| High-throughput typed RPC between services with a \`.proto\` contract | **\`grpc\`** |
|
|
948
1573
|
|
|
949
|
-
|
|
1574
|
+
Tie-breakers: one-way stream → \`sse\`; two-way → \`websocket\`. In-process pub/sub (single Node process, HTTP+SSE) → \`sse\` bus, not \`pubsub\`. Queue consumer → **\`worker\`** (the \`queue\` kind is dead — it throws at construction; never emit \`trigger: { queue: ... }\`).
|
|
950
1575
|
|
|
951
|
-
|
|
952
|
-
npm run dev # Start dev server
|
|
953
|
-
blokctl dev # Multi-runtime dev server
|
|
954
|
-
blokctl create node <name> # Scaffold new node
|
|
955
|
-
blokctl create workflow <name> # Scaffold new workflow
|
|
956
|
-
blokctl trace # Open Blok Studio
|
|
957
|
-
npm test # Run tests
|
|
958
|
-
\\\`\\\`\\\`
|
|
1576
|
+
\`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 — only the \`trigger:\` block changes. See \`AGENTS.md\` for each kind's full config + a runnable example.
|
|
959
1577
|
|
|
960
|
-
|
|
1578
|
+
---
|
|
961
1579
|
|
|
962
|
-
|
|
963
|
-
2. **\\\`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\\\`.
|
|
964
|
-
3. **Blueprint Mapper resolves \\\`$.<path>\\\` and \\\`js/\\\` expressions BEFORE node execution.**
|
|
1580
|
+
## 2. Context & State (v2)
|
|
965
1581
|
|
|
966
|
-
|
|
1582
|
+
**Every step's output auto-persists to \`ctx.state[id]\` — 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.
|
|
967
1583
|
|
|
968
|
-
|
|
1584
|
+
**The four reads** (the \`$\` proxy compiles to \`"js/ctx.<path>"\` strings; in JSON write those strings by hand):
|
|
969
1585
|
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
the generated \\\`BlokApp\\\` type.
|
|
1586
|
+
| Read | Resolves to | Scope |
|
|
1587
|
+
|---|---|---|
|
|
1588
|
+
| \`$.state.<id>\` | A prior step's stored output | Whole workflow (cross-step) |
|
|
1589
|
+
| \`$.prev\` | Immediately previous step's output | Adjacent only — overwritten every step |
|
|
1590
|
+
| \`$.req\` | Request envelope (body/headers/params/query/method) | Whole run |
|
|
1591
|
+
| \`$.error\` | Captured error (\`.message\`/\`.code\`/\`.stepId\`) | \`tryCatch.catch\` arm only |
|
|
977
1592
|
|
|
978
|
-
|
|
1593
|
+
**Persistence knobs (per-step):**
|
|
979
1594
|
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1595
|
+
| Knob | Effect |
|
|
1596
|
+
|---|---|
|
|
1597
|
+
| *(none)* | Store at \`ctx.state[id]\` (the 95% case) |
|
|
1598
|
+
| \`as: "name"\` | Store at \`ctx.state[name]\` instead. Mutually exclusive with \`spread\` |
|
|
1599
|
+
| \`spread: true\` | Shallow-merge \`result.data\`'s keys into \`ctx.state\` (multi-output nodes) |
|
|
1600
|
+
| \`ephemeral: true\` | Skip storage; only \`$.prev\` carries it to the next step (logging/audit) |
|
|
984
1601
|
|
|
985
|
-
|
|
1602
|
+
Per-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.
|
|
986
1603
|
|
|
987
|
-
|
|
988
|
-
|-------|-----|
|
|
989
|
-
| \\\`Node type X not found\\\` | Wrong \\\`type\\\` in step — use module, local, or runtime.* |
|
|
990
|
-
| \\\`Validation failed\\\` | Zod schema mismatch — check input schema vs actual data |
|
|
991
|
-
| \\\`Runtime execution error\\\` | SDK container not running — check health endpoint |
|
|
992
|
-
| \\\`ctx.state['X'] undefined\\\` | Source step has \\\`ephemeral: true\\\`, or the id doesn't match what's referenced in \\\`$.state.<id>\\\` |
|
|
993
|
-
| \\\`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\\\`. |
|
|
1604
|
+
---
|
|
994
1605
|
|
|
995
|
-
## Generating
|
|
1606
|
+
## 3. Generating Nodes
|
|
996
1607
|
|
|
997
|
-
Always
|
|
1608
|
+
Always \`export default defineNode(...)\` (TS) — never class-based \`BlokService\`. Zod input/output are mandatory. Never write \`ctx.state\` from a node — return your output and let the runner persist it (use \`ctx.publish(name, value)\` for a true side-channel). No \`any\` types — use \`z.unknown()\`.
|
|
998
1609
|
|
|
999
|
-
|
|
1610
|
+
\`\`\`typescript
|
|
1000
1611
|
import { defineNode } from "@blokjs/runner";
|
|
1001
1612
|
import { z } from "zod";
|
|
1002
1613
|
|
|
1003
1614
|
export default defineNode({
|
|
1004
|
-
name: "
|
|
1005
|
-
description: "
|
|
1006
|
-
input:
|
|
1007
|
-
output: z.object({
|
|
1615
|
+
name: "fetch-user",
|
|
1616
|
+
description: "Fetches a user by ID",
|
|
1617
|
+
input: z.object({ userId: z.string().uuid() }), // validated BEFORE execute → 400 on fail
|
|
1618
|
+
output: z.object({ user: z.object({ id: z.string(), name: z.string() }) }), // validated AFTER → 500 on fail
|
|
1008
1619
|
async execute(ctx, input) {
|
|
1009
|
-
|
|
1620
|
+
const user = await fetchUser(input.userId); // input is type-safe
|
|
1621
|
+
return { user }; // MUST match the output schema
|
|
1010
1622
|
},
|
|
1011
1623
|
});
|
|
1012
|
-
|
|
1624
|
+
\`\`\`
|
|
1625
|
+
|
|
1626
|
+
### Nodes in other runtimes
|
|
1627
|
+
|
|
1628
|
+
A 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/\`.
|
|
1629
|
+
|
|
1630
|
+
| Runtime | Step \`type\` | gRPC port |
|
|
1631
|
+
|---|---|---|
|
|
1632
|
+
| Go | \`runtime.go\` | 10001 |
|
|
1633
|
+
| Rust | \`runtime.rust\` | 10002 |
|
|
1634
|
+
| Java | \`runtime.java\` | 10003 |
|
|
1635
|
+
| C# | \`runtime.csharp\` | 10004 |
|
|
1636
|
+
| PHP | \`runtime.php\` | 10005 |
|
|
1637
|
+
| Ruby | \`runtime.ruby\` | 10006 |
|
|
1638
|
+
| Python3 | \`runtime.python3\` | 10007 |
|
|
1013
1639
|
|
|
1014
|
-
|
|
1015
|
-
- Zod input schema covers all inputs
|
|
1016
|
-
- Zod output schema matches execute() return
|
|
1017
|
-
- Node name matches workflow references
|
|
1018
|
-
- No \\\`any\\\` types — use \\\`z.unknown()\\\` if dynamic
|
|
1019
|
-
- \\\`export default defineNode(...)\\\`
|
|
1640
|
+
**Inline cross-runtime example (Python3 — \`@node\` is the Python \`defineNode\`):**
|
|
1020
1641
|
|
|
1021
|
-
|
|
1642
|
+
\`\`\`python
|
|
1643
|
+
# runtimes/python3/nodes/add_numbers/node.py
|
|
1644
|
+
from pydantic import BaseModel, Field
|
|
1645
|
+
from blok import node, Context
|
|
1022
1646
|
|
|
1023
|
-
|
|
1647
|
+
class AddNumbersInput(BaseModel):
|
|
1648
|
+
a: float
|
|
1649
|
+
b: float = Field(0)
|
|
1024
1650
|
|
|
1025
|
-
|
|
1651
|
+
class AddNumbersOutput(BaseModel):
|
|
1652
|
+
sum: float
|
|
1653
|
+
|
|
1654
|
+
@node("add-numbers", "Adds two numbers and returns their sum")
|
|
1655
|
+
def add_numbers(ctx: Context, input: AddNumbersInput) -> AddNumbersOutput:
|
|
1656
|
+
return AddNumbersOutput(sum=input.a + input.b)
|
|
1657
|
+
\`\`\`
|
|
1658
|
+
|
|
1659
|
+
Registration is **manual** in non-TS runtimes — 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.
|
|
1660
|
+
|
|
1661
|
+
---
|
|
1662
|
+
|
|
1663
|
+
## 4. Generating Workflows
|
|
1664
|
+
|
|
1665
|
+
Canonical form: \`workflow({ name, version, trigger, steps })\` from \`@blokjs/helper\` — one object literal, no chained builder, no separate \`nodes{}\` map. \`name\` ≥ 3 chars, \`version\` ≥ 5 chars (semver). Reference earlier outputs with \`$.state.<id>\` / \`$.req.body\`. Use \`branch\`, \`switchOn\`, \`forEach\`, \`loop\`, \`tryCatch\` (all from \`@blokjs/helper\`) for control flow.
|
|
1666
|
+
|
|
1667
|
+
\`\`\`typescript
|
|
1026
1668
|
import { workflow, $ } from "@blokjs/helper";
|
|
1027
1669
|
|
|
1028
1670
|
export default workflow({
|
|
1029
|
-
name: "Process
|
|
1671
|
+
name: "Process Order",
|
|
1030
1672
|
version: "1.0.0",
|
|
1031
|
-
trigger: {
|
|
1673
|
+
trigger: { http: { method: "POST", path: "/orders" } }, // path optional → derived from file path
|
|
1032
1674
|
steps: [
|
|
1033
|
-
{ id: "
|
|
1034
|
-
|
|
1675
|
+
{ id: "validate", use: "order-validator", inputs: { order: $.req.body } },
|
|
1676
|
+
{ id: "save", use: "order-store", inputs: { data: $.state.validate } },
|
|
1035
1677
|
],
|
|
1036
1678
|
});
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
Job data: \\\`ctx.request.body\\\` = payload, \\\`ctx.request.params.queue/jobId/attempt\\\` = metadata.
|
|
1040
|
-
Providers: \\\`in-memory\\\` (dev default), \\\`nats\\\`, \\\`bullmq\\\`, \\\`rabbitmq\\\`, \\\`sqs\\\`, \\\`kafka\\\`, \\\`redis\\\`, \\\`pg-boss\\\` — set via \\\`trigger.worker.provider\\\` or \\\`BLOK_WORKER_ADAPTER\\\`.
|
|
1041
|
-
|
|
1042
|
-
## Testing
|
|
1679
|
+
\`\`\`
|
|
1043
1680
|
|
|
1044
|
-
|
|
1045
|
-
import { NodeTestHarness, WorkflowTestRunner } from "@blokjs/runner";
|
|
1681
|
+
**Swap the \`trigger:\` block for any other kind** (body stays the same). Full configs + examples in \`AGENTS.md\`:
|
|
1046
1682
|
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1683
|
+
\`\`\`typescript
|
|
1684
|
+
trigger: { worker: { queue: "background-jobs" } } // background jobs
|
|
1685
|
+
trigger: { cron: { schedule: "0 2 * * *", timezone: "America/New_York" } } // recurring
|
|
1686
|
+
trigger: { pubsub: { provider: "gcp", topic: "orders.placed", subscription: "fulfillment-svc" } }
|
|
1687
|
+
trigger: { sse: { path: "/sse/clock", heartbeatInterval: 15000 } } // one-way stream
|
|
1688
|
+
trigger: { websocket: { path: "/ws/echo", events: ["message", "open", "close"] } }
|
|
1689
|
+
trigger: { webhook: { provider: "stripe", secretEnv: "STRIPE_WEBHOOK_SECRET", idempotencyKey: "js/ctx.request.body.id" } }
|
|
1690
|
+
trigger: { mcp: { path: "/mcp", tool: { description: "..." } } } // needs a workflow-level input: z.object({...})
|
|
1691
|
+
trigger: { grpc: { service: "UserService", method: "GetUser", proto: "users.proto" } }
|
|
1692
|
+
\`\`\`
|
|
1051
1693
|
|
|
1052
|
-
|
|
1053
|
-
const runner = new WorkflowTestRunner({ mockAllNodes: true });
|
|
1054
|
-
runner.loadWorkflow(definition);
|
|
1055
|
-
const wfResult = await runner.execute({ input: "data" });
|
|
1056
|
-
\\\`\\\`\\\`
|
|
1694
|
+
**Branch:**
|
|
1057
1695
|
|
|
1058
|
-
|
|
1696
|
+
\`\`\`typescript
|
|
1697
|
+
import { workflow, branch, $ } from "@blokjs/helper";
|
|
1698
|
+
branch({ id: "route",
|
|
1699
|
+
when: '$.req.method === "POST"', // when is a JS-expression STRING ($ can't intercept ===)
|
|
1700
|
+
then: [{ id: "create", use: "...", inputs: {...} }],
|
|
1701
|
+
else: [{ id: "read", use: "...", inputs: {...} }] })
|
|
1702
|
+
\`\`\`
|
|
1059
1703
|
|
|
1060
|
-
-
|
|
1061
|
-
- "No output" → Node not returning data or Zod output validation failed
|
|
1062
|
-
- "Step error" → Expand error — check if 400 (validation) or 500 (runtime)
|
|
1063
|
-
- "State not passing" → Source step has \\\`ephemeral: true\\\`, OR target's \\\`$.state.<id>\\\` references a non-existent step id
|
|
1704
|
+
**Worker/pubsub/broker projects** need local infra. The scaffold ships an \`infra/development\` docker-compose with the broker stack — \`cd infra/development && docker compose up -d\` to start NATS/Redis (run \`docker network create shared-network\` once if prompted).
|
|
1064
1705
|
|
|
1065
|
-
|
|
1706
|
+
---
|
|
1066
1707
|
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1708
|
+
## 5. Common Errors
|
|
1709
|
+
|
|
1710
|
+
| Error | Cause | Fix |
|
|
1711
|
+
|---|---|---|
|
|
1712
|
+
| \`Trigger kind 'queue' has no runtime\` | Used \`trigger: { queue: ... }\` | Use \`trigger: { worker: { queue: "<name>" } }\` |
|
|
1713
|
+
| \`Validation failed: name must be at least 3 characters\` | Workflow \`name\` < 3 chars / \`version\` < 5 chars | Lengthen name; use full semver \`x.x.x\` |
|
|
1714
|
+
| \`Unrecognized key(s) in object: "..."\` | Misspelled / unknown field — every v2 step schema is \`.strict()\` | Fix the spelling; trigger-only fields (\`concurrencyKey\`, \`delay\`, \`ttl\`, \`debounce\`) belong on the trigger, not a step |
|
|
1715
|
+
| \`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 |
|
|
1716
|
+
| \`as and spread are mutually exclusive\` | Step set both | Pick one |
|
|
1717
|
+
| \`branch step is missing 'when'\` | No condition string | Set \`when: "..."\` |
|
|
1718
|
+
| \`step "..." uses set_var\` | Legacy field (removed v0.5) | Drop \`set_var: true\`; replace \`set_var: false\` with \`ephemeral: true\` |
|
|
1719
|
+
| \`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 |
|
|
1720
|
+
| \`Node type X not found\` | Missing runtime resolver / wrong \`type\` | Check \`type: "runtime.<lang>"\` and that the runtime is scaffolded |
|
|
1721
|
+
| \`[blok][mapper] Failed to resolve ...\` | A \`js/...\` input expression threw | Fix the expression; set \`BLOK_MAPPER_MODE=strict\` to fail-fast in prod |
|
|
1070
1722
|
|
|
1071
|
-
|
|
1723
|
+
---
|
|
1072
1724
|
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
- Do NOT
|
|
1076
|
-
- Do NOT
|
|
1077
|
-
- Do NOT
|
|
1725
|
+
## 6. Do NOT
|
|
1726
|
+
|
|
1727
|
+
- Do NOT default to the HTTP trigger — read \`.blok/config.json\` and pick the trigger by intent (Section 1).
|
|
1728
|
+
- Do NOT use \`trigger: { queue: ... }\` — it has no runtime and throws. Use \`worker\`.
|
|
1729
|
+
- Do NOT reuse a step \`id\` anywhere — 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.
|
|
1730
|
+
- Do NOT write to \`ctx.state\` inside a node's \`execute()\` — return your output; use \`ctx.publish(name, value)\` for a side-channel.
|
|
1731
|
+
- Do NOT assume \`$.prev\` (or \`ctx.response.data\`) survives more than one step — use \`$.state.<id>\` for cross-step reads.
|
|
1732
|
+
- Do NOT prefix \`@blokjs/expr\`'s \`expression\` input with \`js/\` — it double-evaluates. Write plain JS: \`expression: "ctx.state.x.y"\`.
|
|
1733
|
+
- Do NOT use \`set_var\` — removed in v0.5, throws at load.
|
|
1734
|
+
- Do NOT use \`"*"\` for the wildcard HTTP method — use \`"ANY"\`.
|
|
1735
|
+
- Do NOT generate class-based \`BlokService\` nodes or use \`any\` types — always \`defineNode()\` (TS) / \`@node\` (Python) with Zod/Pydantic schemas.
|
|
1736
|
+
- Do NOT use ESLint/Prettier — this project uses Biome. Do NOT edit auto-generated files in \`.blok/runtimes/\`.
|
|
1078
1737
|
`;
|
|
1079
1738
|
const function_first_node_file = `import { defineNode } from "@blokjs/runner";
|
|
1080
1739
|
import { z } from "zod";
|