@missionsquad/browserai 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +229 -0
- package/dist/audio/clip.d.ts +51 -0
- package/dist/audio/clip.d.ts.map +1 -0
- package/dist/audio/clip.js +71 -0
- package/dist/audio/clip.js.map +1 -0
- package/dist/audio/decode.d.ts +7 -0
- package/dist/audio/decode.d.ts.map +1 -0
- package/dist/audio/decode.js +19 -0
- package/dist/audio/decode.js.map +1 -0
- package/dist/audio/pcm.d.ts +13 -0
- package/dist/audio/pcm.d.ts.map +1 -0
- package/dist/audio/pcm.js +66 -0
- package/dist/audio/pcm.js.map +1 -0
- package/dist/audio/recorder.d.ts +40 -0
- package/dist/audio/recorder.d.ts.map +1 -0
- package/dist/audio/recorder.js +127 -0
- package/dist/audio/recorder.js.map +1 -0
- package/dist/audio/streaming-player.d.ts +29 -0
- package/dist/audio/streaming-player.d.ts.map +1 -0
- package/dist/audio/streaming-player.js +82 -0
- package/dist/audio/streaming-player.js.map +1 -0
- package/dist/client.d.ts +195 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +638 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +81 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +19 -0
- package/dist/config.js.map +1 -0
- package/dist/errors.d.ts +52 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +74 -0
- package/dist/errors.js.map +1 -0
- package/dist/events.d.ts +18 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +43 -0
- package/dist/events.js.map +1 -0
- package/dist/generation.d.ts +12 -0
- package/dist/generation.d.ts.map +1 -0
- package/dist/generation.js +30 -0
- package/dist/generation.js.map +1 -0
- package/dist/hardware.d.ts +23 -0
- package/dist/hardware.d.ts.map +1 -0
- package/dist/hardware.js +150 -0
- package/dist/hardware.js.map +1 -0
- package/dist/history.d.ts +81 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +193 -0
- package/dist/history.js.map +1 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +50 -0
- package/dist/index.js.map +1 -0
- package/dist/inference.d.ts +89 -0
- package/dist/inference.d.ts.map +1 -0
- package/dist/inference.js +411 -0
- package/dist/inference.js.map +1 -0
- package/dist/json.d.ts +22 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +145 -0
- package/dist/json.js.map +1 -0
- package/dist/metrics.d.ts +18 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +59 -0
- package/dist/metrics.js.map +1 -0
- package/dist/model-cache.d.ts +49 -0
- package/dist/model-cache.d.ts.map +1 -0
- package/dist/model-cache.js +511 -0
- package/dist/model-cache.js.map +1 -0
- package/dist/model-source.d.ts +27 -0
- package/dist/model-source.d.ts.map +1 -0
- package/dist/model-source.js +110 -0
- package/dist/model-source.js.map +1 -0
- package/dist/models.d.ts +103 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +1052 -0
- package/dist/models.js.map +1 -0
- package/dist/multimodal.d.ts +261 -0
- package/dist/multimodal.d.ts.map +1 -0
- package/dist/multimodal.js +506 -0
- package/dist/multimodal.js.map +1 -0
- package/dist/prompt.d.ts +13 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +32 -0
- package/dist/prompt.js.map +1 -0
- package/dist/proxy-verify.d.ts +10 -0
- package/dist/proxy-verify.d.ts.map +1 -0
- package/dist/proxy-verify.js +98 -0
- package/dist/proxy-verify.js.map +1 -0
- package/dist/schema-validator.d.ts +6 -0
- package/dist/schema-validator.d.ts.map +1 -0
- package/dist/schema-validator.js +104 -0
- package/dist/schema-validator.js.map +1 -0
- package/dist/schema.d.ts +98 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +115 -0
- package/dist/schema.js.map +1 -0
- package/dist/text-utils.d.ts +8 -0
- package/dist/text-utils.d.ts.map +1 -0
- package/dist/text-utils.js +14 -0
- package/dist/text-utils.js.map +1 -0
- package/dist/voice/helpers.d.ts +35 -0
- package/dist/voice/helpers.d.ts.map +1 -0
- package/dist/voice/helpers.js +49 -0
- package/dist/voice/helpers.js.map +1 -0
- package/dist/voice/voice-pipeline.d.ts +155 -0
- package/dist/voice/voice-pipeline.d.ts.map +1 -0
- package/dist/voice/voice-pipeline.js +407 -0
- package/dist/voice/voice-pipeline.js.map +1 -0
- package/dist/webllm.worker.d.ts +2 -0
- package/dist/webllm.worker.d.ts.map +1 -0
- package/dist/webllm.worker.js +8 -0
- package/dist/webllm.worker.js.map +1 -0
- package/package.json +62 -0
- package/src/audio/clip.ts +94 -0
- package/src/audio/decode.ts +19 -0
- package/src/audio/pcm.ts +62 -0
- package/src/audio/recorder.ts +146 -0
- package/src/audio/streaming-player.ts +99 -0
- package/src/client.ts +784 -0
- package/src/config.ts +104 -0
- package/src/errors.ts +81 -0
- package/src/events.ts +53 -0
- package/src/generation.ts +39 -0
- package/src/hardware.ts +193 -0
- package/src/history.ts +257 -0
- package/src/index.ts +203 -0
- package/src/inference.ts +579 -0
- package/src/json.ts +169 -0
- package/src/metrics.ts +63 -0
- package/src/model-cache.ts +637 -0
- package/src/model-source.ts +142 -0
- package/src/models.ts +1171 -0
- package/src/multimodal.ts +697 -0
- package/src/prompt.ts +33 -0
- package/src/proxy-verify.ts +113 -0
- package/src/schema-validator.ts +117 -0
- package/src/schema.ts +118 -0
- package/src/text-utils.ts +12 -0
- package/src/voice/helpers.ts +59 -0
- package/src/voice/voice-pipeline.ts +513 -0
- package/src/webllm.worker.ts +9 -0
- package/worker-template/README.md +39 -0
- package/worker-template/cloudflare-worker.ts +389 -0
- package/worker-template/wrangler.jsonc +19 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { MODEL_PRESETS } from "@missionsquad/browserai";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cloudflare Worker template: same-origin model-artifact proxy for @missionsquad/browserai.
|
|
5
|
+
*
|
|
6
|
+
* Deploy this Worker (plus your static assets) and configure the SDK with
|
|
7
|
+
* `{ modelSource: "proxy" }` (same origin) or `{ modelSource: "proxy", proxyOrigin: "https://…" }`.
|
|
8
|
+
* Routes: /hf/* (WebLLM weights), /hf-transformers/* (Transformers.js artifacts),
|
|
9
|
+
* /gh-raw/* (WebLLM wasm libraries), /__worker-health, /__proxy-test.
|
|
10
|
+
*
|
|
11
|
+
* Proxying is restricted to an allowlist: the MLC weight repos below plus every Transformers.js
|
|
12
|
+
* preset in the SDK catalog (derived automatically, so it cannot drift out of sync).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
type AssetsBinding = {
|
|
16
|
+
fetch(request: Request): Promise<Response>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type Env = {
|
|
20
|
+
ASSETS?: AssetsBinding;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const WORKER_VERSION = "0.1.0";
|
|
24
|
+
const WORKER_NAME = "browserai-proxy";
|
|
25
|
+
|
|
26
|
+
// WebLLM/MLC weight repos (fetched via /hf/*). The prebuilt records live under mlc-ai; the custom
|
|
27
|
+
// Gemma 4 MLC builds under welcoma. These are not derivable from MODEL_PRESETS (preset ids are
|
|
28
|
+
// WebLLM model ids, not HF repo paths), so they stay explicit.
|
|
29
|
+
const ALLOWED_HF_REPOS_BY_OWNER = new Map<string, Set<string>>([
|
|
30
|
+
[
|
|
31
|
+
"mlc-ai",
|
|
32
|
+
new Set([
|
|
33
|
+
"Qwen3.5-0.8B-q4f16_1-MLC",
|
|
34
|
+
"Qwen3.5-2B-q4f16_1-MLC",
|
|
35
|
+
"Qwen3.5-4B-q4f16_1-MLC",
|
|
36
|
+
"gemma3-1b-it-q4f16_1-MLC",
|
|
37
|
+
"Llama-3.2-1B-Instruct-q4f16_1-MLC",
|
|
38
|
+
"Llama-3.2-3B-Instruct-q4f16_1-MLC",
|
|
39
|
+
"Hermes-3-Llama-3.2-3B-q4f16_1-MLC",
|
|
40
|
+
"OLMo-2-0425-1B-Instruct-q4f16_1-MLC",
|
|
41
|
+
"Phi-4-mini-instruct-q4f16_1-MLC",
|
|
42
|
+
"Ministral-3-3B-Instruct-2512-BF16-q4f16_1-MLC",
|
|
43
|
+
"Mistral-7B-Instruct-v0.3-q4f16_1-MLC",
|
|
44
|
+
]),
|
|
45
|
+
],
|
|
46
|
+
["welcoma", new Set(["gemma-4-E2B-it-q4f16_1-MLC", "gemma-4-E4B-it-q4f16_1-MLC"])],
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
// Every Transformers.js preset downloads from its own Hugging Face repo (preset id = owner/repo)
|
|
50
|
+
// through /hf-transformers/*. Derive those allowlist entries from the SDK's model catalog so the
|
|
51
|
+
// proxy can never drift out of sync with it — a missing entry 403s hosted model loads.
|
|
52
|
+
for (const preset of MODEL_PRESETS) {
|
|
53
|
+
if (preset.backend !== "transformers-js") continue;
|
|
54
|
+
const [owner, ...repoParts] = preset.id.split("/");
|
|
55
|
+
if (!owner || repoParts.length === 0) continue;
|
|
56
|
+
const repos = ALLOWED_HF_REPOS_BY_OWNER.get(owner) ?? new Set<string>();
|
|
57
|
+
repos.add(repoParts.join("/"));
|
|
58
|
+
ALLOWED_HF_REPOS_BY_OWNER.set(owner, repos);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const ALLOWED_GITHUB_OWNER = "mlc-ai";
|
|
62
|
+
const ALLOWED_GITHUB_REPO = "binary-mlc-llm-libs";
|
|
63
|
+
const ALLOWED_GITHUB_BRANCH = "main";
|
|
64
|
+
const ALLOWED_GITHUB_PREFIX = "web-llm-models/";
|
|
65
|
+
|
|
66
|
+
const CORS_HEADERS = {
|
|
67
|
+
"Access-Control-Allow-Origin": "*",
|
|
68
|
+
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
|
|
69
|
+
"Access-Control-Allow-Headers": "Range, If-None-Match, If-Modified-Since, Accept, Content-Type",
|
|
70
|
+
"Access-Control-Expose-Headers":
|
|
71
|
+
"Content-Length, Content-Range, Accept-Ranges, ETag, Last-Modified, X-Linked-Size, X-Linked-ETag, X-Repo-Commit, X-Upstream-URL, X-Upstream-Status, X-Proxy-Worker, X-Proxy-Route",
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export default {
|
|
75
|
+
async fetch(request: Request, env: Env): Promise<Response> {
|
|
76
|
+
const url = new URL(request.url);
|
|
77
|
+
|
|
78
|
+
if (url.pathname === "/__worker-health") {
|
|
79
|
+
return jsonResponse(
|
|
80
|
+
{
|
|
81
|
+
ok: true,
|
|
82
|
+
worker: `${WORKER_NAME}/${WORKER_VERSION}`,
|
|
83
|
+
app: WORKER_NAME,
|
|
84
|
+
version: WORKER_VERSION,
|
|
85
|
+
routes: ["/hf/*", "/hf-transformers/*", "/gh-raw/*", "/__proxy-test"],
|
|
86
|
+
allowedHuggingFaceRepos: Array.from(ALLOWED_HF_REPOS_BY_OWNER.entries()).flatMap(([owner, repos]) =>
|
|
87
|
+
Array.from(repos).map((repo) => `${owner}/${repo}`),
|
|
88
|
+
),
|
|
89
|
+
note: "If this endpoint returns 200, the proxy Worker is deployed. If /hf/* returns 404 without X-Proxy-Worker, the request is bypassing the Worker.",
|
|
90
|
+
},
|
|
91
|
+
200,
|
|
92
|
+
{ "Cache-Control": "no-store" },
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (url.pathname === "/__proxy-test") {
|
|
97
|
+
return proxyTest(request, url);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (url.pathname.startsWith("/hf/")) {
|
|
101
|
+
return proxyHuggingFaceModelFile(request, url, "hf");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (url.pathname.startsWith("/hf-transformers/")) {
|
|
105
|
+
return proxyHuggingFaceModelFile(request, url, "hf-transformers");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (url.pathname.startsWith("/gh-raw/")) {
|
|
109
|
+
return proxyRawGithubModelLibrary(request, url);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return serveStaticAsset(request, env);
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
async function serveStaticAsset(request: Request, env: Env): Promise<Response> {
|
|
117
|
+
if (!env.ASSETS) {
|
|
118
|
+
return jsonResponse({ error: "No static assets binding; this Worker only serves the proxy routes." }, 404);
|
|
119
|
+
}
|
|
120
|
+
const response = await env.ASSETS.fetch(request);
|
|
121
|
+
const headers = new Headers(response.headers);
|
|
122
|
+
const url = new URL(request.url);
|
|
123
|
+
const accept = request.headers.get("Accept") ?? "";
|
|
124
|
+
|
|
125
|
+
headers.set("X-App-Version", `${WORKER_NAME}/${WORKER_VERSION}`);
|
|
126
|
+
|
|
127
|
+
// Hashed JS/CSS assets can be cached; keep the HTML shell fresh so users get new deploys.
|
|
128
|
+
const looksLikeHtmlDocument =
|
|
129
|
+
request.method === "GET" && (url.pathname === "/" || url.pathname.endsWith("/index.html") || accept.includes("text/html"));
|
|
130
|
+
if (looksLikeHtmlDocument) {
|
|
131
|
+
headers.set("Cache-Control", "no-cache, no-store, must-revalidate");
|
|
132
|
+
headers.set("Pragma", "no-cache");
|
|
133
|
+
headers.set("Expires", "0");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return new Response(request.method === "HEAD" ? null : response.body, {
|
|
137
|
+
status: response.status,
|
|
138
|
+
statusText: response.statusText,
|
|
139
|
+
headers,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isAllowedHuggingFaceRepo(owner: string, repo: string): boolean {
|
|
144
|
+
return ALLOWED_HF_REPOS_BY_OWNER.get(owner)?.has(repo) === true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function proxyTest(request: Request, url: URL): Promise<Response> {
|
|
148
|
+
if (request.method === "OPTIONS") {
|
|
149
|
+
return new Response(null, { status: 204, headers: withProxyHeaders(CORS_HEADERS, "proxy-test") });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
153
|
+
return jsonResponse({ error: "Method not allowed" }, 405, {}, "proxy-test");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const owner = url.searchParams.get("owner") || "mlc-ai";
|
|
157
|
+
const repo = url.searchParams.get("repo") || "";
|
|
158
|
+
const file = url.searchParams.get("file") || "mlc-chat-config.json";
|
|
159
|
+
|
|
160
|
+
if (!isAllowedHuggingFaceRepo(owner, repo)) {
|
|
161
|
+
return jsonResponse({ ok: false, error: "Model repository is not allowlisted", owner, repo }, 403, {}, "proxy-test");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const filePath = file
|
|
165
|
+
.split("/")
|
|
166
|
+
.filter(Boolean)
|
|
167
|
+
.map((part) => encodeURIComponent(decodeURIComponent(part)))
|
|
168
|
+
.join("/");
|
|
169
|
+
|
|
170
|
+
if (!filePath || filePath.includes("..")) {
|
|
171
|
+
return jsonResponse({ ok: false, error: "Invalid file path", file }, 400, {}, "proxy-test");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const upstreamUrl = new URL(`https://huggingface.co/${owner}/${repo}/resolve/main/${filePath}`);
|
|
175
|
+
const upstreamResponse = await fetch(upstreamUrl, { method: "HEAD", redirect: "follow" });
|
|
176
|
+
|
|
177
|
+
return jsonResponse(
|
|
178
|
+
{
|
|
179
|
+
ok: upstreamResponse.ok,
|
|
180
|
+
owner,
|
|
181
|
+
repo,
|
|
182
|
+
file: filePath,
|
|
183
|
+
upstreamUrl: upstreamUrl.href,
|
|
184
|
+
upstreamStatus: upstreamResponse.status,
|
|
185
|
+
upstreamStatusText: upstreamResponse.statusText,
|
|
186
|
+
contentType: upstreamResponse.headers.get("Content-Type"),
|
|
187
|
+
contentLength: upstreamResponse.headers.get("Content-Length"),
|
|
188
|
+
},
|
|
189
|
+
upstreamResponse.ok ? 200 : 502,
|
|
190
|
+
{
|
|
191
|
+
"Cache-Control": "no-store",
|
|
192
|
+
"X-Upstream-URL": upstreamUrl.href,
|
|
193
|
+
"X-Upstream-Status": String(upstreamResponse.status),
|
|
194
|
+
},
|
|
195
|
+
"proxy-test",
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function proxyHuggingFaceModelFile(request: Request, url: URL, route: "hf" | "hf-transformers"): Promise<Response> {
|
|
200
|
+
if (request.method === "OPTIONS") {
|
|
201
|
+
return new Response(null, { status: 204, headers: withProxyHeaders(CORS_HEADERS, route) });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
205
|
+
return jsonResponse({ error: "Method not allowed" }, 405, {}, route);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const match = url.pathname.match(/^\/(?:hf|hf-transformers)\/([^/]+)\/([^/]+)\/resolve\/([^/]+)\/(.+)$/);
|
|
209
|
+
if (!match) {
|
|
210
|
+
return jsonResponse(
|
|
211
|
+
{
|
|
212
|
+
error: "Invalid Hugging Face proxy path",
|
|
213
|
+
expected: "/hf/:owner/:repo/resolve/:branch/:file",
|
|
214
|
+
},
|
|
215
|
+
400,
|
|
216
|
+
{},
|
|
217
|
+
route,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const [, rawOwner, rawRepo, rawBranch, rawFilePath] = match;
|
|
222
|
+
const owner = decodeURIComponent(rawOwner);
|
|
223
|
+
const repo = decodeURIComponent(rawRepo);
|
|
224
|
+
const branch = decodeURIComponent(rawBranch);
|
|
225
|
+
const filePath = decodeAndReencodePath(rawFilePath);
|
|
226
|
+
|
|
227
|
+
if (!isAllowedHuggingFaceRepo(owner, repo)) {
|
|
228
|
+
return jsonResponse({ error: "Model repository is not allowlisted", owner, repo }, 403, {}, route);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const upstreamUrl = new URL(`https://huggingface.co/${owner}/${repo}/resolve/${encodeURIComponent(branch)}/${filePath}`);
|
|
232
|
+
upstreamUrl.search = url.search;
|
|
233
|
+
|
|
234
|
+
return proxyUpstream(request, upstreamUrl, route);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function proxyRawGithubModelLibrary(request: Request, url: URL): Promise<Response> {
|
|
238
|
+
if (request.method === "OPTIONS") {
|
|
239
|
+
return new Response(null, { status: 204, headers: withProxyHeaders(CORS_HEADERS, "gh-raw") });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
243
|
+
return jsonResponse({ error: "Method not allowed" }, 405, {}, "gh-raw");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const match = url.pathname.match(/^\/gh-raw\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/);
|
|
247
|
+
if (!match) {
|
|
248
|
+
return jsonResponse(
|
|
249
|
+
{
|
|
250
|
+
error: "Invalid raw GitHub proxy path",
|
|
251
|
+
expected: "/gh-raw/:owner/:repo/:branch/:file",
|
|
252
|
+
},
|
|
253
|
+
400,
|
|
254
|
+
{},
|
|
255
|
+
"gh-raw",
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const [, rawOwner, rawRepo, rawBranch, rawFilePath] = match;
|
|
260
|
+
const owner = decodeURIComponent(rawOwner);
|
|
261
|
+
const repo = decodeURIComponent(rawRepo);
|
|
262
|
+
const branch = decodeURIComponent(rawBranch);
|
|
263
|
+
const filePath = rawFilePath
|
|
264
|
+
.split("/")
|
|
265
|
+
.map((part) => decodeURIComponent(part))
|
|
266
|
+
.join("/");
|
|
267
|
+
|
|
268
|
+
if (
|
|
269
|
+
owner !== ALLOWED_GITHUB_OWNER ||
|
|
270
|
+
repo !== ALLOWED_GITHUB_REPO ||
|
|
271
|
+
branch !== ALLOWED_GITHUB_BRANCH ||
|
|
272
|
+
!filePath.startsWith(ALLOWED_GITHUB_PREFIX) ||
|
|
273
|
+
!filePath.endsWith(".wasm")
|
|
274
|
+
) {
|
|
275
|
+
return jsonResponse({ error: "Raw GitHub file is not allowlisted", owner, repo, branch, filePath }, 403, {}, "gh-raw");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const encodedFilePath = filePath.split("/").map(encodeURIComponent).join("/");
|
|
279
|
+
const upstreamUrl = new URL(`https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${encodedFilePath}`);
|
|
280
|
+
upstreamUrl.search = url.search;
|
|
281
|
+
|
|
282
|
+
return proxyUpstream(request, upstreamUrl, "gh-raw");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function proxyUpstream(request: Request, upstreamUrl: URL, source: "hf" | "hf-transformers" | "gh-raw"): Promise<Response> {
|
|
286
|
+
const upstreamHeaders = new Headers();
|
|
287
|
+
copyHeader(request.headers, upstreamHeaders, "Accept");
|
|
288
|
+
copyHeader(request.headers, upstreamHeaders, "Range");
|
|
289
|
+
|
|
290
|
+
// Do not forward browser conditional request headers. If Hugging Face or GitHub returns 304,
|
|
291
|
+
// WebLLM's artifact fetch/cache path can treat it as a failed artifact load. A fresh 200/206
|
|
292
|
+
// response is clearer across Cache API and IndexedDB backends.
|
|
293
|
+
const upstreamResponse = await fetch(upstreamUrl, {
|
|
294
|
+
method: request.method,
|
|
295
|
+
headers: upstreamHeaders,
|
|
296
|
+
redirect: "follow",
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
if (!upstreamResponse.ok) {
|
|
300
|
+
return jsonResponse(
|
|
301
|
+
{
|
|
302
|
+
error: "Upstream artifact request failed",
|
|
303
|
+
source,
|
|
304
|
+
upstreamUrl: upstreamUrl.href,
|
|
305
|
+
upstreamStatus: upstreamResponse.status,
|
|
306
|
+
upstreamStatusText: upstreamResponse.statusText,
|
|
307
|
+
},
|
|
308
|
+
upstreamResponse.status,
|
|
309
|
+
{
|
|
310
|
+
"X-Upstream-URL": upstreamUrl.href,
|
|
311
|
+
"X-Upstream-Status": String(upstreamResponse.status),
|
|
312
|
+
"Cache-Control": "no-store",
|
|
313
|
+
},
|
|
314
|
+
source,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const responseHeaders = sanitizeProxyHeaders(upstreamResponse.headers);
|
|
319
|
+
addCorsHeaders(responseHeaders);
|
|
320
|
+
addProxyIdentityHeaders(responseHeaders, source);
|
|
321
|
+
|
|
322
|
+
responseHeaders.set("Cache-Control", responseHeaders.get("Cache-Control") || "public, max-age=31536000, immutable");
|
|
323
|
+
responseHeaders.set("Cross-Origin-Resource-Policy", "cross-origin");
|
|
324
|
+
responseHeaders.set("X-Upstream-URL", upstreamUrl.href);
|
|
325
|
+
responseHeaders.set("X-Upstream-Status", String(upstreamResponse.status));
|
|
326
|
+
|
|
327
|
+
return new Response(request.method === "HEAD" ? null : upstreamResponse.body, {
|
|
328
|
+
status: upstreamResponse.status,
|
|
329
|
+
statusText: upstreamResponse.statusText,
|
|
330
|
+
headers: responseHeaders,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function sanitizeProxyHeaders(headers: Headers): Headers {
|
|
335
|
+
const result = new Headers(headers);
|
|
336
|
+
|
|
337
|
+
for (const name of [
|
|
338
|
+
"Set-Cookie",
|
|
339
|
+
"Vary",
|
|
340
|
+
"Report-To",
|
|
341
|
+
"NEL",
|
|
342
|
+
"Content-Security-Policy",
|
|
343
|
+
"Content-Security-Policy-Report-Only",
|
|
344
|
+
"X-Frame-Options",
|
|
345
|
+
]) {
|
|
346
|
+
result.delete(name);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return result;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function decodeAndReencodePath(rawPath: string): string {
|
|
353
|
+
return rawPath
|
|
354
|
+
.split("/")
|
|
355
|
+
.map((part) => decodeURIComponent(part))
|
|
356
|
+
.map(encodeURIComponent)
|
|
357
|
+
.join("/");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function copyHeader(source: Headers, target: Headers, name: string): void {
|
|
361
|
+
const value = source.get(name);
|
|
362
|
+
if (value) {
|
|
363
|
+
target.set(name, value);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function addCorsHeaders(headers: Headers): void {
|
|
368
|
+
for (const [key, value] of Object.entries(CORS_HEADERS)) {
|
|
369
|
+
headers.set(key, value);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function addProxyIdentityHeaders(headers: Headers, route = "worker"): void {
|
|
374
|
+
headers.set("X-Proxy-Worker", `${WORKER_NAME}/${WORKER_VERSION}`);
|
|
375
|
+
headers.set("X-Proxy-Route", route);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function withProxyHeaders(headersLike: Record<string, string>, route = "worker"): Headers {
|
|
379
|
+
const headers = new Headers(headersLike);
|
|
380
|
+
addProxyIdentityHeaders(headers, route);
|
|
381
|
+
return headers;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function jsonResponse(body: unknown, status: number, extraHeaders: Record<string, string> = {}, route = "worker"): Response {
|
|
385
|
+
const headers = new Headers({ "Content-Type": "application/json; charset=utf-8", ...extraHeaders });
|
|
386
|
+
addCorsHeaders(headers);
|
|
387
|
+
addProxyIdentityHeaders(headers, route);
|
|
388
|
+
return new Response(JSON.stringify(body, null, 2), { status, headers });
|
|
389
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
// Example Wrangler config for the @missionsquad/browserai proxy Worker.
|
|
3
|
+
// Copy cloudflare-worker.ts + this file into your project, adjust the name/routes, and deploy
|
|
4
|
+
// with `npx wrangler deploy`. To also serve your site's static assets from the same Worker,
|
|
5
|
+
// point `assets.directory` at your build output.
|
|
6
|
+
"name": "browserai-proxy",
|
|
7
|
+
"compatibility_date": "2026-06-30",
|
|
8
|
+
"main": "./cloudflare-worker.ts",
|
|
9
|
+
|
|
10
|
+
"assets": {
|
|
11
|
+
"directory": "./dist",
|
|
12
|
+
"binding": "ASSETS",
|
|
13
|
+
"not_found_handling": "single-page-application",
|
|
14
|
+
|
|
15
|
+
// Run the Worker before assets for every path so /hf/*, /hf-transformers/*, /gh-raw/* and the
|
|
16
|
+
// diagnostics routes are always handled; normal app files are delegated to env.ASSETS.
|
|
17
|
+
"run_worker_first": true
|
|
18
|
+
}
|
|
19
|
+
}
|