ccdeck 3.28.1 → 3.29.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.
- package/README.md +4 -0
- package/THIRD_PARTY_NOTICES.md +148 -4
- package/dist/web/assets/{BrowserWatchModal-CIla-_hm.js → BrowserWatchModal-C-TkzDPi.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-CSgh5kjS.js → UsageHistoryModal-FKBbTozZ.js} +1 -1
- package/dist/web/assets/hls-BFpXDghs.js +42 -0
- package/dist/web/assets/index-7mOqoJ3Y.js +298 -0
- package/dist/web/assets/index-FeNTTJ0x.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/release-notes.json +36 -0
- package/src/server/account-health.mjs +38 -0
- package/src/server/account-projects.mjs +89 -23
- package/src/server/claude-accounts.mjs +84 -26
- package/src/server/claude-dir.mjs +35 -0
- package/src/server/cswap-admin.mjs +136 -8
- package/src/server/deck-prefs.mjs +2 -0
- package/src/server/fm-station.mjs +186 -0
- package/src/server/index.mjs +208 -55
- package/src/server/lan-engine.mjs +244 -38
- package/src/server/lan-socket.mjs +101 -15
- package/src/server/lan-sync.mjs +86 -9
- package/dist/web/assets/index-CsHTYwoD.js +0 -297
- package/dist/web/assets/index-DbwzUUcd.css +0 -1
|
@@ -24,7 +24,8 @@ import { readFile } from "node:fs/promises";
|
|
|
24
24
|
import { homedir } from "node:os";
|
|
25
25
|
import { join } from "node:path";
|
|
26
26
|
import { looksMissing, pathLookup, run, runDetached, runInteractive } from "./exec.mjs";
|
|
27
|
-
import { backupRoot, invalidateClaudeAccountsCache, verdictNow } from "./claude-accounts.mjs";
|
|
27
|
+
import { backupRoot, invalidateClaudeAccountsCache, verdictNow, verdictsNow } from "./claude-accounts.mjs";
|
|
28
|
+
import { HERE } from "./lan-sync.mjs";
|
|
28
29
|
import { withStoreLock } from "./store-lock.mjs";
|
|
29
30
|
import { adminClaudeBin, currentIdentity } from "./claude-identity.mjs";
|
|
30
31
|
import { claudeCliCandidates } from "./claude-dir.mjs";
|
|
@@ -35,6 +36,109 @@ import { PRODUCT } from "./brand.mjs";
|
|
|
35
36
|
// open longer than a user would plausibly take to fetch one.
|
|
36
37
|
const LOGIN_TIMEOUT_MS = 5 * 60_000;
|
|
37
38
|
const CSWAP_TIMEOUT_MS = 60_000;
|
|
39
|
+
// ── whether this process can read what claude-swap holds ────────────────────
|
|
40
|
+
//
|
|
41
|
+
// ASKED OF CLAUDE-SWAP, NEVER READ OFF ITS WORDS. A Keychain it cannot open is
|
|
42
|
+
// logged to its own log file and nowhere else: `cswap export` then says only
|
|
43
|
+
// "no backup credentials found for account N", which is also what an account
|
|
44
|
+
// with no stored login says, and `cswap import` falls back to its .enc file and
|
|
45
|
+
// exits 0. Nothing on stderr tells a locked Keychain apart, so nothing here
|
|
46
|
+
// looks. `cswap list --json` does tell it apart — a per-account `usageStatus`
|
|
47
|
+
// of `keychain_unavailable` — and that is the one signal these read.
|
|
48
|
+
//
|
|
49
|
+
// MACOS ONLY. claude-swap reports `keychain_unavailable` on Linux and Windows
|
|
50
|
+
// as well, for an .enc file it cannot open, and every sentence these feed talks
|
|
51
|
+
// about a Mac's Keychain and a Terminal window.
|
|
52
|
+
|
|
53
|
+
/** Fresh verdict rows for several identities from one `cswap list --json`, in
|
|
54
|
+
* the order asked; null for any claude-swap would not answer for. */
|
|
55
|
+
async function rowsNow(ids, verdicts) {
|
|
56
|
+
const all = await verdicts({ fresh: true });
|
|
57
|
+
return ids.map(({ email, org }) => {
|
|
58
|
+
const want = String(email ?? "").trim().toLowerCase();
|
|
59
|
+
return all?.find(a => a.email === want && a.org === (org ?? "")) ?? null;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Why a share came out empty: `keychain_unavailable` when claude-swap says
|
|
65
|
+
* EVERY account that failed is one this Mac's Keychain will not open, and
|
|
66
|
+
* `export_failed` otherwise — the reason has to be true of the whole failure,
|
|
67
|
+
* because the detail printed beside it is.
|
|
68
|
+
*/
|
|
69
|
+
export async function exportFailure(failed, store, { platform = process.platform, verdicts = verdictsNow } = {}) {
|
|
70
|
+
if (platform !== "darwin" || !failed.length) return "export_failed";
|
|
71
|
+
const got = await rowsNow(
|
|
72
|
+
failed.map(f => ({ email: f.email, org: store?.orgs?.[f.num] ?? "" })),
|
|
73
|
+
verdicts,
|
|
74
|
+
);
|
|
75
|
+
// The panel's own copy of the verdicts was just replaced; its cached account
|
|
76
|
+
// list must not go on drawing the old ones.
|
|
77
|
+
invalidateClaudeAccountsCache();
|
|
78
|
+
return got.every(r => r?.status === "keychain_unavailable") ? "keychain_unavailable" : "export_failed";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** What claude-swap's verdict on a login that just landed means for it here.
|
|
82
|
+
* Everything else — `ok`, a transient `unavailable`, an api-key account — is a
|
|
83
|
+
* login that arrived and can be read, which is all this is asked. */
|
|
84
|
+
const AFTER_IMPORT = {
|
|
85
|
+
keychain_unavailable: HERE.unreadable,
|
|
86
|
+
no_credentials: HERE.noLogin,
|
|
87
|
+
relogin_required: HERE.expired,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** A Mac's locally unreadable login cannot be offered to a paired deck. */
|
|
91
|
+
export function markUnreadable(accounts, platform = process.platform) {
|
|
92
|
+
if (platform !== "darwin" || !Array.isArray(accounts)) return accounts;
|
|
93
|
+
return accounts.map(a => a.collector === "keychain_unavailable" ? { ...a, readable: false } : a);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The logins a round just imported, checked once from inside this process.
|
|
98
|
+
*
|
|
99
|
+
* `cswap import` exiting 0 is not proof: on a Mac it falls back quietly when
|
|
100
|
+
* the Keychain will not take the write, and a deck started from SSH or a
|
|
101
|
+
* LaunchAgent may then be unable to read what it holds. One HERE code per
|
|
102
|
+
* identity (see lan-sync.mjs), or null for one that is fine — and
|
|
103
|
+
* `HERE.unverified` when claude-swap could not be asked at all, because an
|
|
104
|
+
* unanswered question is not a yes.
|
|
105
|
+
*
|
|
106
|
+
* One `cswap list --json` for the whole round, after it, rather than one per
|
|
107
|
+
* login inside it: each is a usage collection that can take a minute, and the
|
|
108
|
+
* peer hangs up on a connection left idle for thirty seconds.
|
|
109
|
+
*
|
|
110
|
+
* THE SIGNED-IN ACCOUNT IS READ LIVE. For it, claude-swap's verdict is about the
|
|
111
|
+
* live login rather than the copy just written, so an expired or missing live
|
|
112
|
+
* login says nothing about the import and reads as unverified. A Keychain it
|
|
113
|
+
* cannot open still counts: it is the same Keychain, from the same session.
|
|
114
|
+
*/
|
|
115
|
+
export async function checkImports(ids, { platform = process.platform, verdicts = verdictsNow } = {}) {
|
|
116
|
+
if (platform !== "darwin" || !ids.length) return ids.map(() => null);
|
|
117
|
+
const got = await rowsNow(ids, verdicts);
|
|
118
|
+
return got.map(r => {
|
|
119
|
+
if (r?.status == null) return HERE.unverified;
|
|
120
|
+
if (!Object.hasOwn(AFTER_IMPORT, r.status)) return null;
|
|
121
|
+
return r.active && r.status !== "keychain_unavailable" ? HERE.unverified : AFTER_IMPORT[r.status];
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* An import's per-account results, each landed one carrying `check` — its
|
|
127
|
+
* checkImports code — when this Mac found something wrong with it. For the
|
|
128
|
+
* Add Account route, which says "imported" per account and was as blind to an
|
|
129
|
+
* unreadable login as the LAN round was.
|
|
130
|
+
*/
|
|
131
|
+
export async function checkImportResults(results, opts) {
|
|
132
|
+
const list = Array.isArray(results) ? results : [];
|
|
133
|
+
const came = list.filter(r => landed([r]));
|
|
134
|
+
if (!came.length) return list;
|
|
135
|
+
const codes = await checkImports(came.map(r => ({ email: r.email, org: r.org ?? "" })), opts);
|
|
136
|
+
return list.map(r => {
|
|
137
|
+
const code = codes[came.indexOf(r)];
|
|
138
|
+
return typeof code === "string" ? { ...r, check: code } : r;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
38
142
|
// How long to wait for the CLI's verdict on a pasted code before saying so.
|
|
39
143
|
// Exchanging a code is one HTTPS round trip; a minute is generous.
|
|
40
144
|
const CODE_VERDICT_MS = 60_000;
|
|
@@ -889,8 +993,13 @@ export function mergeExports(texts) {
|
|
|
889
993
|
*
|
|
890
994
|
* The default export shape is used deliberately, never --full, which would
|
|
891
995
|
* embed the entire ~/.claude.json including every project and MCP server.
|
|
996
|
+
*
|
|
997
|
+
* `explain: false` skips asking claude-swap why an empty export failed. The
|
|
998
|
+
* LAN answers a peer that gives up after ten seconds, and that question is a
|
|
999
|
+
* usage collection that can take a minute; it refreshes the verdict in the
|
|
1000
|
+
* background instead, so the next round's refusal can name the Keychain.
|
|
892
1001
|
*/
|
|
893
|
-
export async function shareAccounts(nums) {
|
|
1002
|
+
export async function shareAccounts(nums, { explain = true } = {}) {
|
|
894
1003
|
const asked = Array.isArray(nums) ? nums : [nums];
|
|
895
1004
|
// One spawn per account, so the length of this list is a length of time the
|
|
896
1005
|
// request holds. A store never has fifty accounts; a caller that sends nine
|
|
@@ -939,7 +1048,17 @@ export async function shareAccounts(nums) {
|
|
|
939
1048
|
// Nothing came out at all. There is no partial bundle to hand over, so this
|
|
940
1049
|
// is the plain failure the single-account share has always reported.
|
|
941
1050
|
if (!texts.length) {
|
|
942
|
-
|
|
1051
|
+
if (!explain) {
|
|
1052
|
+
if (process.platform === "darwin") void verdictsNow().then(() => invalidateClaudeAccountsCache(), () => {});
|
|
1053
|
+
return { ok: false, reason: "export_failed", detail: failed[0]?.detail ?? "", failed };
|
|
1054
|
+
}
|
|
1055
|
+
const reason = await exportFailure(failed, store);
|
|
1056
|
+
// failed[0]'s own line would read "no backup credentials found", which
|
|
1057
|
+
// sends somebody to sign in again over a login that is perfectly fine.
|
|
1058
|
+
const detail = reason === "keychain_unavailable"
|
|
1059
|
+
? `claude-swap could not open this Mac's Keychain — start ${PRODUCT} from a Terminal window on the Mac itself, not over SSH or as a background service`
|
|
1060
|
+
: failed[0]?.detail ?? "";
|
|
1061
|
+
return { ok: false, reason, detail, failed };
|
|
943
1062
|
}
|
|
944
1063
|
|
|
945
1064
|
const merged = mergeExports(texts);
|
|
@@ -1106,7 +1225,7 @@ export function importOutcomes(before, after, wanted, stderr = "") {
|
|
|
1106
1225
|
* from here - the fix is a re-login. Requiring the pair is what makes the
|
|
1107
1226
|
* clobber something a person chose while looking at the address.
|
|
1108
1227
|
*/
|
|
1109
|
-
export async function importAccount(blob, { force = false, only = null } = {}) {
|
|
1228
|
+
export async function importAccount(blob, { force = false, only = null, collect = true } = {}) {
|
|
1110
1229
|
const un = unwrapShare(blob);
|
|
1111
1230
|
if (!un.ok) return { ok: false, reason: un.reason };
|
|
1112
1231
|
|
|
@@ -1153,7 +1272,10 @@ export async function importAccount(blob, { force = false, only = null } = {}) {
|
|
|
1153
1272
|
})();
|
|
1154
1273
|
|
|
1155
1274
|
const arrived = results.filter(x => x.state === "imported");
|
|
1156
|
-
|
|
1275
|
+
// `collect: false` when a checkImports follows: its `cswap list --json` is
|
|
1276
|
+
// the same collection, and two at once race for one slot's claim, so the
|
|
1277
|
+
// loser reads a fresh slot as `unavailable`.
|
|
1278
|
+
if (arrived.length && collect) runDetached(await cswapBin(), ["list"]);
|
|
1157
1279
|
return {
|
|
1158
1280
|
ok: true,
|
|
1159
1281
|
results,
|
|
@@ -1204,14 +1326,20 @@ export async function importAccount(blob, { force = false, only = null } = {}) {
|
|
|
1204
1326
|
* would rewrite every matching credential on this machine, and a fresh token
|
|
1205
1327
|
* replaced by a stale one is not recoverable from here.
|
|
1206
1328
|
*/
|
|
1207
|
-
export async function fillEmptySlot(blob, { email, org } = {}) {
|
|
1329
|
+
export async function fillEmptySlot(blob, { email, org, platform = process.platform, collect = true } = {}) {
|
|
1208
1330
|
return withStoreLock(async () => {
|
|
1209
1331
|
// FIRST STATEMENT INSIDE THE LOCK. Anything awaited before this re-opens
|
|
1210
1332
|
// the window it exists to close.
|
|
1211
1333
|
const now = await verdictNow(email, org ?? "");
|
|
1212
|
-
|
|
1334
|
+
// THIS LINE IS THE PROTECTION: anything but `no_credentials` — an
|
|
1335
|
+
// unreadable Keychain included, which is an unknown and not an empty slot —
|
|
1336
|
+
// refuses the forced write. The ternary only names which refusal it was,
|
|
1337
|
+
// since "kept the slot it already has" sends nobody to unlock the Mac.
|
|
1338
|
+
if (now !== "no_credentials") {
|
|
1339
|
+
return { ok: false, why: platform === "darwin" && now === "keychain_unavailable" ? HERE.unreadable : "claude-swap kept the slot it already has" };
|
|
1340
|
+
}
|
|
1213
1341
|
|
|
1214
|
-
const forced = await importAccount(blob, { force: true, only: { email, org: org ?? "" } });
|
|
1342
|
+
const forced = await importAccount(blob, { force: true, only: { email, org: org ?? "" }, collect });
|
|
1215
1343
|
if (!forced?.ok) return { ok: false, why: forced?.reason ?? "import refused" };
|
|
1216
1344
|
// `added` counts `imported` alone, and a forced replace is `healed` — see
|
|
1217
1345
|
// landed, which is the difference between a repair and a repair reported as
|
|
@@ -109,6 +109,7 @@ export const DEFAULTS = Object.freeze({
|
|
|
109
109
|
// network it does not own turns this off in the dialog, in one press.
|
|
110
110
|
autoAsk: true,
|
|
111
111
|
autoAccept: true,
|
|
112
|
+
pairingMode: "automatic",
|
|
112
113
|
// WHICH ACCOUNT THIS DECK IS ON, told to the decks it is paired with — and
|
|
113
114
|
// only ever one it shares, so an unticked account is never named. On, so two
|
|
114
115
|
// of one person's machines show each other where they are working; off, and
|
|
@@ -232,6 +233,7 @@ function normaliseLan(raw) {
|
|
|
232
233
|
// overrides it, because a truthy string from a hand-edited file is not an
|
|
233
234
|
// answer.
|
|
234
235
|
autoAccept: typeof src.autoAccept === "boolean" ? src.autoAccept : DEFAULTS.lan.autoAccept,
|
|
236
|
+
pairingMode: src.pairingMode === "invite" ? "invite" : "automatic",
|
|
235
237
|
// Whether paired decks are told which shared account this one is on. Absent
|
|
236
238
|
// is on — the default above — and only a real boolean turns it off.
|
|
237
239
|
shareActive: typeof src.shareActive === "boolean" ? src.shareActive : DEFAULTS.lan.shareActive,
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Custom Claude FM stations that live on YouTube (#1208).
|
|
2
|
+
//
|
|
3
|
+
// A station somebody adds by link is played by the browser, never by this
|
|
4
|
+
// process: a direct stream goes into an <audio> element and a YouTube one into
|
|
5
|
+
// the same embed Claude FM uses. The only thing the deck itself does is the
|
|
6
|
+
// lookup below, which turns a `/@handle/live` or `watch?v=` link into the
|
|
7
|
+
// channel and the video the embed needs, because YouTube only says which video
|
|
8
|
+
// a handle is broadcasting in the page it serves.
|
|
9
|
+
//
|
|
10
|
+
// THAT MAKES THIS THE ONE ROUTE WHERE A CALLER NAMES WHAT THE DECK FETCHES, so
|
|
11
|
+
// the link is never fetched as given. It is parsed down to one of three shapes
|
|
12
|
+
// and the request is rebuilt from the parts: https, www.youtube.com, no port,
|
|
13
|
+
// no login, a path this file wrote and an id that matched a strict pattern.
|
|
14
|
+
// A link with a port or a login is refused rather than cleaned, because nobody
|
|
15
|
+
// copies one of those out of a YouTube tab and both change where the request
|
|
16
|
+
// would really go. index.mjs puts the route behind the guarded-read gate too,
|
|
17
|
+
// so a page on another site cannot use it to make the deck call anything.
|
|
18
|
+
const CHANNEL_ID = /^UC[A-Za-z0-9_-]{22}$/;
|
|
19
|
+
const VIDEO_ID = /^[A-Za-z0-9_-]{11}$/;
|
|
20
|
+
const HANDLE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
21
|
+
const YOUTUBE_HOSTS = new Set(["youtube.com", "www.youtube.com", "m.youtube.com"]);
|
|
22
|
+
|
|
23
|
+
export const FM_STATION_READ_LIMIT = 2_000_000;
|
|
24
|
+
export const FM_STATION_TIMEOUT_MS = 6_000;
|
|
25
|
+
/** How long an answer stands. A station that resolved stays resolved for as
|
|
26
|
+
* long as claude-fm.mjs trusts its own live check; one that did not is asked
|
|
27
|
+
* again sooner, since "not live" is the answer most likely to change. */
|
|
28
|
+
export const FM_STATION_CACHE_MS = 10 * 60_000;
|
|
29
|
+
export const FM_STATION_MISS_CACHE_MS = 60_000;
|
|
30
|
+
/** Answers kept at once. A deck has a handful of stations; the cap only has to
|
|
31
|
+
* stop a caller walking the map up with invented handles. */
|
|
32
|
+
const CACHE_MAX = 32;
|
|
33
|
+
const MAX_REDIRECTS = 5;
|
|
34
|
+
|
|
35
|
+
const BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
|
|
36
|
+
const CANONICAL_VIDEO = /<link\s+rel="canonical"\s+href="https:\/\/www\.youtube\.com\/watch\?v=([A-Za-z0-9_-]{11})"/;
|
|
37
|
+
const EXTERNAL_CHANNEL = /"externalChannelId"\s*:\s*"(UC[A-Za-z0-9_-]{22})"/;
|
|
38
|
+
const CHANNEL_URL = /https:\/\/www\.youtube\.com\/channel\/(UC[A-Za-z0-9_-]{22})/;
|
|
39
|
+
|
|
40
|
+
export function parseYouTubeStationUrl(value) {
|
|
41
|
+
let url;
|
|
42
|
+
try { url = new URL(String(value ?? "").trim()); } catch { return null; }
|
|
43
|
+
if (url.protocol !== "https:") return null;
|
|
44
|
+
if (url.port || url.username || url.password) return null;
|
|
45
|
+
if (!YOUTUBE_HOSTS.has(url.hostname.toLowerCase())) return null;
|
|
46
|
+
|
|
47
|
+
const channel = /^\/channel\/([^/]+)(?:\/live)?\/?$/.exec(url.pathname)?.[1];
|
|
48
|
+
if (channel && CHANNEL_ID.test(channel)) {
|
|
49
|
+
return { kind: "channel", url: `https://www.youtube.com/channel/${channel}/live`, channel };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const handle = /^\/@([^/]+)\/live\/?$/.exec(url.pathname)?.[1];
|
|
53
|
+
if (handle && HANDLE.test(handle)) {
|
|
54
|
+
return { kind: "handle", url: `https://www.youtube.com/@${handle}/live`, handle };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (url.pathname === "/watch") {
|
|
58
|
+
const video = url.searchParams.get("v") ?? "";
|
|
59
|
+
if (VIDEO_ID.test(video)) return { kind: "video", url: `https://www.youtube.com/watch?v=${video}`, video };
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function readYouTubeStationPage(html) {
|
|
65
|
+
const page = typeof html === "string" ? html : "";
|
|
66
|
+
const detailsAt = page.indexOf('"videoDetails"');
|
|
67
|
+
const details = detailsAt < 0 ? "" : page.slice(detailsAt, detailsAt + 24_000);
|
|
68
|
+
const live = /"isLiveContent"\s*:\s*true/.test(details) || /"isLive"\s*:\s*true/.test(details);
|
|
69
|
+
const channel = /"channelId"\s*:\s*"(UC[A-Za-z0-9_-]{22})"/.exec(details)?.[1]
|
|
70
|
+
?? EXTERNAL_CHANNEL.exec(page)?.[1]
|
|
71
|
+
?? CHANNEL_URL.exec(page)?.[1]
|
|
72
|
+
?? null;
|
|
73
|
+
const video = CANONICAL_VIDEO.exec(page)?.[1] ?? null;
|
|
74
|
+
return { live, channel, video };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const _cache = new Map();
|
|
78
|
+
const _inflight = new Map();
|
|
79
|
+
|
|
80
|
+
/** For tests: every answer this process remembers, forgotten. */
|
|
81
|
+
export function forgetFmStations() {
|
|
82
|
+
_cache.clear();
|
|
83
|
+
_inflight.clear();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The channel and video a YouTube station link plays, or why it cannot.
|
|
88
|
+
*
|
|
89
|
+
* CACHED AND SHARED, the way fetchClaudeFm is. Every canvas that mounts with a
|
|
90
|
+
* custom station selected asks this, and four open tabs were four page loads
|
|
91
|
+
* of up to 2MB each from YouTube on every reload; one lookup per link now
|
|
92
|
+
* answers all of them, and a second caller that arrives while the first is
|
|
93
|
+
* still reading waits for that same read.
|
|
94
|
+
*/
|
|
95
|
+
export async function resolveYouTubeStation(value, { fetchImpl, now = Date.now } = {}) {
|
|
96
|
+
const parsed = parseYouTubeStationUrl(value);
|
|
97
|
+
if (!parsed) return { ok: false, error: "unsupported_url" };
|
|
98
|
+
if (parsed.kind === "channel") return { ok: true, channel: parsed.channel, video: null };
|
|
99
|
+
|
|
100
|
+
const key = parsed.url;
|
|
101
|
+
const hit = _cache.get(key);
|
|
102
|
+
if (hit && now() - hit.at < (hit.answer.ok ? FM_STATION_CACHE_MS : FM_STATION_MISS_CACHE_MS)) return hit.answer;
|
|
103
|
+
const pending = _inflight.get(key);
|
|
104
|
+
if (pending) return pending;
|
|
105
|
+
|
|
106
|
+
const lookup = lookUp(parsed, fetchImpl ?? fetch)
|
|
107
|
+
.then(answer => {
|
|
108
|
+
_cache.delete(key);
|
|
109
|
+
_cache.set(key, { at: now(), answer });
|
|
110
|
+
// A Map iterates in insertion order, so the first key is the oldest.
|
|
111
|
+
while (_cache.size > CACHE_MAX) _cache.delete(_cache.keys().next().value);
|
|
112
|
+
return answer;
|
|
113
|
+
})
|
|
114
|
+
.finally(() => { _inflight.delete(key); });
|
|
115
|
+
_inflight.set(key, lookup);
|
|
116
|
+
return lookup;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function lookUp(parsed, get) {
|
|
120
|
+
try {
|
|
121
|
+
// Validate every hop before fetching it. Automatic redirects may contact
|
|
122
|
+
// an arbitrary host before response.url can be inspected (SSRF).
|
|
123
|
+
const signal = AbortSignal.timeout(FM_STATION_TIMEOUT_MS);
|
|
124
|
+
let target = parsed.url;
|
|
125
|
+
let response;
|
|
126
|
+
for (let redirects = 0; ; redirects++) {
|
|
127
|
+
if (!isYouTubeHost(target)) return { ok: false, error: "unresolved" };
|
|
128
|
+
response = await get(target, {
|
|
129
|
+
headers: { "User-Agent": BROWSER_UA, "Accept-Language": "en-US,en;q=0.9" },
|
|
130
|
+
signal,
|
|
131
|
+
redirect: "manual",
|
|
132
|
+
});
|
|
133
|
+
// A custom fetch implementation must not silently follow redirects.
|
|
134
|
+
if (response.url && !isYouTubeHost(response.url)) {
|
|
135
|
+
try { await response.body?.cancel(); } catch { /* already closed */ }
|
|
136
|
+
return { ok: false, error: "unresolved" };
|
|
137
|
+
}
|
|
138
|
+
if (![301, 302, 303, 307, 308].includes(response.status)) break;
|
|
139
|
+
const location = response.headers?.get("location");
|
|
140
|
+
try { await response.body?.cancel(); } catch { /* already closed */ }
|
|
141
|
+
if (!location || redirects >= MAX_REDIRECTS) return { ok: false, error: "unresolved" };
|
|
142
|
+
try { target = new URL(location, target).href; }
|
|
143
|
+
catch { return { ok: false, error: "unresolved" }; }
|
|
144
|
+
}
|
|
145
|
+
if (!response.ok) return { ok: false, error: `youtube_${response.status}` };
|
|
146
|
+
const marks = readYouTubeStationPage(await readBounded(response));
|
|
147
|
+
if (parsed.kind === "video" && !marks.live) return { ok: false, error: "not_live" };
|
|
148
|
+
if (parsed.kind === "handle" && !marks.live && !marks.video) return { ok: false, error: "not_live" };
|
|
149
|
+
if (marks.channel) return { ok: true, channel: marks.channel, video: marks.video };
|
|
150
|
+
if (marks.video) return { ok: true, channel: null, video: marks.video };
|
|
151
|
+
if (parsed.kind === "video") return { ok: true, channel: null, video: parsed.video };
|
|
152
|
+
return { ok: false, error: "unresolved" };
|
|
153
|
+
} catch {
|
|
154
|
+
// Offline, DNS, a timeout: one answer, because the canvas does the same
|
|
155
|
+
// thing with all of them, and the error text is the operator's, not the
|
|
156
|
+
// page's — see the note above index.mjs's `guard`.
|
|
157
|
+
return { ok: false, error: "unreachable" };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function isYouTubeHost(href) {
|
|
162
|
+
try {
|
|
163
|
+
const url = new URL(href);
|
|
164
|
+
const host = url.hostname.toLowerCase();
|
|
165
|
+
return url.protocol === "https:" && !url.username && !url.password && !url.port
|
|
166
|
+
&& (host === "youtube.com" || host.endsWith(".youtube.com"));
|
|
167
|
+
} catch { return false; }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function readBounded(response, limit = FM_STATION_READ_LIMIT) {
|
|
171
|
+
if (!response.body?.getReader) return (await response.text()).slice(0, limit);
|
|
172
|
+
const reader = response.body.getReader();
|
|
173
|
+
const decoder = new TextDecoder();
|
|
174
|
+
let out = "";
|
|
175
|
+
try {
|
|
176
|
+
while (out.length < limit) {
|
|
177
|
+
const { done, value } = await reader.read();
|
|
178
|
+
if (done) break;
|
|
179
|
+
out += decoder.decode(value, { stream: true });
|
|
180
|
+
if (out.includes('"isLive":true') && out.includes('rel="canonical"') && out.includes('"channelId"')) break;
|
|
181
|
+
}
|
|
182
|
+
} finally {
|
|
183
|
+
try { await reader.cancel(); } catch { /* connection already closed */ }
|
|
184
|
+
}
|
|
185
|
+
return out.slice(0, limit);
|
|
186
|
+
}
|