Files
simstudioai--sim/scripts/check-desktop-bridge-contract.ts
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

260 lines
10 KiB
TypeScript

/**
* Desktop bridge contract audit.
*
* The desktop shell is an installed binary users update on their own
* schedule, while the web app it loads is deployed continuously. The preload
* bridge contract (`@sim/desktop-bridge`, which embeds `@sim/browser-protocol`
* types) must therefore stay backward compatible: an already-installed shell
* has to satisfy whatever the newest web deployment expects.
*
* This script keeps a frozen snapshot of the full bridge type surface
* (`packages/desktop-bridge/contract-snapshot.ts`) and type-checks that a
* shell built from the snapshot is still assignable to the current
* `SimDesktopApi` — additive/optional changes pass, removals, renames, and
* new required members fail.
*
* Modes:
* - `--check` (default, CI): fails when the current types break
* compatibility with the snapshot, or when the snapshot's recorded floor
* drifts from `MIN_DESKTOP_VERSION`.
* - `--update`: regenerates the snapshot from the current sources. A
* breaking regeneration is refused unless `MIN_DESKTOP_VERSION`
* (`apps/sim/lib/desktop/min-version.ts`) was raised above the previous
* snapshot's floor — bumping the floor is the deliberate escape hatch that
* makes outdated shells show the "update to continue" takeover.
*
* Known limitation: TypeScript checks method parameters bivariantly, so
* widening a request union or callback payload is not flagged. Those changes
* are additive for the shell (unknown requests fail soft), but semantic
* changes to callback payloads still need review.
*/
import { spawnSync } from 'node:child_process'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { readFile, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { formatGeneratedSource } from './format-generated-source'
import { localBin } from './local-bin'
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(SCRIPT_DIR, '..')
const BRIDGE_SOURCE_PATH = resolve(ROOT, 'packages/desktop-bridge/src/index.ts')
const PROTOCOL_SOURCE_PATH = resolve(ROOT, 'packages/browser-protocol/src/index.ts')
const TERMINAL_PROTOCOL_SOURCE_PATH = resolve(ROOT, 'packages/terminal-protocol/src/index.ts')
/**
* Protocol modules folded into the snapshot verbatim. Anything the bridge
* imports that carries wire shape belongs here — a package left out stays
* outside the freeze, and changes to it pass the audit unnoticed.
*/
const INLINED_PROTOCOL_PACKAGES = ['@sim/browser-protocol', '@sim/terminal-protocol'] as const
const SNAPSHOT_PATH = resolve(ROOT, 'packages/desktop-bridge/contract-snapshot.ts')
const MIN_VERSION_PATH = resolve(ROOT, 'apps/sim/lib/desktop/min-version.ts')
const FLOOR_PATTERN = /^ \* min-desktop-version: (\S+)$/m
async function readMinDesktopVersion(): Promise<string> {
const source = await readFile(MIN_VERSION_PATH, 'utf8')
const match = /export const MIN_DESKTOP_VERSION = '([^']+)'/.exec(source)
if (!match) {
throw new Error(`Could not find MIN_DESKTOP_VERSION in ${MIN_VERSION_PATH}`)
}
return match[1]
}
async function readSnapshot(): Promise<{ source: string; floor: string } | null> {
let source: string
try {
source = await readFile(SNAPSHOT_PATH, 'utf8')
} catch {
return null
}
const floor = FLOOR_PATTERN.exec(source)?.[1]
if (!floor) {
throw new Error(`${SNAPSHOT_PATH} is missing its min-desktop-version header`)
}
return { source, floor }
}
/** Plain x.y.z ordering for floor versions; throws on anything else. */
function isFloorRaised(next: string, previous: string): boolean {
const parse = (version: string): number[] => {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version)
if (!match) {
throw new Error(`MIN_DESKTOP_VERSION must be a plain x.y.z version, got '${version}'`)
}
return [Number(match[1]), Number(match[2]), Number(match[3])]
}
const [nextParts, previousParts] = [parse(next), parse(previous)]
for (let i = 0; i < 3; i++) {
if (nextParts[i] !== previousParts[i]) {
return nextParts[i] > previousParts[i]
}
}
return false
}
async function buildSnapshot(floor: string): Promise<string> {
const protocol = await readFile(PROTOCOL_SOURCE_PATH, 'utf8')
const terminalProtocol = await readFile(TERMINAL_PROTOCOL_SOURCE_PATH, 'utf8')
const bridgeRaw = await readFile(BRIDGE_SOURCE_PATH, 'utf8')
// The snapshot must be self-contained. A surviving import resolves to the
// CURRENT module on BOTH sides of the comparison, so every change to it is
// invisible to this audit — which is exactly what happened to
// @sim/terminal-protocol, leaving the whole terminal wire surface unfrozen.
const bridge = INLINED_PROTOCOL_PACKAGES.reduce(
(source, pkg) => source.replace(new RegExp(`import type \\{[^}]*\\} from '${pkg}'\\n`), ''),
bridgeRaw
)
for (const pkg of INLINED_PROTOCOL_PACKAGES) {
if (bridge.includes(pkg)) {
throw new Error(
`packages/desktop-bridge/src/index.ts references ${pkg} in an unexpected shape — ` +
'update scripts/check-desktop-bridge-contract.ts to inline it.'
)
}
}
const header = [
'/**',
' * GENERATED FILE — DO NOT EDIT.',
' *',
' * Frozen snapshot of the desktop preload bridge type surface',
' * (@sim/browser-protocol + @sim/terminal-protocol inlined into',
' * @sim/desktop-bridge) as of the last accepted contract change.',
' * CI type-checks that a shell built from this',
' * snapshot still satisfies the current SimDesktopApi, so bridge changes',
' * stay backward compatible with already-installed shells.',
' *',
' * Regenerate with: bun run desktop-bridge-contract:update',
' * Full rules: scripts/check-desktop-bridge-contract.ts',
' *',
` * min-desktop-version: ${floor}`,
' */',
'',
].join('\n')
const source = `${header}${protocol}\n${terminalProtocol}\n${bridge}`
return formatGeneratedSource(source, SNAPSHOT_PATH, ROOT)
}
/**
* Type-checks that an old shell (the committed snapshot) is assignable to
* the current SimDesktopApi — i.e. new web code can run against it.
*/
function checkCompatibility(): { compatible: boolean; output: string } {
const compatSource = [
`import type { SimDesktopApi as CurrentApi } from '${BRIDGE_SOURCE_PATH}'`,
`import type { SimDesktopApi as OldShellApi } from '${SNAPSHOT_PATH}'`,
'',
'declare const oldInstalledShell: OldShellApi',
'// If this assignment fails, the current web app expects something an',
'// already-installed shell cannot provide — a breaking bridge change.',
'const currentWebAppExpectation: CurrentApi = oldInstalledShell',
'void currentWebAppExpectation',
'',
].join('\n')
const tsconfig = {
compilerOptions: {
strict: true,
noEmit: true,
target: 'ES2022',
module: 'ESNext',
moduleResolution: 'bundler',
allowImportingTsExtensions: true,
skipLibCheck: true,
types: [],
paths: {
'@sim/browser-protocol': [PROTOCOL_SOURCE_PATH],
'@sim/terminal-protocol': [TERMINAL_PROTOCOL_SOURCE_PATH],
},
},
files: ['./compat.ts'],
}
const dir = mkdtempSync(join(tmpdir(), 'sim-desktop-bridge-contract-'))
try {
writeFileSync(join(dir, 'compat.ts'), compatSource)
writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2))
const result = spawnSync(localBin('tsc'), ['-p', dir, '--pretty', 'false'], {
cwd: ROOT,
encoding: 'utf8',
})
return {
compatible: result.status === 0,
output: `${result.stdout ?? ''}${result.stderr ?? ''}`.trim(),
}
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
const BREAKING_GUIDANCE = `
A shell built from the committed contract snapshot no longer satisfies the
current SimDesktopApi — installed desktop apps would break against this web
deployment. Either:
1. Make the change backward compatible: new fields, methods, and surfaces
on the bridge must be optional so older shells (which lack them) still
type-check. This is the default — prefer it.
2. If the change is genuinely breaking: bump MIN_DESKTOP_VERSION in
apps/sim/lib/desktop/min-version.ts to the desktop release your shell
change ships in, then run:
bun run desktop-bridge-contract:update
Shells older than that floor will show a blocking "Update Sim to
continue" screen until they update.
`
async function runCheck(): Promise<void> {
const [minVersion, snapshot] = await Promise.all([readMinDesktopVersion(), readSnapshot()])
if (!snapshot) {
console.error(
`Missing ${SNAPSHOT_PATH}.\nRun: bun run desktop-bridge-contract:update and commit the result.`
)
process.exit(1)
}
if (snapshot.floor !== minVersion) {
console.error(
`Contract snapshot floor (${snapshot.floor}) does not match MIN_DESKTOP_VERSION ` +
`(${minVersion}).\nRun: bun run desktop-bridge-contract:update and commit the result.`
)
process.exit(1)
}
const { compatible, output } = checkCompatibility()
if (!compatible) {
console.error('Breaking desktop bridge change detected.\n')
console.error(output)
console.error(BREAKING_GUIDANCE)
process.exit(1)
}
console.log('Desktop bridge contract audit passed: bridge types are backward compatible.')
}
async function runUpdate(): Promise<void> {
const [minVersion, snapshot] = await Promise.all([readMinDesktopVersion(), readSnapshot()])
if (snapshot) {
const { compatible, output } = checkCompatibility()
if (!compatible && !isFloorRaised(minVersion, snapshot.floor)) {
console.error('Refusing to accept a breaking bridge change without a floor bump.\n')
console.error(output)
console.error(BREAKING_GUIDANCE)
process.exit(1)
}
}
await writeFile(SNAPSHOT_PATH, await buildSnapshot(minVersion))
console.log(`Regenerated ${SNAPSHOT_PATH} (min-desktop-version: ${minVersion}).`)
}
const mode = process.argv.includes('--update') ? 'update' : 'check'
try {
if (mode === 'update') {
await runUpdate()
} else {
await runCheck()
}
} catch (error) {
console.error(error instanceof Error ? error.message : error)
process.exit(1)
}