feat(docs): assemble custom offline driver bundles in the browser

This commit is contained in:
t8y2
2026-10-04 20:39:37 +08:00
parent 2c31c6cf32
commit 90221ee3ac
13 changed files with 1626 additions and 38 deletions
@@ -6765,6 +6765,106 @@ mod agent_registry_install_tests {
assert!(!manager.driver_native_platform_path(SQLITE_WORKER_DRIVER_KEY, "linux-x64").exists());
assert!(!manager.driver_native_installed(SQLITE_WORKER_DRIVER_KEY));
}
/// The website drivers page assembles custom offline bundles in the
/// browser: raw artifacts under `drivers/`, the JRE tarball under `jre/`,
/// and a synthesized registry whose artifact URLs are `offline://` file
/// names. This test feeds exactly that JSON shape (including the extra
/// `external_driver_required` field the package registries carry) through
/// the real inspect + import pipeline.
#[tokio::test]
async fn offline_zip_imports_a_web_assembled_custom_bundle() {
let platform = AgentManager::current_platform();
let jar_name = "dbx-agent-h2-1.0.0.jar";
let worker_x64 = "dbx-agent-sqlite-worker-0.1.6-linux-x64";
let worker_arm = "dbx-agent-sqlite-worker-0.1.6-linux-aarch64";
let jre_name = format!("dbx-jre-21-{platform}.tar.zst");
let jar_bytes = test_agent_jar();
let jre_bytes = b"fake-jre-archive".to_vec();
let worker_x64_bytes = linux_native_binary(62);
let worker_arm_bytes = linux_native_binary(183);
let registry_json = serde_json::json!({
"jres": {
"21": {
"version": "21",
"platforms": {
platform: {
"url": format!("offline://{jre_name}"),
"sha256": sha256_bytes(&jre_bytes),
"size": jre_bytes.len(),
"format": "tar_zstd"
}
}
}
},
"drivers": {
"h2": {
"version": "1.0.0",
"label": "H2",
"min_app_version": "0.6.33",
"jre": "21",
"external_driver_required": false,
"jar": {
"url": format!("offline://{jar_name}"),
"sha256": sha256_bytes(&jar_bytes),
"size": jar_bytes.len()
}
},
"sqlite-worker": {
"version": "0.1.6",
"label": "SQLite SSH Worker",
"min_app_version": "0.6.30",
"jre": "21",
"external_driver_required": false,
"native": {
"linux-x64": {
"url": format!("offline://{worker_x64}"),
"sha256": sha256_bytes(&worker_x64_bytes),
"size": worker_x64_bytes.len()
},
"linux-aarch64": {
"url": format!("offline://{worker_arm}"),
"sha256": sha256_bytes(&worker_arm_bytes),
"size": worker_arm_bytes.len()
}
}
}
}
});
let registry: AgentRegistry =
serde_json::from_value(registry_json).expect("web-synthesized registry JSON must deserialize");
let (_dir, package) = write_offline_zip(
&registry,
&[
(format!("drivers/{jar_name}"), jar_bytes),
(format!("drivers/{worker_x64}"), worker_x64_bytes),
(format!("drivers/{worker_arm}"), worker_arm_bytes),
(format!("jre/{jre_name}"), jre_bytes),
],
);
let plan = inspect_offline_zip(&package).expect("web-synthesized bundle must pass inspection");
assert_eq!(plan.driver_keys, vec!["h2".to_string(), SQLITE_WORKER_DRIVER_KEY.to_string()]);
assert!(plan.includes_jre);
let manager = test_manager("offline-web-custom-bundle");
let result = import_offline_zip(&manager, &package, |_| {}).await.unwrap();
// The fake JRE archive cannot extract (no java executable inside), but
// that must only surface as an isolated JRE failure — never block the
// drivers, exactly like a corrupt real-world package.
assert!(result.failures.iter().all(|failure| failure.is_jre), "unexpected failures: {:?}", result.failures);
// The worker contributes one entry per remote platform, so it appears twice.
assert_eq!(
result.drivers_installed,
vec!["h2".to_string(), SQLITE_WORKER_DRIVER_KEY.to_string(), SQLITE_WORKER_DRIVER_KEY.to_string()]
);
assert!(manager.driver_jar_path("h2").is_file());
assert!(manager.driver_native_platform_path(SQLITE_WORKER_DRIVER_KEY, "linux-x64").is_file());
assert!(manager.driver_native_platform_path(SQLITE_WORKER_DRIVER_KEY, "linux-aarch64").is_file());
assert_eq!(manager.load_state().installed_drivers.get("h2").map(|d| d.version.as_str()), Some("1.0.0"));
}
}
#[cfg(test)]
+382 -21
View File
@@ -1,10 +1,12 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "next/navigation";
import { LandingNav } from "@/components/landing/LandingNav";
import { downloadLinksFor, formatSize, type AgentDownloadCatalog, type DownloadSource, type JreDisplayEntry, type NativeAgentDisplayEntry, type OfflineBundleEntry } from "@/lib/agentRegistry";
import { Archive, Cpu, Database, Download, Plug, Search, Terminal, X } from "lucide-react";
import { LandingSelect } from "@/components/landing/LandingSelect";
import { downloadLinksFor, formatSize, platformLabels, type AgentDownloadCatalog, type AgentRegistry, type DownloadSource, type JreDisplayEntry, type NativeAgentDisplayEntry, type OfflineBundleEntry } from "@/lib/agentRegistry";
import { assembleCustomBundle, buildCustomBundleOptions, cnbMirrorUrl, computeBundlePlan, CUSTOM_BUNDLE_PLATFORMS, proxyUrl, resolveBundleJre, type BundleProgress } from "@/lib/agentBundle";
import { Archive, Cpu, Database, Download, ListChecks, Plug, Search, Terminal, X } from "lucide-react";
import { resolveLang, type DocsLang } from "@/lib/i18n";
const i18n = {
@@ -43,6 +45,30 @@ const i18n = {
official: "Official",
},
downloadHint: "For air-gapped environments: download the bundle for your platform on an internet-connected machine, then transfer it to the offline machine and import it in DBX from Settings > Driver Manager. Use the driver and JRE tabs only when you need individual artifacts.",
custom: "Custom Bundle",
customDesc: "Check only the drivers you need; this page assembles a single offline ZIP that imports directly in DBX from Settings > Driver Manager.",
customPlatform: "Target platform",
customIncludeJre: `Include JRE 21`,
customIncludeJreForced: "Included automatically — Java agents require the JRE",
customSource: "Fetch source",
customSourceCnb: "CNB (direct)",
customSourceGithub: "GitHub (via this site)",
customDownload: "Download bundle",
customEstimate: "Estimated download",
customSelected: "selected",
customSelectAll: "Select all",
customClear: "Clear",
customWorkersNote: "The SQLite SSH Worker Linux packages are always included — remote SSH hosts execute them.",
customNoDrivers: "No drivers match this platform or search.",
customPhaseDownload: "Downloading",
customPhaseUnpack: "Unpacking",
customPhaseAssemble: "Assembling ZIP",
customPhaseDone: "Custom bundle downloaded ({size}). Import it in DBX from Settings > Driver Manager > offline import.",
customErrorTitle: "Bundle build failed",
customCancel: "Cancel",
customRetryHint: "Check the network or switch the fetch source, then try again.",
customNativeBadge: "Native",
customJavaBadge: "Java",
},
cn: {
title: "离线驱动下载",
@@ -79,10 +105,45 @@ const i18n = {
official: "官方下载",
},
downloadHint: "内网环境使用说明:在有网的电脑上下载对应平台的整包,然后传输到内网机器,在 DBX 的“设置 > 驱动管理”中导入。只有需要单个产物时再使用驱动和 JRE 标签页。",
custom: "自定义离线包",
customDesc: "只勾选需要的驱动,本页会在浏览器里合成一个离线 ZIP,在 DBX 的“设置 > 驱动管理”中离线导入即可。",
customPlatform: "目标平台",
customIncludeJre: "包含 JRE 21",
customIncludeJreForced: "已自动包含——Java 驱动必须搭配 JRE",
customSource: "下载源",
customSourceCnb: "CNB(直连)",
customSourceGithub: "GitHub(经本站代理)",
customDownload: "下载离线包",
customEstimate: "预计下载量",
customSelected: "已选",
customSelectAll: "全选",
customClear: "清空",
customWorkersNote: "SQLite SSH Worker 的 Linux 包会自动包含——它们在远端 SSH 主机上执行。",
customNoDrivers: "没有匹配该平台或搜索词的驱动。",
customPhaseDownload: "正在下载",
customPhaseUnpack: "正在解包",
customPhaseAssemble: "正在打包",
customPhaseDone: "自定义离线包已下载({size})。在 DBX 的“设置 > 驱动管理”中离线导入即可。",
customErrorTitle: "打包失败",
customCancel: "取消",
customRetryHint: "请检查网络或切换下载源后重试。",
customNativeBadge: "原生",
customJavaBadge: "Java",
},
};
type ActiveTab = "bundles" | "drivers" | "native" | "jre" | "jdbcPlugin";
type ActiveTab = "bundles" | "custom" | "drivers" | "native" | "jre" | "jdbcPlugin";
type BundleSource = "cnb" | "github";
function detectBundlePlatform(): string {
if (typeof navigator === "undefined") return "windows-x64";
const ua = navigator.userAgent;
if (/Windows/i.test(ua)) return /ARM|aarch64/i.test(ua) ? "windows-aarch64" : "windows-x64";
if (/Macintosh|Mac OS/i.test(ua)) return "macos-x64";
if (/Linux|X11/i.test(ua)) return /aarch64|arm64/i.test(ua) ? "linux-aarch64" : "linux-x64";
return "windows-x64";
}
function platformKey(j: JreDisplayEntry): string {
return `${j.jreKey}-${j.platformKey}`;
@@ -92,10 +153,6 @@ function bundleKey(bundle: OfflineBundleEntry): string {
return `${bundle.platformKey}-${bundle.filename}`;
}
function nativeKey(agent: NativeAgentDisplayEntry): string {
return `${agent.key}-${agent.platformKey}`;
}
type NativeAgentGroup = {
key: string;
label: string;
@@ -134,7 +191,7 @@ function matchesSearch(values: Array<string | number | undefined>, query: string
return values.filter(Boolean).join(" ").toLowerCase().includes(query);
}
export function DriversClient({ initialCatalog }: { initialCatalog: AgentDownloadCatalog }) {
export function DriversClient({ initialCatalog, initialRegistry }: { initialCatalog: AgentDownloadCatalog; initialRegistry: AgentRegistry }) {
const params = useParams();
const rawLang = params?.lang as string | undefined;
const lang = resolveLang(rawLang ?? "en");
@@ -145,6 +202,16 @@ export function DriversClient({ initialCatalog }: { initialCatalog: AgentDownloa
const [searchQuery, setSearchQuery] = useState("");
const [selectedNativePlatforms, setSelectedNativePlatforms] = useState<Record<string, string>>({});
const [bundlePlatform, setBundlePlatform] = useState("windows-x64");
const [selectedKeys, setSelectedKeys] = useState<ReadonlySet<string>>(() => new Set());
const [includeJre, setIncludeJre] = useState(false);
const [bundleSource, setBundleSource] = useState<BundleSource>("cnb");
const [bundleBusy, setBundleBusy] = useState(false);
const [bundleProgress, setBundleProgress] = useState<BundleProgress | null>(null);
const [bundleError, setBundleError] = useState<string | null>(null);
const [bundleDone, setBundleDone] = useState<number | null>(null);
const bundleAbortRef = useRef<AbortController | null>(null);
const bundles = catalog?.bundles ?? [];
const drivers = catalog?.drivers ?? [];
const nativeAgents = catalog?.nativeAgents ?? [];
@@ -204,8 +271,133 @@ export function DriversClient({ initialCatalog }: { initialCatalog: AgentDownloa
const filteredJres = useMemo(() => jres.filter((j) => matchesSearch([j.platformLabel, j.platformKey, j.jreVersion, j.jreKey, formatSize(j.info.size)], normalizedSearch)), [jres, normalizedSearch]);
const filteredJdbcPlugin = useMemo(() => (jdbcPlugin && matchesSearch([jdbcPlugin.label, jdbcPlugin.filename, jdbcPlugin.url, t.jdbcPlugin, t.jdbcPluginDesc], normalizedSearch) ? [jdbcPlugin] : []), [jdbcPlugin, normalizedSearch, t.jdbcPlugin, t.jdbcPluginDesc]);
const activeCount = activeTab === "bundles" ? filteredBundles.length : activeTab === "drivers" ? filteredDrivers.length : activeTab === "native" ? filteredNativeGroups.length : activeTab === "jre" ? filteredJres.length : filteredJdbcPlugin.length;
const activeTotal = activeTab === "bundles" ? bundles.length : activeTab === "drivers" ? drivers.length : activeTab === "native" ? nativeGroups.length : activeTab === "jre" ? jres.length : jdbcPlugin ? 1 : 0;
useEffect(() => {
// Detected after hydration: the prerendered HTML cannot know the visitor's
// platform, and a differing initial state would hydration-mismatch.
setBundlePlatform(detectBundlePlatform());
}, []);
const bundleOptions = useMemo(() => buildCustomBundleOptions(initialRegistry, bundlePlatform), [initialRegistry, bundlePlatform]);
const filteredBundleOptions = useMemo(() => bundleOptions.filter((option) => matchesSearch([option.label, option.key, option.version, option.kind], normalizedSearch)), [bundleOptions, normalizedSearch]);
const bundleJre = useMemo(() => resolveBundleJre(initialRegistry, bundlePlatform), [initialRegistry, bundlePlatform]);
const jreRequired = useMemo(() => bundleOptions.some((option) => option.requiresJre && selectedKeys.has(option.key)), [bundleOptions, selectedKeys]);
const bundlePlan = useMemo(() => computeBundlePlan(initialRegistry, bundlePlatform, selectedKeys, includeJre), [initialRegistry, bundlePlatform, selectedKeys, includeJre]);
const toggleBundleDriver = (key: string) => {
setSelectedKeys((current) => {
const next = new Set(current);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
const changeBundlePlatform = (platformKey: string) => {
setBundlePlatform(platformKey);
setSelectedKeys(new Set());
};
const fetchBundlePackage = async (url: string, onProgress: (received: number) => void, signal: AbortSignal): Promise<Uint8Array> => {
const proxy = proxyUrl(window.location.origin, url);
const mirror = cnbMirrorUrl(url);
const attempts: Array<{ label: string; url: string }> =
bundleSource === "cnb"
? [
{ label: t.customSourceCnb, url: mirror },
{ label: t.customSourceGithub, url: proxy },
]
: [
{ label: t.customSourceGithub, url: proxy },
{ label: t.customSourceCnb, url: mirror },
];
const errors: string[] = [];
for (const attempt of attempts) {
try {
const response = await fetch(attempt.url, { signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const reader = response.body?.getReader();
if (!reader) {
const bytes = new Uint8Array(await response.arrayBuffer());
onProgress(bytes.length);
return bytes;
}
const chunks: Uint8Array[] = [];
let received = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
onProgress(received);
}
const bytes = new Uint8Array(received);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.length;
}
return bytes;
} catch (error) {
if (signal.aborted) throw error;
// TypeError is the browser's opaque network/CORS failure; surface a
// readable cause so the combined message stays diagnosable.
const cause = error instanceof TypeError ? "network error" : error instanceof Error ? error.message : String(error);
errors.push(`${attempt.label}: ${cause}`);
}
}
throw new Error(errors.join(" · "));
};
const startBundleDownload = async () => {
if (bundleBusy || selectedKeys.size === 0) return;
const controller = new AbortController();
bundleAbortRef.current = controller;
setBundleBusy(true);
setBundleError(null);
setBundleDone(null);
setBundleProgress(null);
try {
const { blob, filename } = await assembleCustomBundle(bundlePlan, fetchBundlePackage, setBundleProgress, controller.signal);
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
setBundleDone(blob.size);
} catch (error) {
if (!controller.signal.aborted) {
setBundleError(error instanceof Error ? error.message : String(error));
}
} finally {
setBundleBusy(false);
setBundleProgress(null);
bundleAbortRef.current = null;
}
};
const cancelBundleDownload = () => {
bundleAbortRef.current?.abort();
};
const bundleProgressText = useMemo(() => {
if (!bundleProgress) return "";
if (bundleProgress.phase === "download") {
const expected = bundleProgress.expectedBytes > 0 ? ` / ${formatSize(bundleProgress.expectedBytes)}` : "";
return `${t.customPhaseDownload} ${bundleProgress.label} (${bundleProgress.index}/${bundleProgress.total}) · ${formatSize(bundleProgress.received)}${expected}`;
}
if (bundleProgress.phase === "unpack") return `${t.customPhaseUnpack} ${bundleProgress.label}`;
if (bundleProgress.phase === "assemble") return t.customPhaseAssemble;
return "";
}, [bundleProgress, t]);
const activeCount = activeTab === "bundles" ? filteredBundles.length : activeTab === "custom" ? filteredBundleOptions.length : activeTab === "drivers" ? filteredDrivers.length : activeTab === "native" ? filteredNativeGroups.length : activeTab === "jre" ? filteredJres.length : filteredJdbcPlugin.length;
const activeTotal = activeTab === "bundles" ? bundles.length : activeTab === "custom" ? bundleOptions.length : activeTab === "drivers" ? drivers.length : activeTab === "native" ? nativeGroups.length : activeTab === "jre" ? jres.length : jdbcPlugin ? 1 : 0;
return (
<main className="landing">
@@ -238,6 +430,14 @@ export function DriversClient({ initialCatalog }: { initialCatalog: AgentDownloa
<Archive size={14} />
{t.bundles}
</button>
<button
type="button"
onClick={() => setActiveTab("custom")}
className={`inline-flex h-8 cursor-pointer items-center justify-center gap-2 rounded-[6px] px-3 text-xs font-[650] transition-colors ${activeTab === "custom" ? "bg-landing-blue text-white" : "text-landing-muted hover:text-landing-ink"}`}
>
<ListChecks size={14} />
{t.custom}
</button>
<button
type="button"
onClick={() => setActiveTab("drivers")}
@@ -350,6 +550,171 @@ export function DriversClient({ initialCatalog }: { initialCatalog: AgentDownloa
</>
)}
{activeTab === "custom" && (
<>
<p className="border-b border-landing-line px-5 py-3 text-sm text-landing-muted whitespace-nowrap max-[760px]:whitespace-normal max-[760px]:px-4">{t.customDesc}</p>
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 border-b border-landing-line px-5 py-3 max-[760px]:px-4">
<div className="flex items-center gap-2 text-xs text-landing-muted">
<span className="shrink-0">{t.customPlatform}</span>
<LandingSelect
value={bundlePlatform}
options={CUSTOM_BUNDLE_PLATFORMS.map((platformKey) => ({ value: platformKey, label: platformLabels[platformKey] ?? platformKey }))}
onChange={changeBundlePlatform}
ariaLabel={t.customPlatform}
className="min-w-[190px]"
/>
</div>
<label className={`flex items-center gap-2 text-xs ${jreRequired ? "text-landing-sky" : "text-landing-muted"}`} title={jreRequired ? t.customIncludeJreForced : undefined}>
<input
type="checkbox"
checked={includeJre || jreRequired}
disabled={jreRequired || !bundleJre || bundleBusy}
onChange={() => setIncludeJre((current) => !current)}
className="h-3.5 w-3.5 accent-landing-blue"
/>
{t.customIncludeJre}
{bundleJre ? ` (${formatSize(bundleJre.size)})` : ""}
</label>
<div className="flex items-center gap-2 text-xs text-landing-muted">
<span className="shrink-0">{t.customSource}</span>
<LandingSelect<BundleSource>
value={bundleSource}
options={[
{ value: "cnb", label: t.customSourceCnb },
{ value: "github", label: t.customSourceGithub },
]}
onChange={setBundleSource}
disabled={bundleBusy}
ariaLabel={t.customSource}
/>
</div>
<div className="relative min-w-[200px] flex-1 max-[760px]:min-w-full">
<Search size={14} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-landing-muted" />
<input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={t.search}
className="h-8 w-full rounded-[6px] border border-landing-line bg-black/10 pl-8 pr-8 text-xs text-landing-ink outline-none transition-colors placeholder:text-landing-muted focus:border-landing-blue"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-1.5 top-1/2 grid h-6 w-6 -translate-y-1/2 cursor-pointer place-items-center rounded-[5px] text-landing-muted hover:bg-landing-soft hover:text-landing-ink"
aria-label={t.clearSearch}
>
<X size={13} />
</button>
)}
</div>
</div>
<div className="max-h-[520px] overflow-y-auto">
<table className="w-full table-fixed border-collapse text-sm max-[760px]:block">
<thead className="sticky top-0 z-10 bg-landing-panel text-xs font-medium text-landing-muted max-[760px]:hidden">
<tr className="border-b border-landing-line">
<th className="w-[36px] px-5 py-2.5 text-left font-medium"><span className="sr-only">{t.customSelectAll}</span></th>
<th className="w-[24%] px-5 py-2.5 text-left font-medium">Driver</th>
<th className="px-5 py-2.5 text-left font-medium">{t.version}</th>
<th className="w-[116px] px-5 py-2.5 text-right font-medium">{t.size}</th>
</tr>
</thead>
<tbody className="max-[760px]:block">
{filteredBundleOptions.map((option) => {
const checked = selectedKeys.has(option.key);
return (
<tr key={option.key} className={`border-b border-landing-line transition-colors last:border-b-0 ${checked ? "bg-landing-blue/10" : "hover:bg-landing-panel"} max-[760px]:grid max-[760px]:grid-cols-[auto_1fr_auto] max-[760px]:items-center max-[760px]:gap-3 max-[760px]:px-4`}>
<td className="px-5 py-3 max-[760px]:px-0">
<input
type="checkbox"
checked={checked}
disabled={bundleBusy}
onChange={() => toggleBundleDriver(option.key)}
aria-label={`${t.customDownload}: ${option.label}`}
className="h-4 w-4 cursor-pointer accent-landing-blue"
/>
</td>
<td className="min-w-0 px-5 py-3 font-medium text-landing-ink max-[760px]:px-0">
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 truncate">{option.label}</span>
<span className="hidden shrink-0 rounded-[5px] border border-landing-blue/35 bg-landing-blue/10 px-1.5 py-0.5 font-mono text-[11px] text-landing-sky max-[760px]:inline">{option.key}</span>
<span className={`hidden shrink-0 rounded-[5px] border px-1.5 py-0.5 font-mono text-[11px] max-[760px]:inline ${option.kind === "native" ? "border-landing-green/35 bg-landing-green/10 text-landing-green" : "border-landing-line bg-black/10 text-landing-muted"}`}>
{option.kind === "native" ? t.customNativeBadge : t.customJavaBadge}
</span>
</div>
</td>
<td className="px-5 py-3 text-xs text-landing-muted max-[760px]:hidden">{option.version}</td>
<td className="whitespace-nowrap px-5 py-3 text-right text-xs text-landing-muted max-[760px]:hidden">{formatSize(option.packageSize)}</td>
</tr>
);
})}
</tbody>
</table>
{filteredBundleOptions.length === 0 && <div className="px-5 py-12 text-center text-sm text-landing-muted">{t.customNoDrivers}</div>}
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-landing-line px-5 py-3 max-[760px]:px-4">
<div className="min-w-0 text-xs leading-[1.6] text-landing-muted">
<div>
{t.customSelected} {selectedKeys.size} · {t.customEstimate} {formatSize(bundlePlan.totalDownloadBytes)}
</div>
<div className="text-landing-muted/80">{t.customWorkersNote}</div>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
<button
type="button"
disabled={bundleBusy || filteredBundleOptions.length === 0}
onClick={() => setSelectedKeys(new Set(filteredBundleOptions.map((option) => option.key)))}
className="h-8 cursor-pointer rounded-[6px] border border-landing-line px-3 text-xs font-medium text-landing-muted transition-colors hover:border-landing-blue hover:text-landing-ink disabled:cursor-not-allowed disabled:opacity-50"
>
{t.customSelectAll}
</button>
<button
type="button"
disabled={bundleBusy || selectedKeys.size === 0}
onClick={() => setSelectedKeys(new Set())}
className="h-8 cursor-pointer rounded-[6px] border border-landing-line px-3 text-xs font-medium text-landing-muted transition-colors hover:border-landing-blue hover:text-landing-ink disabled:cursor-not-allowed disabled:opacity-50"
>
{t.customClear}
</button>
{bundleBusy ? (
<button
type="button"
onClick={cancelBundleDownload}
className="inline-flex h-8 cursor-pointer items-center gap-2 rounded-[6px] border border-landing-line px-3 text-xs font-[650] text-landing-ink transition-colors hover:border-[#ff7376] hover:text-[#ff7376]"
>
<X size={13} />
{t.customCancel}
</button>
) : (
<button
type="button"
disabled={selectedKeys.size === 0}
onClick={startBundleDownload}
className="inline-flex h-8 cursor-pointer items-center gap-2 rounded-[6px] bg-landing-blue px-3 text-xs font-[650] text-white transition-colors hover:bg-landing-blue/85 disabled:cursor-not-allowed disabled:opacity-50"
>
<Download size={13} />
{t.customDownload}
</button>
)}
</div>
</div>
{bundleProgress && bundleBusy && (
<div className="border-t border-landing-line px-5 py-3 text-xs text-landing-sky max-[760px]:px-4">{bundleProgressText}</div>
)}
{bundleError && (
<div className="border-t border-landing-line px-5 py-3 text-xs leading-[1.6] text-[#ff7376] max-[760px]:px-4">
<strong>{t.customErrorTitle}:</strong>
{bundleError}
<div className="text-landing-muted">{t.customRetryHint}</div>
</div>
)}
{bundleDone !== null && !bundleBusy && (
<div className="border-t border-landing-line px-5 py-3 text-xs leading-[1.6] text-landing-green max-[760px]:px-4">
{t.customPhaseDone.replace("{size}", formatSize(bundleDone))}
</div>
)}
</>
)}
{activeTab === "drivers" && (
<>
<p className="border-b border-landing-line px-5 py-3 text-sm text-landing-muted whitespace-nowrap max-[760px]:whitespace-normal max-[760px]:px-4">{t.driversDesc}</p>
@@ -417,17 +782,13 @@ export function DriversClient({ initialCatalog }: { initialCatalog: AgentDownloa
</div>
</td>
<td className="px-5 py-3 max-[760px]:col-span-2 max-[760px]:px-0 max-[760px]:pt-0">
<select
<LandingSelect
value={selectedAgent.platformKey}
onChange={(event) => setSelectedNativePlatforms((current) => ({ ...current, [group.key]: event.target.value }))}
className="h-8 min-w-[190px] rounded-[6px] border border-landing-line bg-black/10 px-2.5 text-xs text-landing-ink outline-none transition-colors focus:border-landing-blue max-[760px]:w-full"
>
{group.options.map((option) => (
<option key={nativeKey(option)} value={option.platformKey} className="bg-[#202123] text-landing-ink">
{option.platformLabel}
</option>
))}
</select>
options={group.options.map((option) => ({ value: option.platformKey, label: option.platformLabel }))}
onChange={(platformKey) => setSelectedNativePlatforms((current) => ({ ...current, [group.key]: platformKey }))}
ariaLabel={`${group.label}: ${t.platform}`}
className="min-w-[190px] max-[760px]:w-full"
/>
</td>
<td className="px-5 py-3 text-xs text-landing-muted max-[760px]:hidden">{group.version}</td>
<td className="whitespace-nowrap px-5 py-3 text-right text-xs text-landing-muted max-[760px]:hidden">{formatSize(selectedAgent.info.size)}</td>
+4 -4
View File
@@ -1,5 +1,5 @@
import { DriversClient } from "./DriversClient";
import { fetchAgentDownloadCatalog } from "@/lib/agentRegistrySource";
import { fetchAgentDownloadCatalogWithRegistry } from "@/lib/agentRegistrySource";
import { buildMetadata } from "@/lib/metadata";
import type { Metadata } from "next";
import { resolveLang } from "@/lib/i18n";
@@ -30,7 +30,7 @@ export async function generateMetadata({ params }: { params: Promise<{ lang: str
}
export default async function DriversPage() {
const catalog = await fetchAgentDownloadCatalog();
if (!catalog) throw new Error("Unable to generate the static driver catalog from R2 or CNB");
return <DriversClient initialCatalog={catalog} />;
const source = await fetchAgentDownloadCatalogWithRegistry();
if (!source) throw new Error("Unable to generate the static driver catalog from R2 or CNB");
return <DriversClient initialCatalog={source.catalog} initialRegistry={source.registry} />;
}
+153
View File
@@ -0,0 +1,153 @@
"use client";
import { Check, ChevronDown } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
export interface LandingSelectOption<T extends string> {
value: T;
label: string;
}
/**
* Landing-styled dropdown replacing native <select>, which cannot be themed to
* match the dark landing cards. The option list renders in a body portal with
* fixed coordinates so `overflow-hidden` cards and scrollable table rows never
* clip it; it closes on outside click, Escape, and scroll/resize.
*/
export function LandingSelect<T extends string>({
value,
options,
onChange,
disabled = false,
ariaLabel,
className = "",
}: {
value: T;
options: ReadonlyArray<LandingSelectOption<T>>;
onChange: (value: T) => void;
disabled?: boolean;
ariaLabel: string;
className?: string;
}) {
const [open, setOpen] = useState(false);
const [popupStyle, setPopupStyle] = useState<React.CSSProperties | null>(null);
const [activeIndex, setActiveIndex] = useState(() => Math.max(0, options.findIndex((option) => option.value === value)));
const rootRef = useRef<HTMLDivElement>(null);
const popupRef = useRef<HTMLUListElement>(null);
const toggle = (next: boolean) => {
if (!next) {
setOpen(false);
return;
}
const rect = rootRef.current?.getBoundingClientRect();
if (rect) {
const dropUp = window.innerHeight - rect.bottom < 280 && rect.top > 280;
setPopupStyle({
position: "fixed",
left: Math.max(8, Math.min(rect.left, window.innerWidth - Math.max(rect.width, 200) - 8)),
minWidth: rect.width,
...(dropUp ? { bottom: `${window.innerHeight - rect.top + 4}px` } : { top: `${rect.bottom + 4}px` }),
});
}
setOpen(true);
};
useEffect(() => {
if (!open) return;
const onPointerDown = (event: MouseEvent) => {
const target = event.target as Node;
if (!rootRef.current?.contains(target) && !popupRef.current?.contains(target)) setOpen(false);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
const onAnchorMoved = () => setOpen(false);
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
window.addEventListener("scroll", onAnchorMoved, true);
window.addEventListener("resize", onAnchorMoved);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
window.removeEventListener("scroll", onAnchorMoved, true);
window.removeEventListener("resize", onAnchorMoved);
};
}, [open]);
const selected = options.find((option) => option.value === value);
const commit = (index: number) => {
const option = options[index];
if (!option) return;
onChange(option.value);
setOpen(false);
};
const onButtonKeyDown = (event: React.KeyboardEvent) => {
if (disabled) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
setActiveIndex(Math.max(0, options.findIndex((option) => option.value === value)));
toggle(true);
}
};
const onListKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveIndex((current) => Math.min(current + 1, options.length - 1));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setActiveIndex((current) => Math.max(current - 1, 0));
} else if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
commit(activeIndex);
}
};
return (
<div ref={rootRef} className={`relative ${className}`}>
<button
type="button"
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={ariaLabel}
onClick={() => !disabled && toggle(!open)}
onKeyDown={onButtonKeyDown}
className="inline-flex h-8 w-full cursor-pointer items-center justify-between gap-2 rounded-[6px] border border-landing-line bg-black/10 px-2.5 text-xs text-landing-ink outline-none transition-colors hover:border-landing-blue focus:border-landing-blue disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="min-w-0 truncate">{selected?.label ?? value}</span>
<ChevronDown size={13} className={`shrink-0 text-landing-muted transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open &&
createPortal(
<ul
ref={popupRef}
role="listbox"
aria-label={ariaLabel}
tabIndex={-1}
onKeyDown={onListKeyDown}
style={popupStyle ?? undefined}
className="z-50 max-h-[260px] w-max min-w-[200px] overflow-y-auto rounded-[7px] border border-landing-line bg-[#202123] py-1 shadow-[0_8px_24px_rgba(0,0,0,0.45)] outline-none"
>
{options.map((option, index) => (
<li key={option.value} role="option" aria-selected={option.value === value}>
<button
type="button"
onMouseEnter={() => setActiveIndex(index)}
onClick={() => commit(index)}
className={`flex w-full cursor-pointer items-center justify-between gap-3 whitespace-nowrap px-3 py-1.5 text-left text-xs transition-colors ${option.value === value ? "text-landing-sky" : "text-landing-ink"} ${index === activeIndex ? "bg-landing-soft" : ""}`}
>
<span className="min-w-0 truncate">{option.label}</span>
{option.value === value && <Check size={13} className="shrink-0" />}
</button>
</li>
))}
</ul>,
document.body,
)}
</div>
);
}
+337
View File
@@ -0,0 +1,337 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import {
assembleCustomBundle,
buildCustomBundleOptions,
buildSqliteWorkerOptions,
buildStoredZipParts,
cnbMirrorUrl,
computeBundlePlan,
crc32,
extractDriverPackage,
parseTarEntries,
proxyUrl,
resolveBundleJre,
type BundleFetch,
type BundleProgress,
} from "./agentBundle";
import type { AgentRegistry } from "./agentRegistry";
// One real zstd-compressed tar package per artifact kind, generated from the
// same packaging pipeline the agents release uses: agent-registry.json plus a
// single drivers/<raw artifact> entry whose sha256/size live in the package
// registry.
const H2_PACKAGE_B64 =
"KLUv/WQAJ+UIANLPMR5Abx4MRfRiaicr9JyLFkQ+oPd7okg66hWu3jEMvzJUQUDQQoJggC69cU2gMs45/RfsImTnS3ntYEKoNtdhe9Y+T5zT+oxSygeSgxkvdPhYaO8377m7rfHb6Qi9MqZeLOCMQMXePMm10eunqEJO3894EvlMaVSl/JLK92rnq5uGRdYoBFKphB292g/R2xr16sYac+209MDO6PwUnG+vDMbp6XOjMao1latkmqgGUFJC55RrrzX1XmxKekwNZsmSCS8+ByTSL2ogIKBI5OZiDvEB4gGBM0YluJsxQKawR+ruyWIPqAuBfwjWTQIMguX/ukuazBE4rMCIDSoGgHXg1Ns/gAgRNscABWE+lARNsumy9UL2QQWYRssdLqm+fQ==";
const WORKER_X64_PACKAGE_B64 =
"KLUv/WQAJ8UJANaQOBUg33aNNeLmwGRzcMD4KmOEEQQQxJAyADIAMgAu3N8/m6/YhRx/Ovzfs+OAYQDvF8MyYATAYvjjSBTDABTFG8DKwidhvzFskv8d4vWTX7MZf2sX+oEI3UgI77Xt562m2gUukDQt+LFZYVr6wS/tQvfEQRj96Strvh99WXr4sZkGywebD0TGBFkQ0DfjqIiHgTTJT3zogAbIKIwSNk1/ul+Qr4S7MAxDDYqvrPh6qOXVTH+A5ym+87rNmirPmiAvi6js4pgE8R4xCiBHkKf/22SaDzJJMvpECQ/FGZMvdBrvQyUgIIBmAkXcHM0D8m1MlbhuZICcgByVu8cNsNLFxssT9tBZEXR7j3YCKBmiERgujoFLQ5ARlKwcyUM6QnBhUaEAQZgzJQEn2fX6G1zINqgA06i7A1oXEpU=";
const WORKER_ARM_PACKAGE_B64 =
"KLUv/WQAJ/0JAMYQOSAQlR5qET7YNofHkpdFP5XGZR5I2104afgPlBQBAADgMi4AMAAuAB2u839wvj2VSIPmPmOa9Nxfth3neQ541CmE0v1JSf1tGw5TiXLHYctnqtveYgNaZ7eeGsapXVBRjMOx2IY1PUkACAgE8B1eOCDaZ1brqWkFK+9BbxucP5M3IOo7rAWxTgj9wphN/ylnlNS6pL0yt65bQbzZV65I6Yz2wvVEUVQou9kj6YWSraVCng0HT1KE4UEgYJMII7HAGIkC9jgoHg0JlF5d4IFXknHQP8I/45XZvG6t9StWfHtZkVKrDCIggGYCrNockQfkx5hqdt34AIkCbmTuHjewShcbAw+sowlEzFIMLdyknUBKQzwFBvESBBqABRBkBSWbI+khHSH4sOhQgCDMj5KAkyx4fW8NyDJoANOougOGR+RC";
const H2_JAR_BYTES = new TextEncoder().encode("FAKE_JAR_00");
const WORKER_X64_BYTES = new TextEncoder().encode("WORKER_BIN_LINUX_X64__");
const WORKER_ARM_BYTES = new TextEncoder().encode("WORKER_BIN_LINUX_AARCH64");
const JRE_BYTES = new TextEncoder().encode("FAKE_JRE_TAR_ZST_BYTES");
const JRE_SHA256 = "6529fca7c62b0e7b0ecf1afe8fe75a924897c30dda8f7e5862677a59bcf68aa0";
function decodeB64(value: string): Uint8Array {
return new Uint8Array(Buffer.from(value, "base64"));
}
async function sha256Hex(bytes: Uint8Array): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", bytes as unknown as ArrayBuffer);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
const RELEASE = "https://github.com/t8y2/dbx/releases/download/agents-v9.9.9";
const h2Package = decodeB64(H2_PACKAGE_B64);
const workerX64Package = decodeB64(WORKER_X64_PACKAGE_B64);
const workerArmPackage = decodeB64(WORKER_ARM_PACKAGE_B64);
const testRegistry: AgentRegistry = {
jres: {
"21": {
version: "21",
platforms: {
"windows-x64": { url: `${RELEASE}/dbx-jre-21-windows-x64.tar.zst`, sha256: JRE_SHA256, size: JRE_BYTES.length, format: "tar_zstd" },
"macos-aarch64": { url: `${RELEASE}/dbx-jre-21-macos-aarch64.tar.zst`, sha256: "0".repeat(64), size: 5, format: "tar_zstd" },
},
},
},
drivers: {
h2: {
version: "1.0.0",
label: "H2",
min_app_version: "0.6.33",
jre: "21",
jar: { url: `${RELEASE}/dbx-agent-h2-1.0.0.tar.zst`, sha256: await sha256Hex(h2Package), size: h2Package.length, format: "tar_zstd" },
},
duckdb: {
version: "0.1.29",
label: "DuckDB",
min_app_version: "0.6.33",
jre: "21",
native: {
"windows-x64": { url: `${RELEASE}/dbx-agent-duckdb-0.1.29-windows-x64.tar.zst`, sha256: "1".repeat(64), size: 100, format: "tar_zstd" },
"macos-aarch64": { url: `${RELEASE}/dbx-agent-duckdb-0.1.29-macos-aarch64.tar.zst`, sha256: "2".repeat(64), size: 90, format: "tar_zstd" },
},
},
"sqlite-worker": {
version: "0.1.6",
label: "SQLite SSH Worker",
min_app_version: "0.6.30",
jre: "21",
native: {
"linux-x64": { url: `${RELEASE}/dbx-agent-sqlite-worker-0.1.6-linux-x64.tar.zst`, sha256: await sha256Hex(workerX64Package), size: workerX64Package.length, format: "tar_zstd" },
"linux-aarch64": { url: `${RELEASE}/dbx-agent-sqlite-worker-0.1.6-linux-aarch64.tar.zst`, sha256: await sha256Hex(workerArmPackage), size: workerArmPackage.length, format: "tar_zstd" },
},
},
},
};
test("crc32 matches the canonical check value", () => {
assert.equal(crc32(new TextEncoder().encode("123456789")), 0xcbf43926);
});
function tarEntry(name: string, data: Uint8Array): Uint8Array {
const header = new Uint8Array(512);
header.set(new TextEncoder().encode(name), 0);
header.set(new TextEncoder().encode(data.length.toString(8).padStart(11, "0") + "\0"), 124);
header[156] = 48;
const padded = new Uint8Array(Math.ceil(data.length / 512) * 512);
padded.set(data, 0);
const entry = new Uint8Array(512 + padded.length);
entry.set(header, 0);
entry.set(padded, 512);
return entry;
}
test("tar entries round-trip through the parser", () => {
const registry = new TextEncoder().encode('{"drivers":{}}');
const bin = new Uint8Array(600).fill(7);
const parts = [tarEntry("agent-registry.json", registry), tarEntry("drivers/agent.bin", bin), new Uint8Array(1024)];
const bytes = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
let offset = 0;
for (const part of parts) {
bytes.set(part, offset);
offset += part.length;
}
const entries = parseTarEntries(bytes);
assert.deepEqual([...entries.keys()], ["agent-registry.json", "drivers/agent.bin"]);
assert.equal(new TextDecoder().decode(entries.get("agent-registry.json")!), '{"drivers":{}}');
const parsedBin = entries.get("drivers/agent.bin")!;
assert.equal(parsedBin.length, 600);
assert.ok(parsedBin.every((byte) => byte === 7));
});
interface ParsedZipEntry {
name: string;
data: Uint8Array;
method: number;
crc: number;
}
function readStoredZip(bytes: Uint8Array): ParsedZipEntry[] {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const entries: ParsedZipEntry[] = [];
const expectedOffsets: number[] = [];
let offset = 0;
while (view.getUint32(offset, true) === 0x04034b50) {
const method = view.getUint16(offset + 8, true);
const crc = view.getUint32(offset + 14, true);
const size = view.getUint32(offset + 22, true);
const nameLength = view.getUint16(offset + 26, true);
const extraLength = view.getUint16(offset + 28, true);
const name = new TextDecoder().decode(bytes.subarray(offset + 30, offset + 30 + nameLength));
const dataStart = offset + 30 + nameLength + extraLength;
entries.push({ name, data: bytes.subarray(dataStart, dataStart + size), method, crc });
expectedOffsets.push(offset);
offset = dataStart + size;
}
const dataEnd = offset;
// The central directory follows the file data; every record must mirror the
// local header it describes and the EOCD counts must match.
let centralCount = 0;
while (view.getUint32(offset, true) === 0x02014b50) {
const nameLength = view.getUint16(offset + 28, true);
const extraLength = view.getUint16(offset + 30, true);
const commentLength = view.getUint16(offset + 32, true);
const localOffset = view.getUint32(offset + 42, true);
assert.equal(view.getUint16(offset + 10, true), 0, "central directory entry must be STORED");
const entry = entries[centralCount];
assert.ok(entry, `central directory has more entries than local headers: #${centralCount}`);
assert.equal(new TextDecoder().decode(bytes.subarray(offset + 46, offset + 46 + nameLength)), entry.name);
assert.equal(view.getUint32(offset + 16, true), entry.crc);
assert.equal(view.getUint32(offset + 20, true), entry.data.length);
assert.equal(localOffset, expectedOffsets[centralCount]);
centralCount += 1;
offset += 46 + nameLength + extraLength + commentLength;
}
const centralEnd = offset;
assert.equal(view.getUint32(offset, true), 0x06054b50);
assert.equal(view.getUint16(offset + 8, true), entries.length);
assert.equal(view.getUint16(offset + 10, true), centralCount);
assert.equal(view.getUint32(offset + 12, true), centralEnd - dataEnd);
assert.equal(view.getUint32(offset + 16, true), dataEnd);
assert.equal(bytes.length, offset + 22);
return entries;
}
test("stored zip parts assemble a valid zip32 archive", async () => {
const first = new TextEncoder().encode("agent-registry.json content");
const second = new Uint8Array(1000).fill(9);
const result = buildStoredZipParts([
{ name: "agent-registry.json", data: first },
{ name: "drivers/dbx-agent.bin", data: second },
], new Date(2026, 9, 4, 12, 30, 22));
assert.equal(result.totalSize, (30 + 19) + 27 + (30 + 21) + 1000 + (46 + 19) + (46 + 21) + 22);
const blob = new Blob(result.parts);
const entries = readStoredZip(new Uint8Array(await blob.arrayBuffer()));
assert.deepEqual(
entries.map((entry) => entry.name),
["agent-registry.json", "drivers/dbx-agent.bin"],
);
assert.ok(entries.every((entry) => entry.method === 0));
assert.deepEqual([...entries[0]!.data], [...first]);
assert.ok(entries[1]!.data.every((byte) => byte === 9));
assert.equal(entries[0]!.crc, crc32(first));
assert.throws(() => buildStoredZipParts([]), /at least one file/);
assert.throws(() => buildStoredZipParts([{ name: "é.bin", data: first }]), /ASCII/);
});
test("driver packages unpack their raw artifact and offline registry entry", () => {
const extracted = extractDriverPackage(h2Package, "h2");
assert.equal(extracted.artifactKind, "jar");
assert.equal(extracted.filename, "dbx-agent-h2-1.0.0.jar");
assert.deepEqual([...extracted.bytes], [...H2_JAR_BYTES]);
assert.equal(extracted.entry.label, "H2");
assert.equal(extracted.entry.min_app_version, "0.6.33");
assert.equal(extracted.entry.jar?.url, "offline://dbx-agent-h2-1.0.0.jar");
assert.equal(extracted.entry.jar?.size, H2_JAR_BYTES.length);
assert.ok(extracted.entry.jar?.sha256);
const worker = extractDriverPackage(workerX64Package, "sqlite-worker");
assert.equal(worker.artifactKind, "native");
assert.deepEqual(worker.nativePlatforms, ["linux-x64"]);
assert.equal(worker.filename, "dbx-agent-sqlite-worker-0.1.6-linux-x64");
assert.deepEqual([...worker.bytes], [...WORKER_X64_BYTES]);
assert.equal(worker.entry.native?.["linux-x64"]?.url, "offline://dbx-agent-sqlite-worker-0.1.6-linux-x64");
assert.throws(() => extractDriverPackage(h2Package, "duckdb"), /exactly the "duckdb"/);
});
test("bundle options follow native-over-jar and platform availability", () => {
const windows = buildCustomBundleOptions(testRegistry, "windows-x64");
assert.deepEqual(windows.map((option) => option.key), ["duckdb", "h2"]);
assert.equal(windows.find((option) => option.key === "duckdb")?.kind, "native");
assert.equal(windows.find((option) => option.key === "h2")?.kind, "jar");
assert.equal(windows.find((option) => option.key === "h2")?.requiresJre, true);
assert.equal(windows.find((option) => option.key === "duckdb")?.requiresJre, false);
const workers = buildSqliteWorkerOptions(testRegistry, "windows-x64");
assert.equal(workers.length, 2);
assert.deepEqual(
workers.map((worker) => worker.packageUrl.split("/").pop()),
["dbx-agent-sqlite-worker-0.1.6-linux-x64.tar.zst", "dbx-agent-sqlite-worker-0.1.6-linux-aarch64.tar.zst"],
);
const jre = resolveBundleJre(testRegistry, "windows-x64");
assert.equal(jre?.filename, "dbx-jre-21-windows-x64.tar.zst");
assert.equal(jre?.sha256, JRE_SHA256);
});
test("bundle plans force the JRE for Java agents and always bundle SSH workers", () => {
const jarPlan = computeBundlePlan(testRegistry, "windows-x64", new Set(["h2"]), false);
assert.equal(jarPlan.requiresJre, true);
assert.equal(jarPlan.includeJre, true);
assert.ok(jarPlan.jre);
assert.deepEqual(
jarPlan.drivers.map((driver) => driver.key),
["h2"],
);
assert.equal(jarPlan.workers.length, 2);
assert.equal(jarPlan.totalDownloadBytes, h2Package.length + workerX64Package.length + workerArmPackage.length + JRE_BYTES.length);
assert.equal(jarPlan.filename, "dbx-agents-offline-custom-windows-x64.zip");
const nativePlan = computeBundlePlan(testRegistry, "windows-x64", new Set(["duckdb"]), true);
assert.equal(nativePlan.requiresJre, false);
assert.equal(nativePlan.includeJre, true);
assert.ok(nativePlan.jre);
const nativeOnly = computeBundlePlan(testRegistry, "windows-x64", new Set(["duckdb"]), false);
assert.equal(nativeOnly.includeJre, false);
assert.equal(nativeOnly.jre, null);
assert.equal(nativeOnly.totalDownloadBytes, 100 + workerX64Package.length + workerArmPackage.length);
});
test("assemble builds a driver-manager compatible offline ZIP end to end", async () => {
const plan = computeBundlePlan(testRegistry, "windows-x64", new Set(["h2"]), false);
const fetched: string[] = [];
const fetchPackage: BundleFetch = async (url) => {
fetched.push(url);
if (url.endsWith("dbx-agent-h2-1.0.0.tar.zst")) return h2Package;
if (url.endsWith("dbx-agent-sqlite-worker-0.1.6-linux-x64.tar.zst")) return workerX64Package;
if (url.endsWith("dbx-agent-sqlite-worker-0.1.6-linux-aarch64.tar.zst")) return workerArmPackage;
if (url.endsWith("dbx-jre-21-windows-x64.tar.zst")) return JRE_BYTES;
throw new Error(`unexpected url ${url}`);
};
const progress: BundleProgress[] = [];
const signal = new AbortController().signal;
const assembled = await assembleCustomBundle(plan, fetchPackage, (event) => progress.push(event), signal);
assert.equal(assembled.filename, "dbx-agents-offline-custom-windows-x64.zip");
assert.ok(progress.some((event) => event.phase === "download" && event.label === "H2"));
assert.ok(progress.some((event) => event.phase === "unpack"));
assert.ok(progress.some((event) => event.phase === "assemble"));
assert.ok(progress.some((event) => event.phase === "done"));
const bytes = new Uint8Array(await assembled.blob.arrayBuffer());
const entries = readStoredZip(bytes);
assert.deepEqual(
entries.map((entry) => entry.name),
["agent-registry.json", "drivers/dbx-agent-h2-1.0.0.jar", "drivers/dbx-agent-sqlite-worker-0.1.6-linux-x64", "drivers/dbx-agent-sqlite-worker-0.1.6-linux-aarch64", "jre/dbx-jre-21-windows-x64.tar.zst"],
);
assert.deepEqual([...entries[1]!.data], [...H2_JAR_BYTES]);
assert.deepEqual([...entries[2]!.data], [...WORKER_X64_BYTES]);
assert.deepEqual([...entries[4]!.data], [...JRE_BYTES]);
const registry = JSON.parse(new TextDecoder().decode(entries[0]!.data)) as {
jres: Record<string, { version: string; platforms: Record<string, { url: string; sha256?: string; size: number; format: string }> }>;
drivers: Record<string, Record<string, unknown> & { jar?: { url: string; size: number }; native?: Record<string, { url: string }> }>;
};
assert.deepEqual(Object.keys(registry.jres), ["21"]);
const jreEntry = registry.jres["21"]!.platforms["windows-x64"]!;
assert.equal(jreEntry.url, "offline://dbx-jre-21-windows-x64.tar.zst");
assert.equal(jreEntry.sha256, JRE_SHA256);
assert.equal(jreEntry.size, JRE_BYTES.length);
assert.equal(jreEntry.format, "tar_zstd");
assert.equal(registry.drivers.h2!.jar!.url, "offline://dbx-agent-h2-1.0.0.jar");
assert.equal(registry.drivers.h2!.min_app_version, "0.6.33");
assert.equal(registry.drivers.h2!.label, "H2");
assert.deepEqual(Object.keys(registry.drivers["sqlite-worker"]!.native!), ["linux-x64", "linux-aarch64"]);
assert.equal(fetched.length, 4);
});
test("assembly rejects packages whose checksum does not match the registry", async () => {
const corrupted: AgentRegistry = JSON.parse(JSON.stringify(testRegistry));
corrupted.drivers!.h2!.jar!.sha256 = "f".repeat(64);
const plan = computeBundlePlan(corrupted, "windows-x64", new Set(["h2"]), false);
const fetchPackage: BundleFetch = async (url) => (url.endsWith("h2-1.0.0.tar.zst") ? h2Package : url.endsWith("linux-x64.tar.zst") ? workerX64Package : url.endsWith("linux-aarch64.tar.zst") ? workerArmPackage : JRE_BYTES);
await assert.rejects(
assembleCustomBundle(plan, fetchPackage, () => {}, new AbortController().signal),
/checksum mismatch/i,
);
});
test("mirror and proxy URLs map GitHub assets to their alternates", () => {
assert.equal(cnbMirrorUrl(`${RELEASE}/dbx-agent-h2-1.0.0.tar.zst`), `https://cnb.cool/dbxio.com/dbx/-/releases/download/agents-v9.9.9/dbx-agent-h2-1.0.0.tar.zst`);
assert.equal(
proxyUrl("https://dbxio.com", `${RELEASE}/dbx-agent-h2-1.0.0.tar.zst`),
`https://dbxio.com/api/agent-asset?url=${encodeURIComponent(`${RELEASE}/dbx-agent-h2-1.0.0.tar.zst`)}`,
);
});
+534
View File
@@ -0,0 +1,534 @@
import { decompress } from "fzstd";
import { labelForDriver, platformLabels, type AgentRegistry, type AgentRegistryArtifact, type AgentRegistryDriver } from "./agentRegistry";
/**
* Custom offline bundle builder.
*
* The website assembles a driver-manager-compatible ZIP (`agent-registry.json`
* + `drivers/` + `jre/`) entirely in the browser:
*
* 1. The agent registry exposes every driver as a `.tar.zst` *package* that
* wraps the raw artifact (a Java agent JAR or a native executable) plus a
* single-driver `agent-registry.json` with the raw artifact's authoritative
* SHA-256 and size.
* 2. Each selected package is downloaded, decompressed, and unpacked in the
* browser; the raw artifact is stored verbatim while the package registry
* provides the metadata for the synthesized bundle registry.
* 3. The bundle is a STORED (uncompressed) ZIP — the desktop offline import
* (`collect_offline_entries`) matches entries by filename against
* `offline://<filename>` registry URLs and validates size/SHA-256, so raw
* artifacts must be embedded byte-for-byte.
*
* The SQLite SSH worker binaries execute on the remote SSH host, so the Linux
* packages must ship in every bundle regardless of the chosen platform (#8987).
*/
export const SQLITE_WORKER_DRIVER_KEY = "sqlite-worker";
export const SQLITE_WORKER_NATIVE_PLATFORMS = ["linux-x64", "linux-aarch64"] as const;
export const CUSTOM_BUNDLE_PLATFORMS = ["macos-aarch64", "macos-x64", "linux-x64", "linux-aarch64", "windows-x64", "windows-aarch64"] as const;
export type CustomBundlePlatform = (typeof CUSTOM_BUNDLE_PLATFORMS)[number];
const WINDOWS_NATIVE_SUFFIX = ".exe";
const PACKAGE_SUFFIX = ".tar.zst";
/** Hard stop before the zip32 4 GiB ceiling plus header overhead. */
const MAX_BUNDLE_BYTES = 3.5 * 1024 * 1024 * 1024;
export interface CustomBundleDriverOption {
key: string;
label: string;
version: string;
minAppVersion: string;
kind: "jar" | "native";
requiresJre: boolean;
packageUrl: string;
packageSha256?: string;
packageSize: number;
platformLabel: string;
}
export interface CustomBundleJre {
key: string;
version: string;
platformKey: string;
url: string;
sha256?: string;
size: number;
filename: string;
}
function filenameFromUrl(url: string): string {
const bare = url.split("offline://").pop() ?? url;
return bare.substring(bare.lastIndexOf("/") + 1);
}
function artifactForPlatform(driver: AgentRegistryDriver, platform: string): { kind: "jar" | "native"; artifact: AgentRegistryArtifact } | null {
const native = driver.native?.[platform];
if (native?.url && native.size > 0) return { kind: "native", artifact: native };
if (driver.jar?.url && driver.jar.size > 0) return { kind: "jar", artifact: driver.jar };
return null;
}
/** Drivers available for a platform, excluding the auto-included SQLite worker. */
export function buildCustomBundleOptions(registry: AgentRegistry, platform: string): CustomBundleDriverOption[] {
return Object.entries(registry.drivers ?? {})
.filter(([key]) => key !== SQLITE_WORKER_DRIVER_KEY)
.flatMap(([key, driver]) => {
const match = artifactForPlatform(driver, platform);
if (!match) return [];
return [
{
key,
label: driver.label ?? labelForDriver(key),
version: driver.version ?? "",
minAppVersion: driver.min_app_version ?? "",
kind: match.kind,
requiresJre: match.kind === "jar" && !!(driver.jre && driver.jre.trim() !== ""),
packageUrl: match.artifact.url,
packageSha256: match.artifact.sha256,
packageSize: match.artifact.size,
platformLabel: platformLabels[platform] ?? platform,
},
];
})
.sort((a, b) => a.label.localeCompare(b.label));
}
/** The SQLite SSH worker packages that every custom bundle must include. */
export function buildSqliteWorkerOptions(registry: AgentRegistry, platform: string): CustomBundleDriverOption[] {
const driver = registry.drivers?.[SQLITE_WORKER_DRIVER_KEY];
if (!driver) return [];
const options: CustomBundleDriverOption[] = [];
for (const workerPlatform of SQLITE_WORKER_NATIVE_PLATFORMS) {
const artifact = driver.native?.[workerPlatform];
if (!artifact?.url || artifact.size <= 0) continue;
options.push({
key: SQLITE_WORKER_DRIVER_KEY,
label: `${driver.label ?? "SQLite SSH Worker"} (${platformLabels[workerPlatform] ?? workerPlatform})`,
version: driver.version ?? "",
minAppVersion: driver.min_app_version ?? "",
kind: "native",
requiresJre: false,
packageUrl: artifact.url,
packageSha256: artifact.sha256,
packageSize: artifact.size,
platformLabel: platformLabels[workerPlatform] ?? workerPlatform,
});
}
return options;
}
export function resolveBundleJre(registry: AgentRegistry, platform: string): CustomBundleJre | null {
for (const [key, jre] of Object.entries(registry.jres ?? {})) {
const artifact = jre.platforms?.[platform];
if (!artifact?.url || artifact.size <= 0) continue;
return {
key,
version: jre.version ?? key,
platformKey: platform,
url: artifact.url,
sha256: artifact.sha256,
size: artifact.size,
filename: filenameFromUrl(artifact.url),
};
}
return null;
}
export interface BundlePlan {
platform: string;
drivers: CustomBundleDriverOption[];
workers: CustomBundleDriverOption[];
jre: CustomBundleJre | null;
requiresJre: boolean;
includeJre: boolean;
totalDownloadBytes: number;
filename: string;
}
export function computeBundlePlan(registry: AgentRegistry, platform: string, selectedKeys: ReadonlySet<string>, includeJre: boolean): BundlePlan {
const options = buildCustomBundleOptions(registry, platform);
const drivers = options.filter((option) => selectedKeys.has(option.key));
const workers = buildSqliteWorkerOptions(registry, platform);
const jre = resolveBundleJre(registry, platform);
const requiresJre = drivers.some((option) => option.requiresJre);
const effectiveIncludeJre = includeJre || requiresJre;
const items = [...drivers, ...workers];
const totalDownloadBytes = items.reduce((sum, item) => sum + item.packageSize, 0) + (effectiveIncludeJre && jre ? jre.size : 0);
return {
platform,
drivers,
workers,
jre: effectiveIncludeJre ? jre : null,
requiresJre,
includeJre: effectiveIncludeJre,
totalDownloadBytes,
filename: `dbx-agents-offline-custom-${platform}.zip`,
};
}
interface PackageDriverEntry {
version: string;
label?: string;
min_app_version: string;
jre?: string;
jar?: { url: string; sha256?: string; size: number };
native?: Record<string, { url: string; sha256?: string; size: number }>;
[field: string]: unknown;
}
export interface ExtractedDriverPackage {
driverKey: string;
entry: PackageDriverEntry;
artifactKind: "jar" | "native";
nativePlatforms: string[];
filename: string;
bytes: Uint8Array;
sha256?: string;
}
function octal(bytes: Uint8Array): number {
let value = 0;
for (const byte of bytes) {
if (byte === 0 || byte === 32) break;
if (byte < 48 || byte > 55) throw new Error("Invalid tar size field");
value = value * 8 + (byte - 48);
}
return value;
}
/** Minimal ustar tar reader for driver packages (regular files only). */
export function parseTarEntries(bytes: Uint8Array): Map<string, Uint8Array> {
const entries = new Map<string, Uint8Array>();
let offset = 0;
let longName: string | null = null;
while (offset + 512 <= bytes.length) {
const header = bytes.subarray(offset, offset + 512);
if (header.every((byte) => byte === 0)) break;
const rawName = String.fromCharCode(...header.subarray(0, 100)).replace(/\0.*$/, "");
const size = octal(header.subarray(124, 136));
const type = String.fromCharCode(header[156] || 48);
const dataStart = offset + 512;
const dataEnd = dataStart + size;
if (dataEnd > bytes.length) throw new Error(`Truncated tar entry: ${rawName}`);
const data = bytes.subarray(dataStart, dataEnd);
if (type === "L") {
longName = new TextDecoder().decode(data.subarray(0, data.indexOf(0) === -1 ? data.length : data.indexOf(0)));
} else if (type === "0" || type === "\0") {
let name = longName ?? rawName;
longName = null;
const magic = String.fromCharCode(...header.subarray(257, 262));
if (magic === "ustar") {
const prefix = String.fromCharCode(...header.subarray(345, 500)).replace(/\0.*$/, "");
if (prefix) name = `${prefix}/${name}`;
}
if (name) entries.set(name, data);
} else if (type !== "5" && type !== "x" && type !== "g") {
throw new Error(`Unsupported tar entry type "${type}": ${rawName}`);
}
offset = dataStart + Math.ceil(size / 512) * 512;
}
return entries;
}
/**
* Unpacks a downloaded `.tar.zst` driver package and returns the raw artifact
* plus its package registry entry with the artifact URL rewritten to the
* `offline://` form the desktop offline import matches on.
*/
export function extractDriverPackage(packageBytes: Uint8Array, expectedKey: string): ExtractedDriverPackage {
const tarEntries = parseTarEntries(decompress(packageBytes));
const registryRaw = tarEntries.get("agent-registry.json");
if (!registryRaw) throw new Error("Driver package is missing agent-registry.json");
const packageRegistry = JSON.parse(new TextDecoder().decode(registryRaw)) as { drivers?: Record<string, PackageDriverEntry> };
const drivers = packageRegistry.drivers ?? {};
if (Object.keys(drivers).length !== 1 || !(expectedKey in drivers)) {
throw new Error(`Driver package registry does not describe exactly the "${expectedKey}" driver`);
}
const entry = drivers[expectedKey];
const rewrite = (artifact: { url: string; sha256?: string; size: number }) => {
const filename = filenameFromUrl(artifact.url);
if (!filename) throw new Error(`Driver package artifact has no filename: ${artifact.url}`);
return { url: `offline://${filename}`, ...(artifact.sha256 ? { sha256: artifact.sha256 } : {}), size: artifact.size };
};
if (entry.jar && !entry.native) {
const filename = filenameFromUrl(entry.jar.url);
const bytes = tarEntries.get(`drivers/${filename}`);
if (!bytes) throw new Error(`Driver package is missing drivers/${filename}`);
if (entry.jar.size > 0 && bytes.length !== entry.jar.size) throw new Error(`Driver package artifact size mismatch for ${filename}`);
return {
driverKey: expectedKey,
entry: { ...entry, jar: rewrite(entry.jar) },
artifactKind: "jar",
nativePlatforms: [],
filename,
bytes,
sha256: entry.jar.sha256,
};
}
if (entry.native && Object.keys(entry.native).length === 1 && !entry.jar) {
const [platform, artifact] = Object.entries(entry.native)[0]!;
const platformFilename = filenameFromUrl(artifact.url);
const platformBytes = tarEntries.get(`drivers/${platformFilename}`);
if (!platformBytes) throw new Error(`Driver package is missing drivers/${platformFilename}`);
if (artifact.size > 0 && platformBytes.length !== artifact.size) throw new Error(`Driver package artifact size mismatch for ${platformFilename}`);
return {
driverKey: expectedKey,
entry: { ...entry, native: { [platform]: rewrite(artifact) } },
artifactKind: "native",
nativePlatforms: [platform],
filename: platformFilename,
bytes: platformBytes,
sha256: artifact.sha256,
};
}
throw new Error(`Driver package for "${expectedKey}" must contain exactly one artifact`);
}
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let index = 0; index < 256; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
}
table[index] = value >>> 0;
}
return table;
})();
export function crc32(bytes: Uint8Array): number {
let crc = 0xffffffff;
for (const byte of bytes) {
crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
export interface StoredZipEntry {
name: string;
data: Uint8Array;
}
export interface StoredZipResult {
parts: BlobPart[];
totalSize: number;
}
function dosDateTime(date: Date): { time: number; date: number } {
const year = Math.max(1980, date.getFullYear());
return {
time: (date.getHours() << 11) | (date.getMinutes() << 5) | (date.getSeconds() >>> 1),
date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
};
}
/**
* Builds a STORED (uncompressed) zip32 archive as Blob parts. Sizes and CRCs
* are known upfront, so entries need no data descriptors and the central
* directory is written after all file data.
*/
export function buildStoredZipParts(entries: StoredZipEntry[], date = new Date()): StoredZipResult {
if (entries.length === 0) throw new Error("A custom bundle needs at least one file");
if (entries.length > 0xffff) throw new Error("Too many bundle entries for a zip32 archive");
const names = entries.map((entry) => new TextEncoder().encode(entry.name));
for (const name of names) {
if (name.length === 0 || name.length > 0xffff || name.some((byte) => byte > 0x7f)) {
throw new Error("Bundle entry names must be non-empty ASCII");
}
}
const { time, date: dosDate } = dosDateTime(date);
const parts: BlobPart[] = [];
const central: Uint8Array[] = [];
let offset = 0;
let centralSize = 0;
for (let index = 0; index < entries.length; index += 1) {
const name = names[index];
const crc = crc32(entries[index].data);
const size = entries[index].data.length;
if (offset + size > 0xffffffff) throw new Error("Bundle exceeds the 4 GiB zip32 limit");
const local = new Uint8Array(30 + name.length);
const localView = new DataView(local.buffer);
localView.setUint32(0, 0x04034b50, true);
localView.setUint16(4, 20, true);
localView.setUint16(6, 0, true);
localView.setUint16(8, 0, true);
localView.setUint16(10, time, true);
localView.setUint16(12, dosDate, true);
localView.setUint32(14, crc, true);
localView.setUint32(18, size, true);
localView.setUint32(22, size, true);
localView.setUint16(26, name.length, true);
localView.setUint16(28, 0, true);
local.set(name, 30);
parts.push(local, entries[index].data as unknown as BlobPart);
const centralEntry = new Uint8Array(46 + name.length);
const centralView = new DataView(centralEntry.buffer);
centralView.setUint32(0, 0x02014b50, true);
centralView.setUint16(4, 20, true);
centralView.setUint16(6, 20, true);
centralView.setUint16(8, 0, true);
centralView.setUint16(10, 0, true);
centralView.setUint16(12, time, true);
centralView.setUint16(14, dosDate, true);
centralView.setUint32(16, crc, true);
centralView.setUint32(20, size, true);
centralView.setUint32(24, size, true);
centralView.setUint16(28, name.length, true);
centralView.setUint16(30, 0, true);
centralView.setUint16(32, 0, true);
centralView.setUint16(34, 0, true);
centralView.setUint16(36, 0, true);
centralView.setUint32(38, 0, true);
centralView.setUint32(42, offset, true);
centralEntry.set(name, 46);
central.push(centralEntry);
centralSize += centralEntry.length;
offset += local.length + size;
}
const eocd = new Uint8Array(22);
const eocdView = new DataView(eocd.buffer);
eocdView.setUint32(0, 0x06054b50, true);
eocdView.setUint16(8, entries.length, true);
eocdView.setUint16(10, entries.length, true);
eocdView.setUint32(12, centralSize, true);
eocdView.setUint32(16, offset, true);
parts.push(...(central as unknown as BlobPart[]), eocd);
return { parts, totalSize: offset + centralSize + 22 };
}
export type BundleFetch = (url: string, onProgress: (received: number) => void, signal: AbortSignal) => Promise<Uint8Array>;
export type BundleProgress =
| { phase: "download"; label: string; index: number; total: number; received: number; expectedBytes: number }
| { phase: "unpack"; label: string }
| { phase: "assemble" }
| { phase: "done"; filename: string; size: number };
export interface AssembleResult {
blob: Blob;
filename: string;
}
async function sha256Hex(bytes: Uint8Array): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", bytes as unknown as ArrayBuffer);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
function mergeDriverEntry(target: Record<string, PackageDriverEntry>, extracted: ExtractedDriverPackage): void {
const existing = target[extracted.driverKey];
if (!existing) {
target[extracted.driverKey] = extracted.entry;
return;
}
// The SQLite worker ships one package per remote platform; merge their
// native maps into a single registry entry.
if (existing.native && extracted.entry.native) {
target[extracted.driverKey] = { ...existing, native: { ...existing.native, ...extracted.entry.native } };
return;
}
throw new Error(`Duplicate driver package for "${extracted.driverKey}"`);
}
/**
* Downloads every planned package, unpacks the raw artifacts, verifies the
* package-provided SHA-256 digests, and assembles the final driver-manager
* compatible ZIP. `fetchPackage` is injected so tests can stub the network.
*/
export async function assembleCustomBundle(
plan: BundlePlan,
fetchPackage: BundleFetch,
onProgress: (progress: BundleProgress) => void,
signal: AbortSignal,
deps: { sha256?: (bytes: Uint8Array) => Promise<string> } = {},
): Promise<AssembleResult> {
if (plan.drivers.length === 0) throw new Error("Select at least one driver");
const hash = deps.sha256 ?? sha256Hex;
const items = [...plan.drivers, ...plan.workers];
const drivers: Record<string, PackageDriverEntry> = {};
const zipEntries: StoredZipEntry[] = [];
let rawBytes = 0;
for (let index = 0; index < items.length; index += 1) {
const item = items[index];
signal.throwIfAborted();
onProgress({ phase: "download", label: item.label, index: index + 1, total: items.length + (plan.jre ? 1 : 0), received: 0, expectedBytes: item.packageSize });
let packageBytes: Uint8Array;
try {
packageBytes = await fetchPackage(item.packageUrl, (received) => {
onProgress({ phase: "download", label: item.label, index: index + 1, total: items.length + (plan.jre ? 1 : 0), received, expectedBytes: item.packageSize });
}, signal);
} catch (error) {
if (signal.aborted) throw error;
throw new Error(`${item.label} — ${error instanceof Error ? error.message : String(error)}`);
}
if (item.packageSha256) {
const actual = await hash(packageBytes);
if (actual !== item.packageSha256.toLowerCase()) throw new Error(`Package checksum mismatch for ${item.label}`);
}
onProgress({ phase: "unpack", label: item.label });
const extracted = extractDriverPackage(packageBytes, item.key);
if (extracted.artifactKind !== item.kind) throw new Error(`Package kind mismatch for ${item.label}: expected ${item.kind}`);
if (extracted.sha256) {
const actual = await hash(extracted.bytes);
if (actual !== extracted.sha256.toLowerCase()) throw new Error(`Artifact checksum mismatch for ${item.label}`);
}
mergeDriverEntry(drivers, extracted);
zipEntries.push({ name: `drivers/${extracted.filename}`, data: extracted.bytes });
rawBytes += extracted.bytes.length;
}
let jres: Record<string, { version: string; platforms: Record<string, { url: string; sha256?: string; size: number; format: "tar_zstd" }> }> = {};
if (plan.jre) {
signal.throwIfAborted();
const jre = plan.jre;
const index = items.length + 1;
const total = index;
onProgress({ phase: "download", label: `JRE ${jre.key}`, index, total, received: 0, expectedBytes: jre.size });
let jreBytes: Uint8Array;
try {
jreBytes = await fetchPackage(jre.url, (received) => {
onProgress({ phase: "download", label: `JRE ${jre.key}`, index, total, received, expectedBytes: jre.size });
}, signal);
} catch (error) {
if (signal.aborted) throw error;
throw new Error(`JRE ${jre.key} — ${error instanceof Error ? error.message : String(error)}`);
}
if (jre.sha256) {
const actual = await hash(jreBytes);
if (actual !== jre.sha256.toLowerCase()) throw new Error("JRE package checksum mismatch");
}
jres = {
[jre.key]: {
version: jre.version,
platforms: {
[jre.platformKey]: { url: `offline://${jre.filename}`, ...(jre.sha256 ? { sha256: jre.sha256 } : {}), size: jre.size, format: "tar_zstd" },
},
},
};
zipEntries.push({ name: `jre/${jre.filename}`, data: jreBytes });
rawBytes += jreBytes.length;
}
if (rawBytes > MAX_BUNDLE_BYTES) throw new Error("Selection exceeds the maximum custom bundle size (3.5 GB)");
onProgress({ phase: "assemble" });
const registryJson = new TextEncoder().encode(JSON.stringify({ jres, drivers }));
const result = buildStoredZipParts([{ name: "agent-registry.json", data: registryJson }, ...zipEntries]);
const blob = new Blob(result.parts, { type: "application/zip" });
onProgress({ phase: "done", filename: plan.filename, size: blob.size });
return { blob, filename: plan.filename };
}
/** CNB mirror URL for a GitHub agents-release asset (CORS-enabled). */
export function cnbMirrorUrl(githubUrl: string): string {
return githubUrl.replace("https://github.com/t8y2/dbx/releases/download/", "https://cnb.cool/dbxio.com/dbx/-/releases/download/");
}
/** Same-origin worker proxy URL for GitHub agents-release assets (no CORS on GitHub). */
export function proxyUrl(origin: string, githubUrl: string): string {
return `${origin}/api/agent-asset?url=${encodeURIComponent(githubUrl)}`;
}
+7 -5
View File
@@ -18,14 +18,16 @@ export interface GitHubReleaseAsset {
browser_download_url: string;
}
interface AgentRegistryArtifact {
export interface AgentRegistryArtifact {
url: string;
sha256?: string;
size: number;
format?: "tar_zstd";
}
interface AgentRegistryDriver {
export interface AgentRegistryDriver {
version?: string;
label?: string;
min_app_version?: string;
jre?: string;
jar?: AgentRegistryArtifact;
@@ -33,7 +35,7 @@ interface AgentRegistryDriver {
}
export interface AgentRegistry {
jres?: Record<string, { platforms?: Record<string, AgentRegistryArtifact> }>;
jres?: Record<string, { version?: string; platforms?: Record<string, AgentRegistryArtifact> }>;
drivers?: Record<string, AgentRegistryDriver>;
}
@@ -92,7 +94,7 @@ const CNB_RELEASE_DOWNLOAD_PREFIX = "https://cnb.cool/dbxio.com/dbx/-/releases/d
const MIN_APP_VERSION = "0.6.0";
const driverVersionMap = driverVersions as Record<string, string>;
const platformLabels: Record<string, string> = {
export const platformLabels: Record<string, string> = {
"macos-aarch64": "macOS (Apple Silicon)",
"macos-x64": "macOS (Intel)",
"linux-aarch64": "Linux (ARM64)",
@@ -158,7 +160,7 @@ const jreVersions: Record<string, string> = {
"21": "21",
};
function labelForDriver(key: string): string {
export function labelForDriver(key: string): string {
return driverLabels[key] ?? key.replace(/-/g, " ");
}
+8 -2
View File
@@ -16,10 +16,16 @@ async function fetchRegistry(url: string): Promise<AgentRegistry> {
}
export async function fetchAgentDownloadCatalog(): Promise<AgentDownloadCatalog | null> {
const result = await fetchAgentDownloadCatalogWithRegistry();
return result?.catalog ?? null;
}
export async function fetchAgentDownloadCatalogWithRegistry(): Promise<{ catalog: AgentDownloadCatalog; registry: AgentRegistry } | null> {
for (const url of AGENT_REGISTRY_URLS) {
try {
const catalog = buildAgentDownloadCatalogFromRegistry(await fetchRegistry(url));
if (hasDownloadAssets(catalog)) return catalog;
const registry = await fetchRegistry(url);
const catalog = buildAgentDownloadCatalogFromRegistry(registry);
if (hasDownloadAssets(catalog)) return { catalog, registry };
} catch {
// Both sources contain the same generated registry; try the synchronized mirror next.
}
+34 -3
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "vitest";
import { afterEach, test, vi } from "vitest";
import worker, { issueRedirectPath, pluginDetailShellRequest, sanitizeReturnTo, signPayload, staticAssetCacheControl, verifySignedPayload } from "../worker";
test("native installers bypass HTML routing and are served as plain text", async () => {
@@ -66,8 +66,7 @@ test("static assets receive browser cache headers without caching HTML", () => {
assert.equal(staticAssetCacheControl("/cn/changelog.txt"), null);
});
test("plugin detail fallback maps unknown ids onto the shell route", () => {
const shell = pluginDetailShellRequest(new URL("https://dbxio.com/cn/plugins/io.github.t8y2.s3"), new Request("https://dbxio.com/cn/plugins/io.github.t8y2.s3"));
test("plugin detail fallback maps unknown ids onto the shell route", () => { const shell = pluginDetailShellRequest(new URL("https://dbxio.com/cn/plugins/io.github.t8y2.s3"), new Request("https://dbxio.com/cn/plugins/io.github.t8y2.s3"));
assert.equal(shell?.url, "https://dbxio.com/cn/plugins/detail?id=io.github.t8y2.s3");
const encoded = pluginDetailShellRequest(new URL("https://dbxio.com/en/plugins/a%20b"), new Request("https://dbxio.com/en/plugins/a%20b"));
@@ -81,3 +80,35 @@ test("plugin detail fallback maps unknown ids onto the shell route", () => {
);
assert.equal(pluginDetailShellRequest(new URL("https://dbxio.com/en/plugins"), new Request("https://dbxio.com/en/plugins")), null);
});
afterEach(() => {
vi.unstubAllGlobals();
});
test("agent asset proxy only streams allowlisted agents-release assets", async () => {
const env = {};
const assetUrl = "https://github.com/t8y2/dbx/releases/download/agents-v0.2.130/dbx-agent-dameng-0.1.85.tar.zst";
const upstream = new Response("package-bytes", { headers: { "Content-Length": "13" } });
const fetchMock = vi.fn(async () => upstream);
vi.stubGlobal("fetch", fetchMock);
const proxied = await worker.fetch(new Request(`https://dbxio.com/api/agent-asset?url=${encodeURIComponent(assetUrl)}`), env);
assert.equal(proxied.status, 200);
assert.equal(proxied.headers.get("Content-Type"), "application/octet-stream");
assert.equal(proxied.headers.get("X-Content-Type-Options"), "nosniff");
assert.equal(await proxied.text(), "package-bytes");
assert.equal(fetchMock.mock.calls[0]?.[0], assetUrl);
assert.equal((await worker.fetch(new Request(`https://dbxio.com/api/agent-asset?url=${encodeURIComponent(assetUrl)}`, { method: "HEAD" }), env)).status, 200);
assert.equal((await worker.fetch(new Request(`https://dbxio.com/api/agent-asset?url=${encodeURIComponent(assetUrl)}`, { method: "POST" }), env)).status, 404);
for (const invalid of [
"https://evil.example/dbx-agent-dameng-0.1.85.tar.zst",
"https://github.com/t8y2/other/releases/download/agents-v0.2.130/dbx-agent-dameng-0.1.85.tar.zst",
"https://github.com/t8y2/dbx/releases/download/v0.6.33/DBX_0.6.33_x64-setup.exe",
"https://github.com/t8y2/dbx/releases/download/agents-v0.2.130/../../secret",
"",
]) {
const response = await worker.fetch(new Request(`https://dbxio.com/api/agent-asset?url=${encodeURIComponent(invalid)}`), env);
assert.equal(response.status, 400, invalid);
}
});
+1
View File
@@ -24,6 +24,7 @@
"fumadocs-core": "^16.8.7",
"fumadocs-mdx": "^14.3.2",
"fumadocs-ui": "^16.8.7",
"fzstd": "^0.1.1",
"lucide-react": "^0.515.0",
"marked": "18.0.4",
"next": "^16.3.0",
+9 -3
View File
@@ -5,9 +5,7 @@ settings:
excludeLinksFromLockfile: false
patchedDependencies:
next-themes@0.4.6:
hash: bc954c2c679c43ecf5351d4a9d68d9f36a9b8fb2dc4237fdbf57304cae4b0c04
path: patches/next-themes@0.4.6.patch
next-themes@0.4.6: bc954c2c679c43ecf5351d4a9d68d9f36a9b8fb2dc4237fdbf57304cae4b0c04
importers:
@@ -37,6 +35,9 @@ importers:
fumadocs-ui:
specifier: ^16.8.7
version: 16.8.7(@tailwindcss/oxide@4.2.4)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.8.7(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.515.0(react@19.2.5))(next@16.3.0(@types/node@25.6.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.3))(next@16.3.0(@types/node@25.6.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(tailwindcss@4.2.4)
fzstd:
specifier: ^0.1.1
version: 0.1.1
lucide-react:
specifier: ^0.515.0
version: 0.515.0(react@19.2.5)
@@ -1312,6 +1313,9 @@ packages:
next:
optional: true
fzstd@0.1.1:
resolution: {integrity: sha512-dkuVSOKKwh3eas5VkJy1AW1vFpet8TA/fGmVA5krThl8YcOVE/8ZIoEA1+U1vEn5ckxxhLirSdY837azmbaNHA==}
get-nonce@1.0.1:
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
engines: {node: '>=6'}
@@ -3037,6 +3041,8 @@ snapshots:
- '@types/react-dom'
- tailwindcss
fzstd@0.1.1: {}
get-nonce@1.0.1: {}
github-slugger@2.0.0: {}
+30
View File
@@ -3,13 +3,18 @@
// /foo/index.html, and unknown paths fall back to 404.html like
// not_found_handling: "404-page". Run `pnpm build` before this script.
//
// It also mirrors the production worker's /api/agent-asset proxy so the
// drivers page custom-bundle builder works locally against GitHub sources.
//
// Usage: node scripts/preview-server.mjs [port]
import { createServer } from "node:http";
import { readFile, stat } from "node:fs/promises";
import { extname, join, resolve } from "node:path";
import { Readable } from "node:stream";
const ROOT = resolve(import.meta.dirname, "../out");
const PORT = Number(process.argv[2] ?? 3000);
const AGENT_ASSET_URL_PATTERN = /^https:\/\/github\.com\/t8y2\/dbx\/releases\/download\/(agents-v[0-9][A-Za-z0-9._-]*|agents-latest)\/([A-Za-z0-9][A-Za-z0-9._-]*\.(?:tar\.zst|zip|jar|exe|json))$/;
const types = {
".html": "text/html; charset=utf-8",
".js": "text/javascript",
@@ -36,9 +41,34 @@ async function tryFile(rel) {
}
}
async function proxyAgentAsset(req, res) {
const target = new URL(req.url, "http://localhost").searchParams.get("url") ?? "";
if (!AGENT_ASSET_URL_PATTERN.test(target)) {
res.writeHead(400, { "content-type": "application/json" });
return res.end(JSON.stringify({ error: "Unsupported asset URL" }));
}
const upstream = await fetch(target, { redirect: "follow" });
if (!upstream.ok || !upstream.body) {
res.writeHead(502, { "content-type": "application/json" });
return res.end(JSON.stringify({ error: "Asset unavailable" }));
}
const headers = {
"content-type": "application/octet-stream",
"cache-control": "public, max-age=3600",
"x-content-type-options": "nosniff",
};
if (upstream.headers.has("content-length")) headers["content-length"] = upstream.headers.get("content-length");
res.writeHead(upstream.status, headers);
if (req.method === "HEAD") return res.end();
Readable.fromWeb(upstream.body).pipe(res);
}
createServer(async (req, res) => {
try {
const path = decodeURIComponent(new URL(req.url, "http://localhost").pathname);
if (path === "/api/agent-asset" && (req.method === "GET" || req.method === "HEAD")) {
return await proxyAgentAsset(req, res);
}
if (path.includes("..")) {
res.writeHead(400);
return res.end();
+27
View File
@@ -301,6 +301,32 @@ async function contributorAvatar(request: Request): Promise<Response> {
});
}
const AGENT_ASSET_URL_PATTERN = /^https:\/\/github\.com\/t8y2\/dbx\/releases\/download\/(agents-v[0-9][A-Za-z0-9._-]*|agents-latest)\/([A-Za-z0-9][A-Za-z0-9._-]*\.(?:tar\.zst|zip|jar|exe|json))$/;
/**
* Streams GitHub agents-release assets through the site because GitHub release
* downloads send no CORS headers; the browser custom-bundle builder on the
* drivers page fetches tar.zst driver packages through this route. CNB stays
* the primary source — this is the fallback for networks where CNB is slow.
*/
async function agentReleaseAsset(request: Request): Promise<Response> {
const target = new URL(request.url).searchParams.get("url") ?? "";
if (!AGENT_ASSET_URL_PATTERN.test(target)) return json({ error: "Unsupported asset URL" }, 400);
const response = await fetch(target, { redirect: "follow" });
if (!response.ok || !response.body) return json({ error: "Asset unavailable" }, 502);
const headers: Record<string, string> = {
"Content-Type": "application/octet-stream",
"Cache-Control": "public, max-age=3600, s-maxage=86400",
"X-Content-Type-Options": "nosniff",
"Cross-Origin-Resource-Policy": "same-origin",
};
const length = response.headers.get("Content-Length");
if (length) headers["Content-Length"] = length;
return new Response(request.method === "HEAD" ? null : response.body, { headers });
}
function issueSessionCookie(value: string, maxAge: number): string {
return `${ISSUE_SESSION_COOKIE}=${encodeURIComponent(value)}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Strict`;
}
@@ -658,6 +684,7 @@ export default {
if (url.pathname === "/api/auth/me" && request.method === "GET") return currentUser(request, env);
if (url.pathname === "/api/auth/logout" && request.method === "POST") return logout();
if (url.pathname === "/api/contributor-avatar" && request.method === "GET") return contributorAvatar(request);
if (url.pathname === "/api/agent-asset" && (request.method === "GET" || request.method === "HEAD")) return agentReleaseAsset(request);
// API paths must never fall through to the cached HTML 404 page: a navigation to an
// unmatched /api route would otherwise be edge-cached and shadow this worker.
if (url.pathname.startsWith("/api/")) return json({ error: "NOT_FOUND" }, 404);