Files
windmill/backend/windmill-api/src/apps_raw_bundler.ts
T
343ce6e143 fix: derive a raw app's policy on deploy, and default an omitted execution_mode (#10733)
* fix: default an omitted app policy execution_mode to publisher

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: drop stale comments claiming execution_mode is required

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: derive a raw app's policy on deploy instead of trusting the caller's

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: pin the ee ref to the companion branch

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: vendor the raw-app policy derivation into the bundle job

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: note the vendored raw-app policy bundle

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: derive the policy on a value-only raw-source update too

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: reject raw-app runnables whose shape yields an unusable grant

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: cache the new policy query and tighten raw-app runnable validation

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: let the policy bundle drift guard survive a CRLF checkout

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 23431f5cf1d627051ded89111bbf2e301e9db456

This commit updates the EE repository reference after PR #729 was merged in windmill-ee-private.

Previous ee-repo-ref: 0bdf8818fa115ad6b0d14f3117a18e8a580cce4d

New ee-repo-ref: 23431f5cf1d627051ded89111bbf2e301e9db456

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-08-18 01:52:10 +02:00

177 lines
7.6 KiB
TypeScript

/**
* Bundles a raw app's sources into the js/css a deployed raw app serves. Runs as
* a bun job so the compile happens on a worker, not in the API process — see
* `apps_raw_bundle.rs`, which is the only thing that runs it.
*
* The build itself is `wmill app bundle`, the same one `wmill app push` runs, so
* an app deployed through the API is compiled exactly as the CLI and the editor
* compile it — entry point, virtual `wmill` module, `/ui/` shared-UI resolution
* and the Svelte/Vue plugins included. Reimplementing any of that here would be
* a third bundler to keep in step with the other two.
*/
declare const __wmillAppPolicy: {
updateRawAppPolicy: (
runnables: Record<string, unknown>,
current: undefined
) => Promise<{ triggerables_v2: Record<string, unknown> }>
}
export async function main(
files: Record<string, string>,
shared_ui: Record<string, string> | undefined,
cli_command: string[],
// Set unless the server was told to build with a specific command.
prefer_installed_cli: boolean | undefined,
// The app's `value.runnables`, whose policy is derived here for the same
// reason the bundle is built here: it has to match what the editor writes.
runnables: Record<string, unknown> | undefined
): Promise<{ js_gz: string; css_gz: string; triggerables_v2: Record<string, unknown> }> {
const fs = await import('node:fs/promises')
const path = await import('node:path')
const dir = path.join(process.cwd(), 'wm_raw_app')
await fs.rm(dir, { recursive: true, force: true })
// Where a key lands, `path.join` normalising `./` and `..` away. Everything
// that reasons about a file goes through this, so nothing disagrees with what
// was actually written.
const target = (rel: string) => {
const abs = path.join(dir, rel.replace(/^\/+/, ''))
if (!abs.startsWith(dir + path.sep)) {
throw new Error(`file path escapes the build directory: ${rel}`)
}
return abs
}
const write = async (rel: string, content: string) => {
const abs = target(rel)
await fs.mkdir(path.dirname(abs), { recursive: true })
await fs.writeFile(abs, content)
}
for (const [p, content] of Object.entries(files ?? {})) {
await write(p, content)
}
// `ui/` next to the app is where `wmill app bundle` looks for the shared UI.
for (const [p, content] of Object.entries(shared_ui ?? {})) {
await write('ui/' + p.replace(/^\/+/, ''), content)
}
const manifest = path.join(dir, 'package.json')
const hasPackageJson = Object.keys(files ?? {}).some((p) => target(p) === manifest)
// Piped rather than inherited so the output can go in the error too, then
// echoed either way — the job's log is where someone looks to see what the
// build did.
const spawn = (argv: string[]) => {
const proc = Bun.spawnSync(argv, { cwd: dir, stdout: 'pipe', stderr: 'pipe' })
const output = proc.stdout.toString() + proc.stderr.toString()
console.log(output)
return { ok: proc.exitCode === 0, output }
}
const run = (argv: string[], what: string) => {
const { ok, output } = spawn(argv)
if (!ok) {
throw new Error(`${what} failed:\n${output}`)
}
return output
}
if (hasPackageJson) {
// Installed here rather than left to the CLI so it can be --ignore-scripts:
// the app's dependencies are compiled, never run, so a package's lifecycle
// script has no business executing on the worker. The CLI skips its own
// install once node_modules exists.
run(['bun', 'install', '--ignore-scripts'], 'bun install')
} else {
// The CLI installs when node_modules is missing, and it shells out to npm,
// which the slim images don't ship. An app with no manifest has nothing to
// install, so hand it the empty directory it would have produced.
await fs.mkdir(path.join(dir, 'node_modules'), { recursive: true })
}
const outDir = path.join(dir, 'dist')
// Prefer the CLI the image installed: no npm reachability needed at deploy
// time. It can predate `app bundle` (the images install it unpinned), and a
// CLI without the command exits with cliffy's usage text before building
// anything — so that specific failure, and only it, falls back to fetching
// the one for this server's release. `wmill --version` isn't used to decide:
// it reports npm's latest release as well as its own, and it reaches out to
// npm to do so, which is the cost this branch exists to avoid.
const installed = prefer_installed_cli ? Bun.which('wmill') : null
let buildOutput: string | undefined
if (installed) {
const attempt = spawn([installed, 'app', 'bundle', dir, '--out', outDir])
// Colours sit between the words cliffy prints, so match on the stripped text.
const plain = attempt.output.replace(/\x1b\[[0-9;]*m/g, '')
if (attempt.ok) {
buildOutput = attempt.output
} else if (!/Unknown command|Usage:\s+wmill app\b/.test(plain)) {
throw new Error(`bundle failed:\n${attempt.output}`)
}
}
if (buildOutput === undefined) {
buildOutput = run([...cli_command, dir, '--out', outDir], 'bundle')
}
const read = async (name: string) => {
try {
return await fs.readFile(path.join(outDir, name), 'utf8')
} catch {
return ''
}
}
const js = await read('bundle.js')
const css = await read('bundle.css')
if (js === '') {
throw new Error('bundle produced no javascript:\n' + buildOutput)
}
// A runnable the derivation can't fully classify still yields a key, just an
// unusable one (`r:undefined/undefined`, or the hash of an absent script), and
// the deploy would then succeed with grants no run can ever match. The tool
// schema describes `runnables` only as an object and the on-disk format has no
// discriminator at all (`wmill app push` adds it), so these shapes are all
// reachable: check them before deriving and name the ones at fault. An
// explicitly empty entry is a runnable nobody configured yet, and needs no
// grant.
const nonEmpty = (v: unknown) => typeof v === 'string' && v.length > 0
// The prefixes `execute_component` resolves a run against; anything else is a
// grant no run can match.
const RUN_TYPES = ['script', 'flow', 'hubscript']
const malformed = Object.entries(runnables ?? {})
.filter(([, r]) => r != null)
.filter(([, r]) => {
if (typeof r !== 'object') return true
const run = r as Record<string, any>
if (run.type === 'inline' || run.type === 'runnableByName') {
return !nonEmpty(run.inlineScript?.content)
}
if (run.type === 'path' || run.type === 'runnableByPath') {
return !RUN_TYPES.includes(run.runType) || !nonEmpty(run.path)
}
return true
})
.map(([id]) => id)
if (malformed.length > 0) {
throw new Error(
`no policy could be derived for runnable(s) ${malformed.join(', ')}: each must be an ` +
`object with a \`type\` of "inline" (with \`inlineScript.content\`) or "path" (with ` +
`\`path\` and a \`runType\` of ${RUN_TYPES.join(', ')})`
)
}
// The policy's `triggerables_v2` is the allowlist the server matches every run
// against, keyed by a hash of each inline runnable's code. Derived by the
// frontend's own code, bundled into this script by cli/generate-app-policy.ts,
// so the keys are the ones the app editor writes: anything else leaves the
// app's runnables "forbidden by policy". Prepended above as a plain `var`, so
// it is in this module's scope (a module's top-level `var` is not a global).
const { triggerables_v2 } = await __wmillAppPolicy.updateRawAppPolicy(
runnables ?? {},
undefined
)
// Gzipped so a large app's bundle stays well inside MAX_RESULT_SIZE_MB, which
// a deployment can set far below the 500MB default.
const gz = (s: string) => Buffer.from(Bun.gzipSync(Buffer.from(s, 'utf8'))).toString('base64')
return { js_gz: gz(js), css_gz: gz(css), triggerables_v2 }
}