diff --git a/.dockerignore b/.dockerignore
index 98af3e0c6e5..3a5d3e50438 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -32,7 +32,9 @@ Dockerfile*
# Build artifacts and caches
.next
+**/.next
.turbo
+**/.turbo
.cache
dist
build
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c81d49b83f6..bfac30a378a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -266,7 +266,7 @@ jobs:
echo "ERROR: DEV_TRIGGER_ACCESS_TOKEN and TRIGGER_PROJECT_ID repo secrets must both be set" >&2
exit 1
fi
- bunx trigger.dev@4.5.7 deploy --env preview --branch dev-sim
+ bunx trigger.dev@4.5.12 deploy --env preview --branch dev-sim
# Main/staging: build AMD64 images and push sha-tagged images to ECR + GHCR.
# Runs in parallel with tests — only immutable sha tags are pushed here, and
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 7d616a3d937..49f5aa98909 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -50,7 +50,7 @@
"@sim/tsconfig": "workspace:*",
"@types/micromatch": "4.0.10",
"@types/node": "24.2.1",
- "electron": "43.4.1",
+ "electron": "43.5.0",
"electron-builder": "26.15.3",
"esbuild": "0.28.1",
"jsdom": "^26.0.0",
diff --git a/apps/docs/components/workflow-preview/block-preview.tsx b/apps/docs/components/workflow-preview/block-preview.tsx
index ca39c8b055c..34ce64ff364 100644
--- a/apps/docs/components/workflow-preview/block-preview.tsx
+++ b/apps/docs/components/workflow-preview/block-preview.tsx
@@ -1,13 +1,13 @@
'use client'
import { useMemo } from 'react'
-import { CANVAS_Z_INDEX_MODE } from '@sim/workflow-renderer'
+import { CANVAS_Z_INDEX_MODE, useCanvasColorMode } from '@sim/workflow-renderer'
import { type NodeTypes, ReactFlow, ReactFlowProvider } from '@xyflow/react'
import { domAnimation, LazyMotion } from 'framer-motion'
import '@xyflow/react/dist/style.css'
import { BLOCK_DISPLAY_WORKFLOWS } from '@/components/workflow-preview/block-display-workflows'
import { DocsBlockNode } from '@/components/workflow-preview/docs-block-node'
-import { usePreviewColorMode } from '@/components/workflow-preview/use-preview-color-mode'
+import { FitViewAfterInit } from '@/components/workflow-preview/fit-view-after-init'
import { toReactFlowElements } from '@/components/workflow-preview/workflow-data'
/** The hero mounts the same node type the canvas uses, so it can never drift. */
@@ -30,7 +30,7 @@ interface BlockPreviewProps {
* `block-display-workflows.ts`.
*/
export function BlockPreview({ type }: BlockPreviewProps) {
- const colorMode = usePreviewColorMode()
+ const colorMode = useCanvasColorMode()
const workflow = BLOCK_DISPLAY_WORKFLOWS[type]
const elements = useMemo(() => (workflow ? toReactFlowElements(workflow) : null), [workflow])
@@ -51,8 +51,6 @@ export function BlockPreview({ type }: BlockPreviewProps) {
edges={elements.edges}
nodeTypes={NODE_TYPES}
proOptions={PRO_OPTIONS}
- fitView
- fitViewOptions={FIT_VIEW_OPTIONS}
minZoom={0.2}
maxZoom={1.3}
nodesDraggable={false}
@@ -64,8 +62,9 @@ export function BlockPreview({ type }: BlockPreviewProps) {
panOnDrag={false}
panOnScroll={false}
preventScrolling={false}
- className='h-full w-full'
+ className='h-full w-full [--xy-background-color:var(--bg)]'
/>
+
diff --git a/apps/docs/components/workflow-preview/fit-view-after-init.tsx b/apps/docs/components/workflow-preview/fit-view-after-init.tsx
new file mode 100644
index 00000000000..fda2379f8d2
--- /dev/null
+++ b/apps/docs/components/workflow-preview/fit-view-after-init.tsx
@@ -0,0 +1,20 @@
+'use client'
+
+import { useEffect } from 'react'
+import { type FitViewOptions, useNodesInitialized, useReactFlow } from '@xyflow/react'
+
+interface FitViewAfterInitProps {
+ options: FitViewOptions
+}
+
+/** Fits a v12 canvas only after every node has real measured dimensions. */
+export function FitViewAfterInit({ options }: FitViewAfterInitProps) {
+ const nodesInitialized = useNodesInitialized()
+ const { fitView } = useReactFlow()
+
+ useEffect(() => {
+ if (nodesInitialized) void fitView(options)
+ }, [fitView, nodesInitialized, options])
+
+ return null
+}
diff --git a/apps/docs/components/workflow-preview/use-preview-color-mode.ts b/apps/docs/components/workflow-preview/use-preview-color-mode.ts
deleted file mode 100644
index 3ab3aac2fe1..00000000000
--- a/apps/docs/components/workflow-preview/use-preview-color-mode.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-'use client'
-
-import type { ColorMode } from '@xyflow/react'
-import { useTheme } from 'next-themes'
-
-/**
- * Resolves the React Flow `colorMode` from the docs theme so the canvas
- * wrapper's color-mode class (and the `--xy-*` palette it selects) follows
- * dark mode instead of React Flow's default `light`.
- */
-export function usePreviewColorMode(): ColorMode {
- const { resolvedTheme } = useTheme()
- // Before next-themes mounts, resolvedTheme is undefined; 'system' lets React
- // Flow follow the OS preference instead of flashing a light-classed frame.
- if (resolvedTheme === undefined) return 'system'
- return resolvedTheme === 'dark' ? 'dark' : 'light'
-}
diff --git a/apps/docs/components/workflow-preview/workflow-preview.tsx b/apps/docs/components/workflow-preview/workflow-preview.tsx
index 197723e2ab5..ef3a1a90998 100644
--- a/apps/docs/components/workflow-preview/workflow-preview.tsx
+++ b/apps/docs/components/workflow-preview/workflow-preview.tsx
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Expand, X } from '@sim/emcn/icons'
-import { CANVAS_Z_INDEX_MODE } from '@sim/workflow-renderer'
+import { CANVAS_Z_INDEX_MODE, useCanvasColorMode } from '@sim/workflow-renderer'
import {
applyEdgeChanges,
applyNodeChanges,
@@ -21,7 +21,7 @@ import { BLOCK_DISPLAY_WORKFLOWS } from '@/components/workflow-preview/block-dis
import { BlockInspector } from '@/components/workflow-preview/block-inspector'
import { DocsBlockNode } from '@/components/workflow-preview/docs-block-node'
import { DocsContainerNode } from '@/components/workflow-preview/docs-container-node'
-import { usePreviewColorMode } from '@/components/workflow-preview/use-preview-color-mode'
+import { FitViewAfterInit } from '@/components/workflow-preview/fit-view-after-init'
import {
EASE_OUT,
type PreviewBlock,
@@ -179,7 +179,8 @@ function PreviewFlow({
[workflow, animate, highlightBlock, highlightEdge, selectedBlock]
)
- const colorMode = usePreviewColorMode()
+ const colorMode = useCanvasColorMode()
+
const [nodes, setNodes] = useState(initialNodes)
const [edges, setEdges] = useState(initialEdges)
@@ -208,34 +209,35 @@ function PreviewFlow({
)
return (
-
- colorMode={colorMode}
- zIndexMode={CANVAS_Z_INDEX_MODE}
- nodes={nodes}
- edges={edges}
- onNodesChange={onNodesChange}
- onEdgesChange={onEdgesChange}
- onNodeClick={onNodeClick ? (_, node) => onNodeClick(node.id) : undefined}
- onPaneClick={onPaneClick}
- nodeTypes={NODE_TYPES}
- edgeTypes={EDGE_TYPES}
- defaultEdgeOptions={{ type: 'previewEdge' }}
- elementsSelectable={false}
- nodesDraggable
- nodesConnectable={false}
- zoomOnScroll={interactive}
- zoomOnDoubleClick={interactive}
- panOnScroll={false}
- zoomOnPinch
- panOnDrag
- preventScrolling={interactive}
- autoPanOnNodeDrag={false}
- proOptions={PRO_OPTIONS}
- minZoom={0.1}
- fitView
- fitViewOptions={interactive ? LIGHTBOX_FIT_VIEW_OPTIONS : FIT_VIEW_OPTIONS}
- className='h-full w-full'
- />
+ <>
+
+ colorMode={colorMode}
+ zIndexMode={CANVAS_Z_INDEX_MODE}
+ nodes={nodes}
+ edges={edges}
+ onNodesChange={onNodesChange}
+ onEdgesChange={onEdgesChange}
+ onNodeClick={onNodeClick ? (_, node) => onNodeClick(node.id) : undefined}
+ onPaneClick={onPaneClick}
+ nodeTypes={NODE_TYPES}
+ edgeTypes={EDGE_TYPES}
+ defaultEdgeOptions={{ type: 'previewEdge' }}
+ elementsSelectable={false}
+ nodesDraggable
+ nodesConnectable={false}
+ zoomOnScroll={interactive}
+ zoomOnDoubleClick={interactive}
+ panOnScroll={false}
+ zoomOnPinch
+ panOnDrag
+ preventScrolling={interactive}
+ autoPanOnNodeDrag={false}
+ proOptions={PRO_OPTIONS}
+ minZoom={0.1}
+ className='h-full w-full [--xy-background-color:var(--bg)]'
+ />
+
+ >
)
}
diff --git a/apps/sim/app/(landing)/demo/components/demo-booking/demo-booking.tsx b/apps/sim/app/(landing)/demo/components/demo-booking/demo-booking.tsx
index 561dc9ec207..9c91f96f92c 100644
--- a/apps/sim/app/(landing)/demo/components/demo-booking/demo-booking.tsx
+++ b/apps/sim/app/(landing)/demo/components/demo-booking/demo-booking.tsx
@@ -5,21 +5,20 @@ import { chipBorderShadowRing, cn } from '@sim/emcn'
import dynamic from 'next/dynamic'
import { preconnect } from 'react-dom'
import { DemoForm, type DemoLead } from '@/app/(landing)/demo/components/demo-form'
-import { CAL_ORIGIN } from '@/app/(landing)/demo/components/demo-scheduler/cal-config'
import { applyLegacyInertFallback } from '@/app/(landing)/demo/components/legacy-inert-fallback'
const importScheduler = () => import('@/app/(landing)/demo/components/demo-scheduler')
/**
* Warm the entire booking path while the visitor fills the form: preconnect to
- * the configured Cal origin, then load the scheduler chunk and the
+ * app.cal.com, then load the scheduler chunk, Cal.com's embed.js, and the
* booker iframe assets (via the embed's `preload` instruction). Fired on first
* form focus so nothing Cal.com-related competes with initial page load — the
* connection handshake overlaps the chunk import, and it all finishes long
* before the visitor submits.
*/
function preloadScheduler() {
- preconnect(CAL_ORIGIN)
+ preconnect('https://app.cal.com')
return importScheduler().then((m) => m.preloadCalEmbed())
}
diff --git a/apps/sim/app/(landing)/demo/components/demo-scheduler/cal-config.ts b/apps/sim/app/(landing)/demo/components/demo-scheduler/cal-config.ts
deleted file mode 100644
index 4fad6e70131..00000000000
--- a/apps/sim/app/(landing)/demo/components/demo-scheduler/cal-config.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-const DEFAULT_CAL_ORIGIN = 'https://app.cal.com'
-const DEFAULT_CAL_LINK = 'team/sim/demo'
-
-/** Resolves a hosted or self-hosted Cal event link and rejects non-HTTP embed targets. */
-export function resolveCalLink(configuredLink?: string): URL {
- const link = configuredLink?.trim() || DEFAULT_CAL_LINK
- let url: URL
-
- try {
- url = new URL(link)
- } catch {
- url = new URL(link.replace(/^\/+/, ''), `${DEFAULT_CAL_ORIGIN}/`)
- }
-
- if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
- throw new Error('NEXT_PUBLIC_CAL_LINK must be an HTTP(S) URL or a Cal.com event path')
- }
-
- url.hash = ''
- return url
-}
-
-/**
- * Resolved at module scope on an eagerly-imported path, so a malformed
- * NEXT_PUBLIC_CAL_LINK degrades to the default link instead of taking the
- * whole /demo page down.
- */
-const calLinkUrl = (() => {
- try {
- return resolveCalLink(process.env.NEXT_PUBLIC_CAL_LINK)
- } catch {
- return resolveCalLink(undefined)
- }
-})()
-
-/** Exact origin used for iframe navigation, preconnect, and postMessage validation. */
-export const CAL_ORIGIN = calLinkUrl.origin
-
-/** Returns a fresh URL so callers can safely add embed-specific paths and parameters. */
-export function createConfiguredCalUrl(): URL {
- return new URL(calLinkUrl)
-}
diff --git a/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.test.tsx b/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.test.tsx
index 4258a1fb2c3..2f0711f0091 100644
--- a/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.test.tsx
+++ b/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.test.tsx
@@ -5,22 +5,30 @@ import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-const { mockConsent, mockTrackGoogleEvent } = vi.hoisted(() => ({
- mockConsent: { marketing: true, measurement: true },
- mockTrackGoogleEvent: vi.fn(),
-}))
+const { mockCal, mockCalComponent, mockConsent, mockGetCalApi, mockTrackGoogleEvent } = vi.hoisted(
+ () => ({
+ mockCal: vi.fn(),
+ mockCalComponent: vi.fn(() => null),
+ mockConsent: { marketing: true, measurement: true },
+ mockGetCalApi: vi.fn(),
+ mockTrackGoogleEvent: vi.fn(),
+ })
+)
+vi.mock('@calcom/embed-react', () => ({
+ default: mockCalComponent,
+ getCalApi: mockGetCalApi,
+}))
vi.mock('@/lib/analytics/google', () => ({ trackGoogleEvent: mockTrackGoogleEvent }))
vi.mock('@/lib/consent/scripts', () => ({ X_DEMO_BOOKED_EVENT_ID: 'demo-booked' }))
vi.mock('@/lib/consent/tracking-consent', () => ({
useTrackingConsent: () => mockConsent,
}))
-import { resolveCalLink } from '@/app/(landing)/demo/components/demo-scheduler/cal-config'
import {
- createCalEmbedUrl,
DemoScheduler,
preloadCalEmbed,
+ resolveCalEmbedConfig,
} from '@/app/(landing)/demo/components/demo-scheduler/demo-scheduler'
const LEAD = {
@@ -38,136 +46,129 @@ describe('DemoScheduler', () => {
vi.clearAllMocks()
mockConsent.marketing = true
mockConsent.measurement = true
+ mockGetCalApi.mockResolvedValue(mockCal)
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
})
- afterEach(() => {
- act(() => root.unmount())
- container.remove()
- document.querySelectorAll('iframe[hidden]').forEach((frame) => {
- frame.remove()
+ afterEach(async () => {
+ await act(async () => {
+ root.unmount()
+ await Promise.resolve()
})
+ container.remove()
window.twq = undefined
})
- function renderScheduler(): HTMLIFrameElement {
- act(() => root.render())
- const frame = container.querySelector('iframe[title="Book a demo"]')
- if (!frame) throw new Error('Expected the Cal booking iframe to render')
- return frame
- }
-
- it('builds the hosted embed URL with the lead and presentation prefilled', () => {
- const url = new URL(createCalEmbedUrl(LEAD))
-
- expect(url.origin).toBe('https://app.cal.com')
- expect(url.pathname).toBe('/team/sim/demo/embed')
- expect(Object.fromEntries(url.searchParams)).toEqual({
- embed: 'demo',
- name: LEAD.name,
- email: LEAD.email,
- notes: LEAD.notes,
- theme: 'light',
- 'ui.color-scheme': 'light',
- layout: 'month_view',
- useSlotsViewOnSmallScreen: 'true',
+ it('passes the main-branch presentation and lead config to the official embed', async () => {
+ await act(async () => {
+ root.render()
+ await Promise.resolve()
})
- })
-
- it('derives the trusted origin from a self-hosted event URL', () => {
- const url = resolveCalLink('https://calendar.example.com/team/sim/demo')
-
- expect(url.origin).toBe('https://calendar.example.com')
- expect(url.pathname).toBe('/team/sim/demo')
- })
- it('rejects unsafe Cal embed protocols and credential-bearing URLs', () => {
- expect(() => resolveCalLink('javascript:alert(1)')).toThrow(
- 'NEXT_PUBLIC_CAL_LINK must be an HTTP(S) URL or a Cal.com event path'
- )
- expect(() => resolveCalLink('https://user:secret@calendar.example.com/demo')).toThrow(
- 'NEXT_PUBLIC_CAL_LINK must be an HTTP(S) URL or a Cal.com event path'
+ expect(mockCalComponent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ namespace: 'demo',
+ calLink: 'team/sim/demo',
+ calOrigin: 'https://app.cal.com',
+ embedJsUrl: 'https://app.cal.com/embed/embed.js',
+ className: 'size-full overflow-auto',
+ config: {
+ name: LEAD.name,
+ email: LEAD.email,
+ notes: LEAD.notes,
+ theme: 'light',
+ 'ui.color-scheme': 'light',
+ layout: 'month_view',
+ useSlotsViewOnSmallScreen: 'true',
+ },
+ }),
+ undefined
)
+ expect(mockCal).toHaveBeenCalledWith('ui', {
+ hideEventTypeDetails: true,
+ styles: { branding: { brandColor: '#6f3dfa' } },
+ })
})
- it('warms the hosted booker only once while the preload frame remains mounted', () => {
- preloadCalEmbed()
- preloadCalEmbed()
-
- const frames = document.querySelectorAll('iframe[hidden]')
- expect(frames).toHaveLength(1)
- expect(frames[0].src).toBe('https://app.cal.com/team/sim/demo?preload=true')
- })
-
- it('tracks a booking only when the message comes from the rendered Cal iframe', () => {
+ it('registers consent-aware booking analytics and removes the listener on unmount', async () => {
const trackXEvent = vi.fn()
window.twq = trackXEvent
- const frame = renderScheduler()
- const frameWindow = frame.contentWindow
- expect(frameWindow).not.toBeNull()
-
- act(() => {
- window.dispatchEvent(
- new MessageEvent('message', {
- origin: 'https://malicious.example',
- source: frameWindow,
- data: { fullType: 'CAL:demo:bookingSuccessfulV2' },
- })
- )
- window.dispatchEvent(
- new MessageEvent('message', {
- origin: 'https://app.cal.com',
- source: frameWindow,
- data: { fullType: 'CAL:demo:bookingSuccessfulV2' },
- })
- )
+
+ await act(async () => {
+ root.render()
+ await Promise.resolve()
})
- expect(mockTrackGoogleEvent).toHaveBeenCalledOnce()
+ const registration = mockCal.mock.calls.find(([method]) => method === 'on')?.[1] as
+ | { action: string; callback: () => void }
+ | undefined
+ expect(registration?.action).toBe('bookingSuccessfulV2')
+
+ registration?.callback()
expect(mockTrackGoogleEvent).toHaveBeenCalledWith('get_a_demo', {
page_path: '/demo',
form_name: 'sim_demo',
booking_status: 'scheduled',
})
- expect(trackXEvent).toHaveBeenCalledOnce()
expect(trackXEvent).toHaveBeenCalledWith('event', 'demo-booked', {})
+
+ await act(async () => {
+ root.unmount()
+ await Promise.resolve()
+ })
+ expect(mockCal).toHaveBeenCalledWith('off', {
+ action: 'bookingSuccessfulV2',
+ callback: registration?.callback,
+ })
+ root = createRoot(container)
})
- it("completes Cal's ready handshake and reapplies the branded UI settings", () => {
- const frame = renderScheduler()
- const frameWindow = frame.contentWindow
- expect(frameWindow).not.toBeNull()
- if (!frameWindow) return
- const postMessage = vi.spyOn(frameWindow, 'postMessage')
-
- act(() => {
- window.dispatchEvent(
- new MessageEvent('message', {
- origin: 'https://app.cal.com',
- source: frameWindow,
- data: { fullType: 'CAL:demo:__iframeReady' },
- })
- )
+ it('does not register booking analytics without measurement or marketing consent', async () => {
+ mockConsent.marketing = false
+ mockConsent.measurement = false
+
+ await act(async () => {
+ root.render()
+ await Promise.resolve()
})
- expect(postMessage).toHaveBeenNthCalledWith(
- 1,
- { originator: 'CAL', method: 'parentKnowsIframeReady' },
- 'https://app.cal.com'
- )
- expect(postMessage).toHaveBeenNthCalledWith(
- 2,
+ expect(mockCal).toHaveBeenCalledWith('ui', {
+ hideEventTypeDetails: true,
+ styles: { branding: { brandColor: '#6f3dfa' } },
+ })
+ expect(mockCal.mock.calls.some(([method]) => method === 'on')).toBe(false)
+ })
+
+ it('preloads the configured booker only once', async () => {
+ await act(async () => {
+ preloadCalEmbed()
+ preloadCalEmbed()
+ await Promise.resolve()
+ })
+
+ expect(mockGetCalApi).toHaveBeenCalledOnce()
+ expect(mockGetCalApi).toHaveBeenCalledWith({
+ namespace: 'demo',
+ embedJsUrl: 'https://app.cal.com/embed/embed.js',
+ })
+ expect(mockCal).toHaveBeenCalledOnce()
+ expect(mockCal).toHaveBeenCalledWith('preload', { calLink: 'team/sim/demo' })
+ })
+
+ it('falls back from malformed Cal configuration and preserves valid custom origins', () => {
+ expect(resolveCalEmbedConfig('javascript:alert(1)')).toEqual({
+ calLink: 'team/sim/demo',
+ calOrigin: 'https://app.cal.com',
+ embedJsUrl: 'https://app.cal.com/embed/embed.js',
+ })
+ expect(resolveCalEmbedConfig('https://book.example.com/team/demo?theme=light#ignored')).toEqual(
{
- originator: 'CAL',
- method: 'ui',
- arg: {
- hideEventTypeDetails: true,
- styles: { branding: { brandColor: '#6f3dfa' } },
- },
- },
- 'https://app.cal.com'
+ calLink: 'team/demo?theme=light',
+ calOrigin: 'https://book.example.com',
+ embedJsUrl: 'https://book.example.com/embed/embed.js',
+ }
)
})
})
diff --git a/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.tsx b/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.tsx
index 4a89825c100..682c1990987 100644
--- a/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.tsx
+++ b/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.tsx
@@ -1,126 +1,100 @@
'use client'
-import { useEffect, useRef } from 'react'
+import { useEffect } from 'react'
+import Cal, { getCalApi } from '@calcom/embed-react'
import { trackGoogleEvent } from '@/lib/analytics/google'
import { X_DEMO_BOOKED_EVENT_ID } from '@/lib/consent/scripts'
import { useTrackingConsent } from '@/lib/consent/tracking-consent'
import type { DemoLead } from '@/app/(landing)/demo/components/demo-form'
-import {
- CAL_ORIGIN,
- createConfiguredCalUrl,
-} from '@/app/(landing)/demo/components/demo-scheduler/cal-config'
const CAL_NAMESPACE = 'demo'
+const DEFAULT_CAL_ORIGIN = 'https://app.cal.com'
+const DEFAULT_CAL_LINK = 'team/sim/demo'
-/** Sim's brand color, matching the `--brand-agent` token. */
-const CAL_BRAND_COLOR = '#6f3dfa'
+interface CalEmbedConfig {
+ calLink: string
+ calOrigin: string
+ embedJsUrl: string
+}
-const CAL_IFRAME_READY_EVENT = `CAL:${CAL_NAMESPACE}:__iframeReady`
-const CAL_BOOKING_SUCCESS_EVENT = `CAL:${CAL_NAMESPACE}:bookingSuccessfulV2`
+function parseCalEmbedConfig(link: string): CalEmbedConfig {
+ const url = new URL(link.replace(/^\/+/, ''), `${DEFAULT_CAL_ORIGIN}/`)
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
+ throw new Error('Cal link must use HTTP(S) without embedded credentials')
+ }
-interface DemoSchedulerProps {
- /** The captured lead used to prefill the Cal.com booking. */
- lead: DemoLead
-}
+ const calLink = `${url.pathname.replace(/^\/+/, '')}${url.search}`
+ if (!calLink) throw new Error('Cal link must include an event path')
-interface CalMessage {
- fullType: string
+ return {
+ calLink,
+ calOrigin: url.origin,
+ embedJsUrl: `${url.origin}/embed/embed.js`,
+ }
}
-let calPreloadFrame: HTMLIFrameElement | null = null
-
-function isCalMessage(data: unknown): data is CalMessage {
- if (!data || typeof data !== 'object') return false
- return typeof Reflect.get(data, 'fullType') === 'string'
+/** Resolves the configured booker, falling back safely when the environment value is invalid. */
+export function resolveCalEmbedConfig(configuredLink?: string): CalEmbedConfig {
+ try {
+ return parseCalEmbedConfig(configuredLink?.trim() || DEFAULT_CAL_LINK)
+ } catch {
+ return parseCalEmbedConfig(DEFAULT_CAL_LINK)
+ }
}
+const CAL_EMBED = resolveCalEmbedConfig(process.env.NEXT_PUBLIC_CAL_LINK)
+
/**
- * Creates the same hosted booker URL the former Cal React wrapper generated.
- * Query parameters keep the lead prefill and light, month-view presentation.
+ * Sim's brand color, matching the `--brand-agent` token. The embed renders in a
+ * cross-origin iframe, so it can't read our CSS vars - it needs the literal hex.
*/
-export function createCalEmbedUrl(lead: DemoLead): string {
- const url = createConfiguredCalUrl()
- const normalizedPath = url.pathname.replace(/\/+$/, '')
- url.pathname = normalizedPath.endsWith('/embed') ? normalizedPath : `${normalizedPath}/embed`
- url.searchParams.set('embed', CAL_NAMESPACE)
- url.searchParams.set('name', lead.name)
- url.searchParams.set('email', lead.email)
- url.searchParams.set('notes', lead.notes)
- url.searchParams.set('theme', 'light')
- url.searchParams.set('ui.color-scheme', 'light')
- url.searchParams.set('layout', 'month_view')
- url.searchParams.set('useSlotsViewOnSmallScreen', 'true')
- return url.toString()
-}
+const CAL_BRAND_COLOR = '#6f3dfa'
-function createCalPreloadUrl(): string {
- const url = createConfiguredCalUrl()
- url.searchParams.set('preload', 'true')
- return url.toString()
+interface DemoSchedulerProps {
+ /** The captured lead used to prefill the Cal.com booking. */
+ lead: DemoLead
}
+let calEmbedPreloaded = false
+
/**
- * Warms Cal.com's booker in a hidden hosted iframe on first form focus. The
- * frame remains mounted so its browser cache and connection stay available to
- * the visible scheduler. Repeat calls are idempotent; a failed navigation can
- * be retried by a later focus.
+ * Warm the Cal.com embed before the scheduler mounts. Loads `embed.js` and
+ * issues the embed's `preload` instruction, which fetches the booker in a
+ * hidden `?preload=true` iframe so its assets are already cached when the real
+ * embed renders on submit. Without this, nothing Cal.com-related starts
+ * downloading until the visitor presses Continue, which is why the calendar
+ * used to take several seconds to appear. Idempotent — repeat calls no-op
+ * while a warm-up is in flight or done, but a failed embed.js load resets the
+ * flag so a later focus can retry.
*/
export function preloadCalEmbed(): void {
- if (typeof document === 'undefined' || !document.body || calPreloadFrame?.isConnected) return
-
- const frame = document.createElement('iframe')
- calPreloadFrame = frame
- frame.src = createCalPreloadUrl()
- frame.hidden = true
- frame.tabIndex = -1
- frame.setAttribute('aria-hidden', 'true')
- frame.addEventListener(
- 'error',
- () => {
- frame.remove()
- if (calPreloadFrame === frame) calPreloadFrame = null
- },
- { once: true }
- )
- document.body.append(frame)
+ if (calEmbedPreloaded) return
+ calEmbedPreloaded = true
+ getCalApi({ namespace: CAL_NAMESPACE, embedJsUrl: CAL_EMBED.embedJsUrl })
+ .then((cal) => {
+ cal('preload', { calLink: CAL_EMBED.calLink })
+ })
+ .catch(() => {
+ calEmbedPreloaded = false
+ })
}
/**
- * Step 2 of the booking card - the hosted Cal.com scheduler, prefilled from the
- * form's {@link DemoLead}. It uses Cal's public iframe protocol directly, which
- * keeps the same booker while avoiding a client SDK in the landing bundle.
+ * Step 2 of the booking card - the Cal.com scheduler, prefilled from the form's
+ * {@link DemoLead}. Rendered inside the card chrome owned by {@link DemoBooking}
+ * and lazy-loaded, so the embed script never touches the initial landing bundle.
*
- * The ready handshake applies the prior light theme, hidden event details, and
- * brand color. Booking-success messages are accepted only from this iframe and
- * Cal's expected origin before consent-aware analytics fire.
+ * The embed is pinned to the page's light theme and Sim's brand color, and the
+ * captured name/email/notes prefill the booking so the visitor never retypes. It
+ * fills the panel (`flex-1`), which the parent sizes to the form's height, so the
+ * card stays the same height across the form→calendar transition.
*/
export function DemoScheduler({ lead }: DemoSchedulerProps) {
const { marketing, measurement } = useTrackingConsent()
- const frameRef = useRef(null)
useEffect(() => {
- const handleMessage = (event: MessageEvent) => {
- const frameWindow = frameRef.current?.contentWindow
- if (event.origin !== CAL_ORIGIN || !frameWindow || event.source !== frameWindow) return
- if (!isCalMessage(event.data)) return
-
- if (event.data.fullType === CAL_IFRAME_READY_EVENT) {
- frameWindow.postMessage({ originator: 'CAL', method: 'parentKnowsIframeReady' }, CAL_ORIGIN)
- frameWindow.postMessage(
- {
- originator: 'CAL',
- method: 'ui',
- arg: {
- hideEventTypeDetails: true,
- styles: { branding: { brandColor: CAL_BRAND_COLOR } },
- },
- },
- CAL_ORIGIN
- )
- return
- }
-
- if (event.data.fullType !== CAL_BOOKING_SUCCESS_EVENT) return
+ let cancelled = false
+ const trackDemoBooked = () => {
if (measurement) {
trackGoogleEvent('get_a_demo', {
page_path: '/demo',
@@ -130,9 +104,26 @@ export function DemoScheduler({ lead }: DemoSchedulerProps) {
}
if (marketing) window.twq?.('event', X_DEMO_BOOKED_EVENT_ID, {})
}
-
- window.addEventListener('message', handleMessage)
- return () => window.removeEventListener('message', handleMessage)
+ const api = getCalApi({ namespace: CAL_NAMESPACE, embedJsUrl: CAL_EMBED.embedJsUrl })
+ api
+ .then((cal) => {
+ if (cancelled) return
+ cal('ui', {
+ hideEventTypeDetails: true,
+ styles: { branding: { brandColor: CAL_BRAND_COLOR } },
+ })
+ if (measurement || marketing) {
+ cal('on', { action: 'bookingSuccessfulV2', callback: trackDemoBooked })
+ }
+ })
+ .catch(() => {})
+ return () => {
+ cancelled = true
+ if (!measurement && !marketing) return
+ api
+ .then((cal) => cal('off', { action: 'bookingSuccessfulV2', callback: trackDemoBooked }))
+ .catch(() => {})
+ }
}, [marketing, measurement])
return (
@@ -144,12 +135,21 @@ export function DemoScheduler({ lead }: DemoSchedulerProps) {
Choose a slot that works for your team and we'll send a calendar invite.
-
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
index c4e6ee1439f..3852c0aedf6 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
@@ -15,7 +15,7 @@ import {
} from '@xyflow/react'
import { useParams, useRouter } from 'next/navigation'
import '@xyflow/react/dist/style.css'
-import { toast } from '@sim/emcn'
+import { cn, toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { omit } from '@sim/utils/object'
@@ -33,6 +33,7 @@ import {
getEdgeZIndexForTarget,
getNoteBlockHeight,
normalizeCursorSourceHandleId,
+ useCanvasColorMode,
} from '@sim/workflow-renderer'
import {
normalizeWorkflowEdgeSourceHandle,
@@ -138,7 +139,6 @@ import {
isFolderOrAncestorLocked,
} from '@/hooks/queries/utils/folder-tree'
import { useUpdateWorkflow, useWorkflowMap } from '@/hooks/queries/workflows'
-import { useCanvasColorMode } from '@/hooks/use-canvas-color-mode'
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useOAuthReturnForWorkflow } from '@/hooks/use-oauth-return'
@@ -5207,7 +5207,12 @@ const WorkflowContent = React.memo(
draggable={false}
noWheelClassName='allow-scroll'
edgesFocusable={!embedded}
- className={`workflow-container h-full bg-[var(--bg)] transition-opacity duration-150 ${reactFlowStyles} ${canvasOpacityClass} ${isHandMode ? 'canvas-mode-hand' : 'canvas-mode-cursor'}`}
+ className={cn(
+ 'workflow-container h-full bg-[var(--bg)] transition-opacity duration-150 [--xy-background-color:var(--bg)]',
+ reactFlowStyles,
+ canvasOpacityClass,
+ isHandMode ? 'canvas-mode-hand' : 'canvas-mode-cursor'
+ )}
onNodeDrag={effectivePermissions.canEdit ? onNodeDrag : undefined}
onNodeDragStop={
!embedded && effectivePermissions.canEdit ? onNodeDragStop : undefined
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx
index ddda9bf6688..99107436c98 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx
@@ -25,6 +25,7 @@ import {
EDGE_Z_BASE,
EDGE_Z_MAX,
getEdgeZIndexForTarget,
+ useCanvasColorMode,
} from '@sim/workflow-renderer'
import { normalizeWorkflowEdgeHandles } from '@sim/workflow-types/workflow'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
@@ -35,7 +36,6 @@ import {
import { PreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block'
import { PreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow'
import { useWorkflowMap } from '@/hooks/queries/workflows'
-import { useCanvasColorMode } from '@/hooks/use-canvas-color-mode'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('PreviewWorkflow')
@@ -704,6 +704,7 @@ export function PreviewWorkflow({
: undefined
}
onPaneClick={onPaneClick}
+ className='[--xy-background-color:var(--bg)]'
/>
{
beforeAll(() => {
const store = useProvidersStore.getState()
- store.setProviderModels('base', ['claude-sonnet-4-6', 'claude-sonnet-4-0', 'gpt-5.4'])
+ store.setProviderModels('base', [
+ 'claude-sonnet-4-6',
+ 'claude-opus-4-1',
+ 'claude-sonnet-4-0',
+ 'gpt-5.4',
+ 'cerebras/zai-glm-4.7',
+ 'glm-5.1',
+ 'glm-4.5-air',
+ ])
store.setProviderModels('openrouter', [
'openrouter/openai/gpt-5',
'openrouter/openrouter/fusion',
@@ -43,6 +51,15 @@ describe('Pi model options', () => {
expect(modelIds).not.toContain('claude-sonnet-4-0')
})
+ it('keeps persisted and selectable models available', () => {
+ const modelIds = getPiModelOptions().map(({ id }) => id)
+
+ expect(modelIds).toContain('claude-opus-4-1')
+ expect(modelIds).toContain('cerebras/zai-glm-4.7')
+ expect(modelIds).toContain('glm-5.1')
+ expect(modelIds).toContain('glm-4.5-air')
+ })
+
it("does not apply OpenRouter capability filters beyond Pi's catalog", () => {
const modelIds = getPiModelOptions().map(({ id }) => id)
diff --git a/apps/sim/components/emails/agent/inbox-response-email.tsx b/apps/sim/components/emails/agent/inbox-response-email.tsx
index 739cf22c28f..05322657c83 100644
--- a/apps/sim/components/emails/agent/inbox-response-email.tsx
+++ b/apps/sim/components/emails/agent/inbox-response-email.tsx
@@ -1,5 +1,5 @@
import { type ComponentType, type CSSProperties, createElement, type ReactNode } from 'react'
-import { Body, Head, Html, Link, Markdown, Section, Text } from 'react-email'
+import { Body, Head, Html, Link, Markdown, Section, Text } from '@react-email/components'
import { colors, fontWeight, typography } from '@/components/emails/_styles'
import { getBrandConfig } from '@/ee/whitelabeling'
diff --git a/apps/sim/components/emails/auth/existing-account-email.tsx b/apps/sim/components/emails/auth/existing-account-email.tsx
index 7a09fbb01e4..8c13a578d57 100644
--- a/apps/sim/components/emails/auth/existing-account-email.tsx
+++ b/apps/sim/components/emails/auth/existing-account-email.tsx
@@ -1,4 +1,4 @@
-import { Text } from 'react-email'
+import { Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailButton, EmailLayout } from '@/components/emails/components'
import { getBaseUrl } from '@/lib/core/utils/urls'
diff --git a/apps/sim/components/emails/auth/onboarding-followup-email.tsx b/apps/sim/components/emails/auth/onboarding-followup-email.tsx
index fe1b63c032c..48564ba1c4e 100644
--- a/apps/sim/components/emails/auth/onboarding-followup-email.tsx
+++ b/apps/sim/components/emails/auth/onboarding-followup-email.tsx
@@ -1,4 +1,4 @@
-import { Body, Head, Html, Preview, Text } from 'react-email'
+import { Body, Head, Html, Preview, Text } from '@react-email/components'
import { plainEmailStyles as styles } from '@/components/emails/_styles'
import { getBrandConfig } from '@/ee/whitelabeling'
diff --git a/apps/sim/components/emails/auth/otp-verification-email.tsx b/apps/sim/components/emails/auth/otp-verification-email.tsx
index 454c69d761d..6f4170286a9 100644
--- a/apps/sim/components/emails/auth/otp-verification-email.tsx
+++ b/apps/sim/components/emails/auth/otp-verification-email.tsx
@@ -1,4 +1,4 @@
-import { Section, Text } from 'react-email'
+import { Section, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/ee/whitelabeling'
diff --git a/apps/sim/components/emails/auth/reset-password-email.tsx b/apps/sim/components/emails/auth/reset-password-email.tsx
index f6591275869..ed0b5f27861 100644
--- a/apps/sim/components/emails/auth/reset-password-email.tsx
+++ b/apps/sim/components/emails/auth/reset-password-email.tsx
@@ -1,4 +1,4 @@
-import { Text } from 'react-email'
+import { Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailButton, EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/ee/whitelabeling'
diff --git a/apps/sim/components/emails/auth/welcome-email.tsx b/apps/sim/components/emails/auth/welcome-email.tsx
index 8eab1a2b491..c5050b7128e 100644
--- a/apps/sim/components/emails/auth/welcome-email.tsx
+++ b/apps/sim/components/emails/auth/welcome-email.tsx
@@ -1,4 +1,4 @@
-import { Link, Text } from 'react-email'
+import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailButton, EmailLayout } from '@/components/emails/components'
import { getBaseUrl } from '@/lib/core/utils/urls'
diff --git a/apps/sim/components/emails/billing/abandoned-checkout-email.tsx b/apps/sim/components/emails/billing/abandoned-checkout-email.tsx
index 65ed15b3747..2a0ab9462e2 100644
--- a/apps/sim/components/emails/billing/abandoned-checkout-email.tsx
+++ b/apps/sim/components/emails/billing/abandoned-checkout-email.tsx
@@ -1,4 +1,4 @@
-import { Body, Head, Html, Preview, Text } from 'react-email'
+import { Body, Head, Html, Preview, Text } from '@react-email/components'
import { plainEmailStyles as styles } from '@/components/emails/_styles'
import { getBrandConfig } from '@/ee/whitelabeling'
diff --git a/apps/sim/components/emails/billing/credit-purchase-email.tsx b/apps/sim/components/emails/billing/credit-purchase-email.tsx
index fa1123c06df..55b14677dd3 100644
--- a/apps/sim/components/emails/billing/credit-purchase-email.tsx
+++ b/apps/sim/components/emails/billing/credit-purchase-email.tsx
@@ -1,4 +1,4 @@
-import { Section, Text } from 'react-email'
+import { Section, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
diff --git a/apps/sim/components/emails/billing/credits-exhausted-email.tsx b/apps/sim/components/emails/billing/credits-exhausted-email.tsx
index 271b6d276a2..c81232b8d7a 100644
--- a/apps/sim/components/emails/billing/credits-exhausted-email.tsx
+++ b/apps/sim/components/emails/billing/credits-exhausted-email.tsx
@@ -1,4 +1,4 @@
-import { Text } from 'react-email'
+import { Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { ProFeaturesBox } from '@/components/emails/billing/pro-features-box'
import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components'
diff --git a/apps/sim/components/emails/billing/enterprise-subscription-email.tsx b/apps/sim/components/emails/billing/enterprise-subscription-email.tsx
index 3059266986c..e252e7839ae 100644
--- a/apps/sim/components/emails/billing/enterprise-subscription-email.tsx
+++ b/apps/sim/components/emails/billing/enterprise-subscription-email.tsx
@@ -1,4 +1,4 @@
-import { Link, Section, Text } from 'react-email'
+import { Link, Section, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components'
import { getBaseUrl } from '@/lib/core/utils/urls'
diff --git a/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx b/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx
index 926c8835e00..f34e15aeba0 100644
--- a/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx
+++ b/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx
@@ -1,4 +1,4 @@
-import { Text } from 'react-email'
+import { Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { ProFeaturesBox } from '@/components/emails/billing/pro-features-box'
import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components'
diff --git a/apps/sim/components/emails/billing/limit-threshold-email.tsx b/apps/sim/components/emails/billing/limit-threshold-email.tsx
index adb806ec9eb..f8e1a3d913f 100644
--- a/apps/sim/components/emails/billing/limit-threshold-email.tsx
+++ b/apps/sim/components/emails/billing/limit-threshold-email.tsx
@@ -1,4 +1,4 @@
-import { Section, Text } from 'react-email'
+import { Section, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailButton, EmailLayout } from '@/components/emails/components'
import { UPGRADE_REASON_COPY, type UpgradeReason } from '@/lib/billing/upgrade-reasons'
diff --git a/apps/sim/components/emails/billing/payment-failed-email.tsx b/apps/sim/components/emails/billing/payment-failed-email.tsx
index 355faea467d..e531ae4c376 100644
--- a/apps/sim/components/emails/billing/payment-failed-email.tsx
+++ b/apps/sim/components/emails/billing/payment-failed-email.tsx
@@ -1,4 +1,4 @@
-import { Link, Section, Text } from 'react-email'
+import { Link, Section, Text } from '@react-email/components'
import { baseStyles, colors, fontWeight } from '@/components/emails/_styles'
import { EmailButton, EmailLayout } from '@/components/emails/components'
import { getEmailSubject } from '@/components/emails/subjects'
diff --git a/apps/sim/components/emails/billing/plan-welcome-email.tsx b/apps/sim/components/emails/billing/plan-welcome-email.tsx
index c9a5890f5df..073c359dff8 100644
--- a/apps/sim/components/emails/billing/plan-welcome-email.tsx
+++ b/apps/sim/components/emails/billing/plan-welcome-email.tsx
@@ -1,4 +1,4 @@
-import { Link, Text } from 'react-email'
+import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components'
import { getBaseUrl } from '@/lib/core/utils/urls'
diff --git a/apps/sim/components/emails/billing/pro-features-box.tsx b/apps/sim/components/emails/billing/pro-features-box.tsx
index eae05a6a65d..58671fa82af 100644
--- a/apps/sim/components/emails/billing/pro-features-box.tsx
+++ b/apps/sim/components/emails/billing/pro-features-box.tsx
@@ -1,4 +1,4 @@
-import { Section, Text } from 'react-email'
+import { Section, Text } from '@react-email/components'
import { baseStyles, colors, fontWeight } from '@/components/emails/_styles'
import { proFeatures } from '@/components/emails/billing/constants'
diff --git a/apps/sim/components/emails/billing/usage-limit-reached-email.tsx b/apps/sim/components/emails/billing/usage-limit-reached-email.tsx
index c5d788046ee..aa54b8272ff 100644
--- a/apps/sim/components/emails/billing/usage-limit-reached-email.tsx
+++ b/apps/sim/components/emails/billing/usage-limit-reached-email.tsx
@@ -1,4 +1,4 @@
-import { Section, Text } from 'react-email'
+import { Section, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailButton, EmailLayout } from '@/components/emails/components'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
diff --git a/apps/sim/components/emails/billing/usage-threshold-email.tsx b/apps/sim/components/emails/billing/usage-threshold-email.tsx
index b215fb64498..0157ea251e6 100644
--- a/apps/sim/components/emails/billing/usage-threshold-email.tsx
+++ b/apps/sim/components/emails/billing/usage-threshold-email.tsx
@@ -1,4 +1,4 @@
-import { Section, Text } from 'react-email'
+import { Section, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailButton, EmailLayout } from '@/components/emails/components'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
diff --git a/apps/sim/components/emails/components/email-button.tsx b/apps/sim/components/emails/components/email-button.tsx
index 80bb8710294..c3bc377bae8 100644
--- a/apps/sim/components/emails/components/email-button.tsx
+++ b/apps/sim/components/emails/components/email-button.tsx
@@ -1,4 +1,4 @@
-import { Link, Text } from 'react-email'
+import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
/** `Link` renders an underline by default; the pill owns its own chrome. */
diff --git a/apps/sim/components/emails/components/email-footer.tsx b/apps/sim/components/emails/components/email-footer.tsx
index 95fb0774959..f03a86b222e 100644
--- a/apps/sim/components/emails/components/email-footer.tsx
+++ b/apps/sim/components/emails/components/email-footer.tsx
@@ -1,4 +1,4 @@
-import { Container, Img, Link, Section } from 'react-email'
+import { Container, Img, Link, Section } from '@react-email/components'
import { baseStyles, colors, spacing } from '@/components/emails/_styles'
import { isHosted } from '@/lib/core/config/env-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
diff --git a/apps/sim/components/emails/components/email-layout.tsx b/apps/sim/components/emails/components/email-layout.tsx
index d1dc6c3e060..2b046586afc 100644
--- a/apps/sim/components/emails/components/email-layout.tsx
+++ b/apps/sim/components/emails/components/email-layout.tsx
@@ -1,4 +1,4 @@
-import { Body, Container, Font, Head, Html, Img, Preview, Section } from 'react-email'
+import { Body, Container, Font, Head, Html, Img, Preview, Section } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailFooter } from '@/components/emails/components/email-footer'
import { EMAIL_WORDMARK_SIZE } from '@/lib/branding/wordmark'
diff --git a/apps/sim/components/emails/credential-groups/credential-group-invitation-email.tsx b/apps/sim/components/emails/credential-groups/credential-group-invitation-email.tsx
index 7de6c208aad..0b0e4f04650 100644
--- a/apps/sim/components/emails/credential-groups/credential-group-invitation-email.tsx
+++ b/apps/sim/components/emails/credential-groups/credential-group-invitation-email.tsx
@@ -1,4 +1,4 @@
-import { Link, Text } from 'react-email'
+import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/ee/whitelabeling'
diff --git a/apps/sim/components/emails/credential-groups/render.ts b/apps/sim/components/emails/credential-groups/render.ts
index 60287f20728..f5bc8188fb0 100644
--- a/apps/sim/components/emails/credential-groups/render.ts
+++ b/apps/sim/components/emails/credential-groups/render.ts
@@ -1,4 +1,4 @@
-import { render } from 'react-email'
+import { render } from '@react-email/render'
import { CredentialGroupInvitationEmail } from '@/components/emails/credential-groups/credential-group-invitation-email'
export async function renderCredentialGroupInvitationEmail(params: {
diff --git a/apps/sim/components/emails/invitations/batch-invitation-email.tsx b/apps/sim/components/emails/invitations/batch-invitation-email.tsx
index 397f02a7058..da9d3d57af7 100644
--- a/apps/sim/components/emails/invitations/batch-invitation-email.tsx
+++ b/apps/sim/components/emails/invitations/batch-invitation-email.tsx
@@ -1,5 +1,5 @@
import { Fragment } from 'react'
-import { Section, Text } from 'react-email'
+import { Section, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components'
import { getBrandConfig } from '@/ee/whitelabeling'
diff --git a/apps/sim/components/emails/invitations/enterprise-owner-invitation-email.tsx b/apps/sim/components/emails/invitations/enterprise-owner-invitation-email.tsx
index f4fb3b01f0a..f4a7320d0b5 100644
--- a/apps/sim/components/emails/invitations/enterprise-owner-invitation-email.tsx
+++ b/apps/sim/components/emails/invitations/enterprise-owner-invitation-email.tsx
@@ -1,4 +1,4 @@
-import { Text } from 'react-email'
+import { Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components'
import { getBrandConfig } from '@/ee/whitelabeling'
diff --git a/apps/sim/components/emails/invitations/invitation-email.tsx b/apps/sim/components/emails/invitations/invitation-email.tsx
index 6195d97a678..11dccce7995 100644
--- a/apps/sim/components/emails/invitations/invitation-email.tsx
+++ b/apps/sim/components/emails/invitations/invitation-email.tsx
@@ -1,5 +1,5 @@
+import { Text } from '@react-email/components'
import { createLogger } from '@sim/logger'
-import { Text } from 'react-email'
import { baseStyles } from '@/components/emails/_styles'
import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components'
import { getBaseUrl } from '@/lib/core/utils/urls'
diff --git a/apps/sim/components/emails/invitations/workspace-added-email.tsx b/apps/sim/components/emails/invitations/workspace-added-email.tsx
index 89325800301..4c22c650c0a 100644
--- a/apps/sim/components/emails/invitations/workspace-added-email.tsx
+++ b/apps/sim/components/emails/invitations/workspace-added-email.tsx
@@ -1,4 +1,4 @@
-import { Text } from 'react-email'
+import { Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components'
import { getBrandConfig } from '@/ee/whitelabeling'
diff --git a/apps/sim/components/emails/invitations/workspace-invitation-email.tsx b/apps/sim/components/emails/invitations/workspace-invitation-email.tsx
index aeb7b8e9c40..5ad0a4156cd 100644
--- a/apps/sim/components/emails/invitations/workspace-invitation-email.tsx
+++ b/apps/sim/components/emails/invitations/workspace-invitation-email.tsx
@@ -1,4 +1,4 @@
-import { Text } from 'react-email'
+import { Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components'
import { getBrandConfig } from '@/ee/whitelabeling'
diff --git a/apps/sim/components/emails/notifications/schedule-disabled-email.tsx b/apps/sim/components/emails/notifications/schedule-disabled-email.tsx
index cf5cc2739f3..8c8cb60ae40 100644
--- a/apps/sim/components/emails/notifications/schedule-disabled-email.tsx
+++ b/apps/sim/components/emails/notifications/schedule-disabled-email.tsx
@@ -1,4 +1,4 @@
-import { Section, Text } from 'react-email'
+import { Section, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailButton, EmailLayout } from '@/components/emails/components'
import {
diff --git a/apps/sim/components/emails/notifications/subprocessor-change-email.tsx b/apps/sim/components/emails/notifications/subprocessor-change-email.tsx
index a64c2921796..fbb02da0a9e 100644
--- a/apps/sim/components/emails/notifications/subprocessor-change-email.tsx
+++ b/apps/sim/components/emails/notifications/subprocessor-change-email.tsx
@@ -1,4 +1,4 @@
-import { Link, Section, Text } from 'react-email'
+import { Link, Section, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components'
import { getBrandConfig } from '@/ee/whitelabeling'
diff --git a/apps/sim/components/emails/render.ts b/apps/sim/components/emails/render.ts
index b30374850e6..6f6e74bd1a3 100644
--- a/apps/sim/components/emails/render.ts
+++ b/apps/sim/components/emails/render.ts
@@ -1,4 +1,4 @@
-import { render } from 'react-email'
+import { render } from '@react-email/render'
import { InboxErrorEmail, InboxResponseEmail } from '@/components/emails/agent/inbox-response-email'
import {
ExistingAccountEmail,
diff --git a/apps/sim/components/emails/support/help-confirmation-email.tsx b/apps/sim/components/emails/support/help-confirmation-email.tsx
index 27bc617e2b3..ebf0ed68e9c 100644
--- a/apps/sim/components/emails/support/help-confirmation-email.tsx
+++ b/apps/sim/components/emails/support/help-confirmation-email.tsx
@@ -1,5 +1,5 @@
+import { Text } from '@react-email/components'
import { format } from 'date-fns'
-import { Text } from 'react-email'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout, EmailStrong } from '@/components/emails/components'
diff --git a/apps/sim/executor/handlers/pi/core/pi-sdk.test.ts b/apps/sim/executor/handlers/pi/core/pi-sdk.test.ts
index e5644c94271..59da66ee33c 100644
--- a/apps/sim/executor/handlers/pi/core/pi-sdk.test.ts
+++ b/apps/sim/executor/handlers/pi/core/pi-sdk.test.ts
@@ -21,9 +21,7 @@ describe('createSealedPiResourceLoader', () => {
})
expect(loader.getAgentsFiles()).toEqual({ agentsFiles: [] })
expect(loader.getSystemPrompt()).toBe('sealed system prompt')
- expect(loader.getSystemPromptSource()).toBeUndefined()
expect(loader.getAppendSystemPrompt()).toEqual([])
- expect(loader.getAppendSystemPromptSources()).toEqual([])
await expect(loader.reload()).resolves.toBeUndefined()
})
})
diff --git a/apps/sim/executor/handlers/pi/core/pi-sdk.ts b/apps/sim/executor/handlers/pi/core/pi-sdk.ts
index 317f3b618ff..30e43c61b54 100644
--- a/apps/sim/executor/handlers/pi/core/pi-sdk.ts
+++ b/apps/sim/executor/handlers/pi/core/pi-sdk.ts
@@ -90,9 +90,7 @@ export function createSealedPiResourceLoader(sdk: PiSdk, systemPrompt: string):
getThemes: () => ({ themes: [], diagnostics: [] }),
getAgentsFiles: () => ({ agentsFiles: [] }),
getSystemPrompt: () => systemPrompt,
- getSystemPromptSource: () => undefined,
getAppendSystemPrompt: () => [],
- getAppendSystemPromptSources: () => [],
extendResources: () => {},
reload: async () => {},
}
diff --git a/apps/sim/executor/handlers/pi/pi-handler.test.ts b/apps/sim/executor/handlers/pi/pi-handler.test.ts
index d6dcc62fb41..ff299764613 100644
--- a/apps/sim/executor/handlers/pi/pi-handler.test.ts
+++ b/apps/sim/executor/handlers/pi/pi-handler.test.ts
@@ -267,6 +267,19 @@ describe('PiBlockHandler', () => {
expect(mockResolveKey).not.toHaveBeenCalled()
})
+ it('passes a persisted catalog model to the backend', async () => {
+ mockGetProviderFromModel.mockReturnValue('cerebras')
+ mockResolvePiModelId.mockReturnValue('zai-glm-4.7')
+
+ await handler.execute(ctx(), block, localInputs({ model: 'cerebras/zai-glm-4.7' }))
+
+ expect(mockRunLocal.mock.calls[0][0]).toMatchObject({
+ model: 'cerebras/zai-glm-4.7',
+ piModel: 'zai-glm-4.7',
+ providerId: 'cerebras',
+ })
+ })
+
it('routes Local Dev to the local backend with SSH params', async () => {
const output = await handler.execute(ctx(), block, localInputs())
expect(mockRunLocal).toHaveBeenCalledTimes(1)
diff --git a/apps/sim/hooks/use-canvas-color-mode.ts b/apps/sim/hooks/use-canvas-color-mode.ts
deleted file mode 100644
index 2424443c59b..00000000000
--- a/apps/sim/hooks/use-canvas-color-mode.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import type { ColorMode } from '@xyflow/react'
-import { useTheme } from 'next-themes'
-
-/**
- * Resolves the React Flow `colorMode` from the application theme.
- *
- * React Flow v12 stamps its color-mode class (`light` by default) onto the
- * `.react-flow` wrapper. The app's Tailwind `dark` variant is defined as
- * `&:where(.dark, .dark *):not(:where(.light, .light *))`, so an unmanaged
- * `light` class on the wrapper cancels every `dark:` utility inside the
- * canvas. Deriving the mode from the resolved app theme keeps the wrapper
- * class in agreement with the `.dark`/`.light` layer on ``.
- */
-export function useCanvasColorMode(): ColorMode {
- const { resolvedTheme } = useTheme()
- // Before next-themes mounts, resolvedTheme is undefined; 'system' lets React
- // Flow follow the OS preference instead of flashing a light-classed frame.
- if (resolvedTheme === undefined) return 'system'
- return resolvedTheme === 'dark' ? 'dark' : 'light'
-}
diff --git a/apps/sim/lib/audio/extractor.test.ts b/apps/sim/lib/audio/extractor.test.ts
index 0bbef0fdeee..a0d95a05bda 100644
--- a/apps/sim/lib/audio/extractor.test.ts
+++ b/apps/sim/lib/audio/extractor.test.ts
@@ -6,7 +6,14 @@ import path from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { calls, probe } = vi.hoisted(() => ({
- calls: [] as Array<{ executable: string; args: string[]; maxBuffer?: number; timeout?: number }>,
+ calls: [] as Array<{
+ executable: string
+ args: string[]
+ maxBuffer?: number
+ outputSize?: number
+ signal?: AbortSignal
+ timeout?: number
+ }>,
probe: {
fail: false,
json: JSON.stringify({
@@ -20,6 +27,7 @@ const { calls, probe } = vi.hoisted(() => ({
],
format: { bit_rate: '192000', duration: '7.5', format_name: 'mov,mp4,m4a' },
}),
+ outputSize: undefined as number | undefined,
},
}))
@@ -29,10 +37,16 @@ vi.mock('node:child_process', () => ({
execFile: (
executable: string,
args: string[],
- options: { maxBuffer?: number; timeout?: number },
+ options: { maxBuffer?: number; signal?: AbortSignal; timeout?: number },
callback: (error: Error | null, stdout: string, stderr: string) => void
) => {
- calls.push({ executable, args, maxBuffer: options.maxBuffer, timeout: options.timeout })
+ calls.push({
+ executable,
+ args,
+ maxBuffer: options.maxBuffer,
+ signal: options.signal,
+ timeout: options.timeout,
+ })
if (executable.includes('ffprobe')) {
if (probe.fail) {
callback(new Error('invalid media'), '', 'invalid media')
@@ -43,6 +57,9 @@ vi.mock('node:child_process', () => ({
}
fs.writeFileSync(args.at(-1) as string, Buffer.from('converted-audio'))
+ if (probe.outputSize !== undefined) {
+ fs.truncateSync(args.at(-1) as string, probe.outputSize)
+ }
callback(null, '', '')
},
}))
@@ -52,6 +69,7 @@ import { extractAudioFromVideo, getAudioMetadata } from '@/lib/audio/extractor'
beforeEach(() => {
calls.length = 0
probe.fail = false
+ probe.outputSize = undefined
})
describe('audio FFmpeg execution', () => {
@@ -123,6 +141,26 @@ describe('audio FFmpeg execution', () => {
expect(conversion).toMatchObject({ maxBuffer: 4 * 1024 * 1024, timeout: 600_000 })
})
+ it('forwards cancellation to both probing and conversion', async () => {
+ const controller = new AbortController()
+
+ await extractAudioFromVideo(Buffer.from('video'), 'video/mp4', {
+ outputFormat: 'mp3',
+ signal: controller.signal,
+ })
+
+ expect(calls).toHaveLength(2)
+ expect(calls.every((call) => call.signal === controller.signal)).toBe(true)
+ })
+
+ it('rejects oversized converted output before reading it into memory', async () => {
+ probe.outputSize = 250 * 1024 * 1024 + 1
+
+ await expect(
+ extractAudioFromVideo(Buffer.from('video'), 'video/mp4', { outputFormat: 'mp3' })
+ ).rejects.toThrow(/FFmpeg audio output exceeds maximum size/)
+ })
+
it('returns an existing audio buffer when metadata probing fails', async () => {
probe.fail = true
const buffer = Buffer.from('already-audio')
diff --git a/apps/sim/lib/audio/extractor.ts b/apps/sim/lib/audio/extractor.ts
index 9c07a45d367..cdda8e06368 100644
--- a/apps/sim/lib/audio/extractor.ts
+++ b/apps/sim/lib/audio/extractor.ts
@@ -8,6 +8,8 @@ import type {
AudioExtractionResult,
AudioMetadata,
} from '@/lib/audio/types'
+import { assertKnownSizeWithinLimit } from '@/lib/core/utils/stream-limits'
+import { MAX_MEDIA_BYTES } from '@/lib/media/falai'
import { FFMPEG_BASE_ARGS, resolveExecutable, runExecutable } from '@/lib/media/ffmpeg-process'
const logger = createLogger('AudioExtractor')
@@ -55,13 +57,15 @@ async function withTempDir(prefix: string, fn: (dir: string) => Promise):
}
}
-async function runFfmpeg(args: string[]): Promise {
+async function runFfmpeg(args: string[], signal?: AbortSignal): Promise {
try {
await runExecutable(requireFfmpeg(), [...FFMPEG_BASE_ARGS, ...args], {
maxOutputBytes: MAX_PROCESS_OUTPUT_BYTES,
+ signal,
timeoutMs: CONVERSION_TIMEOUT_MS,
})
} catch (error) {
+ signal?.throwIfAborted()
const failure = error as NodeJS.ErrnoException & { killed?: boolean; stderr?: string }
if (failure.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') {
throw new Error('FFmpeg error: process output was too large to read')
@@ -84,7 +88,7 @@ export async function extractAudioFromVideo(
if (isAudio && !options.outputFormat) {
try {
- const metadata = await getAudioMetadata(inputBuffer, mimeType)
+ const metadata = await getAudioMetadata(inputBuffer, mimeType, options.signal)
return {
buffer: inputBuffer,
format: mimeType.split('/')[1] || 'unknown',
@@ -92,6 +96,7 @@ export async function extractAudioFromVideo(
size: inputBuffer.length,
}
} catch {
+ options.signal?.throwIfAborted()
return {
buffer: inputBuffer,
format: mimeType.split('/')[1] || 'unknown',
@@ -124,12 +129,13 @@ async function convertAudioWithFfmpeg(
return withTempDir('audio-ffmpeg-', async (dir) => {
const inputFile = path.join(dir, `input.${inputExt}`)
const outputFile = path.join(dir, `output.${outputFormat}`)
- await fs.writeFile(inputFile, inputBuffer)
+ await fs.writeFile(inputFile, inputBuffer, { signal: options.signal })
let duration = 0
try {
- duration = (await getAudioMetadataFromFile(inputFile)).duration || 0
+ duration = (await getAudioMetadataFromFile(inputFile, options.signal)).duration || 0
} catch (error) {
+ options.signal?.throwIfAborted()
logger.warn('Failed to extract metadata:', error)
}
@@ -139,8 +145,11 @@ async function convertAudioWithFfmpeg(
if (options.bitrate) args.push('-b:a', options.bitrate.replace(/k?$/, 'k'))
args.push(outputFile)
- await runFfmpeg(args)
- const outputBuffer = await fs.readFile(outputFile)
+ await runFfmpeg(args, options.signal)
+ options.signal?.throwIfAborted()
+ const { size } = await fs.stat(outputFile)
+ assertKnownSizeWithinLimit(size, MAX_MEDIA_BYTES, 'FFmpeg audio output')
+ const outputBuffer = await fs.readFile(outputFile, { signal: options.signal })
return {
buffer: outputBuffer,
@@ -152,12 +161,16 @@ async function convertAudioWithFfmpeg(
}
/** Read audio metadata with ffprobe. */
-export async function getAudioMetadata(buffer: Buffer, mimeType: string): Promise {
+export async function getAudioMetadata(
+ buffer: Buffer,
+ mimeType: string,
+ signal?: AbortSignal
+): Promise {
const inputExt = getExtensionFromMimeType(mimeType)
return withTempDir('audio-ffprobe-', async (dir) => {
const inputFile = path.join(dir, `input.${inputExt}`)
- await fs.writeFile(inputFile, buffer)
- return getAudioMetadataFromFile(inputFile)
+ await fs.writeFile(inputFile, buffer, { signal })
+ return getAudioMetadataFromFile(inputFile, signal)
})
}
@@ -176,7 +189,10 @@ interface FfprobeMetadata {
}
}
-async function getAudioMetadataFromFile(filePath: string): Promise {
+async function getAudioMetadataFromFile(
+ filePath: string,
+ signal?: AbortSignal
+): Promise {
let stdout: string
try {
;({ stdout } = await runExecutable(
@@ -184,10 +200,12 @@ async function getAudioMetadataFromFile(filePath: string): Promise
+>[0]
const initializeGoogleAnalytics = GOOGLE_ANALYTICS_SCRIPT.onBeforeLoad
function withoutQueryOrHash(value: string): string | undefined {
@@ -34,7 +36,7 @@ function withoutQueryOrHash(value: string): string | undefined {
export const GLOBAL_CONSENT_SCRIPTS = [
{
...GOOGLE_ANALYTICS_SCRIPT,
- onBeforeLoad: (info: ScriptCallbackInfo) => {
+ onBeforeLoad: (info: ConsentScriptCallbackInfo) => {
window.dataLayer ||= []
window.gtag ||= (...args: unknown[]) => {
window.dataLayer.push(args)
diff --git a/apps/sim/lib/credential-groups/standard-oauth-provider.ts b/apps/sim/lib/credential-groups/standard-oauth-provider.ts
index c9e7c85c2e7..a87fdae1ed3 100644
--- a/apps/sim/lib/credential-groups/standard-oauth-provider.ts
+++ b/apps/sim/lib/credential-groups/standard-oauth-provider.ts
@@ -1,11 +1,11 @@
import { randomBytes } from 'node:crypto'
+import { normalizeEmail } from '@sim/utils/string'
import {
applyDefaultAccessTokenExpiry,
createAuthorizationURL,
type OAuth2Tokens,
validateAuthorizationCode,
-} from '@better-auth/core/oauth2'
-import { normalizeEmail } from '@sim/utils/string'
+} from 'better-auth/oauth2'
import {
type ConnectorProviderConfig,
getManagedOAuthConnectorProviderConfig,
diff --git a/apps/sim/lib/file-parsers/officeparser-module.test.ts b/apps/sim/lib/file-parsers/officeparser-module.test.ts
index 2e61cc40e8d..7a94d3759fa 100644
--- a/apps/sim/lib/file-parsers/officeparser-module.test.ts
+++ b/apps/sim/lib/file-parsers/officeparser-module.test.ts
@@ -20,6 +20,10 @@ describe('resolveParseOfficeAsync', () => {
expect(resolveParseOfficeAsync({ default: { parseOfficeAsync: parse } })).toBe(parse)
})
+ it('resolves a callable default export', () => {
+ expect(resolveParseOfficeAsync({ default: parse })).toBe(parse)
+ })
+
it('throws when the entry point is absent', () => {
expect(() => resolveParseOfficeAsync({})).toThrow('did not expose parseOfficeAsync')
})
diff --git a/apps/sim/lib/file-parsers/pdf-parser-cancellation.test.ts b/apps/sim/lib/file-parsers/pdf-parser-cancellation.test.ts
new file mode 100644
index 00000000000..cef7ef6550f
--- /dev/null
+++ b/apps/sim/lib/file-parsers/pdf-parser-cancellation.test.ts
@@ -0,0 +1,138 @@
+/**
+ * @vitest-environment node
+ */
+import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist/types/src/pdf'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockOpenPdfDocument } = vi.hoisted(() => ({
+ mockOpenPdfDocument: vi.fn(),
+}))
+
+vi.mock('@/lib/file-parsers/pdfjs-server', () => ({
+ openPdfDocument: mockOpenPdfDocument,
+}))
+
+import { PdfParser } from '@/lib/file-parsers/pdf-parser'
+
+describe('PdfParser cancellation', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('rejects an already-cancelled parse before opening pdf.js', async () => {
+ const controller = new AbortController()
+ controller.abort()
+
+ await expect(
+ new PdfParser().parseBuffer(Buffer.from('%PDF-1.4'), { signal: controller.signal })
+ ).rejects.toMatchObject({ name: 'AbortError' })
+ expect(mockOpenPdfDocument).not.toHaveBeenCalled()
+ })
+
+ it('forwards cancellation while pdf.js is opening', async () => {
+ const controller = new AbortController()
+ mockOpenPdfDocument.mockImplementationOnce(
+ (_data: Uint8Array, signal?: AbortSignal) =>
+ new Promise((_resolve, reject) => {
+ signal?.addEventListener('abort', () => reject(signal.reason), { once: true })
+ })
+ )
+
+ const parsing = new PdfParser().parseBuffer(Buffer.from('%PDF-1.4'), {
+ signal: controller.signal,
+ })
+ await vi.waitFor(() => expect(mockOpenPdfDocument).toHaveBeenCalledOnce())
+ controller.abort()
+
+ await expect(parsing).rejects.toMatchObject({ name: 'AbortError' })
+ expect(mockOpenPdfDocument).toHaveBeenCalledWith(expect.any(Uint8Array), controller.signal)
+ })
+
+ it('cancels a pending text reader and releases page and document state', async () => {
+ const reader = {
+ cancel: vi.fn().mockResolvedValue(undefined),
+ read: vi.fn(() => new Promise(() => {})),
+ }
+ const page = {
+ cleanup: vi.fn(),
+ streamTextContent: vi.fn(() => ({ getReader: () => reader })),
+ } as PDFPageProxy
+ const pdf = {
+ destroy: vi.fn().mockResolvedValue(undefined),
+ getPage: vi.fn().mockResolvedValue(page),
+ numPages: 1,
+ } as PDFDocumentProxy
+ mockOpenPdfDocument.mockResolvedValueOnce(pdf)
+ const controller = new AbortController()
+
+ const parsing = new PdfParser().parseBuffer(Buffer.from('%PDF-1.4'), {
+ signal: controller.signal,
+ })
+ await vi.waitFor(() => expect(reader.read).toHaveBeenCalledOnce())
+ controller.abort()
+
+ await expect(parsing).rejects.toMatchObject({ name: 'AbortError' })
+ expect(reader.cancel).toHaveBeenCalledOnce()
+ expect(page.cleanup).toHaveBeenCalledOnce()
+ expect(pdf.destroy).toHaveBeenCalledOnce()
+ })
+
+ it('cancels a stalled text reader at the extraction deadline and returns a partial result', async () => {
+ vi.useFakeTimers()
+ const reader = {
+ cancel: vi.fn().mockResolvedValue(undefined),
+ read: vi
+ .fn()
+ .mockResolvedValueOnce({ value: { items: [{ str: 'partial page text' }] }, done: false })
+ .mockImplementation(() => new Promise(() => {})),
+ }
+ const page = {
+ cleanup: vi.fn(),
+ streamTextContent: vi.fn(() => ({ getReader: () => reader })),
+ } as PDFPageProxy
+ const pdf = {
+ destroy: vi.fn().mockResolvedValue(undefined),
+ getPage: vi.fn().mockResolvedValue(page),
+ numPages: 1,
+ } as PDFDocumentProxy
+ mockOpenPdfDocument.mockResolvedValueOnce(pdf)
+
+ const parsing = new PdfParser().parseBuffer(Buffer.from('%PDF-1.4'))
+ await vi.advanceTimersByTimeAsync(0)
+ expect(reader.read).toHaveBeenCalledTimes(2)
+
+ await vi.advanceTimersByTimeAsync(60_000)
+ const result = await parsing
+
+ expect(result.metadata).toMatchObject({ pageCount: 1, truncated: true })
+ expect(result.content).toContain('partial page text')
+ expect(result.content).toMatch(/PDF text truncated at parser limits/)
+ expect(reader.cancel).toHaveBeenCalledOnce()
+ expect(page.cleanup).toHaveBeenCalledOnce()
+ expect(pdf.destroy).toHaveBeenCalledOnce()
+ })
+
+ it('stops at the extraction deadline when loading a page stalls', async () => {
+ vi.useFakeTimers()
+ const pdf = {
+ destroy: vi.fn().mockResolvedValue(undefined),
+ getPage: vi.fn(() => new Promise(() => {})),
+ numPages: 1,
+ } as PDFDocumentProxy
+ mockOpenPdfDocument.mockResolvedValueOnce(pdf)
+
+ const parsing = new PdfParser().parseBuffer(Buffer.from('%PDF-1.4'))
+ await vi.advanceTimersByTimeAsync(0)
+ expect(pdf.getPage).toHaveBeenCalledOnce()
+
+ await vi.advanceTimersByTimeAsync(60_000)
+ const result = await parsing
+
+ expect(result.metadata).toMatchObject({ pageCount: 1, truncated: true })
+ expect(pdf.destroy).toHaveBeenCalledOnce()
+ })
+})
diff --git a/apps/sim/lib/file-parsers/pdf-parser.test.ts b/apps/sim/lib/file-parsers/pdf-parser.test.ts
index d411789cd84..b1f352223c5 100644
--- a/apps/sim/lib/file-parsers/pdf-parser.test.ts
+++ b/apps/sim/lib/file-parsers/pdf-parser.test.ts
@@ -4,6 +4,7 @@
import { deflateSync } from 'zlib'
import { describe, expect, it } from 'vitest'
import { MAX_PDF_TEXT_CHARS, PdfParser } from '@/lib/file-parsers/pdf-parser'
+import { openPdfDocument } from '@/lib/file-parsers/pdfjs-server'
/**
* Builds a single-page PDF that draws 64 characters per repeat from a
@@ -98,6 +99,27 @@ function assemblePdf(objects: Buffer[], trailerEntries = ''): Buffer {
}
describe('PdfParser', () => {
+ it('preloads the server worker instead of relying on a runtime-relative worker path', async () => {
+ const previousWorker: unknown = Reflect.get(globalThis, 'pdfjsWorker')
+ Reflect.deleteProperty(globalThis, 'pdfjsWorker')
+
+ const pdf = await openPdfDocument(new Uint8Array(buildTextFreePdf(1)))
+
+ try {
+ expect(Reflect.get(globalThis, 'pdfjsWorker')).toEqual({
+ WorkerMessageHandler: expect.anything(),
+ })
+ expect(pdf.numPages).toBe(1)
+ } finally {
+ await pdf.destroy()
+ if (previousWorker === undefined) {
+ Reflect.deleteProperty(globalThis, 'pdfjsWorker')
+ } else {
+ Reflect.set(globalThis, 'pdfjsWorker', previousWorker)
+ }
+ }
+ })
+
it('bounds extracted text from a compression-bomb PDF instead of exhausting the heap', async () => {
const bomb = buildTextBombPdf(200_000)
expect(bomb.length).toBeLessThan(200 * 1024)
diff --git a/apps/sim/lib/file-parsers/pdf-parser.ts b/apps/sim/lib/file-parsers/pdf-parser.ts
index bc629433dbc..e4106b19f9e 100644
--- a/apps/sim/lib/file-parsers/pdf-parser.ts
+++ b/apps/sim/lib/file-parsers/pdf-parser.ts
@@ -2,7 +2,7 @@ import { readFile } from 'fs/promises'
import { createLogger } from '@sim/logger'
import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist/types/src/pdf'
import { openPdfDocument } from '@/lib/file-parsers/pdfjs-server'
-import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
+import type { FileParseOptions, FileParseResult, FileParser } from '@/lib/file-parsers/types'
import { sanitizeTextForUTF8, truncationNotice } from '@/lib/file-parsers/utils'
const logger = createLogger('PdfParser')
@@ -21,6 +21,7 @@ export const MAX_PDF_TEXT_CHARS = 10_000_000
const PDF_EXTRACTION_TIMEOUT_MS = 60_000
const PDF_TRUNCATION_WARNING = 'PDF text extraction stopped at a parser limit and is incomplete'
+const PDF_READ_DEADLINE_REACHED = Symbol('PDF_READ_DEADLINE_REACHED')
/** Stable metadata identifier retained for documents indexed before the parser swap. */
const PDF_PARSER_SOURCE = 'unpdf'
@@ -47,6 +48,69 @@ interface BoundedExtraction {
truncated: boolean
}
+function waitForAbort(
+ operation: Promise,
+ signal?: AbortSignal,
+ onAbort?: () => void
+): Promise {
+ if (!signal) return operation
+
+ try {
+ signal.throwIfAborted()
+ } catch (error) {
+ onAbort?.()
+ return Promise.reject(error)
+ }
+
+ return new Promise((resolve, reject) => {
+ const cleanup = () => signal.removeEventListener('abort', handleAbort)
+ const handleAbort = () => {
+ cleanup()
+ onAbort?.()
+ reject(signal.reason)
+ }
+
+ signal.addEventListener('abort', handleAbort, { once: true })
+ if (signal.aborted) handleAbort()
+ operation.then(
+ (value) => {
+ cleanup()
+ resolve(value)
+ },
+ (error: unknown) => {
+ cleanup()
+ reject(error)
+ }
+ )
+ })
+}
+
+function waitForDeadline(
+ operation: Promise,
+ deadline: number,
+ signal: AbortSignal | undefined,
+ onDeadline: () => void,
+ onAbort: () => void
+): Promise {
+ const remainingMs = deadline - Date.now()
+ if (remainingMs <= 0) {
+ onDeadline()
+ return Promise.resolve(PDF_READ_DEADLINE_REACHED)
+ }
+
+ let timeoutId: ReturnType | undefined
+ const deadlineReached = new Promise((resolve) => {
+ timeoutId = setTimeout(() => {
+ resolve(PDF_READ_DEADLINE_REACHED)
+ onDeadline()
+ }, remainingMs)
+ })
+
+ return waitForAbort(Promise.race([operation, deadlineReached]), signal, onAbort).finally(() => {
+ if (timeoutId !== undefined) clearTimeout(timeoutId)
+ })
+}
+
/**
* Reads one page's text through pdf.js's streaming API, stopping once the
* character budget or the deadline is spent.
@@ -61,8 +125,10 @@ interface BoundedExtraction {
async function readPageWithinBudget(
page: PDFPageProxy,
budget: number,
- deadline: number
+ deadline: number,
+ signal?: AbortSignal
): Promise {
+ signal?.throwIfAborted()
const reader = page
.streamTextContent()
.getReader() as ReadableStreamDefaultReader
@@ -71,14 +137,33 @@ async function readPageWithinBudget(
let remaining = budget
let completed = false
let dropped = false
+ let deadlineReached = false
+ let cancellation: Promise | undefined
+
+ const cancelReader = (reason: unknown): Promise => {
+ cancellation ??= reader.cancel(reason).catch(() => {})
+ return cancellation
+ }
try {
/**
* Loops until content is actually dropped rather than until the budget hits
* zero: text that ends exactly on the budget is complete, not truncated.
*/
- while (!dropped && Date.now() <= deadline) {
- const { value, done } = await reader.read()
+ while (!dropped) {
+ const result = await waitForDeadline(
+ reader.read(),
+ deadline,
+ signal,
+ () => void cancelReader(new Error('PDF text extraction deadline exceeded')),
+ () => void cancelReader(signal?.reason)
+ )
+ if (result === PDF_READ_DEADLINE_REACHED) {
+ deadlineReached = true
+ break
+ }
+
+ const { value, done } = result
if (done) {
completed = true
break
@@ -101,19 +186,18 @@ async function readPageWithinBudget(
}
} finally {
if (!completed) {
- try {
- await reader.cancel(new Error('PDF text extraction budget exceeded'))
- } catch {
- // Cancelling a stream that already failed is not itself an error, and
- // throwing here would mask whatever ended the read loop.
- }
+ const pendingCancellation = cancelReader(new Error('PDF text extraction budget exceeded'))
+ if (!signal?.aborted && !deadlineReached) await pendingCancellation
}
}
return { text: parts.join(''), used: budget - remaining, completed }
}
-async function extractTextWithinBudget(pdf: PDFDocumentProxy): Promise {
+async function extractTextWithinBudget(
+ pdf: PDFDocumentProxy,
+ signal?: AbortSignal
+): Promise {
const deadline = Date.now() + PDF_EXTRACTION_TIMEOUT_MS
const totalPages = pdf.numPages
const pageLimit = Math.min(totalPages, MAX_PDF_PAGES)
@@ -123,8 +207,32 @@ async function extractTextWithinBudget(pdf: PDFDocumentProxy): Promise pageLimit
for (let pageNumber = 1; pageNumber <= pageLimit; pageNumber++) {
- const page = await pdf.getPage(pageNumber)
- const { text, used, completed } = await readPageWithinBudget(page, remainingChars, deadline)
+ signal?.throwIfAborted()
+ const pagePromise = pdf.getPage(pageNumber)
+ const cleanupLatePage = () => {
+ void pagePromise.then((latePage) => latePage.cleanup()).catch(() => {})
+ }
+ const pageResult = await waitForDeadline(
+ pagePromise,
+ deadline,
+ signal,
+ cleanupLatePage,
+ cleanupLatePage
+ )
+ if (pageResult === PDF_READ_DEADLINE_REACHED) {
+ truncated = true
+ break
+ }
+
+ const page = pageResult
+ let extraction: PageExtraction
+ try {
+ extraction = await readPageWithinBudget(page, remainingChars, deadline, signal)
+ } finally {
+ page.cleanup()
+ }
+
+ const { text, used, completed } = extraction
remainingChars -= used
@@ -133,7 +241,6 @@ async function extractTextWithinBudget(pdf: PDFDocumentProxy): Promise 0) {
pageTexts.push(text)
}
- page.cleanup()
if (!completed) {
truncated = true
@@ -150,7 +257,7 @@ async function extractTextWithinBudget(pdf: PDFDocumentProxy): Promise {
+ async parseFile(filePath: string, options: FileParseOptions = {}): Promise {
try {
logger.info('Starting to parse file:', filePath)
@@ -159,25 +266,29 @@ export class PdfParser implements FileParser {
}
logger.info('Reading file...')
- const dataBuffer = await readFile(filePath)
+ const dataBuffer = await readFile(filePath, { signal: options.signal })
logger.info('File read successfully, size:', dataBuffer.length)
- return this.parseBuffer(dataBuffer)
+ return this.parseBuffer(dataBuffer, options)
} catch (error) {
logger.error('Error reading file:', error)
throw error
}
}
- async parseBuffer(dataBuffer: Buffer): Promise {
+ async parseBuffer(dataBuffer: Buffer, options: FileParseOptions = {}): Promise {
try {
+ options.signal?.throwIfAborted()
logger.info('Starting to parse buffer, size:', dataBuffer.length)
const uint8Array = new Uint8Array(dataBuffer)
- const pdf = await openPdfDocument(uint8Array)
+ const pdf = await openPdfDocument(uint8Array, options.signal)
try {
- const { text, totalPages, pagesRead, truncated } = await extractTextWithinBudget(pdf)
+ const { text, totalPages, pagesRead, truncated } = await extractTextWithinBudget(
+ pdf,
+ options.signal
+ )
logger.info('PDF parsed successfully, pages:', totalPages, 'text length:', text.length)
diff --git a/apps/sim/lib/file-parsers/pdfjs-server.test.ts b/apps/sim/lib/file-parsers/pdfjs-server.test.ts
new file mode 100644
index 00000000000..768493d79c1
--- /dev/null
+++ b/apps/sim/lib/file-parsers/pdfjs-server.test.ts
@@ -0,0 +1,47 @@
+/**
+ * @vitest-environment node
+ */
+import type { PDFDocumentLoadingTask } from 'pdfjs-dist/types/src/pdf'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockGetDocument, workerMessageHandler } = vi.hoisted(() => ({
+ mockGetDocument: vi.fn(),
+ workerMessageHandler: {},
+}))
+
+vi.mock('pdfjs-dist/legacy/build/pdf.mjs', () => ({ getDocument: mockGetDocument }))
+vi.mock('pdfjs-dist/legacy/build/pdf.worker.mjs', () => ({
+ WorkerMessageHandler: workerMessageHandler,
+}))
+
+import { openPdfDocument } from '@/lib/file-parsers/pdfjs-server'
+
+describe('openPdfDocument', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('destroys a pending loading task immediately when parsing is cancelled', async () => {
+ let resolveLoading: ((pdf: { destroy: () => Promise }) => void) | undefined
+ const lateDocumentDestroy = vi.fn().mockResolvedValue(undefined)
+ const destroy = vi.fn().mockResolvedValue(undefined)
+ const loadingTask = {
+ destroy,
+ promise: new Promise((resolve) => {
+ resolveLoading = resolve
+ }),
+ } as PDFDocumentLoadingTask
+ mockGetDocument.mockReturnValueOnce(loadingTask)
+ const controller = new AbortController()
+
+ const opening = openPdfDocument(new Uint8Array([1, 2, 3]), controller.signal)
+ await vi.waitFor(() => expect(mockGetDocument).toHaveBeenCalledOnce())
+ controller.abort()
+
+ await expect(opening).rejects.toMatchObject({ name: 'AbortError' })
+ expect(destroy).toHaveBeenCalledOnce()
+
+ resolveLoading?.({ destroy: lateDocumentDestroy })
+ await vi.waitFor(() => expect(lateDocumentDestroy).toHaveBeenCalledOnce())
+ })
+})
diff --git a/apps/sim/lib/file-parsers/pdfjs-server.ts b/apps/sim/lib/file-parsers/pdfjs-server.ts
index d4b6b60b9e2..88184fef7f5 100644
--- a/apps/sim/lib/file-parsers/pdfjs-server.ts
+++ b/apps/sim/lib/file-parsers/pdfjs-server.ts
@@ -1,11 +1,71 @@
-import type { PDFDocumentProxy } from 'pdfjs-dist/types/src/pdf'
+import type { PDFDocumentLoadingTask, PDFDocumentProxy } from 'pdfjs-dist/types/src/pdf'
+
+function waitForLoadingTask(
+ loadingTask: PDFDocumentLoadingTask,
+ signal?: AbortSignal
+): Promise {
+ if (!signal) return loadingTask.promise
+
+ const destroy = () => {
+ try {
+ void loadingTask.destroy().catch(() => {})
+ } catch {}
+ }
+
+ if (signal.aborted) {
+ destroy()
+ signal.throwIfAborted()
+ }
+
+ let aborted = false
+ return new Promise((resolve, reject) => {
+ const cleanup = () => signal.removeEventListener('abort', handleAbort)
+ const handleAbort = () => {
+ aborted = true
+ cleanup()
+ destroy()
+ reject(signal.reason)
+ }
+
+ signal.addEventListener('abort', handleAbort, { once: true })
+ loadingTask.promise.then(
+ (pdf) => {
+ cleanup()
+ if (aborted) {
+ void pdf.destroy().catch(() => {})
+ return
+ }
+ resolve(pdf)
+ },
+ (error: unknown) => {
+ cleanup()
+ reject(error)
+ }
+ )
+ })
+}
/** Open a PDF with the server-compatible pdf.js build and hardened defaults. */
-export async function openPdfDocument(data: Uint8Array): Promise {
- const { getDocument } = await import('pdfjs-dist/legacy/build/pdf.mjs')
- return getDocument({
+export async function openPdfDocument(
+ data: Uint8Array,
+ signal?: AbortSignal
+): Promise {
+ signal?.throwIfAborted()
+ const [{ getDocument }, { WorkerMessageHandler }] = await Promise.all([
+ import('pdfjs-dist/legacy/build/pdf.mjs'),
+ import('pdfjs-dist/legacy/build/pdf.worker.mjs'),
+ ])
+ signal?.throwIfAborted()
+
+ Object.assign(globalThis, {
+ pdfjsWorker: { WorkerMessageHandler },
+ })
+
+ const loadingTask = getDocument({
data,
isEvalSupported: false,
useSystemFonts: true,
- }).promise
+ })
+
+ return waitForLoadingTask(loadingTask, signal)
}
diff --git a/apps/sim/lib/internal/file/parser.test.ts b/apps/sim/lib/internal/file/parser.test.ts
index ab5f1d167ed..dc091099550 100644
--- a/apps/sim/lib/internal/file/parser.test.ts
+++ b/apps/sim/lib/internal/file/parser.test.ts
@@ -3,6 +3,8 @@
*
* @vitest-environment node
*/
+
+import { Readable } from 'node:stream'
import {
authMockFns,
createMockRequest,
@@ -24,14 +26,15 @@ const {
mockGetStorageProvider,
mockIsUsingCloudStorage,
mockIsSupportedFileType,
- mockParseFile,
mockParseBuffer,
+ mockPdfParseBuffer,
+ mockCreateReadStream,
mockFsAccess,
mockFsStat,
- mockFsReadFile,
mockFsWriteFile,
mockJoin,
actualPath,
+ mockUploadExecutionFile,
mockUploadWorkspaceFile,
mockReadWorkspaceFileNameByKey,
} = vi.hoisted(() => {
@@ -43,17 +46,17 @@ const {
mockGetStorageProvider: vi.fn().mockReturnValue('s3'),
mockIsUsingCloudStorage: vi.fn().mockReturnValue(true),
mockIsSupportedFileType: vi.fn().mockReturnValue(true),
- mockParseFile: vi.fn().mockResolvedValue({
- content: 'parsed content',
- metadata: { pageCount: 1 },
- }),
mockParseBuffer: vi.fn().mockResolvedValue({
content: 'parsed buffer content',
metadata: { pageCount: 1 },
}),
+ mockPdfParseBuffer: vi.fn().mockResolvedValue({
+ content: 'parsed PDF content',
+ metadata: { pageCount: 1 },
+ }),
+ mockCreateReadStream: vi.fn(),
mockFsAccess: vi.fn().mockResolvedValue(undefined),
mockFsStat: vi.fn().mockImplementation(() => ({ isFile: () => true, size: 17 })),
- mockFsReadFile: vi.fn().mockResolvedValue(Buffer.from('test file content')),
mockFsWriteFile: vi.fn().mockResolvedValue(undefined),
mockJoin: vi.fn((...args: string[]): string => {
if (args[0] === '/test/uploads') {
@@ -62,6 +65,7 @@ const {
return actualPath.join(...args)
}),
actualPath,
+ mockUploadExecutionFile: vi.fn(),
mockUploadWorkspaceFile: vi
.fn()
.mockImplementation(
@@ -93,10 +97,21 @@ vi.mock('@/lib/uploads', () => ({
vi.mock('@/lib/file-parsers', () => ({
isSupportedFileType: mockIsSupportedFileType,
- parseFile: mockParseFile,
parseBuffer: mockParseBuffer,
}))
+vi.mock('node:fs', () => ({
+ createReadStream: mockCreateReadStream,
+}))
+
+vi.mock('@/lib/file-parsers/pdf-parser', () => ({
+ PdfParser: class {
+ parseBuffer(...args: Parameters) {
+ return mockPdfParseBuffer(...args)
+ }
+ },
+}))
+
vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)
vi.mock('path', () => ({
@@ -118,7 +133,7 @@ vi.mock('@/lib/core/utils/logging', () => ({
}))
vi.mock('@/lib/uploads/contexts/execution', () => ({
- uploadExecutionFile: vi.fn(),
+ uploadExecutionFile: mockUploadExecutionFile,
}))
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
@@ -139,12 +154,10 @@ vi.mock('fs/promises', () => ({
default: {
access: mockFsAccess,
stat: mockFsStat,
- readFile: mockFsReadFile,
writeFile: mockFsWriteFile,
},
access: mockFsAccess,
stat: mockFsStat,
- readFile: mockFsReadFile,
writeFile: mockFsWriteFile,
}))
@@ -228,18 +241,27 @@ describe('file parser operation', () => {
storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true)
storageServiceMockFns.mockDownloadFile.mockResolvedValue(Buffer.from('test file content'))
mockFsStat.mockResolvedValue({ isFile: () => true, size: 17 })
- mockFsReadFile.mockResolvedValue(Buffer.from('test file content'))
+ mockCreateReadStream.mockImplementation(() => Readable.from([Buffer.from('test file content')]))
mockIsSupportedFileType.mockReturnValue(true)
+ mockUploadExecutionFile.mockResolvedValue({
+ id: 'file_test',
+ name: 'report.pdf',
+ url: '/api/files/serve/execution/report.pdf',
+ size: 17,
+ type: 'application/pdf',
+ key: 'execution/report.pdf',
+ context: 'execution',
+ })
mockUploadWorkspaceFile.mockClear()
mockReadWorkspaceFileNameByKey.mockResolvedValue({ name: null })
- mockParseFile.mockResolvedValue({
- content: 'parsed content',
- metadata: { pageCount: 1 },
- })
mockParseBuffer.mockResolvedValue({
content: 'parsed buffer content',
metadata: { pageCount: 1 },
})
+ mockPdfParseBuffer.mockResolvedValue({
+ content: 'parsed PDF content',
+ metadata: { pageCount: 1 },
+ })
})
afterEach(() => {
@@ -369,6 +391,77 @@ describe('file parser operation', () => {
})
})
+ it('forwards request cancellation to external PDF parsing', async () => {
+ inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
+ isValid: true,
+ resolvedIP: '203.0.113.10',
+ })
+ inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue(
+ new Response('pdf bytes', {
+ status: 200,
+ headers: { 'content-type': 'application/pdf' },
+ })
+ )
+ const req = createMockRequest('POST', {
+ filePath: 'https://example.com/report.pdf',
+ })
+
+ const response = await POST(req)
+
+ expect(response.status).toBe(200)
+ expect(mockPdfParseBuffer).toHaveBeenCalledWith(expect.any(Buffer), {
+ signal: req.signal,
+ })
+ })
+
+ it('forwards request cancellation to cloud PDF parsing', async () => {
+ const req = createMockRequest('POST', {
+ filePath: '/api/files/serve/execution/workspace-1/workflow-1/execution-1/report.pdf',
+ })
+
+ const response = await POST(req)
+
+ expect(response.status).toBe(200)
+ expect(mockPdfParseBuffer).toHaveBeenCalledWith(expect.any(Buffer), {
+ signal: req.signal,
+ })
+ })
+
+ it('parses and uploads one bounded local-file snapshot', async () => {
+ setupFileApiMocks({
+ cloudEnabled: false,
+ storageProvider: 'local',
+ authenticated: true,
+ })
+ mockFsStat.mockResolvedValue({ isFile: () => true, size: 3 })
+ const req = createMockRequest('POST', {
+ filePath: 'workspace/report.pdf',
+ })
+
+ const response = await POST(req)
+
+ const data = await response.json()
+ const parsedBuffer = mockParseBuffer.mock.calls[0][0]
+
+ expect(response.status).toBe(200)
+ expect(data.output).toMatchObject({
+ content: 'parsed buffer content',
+ fileType: 'application/pdf',
+ size: 17,
+ })
+ expect(mockCreateReadStream).toHaveBeenCalledWith('/test/uploads/workspace/report.pdf')
+ expect(mockCreateReadStream).toHaveBeenCalledOnce()
+ expect(mockParseBuffer).toHaveBeenCalledWith(parsedBuffer, 'pdf', { signal: req.signal })
+ expect(mockParseBuffer).toHaveBeenCalledOnce()
+ expect(mockUploadExecutionFile).toHaveBeenCalledWith(
+ expect.any(Object),
+ parsedBuffer,
+ 'report.pdf',
+ 'application/pdf',
+ 'test-user-id'
+ )
+ })
+
it('should reject parser complexity limits instead of returning raw text', async () => {
setupFileApiMocks({
cloudEnabled: true,
@@ -670,7 +763,9 @@ describe('file parser operation', () => {
expect(response.status).toBe(200)
expect(data.success).toBe(false)
expect(data.error).toContain('too large')
- expect(mockFsReadFile).not.toHaveBeenCalled()
+ expect(mockCreateReadStream).not.toHaveBeenCalled()
+ expect(mockParseBuffer).not.toHaveBeenCalled()
+ expect(mockUploadExecutionFile).not.toHaveBeenCalled()
})
it('should process execution file URLs with context query param', async () => {
diff --git a/apps/sim/lib/internal/file/parser.ts b/apps/sim/lib/internal/file/parser.ts
index 22af7232fb8..f47dc5e749f 100644
--- a/apps/sim/lib/internal/file/parser.ts
+++ b/apps/sim/lib/internal/file/parser.ts
@@ -1,3 +1,4 @@
+import { createReadStream } from 'node:fs'
import { Buffer, isUtf8 } from 'buffer'
import { createHash } from 'crypto'
import fsPromises from 'fs/promises'
@@ -10,12 +11,16 @@ import binaryExtensionsList from 'binary-extensions'
import type { ContractBody } from '@/lib/api/contracts'
import type { fileParseContract } from '@/lib/api/contracts/storage-transfer'
import { sanitizeUrlForLog } from '@/lib/core/utils/logging'
-import { assertKnownSizeWithinLimit, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
+import {
+ assertKnownSizeWithinLimit,
+ isPayloadSizeLimitError,
+ readNodeStreamToBufferWithLimit,
+} from '@/lib/core/utils/stream-limits'
import {
assertUserFileContentAccess,
type ExecutionMaterializationContext,
} from '@/lib/execution/payloads/materialization.server'
-import { isSupportedFileType, parseFile } from '@/lib/file-parsers'
+import { isSupportedFileType, parseBuffer } from '@/lib/file-parsers'
import { isFileParserError } from '@/lib/file-parsers/errors'
import { isUsingCloudStorage, StorageService } from '@/lib/uploads'
import { uploadExecutionFile } from '@/lib/uploads/contexts/execution'
@@ -562,7 +567,14 @@ async function handleExternalUrl(
let parseResult: ParseResult
if (extension === 'pdf') {
- parseResult = await handlePdfBuffer(buffer, filename, fileType, url, maxParsedOutputBytes)
+ parseResult = await handlePdfBuffer(
+ buffer,
+ filename,
+ fileType,
+ url,
+ maxParsedOutputBytes,
+ signal
+ )
} else if (extension === 'csv') {
parseResult = await handleCsvBuffer(buffer, filename, fileType, url, maxParsedOutputBytes)
} else if (isSupportedFileType(extension)) {
@@ -586,6 +598,7 @@ async function handleExternalUrl(
return parseResult
} catch (error) {
+ signal?.throwIfAborted()
logger.error(`Error handling external URL ${sanitizeUrlForLog(url)}:`, error)
if (isPayloadSizeLimitError(error)) {
logger.warn('Rejected oversized external file parse payload', {
@@ -748,7 +761,8 @@ async function handleCloudFile(
filename,
fileType,
normalizedFilePath,
- maxParsedOutputBytes
+ maxParsedOutputBytes,
+ signal
)
} else if (extension === 'csv') {
parseResult = await handleCsvBuffer(
@@ -794,6 +808,7 @@ async function handleCloudFile(
return parseResult
} catch (error) {
+ signal?.throwIfAborted()
logger.error(`Error handling cloud file ${filePath}:`, error)
const errorMessage = (error as Error).message
@@ -871,13 +886,17 @@ async function handleLocalFile(
const stats = await fsPromises.stat(fullPath)
assertKnownSizeWithinLimit(stats.size, maxDownloadBytes, 'local file')
- const result = await parseFile(fullPath)
+ const fileBuffer = await readNodeStreamToBufferWithLimit(createReadStream(fullPath), {
+ maxBytes: maxDownloadBytes,
+ label: 'local file',
+ signal,
+ })
+ const extension = path.extname(filename).toLowerCase().substring(1)
+ const result = await parseBuffer(fileBuffer, extension, { signal })
const content = assertParsedContentWithinLimit(result.content, maxParsedOutputBytes)
- const fileBuffer = await fsPromises.readFile(fullPath)
signal?.throwIfAborted()
const hash = createHash('md5').update(fileBuffer).digest('hex')
- const extension = path.extname(filename).toLowerCase().substring(1)
const mimeType = fileType || getMimeTypeFromExtension(extension)
// Store file in execution storage if executionContext is provided
@@ -906,12 +925,13 @@ async function handleLocalFile(
userFile,
metadata: {
fileType: mimeType,
- size: stats.size,
+ size: fileBuffer.length,
hash,
processingTime: 0,
},
}
} catch (error) {
+ signal?.throwIfAborted()
logger.error(`Error handling local file ${filePath}:`, error)
if (isPayloadSizeLimitError(error)) {
logger.warn('Rejected oversized local file parse payload', {
@@ -948,12 +968,14 @@ async function handlePdfBuffer(
filename: string,
fileType?: string,
originalPath?: string,
- maxParsedOutputBytes?: number
+ maxParsedOutputBytes?: number,
+ signal?: AbortSignal
): Promise {
try {
+ signal?.throwIfAborted()
logger.info(`Parsing PDF in memory: ${filename}`)
- const result = await parseBufferAsPdf(fileBuffer)
+ const result = await parseBufferAsPdf(fileBuffer, signal)
const content =
result.content ||
@@ -972,6 +994,7 @@ async function handlePdfBuffer(
},
}
} catch (error) {
+ signal?.throwIfAborted()
if (isPayloadSizeLimitError(error)) throw error
logger.error('Failed to parse PDF in memory:', error)
@@ -1149,14 +1172,16 @@ function handleGenericBuffer(
/**
* Parse a PDF buffer
*/
-async function parseBufferAsPdf(buffer: Buffer) {
+async function parseBufferAsPdf(buffer: Buffer, signal?: AbortSignal) {
try {
+ signal?.throwIfAborted()
const { PdfParser } = await import('@/lib/file-parsers/pdf-parser')
const parser = new PdfParser()
logger.info('Using main PDF parser for buffer')
- return await parser.parseBuffer(buffer)
+ return await parser.parseBuffer(buffer, { signal })
} catch (error) {
+ signal?.throwIfAborted()
throw new Error(`PDF parsing failed: ${(error as Error).message}`)
}
}
diff --git a/apps/sim/lib/internal/mysql/client.test.ts b/apps/sim/lib/internal/mysql/client.test.ts
index 3ae9faff36e..6894a504469 100644
--- a/apps/sim/lib/internal/mysql/client.test.ts
+++ b/apps/sim/lib/internal/mysql/client.test.ts
@@ -3,18 +3,26 @@
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
-const { mockCreateConnection, mockNetConnect, mockValidateDatabaseHost } = vi.hoisted(() => ({
- mockCreateConnection: vi.fn(),
- mockNetConnect: vi.fn(),
- mockValidateDatabaseHost: vi.fn(),
-}))
+const { mockCreateConnection, mockNetConnect, mockTypedParameterNull, mockValidateDatabaseHost } =
+ vi.hoisted(() => {
+ class MockTypedParameter {}
+ return {
+ mockCreateConnection: vi.fn(),
+ mockNetConnect: vi.fn(),
+ mockTypedParameterNull: vi.fn(() => new MockTypedParameter()),
+ mockValidateDatabaseHost: vi.fn(),
+ }
+ })
vi.mock('node:net', () => ({
default: { connect: mockNetConnect },
}))
vi.mock('mysql2/promise', () => ({
- default: { createConnection: mockCreateConnection },
+ default: {
+ createConnection: mockCreateConnection,
+ TypedParameter: { NULL: mockTypedParameterNull },
+ },
}))
vi.mock('@/lib/core/security/input-validation.server', () => ({
@@ -114,4 +122,53 @@ describe('MySQL client', () => {
await expect(execution).rejects.toMatchObject({ name: 'AbortError' })
expect(connection.destroy).toHaveBeenCalledOnce()
})
+
+ it('rejects unsupported bind values before executing the query', async () => {
+ const connection = { execute: vi.fn(), destroy: vi.fn() }
+
+ await expect(executeMysqlCommand(connection as never, 'SELECT ?', [undefined])).rejects.toThrow(
+ 'MySQL bind values must contain only supported scalar or structured values'
+ )
+ expect(connection.execute).not.toHaveBeenCalled()
+ })
+
+ it.each([
+ new Map([['key', 'value']]),
+ new Set(['value']),
+ /value/,
+ Object.assign(Object.create(null) as Record, { key: 'value' }),
+ ])('rejects non-plain structured bind values: %s', async (value) => {
+ const connection = { execute: vi.fn(), destroy: vi.fn() }
+
+ await expect(executeMysqlCommand(connection as never, 'SELECT ?', [value])).rejects.toThrow(
+ 'MySQL bind values must contain only supported scalar or structured values'
+ )
+ expect(connection.execute).not.toHaveBeenCalled()
+ })
+
+ it('accepts nested plain structured bind values', async () => {
+ const result = { affectedRows: 1 }
+ const values = [{ nested: ['value', 1, true, null] }]
+ const connection = {
+ execute: vi.fn().mockResolvedValue([result]),
+ destroy: vi.fn(),
+ }
+
+ await expect(executeMysqlCommand(connection as never, 'SELECT ?', values)).resolves.toBe(result)
+ expect(connection.execute).toHaveBeenCalledWith('SELECT ?', values)
+ })
+
+ it('accepts mysql2 typed parameters', async () => {
+ const result = { affectedRows: 1 }
+ const typedParameter = mockTypedParameterNull()
+ const connection = {
+ execute: vi.fn().mockResolvedValue([result]),
+ destroy: vi.fn(),
+ }
+
+ await expect(
+ executeMysqlCommand(connection as never, 'SELECT ?', [typedParameter])
+ ).resolves.toBe(result)
+ expect(connection.execute).toHaveBeenCalledWith('SELECT ?', [typedParameter])
+ })
})
diff --git a/apps/sim/lib/internal/mysql/client.ts b/apps/sim/lib/internal/mysql/client.ts
index 6c72f55816e..3fd425bba5c 100644
--- a/apps/sim/lib/internal/mysql/client.ts
+++ b/apps/sim/lib/internal/mysql/client.ts
@@ -11,6 +11,35 @@ export interface MysqlConnectionConfig {
ssl: 'disabled' | 'required' | 'preferred'
}
+const MYSQL_TYPED_PARAMETER_PROTOTYPE = Object.getPrototypeOf(mysql.TypedParameter.NULL())
+
+function isMysqlExecuteValue(value: unknown): value is mysql.ExecuteValues {
+ if (
+ value === null ||
+ ['string', 'number', 'bigint', 'boolean'].includes(typeof value) ||
+ value instanceof Date ||
+ value instanceof Blob ||
+ value instanceof Uint8Array
+ ) {
+ return true
+ }
+
+ if (Array.isArray(value)) return value.every(isMysqlExecuteValue)
+ if (typeof value !== 'object') return false
+ const prototype = Object.getPrototypeOf(value)
+ if (prototype === MYSQL_TYPED_PARAMETER_PROTOTYPE) return true
+ if (prototype !== Object.prototype) return false
+ return Object.values(value).every(isMysqlExecuteValue)
+}
+
+function assertMysqlExecuteValues(
+ values: unknown[] | undefined
+): asserts values is mysql.ExecuteValues[] | undefined {
+ if (values?.some((value) => !isMysqlExecuteValue(value))) {
+ throw new TypeError('MySQL bind values must contain only supported scalar or structured values')
+ }
+}
+
export async function createMysqlConnection(
config: MysqlConnectionConfig,
signal?: AbortSignal
@@ -70,6 +99,7 @@ export async function executeMysqlCommand(
signal?.addEventListener('abort', destroyConnection, { once: true })
try {
+ assertMysqlExecuteValues(values)
const [result] = await connection.execute(query, values)
signal?.throwIfAborted()
return result
diff --git a/apps/sim/lib/internal/stt/execute-tool.test.ts b/apps/sim/lib/internal/stt/execute-tool.test.ts
index 79340a99fe1..d451ce8164c 100644
--- a/apps/sim/lib/internal/stt/execute-tool.test.ts
+++ b/apps/sim/lib/internal/stt/execute-tool.test.ts
@@ -45,6 +45,7 @@ vi.mock('@/lib/audio/extractor', () => ({
extractAudioFromVideo: vi.fn(),
}))
+import { extractAudioFromVideo, isVideoFile } from '@/lib/audio/extractor'
import { executeSttTool } from '@/lib/internal/stt/execute-tool'
import type { InternalToolOperationCall } from '@/lib/internal/tool-operations/types'
@@ -161,6 +162,31 @@ describe('executeSttTool', () => {
expect(data.transcript).toBe('hello world')
})
+ it('forwards tool cancellation through video audio extraction', async () => {
+ const controller = new AbortController()
+ vi.mocked(isVideoFile).mockReturnValueOnce(true)
+ vi.mocked(extractAudioFromVideo).mockResolvedValueOnce({
+ buffer: Buffer.from('converted-audio'),
+ duration: 1,
+ format: 'mp3',
+ size: 15,
+ })
+ inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
+ mockSecureFetchResponse({ contentType: 'video/mp4' })
+ )
+
+ const response = await executeSttTool(
+ createVerifiedSttRequest(baseBody, { signal: controller.signal })
+ )
+
+ expect(response.status).toBe(200)
+ expect(extractAudioFromVideo).toHaveBeenCalledWith(
+ expect.any(Buffer),
+ 'video/mp4',
+ expect.objectContaining({ signal: controller.signal })
+ )
+ })
+
it('rejects an authenticated but incomplete private provenance envelope before downloading', async () => {
const response = await executeSttTool(
createSttRequest(
diff --git a/apps/sim/lib/internal/stt/operations.ts b/apps/sim/lib/internal/stt/operations.ts
index 9ec4b22e0d4..5785fa9beaa 100644
--- a/apps/sim/lib/internal/stt/operations.ts
+++ b/apps/sim/lib/internal/stt/operations.ts
@@ -296,13 +296,21 @@ export async function executeSttOperation(
outputFormat: 'mp3',
sampleRate: 16000,
channels: 1,
+ signal,
})
signal?.throwIfAborted()
audioBuffer = extracted.buffer
audioMimeType = 'audio/mpeg'
audioFileName = audioFileName.replace(/\.[^.]+$/, '.mp3')
} catch (error) {
+ signal?.throwIfAborted()
logger.error(`[${requestId}] Video extraction failed:`, error)
+ if (isPayloadSizeLimitError(error)) {
+ return Response.json(
+ { error: 'Extracted audio exceeds the maximum supported size' },
+ { status: 413 }
+ )
+ }
return Response.json(
{
error: `Failed to extract audio from video: ${getErrorMessage(error, 'Unknown error')}`,
diff --git a/apps/sim/lib/media/ffmpeg.test.ts b/apps/sim/lib/media/ffmpeg.test.ts
index ddaec2bcf1f..8c0cc0dfb9a 100644
--- a/apps/sim/lib/media/ffmpeg.test.ts
+++ b/apps/sim/lib/media/ffmpeg.test.ts
@@ -79,7 +79,7 @@ vi.mock('node:child_process', () => ({
},
}))
-import { runFfmpegOperation } from '@/lib/media/ffmpeg'
+import { extFromMime, runFfmpegOperation } from '@/lib/media/ffmpeg'
const videoInput = {
buffer: Buffer.from('fake-video-bytes'),
@@ -87,6 +87,33 @@ const videoInput = {
name: 'clip.mp4',
}
+describe('extFromMime', () => {
+ it('normalizes parameters and casing before selecting a known extension', () => {
+ expect(extFromMime(' Video/MP4; codecs=avc1 ')).toBe('mp4')
+ })
+
+ it.each(['constructor', '__proto__', 'toString'])(
+ 'does not resolve inherited object properties as MIME types: %s',
+ (mimeType) => {
+ expect(extFromMime(mimeType)).toBe('bin')
+ }
+ )
+
+ it.each(['video/../../../../escaped', 'video/..\\..\\..\\..\\escaped', 'video/mp4\\..\\escaped'])(
+ 'rejects path syntax in an unknown MIME subtype: %s',
+ (mimeType) => {
+ expect(extFromMime(mimeType)).toBe('bin')
+ }
+ )
+
+ it('never returns separators from extra MIME path segments', () => {
+ const extension = extFromMime('video/mp4/../../escaped')
+
+ expect(extension).toBe('mp4')
+ expect(extension).not.toMatch(/[\\/]/)
+ })
+})
+
/** Resolves once the next FFmpeg command reaches `.save()`, i.e. once it is running. */
function nextSave(): Promise {
return new Promise((resolve) => saves.waiters.push(resolve))
diff --git a/apps/sim/lib/media/ffmpeg.ts b/apps/sim/lib/media/ffmpeg.ts
index bed617b2fe4..16c3f396250 100644
--- a/apps/sim/lib/media/ffmpeg.ts
+++ b/apps/sim/lib/media/ffmpeg.ts
@@ -181,7 +181,13 @@ const EXT_TO_MIME: Record = {
}
function extFromMime(mime: string): string {
- return MIME_TO_EXT[mime] || mime.split('/')[1] || 'bin'
+ const normalizedMime = mime.split(';', 1)[0].trim().toLowerCase()
+ if (Object.hasOwn(MIME_TO_EXT, normalizedMime)) {
+ return MIME_TO_EXT[normalizedMime]
+ }
+
+ const subtype = normalizedMime.split('/')[1]
+ return subtype && /^[a-z0-9][a-z0-9.+_-]{0,63}$/.test(subtype) ? subtype : 'bin'
}
function mimeFromExt(ext: string): string {
diff --git a/apps/sim/lib/pptx-renderer/renderer/echarts-runtime.smoke.test.ts b/apps/sim/lib/pptx-renderer/renderer/echarts-runtime.smoke.test.ts
new file mode 100644
index 00000000000..dc76f3d8889
--- /dev/null
+++ b/apps/sim/lib/pptx-renderer/renderer/echarts-runtime.smoke.test.ts
@@ -0,0 +1,41 @@
+/**
+ * @vitest-environment node
+ */
+import { use } from 'echarts/core'
+import { SVGRenderer } from 'echarts/renderers'
+import { describe, expect, it } from 'vitest'
+import { init } from '@/lib/pptx-renderer/renderer/echarts-runtime'
+
+use([SVGRenderer])
+
+describe('PPTX ECharts runtime smoke', () => {
+ it('renders representative registered charts and components', () => {
+ const chart = init(null, null, {
+ renderer: 'svg',
+ ssr: true,
+ width: 400,
+ height: 300,
+ })
+
+ try {
+ chart.setOption({
+ title: { text: 'Quarterly results' },
+ tooltip: {},
+ legend: {},
+ xAxis: { type: 'category', data: ['Q1', 'Q2'] },
+ yAxis: { type: 'value' },
+ series: [
+ { name: 'Revenue', type: 'bar', data: [12, 18] },
+ { name: 'Target', type: 'line', data: [10, 16] },
+ ],
+ })
+
+ const svg = chart.renderToSVGString()
+ expect(svg).toContain('