mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(mobile): turn Zod's JIT probe off for the page, before any module
The page filed a CSP `script-src` violation on every load: Zod decides
whether it may compile by constructing `new Function('')` and reading
the throw as "no JIT here", the shell's `script-src 'self'` is exactly
that throw, and the browser reports it before Zod catches it. Zod's own
source gates the probe on `jitless` for this case.
`z.config({ jitless: true })` at the entry does not work, and the
reviewer's suggestion of putting it there was measured losing the race.
`$ZodObject` reads `allowsEval` when a schema is constructed, not when
one is parsed, so the first module-scope `z.object(...)` in the bundle
fires the probe — and esbuild evaluates the chunk holding zod and its
callers before the chunk holding any module of ours that imports zod. A
Function-constructor trap in the page put the call under `new ZodObject`
ahead of the entry's first statement.
`globalConfig` is `globalThis.__zod_globalConfig`, which zod adopts with
`??=` rather than replacing, so the banner can set the flag before any
module runs. That is where it now lives, beside the `process` shim and
under the same `MOBILE_WEB_APP_SHIMS` contract, which asserts it is
applied. Nothing is lost: the compiled path was never reachable in a
page under this policy.
The render check's `newCsp()` filter is gone. It dropped violations by
`blockedURI === 'eval'`, which would have hidden a real one, and every
case now asserts zero. The first case walks load and first paint, which
is where the second of the two reports fired. The dead `violations`
array is deleted.
Red first: blank the banner constant and four of the six cases fail,
each naming a `blockedUri: 'eval'` the filter used to swallow.
Finding, not fixed here and reported instead: the page bundles two
copies of zod, mobile's 4.4.3 and the repo root's 4.5.4, because
`src/shared/zod-salvage.ts` resolves upward. That is 808 KB of duplicate
source. Aliasing `zod` to one copy in the builder fixes it and was
measured working, but it changes which zod shared code runs in the
shipped page, which is a call to make on its own rather than inside a
CSP fix.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -54,6 +54,13 @@ export const MOBILE_WEB_APP_SHIMS = [
|
||||
name: 'process-banner',
|
||||
appliesTo: (options) => options.banner?.js?.includes('globalThis.process ??=') === true
|
||||
},
|
||||
{
|
||||
// Zod probes for a usable JIT with `new Function('')`, which the shell's CSP reports even
|
||||
// though Zod catches the throw and runs interpreted. Turned off before any module, because a
|
||||
// schema constructed at module scope reaches the probe before our own code can run.
|
||||
name: 'zod-jitless-banner',
|
||||
appliesTo: (options) => options.banner?.js?.includes('__zod_globalConfig') === true
|
||||
},
|
||||
{
|
||||
// lucide-react-native@1.14.0's barrel re-exports LucideProvider from a context.mjs that does
|
||||
// not export it. Metro's loose CJS interop tolerates it; esbuild's strict ESM does not.
|
||||
@@ -111,6 +118,27 @@ const PAGE_ASYNC_STORAGE_MODULE = join(
|
||||
'page-async-storage.ts'
|
||||
)
|
||||
|
||||
/**
|
||||
* Zod's compiled path, off before any module runs.
|
||||
*
|
||||
* Zod decides whether it may compile by constructing `new Function('')` and reading the throw as
|
||||
* "no JIT here". Under the shell's `script-src 'self'` that throw is exactly what happens, Zod
|
||||
* catches it and takes the interpreted path — but the browser files a `securitypolicyviolation`
|
||||
* report first, and it does so on every page load. Zod's own source gates the probe on `jitless`
|
||||
* for this case, so nothing here is a workaround.
|
||||
*
|
||||
* In the banner rather than a module that calls `z.config`, because a module cannot win the race.
|
||||
* `$ZodObject` reads `allowsEval` when a schema is *constructed*, not parsed, so the first
|
||||
* module-scope `z.object(...)` in the bundle fires the probe — and esbuild evaluates the chunk
|
||||
* holding zod and its callers before the chunk holding any module of ours that imports zod. An
|
||||
* entry import placed first was measured losing that race; the banner runs before every module.
|
||||
*
|
||||
* `globalConfig` is `globalThis.__zod_globalConfig`, which zod adopts with `??=` rather than
|
||||
* replacing, so setting the flag on it here is what zod itself reads.
|
||||
*/
|
||||
const ZOD_JITLESS_BANNER =
|
||||
'globalThis.__zod_globalConfig ??= {}; globalThis.__zod_globalConfig.jitless = true;'
|
||||
|
||||
const ROUTE_MANIFEST_PLUGIN_NAME = 'orca-route-manifest'
|
||||
const LUCIDE_PLUGIN_NAME = 'orca-lucide-barrel-provider'
|
||||
|
||||
@@ -210,7 +238,7 @@ export function mobileWebAppBuildOptions(routes) {
|
||||
// script would resolve against the route instead.
|
||||
publicPath: '/assets',
|
||||
banner: {
|
||||
js: "globalThis.process ??= { env: { NODE_ENV: 'production', EXPO_OS: 'web' }, platform: 'web', version: '', nextTick: (fn) => setTimeout(fn, 0) };"
|
||||
js: `globalThis.process ??= { env: { NODE_ENV: 'production', EXPO_OS: 'web' }, platform: 'web', version: '', nextTick: (fn) => setTimeout(fn, 0) };${ZOD_JITLESS_BANNER}`
|
||||
},
|
||||
define: {
|
||||
global: 'globalThis',
|
||||
|
||||
@@ -158,7 +158,6 @@ async function openPane({ grants }) {
|
||||
const context = await browser.newContext({ viewport: VIEWPORT })
|
||||
const page = await context.newPage()
|
||||
const consoleErrors = []
|
||||
const violations = []
|
||||
const foreignRequests = []
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
@@ -201,14 +200,8 @@ async function openPane({ grants }) {
|
||||
page,
|
||||
context,
|
||||
consoleErrors,
|
||||
violations,
|
||||
foreignRequests,
|
||||
csp: () => page.evaluate(() => globalThis.__orcaRenderCheckCsp),
|
||||
/** Every violation but the one the bundle already reports on any route: see ZOD_JIT_PROBE. */
|
||||
newCsp: async () =>
|
||||
(await page.evaluate(() => globalThis.__orcaRenderCheckCsp)).filter(
|
||||
(violation) => violation.blockedUri !== 'eval'
|
||||
)
|
||||
csp: () => page.evaluate(() => globalThis.__orcaRenderCheckCsp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,26 +296,24 @@ const waitForPaint = (page, count) =>
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
|
||||
/**
|
||||
* The one CSP violation this bundle already makes, on any route, before the pane is involved.
|
||||
*
|
||||
* Zod 4 feature-detects its compiled path by constructing `new Function('')` and treating a throw
|
||||
* as "no JIT here". Under the shell's `script-src 'self'` the construction throws, Zod catches it
|
||||
* and takes the interpreted path, so the page is correct — but the browser has already filed a
|
||||
* violation report by then, on every page load. Recorded rather than fixed: it is not the pane's,
|
||||
* and a check that asserted no violations at all would fail on it and say nothing about the frame
|
||||
* path, which is what this file is for.
|
||||
*/
|
||||
const ZOD_JIT_PROBE = { directive: 'script-src', blockedUri: 'eval' }
|
||||
|
||||
describePane('the browser pane in a page', () => {
|
||||
it('reports only the Zod JIT probe, which the page survives', async () => {
|
||||
/**
|
||||
* Zero, which it was not until `page-zod-jitless.ts` landed.
|
||||
*
|
||||
* Zod decided whether it could compile by constructing `new Function('')`, which the shell's
|
||||
* `script-src 'self'` reports even though Zod catches the throw — once on load and again on
|
||||
* first paint. This file filtered those out by `blockedURI === 'eval'` for one round, which
|
||||
* would also have hidden a real one, so the filter is gone and the cause is fixed instead.
|
||||
*/
|
||||
it('files no CSP violation at all, through load and first paint', async () => {
|
||||
const view = await openPane({ grants: [faultGrant, BINARY_GRANT] })
|
||||
try {
|
||||
await view.page.waitForFunction(() => globalThis.__orcaRenderCheckSubscribes.length > 0)
|
||||
// Named, so a second `eval` from anywhere else is visible as a count rather than hidden by
|
||||
// the filter every other case uses.
|
||||
expect(await view.csp()).toEqual([ZOD_JIT_PROBE])
|
||||
const b64 = await encodeNoiseJpeg(view.page, { ...FRAME, seed: 33 })
|
||||
await emitFrame(view.page, { b64, frameSeq: 1, ...FRAME })
|
||||
await waitForPaint(view.page, 1)
|
||||
|
||||
expect(await view.csp()).toEqual([])
|
||||
expect(view.consoleErrors).toEqual([])
|
||||
} finally {
|
||||
await view.context.close()
|
||||
@@ -349,7 +340,7 @@ describePane('the browser pane in a page', () => {
|
||||
expect(layers.length).toBeGreaterThan(0)
|
||||
expect(layers.filter((layer) => layer.opacity === '1')).toHaveLength(1)
|
||||
expect(view.consoleErrors).toEqual([])
|
||||
expect(await view.newCsp()).toEqual([])
|
||||
expect(await view.csp()).toEqual([])
|
||||
expect(view.foreignRequests).toEqual([])
|
||||
} finally {
|
||||
await view.context.close()
|
||||
@@ -384,7 +375,7 @@ describePane('the browser pane in a page', () => {
|
||||
expect(visible).toHaveLength(1)
|
||||
expect(visible[0].digest).not.toBe(before.find((l) => l.opacity === '1')?.digest)
|
||||
expect(view.consoleErrors).toEqual([])
|
||||
expect(await view.newCsp()).toEqual([])
|
||||
expect(await view.csp()).toEqual([])
|
||||
} finally {
|
||||
await view.context.close()
|
||||
}
|
||||
@@ -436,7 +427,7 @@ describePane('the browser pane in a page', () => {
|
||||
)
|
||||
expect(await view.page.evaluate(() => globalThis.__orcaRenderCheckSubscribes)).toEqual([])
|
||||
expect(view.consoleErrors).toEqual([])
|
||||
expect(await view.newCsp()).toEqual([])
|
||||
expect(await view.csp()).toEqual([])
|
||||
expect(view.foreignRequests).toEqual([])
|
||||
} finally {
|
||||
await view.context.close()
|
||||
|
||||
Reference in New Issue
Block a user