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 = "