mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-11 08:07:15 +00:00
feat: deploy a raw app from its sources, bundling them on a worker
This commit is contained in:
@@ -25,6 +25,7 @@ Open-source platform for internal tools, workflows, API integrations, background
|
||||
- **Brand/UI guidelines**: `frontend/brand-guidelines.md`
|
||||
- **CLI commands**: when adding/modifying/removing a command, subcommand, option, or description in `cli/src/commands/`, run `python system_prompts/generate.py` to refresh `system_prompts/auto-generated/` and `cli/src/guidance/skills.gen.ts`. The CLI docs the agents use to operate `wmill` are derived from the source — stale generated files give agents the wrong flags.
|
||||
- **Session recorder**: `frontend/src/lib/components/recording/` is also the recorder `wmill app dev --recording` serves, vendored into the CLI as `cli/src/commands/app/devRecorderBundle.gen.ts`. After changing `rawAppSnapshot.ts` or `rawAppRecording.svelte.ts`, run `bun run gen:dev-recorder` from `cli/` (`cli/test/dev_recorder_bundle_unit.test.ts` fails otherwise).
|
||||
- **Raw-app `wmill` client**: `frontend/src/lib/rawAppWmillTs.ts` is injected as a virtual module by all three raw-app bundlers, so the backend one carries a copy at `backend/windmill-api/src/apps_raw_wmill_ts.ts`. After changing it, copy it over (`apps_raw_bundle.rs`'s `test_vendored_wmill_ts_matches_frontend` fails otherwise).
|
||||
|
||||
## Dev Environment
|
||||
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT files FROM workspace_shared_ui WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "files",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "98206f64a4393791f679a609a5ee1a6bb6dae0bc091e2cbb2d583dd6a5b44941"
|
||||
}
|
||||
@@ -411,6 +411,19 @@ async fn test_raw_app_kind_is_not_flipped_by_update(db: Pool<Postgres>) -> anyho
|
||||
"expected the raw update of a low-code app to be refused"
|
||||
);
|
||||
|
||||
// The source endpoint refuses the same mismatch up front — it must not queue
|
||||
// a bundle job (which no worker would pick up here) to find that out.
|
||||
let resp = authed(client().post(format!("{base}/update_raw_source/{low_code_path}")))
|
||||
.json(&json!({ "value": { "files": { "/index.tsx": "export {}" } } }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 400);
|
||||
assert!(
|
||||
resp.text().await?.contains("is a low-code app"),
|
||||
"expected the source deploy of a low-code app to be refused"
|
||||
);
|
||||
|
||||
// Metadata-only updates and same-kind deploys still go through.
|
||||
let resp = authed(client().post(format!("{base}/update/{raw_path}")))
|
||||
.json(&json!({ "summary": "Renamed" }))
|
||||
|
||||
@@ -12259,7 +12259,7 @@ paths:
|
||||
summary: update app
|
||||
operationId: updateApp
|
||||
x-mcp-tool: true
|
||||
x-mcp-instructions: "Low-code apps only. An app whose `raw_app` field (from getAppByPath) is true is a raw (full-code) app: its value holds source files that must be compiled to a js/css bundle, which this tool cannot upload, so updating one here is refused. Edit a raw app in its editor at /apps_raw/edit/<path> instead."
|
||||
x-mcp-instructions: "Low-code apps only. An app whose `raw_app` field (from getAppByPath) is true is a raw (full-code) app and is refused here; use updateAppRawSource for those."
|
||||
x-mcp-tool-include-fields:
|
||||
- path
|
||||
- value
|
||||
@@ -12314,6 +12314,74 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/apps/update_raw_source/{path}:
|
||||
post:
|
||||
summary: update a raw app from its sources, bundling them server-side
|
||||
operationId: updateAppRawSource
|
||||
x-mcp-tool: true
|
||||
x-mcp-instructions: "Use this to change a raw (full-code) app — an app whose `raw_app` field is true. Send the whole `value` (`files`, `runnables`, `data`), not a patch: read the current one with getAppByPath first and edit it. The sources are compiled on a worker, so a compile error comes back as the error of this call. Low-code apps use updateApp instead."
|
||||
x-mcp-tool-include-fields:
|
||||
- path
|
||||
- value
|
||||
- summary
|
||||
- policy
|
||||
tags:
|
||||
- app
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/ScriptPath"
|
||||
requestBody:
|
||||
description: raw app sources to bundle and deploy
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
value:
|
||||
type: object
|
||||
description: "The raw app's value. `files` maps each source path (e.g. `/index.tsx`, `/App.tsx`, `/package.json`) to its content and must contain an entry point (`/index.tsx`, `/index.ts` or `/index.js`); `runnables` and `data` are carried through unchanged."
|
||||
properties:
|
||||
files:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
runnables:
|
||||
type: object
|
||||
data:
|
||||
type: object
|
||||
required: [files]
|
||||
policy:
|
||||
$ref: "#/components/schemas/Policy"
|
||||
deployment_message:
|
||||
type: string
|
||||
custom_path:
|
||||
type: string
|
||||
preserve_on_behalf_of:
|
||||
type: boolean
|
||||
description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original on_behalf_of value in the policy instead of overwriting it."
|
||||
labels:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
skip_draft_deletion:
|
||||
type: boolean
|
||||
description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path."
|
||||
allow_kind_change:
|
||||
type: boolean
|
||||
description: "When true, this deploy may switch the app between low-code and raw. Without it, deploying a value to an app of the other kind is refused so an app is never converted by accident."
|
||||
responses:
|
||||
"200":
|
||||
description: app updated
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/apps/update_raw/{path}:
|
||||
post:
|
||||
summary: update app
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::job_helpers_oss::{
|
||||
spawn_storage_usage_recount_floored,
|
||||
};
|
||||
use crate::{
|
||||
apps_raw_bundle,
|
||||
auth::{get_end_user_email, OptTokened},
|
||||
db::{ApiAuthed, DB},
|
||||
jobs::RunJobQuery,
|
||||
@@ -113,6 +114,11 @@ pub fn workspaced_service(raw_app_body_limit: usize) -> Router {
|
||||
"/update_raw/{*path}",
|
||||
post(update_app_raw).layer(axum::extract::DefaultBodyLimit::max(raw_app_body_limit)),
|
||||
)
|
||||
.route(
|
||||
"/update_raw_source/{*path}",
|
||||
post(update_app_raw_source)
|
||||
.layer(axum::extract::DefaultBodyLimit::max(raw_app_body_limit)),
|
||||
)
|
||||
.route("/delete/{*path}", delete(delete_app))
|
||||
.route("/create", post(create_app))
|
||||
.route(
|
||||
@@ -2346,6 +2352,127 @@ async fn update_app(
|
||||
Ok(format!("app {} updated (npath: {:?})", opath, npath))
|
||||
}
|
||||
|
||||
/// Deploy a raw app from its sources, compiling them on a worker. `update_raw`
|
||||
/// takes the bundle the caller already built (the editor and the CLI both
|
||||
/// bundle before calling it); an API client has nothing to build with, so this
|
||||
/// is the raw-app deploy an MCP agent or a script can actually reach.
|
||||
async fn update_app_raw_source(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(webhook): Extension<WebhookShared>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Json(ns): Json<EditApp>,
|
||||
) -> Result<String> {
|
||||
if authed.is_operator {
|
||||
return Err(Error::NotAuthorized(
|
||||
"Operators cannot update apps for security reasons".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("apps:write:{}", path))?;
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let Some(value) = ns.value.as_ref() else {
|
||||
return Err(Error::BadRequest(
|
||||
"value with the app's `files` is required to deploy a raw app".to_string(),
|
||||
));
|
||||
};
|
||||
let files: RawAppSourceFiles = serde_json::from_str(value.0.get())
|
||||
.map_err(|e| Error::BadRequest(format!("app value is not a raw app source: {e}")))?;
|
||||
|
||||
// Before the compile, which costs a job: an app of the other kind is refused
|
||||
// by update_app_internal anyway, and the caller shouldn't wait to find out.
|
||||
ensure_deployed_kind_matches(&db, &w_id, path, true, ns.allow_kind_change).await?;
|
||||
|
||||
let (js, css) =
|
||||
apps_raw_bundle::bundle_raw_app_sources(&db, &user_db, &authed, &w_id, &files.files)
|
||||
.await?;
|
||||
|
||||
let opath = path.to_string();
|
||||
let (mut tx, npath, id) =
|
||||
update_app_internal(authed, db, user_db, &w_id, path, true, ns).await?;
|
||||
store_raw_app_file(&w_id, &id, "js", bytes::Bytes::from(js), &mut tx).await?;
|
||||
if !css.is_empty() {
|
||||
store_raw_app_file(&w_id, &id, "css", bytes::Bytes::from(css), &mut tx).await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
|
||||
webhook.send_message(
|
||||
w_id.clone(),
|
||||
WebhookMessage::UpdateApp {
|
||||
workspace: w_id.clone(),
|
||||
old_path: opath.clone(),
|
||||
new_path: npath.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
Ok(format!("app {} updated (npath: {:?})", opath, npath))
|
||||
}
|
||||
|
||||
/// The part of a raw app's value the bundler needs.
|
||||
#[derive(Deserialize)]
|
||||
struct RawAppSourceFiles {
|
||||
#[serde(default)]
|
||||
files: HashMap<String, String>,
|
||||
}
|
||||
|
||||
fn reject_kind_change(path: &str, raw_app: bool, deployed_raw_app: Option<bool>) -> Result<()> {
|
||||
if !deployed_raw_app.is_some_and(|deployed| deployed != raw_app) {
|
||||
return Ok(());
|
||||
}
|
||||
// Name the folder suffix too: a sync push picks the endpoint from the repo
|
||||
// layout, so its operator has no endpoint to swap, only a folder.
|
||||
let (kind, endpoint, folder) = if raw_app {
|
||||
("a low-code app", "/apps/update", ".app")
|
||||
} else {
|
||||
("a raw app", "/apps/update_raw", ".raw_app")
|
||||
};
|
||||
Err(Error::BadRequest(format!(
|
||||
"App {path} is {kind}: deploying a value to it through the other kind's endpoint \
|
||||
would convert it and strand its bundle. Deploy it through {endpoint} instead \
|
||||
(from a synced repo, from a `{folder}` folder), or set allow_kind_change to \
|
||||
convert it on purpose."
|
||||
)))
|
||||
}
|
||||
|
||||
/// The same refusal `update_app_internal` makes, run before the caller pays for
|
||||
/// a compile. Advisory only — the locked check inside the transaction is what
|
||||
/// actually holds the invariant.
|
||||
async fn ensure_deployed_kind_matches(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
path: &str,
|
||||
raw_app: bool,
|
||||
allow_kind_change: Option<bool>,
|
||||
) -> Result<()> {
|
||||
if allow_kind_change.unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
let deployed_raw_app = sqlx::query_scalar!(
|
||||
"SELECT app_version.raw_app FROM app
|
||||
JOIN app_version ON app_version.id = app.versions[array_upper(app.versions, 1)]
|
||||
WHERE app.path = $1 AND app.workspace_id = $2",
|
||||
path,
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
reject_kind_change(path, raw_app, deployed_raw_app)
|
||||
}
|
||||
|
||||
async fn update_app_raw<'a>(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
@@ -2448,21 +2575,7 @@ async fn update_app_internal<'a>(
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
if deployed_raw_app.is_some_and(|deployed| deployed != raw_app) {
|
||||
// Name the folder suffix too: a sync push picks the endpoint from the
|
||||
// repo layout, so its operator has no endpoint to swap, only a folder.
|
||||
let (kind, endpoint, folder) = if raw_app {
|
||||
("a low-code app", "/apps/update", ".app")
|
||||
} else {
|
||||
("a raw app", "/apps/update_raw", ".raw_app")
|
||||
};
|
||||
return Err(Error::BadRequest(format!(
|
||||
"App {path} is {kind}: deploying a value to it through the other kind's endpoint \
|
||||
would convert it and strand its bundle. Deploy it through {endpoint} instead \
|
||||
(from a synced repo, from a `{folder}` folder), or set allow_kind_change to \
|
||||
convert it on purpose."
|
||||
)));
|
||||
}
|
||||
reject_kind_change(path, raw_app, deployed_raw_app)?;
|
||||
}
|
||||
|
||||
let mut preserved_on_behalf_of: Option<String> = None;
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Server-side bundling of a raw app's sources.
|
||||
//!
|
||||
//! A raw app is served as a compiled js/css bundle, so every deploy path has to
|
||||
//! compile first: the editor bundles in a browser iframe, the CLI bundles with
|
||||
//! esbuild on the developer's machine. Neither is reachable from a plain API
|
||||
//! call, which is why `/apps/update_raw` takes the bundle as multipart and why
|
||||
//! there was no way to deploy a raw app from an API client (an MCP agent, most
|
||||
//! of all — its only app-write tool was the low-code one, which converted the
|
||||
//! app instead).
|
||||
//!
|
||||
//! This runs the compile as a normal bun job on a worker: no new job kind, no
|
||||
//! new executor, and the build's logs, timeout, cancellation and attribution are
|
||||
//! the ones every other job gets.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{Error, Result},
|
||||
jobs::{JobPayload, RawCode},
|
||||
scripts::ScriptLang,
|
||||
worker::to_raw_value,
|
||||
DB,
|
||||
};
|
||||
use windmill_queue::{push, PushArgs, PushIsolationLevel};
|
||||
|
||||
use crate::db::ApiAuthed;
|
||||
|
||||
/// The bundler, as its own file so it stays readable/lintable TypeScript.
|
||||
const BUNDLER_TS: &str = include_str!("apps_raw_bundler.ts");
|
||||
|
||||
/// The raw-app `wmill` client, vendored from `frontend/src/lib/rawAppWmillTs.ts`
|
||||
/// because the backend build has no access to the frontend tree. The bundler
|
||||
/// injects it as a virtual module, exactly as the other two bundlers do.
|
||||
/// `test_vendored_wmill_ts_matches_frontend` fails if the copies drift.
|
||||
const RAW_APP_WMILL_TS: &str = include_str!("apps_raw_wmill_ts.ts");
|
||||
|
||||
/// Cap the compile so a pathological `package.json` can't hold the request open
|
||||
/// for the full `TIMEOUT_WAIT_RESULT`.
|
||||
const BUNDLE_TIMEOUT_SECS: i32 = 300;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BundleResult {
|
||||
js: String,
|
||||
css: String,
|
||||
}
|
||||
|
||||
/// Compile `files` into the js/css a deployed raw app serves. Returns the
|
||||
/// bundler's own error when the build fails, so the caller sees the compile
|
||||
/// error rather than a generic failure.
|
||||
pub async fn bundle_raw_app_sources(
|
||||
db: &DB,
|
||||
user_db: &UserDB,
|
||||
authed: &ApiAuthed,
|
||||
w_id: &str,
|
||||
files: &HashMap<String, String>,
|
||||
) -> Result<(String, String)> {
|
||||
if files.is_empty() {
|
||||
return Err(Error::BadRequest(
|
||||
"app value has no `files` to bundle".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let shared_ui = shared_ui_files(user_db, authed, w_id).await?;
|
||||
|
||||
let mut args: HashMap<String, Box<serde_json::value::RawValue>> = HashMap::new();
|
||||
args.insert("files".to_string(), to_raw_value(files));
|
||||
args.insert("wmill_ts".to_string(), to_raw_value(&RAW_APP_WMILL_TS));
|
||||
args.insert("shared_ui".to_string(), to_raw_value(&shared_ui));
|
||||
|
||||
let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into());
|
||||
let (uuid, tx) = push(
|
||||
db,
|
||||
tx,
|
||||
w_id,
|
||||
JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content: BUNDLER_TS.to_string(),
|
||||
path: Some("bundle raw app".to_string()),
|
||||
language: ScriptLang::Bun,
|
||||
lock: None,
|
||||
concurrency_settings: Default::default(),
|
||||
debouncing_settings: Default::default(),
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
modules: None,
|
||||
tag: None,
|
||||
}),
|
||||
PushArgs { args: &args, extra: None },
|
||||
authed.display_username(),
|
||||
&authed.email,
|
||||
windmill_common::users::username_to_permissioned_as(&authed.username),
|
||||
authed.token_prefix.as_deref(),
|
||||
authed.username_override.as_deref(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
true,
|
||||
None,
|
||||
Some(BUNDLE_TIMEOUT_SECS),
|
||||
None,
|
||||
None,
|
||||
Some(&authed.clone().into()),
|
||||
false,
|
||||
None,
|
||||
authed.trigger_or_fallback(None),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
wait_for_bundle(db, w_id, uuid, authed).await
|
||||
}
|
||||
|
||||
async fn wait_for_bundle(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
uuid: Uuid,
|
||||
authed: &ApiAuthed,
|
||||
) -> Result<(String, String)> {
|
||||
let (result, success) = windmill_api_jobs::execution::run_wait_result_internal(
|
||||
db,
|
||||
uuid,
|
||||
w_id,
|
||||
None,
|
||||
false,
|
||||
&authed.username,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !success {
|
||||
// The job's error is the compile error the caller needs to act on.
|
||||
return Err(Error::BadRequest(format!(
|
||||
"raw app bundling failed (job {uuid}): {}",
|
||||
result.get()
|
||||
)));
|
||||
}
|
||||
|
||||
let bundle: BundleResult = serde_json::from_str(result.get()).map_err(|e| {
|
||||
Error::internal_err(format!(
|
||||
"unexpected raw app bundler result (job {uuid}): {e}"
|
||||
))
|
||||
})?;
|
||||
Ok((bundle.js, bundle.css))
|
||||
}
|
||||
|
||||
async fn shared_ui_files(
|
||||
user_db: &UserDB,
|
||||
authed: &ApiAuthed,
|
||||
w_id: &str,
|
||||
) -> Result<HashMap<String, String>> {
|
||||
let mut tx = user_db.clone().begin(authed).await?;
|
||||
let files = sqlx::query_scalar!(
|
||||
"SELECT files FROM workspace_shared_ui WHERE workspace_id = $1",
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(files
|
||||
.and_then(|f| serde_json::from_value::<HashMap<String, String>>(f).ok())
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// The vendored copy is what the bundler injects, so a change to the
|
||||
/// frontend's `wmill` client that doesn't reach it would ship apps compiled
|
||||
/// against a different client than the editor compiles against.
|
||||
#[test]
|
||||
fn test_vendored_wmill_ts_matches_frontend() {
|
||||
let frontend = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../frontend/src/lib/rawAppWmillTs.ts");
|
||||
// Absent in a backend-only checkout (the published crate, a Docker build
|
||||
// context); there is nothing to compare against then.
|
||||
let Ok(expected) = std::fs::read_to_string(&frontend) else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(
|
||||
expected.replace("\r\n", "\n"),
|
||||
super::RAW_APP_WMILL_TS.replace("\r\n", "\n"),
|
||||
"backend/windmill-api/src/apps_raw_wmill_ts.ts is stale — copy \
|
||||
frontend/src/lib/rawAppWmillTs.ts over it"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Mirrors the two bundlers that already exist (the editor's, in the ui_builder
|
||||
* iframe, and the CLI's `createBundle`): same entry-point pick, the same virtual
|
||||
* `wmill` module, `/ui/` resolved against the workspace's shared UI, and
|
||||
* NODE_ENV pinned to production — without which React ships its dev build and
|
||||
* the bundle doubles in size.
|
||||
*/
|
||||
export async function main(
|
||||
files: Record<string, string>,
|
||||
wmill_ts: string,
|
||||
shared_ui: Record<string, string> | undefined
|
||||
): Promise<{ js: string; css: string }> {
|
||||
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 })
|
||||
|
||||
const write = async (rel: string, content: string) => {
|
||||
const abs = path.join(dir, rel.replace(/^\/+/, ''))
|
||||
if (!abs.startsWith(dir + path.sep)) {
|
||||
throw new Error(`file path escapes the build directory: ${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)
|
||||
}
|
||||
for (const [p, content] of Object.entries(shared_ui ?? {})) {
|
||||
await write('ui/' + p.replace(/^\/+/, ''), content)
|
||||
}
|
||||
|
||||
if (files['/package.json']) {
|
||||
// --ignore-scripts: the app's dependencies are compiled, never run, so a
|
||||
// package's lifecycle script has no business executing on the worker.
|
||||
const install = Bun.spawnSync(['bun', 'install', '--ignore-scripts'], {
|
||||
cwd: dir,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe'
|
||||
})
|
||||
if (install.exitCode !== 0) {
|
||||
throw new Error(
|
||||
'bun install failed:\n' + install.stderr.toString() + install.stdout.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const entry = ['/index.tsx', '/index.ts', '/index.js'].find((e) => files?.[e])
|
||||
if (!entry) {
|
||||
throw new Error('no entry point: the app needs one of /index.tsx, /index.ts, /index.js')
|
||||
}
|
||||
|
||||
const wmillPlugin = {
|
||||
name: 'wmill-virtual',
|
||||
setup(build: any) {
|
||||
build.onResolve({ filter: /^(\.\.\/)+wmill(\.ts)?$|^(\.\/|\/)?wmill(\.ts)?$/ }, () => ({
|
||||
path: 'wmill-virtual',
|
||||
namespace: 'wmill-virtual'
|
||||
}))
|
||||
build.onLoad({ filter: /.*/, namespace: 'wmill-virtual' }, () => ({
|
||||
contents: wmill_ts,
|
||||
loader: 'ts'
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const result = await Bun.build({
|
||||
entrypoints: [path.join(dir, entry.replace(/^\/+/, ''))],
|
||||
outdir: path.join(dir, 'dist'),
|
||||
target: 'browser',
|
||||
minify: true,
|
||||
define: { 'process.env.NODE_ENV': '"production"' },
|
||||
plugins: [wmillPlugin]
|
||||
})
|
||||
if (!result.success) {
|
||||
throw new Error('bundle failed:\n' + (result.logs ?? []).map((l: any) => String(l)).join('\n'))
|
||||
}
|
||||
|
||||
let js = ''
|
||||
let css = ''
|
||||
for (const out of result.outputs) {
|
||||
const text = await out.text()
|
||||
if (out.path.endsWith('.css')) css += text
|
||||
else if (out.path.endsWith('.js')) js += text
|
||||
}
|
||||
if (js === '') {
|
||||
throw new Error('bundle produced no javascript')
|
||||
}
|
||||
return { js, css }
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
let reqs: Record<string, any> = {}
|
||||
|
||||
function doRequest(type: string, o: object, extra?: object) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reqId = Math.random().toString(36)
|
||||
reqs[reqId] = { resolve, reject, ...extra }
|
||||
const req = { ...o, type, reqId }
|
||||
parent.postMessage(req, '*')
|
||||
})
|
||||
}
|
||||
|
||||
export const backend = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_, runnable_id: string) {
|
||||
return (v: any) => {
|
||||
return doRequest('backend', { runnable_id, v })
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const backendAsync = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_, runnable_id: string) {
|
||||
return (v: any) => {
|
||||
return doRequest('backendAsync', { runnable_id, v })
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export function waitJob(jobId: string) {
|
||||
return doRequest('waitJob', { jobId })
|
||||
}
|
||||
|
||||
export function getJob(jobId: string) {
|
||||
return doRequest('getJob', { jobId })
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream job results using SSE. Calls onUpdate for each stream update,
|
||||
* and resolves with the final result when the job completes.
|
||||
* @param jobId - The job ID to stream
|
||||
* @param onUpdate - Callback for stream updates with new_result_stream data
|
||||
* @returns Promise that resolves with the final job result
|
||||
*/
|
||||
export function streamJob(
|
||||
jobId: string,
|
||||
onUpdate?: (data: { new_result_stream?: string; stream_offset?: number }) => void
|
||||
): Promise<any> {
|
||||
return doRequest('streamJob', { jobId }, { onUpdate })
|
||||
}
|
||||
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.data.type === 'streamJobUpdate') {
|
||||
// Handle streaming update
|
||||
let job = reqs[e.data.reqId]
|
||||
if (job && job.onUpdate) {
|
||||
job.onUpdate({
|
||||
new_result_stream: e.data.new_result_stream,
|
||||
stream_offset: e.data.stream_offset
|
||||
})
|
||||
}
|
||||
} else if (e.data.type === 'streamJobRes') {
|
||||
// Handle stream completion
|
||||
let job = reqs[e.data.reqId]
|
||||
if (job) {
|
||||
if (e.data.error) {
|
||||
job.reject(new Error(e.data.result?.stack ?? e.data.result?.message ?? 'Stream error'))
|
||||
} else {
|
||||
job.resolve(e.data.result)
|
||||
}
|
||||
delete reqs[e.data.reqId]
|
||||
}
|
||||
} else if (
|
||||
e.data.type === 'backendRes' ||
|
||||
e.data.type === 'backendAsyncRes' ||
|
||||
e.data.type === 'waitJobRes' ||
|
||||
e.data.type === 'getJobRes'
|
||||
) {
|
||||
console.log('Message from parent backend', e.data)
|
||||
let job = reqs[e.data.reqId]
|
||||
if (job) {
|
||||
const result = e.data.result
|
||||
if (e.data.error) {
|
||||
job.reject(new Error(result.stack ?? result.message))
|
||||
} else {
|
||||
job.resolve(result)
|
||||
}
|
||||
delete reqs[e.data.reqId]
|
||||
} else {
|
||||
console.error('No job found for', e.data.reqId)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -68,6 +68,7 @@ use windmill_common::error::AppError;
|
||||
mod ai;
|
||||
mod ai_skills;
|
||||
mod apps;
|
||||
mod apps_raw_bundle;
|
||||
pub use apps::invalidate_app_policy_cache;
|
||||
pub mod args;
|
||||
mod audit;
|
||||
|
||||
@@ -1113,7 +1113,7 @@ Creates a new version of an existing script when called with the same path and t
|
||||
EndpointTool {
|
||||
name: Cow::Borrowed("updateApp"),
|
||||
description: Cow::Borrowed("update app"),
|
||||
instructions: Cow::Borrowed("Low-code apps only. An app whose `raw_app` field (from getAppByPath) is true is a raw (full-code) app: its value holds source files that must be compiled to a js/css bundle, which this tool cannot upload, so updating one here is refused. Edit a raw app in its editor at /apps_raw/edit/<path> instead."),
|
||||
instructions: Cow::Borrowed("Low-code apps only. An app whose `raw_app` field (from getAppByPath) is true is a raw (full-code) app and is refused here; use updateAppRawSource for those."),
|
||||
path: Cow::Borrowed("/w/{workspace}/apps/update/{path}"),
|
||||
method: Cow::Borrowed("POST"),
|
||||
path_params_schema: Some(serde_json::json!({
|
||||
@@ -1152,6 +1152,113 @@ Creates a new version of an existing script when called with the same path and t
|
||||
query_field_renames: None,
|
||||
body_field_renames: Some(serde_json::json!({
|
||||
"path__body": "path"
|
||||
})),
|
||||
},
|
||||
EndpointTool {
|
||||
name: Cow::Borrowed("updateAppRawSource"),
|
||||
description: Cow::Borrowed("update a raw app from its sources, bundling them server-side"),
|
||||
instructions: Cow::Borrowed("Use this to change a raw (full-code) app — an app whose `raw_app` field is true. Send the whole `value` (`files`, `runnables`, `data`), not a patch: read the current one with getAppByPath first and edit it. The sources are compiled on a worker, so a compile error comes back as the error of this call. Low-code apps use updateApp instead."),
|
||||
path: Cow::Borrowed("/w/{workspace}/apps/update_raw_source/{path}"),
|
||||
method: Cow::Borrowed("POST"),
|
||||
path_params_schema: Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"path"
|
||||
]
|
||||
})),
|
||||
query_params_schema: None,
|
||||
body_schema: Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "object",
|
||||
"description": "The raw app's value. `files` maps each source path (e.g. `/index.tsx`, `/App.tsx`, `/package.json`) to its content and must contain an entry point (`/index.tsx`, `/index.ts` or `/index.js`); `runnables` and `data` are carried through unchanged.",
|
||||
"properties": {
|
||||
"files": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"runnables": {
|
||||
"type": "object"
|
||||
},
|
||||
"data": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"files"
|
||||
]
|
||||
},
|
||||
"policy": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"triggerables": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"triggerables_v2": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"s3_inputs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"allowed_s3_keys": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"s3_path": {
|
||||
"type": "string"
|
||||
},
|
||||
"resource": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"execution_mode": {
|
||||
"type": "string",
|
||||
"description": "Possible values: viewer, publisher, anonymous"
|
||||
},
|
||||
"on_behalf_of": {
|
||||
"type": "string"
|
||||
},
|
||||
"on_behalf_of_email": {
|
||||
"type": "string"
|
||||
},
|
||||
"sandbox": {
|
||||
"type": "boolean",
|
||||
"description": "Publisher opt-in to app sandbox isolation (alpha). When true the app is isolated from each viewer's Windmill session. When false/absent the app runs same-origin with the viewer's full session (the default, pre-isolation behavior).\n"
|
||||
}
|
||||
}
|
||||
},
|
||||
"path__body": {
|
||||
"type": "string",
|
||||
"description": "(body parameter). Defaults to `path` when omitted; set it only to change the path."
|
||||
}
|
||||
}
|
||||
})),
|
||||
query_field_renames: None,
|
||||
body_field_renames: Some(serde_json::json!({
|
||||
"path__body": "path"
|
||||
})),
|
||||
},
|
||||
EndpointTool {
|
||||
|
||||
@@ -1121,7 +1121,7 @@ export const mcpEndpointTools: EndpointTool[] = [
|
||||
{
|
||||
name: "updateApp",
|
||||
description: "update app",
|
||||
instructions: "Low-code apps only. An app whose `raw_app` field (from getAppByPath) is true is a raw (full-code) app: its value holds source files that must be compiled to a js/css bundle, which this tool cannot upload, so updating one here is refused. Edit a raw app in its editor at /apps_raw/edit/<path> instead.",
|
||||
instructions: "Low-code apps only. An app whose `raw_app` field (from getAppByPath) is true is a raw (full-code) app and is refused here; use updateAppRawSource for those.",
|
||||
path: "/w/{workspace}/apps/update/{path}",
|
||||
method: "POST",
|
||||
pathParamsSchema: {
|
||||
@@ -1160,6 +1160,113 @@ export const mcpEndpointTools: EndpointTool[] = [
|
||||
queryFieldRenames: undefined,
|
||||
bodyFieldRenames: {
|
||||
"path__body": "path"
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "updateAppRawSource",
|
||||
description: "update a raw app from its sources, bundling them server-side",
|
||||
instructions: "Use this to change a raw (full-code) app — an app whose `raw_app` field is true. Send the whole `value` (`files`, `runnables`, `data`), not a patch: read the current one with getAppByPath first and edit it. The sources are compiled on a worker, so a compile error comes back as the error of this call. Low-code apps use updateApp instead.",
|
||||
path: "/w/{workspace}/apps/update_raw_source/{path}",
|
||||
method: "POST",
|
||||
pathParamsSchema: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"path"
|
||||
]
|
||||
},
|
||||
queryParamsSchema: undefined,
|
||||
bodySchema: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "object",
|
||||
"description": "The raw app's value. `files` maps each source path (e.g. `/index.tsx`, `/App.tsx`, `/package.json`) to its content and must contain an entry point (`/index.tsx`, `/index.ts` or `/index.js`); `runnables` and `data` are carried through unchanged.",
|
||||
"properties": {
|
||||
"files": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"runnables": {
|
||||
"type": "object"
|
||||
},
|
||||
"data": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"files"
|
||||
]
|
||||
},
|
||||
"policy": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"triggerables": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"triggerables_v2": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"s3_inputs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"allowed_s3_keys": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"s3_path": {
|
||||
"type": "string"
|
||||
},
|
||||
"resource": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"execution_mode": {
|
||||
"type": "string",
|
||||
"description": "Possible values: viewer, publisher, anonymous"
|
||||
},
|
||||
"on_behalf_of": {
|
||||
"type": "string"
|
||||
},
|
||||
"on_behalf_of_email": {
|
||||
"type": "string"
|
||||
},
|
||||
"sandbox": {
|
||||
"type": "boolean",
|
||||
"description": "Publisher opt-in to app sandbox isolation (alpha). When true the app is isolated from each viewer's Windmill session. When false/absent the app runs same-origin with the viewer's full session (the default, pre-isolation behavior).\n"
|
||||
}
|
||||
}
|
||||
},
|
||||
"path__body": {
|
||||
"type": "string",
|
||||
"description": "(body parameter). Defaults to `path` when omitted; set it only to change the path."
|
||||
}
|
||||
}
|
||||
},
|
||||
queryFieldRenames: undefined,
|
||||
bodyFieldRenames: {
|
||||
"path__body": "path"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user