codehost 0.1.1 → 0.4.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/.github/workflows/deploy.yml +30 -0
- package/.github/workflows/release.yaml +39 -0
- package/.releaserc.json +17 -0
- package/CHANGELOG.md +41 -0
- package/README.md +5 -0
- package/TODO.md +93 -0
- package/docs/vscode-cdn-proxy.md +117 -0
- package/package.json +12 -1
- package/public/_redirects +10 -0
- package/public/install.ps1 +31 -15
- package/public/install.sh +40 -15
- package/src/cli/commands/dev.ts +110 -0
- package/src/cli/commands/expose.ts +93 -0
- package/src/cli/commands/list.ts +2 -2
- package/src/cli/commands/serve.ts +29 -55
- package/src/cli/commands/setup.ts +9 -6
- package/src/cli/commands/stop.ts +2 -2
- package/src/cli/daemonize.ts +15 -4
- package/src/cli/git.ts +53 -0
- package/src/cli/index.ts +4 -0
- package/src/cli/open-url.ts +47 -0
- package/src/cli/oxmgr-heal.ts +175 -0
- package/src/cli/oxmgr.ts +104 -36
- package/src/cli/run-server.ts +78 -0
- package/src/cli/self-update.ts +89 -0
- package/src/cli/tunnel.ts +30 -4
- package/src/cli/vscode.ts +4 -2
- package/src/shared/repo.test.ts +37 -0
- package/src/shared/repo.ts +123 -0
- package/src/shared/signaling.ts +18 -2
- package/src/web/config.ts +44 -7
- package/src/web/discovery.tsx +131 -6
- package/src/web/history.ts +58 -0
- package/src/web/sw.ts +36 -1
- package/worker/index.ts +49 -1
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Shared helpers for GitHub-shaped deep links and matching them to a daemon.
|
|
2
|
+
// Used by the web resolver (src/web) and conceptually mirrors the daemon's
|
|
3
|
+
// repo identity (src/cli/git.ts).
|
|
4
|
+
|
|
5
|
+
import type { PeerInfo, PeerMeta } from "./signaling";
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_LAYOUT = "{owner}/{repo}/tree/{branch}";
|
|
8
|
+
|
|
9
|
+
export interface RepoTarget {
|
|
10
|
+
provider: "gh";
|
|
11
|
+
owner: string;
|
|
12
|
+
name: string;
|
|
13
|
+
/** Branch from the deep link, if present. */
|
|
14
|
+
branch?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A direct folder mount address: `/dev/<absolute-fs-path>`. */
|
|
18
|
+
export interface DevTarget {
|
|
19
|
+
path: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type DeepLink =
|
|
23
|
+
| { type: "repo"; target: RepoTarget }
|
|
24
|
+
| { type: "dev"; target: DevTarget }
|
|
25
|
+
| null;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parse a deep-link pathname:
|
|
29
|
+
* /gh/<owner>/<repo> -> repo target (default branch)
|
|
30
|
+
* /gh/<owner>/<repo>/tree/<branch> -> repo target (branch; may contain slashes)
|
|
31
|
+
* /dev/<fs-path> -> direct folder mount
|
|
32
|
+
* Anything else -> null (normal app).
|
|
33
|
+
*/
|
|
34
|
+
export function parseDeepLink(pathname: string): DeepLink {
|
|
35
|
+
const clean = pathname.replace(/\/+$/, "");
|
|
36
|
+
const gh = clean.match(/^\/gh\/([^/]+)\/([^/]+)(?:\/tree\/(.+))?$/);
|
|
37
|
+
if (gh) {
|
|
38
|
+
return {
|
|
39
|
+
type: "repo",
|
|
40
|
+
target: { provider: "gh", owner: gh[1], name: gh[2], branch: gh[3] },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const dev = clean.match(/^\/dev\/(.+)$/);
|
|
44
|
+
if (dev) {
|
|
45
|
+
return { type: "dev", target: { path: `/${dev[1].replace(/^\/+/, "")}` } };
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Normalized repo key, e.g. "gh/owner/repo". */
|
|
51
|
+
export function repoKey(t: Pick<RepoTarget, "owner" | "name">): string {
|
|
52
|
+
return `gh/${t.owner}/${t.name}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Normalize a served workspace path to the POSIX-drive form the browser side
|
|
57
|
+
* and VS Code web expect. A Windows drive path becomes a `/c/...` style path
|
|
58
|
+
* (lowercased drive, backslashes -> slashes): `C:\ws` -> `/c/ws`,
|
|
59
|
+
* `C:\Users\x` -> `/c/Users/x`, `D:\` -> `/d`. POSIX absolute paths (mac/linux)
|
|
60
|
+
* are returned unchanged. Used for `PeerMeta.cwd`, which feeds the `?folder=`
|
|
61
|
+
* URI — the real OS path is still used for the local VS Code working dir.
|
|
62
|
+
*/
|
|
63
|
+
export function toPosixPath(p: string): string {
|
|
64
|
+
const drive = /^([A-Za-z]):(?:[\\/](.*))?$/.exec(p);
|
|
65
|
+
if (drive) {
|
|
66
|
+
const letter = drive[1].toLowerCase();
|
|
67
|
+
const rest = (drive[2] ?? "").replace(/\\/g, "/").replace(/\/+$/, "");
|
|
68
|
+
return rest ? `/${letter}/${rest}` : `/${letter}`;
|
|
69
|
+
}
|
|
70
|
+
// Already POSIX (or a relative path): just unify any stray backslashes.
|
|
71
|
+
return p.replace(/\\/g, "/");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Fill a layout template from a repo target (default branch -> "main"). */
|
|
75
|
+
export function fillLayout(layout: string, t: RepoTarget): string {
|
|
76
|
+
return layout
|
|
77
|
+
.replace(/\{owner\}/g, t.owner)
|
|
78
|
+
.replace(/\{repo\}/g, t.name)
|
|
79
|
+
.replace(/\{branch\}/g, t.branch || "main");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface Resolution {
|
|
83
|
+
peerId: string;
|
|
84
|
+
/** Folder to open via ?folder= (root kind); undefined opens the repo as-is. */
|
|
85
|
+
folder?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Pick the best live server for a repo deep link. Prefers an exact `repo`
|
|
90
|
+
* daemon; otherwise falls back to a `root` daemon that can open the subfolder.
|
|
91
|
+
* Returns null if nothing matches.
|
|
92
|
+
*/
|
|
93
|
+
export function resolveRepoTarget(servers: PeerInfo[], target: RepoTarget): Resolution | null {
|
|
94
|
+
const key = repoKey(target);
|
|
95
|
+
const repoMatch = servers.find(
|
|
96
|
+
(s) => s.meta?.kind !== "root" && s.meta?.repo === key && branchOk(s.meta, target),
|
|
97
|
+
);
|
|
98
|
+
if (repoMatch) return { peerId: repoMatch.peerId };
|
|
99
|
+
|
|
100
|
+
const root = servers.find((s) => s.meta?.kind === "root");
|
|
101
|
+
if (root && root.meta) {
|
|
102
|
+
const folder = `${trimSlash(root.meta.cwd)}/${fillLayout(root.meta.layout || DEFAULT_LAYOUT, target)}`;
|
|
103
|
+
return { peerId: root.peerId, folder };
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Pick a `dev`/repo server whose served cwd matches a /dev/<path> target. */
|
|
109
|
+
export function resolveDevTarget(servers: PeerInfo[], target: DevTarget): Resolution | null {
|
|
110
|
+
const want = trimSlash(target.path);
|
|
111
|
+
const hit = servers.find((s) => s.meta && trimSlash(s.meta.cwd) === want);
|
|
112
|
+
return hit ? { peerId: hit.peerId } : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function branchOk(meta: PeerMeta, target: RepoTarget): boolean {
|
|
116
|
+
// No branch requested, or the server doesn't report one -> accept; else exact.
|
|
117
|
+
if (!target.branch || !meta.branch) return true;
|
|
118
|
+
return meta.branch === target.branch;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function trimSlash(p: string): string {
|
|
122
|
+
return p.replace(/\/+$/, "");
|
|
123
|
+
}
|
package/src/shared/signaling.ts
CHANGED
|
@@ -4,14 +4,30 @@
|
|
|
4
4
|
|
|
5
5
|
export type Role = "server" | "viewer";
|
|
6
6
|
|
|
7
|
-
/** Metadata a `codehost serve` daemon advertises about itself. */
|
|
7
|
+
/** Metadata a `codehost serve`/`dev` daemon advertises about itself. */
|
|
8
8
|
export interface PeerMeta {
|
|
9
9
|
/** Human label, defaults to hostname. */
|
|
10
10
|
name: string;
|
|
11
|
-
/** Directory the VS Code instance is serving. */
|
|
11
|
+
/** Directory the VS Code instance is serving (the repo dir, or the root). */
|
|
12
12
|
cwd: string;
|
|
13
13
|
/** Hostname of the machine running the daemon. */
|
|
14
14
|
host: string;
|
|
15
|
+
/**
|
|
16
|
+
* "repo": serves a single folder (`codehost dev`), git-identified when possible.
|
|
17
|
+
* "root": serves a workspace root (`codehost serve`) whose repos live under it.
|
|
18
|
+
* Absent is treated as "repo" for backward compatibility.
|
|
19
|
+
*/
|
|
20
|
+
kind?: "repo" | "root";
|
|
21
|
+
/** repo kind: normalized identity, e.g. "gh/snomiao/codehost". */
|
|
22
|
+
repo?: string;
|
|
23
|
+
/** repo kind: current branch, e.g. "main". */
|
|
24
|
+
branch?: string;
|
|
25
|
+
/**
|
|
26
|
+
* root kind: on-disk layout of repos under `cwd`, as a template filled from a
|
|
27
|
+
* deep link — default "{owner}/{repo}/tree/{branch}". The opened folder is
|
|
28
|
+
* `cwd + "/" + fill(layout, target)`.
|
|
29
|
+
*/
|
|
30
|
+
layout?: string;
|
|
15
31
|
}
|
|
16
32
|
|
|
17
33
|
export interface PeerInfo {
|
package/src/web/config.ts
CHANGED
|
@@ -3,19 +3,56 @@
|
|
|
3
3
|
// (vite on :5173) it talks to `wrangler dev` on :8787. Override either with
|
|
4
4
|
// localStorage key "codehost.signal".
|
|
5
5
|
|
|
6
|
+
/** The signaling host for a given page host, e.g. signal.codehost.dev. */
|
|
7
|
+
function signalHost(hostname: string): string {
|
|
8
|
+
return `signal.${hostname.replace(/^www\./, "")}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
6
11
|
function defaultSignalUrl(): string {
|
|
7
|
-
|
|
8
|
-
|
|
12
|
+
// Read location off globalThis so this module also type-checks in the Service
|
|
13
|
+
// Worker build (webworker lib, no `window`). In a worker there's no signaling
|
|
14
|
+
// anyway; getSignalUrl is page-only and tree-shaken out of the SW bundle.
|
|
15
|
+
const loc = (globalThis as { location?: { hostname: string; protocol: string } }).location;
|
|
16
|
+
if (!loc) return "wss://signal.codehost.dev";
|
|
17
|
+
const { hostname, protocol } = loc;
|
|
9
18
|
const isLocal = hostname === "localhost" || hostname === "127.0.0.1";
|
|
10
19
|
if (isLocal) return "ws://localhost:8787";
|
|
11
20
|
const wsProto = protocol === "https:" ? "wss:" : "ws:";
|
|
12
|
-
return `${wsProto}
|
|
21
|
+
return `${wsProto}//${signalHost(hostname)}`;
|
|
13
22
|
}
|
|
14
23
|
|
|
15
24
|
export function getSignalUrl(): string {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
25
|
+
const ls = (globalThis as { localStorage?: { getItem(k: string): string | null } }).localStorage;
|
|
26
|
+
const override = ls?.getItem("codehost.signal");
|
|
27
|
+
if (override) return override;
|
|
20
28
|
return defaultSignalUrl();
|
|
21
29
|
}
|
|
30
|
+
|
|
31
|
+
// --- VS Code CDN proxy ---------------------------------------------------
|
|
32
|
+
// Microsoft's VS Code product CDN sends no CORS headers, so the workbench's
|
|
33
|
+
// cross-origin fetches (e.g. https://main.vscode-cdn.net/extensions/chat.json)
|
|
34
|
+
// are blocked when VS Code runs inside our iframe. The Service Worker rewrites
|
|
35
|
+
// those requests to the signaling Worker's /cdn route, which re-serves them
|
|
36
|
+
// with Access-Control-Allow-Origin: *. See docs/vscode-cdn-proxy.md.
|
|
37
|
+
|
|
38
|
+
/** Hostname suffix for the CDN we proxy. The Worker is the authoritative gate;
|
|
39
|
+
* this lets the SW know which cross-origin requests to rewrite. */
|
|
40
|
+
export const VSCODE_CDN_SUFFIX = ".vscode-cdn.net";
|
|
41
|
+
|
|
42
|
+
export function isProxiableCdnHost(hostname: string): boolean {
|
|
43
|
+
return hostname.endsWith(VSCODE_CDN_SUFFIX);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* HTTPS base of the signaling host for the current page, e.g.
|
|
48
|
+
* https://signal.codehost.dev. The SW rewrites blocked CDN requests to
|
|
49
|
+
* `${base}/cdn/<host>/<path>`. Derived from the live host (never hardcoded) so a
|
|
50
|
+
* self-hoster serving the page + Worker on their own domain is proxied by their
|
|
51
|
+
* own Worker at signal.<their-domain>.
|
|
52
|
+
*/
|
|
53
|
+
export function cdnProxyBase(hostname: string, protocol: string): string {
|
|
54
|
+
const isLocal = hostname === "localhost" || hostname === "127.0.0.1";
|
|
55
|
+
if (isLocal) return "http://localhost:8787";
|
|
56
|
+
const httpProto = protocol === "https:" ? "https:" : "http:";
|
|
57
|
+
return `${httpProto}//${signalHost(hostname)}`;
|
|
58
|
+
}
|
package/src/web/discovery.tsx
CHANGED
|
@@ -7,13 +7,54 @@ import { RtcClient } from "./rtc-client";
|
|
|
7
7
|
import { getSignalUrl } from "./config";
|
|
8
8
|
import { registerTunnelHost } from "./tunnel-host";
|
|
9
9
|
import { connBroker } from "./conn-broker";
|
|
10
|
+
import {
|
|
11
|
+
type DeepLink,
|
|
12
|
+
parseDeepLink,
|
|
13
|
+
repoKey,
|
|
14
|
+
resolveDevTarget,
|
|
15
|
+
resolveRepoTarget,
|
|
16
|
+
} from "../shared/repo";
|
|
17
|
+
import { addRoom, historyFor, recordConnection } from "./history";
|
|
10
18
|
|
|
11
19
|
const TOKEN_KEY = "codehost.token";
|
|
12
20
|
|
|
13
21
|
type ConnState = "idle" | "connecting" | "connected" | "failed";
|
|
14
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Read a room token handed in the URL fragment as `#t=<token>` (what the CLI
|
|
25
|
+
* prints/opens after `setup`/`serve`). The page is static, so the fragment
|
|
26
|
+
* never reaches the server — a safe place for the shared secret. Everything
|
|
27
|
+
* after `#t=` is the token (URL-encoded by the CLI); returns "" if absent.
|
|
28
|
+
*/
|
|
29
|
+
function tokenFromHash(): string {
|
|
30
|
+
const m = window.location.hash.match(/^#t=(.+)$/);
|
|
31
|
+
if (!m) return "";
|
|
32
|
+
try {
|
|
33
|
+
return decodeURIComponent(m[1]).trim();
|
|
34
|
+
} catch {
|
|
35
|
+
return m[1].trim();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Short label for the "looking for…" state from a deep link. */
|
|
40
|
+
function deepLinkLabel(dl: DeepLink): string | null {
|
|
41
|
+
if (!dl) return null;
|
|
42
|
+
return dl.type === "repo" ? `${dl.target.owner}/${dl.target.name}` : dl.target.path;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function folderQuery(folder?: string): string {
|
|
46
|
+
return folder ? `?folder=${encodeURIComponent(folder)}` : "";
|
|
47
|
+
}
|
|
48
|
+
|
|
15
49
|
export function Discovery() {
|
|
16
|
-
const [token, setToken] = useState(() =>
|
|
50
|
+
const [token, setToken] = useState(() => {
|
|
51
|
+
const fromHash = tokenFromHash();
|
|
52
|
+
if (fromHash && validateToken(fromHash).ok) {
|
|
53
|
+
localStorage.setItem(TOKEN_KEY, fromHash);
|
|
54
|
+
return fromHash;
|
|
55
|
+
}
|
|
56
|
+
return localStorage.getItem(TOKEN_KEY) ?? "";
|
|
57
|
+
});
|
|
17
58
|
const [draft, setDraft] = useState(token);
|
|
18
59
|
const [tokenError, setTokenError] = useState<string | null>(null);
|
|
19
60
|
const [connected, setConnected] = useState(false);
|
|
@@ -30,6 +71,15 @@ export function Discovery() {
|
|
|
30
71
|
const rtcRef = useRef<RtcClient | null>(null);
|
|
31
72
|
const activePeerRef = useRef<string | null>(null);
|
|
32
73
|
|
|
74
|
+
// Deep-link resolution (/gh/<owner>/<repo>/... or /dev/<path>): parse once,
|
|
75
|
+
// auto-connect when a matching server appears, remember the opened folder.
|
|
76
|
+
const deepLinkRef = useRef<DeepLink>(parseDeepLink(window.location.pathname));
|
|
77
|
+
const resolvedRef = useRef(false);
|
|
78
|
+
// A valid token in the URL fragment enables single-server auto-connect.
|
|
79
|
+
const autoConnectRef = useRef(false);
|
|
80
|
+
const activeFolderRef = useRef<string | undefined>(undefined);
|
|
81
|
+
const [resolving, setResolving] = useState<string | null>(() => deepLinkLabel(deepLinkRef.current));
|
|
82
|
+
|
|
33
83
|
// Register the Service Worker + connection broker once. The broker shares one
|
|
34
84
|
// WebRTC connection per server across tabs; on owner failover it asks us to
|
|
35
85
|
// reload the iframe so it reconnects through the new owner.
|
|
@@ -38,8 +88,27 @@ export function Discovery() {
|
|
|
38
88
|
connBroker.onLost((peerId) => {
|
|
39
89
|
if (peerId !== activePeerRef.current) return;
|
|
40
90
|
setIframeSrc(null);
|
|
41
|
-
|
|
91
|
+
const folder = activeFolderRef.current;
|
|
92
|
+
setTimeout(() => setIframeSrc(`/vs/${peerId}/${folderQuery(folder)}`), 400);
|
|
42
93
|
});
|
|
94
|
+
// A valid token in the URL fragment (#t=<token>) seeds the room and turns on
|
|
95
|
+
// single-server auto-connect; consume it from the address bar afterwards so
|
|
96
|
+
// the secret isn't left visible or re-applied on a manual reload.
|
|
97
|
+
const urlToken = tokenFromHash();
|
|
98
|
+
if (urlToken && validateToken(urlToken).ok) {
|
|
99
|
+
autoConnectRef.current = true;
|
|
100
|
+
if (window.location.hash) {
|
|
101
|
+
history.replaceState(null, "", window.location.pathname + window.location.search);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// For a repo deep link, adopt the room that last served it so it resolves
|
|
106
|
+
// without re-entering a token.
|
|
107
|
+
const dl = deepLinkRef.current;
|
|
108
|
+
if (dl?.type === "repo") {
|
|
109
|
+
const h = historyFor(repoKey(dl.target));
|
|
110
|
+
if (h?.token) setToken(h.token);
|
|
111
|
+
}
|
|
43
112
|
}, []);
|
|
44
113
|
|
|
45
114
|
useEffect(() => {
|
|
@@ -48,9 +117,16 @@ export function Discovery() {
|
|
|
48
117
|
url: getSignalUrl(),
|
|
49
118
|
token,
|
|
50
119
|
role: "viewer",
|
|
51
|
-
onOpen: () =>
|
|
120
|
+
onOpen: () => {
|
|
121
|
+
setConnected(true);
|
|
122
|
+
addRoom(token);
|
|
123
|
+
},
|
|
52
124
|
onClose: () => setConnected(false),
|
|
53
|
-
onPeers: (peers) =>
|
|
125
|
+
onPeers: (peers) => {
|
|
126
|
+
const list = peers.filter((p) => p.role === "server");
|
|
127
|
+
setServers(list);
|
|
128
|
+
void tryAutoConnect(list);
|
|
129
|
+
},
|
|
54
130
|
onSignal: (from, data) => {
|
|
55
131
|
if (from === activePeerRef.current) void rtcRef.current?.handleSignal(data);
|
|
56
132
|
},
|
|
@@ -85,7 +161,7 @@ export function Discovery() {
|
|
|
85
161
|
setToken(t);
|
|
86
162
|
}
|
|
87
163
|
|
|
88
|
-
async function connectTo(server: PeerInfo) {
|
|
164
|
+
async function connectTo(server: PeerInfo, folder?: string) {
|
|
89
165
|
const client = clientRef.current;
|
|
90
166
|
if (!client) return;
|
|
91
167
|
|
|
@@ -128,12 +204,55 @@ export function Discovery() {
|
|
|
128
204
|
try {
|
|
129
205
|
await connBroker.connect(server.peerId, establish);
|
|
130
206
|
setConnState("connected");
|
|
131
|
-
|
|
207
|
+
// The daemon no longer sets a default folder (current VS Code serve-web
|
|
208
|
+
// dropped that flag), so open the served workspace from here: an explicit
|
|
209
|
+
// deep-link folder if we have one, else the server's reported cwd.
|
|
210
|
+
const openFolder = folder ?? server.meta?.cwd;
|
|
211
|
+
activeFolderRef.current = openFolder;
|
|
212
|
+
setIframeSrc(`/vs/${server.peerId}/${folderQuery(openFolder)}`);
|
|
213
|
+
setResolving(null);
|
|
214
|
+
recordConnect(server, openFolder);
|
|
132
215
|
} catch {
|
|
133
216
|
setConnState("failed");
|
|
134
217
|
}
|
|
135
218
|
}
|
|
136
219
|
|
|
220
|
+
// Deep-link auto-connect: when servers arrive, pick the best match (exact repo
|
|
221
|
+
// daemon, else a root daemon's subfolder) and open it once.
|
|
222
|
+
async function tryAutoConnect(list: PeerInfo[]) {
|
|
223
|
+
if (resolvedRef.current) return;
|
|
224
|
+
const dl = deepLinkRef.current;
|
|
225
|
+
if (dl) {
|
|
226
|
+
const res = dl.type === "repo" ? resolveRepoTarget(list, dl.target) : resolveDevTarget(list, dl.target);
|
|
227
|
+
if (!res) return;
|
|
228
|
+
const server = list.find((s) => s.peerId === res.peerId);
|
|
229
|
+
if (!server) return;
|
|
230
|
+
resolvedRef.current = true;
|
|
231
|
+
await connectTo(server, res.folder);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
// No deep link, but a token arrived via the URL: open the room's server
|
|
235
|
+
// straight away when there's exactly one; with several, leave the picker.
|
|
236
|
+
if (autoConnectRef.current && list.length === 1) {
|
|
237
|
+
resolvedRef.current = true;
|
|
238
|
+
await connectTo(list[0]);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function recordConnect(server: PeerInfo, folder?: string) {
|
|
243
|
+
const base = {
|
|
244
|
+
token,
|
|
245
|
+
peerId: server.peerId,
|
|
246
|
+
kind: server.meta?.kind,
|
|
247
|
+
name: server.meta?.name,
|
|
248
|
+
host: server.meta?.host,
|
|
249
|
+
lastConnected: Date.now(),
|
|
250
|
+
};
|
|
251
|
+
if (server.meta?.repo) recordConnection(server.meta.repo, { ...base, folder });
|
|
252
|
+
const dl = deepLinkRef.current;
|
|
253
|
+
if (dl?.type === "repo") recordConnection(repoKey(dl.target), { ...base, folder });
|
|
254
|
+
}
|
|
255
|
+
|
|
137
256
|
function disconnect() {
|
|
138
257
|
rtcRef.current?.close();
|
|
139
258
|
rtcRef.current = null;
|
|
@@ -178,6 +297,12 @@ export function Discovery() {
|
|
|
178
297
|
</header>
|
|
179
298
|
|
|
180
299
|
<main style={styles.main}>
|
|
300
|
+
{resolving && (
|
|
301
|
+
<p style={{ color: "#dcb67a", marginBottom: 12 }}>
|
|
302
|
+
Looking for <code style={styles.code}>{resolving}</code> in your rooms…{" "}
|
|
303
|
+
{token ? "waiting for a live server" : "enter the room's token below"}.
|
|
304
|
+
</p>
|
|
305
|
+
)}
|
|
181
306
|
<form onSubmit={applyToken} style={styles.tokenForm}>
|
|
182
307
|
<label style={styles.label}>Token</label>
|
|
183
308
|
<input
|
|
@@ -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)
|
|
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
|
+
}
|