Skip to content

Commit dd79515

Browse files
authored
fix(env): restore beforeInteractive on the hosted public env script (#6214)
The hosted `<PublicEnvScript>` rendered a plain `<script>`, which lands at the end of `<head>` — after the ~40 `<script async>` chunk tags Next emits at the top of the document. An async script runs as soon as its fetch resolves, so on a warm cache a Next chunk could execute (and hydration begin) before the parser reached the env tag, leaving `window.__ENV` undefined for the first render. That surfaced as "Something went wrong" on the workflow page, since `getBaseUrl()` throws during the deploy modal's render, and as the socket falling back to the page origin instead of NEXT_PUBLIC_SOCKET_URL. Regressed in #5522, which replaced next-runtime-env's PublicEnvScript (to avoid its unstable_noStore forcing dynamic rendering) with a static equivalent that dropped the beforeInteractive strategy. - render the library's own `<EnvScript>`, which defaults to beforeInteractive and does not call unstable_noStore — hosted and self-hosted now share one implementation and one loading strategy - drop the hand-rolled serialization and `<` escaping; Next's beforeInteractive path already runs the payload through htmlEscapeJsonString, which escapes `& > < U+2028 U+2029` - fall back to the browser origin in getBaseUrl() rather than throwing, so a missing injected env can never tear down a page through the error boundary; server-side callers still fail loudly - read NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED via getEnv() in the SSO form, matching login/signup/auth-modal — `env.X` returns the build-time placeholder, not the runtime value
1 parent 0bc4fb4 commit dd79515

6 files changed

Lines changed: 127 additions & 42 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { EnvScript } from 'next-runtime-env'
5+
import { describe, expect, it } from 'vitest'
6+
import { PublicEnvScript } from '@/app/_shell/public-env-script'
7+
8+
/**
9+
* Guards the loading strategy, not the markup. A plain `<script>` rendered from
10+
* the root layout lands after the `<script async>` chunk tags Next emits at the
11+
* top of the document, so a chunk can execute - and hydration can begin - before
12+
* `window.__ENV` is populated. Delegating to `<EnvScript>` keeps the
13+
* `beforeInteractive` guarantee that `next-runtime-env` applies by default.
14+
*/
15+
describe('PublicEnvScript', () => {
16+
it('delegates to next-runtime-env EnvScript rather than emitting a raw script tag', () => {
17+
const element = PublicEnvScript()
18+
19+
expect(element.type).toBe(EnvScript)
20+
expect(element.type).not.toBe('script')
21+
})
22+
23+
it('does not opt out of the beforeInteractive strategy', () => {
24+
const { disableNextScript, nextScriptProps } = PublicEnvScript().props
25+
26+
expect(disableNextScript).toBeUndefined()
27+
expect(nextScriptProps?.strategy ?? 'beforeInteractive').toBe('beforeInteractive')
28+
})
29+
30+
it('passes only NEXT_PUBLIC_ variables through to the browser', () => {
31+
const keys = Object.keys(PublicEnvScript().props.env)
32+
33+
expect(keys.every((key) => /^NEXT_PUBLIC_/i.test(key))).toBe(true)
34+
})
35+
})
Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,38 @@
1-
import { PUBLIC_ENV_KEY } from 'next-runtime-env'
1+
import { EnvScript } from 'next-runtime-env'
22

33
/**
4-
* `NEXT_PUBLIC_*` values, captured once at module load (build time / server
5-
* start) rather than read per-request - correct on the hosted deployment,
6-
* where a build's env never changes between requests. Filter matches
7-
* `next-runtime-env`'s own `getPublicEnv()` exactly.
4+
* `NEXT_PUBLIC_*` values, captured once when this module is first loaded - i.e.
5+
* 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.
8+
*
9+
* These are deliberately NOT the values Next inlines into the client bundle:
10+
* 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.
813
*/
914
const HOSTED_PUBLIC_ENV = Object.fromEntries(
1015
Object.entries(process.env).filter(([key]) => /^NEXT_PUBLIC_/i.test(key))
1116
)
1217

1318
/**
14-
* Static, build-time equivalent of `next-runtime-env`'s `<PublicEnvScript>`
15-
* for the hosted deployment. It populates `window[PUBLIC_ENV_KEY]` with the
16-
* exact same shape `getEnv()` (`lib/core/config/env.ts`) reads client-side,
17-
* but without `next-runtime-env`'s unconditional `unstable_noStore()` call -
18-
* that call opts the entire app into dynamic rendering, which only pays off
19-
* for self-hosted Docker images that re-inject env per deploy without a
20-
* rebuild. On hosted, env is fixed per build, so this is safe to render
21-
* statically alongside the marketing pages' `revalidate`.
19+
* Static equivalent of `next-runtime-env`'s `<PublicEnvScript>` for the hosted
20+
* deployment. It renders the library's own `<EnvScript>`, so the emitted markup
21+
* and its `beforeInteractive` loading strategy are identical to the self-hosted
22+
* path - only the env read differs. `<PublicEnvScript>` additionally calls
23+
* `unstable_noStore()`, which opts the entire app into dynamic rendering; that
24+
* only pays off for self-hosted Docker images that re-inject env per deploy
25+
* without a rebuild, so hosted reads the env once here and stays static.
2226
*
23-
* Escapes `<` in the serialized JSON so an env value containing `</script>`
24-
* can't close this tag early and inject markup into every hosted page.
27+
* `beforeInteractive` is load-bearing, not an optimization. A plain `<script>`
28+
* rendered from the root layout lands at the end of `<head>`, after the ~40
29+
* `<script async>` chunk tags Next emits at the top of the document; an `async`
30+
* script runs as soon as its fetch resolves, so on a warm cache a Next chunk
31+
* can execute - and hydration can begin - before the parser reaches the env
32+
* tag, leaving `window.__ENV` undefined for the first render.
33+
* `beforeInteractive` instead queues the script into `self.__next_s`, which
34+
* Next's `appBootstrap` drains to completion before calling `hydrate()`.
2535
*/
2636
export function PublicEnvScript() {
27-
const serialized = JSON.stringify(HOSTED_PUBLIC_ENV).replace(/</g, '\\u003c')
28-
return (
29-
<script
30-
id='public-env'
31-
dangerouslySetInnerHTML={{
32-
__html: `window['${PUBLIC_ENV_KEY}'] = ${serialized}`,
33-
}}
34-
/>
35-
)
37+
return <EnvScript env={HOSTED_PUBLIC_ENV} />
3638
}

apps/sim/ee/sso/components/sso-form.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { createLogger } from '@sim/logger'
66
import Link from 'next/link'
77
import { useRouter, useSearchParams } from 'next/navigation'
88
import { client } from '@/lib/auth/auth-client'
9-
import { env, isFalsy } from '@/lib/core/config/env'
9+
import { getEnv, isFalsy } from '@/lib/core/config/env'
1010
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
1111
import { quickValidateEmail } from '@/lib/messaging/email/validation'
1212
import { AuthSubmitButton } from '@/app/(auth)/components'
@@ -38,6 +38,8 @@ export default function SSOForm() {
3838
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
3939
const [callbackUrl, setCallbackUrl] = useState('/workspace')
4040

41+
const emailEnabled = !isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED'))
42+
4143
useEffect(() => {
4244
if (searchParams) {
4345
const callback = searchParams.get('callbackUrl')
@@ -184,8 +186,7 @@ export default function SSOForm() {
184186
</AuthSubmitButton>
185187
</form>
186188

187-
{/* Only show divider and email signin button if email/password is enabled */}
188-
{!isFalsy(env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED) && (
189+
{emailEnabled && (
189190
<>
190191
<div className='relative my-6 font-light'>
191192
<div className='absolute inset-0 flex items-center'>
@@ -208,8 +209,7 @@ export default function SSOForm() {
208209
</>
209210
)}
210211

211-
{/* Only show signup link if email/password signup is enabled */}
212-
{!isFalsy(env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED) && (
212+
{emailEnabled && (
213213
<div className='pt-6 text-center font-light text-base'>
214214
<span className='font-normal'>Don't have an account? </span>
215215
<Link

apps/sim/lib/core/utils/urls.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ vi.mock('@/lib/core/config/env', () => ({
1414
}))
1515

1616
import {
17+
getBaseUrl,
1718
getBrowserOrigin,
1819
getSocketUrl,
1920
isLocalhostUrl,
@@ -37,6 +38,36 @@ describe('getBrowserOrigin', () => {
3738
})
3839
})
3940

41+
describe('getBaseUrl', () => {
42+
beforeEach(() => {
43+
mockGetEnv.mockReset()
44+
mockGetEnv.mockReturnValue(undefined)
45+
})
46+
47+
afterEach(() => {
48+
vi.restoreAllMocks()
49+
})
50+
51+
it('uses NEXT_PUBLIC_APP_URL when set', () => {
52+
mockGetEnv.mockImplementation((key) =>
53+
key === 'NEXT_PUBLIC_APP_URL' ? 'https://app.example.com' : undefined
54+
)
55+
setLocation('https://other.example.com/workspace/w/1')
56+
expect(getBaseUrl()).toBe('https://app.example.com')
57+
})
58+
59+
it('falls back to the page origin instead of throwing when the injected env is missing', () => {
60+
setLocation('https://www.sim.ai/workspace/ws-1/w/wf-1')
61+
expect(getBaseUrl()).toBe('https://www.sim.ai')
62+
})
63+
64+
it('treats a whitespace-only NEXT_PUBLIC_APP_URL as unset', () => {
65+
mockGetEnv.mockImplementation((key) => (key === 'NEXT_PUBLIC_APP_URL' ? ' ' : undefined))
66+
setLocation('https://www.sim.ai/')
67+
expect(getBaseUrl()).toBe('https://www.sim.ai')
68+
})
69+
})
70+
4071
describe('getSocketUrl', () => {
4172
beforeEach(() => {
4273
mockGetEnv.mockReset()

apps/sim/lib/core/utils/urls.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,19 +24,32 @@ function normalizeBaseUrl(url: string): string {
2424
/**
2525
* Returns the base URL of the application from NEXT_PUBLIC_APP_URL
2626
* This ensures webhooks, callbacks, and other integrations always use the correct public URL
27+
*
28+
* In the browser, falls back to the page's own origin when the injected env is
29+
* unavailable. Client-side callers only ever want a URL back to the app they are
30+
* already served from, so the origin is a correct answer — and a throw here
31+
* during render tears down the whole page through the error boundary. Server-side
32+
* callers (webhooks, callbacks, emails) have no origin to fall back to and must
33+
* still fail loudly on a misconfigured deployment.
34+
*
2735
* @returns The base URL string (e.g., 'http://localhost:3000' or 'https://example.com')
28-
* @throws Error if NEXT_PUBLIC_APP_URL is not configured
36+
* @throws Error if NEXT_PUBLIC_APP_URL is not configured and no browser origin exists
2937
*/
3038
export function getBaseUrl(): string {
3139
const baseUrl = getEnv('NEXT_PUBLIC_APP_URL')?.trim()
3240

33-
if (!baseUrl) {
34-
throw new Error(
35-
'NEXT_PUBLIC_APP_URL must be configured for webhooks and callbacks to work correctly'
36-
)
41+
if (baseUrl) {
42+
return normalizeBaseUrl(baseUrl)
43+
}
44+
45+
const browserOrigin = getBrowserOrigin()
46+
if (browserOrigin) {
47+
return browserOrigin
3748
}
3849

39-
return normalizeBaseUrl(baseUrl)
50+
throw new Error(
51+
'NEXT_PUBLIC_APP_URL must be configured for webhooks and callbacks to work correctly'
52+
)
4053
}
4154

4255
/**

packages/testing/src/mocks/urls.mock.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,18 @@ function hasHttpProtocol(url: string): boolean {
2626

2727
function getBaseUrlImpl(): string {
2828
const baseUrl = readEnv('NEXT_PUBLIC_APP_URL')?.trim()
29-
if (!baseUrl) {
30-
throw new Error(
31-
'NEXT_PUBLIC_APP_URL must be configured for webhooks and callbacks to work correctly'
32-
)
29+
if (baseUrl) {
30+
// Mirrors the real module: protocol-less values get https:// under isProd.
31+
const protocol = envFlagsMock.isProd ? 'https://' : 'http://'
32+
return hasHttpProtocol(baseUrl) ? baseUrl : `${protocol}${baseUrl}`
3333
}
34-
// Mirrors the real module: protocol-less values get https:// under isProd.
35-
const protocol = envFlagsMock.isProd ? 'https://' : 'http://'
36-
return hasHttpProtocol(baseUrl) ? baseUrl : `${protocol}${baseUrl}`
34+
// Mirrors the real module: the browser falls back to its own origin, only
35+
// server-side (no `window`) callers throw.
36+
const browserOrigin = getBrowserOriginImpl()
37+
if (browserOrigin) return browserOrigin
38+
throw new Error(
39+
'NEXT_PUBLIC_APP_URL must be configured for webhooks and callbacks to work correctly'
40+
)
3741
}
3842

3943
function getInternalApiBaseUrlImpl(): string {

0 commit comments

Comments
 (0)