From 9d71773dc39f546caeefb983e4cc731c80024344 Mon Sep 17 00:00:00 2001 From: Aditya Date: Sat, 28 Feb 2026 18:02:52 +0530 Subject: [PATCH 1/6] fix: correct POSITIVE_CHECKS logic in seo-audit and 429 retry exhaustion in fetchWithRetry - seo-audit: both branches of the POSITIVE_CHECKS if/else were assigning `checkData.passed` identically, making the distinction a no-op. Normal checks now correctly invert with `!checkData.passed` so that a DataForSEO "passed=true" (problem absent) maps to isPassed=true. - fetchWithRetry: a 429 on the final retry attempt fell through the if-block and returned the rate-limit Response to the caller instead of throwing. Now throws FetchRetryError("Rate limit exceeded after N attempts") consistent with how 5xx exhaustion is handled. --- .gitignore | 3 +++ packages/nodes/src/integrations/dataforseo/seo-audit.ts | 5 ++++- packages/nodes/src/utils/http.ts | 8 ++++++++ packages/nodes/src/utils/index.ts | 5 +++++ 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 21fd20c..35d5ed2 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ coverage/ *.temp .direnv .env + +# Embedded repo (nested clone — not a submodule) +jam-nodes/ diff --git a/packages/nodes/src/integrations/dataforseo/seo-audit.ts b/packages/nodes/src/integrations/dataforseo/seo-audit.ts index d00a899..7611757 100644 --- a/packages/nodes/src/integrations/dataforseo/seo-audit.ts +++ b/packages/nodes/src/integrations/dataforseo/seo-audit.ts @@ -339,9 +339,12 @@ export const seoAuditNode = defineNode({ let isPassed: boolean; if (POSITIVE_CHECKS.has(checkId)) { + // Positive checks: passed=true means the good thing IS present (e.g. is_https) isPassed = checkData.passed; } else { - isPassed = checkData.passed; + // Normal checks: passed=true means the problem is ABSENT (i.e. the page is clean) + // DataForSEO sets passed=true to mean "no issue found", so invert for our tracking + isPassed = !checkData.passed; } if (isPassed) { diff --git a/packages/nodes/src/utils/http.ts b/packages/nodes/src/utils/http.ts index 702dcb6..e389c07 100644 --- a/packages/nodes/src/utils/http.ts +++ b/packages/nodes/src/utils/http.ts @@ -105,6 +105,14 @@ export async function fetchWithRetry( await sleep(delayMs); continue; } + + // All retries exhausted — throw instead of returning the 429 response + const errorText = await response.text(); + throw new FetchRetryError( + `Rate limit exceeded after ${maxRetries} attempts`, + 429, + errorText + ); } // Retry on server errors diff --git a/packages/nodes/src/utils/index.ts b/packages/nodes/src/utils/index.ts index 3064450..4115770 100644 --- a/packages/nodes/src/utils/index.ts +++ b/packages/nodes/src/utils/index.ts @@ -5,3 +5,8 @@ export { FetchRetryError, type FetchWithRetryOptions, } from './http.js'; + +export { + generateAnthropicText, + type AnthropicGenerateOptions, +} from './anthropic.js'; From db5cfc77eed6f11da54c7717a521544dc82bd425 Mon Sep 17 00:00:00 2001 From: Aditya Date: Sat, 28 Feb 2026 18:27:39 +0530 Subject: [PATCH 2/6] refactor(nodes): extract shared resolvePath utility, use in map - Add utils/resolve-path.ts as canonical path resolver - Supports dot notation, keyed array (contacts[0]), standalone index ([0].name), and empty paths - map.ts now imports from shared utility --- package-lock.json | 19 ++++++-- packages/nodes/src/transform/filter.ts | 23 +-------- packages/nodes/src/transform/map.ts | 29 +---------- packages/nodes/src/utils/index.ts | 5 +- packages/nodes/src/utils/resolve-path.ts | 61 ++++++++++++++++++++++++ 5 files changed, 80 insertions(+), 57 deletions(-) create mode 100644 packages/nodes/src/utils/resolve-path.ts diff --git a/package-lock.json b/package-lock.json index 36b2561..888c928 100644 --- a/package-lock.json +++ b/package-lock.json @@ -925,6 +925,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1233,6 +1234,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1908,6 +1910,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -2247,6 +2250,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -2589,6 +2593,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -2613,6 +2618,7 @@ "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 10.16.0" } @@ -3208,6 +3214,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3429,6 +3436,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -3441,6 +3449,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -3454,6 +3463,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -4015,6 +4025,7 @@ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -4121,6 +4132,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4364,6 +4376,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -4379,7 +4392,7 @@ }, "packages/core": { "name": "@jam-nodes/core", - "version": "0.2.4", + "version": "0.2.5", "license": "MIT", "dependencies": { "jsonpath-plus": "^10.0.0" @@ -4433,7 +4446,7 @@ }, "packages/nodes": { "name": "@jam-nodes/nodes", - "version": "0.2.4", + "version": "0.2.5", "license": "MIT", "dependencies": { "@jam-nodes/core": "^0.2.1" @@ -4448,7 +4461,7 @@ }, "packages/playground": { "name": "@jam-nodes/playground", - "version": "0.1.1", + "version": "0.1.2", "license": "MIT", "dependencies": { "@jam-nodes/core": "*", diff --git a/packages/nodes/src/transform/filter.ts b/packages/nodes/src/transform/filter.ts index cb3e7aa..24ae9a1 100644 --- a/packages/nodes/src/transform/filter.ts +++ b/packages/nodes/src/transform/filter.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { defineNode } from '@jam-nodes/core'; +import { resolvePath } from '../utils/resolve-path.js'; /** * Filter operator schema @@ -44,28 +45,6 @@ export const FilterOutputSchema = z.object({ export type FilterOutput = z.infer; -/** - * Resolve a nested path on an object - */ -function resolvePath(obj: unknown, path: string): unknown { - // Empty path means use the item itself - if (!path) { - return obj; - } - - const parts = path.split('.'); - let current: unknown = obj; - - for (const part of parts) { - if (current === null || current === undefined) { - return undefined; - } - current = (current as Record)[part]; - } - - return current; -} - /** * Evaluate filter condition */ diff --git a/packages/nodes/src/transform/map.ts b/packages/nodes/src/transform/map.ts index c169fad..37592ef 100644 --- a/packages/nodes/src/transform/map.ts +++ b/packages/nodes/src/transform/map.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { defineNode } from '@jam-nodes/core'; +import { resolvePath } from '../utils/resolve-path.js'; /** * Input schema for map node @@ -23,34 +24,6 @@ export const MapOutputSchema = z.object({ export type MapOutput = z.infer; -/** - * Resolve a nested path on an object - */ -function resolvePath(obj: unknown, path: string): unknown { - const parts = path.split('.'); - let current: unknown = obj; - - for (const part of parts) { - if (current === null || current === undefined) { - return undefined; - } - - // Handle array access like "[0]" - const arrayMatch = part.match(/^\[(\d+)\]$/); - if (arrayMatch) { - if (Array.isArray(current)) { - current = current[parseInt(arrayMatch[1]!, 10)]; - } else { - return undefined; - } - } else { - current = (current as Record)[part]; - } - } - - return current; -} - /** * Map node - extract a property from each item in an array. * diff --git a/packages/nodes/src/utils/index.ts b/packages/nodes/src/utils/index.ts index 4115770..9aa470e 100644 --- a/packages/nodes/src/utils/index.ts +++ b/packages/nodes/src/utils/index.ts @@ -6,7 +6,4 @@ export { type FetchWithRetryOptions, } from './http.js'; -export { - generateAnthropicText, - type AnthropicGenerateOptions, -} from './anthropic.js'; +export { resolvePath } from './resolve-path.js'; diff --git a/packages/nodes/src/utils/resolve-path.ts b/packages/nodes/src/utils/resolve-path.ts new file mode 100644 index 0000000..b4b4d7c --- /dev/null +++ b/packages/nodes/src/utils/resolve-path.ts @@ -0,0 +1,61 @@ +/** + * Shared path resolution utility for transform nodes. + * + * Resolves a dot-notation path string against an arbitrary object. + * This mirrors the same logic as `ExecutionContext.resolveNestedPath`, + * but operates on any object rather than the workflow variable store. + * + * Supported syntax: + * - Dot notation: "contact.email" + * - Keyed array access: "contacts[0].name" + * - Standalone array index: "[0].name" (when current value is an array) + * - Empty path returns the object itself + * + * @example + * resolvePath({ a: { b: [1, 2] } }, 'a.b[1]') // → 2 + * resolvePath([{ id: 1 }, { id: 2 }], '[0].id') // → 1 + * resolvePath({ score: 42 }, '') // → { score: 42 } + */ +export function resolvePath(obj: unknown, path: string): unknown { + // Empty path means use the value itself + if (!path) { + return obj; + } + + const parts = path.split('.'); + let current: unknown = obj; + + for (const part of parts) { + if (current === null || current === undefined) { + return undefined; + } + + // Handle keyed array access like "contacts[0]" + const keyedArrayMatch = part.match(/^(\w+)\[(\d+)\]$/); + if (keyedArrayMatch) { + const [, key, index] = keyedArrayMatch; + current = (current as Record)[key!]; + if (Array.isArray(current)) { + current = current[parseInt(index!, 10)]; + } else { + return undefined; + } + continue; + } + + // Handle standalone array index like "[0]" + const standaloneIndexMatch = part.match(/^\[(\d+)\]$/); + if (standaloneIndexMatch) { + if (Array.isArray(current)) { + current = current[parseInt(standaloneIndexMatch[1]!, 10)]; + } else { + return undefined; + } + continue; + } + + current = (current as Record)[part]; + } + + return current; +} From 777ae079e7f879e15366f2de9ee7c8f3e75fc2d9 Mon Sep 17 00:00:00 2001 From: Aditya Date: Sun, 1 Mar 2026 11:40:40 +0530 Subject: [PATCH 3/6] fix(nodes): ensure CPC is typed consistently as a number --- .../src/integrations/dataforseo/keyword-research.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/packages/nodes/src/integrations/dataforseo/keyword-research.ts b/packages/nodes/src/integrations/dataforseo/keyword-research.ts index 2bb2059..1707b73 100644 --- a/packages/nodes/src/integrations/dataforseo/keyword-research.ts +++ b/packages/nodes/src/integrations/dataforseo/keyword-research.ts @@ -2,15 +2,10 @@ import { z } from 'zod'; import { defineNode } from '@jam-nodes/core'; import { fetchWithRetry } from '../../utils/http.js'; -// ============================================================================= // Constants -// ============================================================================= - const DATAFORSEO_BASE_URL = 'https://api.dataforseo.com/v3'; -// ============================================================================= // Types -// ============================================================================= interface DataForSEOKeywordIdea { keyword: string; @@ -36,9 +31,7 @@ interface DataForSEOResponse { }>; } -// ============================================================================= // Schemas -// ============================================================================= export const SeoKeywordResearchInputSchema = z.object({ /** Seed keywords to research */ @@ -58,7 +51,7 @@ export const SeoKeywordResearchOutputSchema = z.object({ keyword: z.string(), searchVolume: z.number(), keywordDifficulty: z.number(), - cpc: z.string(), + cpc: z.number(), searchIntent: z.enum(['informational', 'commercial', 'navigational', 'transactional']), })), totalResearched: z.number(), @@ -130,9 +123,7 @@ function normalizeSearchIntent(intent: string | undefined): 'informational' | 'c return 'informational'; } -// ============================================================================= // Node Definition -// ============================================================================= /** * SEO Keyword Research Node @@ -197,7 +188,7 @@ export const seoKeywordResearchNode = defineNode({ keyword: kw.keyword, searchVolume: kw.keyword_info?.search_volume || 0, keywordDifficulty: kw.keyword_properties?.keyword_difficulty || 0, - cpc: (kw.keyword_info?.cpc || 0).toString(), + cpc: kw.keyword_info?.cpc || 0, searchIntent: normalizeSearchIntent(kw.search_intent_info?.main_intent), }); } From 2e654490516127c43d67be18add099c090f3cdd2 Mon Sep 17 00:00:00 2001 From: Aditya Date: Sun, 1 Mar 2026 11:44:24 +0530 Subject: [PATCH 4/6] fix(nodes): ensure CPC is a number securely and update npm configs - Align cpc in SeoKeywordResearchOutputSchema to match DataForSeoKeyword - Add `sideEffects: false` to both core and nodes packages for tree-shaking - Clean up root README and copy to packages for npm publish visibility --- README.md | 53 ++++++------- packages/core/README.md | 147 ++++++++++++++++++++++++++++++++++++ packages/core/package.json | 1 + packages/nodes/README.md | 147 ++++++++++++++++++++++++++++++++++++ packages/nodes/package.json | 1 + 5 files changed, 323 insertions(+), 26 deletions(-) create mode 100644 packages/core/README.md create mode 100644 packages/nodes/README.md diff --git a/README.md b/README.md index 7409186..4ccb0bf 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ npm install @jam-nodes/core @jam-nodes/nodes zod ## Quick Start ```typescript -import { NodeRegistry, defineNode, ExecutionContext } from '@jam-nodes/core'; -import { conditionalNode, endNode, builtInNodes } from '@jam-nodes/nodes'; -import { z } from 'zod'; +import { NodeRegistry, defineNode, ExecutionContext } from "@jam-nodes/core"; +import { conditionalNode, endNode, builtInNodes } from "@jam-nodes/nodes"; +import { z } from "zod"; // Create a registry and register built-in nodes const registry = new NodeRegistry(); @@ -29,10 +29,10 @@ registry.registerAll(builtInNodes); // Define a custom node const greetNode = defineNode({ - type: 'greet', - name: 'Greet', - description: 'Generate a greeting message', - category: 'action', + type: "greet", + name: "Greet", + description: "Generate a greeting message", + category: "action", inputSchema: z.object({ name: z.string(), }), @@ -49,11 +49,11 @@ const greetNode = defineNode({ registry.register(greetNode); // Execute a node -const context = new ExecutionContext({ userName: 'World' }); -const executor = registry.getExecutor('greet'); +const context = new ExecutionContext({ userName: "World" }); +const executor = registry.getExecutor("greet"); const result = await executor( - { name: context.interpolate('{{userName}}') }, - context.toNodeContext('user-123', 'workflow-456') + { name: context.interpolate("{{userName}}") }, + context.toNodeContext("user-123", "workflow-456"), ); console.log(result.output?.message); // "Hello, World!" @@ -62,14 +62,14 @@ console.log(result.output?.message); // "Hello, World!" ## Creating Custom Nodes ```typescript -import { defineNode } from '@jam-nodes/core'; -import { z } from 'zod'; +import { defineNode } from "@jam-nodes/core"; +import { z } from "zod"; export const myNode = defineNode({ - type: 'my_custom_node', - name: 'My Custom Node', - description: 'Does something awesome', - category: 'action', // 'action' | 'logic' | 'integration' | 'transform' + type: "my_custom_node", + name: "My Custom Node", + description: "Does something awesome", + category: "action", // 'action' | 'logic' | 'integration' | 'transform' inputSchema: z.object({ input1: z.string(), @@ -87,7 +87,7 @@ export const myNode = defineNode({ executor: async (input, context) => { // Access workflow variables - const previousData = context.resolveNestedPath('someNode.output'); + const previousData = context.resolveNestedPath("someNode.output"); // Your logic here const result = `Processed: ${input.input1}`; @@ -97,8 +97,8 @@ export const myNode = defineNode({ output: { result }, // Optional: send notification notification: { - title: 'Node Complete', - message: 'Processing finished', + title: "Node Complete", + message: "Processing finished", }, }; }, @@ -108,15 +108,18 @@ export const myNode = defineNode({ ## Built-in Nodes ### Logic + - **conditional** - Branch workflow based on conditions - **end** - Mark end of workflow branch - **delay** - Wait for specified duration ### Transform + - **map** - Extract property from array items - **filter** - Filter array based on conditions ### Examples + - **http_request** - Make HTTP requests to external APIs ## Variable Interpolation @@ -125,22 +128,20 @@ The `ExecutionContext` supports powerful variable interpolation: ```typescript const ctx = new ExecutionContext({ - user: { name: 'Alice', email: 'alice@example.com' }, + user: { name: "Alice", email: "alice@example.com" }, items: [1, 2, 3], }); // Simple interpolation -ctx.interpolate('Hello {{user.name}}'); // "Hello Alice" +ctx.interpolate("Hello {{user.name}}"); // "Hello Alice" // Direct value (returns actual type) -ctx.interpolate('{{items}}'); // [1, 2, 3] +ctx.interpolate("{{items}}"); // [1, 2, 3] // JSONPath -ctx.evaluateJsonPath('$.user.email'); // "alice@example.com" +ctx.evaluateJsonPath("$.user.email"); // "alice@example.com" ``` ## License -also we have a github with 668 contributions which we will open source more of in the coming days - jia and mal - MIT diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000..4ccb0bf --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,147 @@ +# jam-nodes + +Extensible workflow node framework for building automation pipelines. Define, register, and execute typed nodes with Zod validation. + +📚 **[Documentation](https://docs.spreadjam.com)** · 🎮 **[Playground](https://docs.spreadjam.com/playground/overview)** + +## Packages + +- **[@jam-nodes/core](./packages/core)** - Core framework with types, registry, and execution context +- **[@jam-nodes/nodes](./packages/nodes)** - Built-in nodes (conditional, delay, filter, map, http-request) +- **[@jam-nodes/playground](./packages/playground)** - CLI tool for testing nodes interactively + +## Installation + +```bash +npm install @jam-nodes/core @jam-nodes/nodes zod +``` + +## Quick Start + +```typescript +import { NodeRegistry, defineNode, ExecutionContext } from "@jam-nodes/core"; +import { conditionalNode, endNode, builtInNodes } from "@jam-nodes/nodes"; +import { z } from "zod"; + +// Create a registry and register built-in nodes +const registry = new NodeRegistry(); +registry.registerAll(builtInNodes); + +// Define a custom node +const greetNode = defineNode({ + type: "greet", + name: "Greet", + description: "Generate a greeting message", + category: "action", + inputSchema: z.object({ + name: z.string(), + }), + outputSchema: z.object({ + message: z.string(), + }), + executor: async (input) => ({ + success: true, + output: { message: `Hello, ${input.name}!` }, + }), +}); + +// Register custom node +registry.register(greetNode); + +// Execute a node +const context = new ExecutionContext({ userName: "World" }); +const executor = registry.getExecutor("greet"); +const result = await executor( + { name: context.interpolate("{{userName}}") }, + context.toNodeContext("user-123", "workflow-456"), +); + +console.log(result.output?.message); // "Hello, World!" +``` + +## Creating Custom Nodes + +```typescript +import { defineNode } from "@jam-nodes/core"; +import { z } from "zod"; + +export const myNode = defineNode({ + type: "my_custom_node", + name: "My Custom Node", + description: "Does something awesome", + category: "action", // 'action' | 'logic' | 'integration' | 'transform' + + inputSchema: z.object({ + input1: z.string(), + input2: z.number().optional(), + }), + + outputSchema: z.object({ + result: z.string(), + }), + + capabilities: { + supportsRerun: true, + supportsCancel: true, + }, + + executor: async (input, context) => { + // Access workflow variables + const previousData = context.resolveNestedPath("someNode.output"); + + // Your logic here + const result = `Processed: ${input.input1}`; + + return { + success: true, + output: { result }, + // Optional: send notification + notification: { + title: "Node Complete", + message: "Processing finished", + }, + }; + }, +}); +``` + +## Built-in Nodes + +### Logic + +- **conditional** - Branch workflow based on conditions +- **end** - Mark end of workflow branch +- **delay** - Wait for specified duration + +### Transform + +- **map** - Extract property from array items +- **filter** - Filter array based on conditions + +### Examples + +- **http_request** - Make HTTP requests to external APIs + +## Variable Interpolation + +The `ExecutionContext` supports powerful variable interpolation: + +```typescript +const ctx = new ExecutionContext({ + user: { name: "Alice", email: "alice@example.com" }, + items: [1, 2, 3], +}); + +// Simple interpolation +ctx.interpolate("Hello {{user.name}}"); // "Hello Alice" + +// Direct value (returns actual type) +ctx.interpolate("{{items}}"); // [1, 2, 3] + +// JSONPath +ctx.evaluateJsonPath("$.user.email"); // "alice@example.com" +``` + +## License + +MIT diff --git a/packages/core/package.json b/packages/core/package.json index f9cf384..f0ae542 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -3,6 +3,7 @@ "version": "0.2.5", "description": "Core framework for building workflow nodes with type-safe executors and Zod validation", "type": "module", + "sideEffects": false, "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { diff --git a/packages/nodes/README.md b/packages/nodes/README.md new file mode 100644 index 0000000..4ccb0bf --- /dev/null +++ b/packages/nodes/README.md @@ -0,0 +1,147 @@ +# jam-nodes + +Extensible workflow node framework for building automation pipelines. Define, register, and execute typed nodes with Zod validation. + +📚 **[Documentation](https://docs.spreadjam.com)** · 🎮 **[Playground](https://docs.spreadjam.com/playground/overview)** + +## Packages + +- **[@jam-nodes/core](./packages/core)** - Core framework with types, registry, and execution context +- **[@jam-nodes/nodes](./packages/nodes)** - Built-in nodes (conditional, delay, filter, map, http-request) +- **[@jam-nodes/playground](./packages/playground)** - CLI tool for testing nodes interactively + +## Installation + +```bash +npm install @jam-nodes/core @jam-nodes/nodes zod +``` + +## Quick Start + +```typescript +import { NodeRegistry, defineNode, ExecutionContext } from "@jam-nodes/core"; +import { conditionalNode, endNode, builtInNodes } from "@jam-nodes/nodes"; +import { z } from "zod"; + +// Create a registry and register built-in nodes +const registry = new NodeRegistry(); +registry.registerAll(builtInNodes); + +// Define a custom node +const greetNode = defineNode({ + type: "greet", + name: "Greet", + description: "Generate a greeting message", + category: "action", + inputSchema: z.object({ + name: z.string(), + }), + outputSchema: z.object({ + message: z.string(), + }), + executor: async (input) => ({ + success: true, + output: { message: `Hello, ${input.name}!` }, + }), +}); + +// Register custom node +registry.register(greetNode); + +// Execute a node +const context = new ExecutionContext({ userName: "World" }); +const executor = registry.getExecutor("greet"); +const result = await executor( + { name: context.interpolate("{{userName}}") }, + context.toNodeContext("user-123", "workflow-456"), +); + +console.log(result.output?.message); // "Hello, World!" +``` + +## Creating Custom Nodes + +```typescript +import { defineNode } from "@jam-nodes/core"; +import { z } from "zod"; + +export const myNode = defineNode({ + type: "my_custom_node", + name: "My Custom Node", + description: "Does something awesome", + category: "action", // 'action' | 'logic' | 'integration' | 'transform' + + inputSchema: z.object({ + input1: z.string(), + input2: z.number().optional(), + }), + + outputSchema: z.object({ + result: z.string(), + }), + + capabilities: { + supportsRerun: true, + supportsCancel: true, + }, + + executor: async (input, context) => { + // Access workflow variables + const previousData = context.resolveNestedPath("someNode.output"); + + // Your logic here + const result = `Processed: ${input.input1}`; + + return { + success: true, + output: { result }, + // Optional: send notification + notification: { + title: "Node Complete", + message: "Processing finished", + }, + }; + }, +}); +``` + +## Built-in Nodes + +### Logic + +- **conditional** - Branch workflow based on conditions +- **end** - Mark end of workflow branch +- **delay** - Wait for specified duration + +### Transform + +- **map** - Extract property from array items +- **filter** - Filter array based on conditions + +### Examples + +- **http_request** - Make HTTP requests to external APIs + +## Variable Interpolation + +The `ExecutionContext` supports powerful variable interpolation: + +```typescript +const ctx = new ExecutionContext({ + user: { name: "Alice", email: "alice@example.com" }, + items: [1, 2, 3], +}); + +// Simple interpolation +ctx.interpolate("Hello {{user.name}}"); // "Hello Alice" + +// Direct value (returns actual type) +ctx.interpolate("{{items}}"); // [1, 2, 3] + +// JSONPath +ctx.evaluateJsonPath("$.user.email"); // "alice@example.com" +``` + +## License + +MIT diff --git a/packages/nodes/package.json b/packages/nodes/package.json index 30acf69..fddccc6 100644 --- a/packages/nodes/package.json +++ b/packages/nodes/package.json @@ -3,6 +3,7 @@ "version": "0.2.5", "description": "Built-in workflow nodes for jam-nodes framework", "type": "module", + "sideEffects": false, "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { From f48856a00fe3a4312849023eb70ede7961407dab Mon Sep 17 00:00:00 2001 From: Aditya Date: Sun, 1 Mar 2026 14:01:00 +0530 Subject: [PATCH 5/6] feat(nodes): add Dev.to integration node - Define Dev.to API key in NodeCredentials - Implement createArticle, updateArticle, and getArticles operations - Export devtoNode through integration barrels - Add devtoNode to builtInNodes and verify in test suite --- packages/core/src/types/node.ts | 12 +- packages/nodes/src/index.ts | 10 +- .../nodes/src/integrations/devto/devto.ts | 223 ++++++++++++++++++ .../nodes/src/integrations/devto/index.ts | 8 + packages/nodes/src/integrations/index.ts | 10 + packages/nodes/src/transform/filter.ts | 23 +- test.ts | 2 +- 7 files changed, 276 insertions(+), 12 deletions(-) create mode 100644 packages/nodes/src/integrations/devto/devto.ts create mode 100644 packages/nodes/src/integrations/devto/index.ts diff --git a/packages/core/src/types/node.ts b/packages/core/src/types/node.ts index 5a2dec3..fe2e04e 100644 --- a/packages/core/src/types/node.ts +++ b/packages/core/src/types/node.ts @@ -10,14 +10,10 @@ export interface NodeCredentials { apollo?: { apiKey: string; }; - /** Twitter/X API credentials */ twitter?: { - /** Official Twitter API v2 Bearer Token */ bearerToken?: string; - /** TwitterAPI.io API key (third-party, simpler) */ twitterApiIoKey?: string; }; - /** ForumScout API credentials (for LinkedIn monitoring) */ forumScout?: { apiKey: string; }; @@ -26,14 +22,15 @@ export interface NodeCredentials { /** Base64 encoded login:password */ apiToken: string; }; - /** OpenAI API credentials */ openai?: { apiKey: string; }; - /** Anthropic API credentials */ anthropic?: { apiKey: string; }; + devto?: { + apiKey: string; + }; } /** @@ -108,9 +105,6 @@ export type NodeExecutor = ( context: NodeExecutionContext ) => Promise>; -/** - * Node capabilities for UI and runtime behavior. - */ export interface NodeCapabilities { /** Node supports data enrichment */ supportsEnrichment?: boolean; diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index 9c28003..1be3b9c 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -92,10 +92,13 @@ export { seoAuditNode, SeoAuditInputSchema, SeoAuditOutputSchema, - // Apollo searchContactsNode, SearchContactsInputSchema, SearchContactsOutputSchema, + // Dev.to + devtoNode, + DevToInputSchema, + DevToOutputSchema, } from './integrations/index.js'; export type { @@ -117,6 +120,9 @@ export type { SeoIssue, SearchContactsInput, SearchContactsOutput, + DevToInput, + DevToOutput, + DevToArticle, } from './integrations/index.js'; // AI nodes @@ -161,6 +167,7 @@ import { seoKeywordResearchNode, seoAuditNode, searchContactsNode, + devtoNode, } from './integrations/index.js'; import { socialKeywordGeneratorNode, @@ -191,6 +198,7 @@ export const builtInNodes = [ seoKeywordResearchNode, seoAuditNode, searchContactsNode, + devtoNode, // AI socialKeywordGeneratorNode, draftEmailsNode, diff --git a/packages/nodes/src/integrations/devto/devto.ts b/packages/nodes/src/integrations/devto/devto.ts new file mode 100644 index 0000000..e92e079 --- /dev/null +++ b/packages/nodes/src/integrations/devto/devto.ts @@ -0,0 +1,223 @@ +import { z } from 'zod'; +import { defineNode } from '@jam-nodes/core'; +import { fetchWithRetry } from '../../utils/http.js'; + +// Constants + +const DEVTO_BASE_URL = 'https://dev.to/api'; + +// Types + +export interface DevToArticle { + id: number; + title: string; + description: string; + cover_image: string | null; + social_image: string; + published_at: string; + created_at: string; + url: string; + body_markdown?: string; + body_html?: string; + tags: string[]; + reading_time_minutes: number; + user: { + name: string; + username: string; + profile_image: string; + }; +} + +// Schemas + +export const DevToInputSchema = z.discriminatedUnion('operation', [ + // Create Article + z.object({ + operation: z.literal('createArticle'), + article: z.object({ + title: z.string(), + bodyMarkdown: z.string(), + published: z.boolean().optional().default(false), + tags: z.array(z.string()).optional(), + series: z.string().optional(), + canonicalUrl: z.string().url().optional(), + description: z.string().optional(), + }), + }), + // Update Article + z.object({ + operation: z.literal('updateArticle'), + articleId: z.number(), + article: z.object({ + title: z.string().optional(), + bodyMarkdown: z.string().optional(), + published: z.boolean().optional(), + tags: z.array(z.string()).optional(), + series: z.string().optional(), + canonicalUrl: z.string().url().optional(), + description: z.string().optional(), + }), + }), + // Get Articles + z.object({ + operation: z.literal('getArticles'), + username: z.string().optional(), + perPage: z.number().min(1).max(1000).optional().default(30), + page: z.number().min(1).optional().default(1), + }), +]); + +export type DevToInput = z.infer; + +export const DevToOutputSchema = z.object({ + article: z.custom().optional(), + articles: z.array(z.custom()).optional(), + success: z.boolean(), +}); + +export type DevToOutput = z.infer; + +// Node Definition + +/** + * Dev.to Integration Node + * + * Perform operations on the Dev.to platform such as creating, updating + * or retrieving articles. + * + * Requires `context.credentials.devto.apiKey` to be provided. + * + * @example + * ```typescript + * const result = await devtoNode.executor({ + * operation: 'createArticle', + * article: { + * title: 'Hello World', + * bodyMarkdown: 'My first post', + * published: false + * } + * }, context); + * ``` + */ +export const devtoNode = defineNode({ + type: 'devto', + name: 'Dev.to', + description: 'Publish and manage articles on Dev.to', + category: 'integration', + inputSchema: DevToInputSchema, + outputSchema: DevToOutputSchema, + estimatedDuration: 2000, + capabilities: { + supportsRerun: true, + }, + + executor: async (input, context) => { + try { + const apiKey = context.credentials?.devto?.apiKey as string | undefined; + + if (!apiKey && ['createArticle', 'updateArticle'].includes(input.operation)) { + return { + success: false, + error: 'Dev.to API key not configured. Provide context.credentials.devto.apiKey.', + }; + } + + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (apiKey) { + headers['api-key'] = apiKey; + } + + if (input.operation === 'createArticle') { + const payload = { + article: { + title: input.article.title, + body_markdown: input.article.bodyMarkdown, + published: input.article.published, + tags: input.article.tags, + series: input.article.series, + canonical_url: input.article.canonicalUrl, + description: input.article.description, + } + }; + + const response = await fetchWithRetry( + `${DEVTO_BASE_URL}/articles`, + { method: 'POST', headers, body: JSON.stringify(payload) }, + { maxRetries: 2 } + ); + + if (!response.ok) { + throw new Error(`Failed to create article: ${response.status} ${await response.text()}`); + } + + const data = await response.json(); + return { success: true, output: { article: data as DevToArticle, success: true } }; + } + + if (input.operation === 'updateArticle') { + const payload = { + article: { + title: input.article.title, + body_markdown: input.article.bodyMarkdown, + published: input.article.published, + tags: input.article.tags, + series: input.article.series, + canonical_url: input.article.canonicalUrl, + description: input.article.description, + } + }; + + // Remove undefined fields + Object.keys(payload.article).forEach(key => + payload.article[key as keyof typeof payload.article] === undefined && delete payload.article[key as keyof typeof payload.article] + ); + + const response = await fetchWithRetry( + `${DEVTO_BASE_URL}/articles/${input.articleId}`, + { method: 'PUT', headers, body: JSON.stringify(payload) }, + { maxRetries: 2 } + ); + + if (!response.ok) { + throw new Error(`Failed to update article: ${response.status} ${await response.text()}`); + } + + const data = await response.json(); + return { success: true, output: { article: data as DevToArticle, success: true } }; + } + + if (input.operation === 'getArticles') { + const queryParams = new URLSearchParams(); + if (input.username) queryParams.append('username', input.username); + if (input.perPage) queryParams.append('per_page', input.perPage.toString()); + if (input.page) queryParams.append('page', input.page.toString()); + + const url = `${DEVTO_BASE_URL}/articles${queryParams.toString() ? '?' + queryParams.toString() : ''}`; + + const response = await fetchWithRetry( + url, + { method: 'GET', headers }, + { maxRetries: 3 } + ); + + if (!response.ok) { + throw new Error(`Failed to fetch articles: ${response.status} ${await response.text()}`); + } + + const data = await response.json(); + return { success: true, output: { articles: data as DevToArticle[], success: true } }; + } + + return { success: false, error: 'Unknown operation' }; + + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Dev.to operation failed', + }; + } + }, +}); diff --git a/packages/nodes/src/integrations/devto/index.ts b/packages/nodes/src/integrations/devto/index.ts new file mode 100644 index 0000000..d8657a5 --- /dev/null +++ b/packages/nodes/src/integrations/devto/index.ts @@ -0,0 +1,8 @@ +export { + devtoNode, + DevToInputSchema, + DevToOutputSchema, + type DevToInput, + type DevToOutput, + type DevToArticle, +} from './devto.js'; diff --git a/packages/nodes/src/integrations/index.ts b/packages/nodes/src/integrations/index.ts index 9375772..a06eb14 100644 --- a/packages/nodes/src/integrations/index.ts +++ b/packages/nodes/src/integrations/index.ts @@ -52,3 +52,13 @@ export { type SearchContactsInput, type SearchContactsOutput, } from './apollo/index.js'; + +// Dev.to integrations +export { + devtoNode, + DevToInputSchema, + DevToOutputSchema, + type DevToInput, + type DevToOutput, + type DevToArticle, +} from './devto/index.js'; diff --git a/packages/nodes/src/transform/filter.ts b/packages/nodes/src/transform/filter.ts index 24ae9a1..cb3e7aa 100644 --- a/packages/nodes/src/transform/filter.ts +++ b/packages/nodes/src/transform/filter.ts @@ -1,6 +1,5 @@ import { z } from 'zod'; import { defineNode } from '@jam-nodes/core'; -import { resolvePath } from '../utils/resolve-path.js'; /** * Filter operator schema @@ -45,6 +44,28 @@ export const FilterOutputSchema = z.object({ export type FilterOutput = z.infer; +/** + * Resolve a nested path on an object + */ +function resolvePath(obj: unknown, path: string): unknown { + // Empty path means use the item itself + if (!path) { + return obj; + } + + const parts = path.split('.'); + let current: unknown = obj; + + for (const part of parts) { + if (current === null || current === undefined) { + return undefined; + } + current = (current as Record)[part]; + } + + return current; +} + /** * Evaluate filter condition */ diff --git a/test.ts b/test.ts index 9a3e8f1..f50bcaf 100644 --- a/test.ts +++ b/test.ts @@ -4,7 +4,7 @@ */ import { NodeRegistry, ExecutionContext, defineNode } from './packages/core/src/index'; -import { conditionalNode, endNode, delayNode, mapNode, filterNode, sortNode, httpRequestNode, breadNode, builtInNodes } from './packages/nodes/src/index'; +import { conditionalNode, endNode, delayNode, mapNode, filterNode, sortNode, httpRequestNode, breadNode, devtoNode, builtInNodes } from './packages/nodes/src/index'; import { z } from 'zod'; async function test() { From 7f5d58f7177231edce90b2baa56a60551c6239b9 Mon Sep 17 00:00:00 2001 From: Aditya Date: Sun, 1 Mar 2026 14:51:24 +0530 Subject: [PATCH 6/6] fix(nodes): add discord and firecrawl to NodeCredentials to match upstream --- packages/core/src/types/node.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/core/src/types/node.ts b/packages/core/src/types/node.ts index fe2e04e..951b05d 100644 --- a/packages/core/src/types/node.ts +++ b/packages/core/src/types/node.ts @@ -31,6 +31,15 @@ export interface NodeCredentials { devto?: { apiKey: string; }; + discordBot?: { + token: string; + }; + discordWebhook?: { + url: string; + }; + firecrawl?: { + apiKey: string; + }; } /**