mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
* feat(mobile-web): add the Phase A bootstrap web source A peer of src/ so the root workspace owns it and mobile's separate lockfile stays out of packaging. Four assets across four content types, enough to exercise multi-asset manifest handling rather than assume it. The page reads buildId from manifest.json at runtime: buildId hashes the asset list that index.html belongs to, so injecting it into a hashed asset would make that asset's hash depend on itself. Registered as a fourth typecheck project; without it the entry would be the only TypeScript in a release path that tsc never sees. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(build): build and verify the mobile web bundle from the root workspace Root esbuild over mobile-web/ into out/mobile-web/, content-addressed as assets/<sha256>.<ext> with index.html the only stable name. buildId is the sha256 of the canonical serialization of the sorted asset list, so it is a pure function of content and usable as a cache key with no further reasoning. The verifier builds twice into scratch dirs and compares: a timestamp, an absolute path, or an unstable ordering fails the build when someone introduces it, not the first time a phone gets a spurious cache miss. It also enforces the Phase A budget of 16 assets and 256 KiB, separate from the permanent contract ceiling. build:release does not call build:desktop, so build:mobile-web is wired into build:desktop, build:release, and build:release:parallel. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(packaging): fail the release when the mobile web bundle is missing or stale electron-builder only warns about a missing input, so without a beforePack guard a release ships an app that advertises the bundle capability and then errors on every request. The hash check, not the existence check, is what catches a half-written or stale out/. The source tree is excluded from app.asar; out/mobile-web ships inside it under the existing out rules, exactly as out/web does. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile-web): narrow the manifest with `in` instead of a cast The changed-code casting gate rejects assertions, and `in` narrows the same untrusted JSON without one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web): move the bundle source under src/ so the root guard passes .github/scripts/check-root-directory-entries.mjs blocks any new top-level entry by name, so mobile-web/ could not live at the root. The source is excluded from app.asar by the existing '!src{,/**/*}' rule; the explicit '!src/mobile-web{,/**/*}' entry stays as a marker. out/mobile-web is unaffected and still ships under the out rules like out/web. No tsconfig includes src/**, so node, web, cli, and relay do not pick the tree up; it is registered as a knip entry so audit:dead-code does not call it unused. buildId is unchanged at 9d78435e: the builder hashes content, not paths. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(build): resolve the entry-script guard through pathToFileURL `file://${process.argv[1]}` never equals import.meta.url on Windows, where that url is file:///C:/... So the builder exited 0 having written nothing and the Windows packaging job failed later, at the guard, with no clue why. Every other script in config/scripts already uses pathToFileURL; this one now does too, via an exported predicate a posix runner can exercise with a win32 path. The verify script had no entry guard at all, so importing its budget constants ran the whole verification — including its process.exit — inside the test worker. It is now a function behind the same guard. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(ci): build the mobile web bundle in the PR package job That job assembles packaging inputs step by step instead of calling build:release, so the new beforePack guard hard-failed it. The census test added here is the oracle: it walks every workflow job that invokes electron-builder without --prepackaged (which short-circuits doPack before beforePack) and requires a bundle-producing script in the same job. It goes red on exactly pr.yml's package job when this step is removed. Ten jobs covered; the other nine already ran build:release, build:release:parallel, or build:desktop. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web): pin source line endings, because CRLF changes the buildId Every text byte under src/mobile-web is hashed into an asset digest and from there into buildId, so a CRLF checkout produces a different bundle id for the same commit: 91af2897 instead of 9d78435e. That would make a Windows-built desktop disagree with a mac-built one about which bundle a phone has cached. .gitattributes pins eol=lf for the text sources and -text for the PNG, matching the four trees already pinned for byte-hashing. The verify script asserts no source file carries a CR, so the build fails if the pin ever stops applying rather than silently shipping a second bundle identity. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(build): read the test's own path from import.meta.filename oxlint unicorn/prefer-import-meta-properties. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(test): census packaging jobs over raw workflow text, not re-serialized YAML yaml.stringify folds long lines, and in dev-channel-win-build.yml's build-win the fold landed between `electron-builder` and `--config`, so a real packaging job was invisible to the census: 11 jobs exist, the test saw 10. Slice each job's raw source by its parsed boundaries instead, and pin the inventory so a new packaging workflow has to be added here on purpose. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): assert the script chain the packaging census trusts The census only checks that a packaging job invokes one of ten build scripts; that those scripts still reach build:mobile-web was asserted nowhere, so a dropped link would leave every job looking covered while packaging failed at beforePack. Resolve each script for real, and pin pr.yml's hand-rolled step, since that job never calls build:release. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(build): realpath the entry path before the direct-invocation compare Node resolves symlinks in import.meta.url but not in argv[1], so `node /tmp/...` against a /private/tmp realpath compared two different strings: the builder and the verifier exited 0 having written and checked nothing. Same silent-success shape as the Windows file:// bug, so the fix sits next to it, with both seams injectable. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile-web): format bootstrap.css with oxfmt It was the only tracked CSS failing oxfmt --check. The buildId is unchanged at 9d78435e8bb73c3341f833c20aaefbd7bfdfc414b68dadf87c1689d86728fe33, because esbuild's CSS minifier normalises the whitespace this touches before the asset is hashed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(packaging): reject bundle files the manifest does not list The guard only walked the manifest, so a dropped assets/stale.js passed: assets are content-addressed, nothing ever overwrites a stale copy, and it would ship inside asar unreachable and unverified. Require every file under out/mobile-web to be the manifest or a listed asset. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(packaging): give beforePack an explicit mobile web bundle root The bundle guard read the repo's out/mobile-web unconditionally, so the two arch-aware packaging tests that call the real beforePack went red in the unit-test job, which never runs build:mobile-web. beforePack now takes the bundle root as a second parameter defaulting to out/mobile-web, which is what electron-builder gets, and those tests build a real bundle into a temp dir instead. The guard is neither skipped nor made tolerant of a missing bundle. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(packaging): census sees script-wrapped packers; dev verify reuses the guard The workflow census only matched a literal `electron-builder --config` line, so daemon-relocation-spike's `pnpm run build:unpack` (which packs and runs beforePack) was invisible to it. Jobs now count when any `pnpm run <script>` they invoke chains to electron-builder without --prepackaged; the spike joins the pinned list (12 jobs). verify-mobile-web-bundle.mjs re-implemented a weaker subset of the packaging guard (no safe-path check, no buildId recompute). It now calls assertMobileWebBundleBuilt, so a manifest edited after the build fails at `pnpm build:mobile-web` exactly as at beforePack. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
231 lines
8.3 KiB
JavaScript
231 lines
8.3 KiB
JavaScript
import { createHash } from 'node:crypto'
|
|
import { realpathSync } from 'node:fs'
|
|
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
import { join } from 'node:path'
|
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
import * as esbuild from 'esbuild'
|
|
|
|
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
|
|
const sourceDir = join(projectDir, 'src', 'mobile-web')
|
|
const defaultOutDir = join(projectDir, 'out', 'mobile-web')
|
|
|
|
export const MOBILE_WEB_BUNDLE_SCHEMA_VERSION = 1
|
|
export const MOBILE_WEB_BUNDLE_ENTRYPOINT = 'index.html'
|
|
|
|
const CONTENT_TYPE_BY_EXTENSION = {
|
|
css: 'text/css; charset=utf-8',
|
|
html: 'text/html; charset=utf-8',
|
|
js: 'text/javascript; charset=utf-8',
|
|
png: 'image/png'
|
|
}
|
|
|
|
/**
|
|
* Canonical serialization the buildId hashes. Key order is fixed and the list is sorted by path,
|
|
* so the id is a pure function of content. Must stay byte-identical to the contract module's
|
|
* serializer in src/shared/mobile-web-bundle/.
|
|
*/
|
|
export function serializeMobileWebBundleAssets(assets) {
|
|
return JSON.stringify(
|
|
[...assets]
|
|
.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
|
|
.map(({ path, sha256, byteLength, contentType }) => ({
|
|
path,
|
|
sha256,
|
|
byteLength,
|
|
contentType
|
|
}))
|
|
)
|
|
}
|
|
|
|
export function computeMobileWebBundleBuildId(assets) {
|
|
return createHash('sha256').update(serializeMobileWebBundleAssets(assets), 'utf8').digest('hex')
|
|
}
|
|
|
|
function sha256Hex(bytes) {
|
|
return createHash('sha256').update(bytes).digest('hex')
|
|
}
|
|
|
|
function contentTypeForExtension(extension) {
|
|
const contentType = CONTENT_TYPE_BY_EXTENSION[extension]
|
|
if (!contentType) {
|
|
throw new Error(`[build-mobile-web-bundle] no content type registered for .${extension}`)
|
|
}
|
|
return contentType
|
|
}
|
|
|
|
function readIntegerConstant(source, name) {
|
|
const match = new RegExp(`export const ${name} = (\\d+)`).exec(source)
|
|
if (!match) {
|
|
throw new Error(`[build-mobile-web-bundle] ${name} not found in src/shared/protocol-version.ts`)
|
|
}
|
|
return Number.parseInt(match[1], 10)
|
|
}
|
|
|
|
/**
|
|
* Parsed rather than imported because protocol-version.ts is TypeScript and this script runs on
|
|
* bare node during packaging, before any build output exists.
|
|
*/
|
|
async function readProtocolWindow() {
|
|
const source = await readFile(join(projectDir, 'src', 'shared', 'protocol-version.ts'), 'utf8')
|
|
return {
|
|
runtimeProtocolVersion: readIntegerConstant(source, 'RUNTIME_PROTOCOL_VERSION'),
|
|
// The bundle is a client: the floor it cares about is the oldest host protocol it can talk to.
|
|
minCompatibleRuntimeProtocolVersion: readIntegerConstant(
|
|
source,
|
|
'MIN_COMPATIBLE_RUNTIME_SERVER_VERSION'
|
|
)
|
|
}
|
|
}
|
|
|
|
async function readDesktopVersion() {
|
|
const packageJson = JSON.parse(await readFile(join(projectDir, 'package.json'), 'utf8'))
|
|
if (typeof packageJson.version !== 'string' || packageJson.version.length === 0) {
|
|
throw new Error('[build-mobile-web-bundle] root package.json has no version')
|
|
}
|
|
return packageJson.version
|
|
}
|
|
|
|
async function transformEntries(protocolWindow, desktopVersion) {
|
|
const result = await esbuild.build({
|
|
absWorkingDir: sourceDir,
|
|
entryPoints: [join(sourceDir, 'src', 'bootstrap.ts'), join(sourceDir, 'src', 'bootstrap.css')],
|
|
bundle: true,
|
|
minify: true,
|
|
// Virtual: write is false, so outdir only names the emitted files esbuild hands back.
|
|
outdir: 'dist',
|
|
write: false,
|
|
format: 'iife',
|
|
target: ['es2022'],
|
|
charset: 'utf8',
|
|
legalComments: 'none',
|
|
// Why no sourcemap and no metafile: both embed absolute paths, which would break reproducibility.
|
|
sourcemap: false,
|
|
logLevel: 'silent',
|
|
define: {
|
|
ORCA_MOBILE_WEB_DESKTOP_VERSION: JSON.stringify(desktopVersion),
|
|
ORCA_MOBILE_WEB_RUNTIME_PROTOCOL_VERSION: JSON.stringify(
|
|
protocolWindow.runtimeProtocolVersion
|
|
),
|
|
ORCA_MOBILE_WEB_MIN_COMPATIBLE_RUNTIME_PROTOCOL_VERSION: JSON.stringify(
|
|
protocolWindow.minCompatibleRuntimeProtocolVersion
|
|
)
|
|
}
|
|
})
|
|
const byExtension = new Map()
|
|
for (const file of result.outputFiles) {
|
|
const extension = file.path.endsWith('.css') ? 'css' : 'js'
|
|
byExtension.set(extension, Buffer.from(file.contents))
|
|
}
|
|
const script = byExtension.get('js')
|
|
const stylesheet = byExtension.get('css')
|
|
if (!script || !stylesheet) {
|
|
throw new Error('[build-mobile-web-bundle] esbuild did not emit both a script and a stylesheet')
|
|
}
|
|
return { script, stylesheet }
|
|
}
|
|
|
|
function hashedAsset(bytes, extension) {
|
|
const sha256 = sha256Hex(bytes)
|
|
return {
|
|
bytes,
|
|
path: `assets/${sha256}.${extension}`,
|
|
sha256,
|
|
byteLength: bytes.byteLength,
|
|
contentType: contentTypeForExtension(extension)
|
|
}
|
|
}
|
|
|
|
export async function buildMobileWebBundle({ outDir = defaultOutDir } = {}) {
|
|
const [desktopVersion, protocolWindow] = await Promise.all([
|
|
readDesktopVersion(),
|
|
readProtocolWindow()
|
|
])
|
|
const { script, stylesheet } = await transformEntries(protocolWindow, desktopVersion)
|
|
const mark = await readFile(join(sourceDir, 'src', 'orca-mark.png'))
|
|
|
|
const hashed = [
|
|
hashedAsset(script, 'js'),
|
|
hashedAsset(stylesheet, 'css'),
|
|
hashedAsset(mark, 'png')
|
|
]
|
|
const [scriptAsset, stylesheetAsset, markAsset] = hashed
|
|
|
|
const template = await readFile(join(sourceDir, MOBILE_WEB_BUNDLE_ENTRYPOINT), 'utf8')
|
|
const substitutions = {
|
|
__ORCA_BOOTSTRAP_JS__: scriptAsset.path,
|
|
__ORCA_BOOTSTRAP_CSS__: stylesheetAsset.path,
|
|
__ORCA_MARK_PNG__: markAsset.path
|
|
}
|
|
let html = template
|
|
for (const [token, value] of Object.entries(substitutions)) {
|
|
if (!html.includes(token)) {
|
|
throw new Error(`[build-mobile-web-bundle] ${MOBILE_WEB_BUNDLE_ENTRYPOINT} lacks ${token}`)
|
|
}
|
|
html = html.replaceAll(token, value)
|
|
}
|
|
const indexBytes = Buffer.from(html, 'utf8')
|
|
const indexAsset = {
|
|
bytes: indexBytes,
|
|
path: MOBILE_WEB_BUNDLE_ENTRYPOINT,
|
|
sha256: sha256Hex(indexBytes),
|
|
byteLength: indexBytes.byteLength,
|
|
contentType: contentTypeForExtension('html')
|
|
}
|
|
|
|
const written = [indexAsset, ...hashed]
|
|
const assets = written
|
|
.map(({ path, sha256, byteLength, contentType }) => ({ path, sha256, byteLength, contentType }))
|
|
.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
|
|
const manifest = {
|
|
schemaVersion: MOBILE_WEB_BUNDLE_SCHEMA_VERSION,
|
|
buildId: computeMobileWebBundleBuildId(assets),
|
|
desktopVersion,
|
|
minCompatibleRuntimeProtocolVersion: protocolWindow.minCompatibleRuntimeProtocolVersion,
|
|
runtimeProtocolVersion: protocolWindow.runtimeProtocolVersion,
|
|
entrypoint: MOBILE_WEB_BUNDLE_ENTRYPOINT,
|
|
totalBytes: assets.reduce((total, asset) => total + asset.byteLength, 0),
|
|
assets
|
|
}
|
|
|
|
// Why a full clear: a stale asset left from an earlier build would ship unreferenced inside asar.
|
|
await rm(outDir, { recursive: true, force: true })
|
|
await mkdir(join(outDir, 'assets'), { recursive: true })
|
|
for (const asset of written) {
|
|
await writeFile(join(outDir, asset.path), asset.bytes)
|
|
}
|
|
await writeFile(join(outDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
|
|
return { manifest, outDir }
|
|
}
|
|
|
|
/**
|
|
* Whether this module was run as the entry script. Two ways to get this wrong, both of which end
|
|
* with the builder exiting 0 having written nothing: `file://${path}` never matches on Windows,
|
|
* where import.meta.url is `file:///C:/...`; and Node resolves symlinks in import.meta.url but not
|
|
* in argv[1], so `node /tmp/...` against a /private/tmp realpath compares two different strings.
|
|
* Both seams are injectable so win32 and a missing path can be exercised from a posix runner.
|
|
*/
|
|
export function isDirectInvocation(
|
|
moduleUrl,
|
|
scriptPath,
|
|
{ toFileUrl = pathToFileURL, realpath = realpathSync } = {}
|
|
) {
|
|
if (!scriptPath) {
|
|
return false
|
|
}
|
|
let resolved = scriptPath
|
|
try {
|
|
resolved = realpath(scriptPath)
|
|
} catch {
|
|
// A path that cannot be resolved cannot be this module; fall through to the literal compare.
|
|
}
|
|
return moduleUrl === toFileUrl(resolved).href
|
|
}
|
|
|
|
if (isDirectInvocation(import.meta.url, process.argv[1])) {
|
|
const { manifest, outDir } = await buildMobileWebBundle()
|
|
console.log(
|
|
`[build-mobile-web-bundle] OK — ${String(manifest.assets.length)} asset(s), ` +
|
|
`${String(manifest.totalBytes)} bytes, buildId ${manifest.buildId} -> ${outDir}`
|
|
)
|
|
}
|