fix(env): put runtime config on <html> so client reads can't outrun it (#6923)

* 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.

* fix(realtime): count each failed connect attempt once

`manager.reconnect()` calls `open()`, whose error path emits `error` — which
the socket re-emits as `connect_error` — and then emits `reconnect_error`
itself. A failed reconnect therefore reached both handlers and advanced the
counter twice, so the outage report tripped on the second real attempt while
claiming three.

Count in `connect_error` alone: it is the only handler that fires exactly once
for both the initial failure and every retry. `reconnect_error` keeps its log
line and states why it deliberately does not count.

* fix(workflow): let an unresolvable webhook URL fail loudly

Reverts the guard added earlier in this PR. A blank row titled "Webhook URL"
is a worse outcome than a crash: it explains nothing, and the value is one a
user copies into a third-party provider, so any substitute — a guessed page
origin, an empty string — is a URL that provider accepts and then never
delivers to.

The read can no longer come back empty from the hydration race this PR fixes,
so reaching it at all means the deployment has no application base URL, which
breaks webhook registration and callbacks regardless. The error boundary now
reports what it caught, so the throw names its own cause instead of surfacing
as an unexplained fallback.
This commit is contained in:
Vikhyath Mondreti
2026-08-20 22:12:36 -07:00
committed by GitHub
parent 4ad1d53de9
commit aca152c7fb
11 changed files with 394 additions and 47 deletions
@@ -35,6 +35,23 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
capture_performance: false,
capture_dead_clicks: false,
enable_heatmaps: false,
/**
* PostHog's own error tracking, wired to `window.onerror` and
* `unhandledrejection`. This is the app-wide net: React error
* boundaries only see errors thrown inside the tree they wrap, and
* a failed chunk load, a rejected promise, or anything thrown from
* an event handler or socket callback reaches none of them.
*
* `capture_console_errors` stays off. It is not error reporting
* it captures every `console.error`, which here means React's
* hydration and dev warnings (the ones `HydrationErrorHandler`
* already filters out as noise) drowning the real exceptions.
*/
capture_exceptions: {
capture_unhandled_errors: true,
capture_unhandled_rejections: true,
capture_console_errors: false,
},
disable_session_recording: true,
session_recording: {
maskAllInputs: false,
+37 -2
View File
@@ -2,8 +2,11 @@
* @vitest-environment node
*/
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { PublicEnvScript } from '@/app/_shell/public-env-script'
import { describe, expect, it, vi } from 'vitest'
import { PUBLIC_ENV_ATTRIBUTE } from '@/lib/core/config/env'
import { PublicEnvScript, publicEnvHtmlAttributes } from '@/app/_shell/public-env-script'
vi.unmock('@/lib/core/config/env')
/**
* Guards the one property that matters: the emitted tag assigns `window.__ENV`
@@ -32,3 +35,35 @@ describe('PublicEnvScript', () => {
expect(keys.every((key) => /^NEXT_PUBLIC_/i.test(key))).toBe(true)
})
})
/**
* The script above is rendered from the component tree, so it lands at the end
* of `<head>` - after the bootstrap chunks that can already be executing. These
* attributes go on `<html>`, the document's first tag, which is what makes the
* same values readable by code that runs in that gap.
*/
describe('publicEnvHtmlAttributes', () => {
it('carries the public env under the attribute getEnv reads', () => {
const attributes = publicEnvHtmlAttributes()
expect(Object.keys(attributes)).toEqual([PUBLIC_ENV_ATTRIBUTE])
expect(() => JSON.parse(attributes[PUBLIC_ENV_ATTRIBUTE])).not.toThrow()
})
it('exposes only NEXT_PUBLIC_ variables', () => {
const values = JSON.parse(publicEnvHtmlAttributes()[PUBLIC_ENV_ATTRIBUTE])
expect(Object.keys(values).every((key) => /^NEXT_PUBLIC_/i.test(key))).toBe(true)
})
/**
* Two transports for one snapshot only stays safe while they agree; a reader
* that resolved different values depending on which one it happened to hit
* would be worse than the race this replaces.
*/
it('carries exactly what the script assigns', () => {
const values = JSON.parse(publicEnvHtmlAttributes()[PUBLIC_ENV_ATTRIBUTE])
expect(values).toEqual(PublicEnvScript().props.env)
})
})
+43 -14
View File
@@ -1,19 +1,48 @@
import { EnvScript } from 'next-runtime-env'
import { PUBLIC_ENV_ATTRIBUTE } from '@/lib/core/config/env'
/**
* Every `NEXT_PUBLIC_*` value currently in `process.env`. Filter matches
* `next-runtime-env`'s own `getPublicEnv()` exactly.
*/
function readPublicEnv(): Record<string, string | undefined> {
return Object.fromEntries(
Object.entries(process.env).filter(([key]) => /^NEXT_PUBLIC_/i.test(key))
)
}
/**
* `NEXT_PUBLIC_*` values, captured once when this module is first loaded - i.e.
* at server start on the hosted deployment, where a build's env never changes
* between requests. Filter matches `next-runtime-env`'s own `getPublicEnv()`
* exactly.
* between requests (`bootstrap.ts` awaits the runtime secret before importing
* the server, so `process.env` is complete before any module evaluates).
*
* These are deliberately NOT the values Next inlines into the client bundle:
* the image is built with placeholder `NEXT_PUBLIC_*` values and the real ones
* are supplied to the container at start, so `window.__ENV` is the only source
* of truth in the browser.
* are supplied to the container at start, so the browser has no compiled-in
* copy to fall back on.
*/
const HOSTED_PUBLIC_ENV = Object.fromEntries(
Object.entries(process.env).filter(([key]) => /^NEXT_PUBLIC_/i.test(key))
)
const HOSTED_PUBLIC_ENV = readPublicEnv()
/**
* Props to spread onto the `<html>` element so the public env is readable by any
* client code that can run at all.
*
* The script below is rendered from the component tree and therefore lands at
* the end of `<head>`, well after the `<script async>` bootstrap tags React
* emits in the preamble see {@link PUBLIC_ENV_ATTRIBUTE} for the full ordering
* argument and why that gap is reachable. `<html>` is the document's first tag,
* so its attributes are parsed before any script exists to read them.
*
* Read fresh rather than from {@link HOSTED_PUBLIC_ENV} so the one helper serves
* both deployment modes: self-hosted images re-inject env per deploy without a
* rebuild, and `next-runtime-env`'s script reads `process.env` per request for
* exactly that reason. On hosted the two reads are the same values, because
* nothing mutates `process.env` after boot.
*/
export function publicEnvHtmlAttributes(): Record<string, string> {
return { [PUBLIC_ENV_ATTRIBUTE]: JSON.stringify(readPublicEnv()) }
}
/**
* Static equivalent of `next-runtime-env`'s `<PublicEnvScript>` for the hosted
@@ -35,13 +64,13 @@ const HOSTED_PUBLIC_ENV = Object.fromEntries(
* `window.__ENV` stays undefined for the entire lifetime of the document -
* every `getEnv` read empty, until a reload happens to win the race.
*
* A plain `<script>` assigns unconditionally when the parser reaches it. When
* it is reached before the bootstrap chunk runs it lands strictly earlier than
* the queue drain would have; when it is not, the value still arrives a few
* milliseconds late instead of never. There is no supported way to place an
* inline script ahead of the framework's own bootstrap tags - React emits those
* in the preamble, before any content from the component tree - so the goal is
* to make losing that race harmless rather than to try to win it.
* A plain `<script>` assigns unconditionally when the parser reaches it, so a
* lost race costs milliseconds instead of the session. It does not make the
* assignment win the race, though: draining that queue was also the only thing
* sequencing the assignment ahead of `hydrate()`, and with the queue empty
* `appBootstrap` hydrates synchronously. {@link publicEnvHtmlAttributes} is what
* closes the remaining window - this tag stays because `window.__ENV` is the
* documented global, and it is what `getEnv` reads first.
*/
export function PublicEnvScript() {
return <EnvScript env={HOSTED_PUBLIC_ENV} disableNextScript />
+2 -2
View File
@@ -19,7 +19,7 @@ import { QueryProvider } from '@/app/_shell/providers/query-provider'
import { SessionProvider } from '@/app/_shell/providers/session-provider'
import { ThemeProvider } from '@/app/_shell/providers/theme-provider'
import { TooltipProvider } from '@/app/_shell/providers/tooltip-provider'
import { PublicEnvScript } from '@/app/_shell/public-env-script'
import { PublicEnvScript, publicEnvHtmlAttributes } from '@/app/_shell/public-env-script'
import { season } from '@/app/_styles/fonts/season/season'
export const viewport: Viewport = {
@@ -40,7 +40,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
const themeCSS = generateThemeCSS()
return (
<html lang='en' suppressHydrationWarning>
<html lang='en' suppressHydrationWarning {...publicEnvHtmlAttributes()}>
<head>
{isReactScanEnabled && (
<Script
@@ -1,10 +1,12 @@
'use client'
import { Component, type ReactNode, useEffect } from 'react'
import { Component, type ErrorInfo, type ReactNode } from 'react'
import { Button } from '@sim/emcn'
import { RefreshCw } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { truncate } from '@sim/utils/string'
import { ReactFlowProvider } from 'reactflow'
import { captureClientEvent } from '@/lib/posthog/client'
import { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components'
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
@@ -12,6 +14,9 @@ import { readCollapsedCookie } from '@/stores/sidebar/store'
const logger = createLogger('ErrorBoundary')
/** Keeps a runaway stack out of the event payload without losing the top frames. */
const MAX_REPORTED_COMPONENT_STACK = 2000
/**
* Shared Error UI Component
*/
@@ -90,6 +95,33 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
return { hasError: true, error }
}
/**
* Reports what was caught. This boundary latches for the life of the document
* and its fallback names nothing, so without this the only trace of a canvas
* crash is React's own console output on whichever machine happened to hit it
* leaving an intermittent failure with no evidence to diagnose from.
* `error.name` is carried separately from the message because it is what
* separates the failure classes from each other.
*/
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
const componentStack = errorInfo.componentStack ?? undefined
logger.error('Workflow canvas crashed', {
name: error.name,
message: error.message,
stack: error.stack,
componentStack,
})
captureClientEvent('workflow_canvas_crashed', {
error_name: error.name,
error_message: error.message,
component_stack: componentStack
? truncate(componentStack, MAX_REPORTED_COMPONENT_STACK)
: undefined,
})
}
public render() {
if (this.state.hasError) {
return this.props.fallback || <ErrorUI />
@@ -98,20 +130,3 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
return this.props.children
}
}
/**
* Next.js Error Page Component
* Renders when a workflow-specific error occurs
*/
interface NextErrorProps {
error: Error & { digest?: string }
reset: () => void
}
export function NextError({ error, reset }: NextErrorProps) {
useEffect(() => {
logger.error('Workflow error:', { error })
}, [error])
return <ErrorUI onReset={reset} />
}
@@ -500,6 +500,12 @@ const SubBlockRow = memo(function SubBlockRow({
if (!subBlock?.id?.startsWith('webhookUrlDisplay') || !blockId) {
return null
}
/* Deliberately unguarded. `getBaseUrl` throws when no application base URL is
configured, and that is the right outcome here: this value gets copied into
a third-party provider, so a guessed origin would hand the user a URL that
provider accepts and then never delivers to, and a blank row explains
nothing. The error boundary reports what it caught, so the throw names its
own cause. */
const baseUrl = getBaseUrl()
const triggerPath = allSubBlockValues?.triggerPath?.value as string | undefined
return triggerPath
@@ -30,7 +30,9 @@ import { backoffWithJitter } from '@sim/utils/retry'
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
import type { Socket } from 'socket.io-client'
import { getEnv } from '@/lib/core/config/env'
import { getSocketUrl } from '@/lib/core/utils/urls'
import { captureClientEvent } from '@/lib/posthog/client'
import {
type SocketJoinCommand,
SocketJoinController,
@@ -54,6 +56,14 @@ const logger = createLogger('SocketContext')
const TAB_SESSION_ID_KEY = 'sim_tab_session_id'
/**
* Consecutive connect failures before the realtime connection is reported as
* failing. Three attempts at the 1s base delay lands around the same few seconds
* as the "Reconnecting…" toast, so the event marks a real outage rather than the
* sub-second transport hiccups that recover on the first retry.
*/
const CONNECT_FAILURES_BEFORE_REPORT = 3
/** Bounded auto-retry budget for auth-class connect failures before going terminal. */
const MAX_AUTH_RETRY_ATTEMPTS = 5
const AUTH_RETRY_BASE_MS = 1000
@@ -174,6 +184,8 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
const authRetryAttemptsRef = useRef(0)
const authRetryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const sessionRejectedRef = useRef(false)
const connectFailureCountRef = useRef(0)
const connectFailureReportedRef = useRef(false)
const queryClient = useQueryClient()
const params = useParams()
@@ -375,6 +387,15 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
try {
const { io } = await import('socket.io-client')
const socketUrl = getSocketUrl()
/* Origin only: enough to tell a misresolved host from a down service,
without putting a path or query into an analytics payload. `new URL`
rather than `URL.parse`, which is unavailable on Safari below 18. */
let socketOrigin = 'unparseable'
try {
socketOrigin = new URL(socketUrl).origin
} catch {
/* Reported as-is; an unparseable socket URL is itself the finding. */
}
logger.info('Attempting to connect to Socket.IO server', {
url: socketUrl,
@@ -409,11 +430,52 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
},
})
/**
* Reports a realtime connection that is not coming back.
*
* A socket that never connects raises no exception anywhere every
* failure path here is handled, so error tracking sees nothing and the
* only user-visible trace is a "Reconnecting…" toast. Socket.IO then
* retries the same URL forever, so the failure is both permanent and
* silent. This is the one signal that distinguishes "the realtime
* service is down" from "this client resolved the wrong URL", which is
* why the origin is reported alongside the reason.
*
* Fires at most once per socket instance, at the point the toast
* appears, rather than once per retry.
*
* Called from `connect_error` and nowhere else. That is the only handler
* that sees exactly one event per attempt: a failed *reconnect* also
* emits the manager's `reconnect_error` (`manager.reconnect()` calls
* `open()`, whose error path emits `error` which the socket re-emits as
* `connect_error` and then emits `reconnect_error` itself), so counting
* in both would advance twice per try and trip the threshold early.
*/
const reportPersistentConnectFailure = (reason: string) => {
connectFailureCountRef.current += 1
if (
connectFailureReportedRef.current ||
connectFailureCountRef.current < CONNECT_FAILURES_BEFORE_REPORT
) {
return
}
connectFailureReportedRef.current = true
captureClientEvent('realtime_connection_failing', {
socket_origin: socketOrigin,
expected_socket_origin_configured: Boolean(getEnv('NEXT_PUBLIC_SOCKET_URL')?.trim()),
attempts: connectFailureCountRef.current,
reason,
})
}
socketInstance.on('connect', () => {
setIsConnected(true)
setIsConnecting(false)
setIsReconnecting(false)
authRetryAttemptsRef.current = 0
connectFailureCountRef.current = 0
connectFailureReportedRef.current = false
clearAuthRetryTimeout()
setCurrentSocketId(socketInstance.id ?? null)
logger.info('Socket connected successfully', {
@@ -451,6 +513,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
message: error.message,
})
setIsReconnecting(true)
reportPersistentConnectFailure(error.message)
return
}
@@ -518,6 +581,8 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
logger.info('Socket reconnection attempt', { attemptNumber })
})
/* Deliberately does not count toward the outage report the socket's
own `connect_error` already fired for this same attempt. */
socketInstance.io.on('reconnect_error', (error: Error) => {
logger.warn('Socket reconnection attempt failed, will retry', {
message: error.message,
+87
View File
@@ -0,0 +1,87 @@
/**
* @vitest-environment jsdom
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getEnv, PUBLIC_ENV_ATTRIBUTE } from '@/lib/core/config/env'
vi.unmock('@/lib/core/config/env')
/**
* A key no deployment defines, so these assertions describe the resolution order
* itself rather than whatever `process.env` happens to hold. `getEnv`'s last
* fallback is `process.env`, which is populated in a Node test run but all but
* empty in the browser bundle - reusing a real key here would pass or fail on
* whether a local `.env` is present.
*/
const TEST_KEY = 'NEXT_PUBLIC_SIM_ENV_RESOLUTION_FIXTURE'
/**
* Covers the browser resolution order for `NEXT_PUBLIC_*`.
*
* The `<html>` attribute exists because `window.__ENV` is assigned by a script
* ~13KB into the document while Next's bootstrap chunks sit in the preamble and
* `appBootstrap` hydrates synchronously once `self.__next_s` is empty. Client
* code can therefore read env before that assignment lands; the attribute is
* parsed before any script can run, so it always has.
*/
describe('getEnv', () => {
afterEach(() => {
document.documentElement.removeAttribute(PUBLIC_ENV_ATTRIBUTE)
window.__ENV = undefined as unknown as typeof window.__ENV
})
it('resolves from the <html> attribute while window.__ENV is still unassigned', () => {
document.documentElement.setAttribute(
PUBLIC_ENV_ATTRIBUTE,
JSON.stringify({ [TEST_KEY]: 'https://attribute.example' })
)
expect(window.__ENV).toBeUndefined()
expect(getEnv(TEST_KEY)).toBe('https://attribute.example')
})
it('prefers window.__ENV once assigned, so a runtime override still wins', () => {
document.documentElement.setAttribute(
PUBLIC_ENV_ATTRIBUTE,
JSON.stringify({ [TEST_KEY]: 'https://attribute.example' })
)
window.__ENV = { [TEST_KEY]: 'https://global.example' }
expect(getEnv(TEST_KEY)).toBe('https://global.example')
})
it('falls through to the attribute for keys window.__ENV does not carry', () => {
document.documentElement.setAttribute(
PUBLIC_ENV_ATTRIBUTE,
JSON.stringify({ [TEST_KEY]: 'https://attribute.example' })
)
window.__ENV = { NEXT_PUBLIC_SOMETHING_ELSE: 'https://global.example' }
expect(getEnv(TEST_KEY)).toBe('https://attribute.example')
})
it('re-reads when the attribute changes rather than serving a stale parse', () => {
document.documentElement.setAttribute(
PUBLIC_ENV_ATTRIBUTE,
JSON.stringify({ [TEST_KEY]: 'https://first.example' })
)
expect(getEnv(TEST_KEY)).toBe('https://first.example')
document.documentElement.setAttribute(
PUBLIC_ENV_ATTRIBUTE,
JSON.stringify({ [TEST_KEY]: 'https://second.example' })
)
expect(getEnv(TEST_KEY)).toBe('https://second.example')
})
it('treats a malformed attribute as absent instead of throwing', () => {
document.documentElement.setAttribute(PUBLIC_ENV_ATTRIBUTE, '{not json')
expect(() => getEnv(TEST_KEY)).not.toThrow()
expect(getEnv(TEST_KEY)).toBeUndefined()
})
it('returns undefined when no source carries the key', () => {
expect(getEnv(TEST_KEY)).toBeUndefined()
})
})
+69 -11
View File
@@ -10,30 +10,88 @@ import {
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'
/**
* Attribute on the `<html>` element carrying the same `NEXT_PUBLIC_*` snapshot
* `<PublicEnvScript>` assigns to `window.__ENV`.
*
* That script is rendered from the component tree, so it lands at the end of
* `<head>` measured at ~13 KB after the `<script async>` bootstrap tags React
* emits in the preamble. An `async` script runs the moment its fetch resolves,
* and Next's `appBootstrap` calls `hydrate()` **synchronously** when
* `self.__next_s` is empty, which it always is here: `disableNextScript` emits a
* plain inline tag rather than a `beforeInteractive` one, and that queue was the
* only thing that used to order the assignment ahead of hydration. So on a warm
* cache both module bodies and the first commit can run before the parser has
* reached the assignment.
*
* An attribute has no such ordering problem. `<html>` is the first tag in the
* document ~490 bytes ahead of the first bootstrap script so
* `document.documentElement` already carries this value by the time *any*
* script, framework or application, is able to execute. This is the race-free
* transport; `window.__ENV` stays the public global and the preferred read.
*/
export const PUBLIC_ENV_ATTRIBUTE = 'data-public-env'
let cachedEnvAttribute: string | null = null
let cachedEnvAttributeValues: Record<string, string> | null = null
/**
* `NEXT_PUBLIC_*` values read off {@link PUBLIC_ENV_ATTRIBUTE}. Only consulted
* when `window.__ENV` has not been assigned yet, which is a window of
* milliseconds but one that module bodies and the first commit both land in.
*
* The parse is memoized against the raw attribute, not against having run once,
* so the cache can never serve a value the document no longer carries. Each call
* costs one `getAttribute` and a string compare, and only until `window.__ENV`
* exists after that {@link getEnv} short-circuits before reaching here.
*/
function readDocumentPublicEnv(): Record<string, string> | null {
if (typeof document === 'undefined') return null
const serialized = document.documentElement?.getAttribute(PUBLIC_ENV_ATTRIBUTE)
if (!serialized) return null
if (serialized === cachedEnvAttribute) return cachedEnvAttributeValues
cachedEnvAttribute = serialized
cachedEnvAttributeValues = null
try {
const parsed: unknown = JSON.parse(serialized)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
cachedEnvAttributeValues = parsed as Record<string, string>
}
} catch {
/* A malformed attribute must not take the page down; fall through to the other sources. */
}
return cachedEnvAttributeValues
}
/**
* Reads NEXT_PUBLIC_* env vars in both client and server contexts.
* Client reads `window.__ENV` (populated by `<PublicEnvScript>`); server reads `process.env`.
* Server reads `process.env`. The client prefers `window.__ENV` (assigned by
* `<PublicEnvScript>`), falling back to {@link PUBLIC_ENV_ATTRIBUTE} for reads
* that happen before the parser reaches that script see the attribute's own
* docs for why that window exists.
*
* We do not use next-runtime-env's `env()` helper because it calls `unstable_noStore()`,
* which Next 16.2+ rejects outside a request scope.
*/
const getEnv = (variable: string): string | undefined => {
if (typeof window === 'undefined') return process.env[variable]
return window.__ENV?.[variable] ?? process.env[variable]
return window.__ENV?.[variable] ?? readDocumentPublicEnv()?.[variable] ?? process.env[variable]
}
/**
* Whether `window.__ENV` was still unset when this module first evaluated in the
* browser. Always `false` on the server.
*
* Module bodies run inside the framework bootstrap, which an `async` chunk can
* start before the parser has reached the inline assignment at the end of
* `<head>`. Reads made during render are unaffected: nothing renders until the
* RSC payload arrives, and that streams from `<body>` after the assignment.
* Module-scope reads have no such ordering, and the values they derive
* (`isHosted` and the other flags in `env-flags`) stay frozen for the session.
*
* Reported once per load so the rate is measurable rather than assumed; it is
* what decides whether those flags need to become lazy.
* This is the rate at which the ordering race described on
* {@link PUBLIC_ENV_ATTRIBUTE} is lost. It is no longer a correctness signal
* {@link getEnv} resolves the same values off the `<html>` attribute in that
* window but it stays reported so the race remains measurable rather than
* assumed, and so a regression that removes the attribute is visible as reads
* starting to fail again rather than as silence.
*/
export const publicEnvMissingAtModuleInit =
typeof window !== 'undefined' && window.__ENV === undefined
+28
View File
@@ -761,6 +761,34 @@ export interface PostHogEventMap {
workspace_id: string
}
/**
* The workflow editor's error boundary caught a render or effect error and
* replaced the canvas with its fallback. `error_name` is what distinguishes
* the failure classes (`ChunkLoadError`, `TypeError`, a thrown config error),
* so it is the property to break down on.
*/
workflow_canvas_crashed: {
error_name: string
error_message: string
component_stack?: string
}
/**
* The realtime socket has failed to connect enough times in a row to count as
* an outage rather than a hiccup. Emitted at most once per socket instance.
*
* A socket that cannot connect throws nothing, so exception capture never sees
* it. `socket_origin` separates the two causes that look identical to the
* user: the realtime service being unreachable, and this client resolving the
* wrong host the latter shows up as an origin equal to the app's own.
*/
realtime_connection_failing: {
socket_origin: string
expected_socket_origin_configured: boolean
attempts: number
reason: string
}
/** A stored credential's plaintext secret was deliberately retrieved via the token API. */
credential_used: {
credential_type:
+7
View File
@@ -201,4 +201,11 @@ export const envMock = {
isFalsy: isFalsyImpl,
envBoolean: envBooleanImpl,
envNumber: envNumberImpl,
/**
* Mirrors `PUBLIC_ENV_ATTRIBUTE` in `apps/sim/lib/core/config/env.ts`. The
* literal is repeated rather than imported because packages never import from
* `apps/*`; keep the two in step if the attribute is ever renamed.
*/
PUBLIC_ENV_ATTRIBUTE: 'data-public-env',
publicEnvMissingAtModuleInit: false,
}