Skip to content

Commit 7ae3dd0

Browse files
committed
fix(env): put runtime config on <html> so client reads can't outrun it
The inline script that assigns `window.__ENV` is rendered from the component tree, so it lands ~13KB after the `<script async>` bootstrap tags React emits in the preamble. `appBootstrap` calls `hydrate()` synchronously whenever `self.__next_s` is empty — which it always is now that the script is a plain tag rather than a `beforeInteractive` one, that queue having been the only thing sequencing the assignment ahead of hydration. So module bodies and the first commit could both read env before the assignment landed: the socket URL fell back to the page origin for the life of the document, `getBaseUrl()` threw, and every module-scope flag in `env-flags` froze on nothing. Carry the same snapshot on `<html>`, the document's first tag, and read it in `getEnv` when `window.__ENV` is not yet assigned. Parsing is memoized against the raw attribute rather than against having run once, so the cache can never serve a value the document no longer carries. `window.__ENV` stays the public global and the preferred read, and both transports are built from one function so they cannot drift. Alongside: guard the read-only webhook-URL field so a base URL it cannot resolve is a blank field rather than a dead canvas; report what the workflow error boundary catches, which it previously swallowed entirely; and enable PostHog's native exception capture, since error boundaries only ever see their own subtree and chunk-load failures, rejected promises and throws from event or socket callbacks reached nothing.
1 parent 1ced0b6 commit 7ae3dd0

11 files changed

Lines changed: 396 additions & 48 deletions

File tree

