codehost 0.1.0 → 0.3.1

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.
@@ -0,0 +1,58 @@
1
+ // Persists which rooms you've joined and which repo opened where, so a deep
2
+ // link (/gh/<owner>/<repo>/...) can resolve to the right room + server without
3
+ // re-entering a token. Keyed by repoKey ("gh/owner/repo").
4
+
5
+ const ROOMS_KEY = "codehost.rooms";
6
+ const HISTORY_KEY = "codehost.history";
7
+
8
+ export interface HistoryEntry {
9
+ token: string;
10
+ peerId?: string;
11
+ kind?: "repo" | "root";
12
+ /** For root-kind opens, the ?folder= path used. */
13
+ folder?: string;
14
+ name?: string;
15
+ host?: string;
16
+ lastConnected: number;
17
+ }
18
+
19
+ function read<T>(key: string, fallback: T): T {
20
+ try {
21
+ const s = localStorage.getItem(key);
22
+ return s ? (JSON.parse(s) as T) : fallback;
23
+ } catch {
24
+ return fallback;
25
+ }
26
+ }
27
+
28
+ function write(key: string, val: unknown): void {
29
+ try {
30
+ localStorage.setItem(key, JSON.stringify(val));
31
+ } catch {
32
+ // quota / private mode — non-fatal
33
+ }
34
+ }
35
+
36
+ export function getRooms(): string[] {
37
+ return read<string[]>(ROOMS_KEY, []);
38
+ }
39
+
40
+ export function addRoom(token: string): void {
41
+ if (!token) return;
42
+ const rooms = getRooms();
43
+ if (!rooms.includes(token)) write(ROOMS_KEY, [...rooms, token]);
44
+ }
45
+
46
+ export function getHistory(): Record<string, HistoryEntry> {
47
+ return read<Record<string, HistoryEntry>>(HISTORY_KEY, {});
48
+ }
49
+
50
+ export function historyFor(repoKey: string): HistoryEntry | undefined {
51
+ return getHistory()[repoKey];
52
+ }
53
+
54
+ export function recordConnection(repoKey: string, entry: HistoryEntry): void {
55
+ const all = getHistory();
56
+ all[repoKey] = entry;
57
+ write(HISTORY_KEY, all);
58
+ }
package/src/web/sw.ts CHANGED
@@ -4,16 +4,27 @@
4
4
  // MessageChannel, streaming the response back. WebSocket connections can't be
5
5
  // intercepted here, so we inject a bootstrap script into VS Code's HTML that
6
6
  // overrides window.WebSocket inside the iframe (see tunnel-websocket.ts).
7
+ import { cdnProxyBase, isProxiableCdnHost } from "./config";
8
+
7
9
  const sw = self as unknown as ServiceWorkerGlobalScope;
8
10
 
9
11
  const VS_PREFIX = /^\/vs\/([^/]+)(\/.*)?$/;
12
+ const CDN_CACHE = "codehost-cdn-v1";
10
13
 
11
14
  sw.addEventListener("install", () => sw.skipWaiting());
12
15
  sw.addEventListener("activate", (e) => e.waitUntil(sw.clients.claim()));
13
16
 
