seekmux 0.0.0-stage → 0.0.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/dist/cli.js ADDED
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ // npx seekmux --server https://seekmux.example.com --token <worker token>
3
+ import { readFileSync } from "node:fs";
4
+ import { parseArgs } from "node:util";
5
+ import { endpoint, run } from "./worker.js";
6
+ const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
7
+ const usage = `seekmux ${version} — read pages for a SeekMux gateway in your logged-in Chrome
8
+
9
+ Usage:
10
+ npx seekmux --server <url> --token <token> [--slots 1]
11
+
12
+ Options:
13
+ --server The gateway's address, e.g. https://seekmux.example.com
14
+ (env SEEKMUX_SERVER)
15
+ --token This worker's token, from its fetcher on the gateway's
16
+ Self-hosted fetchers page (env SEEKMUX_WORKER_TOKEN)
17
+ --slots Pages read at once, 1 or 2 (default 1)
18
+ --help Show this help
19
+ --version Show the version
20
+
21
+ Chrome needs the OpenCLI extension (Browser Bridge) installed and enabled:
22
+ https://github.com/jackwener/opencli#2-install-the-browser-bridge-extension
23
+ `;
24
+ function log(line) {
25
+ const time = new Date().toLocaleTimeString("en-GB", { hour12: false });
26
+ console.log(`${time} ${line}`);
27
+ }
28
+ async function main() {
29
+ const { values } = parseArgs({
30
+ options: {
31
+ server: { type: "string" },
32
+ token: { type: "string" },
33
+ slots: { type: "string" },
34
+ help: { type: "boolean", short: "h" },
35
+ version: { type: "boolean", short: "v" },
36
+ },
37
+ });
38
+ if (values.help) {
39
+ process.stdout.write(usage);
40
+ return;
41
+ }
42
+ if (values.version) {
43
+ console.log(version);
44
+ return;
45
+ }
46
+ const server = values.server ?? process.env.SEEKMUX_SERVER;
47
+ const token = values.token ?? process.env.SEEKMUX_WORKER_TOKEN;
48
+ if (!server || !token) {
49
+ process.stderr.write(usage);
50
+ process.exitCode = 2;
51
+ return;
52
+ }
53
+ const slots = Number(values.slots ?? 1);
54
+ if (!Number.isInteger(slots) || slots < 1 || slots > 2) {
55
+ console.error("--slots must be 1 or 2: the worker drives a single browser");
56
+ process.exitCode = 2;
57
+ return;
58
+ }
59
+ log(`seekmux worker ${version}`);
60
+ await run({ url: endpoint(server), token, slots, version, log });
61
+ }
62
+ main().catch((err) => {
63
+ log(`stopped: ${err instanceof Error ? err.message : String(err)}`);
64
+ process.exit(1);
65
+ });
@@ -0,0 +1,202 @@
1
+ // Runs OpenCLI as a child process and turns what it prints into pages,
2
+ // states and errors. OpenCLI is a dependency of this package, so the version
3
+ // it runs is the one this package was tested with.
4
+ import { spawn } from "node:child_process";
5
+ import { readFileSync } from "node:fs";
6
+ import { fileURLToPath } from "node:url";
7
+ import { FetchError } from "./protocol.js";
8
+ const main = fileURLToPath(import.meta.resolve("@jackwener/opencli"));
9
+ /** The version of OpenCLI this worker runs. */
10
+ export const openCLIVersion = (() => {
11
+ try {
12
+ const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.resolve("@jackwener/opencli")), "utf8"));
13
+ return String(pkg.version);
14
+ }
15
+ catch {
16
+ return "";
17
+ }
18
+ })();
19
+ /** Thrown when the caller canceled the fetch; nothing is reported for it. */
20
+ export class Canceled extends Error {
21
+ }
22
+ function run(args, opts) {
23
+ return new Promise((resolve, reject) => {
24
+ if (opts.signal?.aborted)
25
+ return reject(new Canceled("canceled"));
26
+ const env = {
27
+ ...process.env,
28
+ // OpenCLI gives up on a browser step after this many seconds.
29
+ OPENCLI_BROWSER_COMMAND_TIMEOUT: String(Math.max(5, Math.floor(opts.timeoutMs / 1000))),
30
+ // Close the tab a read opened, so tabs do not pile up in the user's Chrome.
31
+ OPENCLI_CLOSE_OWNED_TAB: "1",
32
+ };
33
+ if (opts.profile)
34
+ env.OPENCLI_PROFILE = opts.profile;
35
+ const child = spawn(process.execPath, [main, ...args], { env, stdio: ["ignore", "pipe", "pipe"] });
36
+ let stdout = "";
37
+ let stderr = "";
38
+ child.stdout.setEncoding("utf8").on("data", (chunk) => (stdout += chunk));
39
+ child.stderr.setEncoding("utf8").on("data", (chunk) => (stderr += chunk));
40
+ // A canceled read is left to finish: OpenCLI closes the tab it opened
41
+ // and releases its hold on the site only when the command ends, and has
42
+ // no signal handler to do it when killed. The slot stays taken until
43
+ // then. Killing is kept for the deadline, after which OpenCLI reclaims
44
+ // the hold by itself (within 45s).
45
+ let ended;
46
+ const timer = setTimeout(() => {
47
+ ended ??= "timeout";
48
+ child.kill("SIGTERM");
49
+ setTimeout(() => child.kill("SIGKILL"), 2000).unref();
50
+ }, opts.timeoutMs);
51
+ const onAbort = () => {
52
+ ended ??= "canceled";
53
+ };
54
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
55
+ child.on("error", (err) => {
56
+ clearTimeout(timer);
57
+ opts.signal?.removeEventListener("abort", onAbort);
58
+ reject(new FetchError("internal", `could not start OpenCLI: ${err.message}`));
59
+ });
60
+ child.on("close", (code) => {
61
+ clearTimeout(timer);
62
+ opts.signal?.removeEventListener("abort", onAbort);
63
+ if (ended === "canceled")
64
+ return reject(new Canceled("canceled"));
65
+ if (ended === "timeout")
66
+ return reject(new FetchError("timeout", `OpenCLI did not finish within ${Math.round(opts.timeoutMs / 1000)}s`));
67
+ resolve({ stdout, stderr, code });
68
+ });
69
+ });
70
+ }
71
+ /** Finds the JSON envelope OpenCLI printed, on either stream. */
72
+ function envelope(out) {
73
+ for (const text of [out.stdout, out.stderr]) {
74
+ const start = text.indexOf("{");
75
+ if (start < 0)
76
+ continue;
77
+ try {
78
+ const parsed = JSON.parse(text.slice(start));
79
+ if (typeof parsed === "object" && parsed && "ok" in parsed)
80
+ return parsed;
81
+ }
82
+ catch {
83
+ // Not JSON, or not only JSON: try the other stream.
84
+ }
85
+ }
86
+ return undefined;
87
+ }
88
+ /** Maps an OpenCLI failure to the code the gateway acts on. */
89
+ function failure(out) {
90
+ const env = envelope(out);
91
+ const code = env?.error?.code ?? "";
92
+ const message = [env?.error?.message, env?.error?.help].filter(Boolean).join(" — ") || lastLine(out.stderr) || `OpenCLI exited with ${out.code}`;
93
+ const map = {
94
+ AUTH_REQUIRED: "login_required",
95
+ // An HTML page where JSON was expected: a login wall, a rate limit or a
96
+ // WAF challenge, OpenCLI cannot tell which. The message says as much.
97
+ LOGIN_WALL: "blocked",
98
+ EMPTY_RESULT: "empty",
99
+ TIMEOUT: "timeout",
100
+ SESSION_BUSY: "busy",
101
+ BROWSER_CONNECT: "browser_unavailable",
102
+ };
103
+ return new FetchError(map[code] ?? "failed", message);
104
+ }
105
+ /**
106
+ * Says what is wrong with the profile choice, by OpenCLI's own rule: a
107
+ * chosen profile must be connected; without one, the default is used, else
108
+ * the only connected profile. It mirrors State.Route on the gateway.
109
+ */
110
+ export function profileProblem(profiles, chosen) {
111
+ const connected = profiles.filter((p) => p.connected);
112
+ if (chosen) {
113
+ if (!profiles.some((p) => p.id === chosen))
114
+ return new FetchError("profile_missing", `OpenCLI on the worker does not know the Chrome profile "${chosen}"`);
115
+ if (!connected.some((p) => p.id === chosen))
116
+ return new FetchError("browser_unavailable", `the Chrome profile "${chosen}" is not open on the worker`);
117
+ return undefined;
118
+ }
119
+ if (connected.length > 1 && !connected.some((p) => p.default)) {
120
+ return new FetchError("profile_missing", `${connected.length} Chrome profiles are connected and none is chosen: choose one for OpenCLI in the console`);
121
+ }
122
+ if (connected.length === 0)
123
+ return new FetchError("browser_unavailable", "no Chrome profile is connected to OpenCLI on the worker");
124
+ return undefined;
125
+ }
126
+ /**
127
+ * Turns a failed run into its error. When OpenCLI could not reach the
128
+ * browser, the profiles are read again and judged by profileProblem, rather
129
+ * than by the wording of OpenCLI's message.
130
+ */
131
+ async function failed(out, opts) {
132
+ const err = failure(out);
133
+ if (err.code !== "browser_unavailable" || opts.builtin)
134
+ return err;
135
+ const profiles = await listProfiles().catch(() => undefined);
136
+ return (profiles && profileProblem(profiles, opts.profile)) ?? err;
137
+ }
138
+ function lastLine(text) {
139
+ const lines = text.trim().split("\n");
140
+ return lines[lines.length - 1]?.trim() ?? "";
141
+ }
142
+ /**
143
+ * Runs an OpenCLI command and returns the data of the JSON it prints. Site
144
+ * commands are asked for JSON; built-in ones such as doctor print only JSON
145
+ * and refuse the flag (format: false).
146
+ */
147
+ export async function json(args, opts, format = true) {
148
+ const out = await run(format ? [...args, "-f", "json"] : args, opts);
149
+ const env = envelope(out);
150
+ if (out.code !== 0 || !env?.ok)
151
+ throw await failed(out, opts);
152
+ return env.data;
153
+ }
154
+ /** Runs an OpenCLI command that prints its result as it is. */
155
+ export async function text(args, opts) {
156
+ const out = await run(args, opts);
157
+ if (out.code !== 0)
158
+ throw await failed(out, opts);
159
+ return out.stdout;
160
+ }
161
+ /** The Chrome profiles OpenCLI knows: connected ones, and those it has an alias or default for. */
162
+ async function listProfiles() {
163
+ const list = (await json(["profile", "list"], { timeoutMs: 30_000, builtin: true }, false));
164
+ return (list.profiles ?? []).map((p) => ({
165
+ id: p.contextId,
166
+ ...(p.alias ? { alias: p.alias } : {}),
167
+ ...(p.default ? { default: true } : {}),
168
+ connected: Boolean(p.connected),
169
+ }));
170
+ }
171
+ /**
172
+ * Asks OpenCLI whether it reaches a browser, and which profiles it knows.
173
+ *
174
+ * The worker is ready while a Chrome profile is connected. Which profile a
175
+ * read uses is the gateway's choice, sent with each fetch, so readiness does
176
+ * not follow doctor's verdict: doctor judges by OpenCLI's local default and
177
+ * reports several connected profiles without one as not connected.
178
+ */
179
+ export async function readState() {
180
+ let doctorProblem;
181
+ try {
182
+ // doctor starts the daemon when it is not running.
183
+ const doctor = (await json(["doctor"], { timeoutMs: 30_000, builtin: true }, false));
184
+ if (!doctor.extensionConnected || !doctor.connectivity?.ok) {
185
+ doctorProblem = doctor.connectivity?.error ?? doctor.issues?.[0]?.split("\n")[0];
186
+ }
187
+ }
188
+ catch (err) {
189
+ doctorProblem = err instanceof Error ? err.message : String(err);
190
+ }
191
+ let profiles = [];
192
+ let listProblem;
193
+ try {
194
+ profiles = await listProfiles();
195
+ }
196
+ catch (err) {
197
+ listProblem = err instanceof Error ? err.message : String(err);
198
+ }
199
+ const ready = profiles.some((p) => p.connected);
200
+ const problem = ready ? undefined : (doctorProblem ?? listProblem ?? "no Chrome profile is connected to OpenCLI");
201
+ return { ready, ...(problem ? { problem } : {}), opencli: openCLIVersion, profiles };
202
+ }
@@ -0,0 +1,22 @@
1
+ // The frames exchanged with the gateway. internal/worker/protocol.go is the
2
+ // other side and describes the exchange; change both together.
3
+ export const PROTOCOL_VERSION = 1;
4
+ /** Close codes the gateway ends a connection with. */
5
+ export const CloseCode = {
6
+ Protocol: 4400,
7
+ Unauthorized: 4401,
8
+ Replaced: 4409,
9
+ };
10
+ /** A failed fetch, with the code the gateway acts on. */
11
+ export class FetchError extends Error {
12
+ code;
13
+ status;
14
+ constructor(code, message, status) {
15
+ super(message);
16
+ this.code = code;
17
+ this.status = status;
18
+ }
19
+ toWire() {
20
+ return { code: this.code, message: this.message, ...(this.status ? { status: this.status } : {}) };
21
+ }
22
+ }
package/dist/sites.js ADDED
@@ -0,0 +1,142 @@
1
+ // Reads a page: through a site's OpenCLI adapter when the gateway asked for
2
+ // one and the URL is a post the adapter reads, otherwise as the page renders.
3
+ //
4
+ // Rendering stays close to what OpenCLI returns. It only arranges the rows
5
+ // into Markdown an agent reads in order; it does not try to clean the text,
6
+ // which would risk dropping what the page says.
7
+ import { json, text } from "./opencli.js";
8
+ import { FetchError } from "./protocol.js";
9
+ /** The adapters this worker has, by the gateway's routing rule ids. */
10
+ export const adapters = ["twitter", "reddit"];
11
+ export function match(raw) {
12
+ let url;
13
+ try {
14
+ url = new URL(raw);
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ const host = url.hostname.toLowerCase().replace(/^(www|mobile|m)\./, "");
20
+ const path = url.pathname;
21
+ if (host === "x.com" || host === "twitter.com") {
22
+ const id = /^\/(?:[^/]+|i\/web|i)\/status(?:es)?\/(\d+)/.exec(path)?.[1];
23
+ return id ? { site: "twitter", id } : undefined;
24
+ }
25
+ if (host === "redd.it") {
26
+ const id = /^\/([a-z0-9]+)\/?$/i.exec(path)?.[1];
27
+ return id ? { site: "reddit", target: id } : undefined;
28
+ }
29
+ if (host === "reddit.com" || host.endsWith(".reddit.com")) {
30
+ return /\/comments\/[a-z0-9]+/i.test(path) ? { site: "reddit", target: url.origin + path } : undefined;
31
+ }
32
+ return undefined;
33
+ }
34
+ export async function read(url, adapter, opts) {
35
+ const site = adapter ? match(url) : undefined;
36
+ switch (site?.site) {
37
+ case "twitter":
38
+ return readTweet(url, site.id, opts);
39
+ case "reddit":
40
+ return readReddit(url, site.target, opts);
41
+ }
42
+ return readPage(url, opts);
43
+ }
44
+ async function readPage(url, opts) {
45
+ const markdown = await text(["web", "read", "--url", url, "--stdout", "true", "--download-images", "false"], opts);
46
+ if (!markdown.trim())
47
+ throw new FetchError("empty", "the page rendered without any text");
48
+ const title = /^# (.+)$/m.exec(markdown)?.[1]?.trim();
49
+ return { url, ...(title && title !== "untitled" ? { title } : {}), content: markdown, source: "page" };
50
+ }
51
+ async function readTweet(url, id, opts) {
52
+ const rows = (await json(["twitter", "thread", id, "--limit", "100"], opts));
53
+ if (!Array.isArray(rows) || rows.length === 0)
54
+ throw new FetchError("empty", "the thread came back empty");
55
+ const main = rows.find((t) => t.id === id) ?? rows[0];
56
+ // Replies in the order they were written, so a thread split into several
57
+ // posts reads from its start.
58
+ const replies = rows.filter((t) => t !== main).sort((a, b) => time(a) - time(b));
59
+ const authors = new Map(rows.map((t) => [t.id, t.author]));
60
+ const out = [`# @${main.author} on X`, "", tweetMeta(main), "", unescape(main.text)];
61
+ if (main.media_urls?.length)
62
+ out.push("", ...main.media_urls.map((u) => `![](${u})`));
63
+ if (main.quoted_tweet) {
64
+ const q = main.quoted_tweet;
65
+ out.push("", `> Quoting @${q.author}${q.name ? ` (${q.name})` : ""}${q.url ? ` — ${q.url}` : ""}`, ">");
66
+ out.push(...unescape(q.text).split("\n").map((line) => `> ${line}`));
67
+ }
68
+ if (replies.length) {
69
+ out.push("", `## Replies (${replies.length})`);
70
+ for (const r of replies) {
71
+ const to = r.in_reply_to && r.in_reply_to !== main.id && authors.has(r.in_reply_to) ? ` · replying to @${authors.get(r.in_reply_to)}` : "";
72
+ out.push("", `**@${r.author}**${to} · ${tweetMeta(r, true)}`, "", unescape(r.text));
73
+ if (r.media_urls?.length)
74
+ out.push(...r.media_urls.map((u) => `![](${u})`));
75
+ }
76
+ }
77
+ const first = unescape(main.text).split("\n")[0].slice(0, 80);
78
+ return { url: main.url ?? url, title: `@${main.author} on X: ${first}`, content: out.join("\n") + "\n", source: "twitter" };
79
+ }
80
+ function time(t) {
81
+ const ms = t.created_at ? Date.parse(t.created_at) : NaN;
82
+ return Number.isNaN(ms) ? 0 : ms;
83
+ }
84
+ function tweetMeta(t, short = false) {
85
+ const parts = [];
86
+ if (!short && t.name)
87
+ parts.push(t.name);
88
+ const ms = time(t);
89
+ if (ms)
90
+ parts.push(new Date(ms).toISOString().replace("T", " ").slice(0, 16) + " UTC");
91
+ if (t.likes)
92
+ parts.push(`${t.likes} likes`);
93
+ if (!short && t.retweets)
94
+ parts.push(`${t.retweets} reposts`);
95
+ if (!short && t.views)
96
+ parts.push(`${t.views} views`);
97
+ if (!short && t.url)
98
+ parts.push(t.url);
99
+ return parts.join(" · ");
100
+ }
101
+ /** X sends post text HTML-escaped. */
102
+ function unescape(s) {
103
+ return s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&");
104
+ }
105
+ async function readReddit(url, target, opts) {
106
+ const args = ["reddit", "read", target, "--limit", "50", "--depth", "4", "--replies", "10", "--expand-more", "true"];
107
+ const rows = (await json(args, opts));
108
+ const post = Array.isArray(rows) ? rows.find((r) => r.type === "POST") : undefined;
109
+ if (!post)
110
+ throw new FetchError("empty", "the post came back empty");
111
+ // The post's text is its title, a blank line, and its body.
112
+ const [title, ...body] = post.text.split("\n");
113
+ const out = [`# ${title}`, "", `u/${post.author} · ${post.score} points`];
114
+ const link = post.url_overridden_by_dest;
115
+ if (link && link !== url)
116
+ out.push("", `Link: ${link}`);
117
+ const bodyText = body.join("\n").trim();
118
+ if (bodyText)
119
+ out.push("", bodyText);
120
+ for (const image of [post.preview_image_url, ...(post.gallery_urls ?? [])])
121
+ if (image)
122
+ out.push("", `![](${image})`);
123
+ const comments = rows.filter((r) => r !== post);
124
+ if (comments.length)
125
+ out.push("", "## Comments", "");
126
+ for (const row of comments) {
127
+ const level = /^L(\d+)$/.exec(row.type) ? Number(row.type.slice(1)) : 0;
128
+ const indent = " ".repeat(level);
129
+ // OpenCLI indents a reply and quotes it with "> " for a table; the list
130
+ // nesting says the same, so the prefix comes off and the text stays.
131
+ const prefix = " ".repeat(level) + (level > 0 ? "> " : "");
132
+ const lines = row.text.split("\n").map((line) => (line.startsWith(prefix) ? line.slice(prefix.length) : line.trimStart().replace(/^> ?/, "")));
133
+ if (!row.author) {
134
+ out.push(`${indent}- *${lines.join(" ").trim()}*`);
135
+ continue;
136
+ }
137
+ out.push(`${indent}- **u/${row.author}** (${row.score} points)`);
138
+ for (const line of lines)
139
+ out.push(line ? `${indent} ${line}` : "");
140
+ }
141
+ return { url, title, content: out.join("\n") + "\n", source: "reddit" };
142
+ }
package/dist/worker.js ADDED
@@ -0,0 +1,139 @@
1
+ // The connection to the gateway: says hello, reports the browser's state,
2
+ // runs the fetches it is handed and reconnects when the connection drops.
3
+ import { Canceled, readState } from "./opencli.js";
4
+ import { CloseCode, FetchError, PROTOCOL_VERSION } from "./protocol.js";
5
+ import { read } from "./sites.js";
6
+ /** How often the browser's state is checked and sent, which also keeps the connection in use. */
7
+ const STATE_INTERVAL = 30_000;
8
+ /** Turns what the user typed into the URL of the gateway's worker endpoint. */
9
+ export function endpoint(server) {
10
+ const url = new URL(/^[a-z]+:\/\//i.test(server) ? server : `https://${server}`);
11
+ url.protocol = url.protocol === "http:" || url.protocol === "ws:" ? "ws:" : "wss:";
12
+ if (url.pathname === "/" || url.pathname === "")
13
+ url.pathname = "/worker";
14
+ url.search = "";
15
+ url.hash = "";
16
+ return url.toString();
17
+ }
18
+ /** Runs until the gateway refuses the worker for good. */
19
+ export async function run(opts) {
20
+ let state = await readState();
21
+ logState(opts, state);
22
+ let delay = 1000;
23
+ for (;;) {
24
+ const { code, reason, welcomed } = await session(opts, state, (next) => (state = next));
25
+ if (code === CloseCode.Unauthorized || code === CloseCode.Protocol) {
26
+ throw new Error(`the gateway refused this worker: ${reason || code}`);
27
+ }
28
+ if (code === CloseCode.Replaced) {
29
+ throw new Error("another worker connected with the same token; this one stops so the two do not take turns");
30
+ }
31
+ if (welcomed)
32
+ delay = 1000;
33
+ opts.log(`disconnected (${reason || code}); reconnecting in ${delay / 1000}s`);
34
+ await new Promise((resolve) => setTimeout(resolve, delay));
35
+ delay = Math.min(delay * 2, 30_000);
36
+ }
37
+ }
38
+ function logState(opts, state) {
39
+ const profiles = state.profiles.map((p) => `${p.alias ?? p.id}${p.connected ? "" : " (closed)"}`).join(", ") || "none";
40
+ opts.log(state.ready ? `browser ready · OpenCLI ${state.opencli} · profiles: ${profiles}` : `browser not ready: ${state.problem}`);
41
+ }
42
+ function session(opts, initial, setState) {
43
+ return new Promise((resolve) => {
44
+ let state = initial;
45
+ let welcomed = false;
46
+ const running = new Map();
47
+ const ws = new WebSocket(opts.url);
48
+ const send = (m) => {
49
+ if (ws.readyState === WebSocket.OPEN)
50
+ ws.send(JSON.stringify(m));
51
+ };
52
+ // Re-read the state now and then, and right after a fetch found the
53
+ // browser unreachable; send it when it changed.
54
+ let checking = false;
55
+ const check = async () => {
56
+ if (checking)
57
+ return;
58
+ checking = true;
59
+ try {
60
+ const next = await readState();
61
+ const changed = JSON.stringify(next) !== JSON.stringify(state);
62
+ state = next;
63
+ setState(next);
64
+ if (changed)
65
+ logState(opts, next);
66
+ // Sent even unchanged: it keeps the connection in use.
67
+ send({ type: "state", state: next });
68
+ }
69
+ finally {
70
+ checking = false;
71
+ }
72
+ };
73
+ const timer = setInterval(check, STATE_INTERVAL);
74
+ ws.addEventListener("open", () => {
75
+ send({ type: "hello", protocol: PROTOCOL_VERSION, token: opts.token, version: opts.version, slots: opts.slots, state });
76
+ });
77
+ ws.addEventListener("message", async (event) => {
78
+ let m;
79
+ try {
80
+ m = JSON.parse(String(event.data));
81
+ }
82
+ catch {
83
+ return;
84
+ }
85
+ switch (m.type) {
86
+ case "welcome":
87
+ welcomed = true;
88
+ opts.log(`connected to ${opts.url} · ${opts.slots} slot${opts.slots > 1 ? "s" : ""}`);
89
+ return;
90
+ case "cancel":
91
+ running.get(m.id)?.abort();
92
+ return;
93
+ case "fetch":
94
+ break;
95
+ default:
96
+ return;
97
+ }
98
+ const { id, url } = m;
99
+ if (running.size >= opts.slots) {
100
+ send({ type: "result", id, error: { code: "busy", message: `the worker is already running ${running.size} fetches` } });
101
+ return;
102
+ }
103
+ const controller = new AbortController();
104
+ running.set(id, controller);
105
+ const started = Date.now();
106
+ opts.log(`→ ${url}${m.adapter ? " (adapter)" : ""}`);
107
+ try {
108
+ const page = await read(url, Boolean(m.adapter), { timeoutMs: m.timeout_ms, signal: controller.signal, profile: m.profile || undefined });
109
+ send({ type: "result", id, page });
110
+ opts.log(`✓ ${url} · ${page.source} · ${page.content.length} chars · ${seconds(started)}`);
111
+ }
112
+ catch (err) {
113
+ if (err instanceof Canceled) {
114
+ opts.log(`· ${url} · canceled by the gateway · ${seconds(started)}`);
115
+ return;
116
+ }
117
+ const failure = err instanceof FetchError ? err : new FetchError("internal", err instanceof Error ? err.message : String(err));
118
+ send({ type: "result", id, error: failure.toWire() });
119
+ opts.log(`✗ ${url} · ${failure.code}: ${failure.message} · ${seconds(started)}`);
120
+ if (failure.code === "browser_unavailable" || failure.code === "profile_missing")
121
+ void check();
122
+ }
123
+ finally {
124
+ running.delete(id);
125
+ }
126
+ });
127
+ ws.addEventListener("close", (event) => {
128
+ clearInterval(timer);
129
+ for (const controller of running.values())
130
+ controller.abort();
131
+ resolve({ code: event.code, reason: event.reason, welcomed });
132
+ });
133
+ // An error is followed by a close event, which ends the session.
134
+ ws.addEventListener("error", () => { });
135
+ });
136
+ }
137
+ function seconds(started) {
138
+ return `${((Date.now() - started) / 1000).toFixed(1)}s`;
139
+ }
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "seekmux",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.1",
4
+ "description": "SeekMux worker: reads pages for a SeekMux gateway in your logged-in Chrome, through OpenCLI",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/fl0w1nd/seekmux.git",
9
+ "directory": "worker"
10
+ },
11
+ "type": "module",
12
+ "bin": {
13
+ "seekmux": "dist/cli.js"
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "engines": {
19
+ "node": ">=22.4"
20
+ },
21
+ "scripts": {
22
+ "build": "tsc -p .",
23
+ "typecheck": "tsc -p . --noEmit",
24
+ "start": "node src/cli.ts",
25
+ "prepack": "tsc -p ."
26
+ },
27
+ "dependencies": {
28
+ "@jackwener/opencli": "^2.0.2"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^26.6.6",
32
+ "typescript": "^7.0.2"
33
+ }
34
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.