Files
Maciej Lisiewski 7062229090 chore(js): switch the workspaces from npm to pnpm
pnpm 12.15.0 (native executable) installs every JS workspace from one
pnpm-lock.yaml, imported from package-lock.json with the same resolved
versions. website/, the WhatsApp bridge and the photon sidecar stay separate
npm projects.

Configuration moves from .npmrc and the root manifest into
pnpm-workspace.yaml: the 14-day minimumReleaseAge and its excludes,
overrides, allowBuilds (the old allowScripts set, with strictDepBuilds
failing any unlisted build script), engineStrict, sideEffectsCache: false
(in-place rebuilds must not write into the store) and
verifyDepsBeforeRun: false (a `pnpm run` never installs).

The pnpm version is pinned in pm/lock.json and in root devEngines.
pmOnFail: ignore keeps that pin out of pnpm-lock.yaml: a leading pin
document makes osv-scanner read zero packages. scripts/build/node-deps.mjs
enforces the exact version instead, and every lock reader refuses a
multi-document lock. A root preinstall refuses `npm install`; devEngines
onFail is warn so npx (MCP servers, the tsc linter) keeps working inside
the checkout.

User-machine installs run --frozen-lockfile --trust-lockfile (release age is
checked when locking, in CI, not per user). node-deps removes npm-era
node_modules trees before the first pnpm install. Nix builds the store with
fetchPnpmDeps and a hash file that CI checks.
2026-11-06 04:08:24 +01:00

97 lines
4.7 KiB
JavaScript

#!/usr/bin/env node
// Pure browser-renderer compilation. The workspace is prepared by the caller
// (apps/desktop/scripts/build-webapp.mjs); the receipt certifies the checkout.
import { constants, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync } from 'node:fs'
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { productOutput, withProduct, workspaceTool } from './frontend-common.mjs'
import { buildInputs, outputIntact, recordProduct, sourceHash } from './freshness.mjs'
// Vite's build manifest (apps/desktop/vite.config.ts names it too): the files a generation emitted.
const manifestName = 'renderer-manifest.json'
// The build still publishes; say why open tabs of the previous generation may miss this chunk.
const notKept = (from, error) =>
console.warn(`webapp: could not keep ${from} for tabs on the previous generation (${error?.code ?? error})`)
/** Keep in ``next`` the lazy chunks an open tab of the previous generation still loads: only missing
* regular files from a generation intact against its receipt, never through a symlink or over a file.
* A manifest omits the chunks its generation inherited, so retention stays one generation back. */
export function keepPreviousGeneration(previous, next) {
let root
let chunks
try {
if (!outputIntact(previous, 'webapp')) return []
root = realpathSync(previous)
chunks = Object.values(JSON.parse(readFileSync(join(previous, manifestName), 'utf8')) ?? {})
} catch {
return [] // no previous generation, or one without a manifest
}
const list = value => (Array.isArray(value) ? value : [])
const names = new Set(chunks.flatMap(chunk => [chunk?.file, ...list(chunk?.css), ...list(chunk?.assets)]))
const kept = []
for (const name of names) {
if (typeof name !== 'string' || !name || isAbsolute(name) || name.split(/[\\/]/).includes('..')) continue
const from = join(previous, name)
const to = join(next, name)
try {
// A symlink anywhere on the way would resolve elsewhere than the name it was listed under.
if (existsSync(to) || !lstatSync(from).isFile() || relative(root, realpathSync(from)) !== join(name)) continue
} catch {
continue // missing, or not a name this filesystem can hold
}
try {
mkdirSync(dirname(to), { recursive: true })
} catch (error) {
// Quiet when a new file stands where the name's directory would go.
if (!['EEXIST', 'ENOTDIR'].includes(error?.code)) notKept(from, error)
continue
}
try {
copyFileSync(from, to, constants.COPYFILE_EXCL)
kept.push(name)
} catch (error) {
if (error?.code === 'EEXIST') continue // a new file stood there after all
notKept(from, error)
// Only ever this copy's own partial file.
try {
rmSync(to, { force: true })
} catch (cleanup) {
console.warn(`webapp: could not remove the partial copy ${to} (${cleanup?.code ?? cleanup})`)
}
}
}
return kept
}
export async function buildWebapp({ source, workspace, out }) {
if (!workspace) throw new Error('A prepared Webapp workspace is required')
const app = 'apps/desktop'
workspace = resolve(workspace)
;({ source, out } = productOutput(source, out, [
// The output lives inside the Desktop workspace: protect its siblings, not the directory.
...readdirSync(join(resolve(source), app)).filter(name => !['dist', 'dist-webapp', 'build'].includes(name)).map(name => `${app}/${name}`),
'apps/shared', 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'scripts/build', 'node_modules',
relative(resolve(source), workspace),
]))
const inputs = buildInputs(source, 'webapp')
// The workspace is what compiles; a stale mirror must not publish under a current receipt.
if (sourceHash(workspace, 'webapp') !== inputs.sourceHash) {
throw new Error(`Prepared Webapp workspace ${workspace} does not match ${source}; prepare it again`)
}
const { build } = await import(pathToFileURL(workspaceTool(workspace, app, 'vite')).href)
await withProduct(out, async (product, scratch) => {
await build({
root: join(workspace, app),
configLoader: 'runner',
cacheDir: join(scratch, 'vite-cache'),
build: { outDir: product, emptyOutDir: true, manifest: manifestName },
})
if (!existsSync(join(product, 'index.html'))) throw new Error('Webapp build did not produce index.html')
// Before the receipt, whose output hash covers the kept files too.
keepPreviousGeneration(out, product)
recordProduct({ source, product: 'webapp', out: product, inputs })
}, { source })
return { out, index: join(out, 'index.html') }
}