From da2296bc9c0cceb49fe8152e2f4f3be0e94ffbf5 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 21 Aug 2026 04:36:40 +0900 Subject: [PATCH] fix: restore workspace routing contracts --- crates/workspace-server/src/server.rs | 58 ++++++++++++++++++- .../src/lib/workspace/settings/profile-api.ts | 27 ++++----- web/workspace/tests/profile-api.test.ts | 46 +++++++++++++++ 3 files changed, 114 insertions(+), 17 deletions(-) create mode 100644 web/workspace/tests/profile-api.test.ts diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index b63decb3..63534670 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -923,6 +923,7 @@ fn is_server_global_forward(path: &str) -> bool { || path.starts_with("/api/auth/") || path == "/health" || path == "/" + || path.starts_with("/_app/") || path.starts_with("/assets/") } @@ -12502,7 +12503,7 @@ async fn static_or_spa_fallback(State(api): State, uri: Uri) -> Re return StatusCode::NOT_FOUND.into_response(); }; - match read_static_or_index(static_root, uri.path()).await { + match read_static_or_index(static_root, scoped_workspace_static_path(uri.path())).await { Ok(StaticAsset { bytes, content_type, @@ -12553,6 +12554,16 @@ struct StaticAsset { content_type: &'static str, } +fn scoped_workspace_static_path(path: &str) -> &str { + let Some(scoped) = path.strip_prefix("/w/") else { + return path; + }; + match scoped.find('/') { + Some(index) => &scoped[index..], + None => "/", + } +} + async fn read_static_or_index(root: &Path, request_path: &str) -> Result { let candidate = safe_static_candidate(root, request_path)?; let file = if tokio::fs::metadata(&candidate) @@ -14427,7 +14438,20 @@ mod tests { let repository_b = dir.path().join("repository-b"); std::fs::create_dir_all(repository_a.join(".git")).unwrap(); std::fs::create_dir_all(repository_b.join(".git")).unwrap(); - let template = test_server_config(dir.path()); + let static_dir = dir.path().join("static"); + std::fs::create_dir_all(static_dir.join("_app/immutable/entry")).unwrap(); + std::fs::write( + static_dir.join("index.html"), + "
Workspace chooser
", + ) + .unwrap(); + std::fs::write( + static_dir.join("_app/immutable/entry/start.js"), + "console.log('workspace app');", + ) + .unwrap(); + let mut template = test_server_config(dir.path()); + template.static_assets_dir = Some(static_dir); let store = Arc::new(SqliteWorkspaceStore::open(&template.database_path).unwrap()); let catalog = WorkspaceCatalogService::new(store.clone()); let workspace_a = catalog @@ -14470,6 +14494,36 @@ mod tests { assert_eq!(b["workspace_id"], workspace_b.workspace.workspace_id); assert_eq!(b["display_name"], "Workspace B"); + for asset_uri in [ + "/_app/immutable/entry/start.js".to_string(), + format!( + "/w/{}/_app/immutable/entry/start.js", + workspace_a.workspace.workspace_id + ), + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .uri(asset_uri) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + String::from_utf8( + to_bytes(response.into_body(), usize::MAX) + .await + .unwrap() + .to_vec(), + ) + .unwrap(), + "console.log('workspace app');" + ); + } + let handle = missing_resource_handle(); let resource_response = app .clone() diff --git a/web/workspace/src/lib/workspace/settings/profile-api.ts b/web/workspace/src/lib/workspace/settings/profile-api.ts index 56ae0332..faea8804 100644 --- a/web/workspace/src/lib/workspace/settings/profile-api.ts +++ b/web/workspace/src/lib/workspace/settings/profile-api.ts @@ -14,7 +14,10 @@ export type WorkspaceProfileApi = { getProfiles(workspaceId: string): Promise; }; -async function requestJson(input: RequestInfo | URL, init?: RequestInit): Promise { +async function requestJson( + input: RequestInfo | URL, + init?: RequestInit, +): Promise { const response = await fetch(input, init); if (!response.ok) { throw new Error(`request failed: ${response.status}`); @@ -44,7 +47,9 @@ export async function updateWorkspaceMetadataSettings( ); } -export async function fetchProfileSettings(workspaceId: string): Promise { +export async function fetchProfileSettings( + workspaceId: string, +): Promise { return await requestJson( `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`, ); @@ -52,20 +57,12 @@ export async function fetchProfileSettings(workspaceId: string): Promise( - `/api/w/${encodeURIComponent(workspaceId)}/settings/metadata`, - ); - }, + getMetadata: fetchWorkspaceMetadataSettings, async updateMetadata(workspaceId, displayName, expectedRevision) { - return await requestJson( - `/api/w/${encodeURIComponent(workspaceId)}/settings/metadata`, - { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ display_name: displayName, expected_revision: expectedRevision }), - }, - ); + return await updateWorkspaceMetadataSettings(workspaceId, { + display_name: displayName, + revision: expectedRevision, + }); }, getProfiles: fetchProfileSettings, }; diff --git a/web/workspace/tests/profile-api.test.ts b/web/workspace/tests/profile-api.test.ts new file mode 100644 index 00000000..347da125 --- /dev/null +++ b/web/workspace/tests/profile-api.test.ts @@ -0,0 +1,46 @@ +declare const Deno: { + test(name: string, fn: () => void | Promise): void; +}; + +import { createWorkspaceProfileApi } from "../src/lib/workspace/settings/profile-api.ts"; + +Deno.test("workspace profile API delegates metadata calls to current route contract", async () => { + const originalFetch = globalThis.fetch; + const requests: Array<{ input: string; init?: RequestInit }> = []; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ input: String(input), init }); + return Promise.resolve(Response.json({ + workspace_id: "workspace-a", + display_name: "Alpha", + revision: "revision-2", + })); + }) as typeof fetch; + + try { + const api = createWorkspaceProfileApi(); + await api.getMetadata("workspace-a"); + await api.updateMetadata("workspace-a", "Alpha updated", "revision-1"); + } finally { + globalThis.fetch = originalFetch; + } + + if (requests.length !== 2) throw new Error("expected two metadata requests"); + if ( + requests.some((request) => request.input.includes("/settings/metadata")) + ) { + throw new Error("obsolete metadata endpoint was used"); + } + if ( + requests.some((request) => !request.input.endsWith("/settings/workspace")) + ) { + throw new Error("current Workspace settings endpoint was not used"); + } + const updateBody = JSON.parse(String(requests[1].init?.body)); + if ( + updateBody.display_name !== "Alpha updated" || + updateBody.revision !== "revision-1" || + "expected_revision" in updateBody + ) { + throw new Error(`unexpected update payload: ${JSON.stringify(updateBody)}`); + } +});