From 2973b5725f362af488c45958dfd9c2adf7238920 Mon Sep 17 00:00:00 2001 From: Kristoffer Brabrand Date: Mon, 1 Dec 2025 13:06:35 +0100 Subject: [PATCH 1/3] feat: add import error handler that ignores urls from themer.sanity.build --- .../_internal/cli/util/importErrorHandler.ts | 65 +++++++++++++++++++ .../cli/util/mockBrowserEnvironment.ts | 5 ++ 2 files changed, 70 insertions(+) create mode 100644 packages/sanity/src/_internal/cli/util/importErrorHandler.ts diff --git a/packages/sanity/src/_internal/cli/util/importErrorHandler.ts b/packages/sanity/src/_internal/cli/util/importErrorHandler.ts new file mode 100644 index 00000000000..1f2d29b8af8 --- /dev/null +++ b/packages/sanity/src/_internal/cli/util/importErrorHandler.ts @@ -0,0 +1,65 @@ +import Module from 'node:module' + +export interface ImportErrorHandlerResult { + cleanup: () => void +} + +// Module._load is an internal Node.js API not exposed in types +interface ModuleConstructor { + _load(request: string, parent: Module | undefined, isMain: boolean): any +} + +/** + * Return safe empty module with Proxy for deep property access. This ensures any property + * access or function call returns a safe value + */ +function getProxyHandler() { + const handler: ProxyHandler = { + get: (_target, prop) => { + if (prop === '__esModule') return true + if (prop === 'default') return new Proxy({}, handler) + return new Proxy({}, handler) + }, + apply: () => new Proxy({}, handler), + } + return new Proxy({}, handler) +} + +/** + * Sets up a Module._load wrapper to silently ignore imports from https://themer.sanity.build + * This allows users to use themer URL imports in their config without breaking CLI commands. + * + * @returns Handler result with cleanup function + * @internal + */ +export function setupImportErrorHandler(): ImportErrorHandlerResult { + // Store original Module._load + const ModuleConstructor = Module as unknown as ModuleConstructor + const originalLoad = ModuleConstructor._load + + // Override Module._load to catch and handle themer.sanity.build imports + ModuleConstructor._load = function ( + request: string, + parent: Module | undefined, + isMain: boolean, + ) { + try { + return originalLoad.call(this, request, parent, isMain) + } catch (error) { + // Check if this is a themer.sanity.build URL import + if (request.startsWith('https://themer.sanity.build/api/')) { + // Return a safe proxy object that can be used in place of the theme + return getProxyHandler() + } + + // Re-throw all other errors + throw error + } + } + + return { + cleanup: () => { + ModuleConstructor._load = originalLoad + }, + } +} diff --git a/packages/sanity/src/_internal/cli/util/mockBrowserEnvironment.ts b/packages/sanity/src/_internal/cli/util/mockBrowserEnvironment.ts index 0369c43479a..fca4bdef09f 100644 --- a/packages/sanity/src/_internal/cli/util/mockBrowserEnvironment.ts +++ b/packages/sanity/src/_internal/cli/util/mockBrowserEnvironment.ts @@ -7,6 +7,7 @@ import {addHook} from 'pirates' import resolveFrom from 'resolve-from' import {getStudioEnvironmentVariables} from '../server/getStudioEnvironmentVariables' +import {setupImportErrorHandler} from './importErrorHandler' const require = createRequire(import.meta.url) @@ -24,6 +25,9 @@ export function mockBrowserEnvironment(basePath: string): () => void { } } + // Set up import error handler before esbuild-register to silently ignore themer.sanity.build URLs + const importErrorHandler = setupImportErrorHandler() + const btoa = global.btoa const domCleanup = jsdomGlobal(jsdomDefaultHtml, {url: 'http://localhost:3333/'}) @@ -60,6 +64,7 @@ export function mockBrowserEnvironment(basePath: string): () => void { globalCleanup() windowCleanup() domCleanup() + importErrorHandler.cleanup() } } From f053ff73575c51e9215ef18ab897d0b4d5b120e5 Mon Sep 17 00:00:00 2001 From: Kristoffer Brabrand Date: Mon, 1 Dec 2025 13:20:36 +0100 Subject: [PATCH 2/3] test(importErrorHandler): add tests for import error handler --- .../test/cli/util/importErrorHandler.test.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 packages/sanity/test/cli/util/importErrorHandler.test.ts diff --git a/packages/sanity/test/cli/util/importErrorHandler.test.ts b/packages/sanity/test/cli/util/importErrorHandler.test.ts new file mode 100644 index 00000000000..699768be244 --- /dev/null +++ b/packages/sanity/test/cli/util/importErrorHandler.test.ts @@ -0,0 +1,58 @@ +import Module from 'node:module' + +import {describe, expect, it} from 'vitest' + +import {setupImportErrorHandler} from '../../../src/_internal/cli/util/importErrorHandler' + +interface ModuleConstructor { + _load(request: string, parent: Module | undefined, isMain: boolean): any +} + +describe('setupImportErrorHandler', () => { + it('should handle themer.sanity.build URL imports', () => { + const handler = setupImportErrorHandler() + + const ModuleConstructor = Module as unknown as ModuleConstructor + + // Try to load a themer.sanity.build URL (which would normally fail) + const result = ModuleConstructor._load( + 'https://themer.sanity.build/api/hues?default=0078ff', + undefined, + false, + ) + + expect(result).toBeDefined() + expect(result.__esModule).toBe(true) + expect(result.default).toBeDefined() + expect(result.someProperty.deepProperty).toBeDefined() + + handler.cleanup() + }) + + it('should re-throw errors for non-themer URLs', () => { + const handler = setupImportErrorHandler() + + const ModuleConstructor = Module as unknown as ModuleConstructor + + expect(() => { + ModuleConstructor._load( + 'https://example.com/this-module-definitely-does-not-exist-xyz', + undefined, + false, + ) + }).toThrow() + + handler.cleanup() + }) + + it('should restore original Module._load after cleanup', () => { + const ModuleConstructor = Module as unknown as ModuleConstructor + const originalLoad = ModuleConstructor._load + + const handler = setupImportErrorHandler() + + expect(ModuleConstructor._load).not.toBe(originalLoad) + handler.cleanup() + expect(ModuleConstructor._load).toBe(originalLoad) + }) +}) From 60a7c05e1eeb605a0e56320ec0812a10af47ff43 Mon Sep 17 00:00:00 2001 From: Kristoffer Brabrand Date: Tue, 2 Dec 2025 10:05:32 +0100 Subject: [PATCH 3/3] test(mockBrowser): test themer import handling --- .../__tests__}/importErrorHandler.test.ts | 10 ++-- .../__tests__/mockBrowserEnvironment.test.ts | 54 +++++++++++++++++++ .../cli/util/__tests__/themerImportWorker.ts | 36 +++++++++++++ 3 files changed, 95 insertions(+), 5 deletions(-) rename packages/sanity/{test/cli/util => src/_internal/cli/util/__tests__}/importErrorHandler.test.ts (80%) create mode 100644 packages/sanity/src/_internal/cli/util/__tests__/mockBrowserEnvironment.test.ts create mode 100644 packages/sanity/src/_internal/cli/util/__tests__/themerImportWorker.ts diff --git a/packages/sanity/test/cli/util/importErrorHandler.test.ts b/packages/sanity/src/_internal/cli/util/__tests__/importErrorHandler.test.ts similarity index 80% rename from packages/sanity/test/cli/util/importErrorHandler.test.ts rename to packages/sanity/src/_internal/cli/util/__tests__/importErrorHandler.test.ts index 699768be244..5a46de0cab6 100644 --- a/packages/sanity/test/cli/util/importErrorHandler.test.ts +++ b/packages/sanity/src/_internal/cli/util/__tests__/importErrorHandler.test.ts @@ -1,15 +1,15 @@ import Module from 'node:module' -import {describe, expect, it} from 'vitest' +import {describe, expect, test} from 'vitest' -import {setupImportErrorHandler} from '../../../src/_internal/cli/util/importErrorHandler' +import {setupImportErrorHandler} from '../importErrorHandler' interface ModuleConstructor { _load(request: string, parent: Module | undefined, isMain: boolean): any } describe('setupImportErrorHandler', () => { - it('should handle themer.sanity.build URL imports', () => { + test('should handle themer.sanity.build URL imports', () => { const handler = setupImportErrorHandler() const ModuleConstructor = Module as unknown as ModuleConstructor @@ -29,7 +29,7 @@ describe('setupImportErrorHandler', () => { handler.cleanup() }) - it('should re-throw errors for non-themer URLs', () => { + test('should re-throw errors for non-themer URLs', () => { const handler = setupImportErrorHandler() const ModuleConstructor = Module as unknown as ModuleConstructor @@ -45,7 +45,7 @@ describe('setupImportErrorHandler', () => { handler.cleanup() }) - it('should restore original Module._load after cleanup', () => { + test('should restore original Module._load after cleanup', () => { const ModuleConstructor = Module as unknown as ModuleConstructor const originalLoad = ModuleConstructor._load diff --git a/packages/sanity/src/_internal/cli/util/__tests__/mockBrowserEnvironment.test.ts b/packages/sanity/src/_internal/cli/util/__tests__/mockBrowserEnvironment.test.ts new file mode 100644 index 00000000000..4d55d0078da --- /dev/null +++ b/packages/sanity/src/_internal/cli/util/__tests__/mockBrowserEnvironment.test.ts @@ -0,0 +1,54 @@ +import {Worker} from 'node:worker_threads' + +import {describe, expect, test} from 'vitest' + +import {type ThemerImportWorkerData} from './themerImportWorker' + +function getImportWorker(importName: string) { + const workerData: ThemerImportWorkerData = { + workDir: process.cwd(), + importName, + } + + const filepath = new URL('./themerImportWorker.ts', import.meta.url).pathname + + const worker = new Worker( + ` + const { register } = require('esbuild-register/dist/node') + + const { unregister } = register({ + target: 'node18', + format: 'cjs', + extensions: ['.ts'], + }) + + require(${JSON.stringify(filepath)}) + `, + {eval: true, workerData}, + ) + + return new Promise<{success: boolean; error?: string}>((resolve, reject) => { + worker.on('message', resolve) + worker.on('error', reject) + worker.on('exit', (code) => { + if (code !== 0) { + reject(new Error(`Worker stopped with exit code ${code}`)) + } + }) + }) +} + +describe('mockBrowserEnvironment', () => { + test('should handle themer.sanity.build imports in worker thread', async () => { + await expect(getImportWorker('https://themer.sanity.build/api/hues')).resolves.toEqual({ + success: true, + }) + }) + + test('should still error on non-themer.sanity.build imports in worker thread', async () => { + await expect(getImportWorker('https://foobar.official/package')).resolves.toEqual({ + success: false, + error: expect.stringContaining("Cannot find module 'https://foobar.official/package'"), + }) + }) +}) diff --git a/packages/sanity/src/_internal/cli/util/__tests__/themerImportWorker.ts b/packages/sanity/src/_internal/cli/util/__tests__/themerImportWorker.ts new file mode 100644 index 00000000000..e876c2ccc43 --- /dev/null +++ b/packages/sanity/src/_internal/cli/util/__tests__/themerImportWorker.ts @@ -0,0 +1,36 @@ +import {isMainThread, parentPort, workerData as _workerData} from 'node:worker_threads' + +import {mockBrowserEnvironment} from '../mockBrowserEnvironment' + +export type ThemerImportWorkerData = { + workDir: string + importName: string +} + +const {workDir, importName} = _workerData satisfies ThemerImportWorkerData + +async function main() { + if (isMainThread || !parentPort) { + throw new Error('This module must be run as a worker thread') + } + + const cleanup = mockBrowserEnvironment(workDir) + + try { + // eslint-disable-next-line import/no-dynamic-require + require(importName) + + // If we get here, the import was handled successfully + parentPort?.postMessage({success: true}) + } catch (error) { + // If we catch an error, the import error handler didn't work + parentPort?.postMessage({ + success: false, + error: error instanceof Error ? error.message : String(error), + }) + } finally { + cleanup() + } +} + +void main().then(() => process.exit())