Files
orca/mobile/scripts/build-terminal-webview-engine.mjs
T
Jinwoo-H 4420445eee build(mobile): write the xterm stylesheet as its own generated artifact
The page mounts xterm itself, so it needs the engine's stylesheet and must never
resolve the engine string: 612 KiB of minified IIFE built to be injected as text into
a WebView document, unusable under the shell's `script-src 'self'` with no nested
frame to load one into, and the largest single module the session route's closure
would carry. Both lived in `terminal-webview-engine.generated.ts`, so one import of
the CSS pulled the string in behind it.

`build-terminal-webview-engine.mjs` now writes `terminal-webview-engine-css.generated.ts`
beside it from the same read of `@xterm/xterm/css/xterm.css`, with the same comment
strip and the same `http%3A//` scrub the no-external-URL gate wants. Gitignored beside
its neighbour and written by the same postinstall step, so a fresh tree gets both or
neither. `document-shell.ts` takes the CSS from the new module and the engine string
from the old one; `build-terminal-document-fixture.mjs` and the two tests that hold
both constants read them from their new homes.

The document did not move: `terminal-document-golden.txt` is byte for byte what the
last commit left, 105,968 bytes, and the payload digest is unchanged.

The fence is `config/scripts/mobile-web-terminal-engine-closure.test.mjs`. It walks
every module under `src/terminal/document/` as an entry point — the document is one
script whose modules reach each other by side effect, so no single one of them roots
a graph holding the rest — and asserts the engine string is in none of their closures,
with two modules named as the precondition that the walk resolved anything at all. The
native document's own closure is asserted to still hold both generated modules, so the
first case cannot pass by the CSS having gone missing. And the third case plants a
document module that imports the engine string in a scratch tree and shows the walk
reports it, which is what makes the absence above a measurement.

`mobileWebAppRouteClosure` is now a caller of `mobileWebAppEntryClosure`, which takes
the entry points and an optional working directory; the route closure's own two entry
points and its extensionless-specifier reason are unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 11:31:23 -04:00

135 lines
5.1 KiB
JavaScript

import { readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { createRequire } from 'node:module'
import * as esbuild from 'esbuild'
const require = createRequire(import.meta.url)
const scriptDir = import.meta.dirname
const mobileRoot = path.resolve(scriptDir, '..')
const outputPath = path.join(mobileRoot, 'src', 'terminal', 'terminal-webview-engine.generated.ts')
/**
* The stylesheet is written beside the engine rather than inside it because the two have different
* consumers. The WebView document needs both, as text; the web page needs the CSS and must never
* resolve the 612 KiB engine string, which is unusable under the shell's `script-src 'self'` and
* is the largest module the session route's closure would otherwise carry. One file each is what
* lets the page import one without reaching the other.
*/
const cssOutputPath = path.join(
mobileRoot,
'src',
'terminal',
'terminal-webview-engine-css.generated.ts'
)
const target = 'chrome74'
const packages = ['@xterm/xterm', '@xterm/addon-unicode11', '@xterm/addon-webgl']
async function readPackageVersion(packageName) {
// Why: a package.json module specifier must use '/' — path.join emits '\' on
// Windows, yielding an unresolvable bare specifier that fails postinstall there.
const packageJsonPath = require.resolve(`${packageName}/package.json`)
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'))
return `${packageName}@${packageJson.version}`
}
function htmlText(value, closingTag) {
return value.replace(new RegExp(`</${closingTag}`, 'gi'), `<\\/${closingTag}`)
}
async function buildEngineJs() {
const result = await esbuild.build({
stdin: {
contents: `
import { Terminal } from '@xterm/xterm'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { WebglAddon } from '@xterm/addon-webgl'
// Why: xterm reaches for these runtime APIs on the terminal-bringup path,
// and esbuild lowers syntax but not runtime APIs. Guarded shims let the
// chrome74-syntax bundle actually run on old WebViews (the #7030 goal)
// instead of throwing at construction and only surfacing the error overlay.
// WeakRef (Chrome 84+): lazily constructed in window-tracking paths.
// Strong retention is fine for a single-document terminal WebView.
if (typeof window.WeakRef === 'undefined') {
window.WeakRef = function WeakRefShim(target) { this.__target = target }
window.WeakRef.prototype.deref = function () { return this.__target }
}
// structuredClone (Chrome 98+): xterm clones its plain-data DEC-mode default
// objects at Terminal construction; a JSON round-trip clones those correctly
// (any undefined-valued keys drop, but every reader treats absent == undefined).
if (typeof window.structuredClone === 'undefined') {
window.structuredClone = function (value) { return JSON.parse(JSON.stringify(value)) }
}
// Element.prototype.replaceChildren (Chrome 86+): used on the row/selection
// render path; polyfill via remove-all + append (Chrome 54+, under the floor).
if (typeof Element !== 'undefined' && !Element.prototype.replaceChildren) {
Element.prototype.replaceChildren = function () {
while (this.firstChild) this.removeChild(this.firstChild)
this.append.apply(this, arguments)
}
}
window.Terminal = Terminal
window.Unicode11Addon = { Unicode11Addon }
window.WebglAddon = { WebglAddon }
`,
resolveDir: mobileRoot,
sourcefile: 'terminal-webview-engine-entry.js'
},
bundle: true,
format: 'iife',
minify: true,
platform: 'browser',
target,
legalComments: 'none',
write: false,
logLevel: 'silent'
})
return result.outputFiles[0].text
}
async function main() {
const [engineJs, rawEngineCss, ...versions] = await Promise.all([
buildEngineJs(),
readFile(require.resolve('@xterm/xterm/css/xterm.css'), 'utf8'),
...packages.map(readPackageVersion)
])
// Why: the no-external-URL regression gate bans http(s):// anywhere in the
// terminal document. These xmlns URIs live inside data: URLs (never fetched);
// percent-encoding the scheme colon satisfies the gate and URI-decodes back
// before the SVG is parsed.
const engineCss = rawEngineCss
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/http:\/\/www\.w3\.org\/2000\/svg/g, 'http%3A//www.w3.org/2000/svg')
const header = [
'// Generated by scripts/build-terminal-webview-engine.mjs.',
`// Packages: ${versions.join(', ')}.`,
`// Target: ${target}. Do not edit by hand; regenerate via pnpm postinstall.`
]
await Promise.all([
writeFile(
outputPath,
[
...header,
`export const XTERM_ENGINE_JS = ${JSON.stringify(htmlText(engineJs, 'script'))}`,
''
].join('\n')
),
writeFile(
cssOutputPath,
[
...header,
`export const XTERM_ENGINE_CSS = ${JSON.stringify(htmlText(engineCss, 'style'))}`,
''
].join('\n')
)
])
}
await main()