UNPKG

sanity

Version:

Sanity is a real-time content infrastructure with a scalable, hosted backend featuring a Graph Oriented Query Language (GROQ), asset pipelines and fast edge caches

291 lines (244 loc) • 9.45 kB
/** * Looks for and imports (in preferred order): * - src/_document.js * - src/_document.tsx * * Then renders using ReactDOM to a string, which is sent back to the parent * process over the worker `postMessage` channel. */ import chalk from 'chalk' import fs from 'fs' import importFresh from 'import-fresh' import path from 'path' import {createElement} from 'react' import {renderToStaticMarkup} from 'react-dom/server' import {isMainThread, parentPort, Worker, workerData} from 'worker_threads' import {getAliases} from './aliases' import {debug as serverDebug} from './debug' import {type SanityMonorepo} from './sanityMonorepo' const debug = serverDebug.extend('renderDocument') // Don't use threads in the jest world // eslint-disable-next-line no-process-env const useThreads = typeof process.env.JEST_WORKER_ID === 'undefined' const hasWarnedAbout = new Set<string>() const defaultProps = { entryPath: './.sanity/runtime/app.js', } const autoGeneratedWarning = ` This file is auto-generated from "sanity dev". Modifications to this file are automatically discarded. `.trim() interface DocumentProps { basePath: string entryPath?: string css?: string[] } export function renderDocument(options: { monorepo?: SanityMonorepo studioRootPath: string props?: DocumentProps }): Promise<string> { return new Promise((resolve, reject) => { if (!useThreads) { resolve(getDocumentHtml(options.studioRootPath, options.props)) return } debug('Starting worker thread for %s', __filename) const worker = new Worker(__filename, { execArgv: __DEV__ ? ['-r', `${__dirname}/esbuild-register.js`] : undefined, workerData: {...options, dev: __DEV__, shouldWarn: true}, // eslint-disable-next-line no-process-env env: process.env, }) worker.on('message', (msg) => { if (msg.type === 'warning') { if (hasWarnedAbout.has(msg.warnKey)) { return } if (Array.isArray(msg.message)) { msg.message.forEach((warning: string) => console.warn(`${chalk.yellow('[warn]')} ${warning}`), ) } else { console.warn(`${chalk.yellow('[warn]')} ${msg.message}`) } hasWarnedAbout.add(msg.warnKey) return } if (msg.type === 'error') { debug('Error from worker: %s', msg.error || 'Unknown error') reject(new Error(msg.error || 'Document rendering worker stopped with an unknown error')) return } if (msg.type === 'result') { debug('Document HTML rendered, %d bytes', msg.html.length) resolve(msg.html) } }) worker.on('error', (err) => { debug('Worker errored: %s', err.message) reject(err) }) worker.on('exit', (code) => { if (code !== 0) { debug('Worker stopped with code %d', code) reject(new Error(`Document rendering worker stopped with exit code ${code}`)) } }) }) } export function decorateIndexWithAutoGeneratedWarning(template: string): string { return template.replace(/<head/, `\n<!--\n${autoGeneratedWarning}\n-->\n<head`) } export function getPossibleDocumentComponentLocations(studioRootPath: string): string[] { return [path.join(studioRootPath, '_document.js'), path.join(studioRootPath, '_document.tsx')] } /** * Adds a base path to a URL if necessary, and returns the resulting URL. * @param url - The URL to prefix with a base path. * @param basePath - The base path to prefix the URL with. Default value is `/`. * @returns The resulting URL with the base path. * @internal */ export function _prefixUrlWithBasePath(url: string, basePath: string): string { // Normalize basePath by adding a leading slash if it's missing. const normalizedBasePath = basePath.startsWith('/') ? basePath : `/${basePath}` // If the URL starts with a slash, append it to the basePath, removing any trailing slash if present. if (url.startsWith('/')) { if (normalizedBasePath.endsWith('/')) { return `${normalizedBasePath.slice(0, -1)}${url}` } return `${normalizedBasePath}${url}` } // If the URL doesn't start with a slash, append it to the basePath with a slash in between. if (normalizedBasePath.endsWith('/')) { return `${normalizedBasePath}${url}` } return `${normalizedBasePath}/${url}` } if (!isMainThread && parentPort) { renderDocumentFromWorkerData() } function renderDocumentFromWorkerData() { if (!parentPort || !workerData) { throw new Error('Must be used as a Worker with a valid options object in worker data') } const {monorepo, studioRootPath, props} = workerData || {} if (workerData?.dev) { // Define `__DEV__` in the worker thread as well // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(global as any).__DEV__ = true } if (typeof studioRootPath !== 'string') { parentPort.postMessage({type: 'error', message: 'Missing/invalid `studioRootPath` option'}) return } if (props && typeof props !== 'object') { parentPort.postMessage({type: 'error', message: '`props` must be an object if provided'}) return } // Require hook #1 // Alias monorepo modules debug('Registering potential aliases') require('module-alias').addAliases(getAliases({monorepo})) // Require hook #2 // Use `esbuild` to allow JSX/TypeScript and modern JS features debug('Registering esbuild for node %s', process.version) const {unregister} = __DEV__ ? {unregister: () => undefined} : require('esbuild-register/dist/node').register({ target: `node${process.version.slice(1)}`, jsx: 'automatic', extensions: ['.jsx', '.ts', '.tsx', '.mjs'], }) // Require hook #3 // Same as above, but we don't want to enforce a .jsx extension for anything with JSX debug('Registering esbuild for .js files using jsx loader') const {unregister: unregisterJs} = __DEV__ ? () => ({unregister: () => undefined}) : require('esbuild-register/dist/node').register({ target: `node${process.version.slice(1)}`, extensions: ['.js'], jsx: 'automatic', loader: 'jsx', }) const html = getDocumentHtml(studioRootPath, props) parentPort.postMessage({type: 'result', html}) // Be polite and clean up after esbuild-register unregister() unregisterJs() } function getDocumentHtml(studioRootPath: string, props?: DocumentProps): string { const Document = getDocumentComponent(studioRootPath) // NOTE: Validate the list of CSS paths so implementers of `_document.tsx` don't have to // - If the path is not a full URL, check if it starts with `/` // - If not, then prepend a `/` to the string const css = props?.css?.map((url) => { try { // If the URL is absolute, we don't need to prefix it return new URL(url).toString() } catch { return _prefixUrlWithBasePath(url, props.basePath) } }) debug('Rendering document component using React') const result = renderToStaticMarkup(createElement(Document, {...defaultProps, ...props, css})) return `<!DOCTYPE html>${result}` } function getDocumentComponent(studioRootPath: string) { debug('Loading default document component from `sanity` module') const {DefaultDocument} = require('sanity') debug('Attempting to load user-defined document component from %s', studioRootPath) const userDefined = tryLoadDocumentComponent(studioRootPath) if (!userDefined) { debug('Using default document component') return DefaultDocument } debug('Found user defined document component at %s', userDefined.path) const DocumentComp = userDefined.component.default || userDefined.component // CommonJS if (typeof DocumentComp === 'function') { debug('User defined document component is a function, assuming valid') return DocumentComp } debug('User defined document component did not have a default export') const userExports = Object.keys(userDefined.component).join(', ') || 'None' const relativePath = path.relative(process.cwd(), userDefined.path) const typeHint = typeof userDefined.component.default === 'undefined' ? '' : ` (type was ${typeof userDefined.component.default})` const warnKey = `${relativePath}/${userDefined.modified}` parentPort?.postMessage({ type: 'warning', message: [ `${relativePath} did not have a default export that is a React component${typeHint}`, `Named exports/properties found: ${userExports}`.trim(), `Using default document component from "sanity".`, ], warnKey, }) return DefaultDocument } function tryLoadDocumentComponent(studioRootPath: string) { const locations = getPossibleDocumentComponentLocations(studioRootPath) for (const componentPath of locations) { debug('Trying to load document component from %s', componentPath) try { return { // eslint-disable-next-line import/no-dynamic-require component: importFresh<any>(componentPath), path: componentPath, // eslint-disable-next-line no-sync modified: Math.floor(fs.statSync(componentPath)?.mtimeMs), } } catch (err) { // Allow "not found" errors if (err.code !== 'MODULE_NOT_FOUND') { debug('Failed to load document component: %s', err.message) throw err } debug('Document component not found at %s', componentPath) } } return null }