apps/sim/app/_shell/providers/posthog-provider.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,23 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
3535
capture_performance: false,
3636
capture_dead_clicks: false,
3737
enable_heatmaps: false,
38+
/**
39+
* PostHog's own error tracking, wired to `window.onerror` and
40+
* `unhandledrejection`. This is the app-wide net: React error
41+
* boundaries only see errors thrown inside the tree they wrap, and
42+
* a failed chunk load, a rejected promise, or anything thrown from
43+
* an event handler or socket callback reaches none of them.
44+
*
45+
* `capture_console_errors` stays off. It is not error reporting —
46+
* it captures every `console.error`, which here means React's
47+
* hydration and dev warnings (the ones `HydrationErrorHandler`
48+
* already filters out as noise) drowning the real exceptions.
49+
*/
50+
capture_exceptions: {
51+
capture_unhandled_errors: true,
52+
capture_unhandled_rejections: true,
53+
capture_console_errors: false,
54+
},
3855
disable_session_recording: true,
3956
session_recording: {
4057
maskAllInputs: false,

apps/sim/app/_shell/public-env-script.test.tsx

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
* @vitest-environment node
33
*/
44
import { renderToStaticMarkup } from 'react-dom/server'
5-
import { describe, expect, it } from 'vitest'
6-
import { PublicEnvScript } from '@/app/_shell/public-env-script'
5+
import { describe, expect, it, vi } from 'vitest'
6+
import { PUBLIC_ENV_ATTRIBUTE } from '@/lib/core/config/env'
7+
import { PublicEnvScript, publicEnvHtmlAttributes } from '@/app/_shell/public-env-script'
8+
9+
vi.unmock('@/lib/core/config/env')
710

811
/**
912
* Guards the one property that matters: the emitted tag assigns `window.__ENV`
@@ -32,3 +35,35 @@ describe('PublicEnvScript', () => {
3235
expect(keys.every((key) => /^NEXT_PUBLIC_/i.test(key))).toBe(true)
3336
})
3437
})
38+
39+
/**
40+
* The script above is rendered from the component tree, so it lands at the end
41+
* of `<head>` - after the bootstrap chunks that can already be executing. These
42+
* attributes go on `<html>`, the document's first tag, which is what makes the
43+
* same values readable by code that runs in that gap.
44+
*/
45+
describe('publicEnvHtmlAttributes', () => {
46+
it('carries the public env under the attribute getEnv reads', () => {
47+
const attributes = publicEnvHtmlAttributes()
48+
49+
expect(Object.keys(attributes)).toEqual([PUBLIC_ENV_ATTRIBUTE])
50+
expect(() => JSON.parse(attributes[PUBLIC_ENV_ATTRIBUTE])).not.toThrow()
51+
})
52+
53+
it('exposes only NEXT_PUBLIC_ variables', () => {
54+
const values = JSON.parse(publicEnvHtmlAttributes()[PUBLIC_ENV_ATTRIBUTE])
55+
56+
expect(Object.keys(values).every((key) => /^NEXT_PUBLIC_/i.test(key))).toBe(true)
57+
})
58+
59+
/**
60+
* Two transports for one snapshot only stays safe while they agree; a reader
61+
* that resolved different values depending on which one it happened to hit
62+
* would be worse than the race this replaces.
63+
*/
64+
it('carries exactly what the script assigns', () => {
65+
const values = JSON.parse(publicEnvHtmlAttributes()[PUBLIC_ENV_ATTRIBUTE])
66+
67+
expect(values).toEqual(PublicEnvScript().props.env)
68+
})
69+
})

apps/sim/app/_shell/public-env-script.tsx

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,48 @@
11
import { EnvScript } from 'next-runtime-env'
2+
import { PUBLIC_ENV_ATTRIBUTE } from '@/lib/core/config/env'
3+
4+
/**
5+
* Every `NEXT_PUBLIC_*` value currently in `process.env`. Filter matches
6+
* `next-runtime-env`'s own `getPublicEnv()` exactly.
7+
*/
8+
function readPublicEnv(): Record<string, string | undefined> {
9+
return Object.fromEntries(
10+
Object.entries(process.env).filter(([key]) => /^NEXT_PUBLIC_/i.test(key))
11+
)
12+
}
213

314
/**
415
* `NEXT_PUBLIC_*` values, captured once when this module is first loaded - i.e.
516
* at server start on the hosted deployment, where a build's env never changes
6-
* between requests. Filter matches `next-runtime-env`'s own `getPublicEnv()`
7-
* exactly.
17+
* between requests (`bootstrap.ts` awaits the runtime secret before importing
18+
* the server, so `process.env` is complete before any module evaluates).
819
*
920
* These are deliberately NOT the values Next inlines into the client bundle:
1021
* the image is built with placeholder `NEXT_PUBLIC_*` values and the real ones
11-
* are supplied to the container at start, so `window.__ENV` is the only source
12-
* of truth in the browser.
22+
* are supplied to the container at start, so the browser has no compiled-in
23+
* copy to fall back on.
24+
*/
25+
const HOSTED_PUBLIC_ENV = readPublicEnv()
26+
27+
/**
28+
* Props to spread onto the `<html>` element so the public env is readable by any
29+
* client code that can run at all.
30+
*
31+
* The script below is rendered from the component tree and therefore lands at
32+
* the end of `<head>`, well after the `<script async>` bootstrap tags React
33+
* emits in the preamble — see {@link PUBLIC_ENV_ATTRIBUTE} for the full ordering
34+
* argument and why that gap is reachable. `<html>` is the document's first tag,
35+
* so its attributes are parsed before any script exists to read them.
36+
*
37+
* Read fresh rather than from {@link HOSTED_PUBLIC_ENV} so the one helper serves
38+
* both deployment modes: self-hosted images re-inject env per deploy without a
39+
* rebuild, and `next-runtime-env`'s script reads `process.env` per request for
40+
* exactly that reason. On hosted the two reads are the same values, because
41+
* nothing mutates `process.env` after boot.
1342
*/
14-
const HOSTED_PUBLIC_ENV = Object.fromEntries(
15-
Object.entries(process.env).filter(([key]) => /^NEXT_PUBLIC_/i.test(key))
16-
)
43+
export function publicEnvHtmlAttributes(): Record<string, string> {
44+
return { [PUBLIC_ENV_ATTRIBUTE]: JSON.stringify(readPublicEnv()) }
45+
}
1746

1847
/**
1948
* Static equivalent of `next-runtime-env`'s `<PublicEnvScript>` for the hosted
@@ -35,13 +64,13 @@ const HOSTED_PUBLIC_ENV = Object.fromEntries(
3564
* `window.__ENV` stays undefined for the entire lifetime of the document -
3665
* every `getEnv` read empty, until a reload happens to win the race.
3766
*
38-
* A plain `<script>` assigns unconditionally when the parser reaches it. When
39-
* it is reached before the bootstrap chunk runs it lands strictly earlier than
40-
* the queue drain would have; when it is not, the value still arrives a few
41-
* milliseconds late instead of never. There is no supported way to place an
42-
* inline script ahead of the framework's own bootstrap tags - React emits those
43-
* in the preamble, before any content from the component tree - so the goal is
44-
* to make losing that race harmless rather than to try to win it.
67+
* A plain `<script>` assigns unconditionally when the parser reaches it, so a
68+
* lost race costs milliseconds instead of the session. It does not make the
69+
* assignment win the race, though: draining that queue was also the only thing
70+
* sequencing the assignment ahead of `hydrate()`, and with the queue empty
71+
* `appBootstrap` hydrates synchronously. {@link publicEnvHtmlAttributes} is what
72+
* closes the remaining window - this tag stays because `window.__ENV` is the
73+
* documented global, and it is what `getEnv` reads first.
4574
*/
4675
export function PublicEnvScript() {
4776
return <EnvScript env={HOSTED_PUBLIC_ENV} disableNextScript />

apps/sim/app/layout.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { QueryProvider } from '@/app/_shell/providers/query-provider'
1919
import { SessionProvider } from '@/app/_shell/providers/session-provider'
2020
import { ThemeProvider } from '@/app/_shell/providers/theme-provider'
2121
import { TooltipProvider } from '@/app/_shell/providers/tooltip-provider'
22-
import { PublicEnvScript } from '@/app/_shell/public-env-script'
22+
import { PublicEnvScript, publicEnvHtmlAttributes } from '@/app/_shell/public-env-script'
2323
import { season } from '@/app/_styles/fonts/season/season'
2424

2525
export const viewport: Viewport = {
@@ -40,7 +40,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
4040
const themeCSS = generateThemeCSS()
4141

4242
return (
43-
<html lang='en' suppressHydrationWarning>
43+
<html lang='en' suppressHydrationWarning {...publicEnvHtmlAttributes()}>
4444
<head>
4545
{isReactScanEnabled && (
4646
<Script

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
11
'use client'
22

3-
import { Component, type ReactNode, useEffect } from 'react'
3+
import { Component, type ErrorInfo, type ReactNode } from 'react'
44
import { Button } from '@sim/emcn'
55
import { RefreshCw } from '@sim/emcn/icons'
66
import { createLogger } from '@sim/logger'
7+
import { truncate } from '@sim/utils/string'
78
import { ReactFlowProvider } from 'reactflow'
9+
import { captureClientEvent } from '@/lib/posthog/client'
810
import { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components'
911
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
1012
import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
1113
import { readCollapsedCookie } from '@/stores/sidebar/store'
1214

1315
const logger = createLogger('ErrorBoundary')
1416

17+
/** Keeps a runaway stack out of the event payload without losing the top frames. */
18+
const MAX_REPORTED_COMPONENT_STACK = 2000
19+
1520
/**
1621
* Shared Error UI Component
1722
*/
@@ -90,6 +95,33 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
9095
return { hasError: true, error }
9196
}
9297

98+
/**
99+
* Reports what was caught. This boundary latches for the life of the document
100+
* and its fallback names nothing, so without this the only trace of a canvas
101+
* crash is React's own console output on whichever machine happened to hit it
102+
* — leaving an intermittent failure with no evidence to diagnose from.
103+
* `error.name` is carried separately from the message because it is what
104+
* separates the failure classes from each other.
105+
*/
106+
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
107+
const componentStack = errorInfo.componentStack ?? undefined
108+
109+
logger.error('Workflow canvas crashed', {
110+
name: error.name,
111+
message: error.message,
112+
stack: error.stack,
113+
componentStack,
114+
})
115+
116+
captureClientEvent('workflow_canvas_crashed', {
117+
error_name: error.name,
118+
error_message: error.message,
119+
component_stack: componentStack
120+
? truncate(componentStack, MAX_REPORTED_COMPONENT_STACK)
121+
: undefined,
122+
})
123+
}
124+
93125
public render() {
94126
if (this.state.hasError) {
95127
return this.props.fallback || <ErrorUI />
@@ -98,20 +130,3 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
98130
return this.props.children
99131
}
100132
}
101-
102-
/**
103-
* Next.js Error Page Component
104-
* Renders when a workflow-specific error occurs
105-
*/
106-
interface NextErrorProps {
107-
error: Error & { digest?: string }
108-
reset: () => void
109-
}
110-
111-
export function NextError({ error, reset }: NextErrorProps) {
112-
useEffect(() => {
113-
logger.error('Workflow error:', { error })
114-
}, [error])
115-
116-
return <ErrorUI onReset={reset} />
117-
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
TypeNumber,
1717
Wrench,
1818
} from '@sim/emcn/icons'
19+
import { createLogger } from '@sim/logger'
1920
import {
2021
BLOCK_DIMENSIONS,
2122
CanvasSentenceView,
@@ -119,6 +120,8 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
119120
import { formatParameterLabel } from '@/tools/params'
120121
import { TRIGGER_REGISTRY } from '@/triggers/registry'
121122

123+
const logger = createLogger('WorkflowBlock')
124+
122125
/** Stable empty object to avoid creating new references */
123126
const EMPTY_SUBBLOCK_VALUES = {} as Record<string, any>
124127

@@ -500,7 +503,19 @@ const SubBlockRow = memo(function SubBlockRow({
500503
if (!subBlock?.id?.startsWith('webhookUrlDisplay') || !blockId) {
501504
return null
502505
}
503-
const baseUrl = getBaseUrl()
506+
/* `getBaseUrl` throws by design when no application base URL is configured,
507+
and this runs during render — so an unguarded call takes the entire editor
508+
down through the canvas error boundary over one read-only field. A URL this
509+
card cannot resolve is a blank field, never a dead canvas. */
510+
let baseUrl: string
511+
try {
512+
baseUrl = getBaseUrl()
513+
} catch (error) {
514+
logger.warn('Cannot render the webhook URL: no application base URL is configured', {
515+
error,
516+
})
517+
return null
518+
}
504519
const triggerPath = allSubBlockValues?.triggerPath?.value as string | undefined
505520
return triggerPath
506521
? `${baseUrl}/api/webhooks/trigger/${triggerPath}`

apps/sim/app/workspace/providers/socket-provider.tsx

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ import { backoffWithJitter } from '@sim/utils/retry'
3030
import { useQueryClient } from '@tanstack/react-query'
3131
import { useParams } from 'next/navigation'
3232
import type { Socket } from 'socket.io-client'
33+
import { getEnv } from '@/lib/core/config/env'
3334
import { getSocketUrl } from '@/lib/core/utils/urls'
35+
import { captureClientEvent } from '@/lib/posthog/client'
3436
import {
3537
type SocketJoinCommand,
3638
SocketJoinController,
@@ -54,6 +56,14 @@ const logger = createLogger('SocketContext')
5456

5557
const TAB_SESSION_ID_KEY = 'sim_tab_session_id'
5658

59+
/**
60+
* Consecutive connect failures before the realtime connection is reported as
61+
* failing. Three attempts at the 1s base delay lands around the same few seconds
62+
* as the "Reconnecting…" toast, so the event marks a real outage rather than the
63+
* sub-second transport hiccups that recover on the first retry.
64+
*/
65+
const CONNECT_FAILURES_BEFORE_REPORT = 3
66+
5767
/** Bounded auto-retry budget for auth-class connect failures before going terminal. */
5868
const MAX_AUTH_RETRY_ATTEMPTS = 5
5969
const AUTH_RETRY_BASE_MS = 1000
@@ -174,6 +184,8 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
174184
const authRetryAttemptsRef = useRef(0)
175185
const authRetryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
176186
const sessionRejectedRef = useRef(false)
187+
const connectFailureCountRef = useRef(0)
188+
const connectFailureReportedRef = useRef(false)
177189
const queryClient = useQueryClient()
178190

179191
const params = useParams()
@@ -375,6 +387,15 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
375387
try {
376388
const { io } = await import('socket.io-client')
377389
const socketUrl = getSocketUrl()
390+
/* Origin only: enough to tell a misresolved host from a down service,
391+
without putting a path or query into an analytics payload. `new URL`
392+
rather than `URL.parse`, which is unavailable on Safari below 18. */
393+
let socketOrigin = 'unparseable'
394+
try {
395+
socketOrigin = new URL(socketUrl).origin
396+
} catch {
397+
/* Reported as-is; an unparseable socket URL is itself the finding. */
398+
}
378399

379400
logger.info('Attempting to connect to Socket.IO server', {
380401
url: socketUrl,
@@ -409,11 +430,45 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
409430
},
410431
})
411432

433+
/**
434+
* Reports a realtime connection that is not coming back.
435+
*
436+
* A socket that never connects raises no exception anywhere — every
437+
* failure path here is handled, so error tracking sees nothing and the
438+
* only user-visible trace is a "Reconnecting…" toast. Socket.IO then
439+
* retries the same URL forever, so the failure is both permanent and
440+
* silent. This is the one signal that distinguishes "the realtime
441+
* service is down" from "this client resolved the wrong URL", which is
442+
* why the origin is reported alongside the reason.
443+
*
444+
* Fires at most once per socket instance, at the point the toast
445+
* appears, rather than once per retry.
446+
*/
447+
const reportPersistentConnectFailure = (reason: string) => {
448+
connectFailureCountRef.current += 1
449+
if (
450+
connectFailureReportedRef.current ||
451+
connectFailureCountRef.current < CONNECT_FAILURES_BEFORE_REPORT
452+
) {
453+
return
454+
}
455+
connectFailureReportedRef.current = true
456+
457+
captureClientEvent('realtime_connection_failing', {
458+
socket_origin: socketOrigin,
459+
expected_socket_origin_configured: Boolean(getEnv('NEXT_PUBLIC_SOCKET_URL')?.trim()),
460+
attempts: connectFailureCountRef.current,
461+
reason,
462+
})
463+
}
464+
412465
socketInstance.on('connect', () => {
413466
setIsConnected(true)
414467
setIsConnecting(false)
415468
setIsReconnecting(false)
416469
authRetryAttemptsRef.current = 0
470+
connectFailureCountRef.current = 0
471+
connectFailureReportedRef.current = false
417472
clearAuthRetryTimeout()
418473
setCurrentSocketId(socketInstance.id ?? null)
419474
logger.info('Socket connected successfully', {
@@ -451,6 +506,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
451506
message: error.message,
452507
})
453508
setIsReconnecting(true)
509+
reportPersistentConnectFailure(error.message)
454510
return
455511
}
456512

@@ -522,6 +578,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
522578
logger.warn('Socket reconnection attempt failed, will retry', {
523579
message: error.message,
524580
})
581+
reportPersistentConnectFailure(error.message)
525582
})
526583

527584
socketInstance.io.on('reconnect_failed', () => {

0 commit comments

Comments
 (0)