14
17
  sw.addEventListener("fetch", (event: FetchEvent) => {
15
18
  const url = new URL(event.request.url);
16
- if (url.origin !== sw.location.origin) return;
19
+ if (url.origin !== sw.location.origin) {
20
+ // VS Code's product CDN (main.vscode-cdn.net, ...) sends no CORS headers, so
21
+ // its cross-origin fetches fail in our iframe. Route them through the
22
+ // signaling Worker, which re-serves them with permissive CORS.
23
+ if (event.request.method === "GET" && isProxiableCdnHost(url.hostname)) {
24
+ event.respondWith(proxyCdn(url));
25
+ }
26
+ return; // all other cross-origin requests pass through untouched
27
+ }
17
28
 
18
29
  // Serve the iframe bootstrap from the SW itself (same-origin, CSP 'self').
19
30
  if (url.pathname === "/__codehost/bootstrap.js") {
@@ -28,6 +39,25 @@ sw.addEventListener("fetch", (event: FetchEvent) => {
28
39
  event.respondWith(proxyOverTunnel(event.request, peerId));
29
40
  });
30
41
 
42
+ /**
43
+ * Fetch an allow-listed VS Code CDN asset through the signaling Worker's /cdn
44
+ * route (which adds CORS), caching the result so each asset crosses to the
45
+ * Worker once per browser rather than on every request.
46
+ */
47
+ async function proxyCdn(url: URL): Promise<Response> {
48
+ const target = `${cdnProxyBase(sw.location.hostname, sw.location.protocol)}/cdn/${url.hostname}${url.pathname}${url.search}`;
49
+ const cache = await caches.open(CDN_CACHE);
50
+ const hit = await cache.match(target);
51
+ if (hit) return hit;
52
+ try {
53
+ const res = await fetch(target);
54
+ if (res.ok) void cache.put(target, res.clone()).catch(() => {});
55
+ return res;
56
+ } catch (err) {
57
+ return new Response(`cdn proxy error: ${String(err)}`, { status: 502 });
58
+ }
59
+ }
60
+
31
61
  async function proxyOverTunnel(request: Request, peerId: string): Promise<Response> {
32
62
  const client = await pickClient();
33
63
  if (!client) return new Response("no codehost page open", { status: 502 });
@@ -35,6 +65,11 @@ async function proxyOverTunnel(request: Request, peerId: string): Promise<Respon
35
65
  const url = new URL(request.url);
36
66
  const headers: Record<string, string> = {};
37
67
  request.headers.forEach((v, k) => (headers[k] = v));
68
+ // Tell the daemon our public host so VS Code advertises it as the client's
69
+ // remoteAuthority and builds same-origin resource URLs (vscode-remote-resource,
70
+ // extension grammars) that route back through the tunnel — instead of the
71
+ // unreachable 127.0.0.1:<port> it bakes in when it only sees the local host.
72
+ headers["x-forwarded-host"] = sw.location.host;
38
73
  const bodyBuf =
39
74
  request.method === "GET" || request.method === "HEAD"
40
75
  ? undefined
package/worker/index.ts CHANGED
@@ -9,10 +9,17 @@ interface Env {
9
9
 
10
10
  const CORS = {
11
11
  "Access-Control-Allow-Origin": "*",
12
- "Access-Control-Allow-Methods": "GET,OPTIONS",
12
+ "Access-Control-Allow-Methods": "GET,HEAD,OPTIONS",
13
13
  "Access-Control-Allow-Headers": "*",
14
14
  };
15
15
 
16
+ // VS Code's product CDN sends no CORS headers, so the workbench's cross-origin
17
+ // fetches (e.g. main.vscode-cdn.net/extensions/chat.json) are blocked in our
18
+ // iframe. We re-serve allow-listed CDN assets with CORS. This is the
19
+ // authoritative gate — only hosts under this suffix may be proxied.
20
+ const CDN_HOST_SUFFIX = ".vscode-cdn.net";
21
+ const CDN_MAX_AGE = 3600;
22
+
16
23
  export default {
17
24
  async fetch(request: Request, env: Env): Promise<Response> {
18
25
  const url = new URL(request.url);
@@ -21,6 +28,13 @@ export default {
21
28
  return new Response(null, { headers: CORS });
22
29
  }
23
30
 
31
+ // GET /cdn/<host>/<path> -> proxy an allow-listed VS Code CDN asset, adding
32
+ // CORS so it loads cross-origin inside the iframe.
33
+ const cdn = url.pathname.match(/^\/cdn\/([^/]+)(\/.*)?$/);
34
+ if (cdn) {
35
+ return handleCdnProxy(request, cdn[1], `${cdn[2] ?? "/"}${url.search}`);
36
+ }
37
+
24
38
  // GET /room/:token -> WebSocket upgrade routed to the per-token DO.
25
39
  const match = url.pathname.match(/^\/room\/([^/]+)\/?$/);
26
40
  if (match) {
@@ -45,3 +59,37 @@ export default {
45
59
  return new Response("not found", { status: 404, headers: CORS });
46
60
  },
47
61
  };
62
+
63
+ /**
64
+ * Proxy a single VS Code CDN asset with permissive CORS, edge-cached. Only
65
+ * hosts under CDN_HOST_SUFFIX are allowed (not an open proxy). GET/HEAD only.
66
+ */
67
+ async function handleCdnProxy(request: Request, host: string, pathAndQuery: string): Promise<Response> {
68
+ if (request.method !== "GET" && request.method !== "HEAD") {
69
+ return new Response("method not allowed", { status: 405, headers: CORS });
70
+ }
71
+ if (!host.endsWith(CDN_HOST_SUFFIX)) {
72
+ return new Response("host not allowed", { status: 403, headers: CORS });
73
+ }
74
+
75
+ const upstream = `https://${host}${pathAndQuery}`;
76
+ const cacheKey = new Request(upstream, { method: "GET" });
77
+ const cache = caches.default;
78
+
79
+ let res = await cache.match(cacheKey);
80
+ if (!res) {
81
+ const up = await fetch(upstream, { method: "GET", redirect: "follow" });
82
+ const headers = new Headers(CORS);
83
+ const ct = up.headers.get("content-type");
84
+ if (ct) headers.set("content-type", ct);
85
+ headers.set("cache-control", `public, max-age=${CDN_MAX_AGE}`);
86
+ res = new Response(up.body, { status: up.status, headers });
87
+ if (up.ok) await cache.put(cacheKey, res.clone());
88
+ }
89
+
90
+ // HEAD: same headers, no body.
91
+ if (request.method === "HEAD") {
92
+ return new Response(null, { status: res.status, headers: res.headers });
93
+ }
94
+ return res;
95
+ }