1472 lines
61 KiB
JavaScript
1472 lines
61 KiB
JavaScript
import { randomBytes } from "node:crypto";
|
|
import { createServer, request as httpRequest } from "node:http";
|
|
import { readFile, stat } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { gzip } from "node:zlib";
|
|
import { promisify } from "node:util";
|
|
|
|
import { copyProxyResponseHeaders, isCloudWebSseRoute, proxyCloudApiRequest, upstreamRequestHeaders } from "./cloud-web-proxy.mjs";
|
|
import { cloudWebProxyRoutePolicy } from "./cloud-web-routes.mjs";
|
|
import { UPSTREAM_UNAVAILABLE_ERROR_CODE, UPSTREAM_UNAVAILABLE_MESSAGE } from "./http.mjs";
|
|
const gzipAsync = promisify(gzip);
|
|
const GZIP_MIN_BYTES = 1024;
|
|
const CLIENT_DISCONNECT_ERROR_CODES = new Set([
|
|
"ECONNRESET",
|
|
"EPIPE",
|
|
"ERR_STREAM_PREMATURE_CLOSE"
|
|
]);
|
|
const CLIENT_DISCONNECT_ERROR_PATTERN = /\b(?:socket hang up|client disconnected|premature close)\b/iu;
|
|
const CLOUD_WEB_ERROR_HANDLER_INSTALLED = Symbol.for("hwlab.cloud-web.error-handler-installed");
|
|
const CLIENT_ROUTE_FALLBACK_PATHS = new Set(["/workbench", "/workspace", "/opencode", "/gate", "/diagnostics/gate", "/help", "/skills", "/access"]);
|
|
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;
|
|
const OPENCODE_TICKET_MAX_AGE_SECONDS = Math.floor(OPENCODE_TICKET_TTL_MS / 1000);
|
|
const ZERO_OTEL_TRACE_ID = "00000000000000000000000000000000";
|
|
const ZERO_OTEL_SPAN_ID = "0000000000000000";
|
|
const OTEL_TRACEPARENT_PATTERN = /^00-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/u;
|
|
const SAFE_REQUEST_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,160}$/u;
|
|
const TRACE_EXPLORER_TEMPLATE_TOKEN = "{trace_id}";
|
|
const TRACE_EXPLORER_TEMPLATE_PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_:-]+)\}/gu;
|
|
const opencodeTickets = new Map();
|
|
|
|
export async function serveCloudWeb(options) {
|
|
installCloudWebProcessErrorHandlers({ serviceId: options.serviceId });
|
|
await validateCloudWebRuntimeConfig();
|
|
const server = createCloudWebServer(options);
|
|
server.listen(options.port, "0.0.0.0", () => {
|
|
process.stdout.write(JSON.stringify({
|
|
serviceId: options.serviceId,
|
|
environment: process.env.HWLAB_ENVIRONMENT || "dev",
|
|
status: "listening",
|
|
port: options.port
|
|
}) + "\n");
|
|
});
|
|
}
|
|
|
|
export function createCloudWebServer({
|
|
serviceId,
|
|
healthPayload,
|
|
sendJson,
|
|
roots = ["/app/web/hwlab-cloud-web/dist", "/app/web/hwlab-cloud-web"],
|
|
cloudApiBaseUrl = process.env.HWLAB_API_BASE_URL || "",
|
|
cloudApiProxyTimeoutMs = parseTimeout(process.env.HWLAB_CLOUD_WEB_PROXY_TIMEOUT_MS, 1260000, {
|
|
min: 1000,
|
|
max: 2400000
|
|
}),
|
|
opencodeUpstreamUrl = optionalRuntimeConfigEnv("HWLAB_CLOUD_WEB_OPENCODE_UPSTREAM_URL") || "",
|
|
opencodeProxyHost = opencodeProxyHostFromEnv(),
|
|
opencodeProxyTimeoutMs = parseTimeout(process.env.HWLAB_CLOUD_WEB_OPENCODE_PROXY_TIMEOUT_MS, cloudApiProxyTimeoutMs, {
|
|
min: 1000,
|
|
max: 2400000
|
|
}),
|
|
opencodeUsername = optionalRuntimeConfigEnv("HWLAB_CLOUD_WEB_OPENCODE_USERNAME") || "",
|
|
opencodePassword = optionalRuntimeConfigEnv("HWLAB_CLOUD_WEB_OPENCODE_PASSWORD") || "",
|
|
opencodeDefaultProject = optionalRuntimeConfigEnv("HWLAB_CLOUD_WEB_OPENCODE_DEFAULT_PROJECT") || "",
|
|
opencodeEventDirectoryRewrite = opencodeEventDirectoryRewriteFromEnv()
|
|
}) {
|
|
return createServer(async (request, response) => {
|
|
attachRequestErrorHandlers({ request, response, serviceId });
|
|
const url = new URL(request.url || "/", "http://hwlab-cloud-web.local");
|
|
try {
|
|
if (url.pathname === "/health/live" || url.pathname === "/health") {
|
|
sendJson(response, 200, healthPayload());
|
|
return;
|
|
}
|
|
if (url.pathname === OPENCODE_FRAME_URL_PATH && request.method === "GET") {
|
|
await handleOpencodeFrameUrlRequest({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson });
|
|
return;
|
|
}
|
|
if (isOpencodeProxyRequest(request, url, opencodeProxyHost)) {
|
|
await proxyOpencodeRequest({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, opencodeUpstreamUrl, opencodeProxyTimeoutMs, opencodeUsername, opencodePassword, opencodeDefaultProject, opencodeEventDirectoryRewrite, serviceId, sendJson });
|
|
return;
|
|
}
|
|
if (await handleCloudWebAuth({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson })) {
|
|
return;
|
|
}
|
|
|
|
const proxyPolicy = cloudWebProxyRoutePolicy(request.method, url.pathname);
|
|
if (proxyPolicy.proxy) {
|
|
await proxyCloudApi({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson });
|
|
return;
|
|
}
|
|
|
|
const routePath = normalizeCloudWebRoutePath(url.pathname);
|
|
const relativePath = routePath === "/" || CLIENT_ROUTE_FALLBACK_PATHS.has(routePath) || isCloudWebWorkbenchSessionRoute(routePath) || isCloudWebClientRouteRequest(request, routePath)
|
|
? "index.html"
|
|
: url.pathname.slice(1);
|
|
for (const root of roots) {
|
|
const candidate = path.resolve(root, relativePath);
|
|
if (!candidate.startsWith(root)) continue;
|
|
try {
|
|
const info = await stat(candidate);
|
|
if (!info.isFile()) continue;
|
|
const type = contentType(candidate);
|
|
const body = await staticResponseBody({ candidate, relativePath, type, request, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs });
|
|
const encoded = await encodeStaticBody(request, body, type);
|
|
response.writeHead(200, {
|
|
"content-type": type,
|
|
...encoded.headers,
|
|
"content-length": encoded.body.length
|
|
});
|
|
response.end(encoded.body);
|
|
return;
|
|
} catch (error) {
|
|
if (isClientDisconnectError(error)) {
|
|
logCloudWebRuntimeEvent("cloud-web-client-disconnect", { serviceId, request, error });
|
|
return;
|
|
}
|
|
if (!isStaticFileMiss(error)) throw error;
|
|
}
|
|
}
|
|
|
|
sendJson(response, 404, { error: "not_found", serviceId, path: url.pathname });
|
|
} catch (error) {
|
|
if (isClientDisconnectError(error)) {
|
|
logCloudWebRuntimeEvent("cloud-web-client-disconnect", { serviceId, request, error });
|
|
return;
|
|
}
|
|
if (!response.headersSent && !response.writableEnded) {
|
|
sendJson(response, 500, {
|
|
error: "internal_error",
|
|
serviceId,
|
|
message: error instanceof Error ? error.message : String(error)
|
|
});
|
|
return;
|
|
}
|
|
response.destroy(error);
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function validateCloudWebRuntimeConfig() {
|
|
await runtimeConfigFromEnv();
|
|
}
|
|
|
|
function isStaticFileMiss(error) {
|
|
return error?.code === "ENOENT" || error?.code === "ENOTDIR";
|
|
}
|
|
|
|
async function staticResponseBody({ candidate, relativePath, type, request, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs }) {
|
|
const body = await readFile(candidate);
|
|
if (relativePath !== "index.html" || !String(type).startsWith("text/html")) return body;
|
|
return await injectCloudWebRuntimeConfig(body, { request, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs });
|
|
}
|
|
|
|
async function injectCloudWebRuntimeConfig(body, options = {}) {
|
|
const config = await runtimeConfigFromEnv(options);
|
|
if (Object.keys(config).length === 0) return body;
|
|
const html = body.toString("utf8");
|
|
const script = `<script>window.HWLAB_CLOUD_WEB_CONFIG=${safeInlineJson(config)};</script>`;
|
|
if (html.includes("</head>")) return Buffer.from(html.replace("</head>", `${script}</head>`), "utf8");
|
|
if (html.includes("</body>")) return Buffer.from(html.replace("</body>", `${script}</body>`), "utf8");
|
|
return Buffer.from(`${html}${script}`, "utf8");
|
|
}
|
|
|
|
async function runtimeConfigFromEnv(options = {}) {
|
|
const config = { displayTime: displayTimeConfigFromEnv() };
|
|
const workbench = workbenchRuntimeConfigFromEnv();
|
|
if (workbench) config.workbench = workbench;
|
|
const agentObserver = agentObserverRuntimeConfigFromEnv();
|
|
if (agentObserver) config.agentObserver = agentObserver;
|
|
const opencode = await opencodeRuntimeConfigFromEnv(options);
|
|
if (opencode) config.opencode = opencode;
|
|
return config;
|
|
}
|
|
|
|
async function opencodeRuntimeConfigFromEnv(options = {}) {
|
|
const url = optionalRuntimeConfigEnv("HWLAB_CLOUD_WEB_OPENCODE_URL");
|
|
if (!url) return null;
|
|
validateOpencodeUrl(url);
|
|
return { url: await opencodeFrameUrl(url, options) };
|
|
}
|
|
|
|
async function opencodeFrameUrl(value, { request = null, url = null, cloudApiBaseUrl = "", cloudApiProxyTimeoutMs = 0 } = {}) {
|
|
if (!request || normalizeCloudWebRoutePath(url?.pathname || "") !== "/opencode" || !cloudApiBaseUrl || !hasCookieHeader(request.headers.cookie)) return value;
|
|
const result = await mintOpencodeFrameUrl(value, { request, cloudApiBaseUrl, cloudApiProxyTimeoutMs, traceContext: cloudWebTraceContext(request) });
|
|
return result.ok ? result.url : value;
|
|
}
|
|
|
|
async function handleOpencodeFrameUrlRequest({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson }) {
|
|
const traceContext = cloudWebTraceContext(request);
|
|
response.setHeader("cache-control", "no-store");
|
|
if (!cloudApiBaseUrl) {
|
|
sendJsonWithTraceContext(response, 503, { error: "cloud_api_base_url_missing", serviceId, path: url.pathname, diagnostic: opencodeTraceDiagnostic(traceContext) }, traceContext, sendJson);
|
|
return;
|
|
}
|
|
const configuredUrl = optionalRuntimeConfigEnv("HWLAB_CLOUD_WEB_OPENCODE_URL");
|
|
if (!configuredUrl) {
|
|
sendJsonWithTraceContext(response, 503, { error: "opencode_url_missing", serviceId, path: url.pathname, diagnostic: opencodeTraceDiagnostic(traceContext) }, traceContext, sendJson);
|
|
return;
|
|
}
|
|
try {
|
|
validateOpencodeUrl(configuredUrl);
|
|
} catch (error) {
|
|
sendJsonWithTraceContext(response, 503, { error: "opencode_url_invalid", serviceId, path: url.pathname, message: error instanceof Error ? error.message : String(error), diagnostic: opencodeTraceDiagnostic(traceContext) }, traceContext, sendJson);
|
|
return;
|
|
}
|
|
|
|
const result = await mintOpencodeFrameUrl(configuredUrl, { request, cloudApiBaseUrl, cloudApiProxyTimeoutMs, traceContext, serviceId });
|
|
if (!result.ok) {
|
|
logCloudWebRuntimeEvent("opencode_auth_required", {
|
|
serviceId,
|
|
request,
|
|
traceContext,
|
|
details: {
|
|
route: url.pathname,
|
|
sessionStatusCode: result.sessionStatusCode,
|
|
sessionAuthenticated: result.sessionAuthenticated,
|
|
ticketPresent: false,
|
|
ticketAccepted: false,
|
|
valuesPrinted: false
|
|
}
|
|
});
|
|
sendJsonWithTraceContext(response, result.statusCode, {
|
|
authenticated: false,
|
|
error: "opencode_auth_required",
|
|
serviceId,
|
|
path: url.pathname,
|
|
diagnostic: opencodeTraceDiagnostic(traceContext, { sessionStatusCode: result.sessionStatusCode })
|
|
}, traceContext, sendJson);
|
|
return;
|
|
}
|
|
|
|
sendJsonWithTraceContext(response, 200, {
|
|
authenticated: true,
|
|
url: result.url,
|
|
expiresInMs: OPENCODE_TICKET_TTL_MS,
|
|
diagnostic: opencodeTraceDiagnostic(traceContext)
|
|
}, traceContext, sendJson);
|
|
}
|
|
|
|
async function mintOpencodeFrameUrl(value, { request, cloudApiBaseUrl, cloudApiProxyTimeoutMs, traceContext = null, serviceId = "hwlab-cloud-web" } = {}) {
|
|
const session = await requestCloudApiJson({
|
|
request,
|
|
method: "GET",
|
|
pathname: "/auth/session",
|
|
headers: cloudWebTraceHeaders(traceContext, serviceId),
|
|
cloudApiBaseUrl,
|
|
cloudApiProxyTimeoutMs
|
|
});
|
|
if (!session.ok || session.body?.authenticated !== true) {
|
|
return {
|
|
ok: false,
|
|
statusCode: session.statusCode === 403 ? 403 : 401,
|
|
sessionStatusCode: session.statusCode || 0,
|
|
sessionAuthenticated: session.body?.authenticated === true
|
|
};
|
|
}
|
|
const ticket = issueOpencodeTicket(request.headers.cookie);
|
|
const target = new URL(value);
|
|
target.searchParams.set(OPENCODE_TICKET_QUERY, ticket);
|
|
return { ok: true, url: target.toString() };
|
|
}
|
|
|
|
function displayTimeConfigFromEnv() {
|
|
const timeZone = requiredRuntimeConfigEnv("HWLAB_CLOUD_WEB_DISPLAY_TIME_ZONE");
|
|
const locale = requiredRuntimeConfigEnv("HWLAB_CLOUD_WEB_DISPLAY_TIME_LOCALE");
|
|
const label = requiredRuntimeConfigEnv("HWLAB_CLOUD_WEB_DISPLAY_TIME_LABEL");
|
|
validateDisplayTimeZone(timeZone);
|
|
validateDisplayLocale(locale);
|
|
return { timeZone, locale, label };
|
|
}
|
|
|
|
function requiredRuntimeConfigEnv(name) {
|
|
const value = process.env[name];
|
|
if (typeof value !== "string" || value.trim().length === 0) {
|
|
throw new Error(`missing required Cloud Web runtime config env: ${name}`);
|
|
}
|
|
return value.trim();
|
|
}
|
|
|
|
function optionalRuntimeConfigEnv(name) {
|
|
const value = process.env[name];
|
|
if (typeof value !== "string") return null;
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
function validateOpencodeUrl(value) {
|
|
let url;
|
|
try {
|
|
url = new URL(value);
|
|
} catch {
|
|
throw new Error("invalid HWLAB_CLOUD_WEB_OPENCODE_URL: must be an absolute http(s) URL");
|
|
}
|
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
throw new Error("invalid HWLAB_CLOUD_WEB_OPENCODE_URL: only http(s) URLs are allowed");
|
|
}
|
|
if (url.username || url.password || url.hash) {
|
|
throw new Error("invalid HWLAB_CLOUD_WEB_OPENCODE_URL: credentials and fragments are not allowed");
|
|
}
|
|
}
|
|
|
|
function opencodeProxyHostFromEnv() {
|
|
const explicit = optionalRuntimeConfigEnv("HWLAB_CLOUD_WEB_OPENCODE_PROXY_HOST");
|
|
if (explicit) return normalizeHostName(explicit);
|
|
const frameUrl = optionalRuntimeConfigEnv("HWLAB_CLOUD_WEB_OPENCODE_URL");
|
|
if (!frameUrl) return "";
|
|
try {
|
|
const url = new URL(frameUrl);
|
|
return normalizeHostName(url.hostname);
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function opencodeEventDirectoryRewriteFromEnv() {
|
|
const from = optionalRuntimeConfigEnv("HWLAB_CLOUD_WEB_OPENCODE_EVENT_DIRECTORY_FROM");
|
|
const to = optionalRuntimeConfigEnv("HWLAB_CLOUD_WEB_OPENCODE_EVENT_DIRECTORY_TO");
|
|
if (!from || !to || from === to) return null;
|
|
return { from, to };
|
|
}
|
|
|
|
function opencodeEventDirectoryTransformForTarget(target, rewrite) {
|
|
if (!rewrite || target?.pathname !== "/global/event") return null;
|
|
return opencodeEventDirectoryRewriteTransform(rewrite);
|
|
}
|
|
|
|
function opencodeEventDirectoryRewriteTransform(rewrite) {
|
|
const stats = {
|
|
enabled: true,
|
|
from: rewrite.from,
|
|
to: rewrite.to,
|
|
dataLines: 0,
|
|
rewrittenEvents: 0,
|
|
jsonErrors: 0
|
|
};
|
|
let pending = "";
|
|
return {
|
|
stats,
|
|
write(chunk) {
|
|
pending += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk ?? "");
|
|
const output = [];
|
|
let newlineIndex = pending.indexOf("\n");
|
|
while (newlineIndex >= 0) {
|
|
const line = pending.slice(0, newlineIndex + 1);
|
|
pending = pending.slice(newlineIndex + 1);
|
|
output.push(rewriteOpencodeEventDirectoryLine(line, rewrite, stats));
|
|
newlineIndex = pending.indexOf("\n");
|
|
}
|
|
return output;
|
|
},
|
|
end() {
|
|
if (!pending) return "";
|
|
const line = pending;
|
|
pending = "";
|
|
return rewriteOpencodeEventDirectoryLine(line, rewrite, stats);
|
|
}
|
|
};
|
|
}
|
|
|
|
function rewriteOpencodeEventDirectoryLine(line, rewrite, stats) {
|
|
const match = String(line).match(/^(data:\s*)(.*?)(\r?\n)?$/u);
|
|
if (!match) return line;
|
|
const [, prefix, payload, lineEnding = ""] = match;
|
|
const text = payload.trim();
|
|
if (!text) return line;
|
|
stats.dataLines += 1;
|
|
try {
|
|
const parsed = JSON.parse(text);
|
|
if (parsed && typeof parsed === "object" && parsed.directory === rewrite.from) {
|
|
parsed.directory = rewrite.to;
|
|
stats.rewrittenEvents += 1;
|
|
return `${prefix}${JSON.stringify(parsed)}${lineEnding}`;
|
|
}
|
|
} catch {
|
|
stats.jsonErrors += 1;
|
|
}
|
|
return line;
|
|
}
|
|
|
|
function validateDisplayTimeZone(timeZone) {
|
|
try {
|
|
new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date(0));
|
|
} catch {
|
|
throw new Error(`invalid HWLAB_CLOUD_WEB_DISPLAY_TIME_ZONE: ${timeZone}`);
|
|
}
|
|
}
|
|
|
|
function validateDisplayLocale(locale) {
|
|
try {
|
|
new Intl.DateTimeFormat(locale).format(new Date(0));
|
|
} catch {
|
|
throw new Error(`invalid HWLAB_CLOUD_WEB_DISPLAY_TIME_LOCALE: ${locale}`);
|
|
}
|
|
}
|
|
|
|
function workbenchRuntimeConfigFromEnv() {
|
|
const traceTimeline = {};
|
|
const result = {
|
|
realtimeFeatures: {
|
|
liveKafkaSse: requiredWorkbenchRealtimeFeature(process.env.HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED, "HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED"),
|
|
kafkaRefreshReplay: requiredWorkbenchRealtimeFeature(process.env.HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED, "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED"),
|
|
projectionRealtime: requiredWorkbenchRealtimeFeature(process.env.HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED, "HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED")
|
|
},
|
|
debugCapabilities: {
|
|
isolatedKafka: requiredWorkbenchRealtimeFeature(process.env.HWLAB_WORKBENCH_KAFKA_DEBUG_REPLAY_ENABLED, "HWLAB_WORKBENCH_KAFKA_DEBUG_REPLAY_ENABLED"),
|
|
rawHwlabEventWindow: {
|
|
enabled: requiredWorkbenchRealtimeFeature(process.env.HWLAB_WORKBENCH_RAW_HWLAB_EVENT_WINDOW_ENABLED, "HWLAB_WORKBENCH_RAW_HWLAB_EVENT_WINDOW_ENABLED"),
|
|
maxEntries: requiredPositiveWorkbenchInteger(process.env.HWLAB_WORKBENCH_RAW_HWLAB_EVENT_WINDOW_MAX_ENTRIES, "HWLAB_WORKBENCH_RAW_HWLAB_EVENT_WINDOW_MAX_ENTRIES"),
|
|
maxRetainedBytes: requiredPositiveWorkbenchInteger(process.env.HWLAB_WORKBENCH_RAW_HWLAB_EVENT_WINDOW_MAX_RETAINED_BYTES, "HWLAB_WORKBENCH_RAW_HWLAB_EVENT_WINDOW_MAX_RETAINED_BYTES")
|
|
}
|
|
}
|
|
};
|
|
const autoExpandRunning = parseEnvBoolean(process.env.HWLAB_WORKBENCH_TRACE_AUTO_EXPAND_RUNNING);
|
|
const autoCollapseTerminal = parseEnvBoolean(process.env.HWLAB_WORKBENCH_TRACE_AUTO_COLLAPSE_TERMINAL);
|
|
const traceExplorerUrlTemplate = traceExplorerUrlTemplateFromEnv(process.env.HWLAB_WORKBENCH_TRACE_EXPLORER_URL_TEMPLATE);
|
|
if (typeof autoExpandRunning === "boolean") traceTimeline.autoExpandRunning = autoExpandRunning;
|
|
if (typeof autoCollapseTerminal === "boolean") traceTimeline.autoCollapseTerminal = autoCollapseTerminal;
|
|
if (Object.keys(traceTimeline).length > 0) result.traceTimeline = traceTimeline;
|
|
if (traceExplorerUrlTemplate) result.traceExplorerUrlTemplate = traceExplorerUrlTemplate;
|
|
return Object.keys(result).length > 0 ? result : null;
|
|
}
|
|
|
|
function agentObserverRuntimeConfigFromEnv() {
|
|
const names = [
|
|
"HWLAB_AGENT_OBSERVER_RECONNECT_DELAY_MS",
|
|
"HWLAB_AGENT_OBSERVER_BATCH_MAX_EVENTS",
|
|
"HWLAB_AGENT_OBSERVER_LIVE_BUFFER_LIMIT",
|
|
"HWLAB_AGENT_OBSERVER_IDENTITY_WINDOW_LIMIT",
|
|
"HWLAB_AGENT_OBSERVER_REDUCER_EVENT_LIMIT",
|
|
"HWLAB_AGENT_OBSERVER_REDUCER_ENTITY_LIMIT",
|
|
"HWLAB_AGENT_OBSERVER_TREE_NODE_LIMIT",
|
|
"HWLAB_AGENT_OBSERVER_INSPECTOR_EVENT_TAIL_LIMIT",
|
|
"HWLAB_AGENT_OBSERVER_VIRTUAL_LIST_WINDOW_LIMIT",
|
|
"HWLAB_AGENT_OBSERVER_CHANGE_HIGHLIGHT_MS",
|
|
"HWLAB_AGENT_OBSERVER_STALE_AFTER_MS",
|
|
"HWLAB_AGENT_OBSERVER_REDUCED_MOTION"
|
|
];
|
|
if (names.every((name) => process.env[name] === undefined || process.env[name] === "")) return null;
|
|
const value = {
|
|
nodeId: requiredRuntimeConfigValue(process.env.HWLAB_AGENT_OBSERVER_NODE_ID, "HWLAB_AGENT_OBSERVER_NODE_ID"),
|
|
lane: requiredRuntimeConfigValue(process.env.HWLAB_AGENT_OBSERVER_LANE, "HWLAB_AGENT_OBSERVER_LANE"),
|
|
reconnectDelayMs: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_RECONNECT_DELAY_MS, "HWLAB_AGENT_OBSERVER_RECONNECT_DELAY_MS"),
|
|
batchMaxEvents: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_BATCH_MAX_EVENTS, "HWLAB_AGENT_OBSERVER_BATCH_MAX_EVENTS"),
|
|
liveBufferLimit: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_LIVE_BUFFER_LIMIT, "HWLAB_AGENT_OBSERVER_LIVE_BUFFER_LIMIT"),
|
|
identityWindowLimit: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_IDENTITY_WINDOW_LIMIT, "HWLAB_AGENT_OBSERVER_IDENTITY_WINDOW_LIMIT"),
|
|
reducerEventLimit: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_REDUCER_EVENT_LIMIT, "HWLAB_AGENT_OBSERVER_REDUCER_EVENT_LIMIT"),
|
|
reducerEntityLimit: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_REDUCER_ENTITY_LIMIT, "HWLAB_AGENT_OBSERVER_REDUCER_ENTITY_LIMIT"),
|
|
treeNodeLimit: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_TREE_NODE_LIMIT, "HWLAB_AGENT_OBSERVER_TREE_NODE_LIMIT"),
|
|
inspectorEventTailLimit: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_INSPECTOR_EVENT_TAIL_LIMIT, "HWLAB_AGENT_OBSERVER_INSPECTOR_EVENT_TAIL_LIMIT"),
|
|
virtualListWindowLimit: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_VIRTUAL_LIST_WINDOW_LIMIT, "HWLAB_AGENT_OBSERVER_VIRTUAL_LIST_WINDOW_LIMIT"),
|
|
changeHighlightMs: requiredNonNegativeInteger(process.env.HWLAB_AGENT_OBSERVER_CHANGE_HIGHLIGHT_MS, "HWLAB_AGENT_OBSERVER_CHANGE_HIGHLIGHT_MS"),
|
|
staleAfterMs: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_STALE_AFTER_MS, "HWLAB_AGENT_OBSERVER_STALE_AFTER_MS"),
|
|
reducedMotion: requiredRuntimeConfigValue(process.env.HWLAB_AGENT_OBSERVER_REDUCED_MOTION, "HWLAB_AGENT_OBSERVER_REDUCED_MOTION")
|
|
};
|
|
if (value.reducedMotion !== "respect-system") throw new Error("HWLAB_AGENT_OBSERVER_REDUCED_MOTION must be respect-system");
|
|
return value;
|
|
}
|
|
|
|
function requiredNonNegativeInteger(value, name) {
|
|
const number = Number(value);
|
|
if (!Number.isInteger(number) || number < 0) throw new Error(`${name} is required and must be a non-negative integer`);
|
|
return number;
|
|
}
|
|
|
|
function requiredRuntimeConfigValue(value, name) {
|
|
const result = String(value ?? "").trim();
|
|
if (!result) throw new Error(`${name} is required`);
|
|
return result;
|
|
}
|
|
|
|
function requiredWorkbenchRealtimeFeature(value, name) {
|
|
const normalized = String(value ?? "").trim().toLowerCase();
|
|
if (normalized === "true" || normalized === "1") return true;
|
|
if (normalized === "false" || normalized === "0") return false;
|
|
throw new Error(`${name} is required and must be explicitly true or false`);
|
|
}
|
|
|
|
function requiredPositiveWorkbenchInteger(value, name) {
|
|
const number = Number(value);
|
|
if (!Number.isInteger(number) || number <= 0) throw new Error(`${name} is required and must be a positive integer`);
|
|
return number;
|
|
}
|
|
|
|
function traceExplorerUrlTemplateFromEnv(value) {
|
|
if (value === undefined || value === null || value === "") return null;
|
|
const template = String(value).trim();
|
|
if (!isSafeTraceExplorerUrlTemplate(template)) {
|
|
throw new Error("invalid HWLAB_WORKBENCH_TRACE_EXPLORER_URL_TEMPLATE: template must be http(s) or same-origin relative URL and contain only {trace_id}");
|
|
}
|
|
return template;
|
|
}
|
|
|
|
function isSafeTraceExplorerUrlTemplate(template) {
|
|
if (!template.includes(TRACE_EXPLORER_TEMPLATE_TOKEN)) return false;
|
|
const placeholders = Array.from(template.matchAll(TRACE_EXPLORER_TEMPLATE_PLACEHOLDER_PATTERN), (match) => match[0]);
|
|
if (placeholders.length === 0 || placeholders.some((placeholder) => placeholder !== TRACE_EXPLORER_TEMPLATE_TOKEN)) return false;
|
|
try {
|
|
const rendered = template.replaceAll(TRACE_EXPLORER_TEMPLATE_TOKEN, "trc_template_probe");
|
|
const url = new URL(rendered, "http://hwlab-cloud-web.local");
|
|
return (url.protocol === "http:" || url.protocol === "https:") && !template.startsWith("//");
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function parseEnvBoolean(value) {
|
|
if (value === undefined || value === null || value === "") return null;
|
|
const normalized = String(value).trim().toLowerCase();
|
|
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
|
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
|
return null;
|
|
}
|
|
|
|
function safeInlineJson(value) {
|
|
return JSON.stringify(value).replace(/</gu, "\\u003c");
|
|
}
|
|
|
|
function isCloudWebWorkbenchSessionRoute(routePath) {
|
|
return /^\/(?:workbench|workspace)\/sessions\/cnv_[A-Za-z0-9_.:-]+$/u.test(routePath);
|
|
}
|
|
|
|
function normalizeCloudWebRoutePath(pathname) {
|
|
return String(pathname || "/").replace(/\/+$/u, "") || "/";
|
|
}
|
|
|
|
function isCloudWebClientRouteRequest(request, routePath) {
|
|
if (request.method !== "GET" && request.method !== "HEAD") return false;
|
|
if (!routePath || routePath === "/") return true;
|
|
if (routePath.startsWith("/auth/") || routePath === "/auth") return false;
|
|
if (routePath.startsWith("/v1/") || routePath === "/v1") return false;
|
|
if (STATIC_ASSET_EXTENSION_PATTERN.test(path.basename(routePath))) return false;
|
|
const accept = String(request.headers.accept || "").toLowerCase();
|
|
return !accept || accept.includes("text/html") || accept.includes("*/*");
|
|
}
|
|
|
|
export function installCloudWebProcessErrorHandlers({ serviceId } = {}) {
|
|
if (process[CLOUD_WEB_ERROR_HANDLER_INSTALLED]) return;
|
|
process[CLOUD_WEB_ERROR_HANDLER_INSTALLED] = true;
|
|
process.on("uncaughtException", (error) => {
|
|
if (isClientDisconnectError(error)) {
|
|
logCloudWebRuntimeEvent("cloud-web-client-disconnect", { serviceId, error, source: "uncaughtException" });
|
|
return;
|
|
}
|
|
logCloudWebRuntimeEvent("cloud-web-runtime-fatal", { serviceId, error, source: "uncaughtException" });
|
|
process.exit(1);
|
|
});
|
|
process.on("unhandledRejection", (reason) => {
|
|
const error = reason instanceof Error ? reason : new Error(String(reason));
|
|
if (isClientDisconnectError(error)) {
|
|
logCloudWebRuntimeEvent("cloud-web-client-disconnect", { serviceId, error, source: "unhandledRejection" });
|
|
return;
|
|
}
|
|
logCloudWebRuntimeEvent("cloud-web-runtime-fatal", { serviceId, error, source: "unhandledRejection" });
|
|
process.exit(1);
|
|
});
|
|
}
|
|
|
|
function attachRequestErrorHandlers({ request, response, serviceId }) {
|
|
const handle = (error) => {
|
|
if (isClientDisconnectError(error)) {
|
|
logCloudWebRuntimeEvent("cloud-web-client-disconnect", { serviceId, request, error });
|
|
return;
|
|
}
|
|
throw error;
|
|
};
|
|
request.on("error", handle);
|
|
response.on("error", handle);
|
|
}
|
|
|
|
export function isClientDisconnectError(error) {
|
|
const candidate = error instanceof Error ? error : new Error(String(error));
|
|
const code = typeof candidate.code === "string" ? candidate.code : "";
|
|
return CLIENT_DISCONNECT_ERROR_CODES.has(code) || CLIENT_DISCONNECT_ERROR_PATTERN.test(candidate.message || "");
|
|
}
|
|
|
|
function logCloudWebRuntimeEvent(event, { serviceId, request = null, error = null, source = null, traceContext = null, details = null } = {}) {
|
|
const payload = {
|
|
event,
|
|
serviceId: serviceId || "hwlab-cloud-web",
|
|
source,
|
|
method: request?.method || null,
|
|
path: request?.url || null,
|
|
traceId: traceContext?.traceId || null,
|
|
requestId: traceContext?.requestId || null,
|
|
error: error
|
|
? {
|
|
name: error.name || "Error",
|
|
code: typeof error.code === "string" ? error.code : null,
|
|
message: error.message || String(error)
|
|
}
|
|
: null,
|
|
details: details && typeof details === "object" ? details : null,
|
|
observedAt: new Date().toISOString()
|
|
};
|
|
process.stderr.write(JSON.stringify(payload) + "\n");
|
|
}
|
|
|
|
export async function handleCloudWebAuth(options) {
|
|
const { request, response, url } = options;
|
|
if (url.pathname === "/auth/bootstrap" && request.method === "GET") {
|
|
await handleAuthBootstrap(options);
|
|
return true;
|
|
}
|
|
if (url.pathname === "/auth/session" && request.method === "GET") {
|
|
await proxyCloudApi(options);
|
|
return true;
|
|
}
|
|
if (url.pathname === "/auth/login" && request.method === "POST") {
|
|
await proxyCloudApi(options);
|
|
return true;
|
|
}
|
|
if (url.pathname === "/auth/register" && request.method === "POST") {
|
|
await proxyCloudApi(options);
|
|
return true;
|
|
}
|
|
if (url.pathname === "/auth/logout" && request.method === "POST") {
|
|
await proxyCloudApi(options);
|
|
return true;
|
|
}
|
|
if (!url.pathname.startsWith("/auth/")) return false;
|
|
await proxyCloudApi(options);
|
|
return true;
|
|
}
|
|
|
|
async function handleAuthBootstrap(options) {
|
|
const { request, response, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson } = options;
|
|
if (!cloudApiBaseUrl) {
|
|
sendJson(response, 503, { error: "cloud_api_base_url_missing", serviceId, path: "/auth/bootstrap" });
|
|
return;
|
|
}
|
|
|
|
const auth = await resolveAuthBootstrap({ request, cloudApiBaseUrl, cloudApiProxyTimeoutMs });
|
|
sendJsonWithForwardedCookies(response, auth.statusCode || 502, auth.body ?? { authenticated: false, error: "auth_bootstrap_failed" }, auth.headers);
|
|
}
|
|
|
|
async function resolveAuthBootstrap({ request, cloudApiBaseUrl, cloudApiProxyTimeoutMs }) {
|
|
const session = await requestCloudApiJson({ request, method: "GET", pathname: "/auth/session", cloudApiBaseUrl, cloudApiProxyTimeoutMs });
|
|
return withCookieHeader(request, session);
|
|
}
|
|
|
|
function requestCloudApiJson({ request, method, pathname, body = "", headers = {}, cloudApiBaseUrl, cloudApiProxyTimeoutMs }) {
|
|
return new Promise((resolve) => {
|
|
let settled = false;
|
|
const target = new URL(pathname, cloudApiBaseUrl);
|
|
const proxyRequest = { method, headers: { ...upstreamRequestHeaders({ headers: request.headers }, body), ...headers } };
|
|
const upstream = httpRequest(target, { method, headers: proxyRequest.headers }, (upstreamResponse) => {
|
|
const chunks = [];
|
|
upstreamResponse.on("data", (chunk) => chunks.push(chunk));
|
|
upstreamResponse.on("end", () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
const rawBody = Buffer.concat(chunks).toString("utf8");
|
|
let parsed = null;
|
|
try { parsed = rawBody ? JSON.parse(rawBody) : null; } catch { parsed = { error: "invalid_json", rawBody: rawBody.slice(0, 200) }; }
|
|
resolve({ ok: (upstreamResponse.statusCode || 0) >= 200 && (upstreamResponse.statusCode || 0) < 300, statusCode: upstreamResponse.statusCode || 502, body: parsed, headers: upstreamResponse.headers });
|
|
});
|
|
upstreamResponse.on("error", (error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve({ ok: false, statusCode: 502, body: { authenticated: false, error: error.message }, headers: {} });
|
|
});
|
|
});
|
|
const timeout = setTimeout(() => {
|
|
if (settled) return;
|
|
settled = true;
|
|
upstream.destroy();
|
|
resolve({ ok: false, statusCode: 504, body: { authenticated: false, error: "auth_bootstrap_timeout" }, headers: {} });
|
|
}, cloudApiProxyTimeoutMs);
|
|
upstream.on("error", (error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timeout);
|
|
resolve({ ok: false, statusCode: 502, body: { authenticated: false, error: error.message }, headers: {} });
|
|
});
|
|
upstream.on("close", () => clearTimeout(timeout));
|
|
if (body) upstream.write(body);
|
|
upstream.end();
|
|
});
|
|
}
|
|
|
|
function withCookieHeader(request, result) {
|
|
return {
|
|
...result,
|
|
cookieHeader: cookieHeaderForUpstream(request.headers.cookie, result.headers?.["set-cookie"])
|
|
};
|
|
}
|
|
|
|
function cookieHeaderForUpstream(existingCookie, setCookie) {
|
|
const pairs = new Map();
|
|
for (const pair of cookiePairsFromHeader(existingCookie)) pairs.set(pair.name, pair.value);
|
|
for (const pair of cookiePairsFromSetCookie(setCookie)) pairs.set(pair.name, pair.value);
|
|
return Array.from(pairs.entries()).map(([name, value]) => `${name}=${value}`).join("; ");
|
|
}
|
|
|
|
function cookiePairsFromHeader(header) {
|
|
if (!header) return [];
|
|
return String(header).split(";").map((part) => cookiePair(part)).filter(Boolean);
|
|
}
|
|
|
|
function hasCookieHeader(header) {
|
|
return cookiePairsFromHeader(header).length > 0;
|
|
}
|
|
|
|
function cookiePairsFromSetCookie(setCookie) {
|
|
const values = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : [];
|
|
return values.map((value) => cookiePair(String(value).split(";")[0])).filter(Boolean);
|
|
}
|
|
|
|
function cookiePair(value) {
|
|
const index = String(value).indexOf("=");
|
|
if (index <= 0) return null;
|
|
const name = String(value).slice(0, index).trim();
|
|
const cookieValue = String(value).slice(index + 1).trim();
|
|
if (!name || !cookieValue) return null;
|
|
return { name, value: cookieValue };
|
|
}
|
|
|
|
function sendJsonWithForwardedCookies(response, statusCode, body, headers = {}) {
|
|
const payload = JSON.stringify(body);
|
|
const setCookie = headers["set-cookie"];
|
|
const responseHeaders = {
|
|
"content-type": "application/json",
|
|
"content-length": Buffer.byteLength(payload)
|
|
};
|
|
if (setCookie) responseHeaders["set-cookie"] = setCookie;
|
|
response.writeHead(statusCode, responseHeaders);
|
|
response.end(payload);
|
|
}
|
|
|
|
async function readRequestBody(request) {
|
|
let body = "";
|
|
for await (const chunk of request) {
|
|
body += chunk;
|
|
}
|
|
return body;
|
|
}
|
|
|
|
function parseTimeout(value, fallback, { min, max }) {
|
|
const parsed = Number.parseInt(value || "", 10);
|
|
if (!Number.isInteger(parsed)) return fallback;
|
|
return Math.min(Math.max(parsed, min), max);
|
|
}
|
|
|
|
function requestUpstream({ target, request, response, body, url, cloudApiProxyTimeoutMs }) {
|
|
return proxyCloudApiRequest({
|
|
target,
|
|
request,
|
|
response,
|
|
body,
|
|
timeoutMs: cloudApiProxyTimeoutMs,
|
|
forceStream: isCloudWebSseRoute(url.pathname)
|
|
});
|
|
}
|
|
|
|
export async function proxyCloudApi({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson }) {
|
|
if (!cloudApiBaseUrl) {
|
|
sendJson(response, 503, {
|
|
error: "cloud_api_base_url_missing",
|
|
serviceId,
|
|
path: url.pathname
|
|
});
|
|
return;
|
|
}
|
|
|
|
let body = "";
|
|
if (request.method !== "GET") {
|
|
body = await readRequestBody(request);
|
|
}
|
|
|
|
const target = new URL(url.pathname + url.search, cloudApiBaseUrl);
|
|
try {
|
|
await requestUpstream({ target, request, response, body: request.method === "GET" ? "" : body, url, cloudApiProxyTimeoutMs });
|
|
} catch (error) {
|
|
if (response.headersSent || response.writableEnded) {
|
|
if (!response.writableEnded) response.destroy(error);
|
|
return;
|
|
}
|
|
const timedOut = error.timedOut === true || /timed out after \d+ms/iu.test(error.message);
|
|
const code = UPSTREAM_UNAVAILABLE_ERROR_CODE;
|
|
const category = timedOut ? "timeout" : "proxy";
|
|
const traceId = request.headers["x-trace-id"] || null;
|
|
const userMessage = UPSTREAM_UNAVAILABLE_MESSAGE;
|
|
sendJson(response, 502, {
|
|
status: "failed",
|
|
error: {
|
|
code,
|
|
layer: "proxy",
|
|
category,
|
|
retryable: true,
|
|
userMessage,
|
|
message: userMessage,
|
|
timeoutMs: timedOut ? cloudApiProxyTimeoutMs : null,
|
|
traceId,
|
|
route: url.pathname,
|
|
blocker: {
|
|
code,
|
|
layer: "proxy",
|
|
category,
|
|
retryable: true,
|
|
summary: userMessage,
|
|
userMessage,
|
|
traceId,
|
|
route: url.pathname
|
|
}
|
|
},
|
|
serviceId,
|
|
target: target.href.replace(/\/\/[^/@]*@/u, "//***@"),
|
|
traceId,
|
|
reason: userMessage
|
|
});
|
|
}
|
|
}
|
|
|
|
async function proxyOpencodeRequest({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, opencodeUpstreamUrl, opencodeProxyTimeoutMs, opencodeUsername, opencodePassword, opencodeDefaultProject = "", opencodeEventDirectoryRewrite = null, serviceId, sendJson }) {
|
|
const traceContext = cloudWebTraceContext(request);
|
|
const startedAtMs = Date.now();
|
|
if (!cloudApiBaseUrl) {
|
|
emitOpencodeProxySpanAsync({ traceContext, serviceId, request, url, startedAtMs, statusCode: 503, errorCode: "cloud_api_base_url_missing" });
|
|
sendJsonWithTraceContext(response, 503, { error: "cloud_api_base_url_missing", serviceId, path: url.pathname, diagnostic: opencodeTraceDiagnostic(traceContext) }, traceContext, sendJson);
|
|
return;
|
|
}
|
|
if (!opencodeUpstreamUrl) {
|
|
emitOpencodeProxySpanAsync({ traceContext, serviceId, request, url, startedAtMs, statusCode: 503, errorCode: "opencode_upstream_url_missing" });
|
|
sendJsonWithTraceContext(response, 503, { error: "opencode_upstream_url_missing", serviceId, path: url.pathname, diagnostic: opencodeTraceDiagnostic(traceContext) }, traceContext, sendJson);
|
|
return;
|
|
}
|
|
const authorization = opencodeBasicAuthorization(opencodeUsername, opencodePassword);
|
|
if (!authorization) {
|
|
emitOpencodeProxySpanAsync({ traceContext, serviceId, request, url, startedAtMs, statusCode: 503, errorCode: "opencode_credentials_missing" });
|
|
sendJsonWithTraceContext(response, 503, { error: "opencode_credentials_missing", serviceId, path: url.pathname, diagnostic: opencodeTraceDiagnostic(traceContext) }, traceContext, sendJson);
|
|
return;
|
|
}
|
|
|
|
const ticketAuth = opencodeTicketAuthRequest(request, url);
|
|
const session = await requestCloudApiJson({
|
|
request: ticketAuth.request,
|
|
method: "GET",
|
|
pathname: "/auth/session",
|
|
headers: cloudWebTraceHeaders(traceContext, serviceId),
|
|
cloudApiBaseUrl,
|
|
cloudApiProxyTimeoutMs
|
|
});
|
|
if (!session.ok || session.body?.authenticated !== true) {
|
|
logCloudWebRuntimeEvent("opencode_auth_required", {
|
|
serviceId,
|
|
request,
|
|
traceContext,
|
|
details: {
|
|
route: url.pathname,
|
|
sessionStatusCode: session.statusCode || 0,
|
|
sessionAuthenticated: session.body?.authenticated === true,
|
|
ticketPresent: ticketAuth.ticketPresent,
|
|
ticketAccepted: Boolean(ticketAuth.ticket),
|
|
valuesPrinted: false
|
|
}
|
|
});
|
|
emitOpencodeProxySpanAsync({
|
|
traceContext,
|
|
serviceId,
|
|
request,
|
|
url,
|
|
startedAtMs,
|
|
statusCode: session.statusCode === 403 ? 403 : 401,
|
|
errorCode: "opencode_auth_required",
|
|
ticketAuth,
|
|
sessionStatusCode: session.statusCode || 0
|
|
});
|
|
sendJsonWithTraceContext(response, session.statusCode === 403 ? 403 : 401, {
|
|
authenticated: false,
|
|
error: "opencode_auth_required",
|
|
serviceId,
|
|
path: url.pathname,
|
|
diagnostic: opencodeTraceDiagnostic(traceContext, { sessionStatusCode: session.statusCode || 0 })
|
|
}, traceContext, sendJson);
|
|
return;
|
|
}
|
|
|
|
let body = "";
|
|
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
body = await readRequestBody(request);
|
|
}
|
|
|
|
const target = opencodeTargetUrl(url, opencodeUpstreamUrl);
|
|
const streamTransform = opencodeEventDirectoryTransformForTarget(target, opencodeEventDirectoryRewrite);
|
|
if (streamTransform?.stats?.enabled) {
|
|
emitOpencodeProxyStreamStartSpanAsync({
|
|
traceContext,
|
|
serviceId,
|
|
request,
|
|
url,
|
|
target,
|
|
ticketAuth,
|
|
sessionStatusCode: session.statusCode || 0,
|
|
streamTransformStats: streamTransform.stats
|
|
});
|
|
}
|
|
const extraResponseHeaders = {
|
|
...cloudWebTraceHeaders(traceContext, serviceId),
|
|
...(ticketAuth.ticket ? { "set-cookie": opencodeTicketSetCookie(ticketAuth.ticket) } : {})
|
|
};
|
|
const upstreamRequest = {
|
|
method: request.method,
|
|
headers: opencodeUpstreamHeaders({ headers: { ...request.headers, ...cloudWebTraceHeaders(traceContext, serviceId) } }, body, authorization)
|
|
};
|
|
try {
|
|
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,
|
|
body: request.method === "GET" || request.method === "HEAD" ? "" : body,
|
|
timeoutMs: opencodeProxyTimeoutMs,
|
|
extraResponseHeaders,
|
|
streamTransform
|
|
});
|
|
}
|
|
emitOpencodeProxySpanAsync({
|
|
traceContext,
|
|
serviceId,
|
|
request,
|
|
url,
|
|
target,
|
|
startedAtMs,
|
|
statusCode: proxyResult?.statusCode || 0,
|
|
errorCode: proxyResult?.errorCode || "",
|
|
ticketAuth,
|
|
sessionStatusCode: session.statusCode || 0,
|
|
streaming: proxyResult?.streaming === true,
|
|
streamTransformStats: proxyResult?.streamTransformStats
|
|
});
|
|
} catch (error) {
|
|
if (response.headersSent || response.writableEnded) {
|
|
if (!response.writableEnded) response.destroy(error);
|
|
return;
|
|
}
|
|
emitOpencodeProxySpanAsync({
|
|
traceContext,
|
|
serviceId,
|
|
request,
|
|
url,
|
|
target,
|
|
startedAtMs,
|
|
statusCode: 502,
|
|
errorCode: error?.timedOut ? "opencode_proxy_timeout" : "opencode_proxy_unavailable",
|
|
ticketAuth,
|
|
sessionStatusCode: session.statusCode || 0
|
|
});
|
|
sendJsonWithTraceContext(response, 502, {
|
|
status: "failed",
|
|
error: "opencode_proxy_unavailable",
|
|
serviceId,
|
|
path: url.pathname,
|
|
reason: UPSTREAM_UNAVAILABLE_MESSAGE,
|
|
diagnostic: opencodeTraceDiagnostic(traceContext)
|
|
}, traceContext, sendJson);
|
|
}
|
|
}
|
|
|
|
function isOpencodeProxyRequest(request, url, opencodeProxyHost) {
|
|
if (url.pathname === OPENCODE_PROXY_PREFIX || url.pathname.startsWith(`${OPENCODE_PROXY_PREFIX}/`)) return true;
|
|
const targetHost = normalizeHostName(opencodeProxyHost);
|
|
if (!targetHost) return false;
|
|
return requestHostName(request) === targetHost;
|
|
}
|
|
|
|
function opencodeTargetUrl(url, opencodeUpstreamUrl) {
|
|
const pathname = url.pathname === OPENCODE_PROXY_PREFIX
|
|
? "/"
|
|
: url.pathname.startsWith(`${OPENCODE_PROXY_PREFIX}/`)
|
|
? url.pathname.slice(OPENCODE_PROXY_PREFIX.length) || "/"
|
|
: url.pathname;
|
|
const target = new URL(`${pathname}${url.search}`, opencodeUpstreamUrl);
|
|
target.searchParams.delete(OPENCODE_TICKET_QUERY);
|
|
return target;
|
|
}
|
|
|
|
function shouldInjectOpencodeDefaultProject({ request, target, opencodeDefaultProject }) {
|
|
if (!opencodeDefaultProject) return false;
|
|
if (request.method !== "GET") return false;
|
|
const accept = String(request.headers.accept || "");
|
|
if (accept && !accept.includes("text/html") && !accept.includes("*/*")) return false;
|
|
if (target.pathname.startsWith("/assets/")) return false;
|
|
if (STATIC_ASSET_EXTENSION_PATTERN.test(target.pathname)) return false;
|
|
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);
|
|
try {
|
|
const upstream = await fetch(target, {
|
|
method: request.method,
|
|
headers: request.headers,
|
|
signal: controller.signal
|
|
});
|
|
const upstreamHeaders = Object.fromEntries(upstream.headers.entries());
|
|
const contentType = String(upstream.headers.get("content-type") || "");
|
|
const inputBody = Buffer.from(await upstream.arrayBuffer());
|
|
const outputBody = /^text\/html(?:\s*;|$)/iu.test(contentType)
|
|
? Buffer.from(opencodeInjectDefaultProjectHtml(inputBody.toString("utf8"), defaultProject), "utf8")
|
|
: inputBody;
|
|
response.writeHead(upstream.status, {
|
|
...mergeOpencodeProxyResponseHeaders(opencodeDecodedFetchResponseHeaders(upstreamHeaders), extraResponseHeaders),
|
|
"content-length": outputBody.length
|
|
});
|
|
response.end(outputBody);
|
|
return {
|
|
statusCode: upstream.status,
|
|
streaming: false,
|
|
bodyBytes: outputBody.length,
|
|
opencodeDefaultProjectInjected: outputBody.length !== inputBody.length
|
|
};
|
|
} catch (error) {
|
|
if (error?.name === "AbortError") error.timedOut = true;
|
|
throw error;
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
function opencodeInjectDefaultProjectHtml(html, defaultProject) {
|
|
if (!html || html.includes("data-hwlab-opencode-default-project")) return html;
|
|
const script = opencodeDefaultProjectBootstrapScriptTag();
|
|
if (html.includes("</head>")) return html.replace("</head>", `${script}</head>`);
|
|
if (html.includes("</body>")) return html.replace("</body>", `${script}</body>`);
|
|
return `${script}${html}`;
|
|
}
|
|
|
|
function opencodeDefaultProjectBootstrapScriptTag() {
|
|
return `<script data-hwlab-opencode-default-project src="${OPENCODE_DEFAULT_PROJECT_BOOTSTRAP_SCRIPT_PATH}"></script>`;
|
|
}
|
|
|
|
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 = {}) {
|
|
const result = { ...baseHeaders };
|
|
for (const [key, value] of Object.entries(extraHeaders)) {
|
|
if (value === undefined || value === null || value === "") continue;
|
|
const existingKey = Object.keys(result).find((item) => item.toLowerCase() === key.toLowerCase());
|
|
if (existingKey && existingKey.toLowerCase() === "set-cookie") {
|
|
const existing = Array.isArray(result[existingKey]) ? result[existingKey] : [result[existingKey]];
|
|
const next = Array.isArray(value) ? value : [value];
|
|
result[existingKey] = [...existing.filter(Boolean), ...next.filter(Boolean)];
|
|
continue;
|
|
}
|
|
result[existingKey || key] = value;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function issueOpencodeTicket(cookieHeader) {
|
|
pruneOpencodeTickets();
|
|
const ticket = randomBytes(24).toString("base64url");
|
|
opencodeTickets.set(ticket, {
|
|
cookieHeader: String(cookieHeader || ""),
|
|
expiresAtMs: Date.now() + OPENCODE_TICKET_TTL_MS
|
|
});
|
|
return ticket;
|
|
}
|
|
|
|
function opencodeTicketAuthRequest(request, url) {
|
|
const ticket = opencodeTicketFromRequest(request, url);
|
|
const record = ticket ? opencodeTicketRecord(ticket) : null;
|
|
if (!record) return { request, ticket: "", ticketPresent: Boolean(ticket) };
|
|
return {
|
|
request: { headers: { ...request.headers, cookie: record.cookieHeader } },
|
|
ticket,
|
|
ticketPresent: true
|
|
};
|
|
}
|
|
|
|
function opencodeTicketFromRequest(request, url) {
|
|
const fromQuery = url.searchParams.get(OPENCODE_TICKET_QUERY) || "";
|
|
if (fromQuery) return fromQuery;
|
|
return cookiePairsFromHeader(request.headers.cookie).find((pair) => pair.name === OPENCODE_TICKET_COOKIE)?.value || "";
|
|
}
|
|
|
|
function opencodeTicketRecord(ticket) {
|
|
pruneOpencodeTickets();
|
|
const record = opencodeTickets.get(String(ticket || ""));
|
|
if (!record || record.expiresAtMs <= Date.now() || !record.cookieHeader) {
|
|
opencodeTickets.delete(String(ticket || ""));
|
|
return null;
|
|
}
|
|
return record;
|
|
}
|
|
|
|
function pruneOpencodeTickets() {
|
|
const now = Date.now();
|
|
for (const [ticket, record] of opencodeTickets.entries()) {
|
|
if (!record || record.expiresAtMs <= now) opencodeTickets.delete(ticket);
|
|
}
|
|
}
|
|
|
|
function opencodeTicketSetCookie(ticket) {
|
|
return `${OPENCODE_TICKET_COOKIE}=${encodeURIComponent(ticket)}; Path=/; HttpOnly; Secure; SameSite=None; Max-Age=${OPENCODE_TICKET_MAX_AGE_SECONDS}`;
|
|
}
|
|
|
|
function opencodeUpstreamHeaders(request, body, authorization) {
|
|
const headers = {
|
|
accept: request.headers.accept || "*/*",
|
|
authorization
|
|
};
|
|
for (const name of ["content-type", "prefer", "traceparent", "tracestate", "x-hwlab-otel-trace-id", "x-trace-id", "x-request-id"]) {
|
|
if (request.headers[name] !== undefined) headers[name] = request.headers[name];
|
|
}
|
|
if (body) headers["content-length"] = Buffer.byteLength(body);
|
|
return headers;
|
|
}
|
|
|
|
function opencodeBasicAuthorization(username, password) {
|
|
if (!username || !password) return "";
|
|
return `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`;
|
|
}
|
|
|
|
function cloudWebTraceContext(request) {
|
|
const parsed = parseOtelTraceparent(request?.headers?.traceparent);
|
|
const explicitTraceId = normalizedOtelTraceId(request?.headers?.["x-hwlab-otel-trace-id"]);
|
|
const traceId = parsed?.traceId || explicitTraceId || randomOtelTraceId();
|
|
const spanId = randomOtelSpanId();
|
|
return {
|
|
traceId,
|
|
parentSpanId: parsed?.spanId || null,
|
|
spanId,
|
|
traceparent: `00-${traceId}-${spanId}-01`,
|
|
requestId: safeRequestId(request?.headers?.["x-request-id"]) || `req_${randomBytes(16).toString("hex")}`,
|
|
valuesPrinted: false
|
|
};
|
|
}
|
|
|
|
function cloudWebTraceHeaders(traceContext, serviceId = "hwlab-cloud-web") {
|
|
if (!traceContext?.traceId || !traceContext?.traceparent) return {};
|
|
return {
|
|
traceparent: traceContext.traceparent,
|
|
"x-hwlab-otel-trace-id": traceContext.traceId,
|
|
"x-request-id": traceContext.requestId,
|
|
"x-source-service-id": serviceId || "hwlab-cloud-web"
|
|
};
|
|
}
|
|
|
|
function sendJsonWithTraceContext(response, statusCode, body, traceContext, sendJson) {
|
|
for (const [name, value] of Object.entries(cloudWebTraceHeaders(traceContext))) {
|
|
if (value !== undefined && value !== null && value !== "") response.setHeader(name, value);
|
|
}
|
|
sendJson(response, statusCode, body);
|
|
}
|
|
|
|
function opencodeTraceDiagnostic(traceContext, extra = {}) {
|
|
return {
|
|
traceId: traceContext?.traceId || null,
|
|
requestId: traceContext?.requestId || null,
|
|
traceparent: traceContext?.traceparent || null,
|
|
valuesPrinted: false,
|
|
...extra
|
|
};
|
|
}
|
|
|
|
function emitOpencodeProxySpanAsync({ traceContext, serviceId, request, url, target = null, startedAtMs, statusCode = 0, errorCode = "", ticketAuth = null, sessionStatusCode = 0, streaming = false, streamTransformStats = null } = {}) {
|
|
const endpoint = otelTracesEndpoint(process.env);
|
|
if (!endpoint || !traceContext?.traceId || !traceContext?.spanId) return;
|
|
const endedAtMs = Date.now();
|
|
const route = opencodeProxyRoute(url?.pathname || "");
|
|
const span = {
|
|
traceId: traceContext.traceId,
|
|
spanId: traceContext.spanId,
|
|
parentSpanId: traceContext.parentSpanId || "",
|
|
name: "opencode.proxy.request",
|
|
kind: 2,
|
|
startTimeMs: startedAtMs || endedAtMs,
|
|
endTimeMs: endedAtMs,
|
|
statusCode,
|
|
errorCode,
|
|
attributes: {
|
|
"http.request.method": request?.method || "GET",
|
|
"http.route": route,
|
|
"http.response.status_code": statusCode || undefined,
|
|
"http.response.status_class": statusCode ? `${Math.floor(statusCode / 100)}xx` : "unknown",
|
|
"url.scheme": target?.protocol ? target.protocol.replace(":", "") : undefined,
|
|
"server.address": target?.hostname || undefined,
|
|
"server.port": target ? Number(target.port || (target.protocol === "https:" ? 443 : 80)) : undefined,
|
|
"opencode.proxy.duration_ms": Math.max(0, endedAtMs - (startedAtMs || endedAtMs)),
|
|
"opencode.proxy.ticket_present": ticketAuth?.ticketPresent === true,
|
|
"opencode.proxy.ticket_accepted": Boolean(ticketAuth?.ticket),
|
|
"opencode.proxy.session_status_code": sessionStatusCode || undefined,
|
|
"opencode.proxy.streaming": streaming === true,
|
|
...opencodeStreamTransformStatsAttributes(streamTransformStats),
|
|
valuesPrinted: false
|
|
}
|
|
};
|
|
setTimeout(() => {
|
|
emitOtelSpan(endpoint, span, serviceId || "hwlab-cloud-web", "hwlab.cloud_web.opencode_proxy").catch((error) => {
|
|
process.stderr.write(JSON.stringify({
|
|
event: "cloud-web-opencode-otel-emit-failed",
|
|
serviceId: serviceId || "hwlab-cloud-web",
|
|
traceId: traceContext.traceId,
|
|
requestId: traceContext.requestId,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
valuesPrinted: false
|
|
}) + "\n");
|
|
});
|
|
}, 0);
|
|
}
|
|
|
|
function opencodeStreamTransformStatsAttributes(stats) {
|
|
if (!stats?.enabled) return {};
|
|
return {
|
|
"opencode.proxy.sse.directory_rewrite_enabled": true,
|
|
"opencode.proxy.sse.directory_rewrite_from": stats.from,
|
|
"opencode.proxy.sse.directory_rewrite_to": stats.to,
|
|
"opencode.proxy.sse.directory_rewrite_data_lines": stats.dataLines,
|
|
"opencode.proxy.sse.directory_rewrite_events": stats.rewrittenEvents,
|
|
"opencode.proxy.sse.directory_rewrite_json_errors": stats.jsonErrors
|
|
};
|
|
}
|
|
|
|
function emitOpencodeProxyStreamStartSpanAsync({ traceContext, serviceId, request, url, target = null, ticketAuth = null, sessionStatusCode = 0, streamTransformStats = null } = {}) {
|
|
const endpoint = otelTracesEndpoint(process.env);
|
|
if (!endpoint || !traceContext?.traceId) return;
|
|
const observedAtMs = Date.now();
|
|
const span = {
|
|
traceId: traceContext.traceId,
|
|
spanId: randomOtelSpanId(),
|
|
parentSpanId: traceContext.spanId || traceContext.parentSpanId || "",
|
|
name: "opencode.proxy.stream.start",
|
|
kind: 2,
|
|
startTimeMs: observedAtMs,
|
|
endTimeMs: observedAtMs,
|
|
statusCode: 0,
|
|
errorCode: "",
|
|
attributes: {
|
|
"http.request.method": request?.method || "GET",
|
|
"http.route": opencodeProxyRoute(url?.pathname || ""),
|
|
"url.scheme": target?.protocol ? target.protocol.replace(":", "") : undefined,
|
|
"server.address": target?.hostname || undefined,
|
|
"server.port": target ? Number(target.port || (target.protocol === "https:" ? 443 : 80)) : undefined,
|
|
"opencode.proxy.phase": "stream_start",
|
|
"opencode.proxy.ticket_present": ticketAuth?.ticketPresent === true,
|
|
"opencode.proxy.ticket_accepted": Boolean(ticketAuth?.ticket),
|
|
"opencode.proxy.session_status_code": sessionStatusCode || undefined,
|
|
"opencode.proxy.streaming": true,
|
|
...opencodeStreamTransformStatsAttributes(streamTransformStats),
|
|
valuesPrinted: false
|
|
}
|
|
};
|
|
setTimeout(() => {
|
|
emitOtelSpan(endpoint, span, serviceId || "hwlab-cloud-web", "hwlab.cloud_web.opencode_proxy").catch((error) => {
|
|
process.stderr.write(JSON.stringify({
|
|
event: "cloud-web-opencode-otel-emit-failed",
|
|
serviceId: serviceId || "hwlab-cloud-web",
|
|
traceId: traceContext.traceId,
|
|
requestId: traceContext.requestId,
|
|
span: "opencode.proxy.stream.start",
|
|
error: error instanceof Error ? error.message : String(error),
|
|
valuesPrinted: false
|
|
}) + "\n");
|
|
});
|
|
}, 0);
|
|
}
|
|
|
|
function opencodeProxyRoute(pathname) {
|
|
const path = String(pathname || "/");
|
|
if (path === "/global/event") return "/global/event";
|
|
if (path === "/project" || path === "/project/current" || path === "/project/git/init") return path;
|
|
if (path === "/provider" || path === "/path" || path === "/global/health") return path;
|
|
if (/^\/session\/[^/]+\/prompt_async$/u.test(path)) return "/session/:sessionID/prompt_async";
|
|
if (/^\/session\/[^/]+\/message$/u.test(path)) return "/session/:sessionID/message";
|
|
if (/^\/session\/[^/]+\/abort$/u.test(path)) return "/session/:sessionID/abort";
|
|
if (/^\/session\/[^/]+(?:\/.*)?$/u.test(path)) return "/session/:sessionID/*";
|
|
if (path.startsWith("/assets/")) return "/assets/*";
|
|
return path;
|
|
}
|
|
|
|
async function emitOtelSpan(endpoint, span, serviceId, scopeName) {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 1500);
|
|
try {
|
|
await fetch(endpoint, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(otelPayload(span, serviceId, scopeName)),
|
|
signal: controller.signal
|
|
});
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
function otelPayload(span, serviceId, scopeName) {
|
|
return {
|
|
resourceSpans: [{
|
|
resource: { attributes: otelAttributes({ "service.name": serviceId, "deployment.environment": process.env.HWLAB_ENVIRONMENT || "v03" }) },
|
|
scopeSpans: [{
|
|
scope: { name: scopeName, version: "1" },
|
|
spans: [{
|
|
traceId: span.traceId,
|
|
spanId: span.spanId,
|
|
parentSpanId: span.parentSpanId || undefined,
|
|
name: span.name,
|
|
kind: span.kind,
|
|
startTimeUnixNano: unixNano(span.startTimeMs),
|
|
endTimeUnixNano: unixNano(span.endTimeMs),
|
|
attributes: otelAttributes({ ...span.attributes, "otel.trace_id": span.traceId }),
|
|
status: span.errorCode || (span.statusCode && span.statusCode >= 500) ? { code: 2, message: span.errorCode || `http_${span.statusCode}` } : { code: 1 }
|
|
}]
|
|
}]
|
|
}]
|
|
};
|
|
}
|
|
|
|
function otelAttributes(values) {
|
|
const result = [];
|
|
for (const [key, value] of Object.entries(values || {})) {
|
|
if (value === undefined || value === null || value === "") continue;
|
|
if (typeof value === "number" && Number.isFinite(value)) result.push({ key, value: Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value } });
|
|
else if (typeof value === "boolean") result.push({ key, value: { boolValue: value } });
|
|
else result.push({ key, value: { stringValue: String(value) } });
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function unixNano(ms) {
|
|
return String(BigInt(Math.max(0, Math.trunc(ms))) * 1000000n);
|
|
}
|
|
|
|
function otelTracesEndpoint(env) {
|
|
const explicit = firstNonEmpty(env.HWLAB_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT);
|
|
if (explicit) return explicit;
|
|
const base = firstNonEmpty(env.HWLAB_OTEL_EXPORTER_OTLP_ENDPOINT, env.OTEL_EXPORTER_OTLP_ENDPOINT);
|
|
return base ? `${base.replace(/\/+$/u, "")}/v1/traces` : "";
|
|
}
|
|
|
|
function firstNonEmpty(...values) {
|
|
for (const value of values) {
|
|
const text = String(value || "").trim();
|
|
if (text) return text;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function parseOtelTraceparent(value) {
|
|
const match = firstHeaderValue(value).trim().match(OTEL_TRACEPARENT_PATTERN);
|
|
if (!match) return null;
|
|
const traceId = normalizedOtelTraceId(match[1]);
|
|
const spanId = normalizedOtelSpanId(match[2]);
|
|
return traceId && spanId ? { traceId, spanId } : null;
|
|
}
|
|
|
|
function normalizedOtelTraceId(value) {
|
|
const text = firstHeaderValue(value).trim().toLowerCase();
|
|
return /^[0-9a-f]{32}$/u.test(text) && text !== ZERO_OTEL_TRACE_ID ? text : "";
|
|
}
|
|
|
|
function normalizedOtelSpanId(value) {
|
|
const text = firstHeaderValue(value).trim().toLowerCase();
|
|
return /^[0-9a-f]{16}$/u.test(text) && text !== ZERO_OTEL_SPAN_ID ? text : "";
|
|
}
|
|
|
|
function randomOtelTraceId() {
|
|
const value = randomBytes(16).toString("hex");
|
|
return value === ZERO_OTEL_TRACE_ID ? "00000000000000000000000000000001" : value;
|
|
}
|
|
|
|
function randomOtelSpanId() {
|
|
const value = randomBytes(8).toString("hex");
|
|
return value === ZERO_OTEL_SPAN_ID ? "0000000000000001" : value;
|
|
}
|
|
|
|
function safeRequestId(value) {
|
|
const text = firstHeaderValue(value).trim();
|
|
return SAFE_REQUEST_ID_PATTERN.test(text) ? text : "";
|
|
}
|
|
|
|
function firstHeaderValue(value) {
|
|
if (Array.isArray(value)) return String(value[0] ?? "");
|
|
return String(value ?? "");
|
|
}
|
|
|
|
function requestHostName(request) {
|
|
return normalizeHostName(String(request.headers.host || "").split(":")[0]);
|
|
}
|
|
|
|
function normalizeHostName(value) {
|
|
return String(value || "").trim().toLowerCase().replace(/\.+$/u, "");
|
|
}
|
|
|
|
function contentType(filePath) {
|
|
if (filePath.endsWith(".html")) return "text/html; charset=utf-8";
|
|
if (filePath.endsWith(".css")) return "text/css; charset=utf-8";
|
|
if (filePath.endsWith(".mjs") || filePath.endsWith(".js")) return "text/javascript; charset=utf-8";
|
|
if (filePath.endsWith(".svg")) return "image/svg+xml; charset=utf-8";
|
|
if (filePath.endsWith(".md")) return "text/markdown; charset=utf-8";
|
|
if (filePath.endsWith(".json")) return "application/json; charset=utf-8";
|
|
return "application/octet-stream";
|
|
}
|
|
|
|
async function encodeStaticBody(request, body, type) {
|
|
if (!isGzipCandidate(type, body.length) || !acceptsEncoding(request.headers["accept-encoding"], "gzip")) {
|
|
return { body, headers: isCompressibleType(type) ? { "vary": "Accept-Encoding" } : {} };
|
|
}
|
|
const compressed = await gzipAsync(body, { level: 6 });
|
|
return {
|
|
body: compressed,
|
|
headers: {
|
|
"content-encoding": "gzip",
|
|
"vary": "Accept-Encoding"
|
|
}
|
|
};
|
|
}
|
|
|
|
function isGzipCandidate(type, sizeBytes) {
|
|
return sizeBytes >= GZIP_MIN_BYTES && isCompressibleType(type);
|
|
}
|
|
|
|
function isCompressibleType(type) {
|
|
return /^(?:text\/|application\/json\b)/u.test(String(type || ""));
|
|
}
|
|
|
|
function acceptsEncoding(header, encoding) {
|
|
const value = Array.isArray(header) ? header.join(",") : String(header || "");
|
|
for (const segment of value.split(",")) {
|
|
const [name, ...params] = segment.trim().split(";");
|
|
const token = name.trim().toLowerCase();
|
|
if (token !== encoding && token !== "*") continue;
|
|
const q = params.map((param) => param.trim().toLowerCase()).find((param) => param.startsWith("q="));
|
|
if (q && Number.parseFloat(q.slice(2)) <= 0) continue;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|