diff --git a/internal/dev-entrypoint/cloud-web-runtime.mjs b/internal/dev-entrypoint/cloud-web-runtime.mjs index 7ea264c8..52e30f80 100644 --- a/internal/dev-entrypoint/cloud-web-runtime.mjs +++ b/internal/dev-entrypoint/cloud-web-runtime.mjs @@ -21,6 +21,7 @@ const CLIENT_ROUTE_FALLBACK_PATHS = new Set(["/workbench", "/workspace", "/openc const STATIC_ASSET_EXTENSION_PATTERN = /\.[A-Za-z0-9][A-Za-z0-9_-]*$/u; const OPENCODE_PROXY_PREFIX = "/_opencode"; const OPENCODE_FRAME_URL_PATH = "/opencode/frame-url"; +const OPENCODE_DEFAULT_PROJECT_BOOTSTRAP_SCRIPT_PATH = "/__hwlab/opencode-default-project.js"; const OPENCODE_TICKET_QUERY = "hwlab_opencode_ticket"; const OPENCODE_TICKET_COOKIE = "hwlab_opencode_ticket"; const OPENCODE_TICKET_TTL_MS = 5 * 60 * 1000; @@ -822,9 +823,13 @@ async function proxyOpencodeRequest({ request, response, url, cloudApiBaseUrl, c headers: opencodeUpstreamHeaders({ headers: { ...request.headers, ...cloudWebTraceHeaders(traceContext, serviceId) } }, body, authorization) }; try { - const proxyResult = shouldInjectOpencodeDefaultProject({ request, target, opencodeDefaultProject }) - ? await proxyOpencodeHtmlRequest({ target, request: upstreamRequest, response, timeoutMs: opencodeProxyTimeoutMs, extraResponseHeaders, defaultProject: opencodeDefaultProject }) - : await proxyCloudApiRequest({ + let proxyResult; + if (shouldServeOpencodeDefaultProjectBootstrapScript({ request, target, opencodeDefaultProject })) { + proxyResult = sendOpencodeDefaultProjectBootstrapScript({ response, extraResponseHeaders, defaultProject: opencodeDefaultProject }); + } else if (shouldInjectOpencodeDefaultProject({ request, target, opencodeDefaultProject })) { + proxyResult = await proxyOpencodeHtmlRequest({ target, request: upstreamRequest, response, timeoutMs: opencodeProxyTimeoutMs, extraResponseHeaders, defaultProject: opencodeDefaultProject }); + } else { + proxyResult = await proxyCloudApiRequest({ target, request: upstreamRequest, response, @@ -833,6 +838,7 @@ async function proxyOpencodeRequest({ request, response, url, cloudApiBaseUrl, c extraResponseHeaders, streamTransform }); + } emitOpencodeProxySpanAsync({ traceContext, serviceId, @@ -903,6 +909,26 @@ function shouldInjectOpencodeDefaultProject({ request, target, opencodeDefaultPr return true; } +function shouldServeOpencodeDefaultProjectBootstrapScript({ request, target, opencodeDefaultProject }) { + return Boolean(opencodeDefaultProject) && request.method === "GET" && target.pathname === OPENCODE_DEFAULT_PROJECT_BOOTSTRAP_SCRIPT_PATH; +} + +function sendOpencodeDefaultProjectBootstrapScript({ response, extraResponseHeaders, defaultProject }) { + const outputBody = Buffer.from(opencodeDefaultProjectBootstrapJavaScript(defaultProject), "utf8"); + response.writeHead(200, mergeOpencodeProxyResponseHeaders({ + "content-type": "text/javascript; charset=utf-8", + "cache-control": "no-store", + "content-length": outputBody.length + }, extraResponseHeaders)); + response.end(outputBody); + return { + statusCode: 200, + streaming: false, + bodyBytes: outputBody.length, + opencodeDefaultProjectBootstrapScript: true + }; +} + async function proxyOpencodeHtmlRequest({ target, request, response, timeoutMs, extraResponseHeaders, defaultProject }) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); @@ -919,7 +945,7 @@ async function proxyOpencodeHtmlRequest({ target, request, response, timeoutMs, ? Buffer.from(opencodeInjectDefaultProjectHtml(inputBody.toString("utf8"), defaultProject), "utf8") : inputBody; response.writeHead(upstream.status, { - ...mergeOpencodeProxyResponseHeaders(copyProxyResponseHeaders(upstreamHeaders), extraResponseHeaders), + ...mergeOpencodeProxyResponseHeaders(opencodeDecodedFetchResponseHeaders(upstreamHeaders), extraResponseHeaders), "content-length": outputBody.length }); response.end(outputBody); @@ -939,14 +965,27 @@ async function proxyOpencodeHtmlRequest({ target, request, response, timeoutMs, function opencodeInjectDefaultProjectHtml(html, defaultProject) { if (!html || html.includes("data-hwlab-opencode-default-project")) return html; - const script = opencodeDefaultProjectBootstrapScript(defaultProject); + const script = opencodeDefaultProjectBootstrapScriptTag(); if (html.includes("")) return html.replace("", `${script}`); if (html.includes("")) return html.replace("", `${script}`); return `${script}${html}`; } -function opencodeDefaultProjectBootstrapScript(defaultProject) { - return ``; +function opencodeDefaultProjectBootstrapScriptTag() { + return ``; +} + +function opencodeDefaultProjectBootstrapJavaScript(defaultProject) { + return `(function(){try{var project=${safeInlineJson(defaultProject)};if(!project)return;var serverKey="opencode.global.dat:server";var server=JSON.parse(localStorage.getItem(serverKey)||"{}");var projects=server.projects&&typeof server.projects==="object"?server.projects:{};var local=Array.isArray(projects.local)?projects.local.slice():[];var found=false;local=local.map(function(item){if(!item||item.worktree!==project)return item;found=true;return Object.assign({},item,{expanded:true});});if(!found)local.unshift({worktree:project,expanded:true});projects.local=local;server.projects=projects;server.lastProject=Object.assign({},server.lastProject||{},{local:project});localStorage.setItem(serverKey,JSON.stringify(server));var layoutKey="opencode.global.dat:layout.page";var layout=JSON.parse(localStorage.getItem(layoutKey)||"{}");layout.workspaceExpanded=Object.assign({},layout.workspaceExpanded||{});layout.workspaceExpanded[project]=true;localStorage.setItem(layoutKey,JSON.stringify(layout));}catch(error){console.warn("HWLAB OpenCode default project bootstrap failed",error);}})();\n`; +} + +function opencodeDecodedFetchResponseHeaders(headers = {}) { + const result = copyProxyResponseHeaders(headers); + for (const key of Object.keys(result)) { + const lowerKey = key.toLowerCase(); + if (lowerKey === "content-encoding" || lowerKey === "content-md5" || lowerKey === "etag") delete result[key]; + } + return result; } function mergeOpencodeProxyResponseHeaders(baseHeaders = {}, extraHeaders = {}) { diff --git a/internal/dev-entrypoint/cloud-web-runtime.test.mjs b/internal/dev-entrypoint/cloud-web-runtime.test.mjs index 5d8cc549..21c76143 100644 --- a/internal/dev-entrypoint/cloud-web-runtime.test.mjs +++ b/internal/dev-entrypoint/cloud-web-runtime.test.mjs @@ -5,6 +5,7 @@ import { connect } from "node:net"; import os from "node:os"; import path from "node:path"; import test from "node:test"; +import { gzipSync } from "node:zlib"; import { createCloudWebServer, isClientDisconnectError } from "./cloud-web-runtime.mjs"; @@ -658,6 +659,8 @@ test("cloud web OpenCode proxy bootstraps the default project for iframe HTML", response.end(JSON.stringify({ authenticated: request.headers.cookie === "hwlab_session=session-a" })); }); const opencodeRequests = []; + const opencodeHtml = "
\n"; + const opencodeHtmlGzip = gzipSync(Buffer.from(opencodeHtml, "utf8")); const opencode = createServer((request, response) => { opencodeRequests.push({ url: request.url, @@ -665,8 +668,8 @@ test("cloud web OpenCode proxy bootstraps the default project for iframe HTML", cookie: request.headers.cookie }); request.resume(); - response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); - response.end("
\n"); + response.writeHead(200, { "content-type": "text/html; charset=utf-8", "content-encoding": "gzip", etag: "upstream-html-etag" }); + response.end(opencodeHtmlGzip); }); await listen(cloudApi); await listen(opencode); @@ -700,11 +703,24 @@ test("cloud web OpenCode proxy bootstraps the default project for iframe HTML", headers: { accept: "text/html" } }); assert.equal(proxied.status, 200); + assert.equal(proxied.headers.get("content-encoding"), null); + assert.equal(proxied.headers.get("etag"), null); const html = await proxied.text(); assert.match(html, /data-hwlab-opencode-default-project/u); - assert.match(html, /opencode\.global\.dat:server/u); - assert.match(html, /opencode\.global\.dat:layout\.page/u); - assert.match(html, /\/workspace/u); + assert.match(html, /src="\/__hwlab\/opencode-default-project\.js"/u); + assert.doesNotMatch(html, /localStorage/u); + + const ticketCookie = proxied.headers.get("set-cookie")?.match(/hwlab_opencode_ticket=([^;]+)/u)?.[1] || ""; + assert.ok(ticketCookie, "expected iframe HTML response to set OpenCode ticket cookie"); + const bootstrap = await fetch(`${serverUrl(cloudWeb)}/_opencode/__hwlab/opencode-default-project.js`, { + headers: { accept: "text/javascript", cookie: `hwlab_opencode_ticket=${ticketCookie}` } + }); + assert.equal(bootstrap.status, 200); + assert.equal(bootstrap.headers.get("content-type"), "text/javascript; charset=utf-8"); + const bootstrapScript = await bootstrap.text(); + assert.match(bootstrapScript, /opencode\.global\.dat:server/u); + assert.match(bootstrapScript, /opencode\.global\.dat:layout\.page/u); + assert.match(bootstrapScript, /\/workspace/u); assert.deepEqual(opencodeRequests, [{ url: "/", authorization: `Basic ${Buffer.from("oc_user:oc_password", "utf8").toString("base64")}`,