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
text/typescript
/**
* 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
}