Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ Visit [http://localhost:3000](http://localhost:3000) to get started.

<!-- PLUGINS:START - Do not remove. Auto-generated by discover-plugins -->
- **AI Gateway**: Generate Text, Generate Image
- **Apify**: Run Actor, Scrape Single URL
- **Blob**: Put Blob, List Blobs
- **fal.ai**: Generate Image, Generate Video, Upscale Image, Remove Background, Image to Image
- **Firecrawl**: Scrape URL, Search Web
Expand Down Expand Up @@ -268,6 +269,30 @@ const searchResult = await firecrawlSearchStep({
});
```

### Apify (Web Scraping)

```typescript
import {
scrapeSingleUrlStep,
apifyRunActorStep,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README shows an incorrect import path that doesn't exist, which would cause a runtime import error when users follow the documentation.

View Details
📝 Patch Details
diff --git a/lib/steps/apify.ts b/lib/steps/apify.ts
new file mode 100644
index 0000000..2213ef0
--- /dev/null
+++ b/lib/steps/apify.ts
@@ -0,0 +1,6 @@
+/**
+ * Apify steps - barrel export for public API
+ */
+
+export { apifyRunActorStep } from "../../plugins/apify/steps/run-actor/step";
+export { scrapeSingleUrlStep } from "../../plugins/apify/steps/scrape-single-url/step";

Analysis

Missing barrel export for Apify step functions in README documentation

What fails: README.md (lines 276-278) shows an import statement from @/lib/steps/apify, but this barrel export file does not exist. TypeScript compilation fails with error TS2307: Cannot find module '@/lib/steps/apify'.

How to reproduce:

  1. Create a TypeScript file with the import from README:
import {
  scrapeSingleUrlStep,
  apifyRunActorStep,
} from "@/lib/steps/apify";
  1. Run pnpm type-check before the fix - fails with error TS2307: Cannot find module '@/lib/steps/apify'

What happened: Users following the README documentation would be unable to import these functions from the documented path. The functions exist in plugins/apify/steps/run-actor/step.ts and plugins/apify/steps/scrape-single-url/step.ts but are not re-exported from any barrel file that matches the import path shown in the documentation.

Expected: The README example should work without TypeScript errors. The fix creates a barrel export file at lib/steps/apify.ts that re-exports both functions, allowing the documented import path to work correctly.

} from "@/lib/steps/apify";

// Scrape a URL
const scrapeResult = await scrapeSingleUrlStep({
url: "https://example.com",
crawlerType: "playwright:adaptive",
});

// Run an Actor from Apify Store
const searchMapsResults = await apifyRunActorStep({
actorId: "compass/crawler-google-places",
actorInput: {
searchStringsArray: [ "restaurants in San Francisco" ]
},
});
```


## Tech Stack

- **Framework**: Next.js 16 with React 19
Expand Down
151 changes: 151 additions & 0 deletions components/ui/template-badge-json.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"use client";

import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { TemplateBadgeTextarea } from "./template-badge-textarea";

export interface TemplateBadgeJsonProps {
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
id?: string;
rows?: number;
}

/**
* A textarea component that validates JSON input in real-time
* Wraps TemplateBadgeTextarea and adds JSON validation and formatting
*/
export function TemplateBadgeJson({
value = "",
onChange,
placeholder,
disabled,
className,
id,
rows = 3,
}: TemplateBadgeJsonProps) {
const [jsonError, setJsonError] = useState<string | null>(null);
const [isFocused, setIsFocused] = useState(false);
const formatTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastFormattedValueRef = useRef<string>("");

// Validate JSON on value change
useEffect(() => {
if (!value || typeof value !== "string") {
setJsonError(null);
return;
}

// If empty or only whitespace, no error
if (!value.trim()) {
setJsonError(null);
return;
}

// Ensure that parsable values (not object) throws
if (!/^\s*\{[\s\S]*\}\s*$/.test(value)) {
setJsonError("Value must be a JSON object");
return;
}

// Parse JSON directly - template variables will be treated as normal strings
try {
JSON.parse(value);
setJsonError(null);
} catch (error) {
setJsonError(
error instanceof Error ? error.message : "Invalid JSON format"
);
}
}, [value]);

// Format JSON when it becomes valid (debounced to avoid formatting while typing)
useEffect(() => {
// Clear any pending format timeout
if (formatTimeoutRef.current) {
clearTimeout(formatTimeoutRef.current);
}

// Don't format if there's an error, field is focused, or value is empty
if (jsonError || isFocused || !value || typeof value !== "string") {
return;
}

if (!value.trim()) {
return;
}

// Debounce formatting - wait 500ms after user stops typing
formatTimeoutRef.current = setTimeout(() => {
try {
// Parse JSON directly - template variables are treated as normal strings
const parsed = JSON.parse(value);
const formatted = JSON.stringify(parsed, null, 2);

// Only format if different from current value and we haven't already formatted this value
if (formatted !== value && formatted !== lastFormattedValueRef.current) {
lastFormattedValueRef.current = formatted;
onChange?.(formatted);
}
} catch {
// If parsing fails, don't format
}
}, 500);

return () => {
if (formatTimeoutRef.current) {
clearTimeout(formatTimeoutRef.current);
}
};
}, [value, isFocused, jsonError, onChange]);

// Track focus state by listening to focus/blur events on the wrapper
const handleWrapperFocus = () => {
setIsFocused(true);
};

const handleWrapperBlur = () => {
setIsFocused(false);
// Format immediately on blur if JSON is valid
if (!jsonError && value && typeof value === "string" && value.trim()) {
try {
// Parse JSON directly - template variables are treated as normal strings
const parsed = JSON.parse(value);
const formatted = JSON.stringify(parsed, null, 2);

if (formatted !== value) {
onChange?.(formatted);
}
} catch {
// If parsing fails, don't format
}
}
};

return (
<div
className="space-y-1"
onBlur={handleWrapperBlur}
onFocus={handleWrapperFocus}
>
<TemplateBadgeTextarea
className={cn(
jsonError && "border-destructive focus-within:ring-destructive",
className
)}
disabled={disabled}
id={id}
onChange={onChange}
placeholder={placeholder}
rows={rows}
value={value}
/>
{jsonError && (
<p className="ml-1 text-destructive text-xs">{jsonError}</p>
)}
</div>
);
}
25 changes: 17 additions & 8 deletions components/workflow/config/action-config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
getActionsByCategory,
getAllIntegrations,
} from "@/plugins";
import { RunActorConfigFields } from "@/plugins/apify/steps/run-actor/config";
import { ActionConfigRenderer } from "./action-config-renderer";
import { SchemaBuilder, type SchemaField } from "./schema-builder";

Expand Down Expand Up @@ -390,14 +391,22 @@ export function ActionConfig({
)}

{/* Plugin actions - declarative config fields */}
{pluginAction && !SYSTEM_ACTION_IDS.includes(actionType) && (
<ActionConfigRenderer
config={config}
disabled={disabled}
fields={pluginAction.configFields}
onUpdateConfig={handlePluginUpdateConfig}
/>
)}
{pluginAction &&
!SYSTEM_ACTION_IDS.includes(actionType) &&
(actionType === "apify/run-actor" ? (
<RunActorConfigFields
config={config}
disabled={disabled}
onUpdateConfig={handlePluginUpdateConfig}
/>
) : (
<ActionConfigRenderer
config={config}
disabled={disabled}
fields={pluginAction.configFields}
onUpdateConfig={handlePluginUpdateConfig}
/>
))}
</>
);
}
10 changes: 10 additions & 0 deletions lib/steps/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

import type { generateImageStep } from "../../plugins/ai-gateway/steps/generate-image";
import type { generateTextStep } from "../../plugins/ai-gateway/steps/generate-text";
import type { apifyRunActorStep } from "../../plugins/apify/steps/run-actor/step";
import type { scrapeSingleUrlStep } from "../../plugins/apify/steps/scrape-single-url/step";
import type { firecrawlScrapeStep } from "../../plugins/firecrawl/steps/scrape";
import type { firecrawlSearchStep } from "../../plugins/firecrawl/steps/search";
import type { createTicketStep } from "../../plugins/linear/steps/create-ticket";
Expand Down Expand Up @@ -64,6 +66,14 @@ export const stepRegistry: Record<string, StepFunction> = {
(await import("../../plugins/firecrawl/steps/search")).firecrawlSearchStep(
input as Parameters<typeof firecrawlSearchStep>[0]
),
"Run Actor": async (input) =>
(
await import("../../plugins/apify/steps/run-actor/step")
).apifyRunActorStep(input as Parameters<typeof apifyRunActorStep>[0]),
"Scrape Single URL": async (input) =>
(
await import("../../plugins/apify/steps/scrape-single-url/step")
).scrapeSingleUrlStep(input as Parameters<typeof scrapeSingleUrlStep>[0]),
};

// Helper to check if a step exists
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"@vercel/speed-insights": "^1.2.0",
"@xyflow/react": "^12.9.2",
"ai": "^5.0.102",
"apify-client": "^2.20.0",
"better-auth": "^1.3.34",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
Expand Down
60 changes: 60 additions & 0 deletions plugins/apify/codegen/run-actor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Code generation template for Run Actor action
* This template is used when exporting workflows to standalone Next.js projects
* It uses environment variables instead of integrationId
*/
export const runActorCodegenTemplate = `import { ApifyClient } from "apify-client";

export async function apifyRunActorStep(input: {
actorId: string;
actorInput?: string | Record<string, unknown> | null;
}) {
"use step";

const apiKey = process.env.APIFY_API_TOKEN;

if (!apiKey) {
throw new Error("Apify API Token is not configured. Set APIFY_API_TOKEN environment variable.");
}

let parsedActorInput: Record<string, unknown> = {};
if (input.actorInput) {
// If it's already an object, use it directly
if (typeof input.actorInput === "object" && !Array.isArray(input.actorInput)) {
parsedActorInput = input.actorInput;
} else if (typeof input.actorInput === "string") {
// If it's a string, parse it
try {
parsedActorInput = JSON.parse(input.actorInput);
} catch (err) {
throw new Error(\`Cannot parse Actor input: \${err instanceof Error ? err.message : String(err)}\`);
}
}
}

try {
const client = new ApifyClient({ token: apiKey });
const actorClient = client.actor(input.actorId);

// Run synchronously and wait for completion
const runData = await actorClient.call(parsedActorInput);

// Get dataset items
let datasetItems: unknown[] = [];
if (runData.defaultDatasetId) {
const dataset = await client
.dataset(runData.defaultDatasetId)
.listItems();
datasetItems = dataset.items;
}

return {
runId: runData.id || "unknown",
status: runData.status || "SUCCEEDED",
datasetId: runData.defaultDatasetId,
data: datasetItems,
};
} catch (error) {
throw new Error(\`Failed to run Actor: \${error instanceof Error ? error.message : String(error)}\`);
}
}`;
68 changes: 68 additions & 0 deletions plugins/apify/codegen/scrape-single-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Code generation template for Scrape Single URL action
* This template is used when exporting workflows to standalone Next.js projects
* It uses environment variables instead of integrationId
*/
export const scrapeSingleUrlCodegenTemplate = `import { ApifyClient } from "apify-client";

export async function scrapeSingleUrlStep(input: {
url: string;
crawlerType?: string;
}) {
"use step";

const apiKey = process.env.APIFY_API_TOKEN;

if (!apiKey) {
throw new Error("Apify API Token is not configured. Set APIFY_API_TOKEN environment variable.");
}

if (!input.url) {
throw new Error("URL is required.");
}

try {
const client = new ApifyClient({ token: apiKey });
const actorClient = client.actor("apify/website-content-crawler");
const crawlerType = input.crawlerType || "playwright:adaptive";

// Prepare actor input
const actorInput = {
startUrls: [{ url: input.url }],
crawlerType,
maxCrawlDepth: 0,
maxCrawlPages: 1,
maxResults: 1,
proxyConfiguration: {
useApifyProxy: true,
},
removeCookieWarnings: true,
saveMarkdown: true,
};

// Run synchronously and wait for completion
const runData = await actorClient.call(actorInput);

// Get dataset items
let markdown: string | undefined;
if (runData.defaultDatasetId) {
const datasetItems = await client
.dataset(runData.defaultDatasetId)
.listItems();

// Extract markdown from the first item
if (datasetItems.items && datasetItems.items.length > 0) {
const firstItem = datasetItems.items[0] as Record<string, unknown>;
markdown = firstItem.markdown as string;
}
}

return {
runId: runData.id || "unknown",
status: runData.status || "SUCCEEDED",
markdown,
};
} catch (error) {
throw new Error(\`Failed to scrape URL: \${error instanceof Error ? error.message : String(error)}\`);
}
}`;
Loading