Add the WebSocket and mDNS support Home Assistant needs (#901)

* Run extensions that speak WebSocket

A bundled `ws` handshakes through `http.request` and wants a raw socket back,
which the fetch-backed shim had none of. Sockets are `URLSessionWebSocketTask`
now, and the upgrade path hands `ws` one that re-frames RFC 6455 both ways.
Home Assistant is the reference case.

* Resolve a .local host without joining a multicast group

Home Assistant's default `homeassistant.local` is resolved by the extension
itself with multicast-dns, which died on `dgram.createSocket`. `dgram` now
answers an address query out of `getaddrinfo` — mDNSResponder handles
`.local` — so nothing multicasts and no entitlement is needed.

* Harden the WebSocket and dgram shims

A rejected host call poisoned the send queue, so every later send and the
close after it rejected too; the queue now recovers while the caller still
sees the failure. A ping is answered by the peer through
`URLSessionWebSocketTask`, not by a pong the adapter invents. An upgrade no
listener claims destroys the socket, the way Node does, so the native task
goes with it. `dgram` answers address questions only. And a socket id that
is not a whole number falls back instead of trapping.
This commit is contained in:
Jonas List
2026-09-19 03:40:08 +06:00
committed by GitHub
parent eb32d8fe4a
commit ca3da2ee4e
15 changed files with 1007 additions and 29 deletions
+144
View File
@@ -394,6 +394,87 @@ export default async function Command() {
// Hide My Email hands axios a cookie jar through axios-cookiejar-support, whose http-cookie-agent
// extends `http.Agent` at load time and hooks each request in `addRequest` — the same way this does.
// A bundled `ws` reaches the network the way this does: upgrade, then raw frames on the socket.
// multicast-dns drives `dgram` the way this does, down to the packet it writes.
const dgramSource = `
import dgram from "node:dgram";
function query(name, type) {
const labels = name.split(".");
const packet = Buffer.alloc(12 + labels.reduce((total, label) => total + label.length + 1, 1) + 4);
packet.writeUInt16BE(0x1234, 0);
packet.writeUInt16BE(1, 4);
let offset = 12;
for (const label of labels) {
packet[offset] = label.length;
packet.write(label, offset + 1);
offset += label.length + 1;
}
packet.writeUInt16BE(type, offset + 1);
packet.writeUInt16BE(1, offset + 3);
return packet;
}
export default async function Command() {
const socket = dgram.createSocket({ type: "udp4" });
globalThis.__dgram = await new Promise((resolve) => {
socket.on("message", (message, rinfo) => resolve({ hex: message.toString("hex"), port: rinfo.port }));
socket.bind(5353, undefined, () => {
const service = query("_services._dns-sd._udp.local", 12);
socket.send(service, 0, service.length, 5353, "224.0.0.251");
const packet = query("homeassistant.local", 1);
socket.send(packet, 0, packet.length, 5353, "224.0.0.251");
});
});
}
`;
const websocketSource = `
import https from "node:https";
export default async function Command() {
globalThis.__ws = await new Promise((resolve, reject) => {
const request = https.request({
host: "example.test",
path: "/socket",
headers: {
Connection: "Upgrade",
Upgrade: "websocket",
"Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==",
"Sec-WebSocket-Version": "13",
"Sec-WebSocket-Extensions": "permessage-deflate",
},
});
request.on("error", reject);
request.on("upgrade", (response, socket) => {
const frames = [];
socket.on("data", (chunk) => frames.push(chunk.toString("hex")));
const payload = Buffer.from("ping", "utf8");
const mask = Buffer.from([1, 2, 3, 4]);
socket.write(
Buffer.concat([
Buffer.from([0x81, 0x80 | payload.length]),
mask,
Buffer.from(payload.map((byte, index) => byte ^ mask[index % 4])),
]),
);
socket.write(Buffer.from([0x89, 0x80, 1, 2, 3, 4]));
setTimeout(
() =>
resolve({
status: response.statusCode,
accept: response.headers["sec-websocket-accept"],
extensions: response.headers["sec-websocket-extensions"] ?? null,
frames,
}),
40,
);
});
request.end();
});
}
`;
const cookieAgentSource = `
import * as http from "node:http";
import * as url from "node:url";
@@ -887,6 +968,69 @@ export async function runFixtures() {
},
);
const socketOpens = [];
const lookups = [];
await run(
"dgram answers an mDNS query out of the resolver",
dgramSource,
"no-view",
async (harness) => {
const result = harness.call("globalThis.__dgram");
check("resolves the name the query asked for", lookups[0] === "homeassistant.local", String(lookups[0]));
check("leaves a service question alone", lookups.length === 1, JSON.stringify(lookups));
check("answers the query it was sent", result?.hex?.startsWith("123484000001000100000000"), String(result?.hex));
check("names the host in the answer", result?.hex?.includes("0d686f6d65617373697374616e74056c6f63616c00"), String(result?.hex));
check("carries the address as an A record", result?.hex?.endsWith("00010001000000780004c0a801e2"), String(result?.hex));
},
{
stubs: {
"dns.resolve": (args) => {
lookups.push(args[0]);
return ["192.168.1.226"];
},
},
},
);
const socketSends = [];
let socketReads = 0;
let socketPings = 0;
await run(
"a websocket upgrade hands back a socket that frames both ways",
websocketSource,
"no-view",
async (harness) => {
const result = harness.call("globalThis.__ws");
check("opens the native socket over wss", socketOpens[0]?.url === "wss://example.test/socket", JSON.stringify(socketOpens[0]?.url));
check("drops the handshake headers", socketOpens[0]?.headers?.upgrade === undefined, JSON.stringify(socketOpens[0]?.headers));
check("reports the upgrade", result?.status === 101, String(result?.status));
check("answers the key the way a server would", result?.accept === "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=", String(result?.accept));
check("never accepts an extension", result?.extensions === null, String(result?.extensions));
check("unmasks an outgoing frame", socketSends[0]?.text === "ping", JSON.stringify(socketSends[0]));
check("frames an incoming message", result?.frames?.[0] === "8104706f6e67", JSON.stringify(result?.frames));
check("asks the peer before answering a ping", socketPings === 1, String(socketPings));
check("pongs once the peer answered", result?.frames?.includes("8a00"), JSON.stringify(result?.frames));
},
{
stubs: {
"websocket.open": (args) => {
socketOpens.push(args[0]);
return { id: 7, protocol: "" };
},
"websocket.send": (args) => {
socketSends.push(args[0]);
return null;
},
"websocket.ping": () => {
socketPings++;
return null;
},
// The second read never settles, which is what an idle socket looks like from JS.
"websocket.receive": () => (socketReads++ === 0 ? { type: "text", text: "pong" } : new Promise(() => {})),
},
},
);
const cookieSpecs = [];
const cookies = ["a=1; Expires=Wed, 21 Oct 2037 07:28:00 GMT; Path=/", "b=2; Path=/"];
await run(
+151
View File
@@ -0,0 +1,151 @@
// `dgram` exists for one case: multicast-dns resolving a `.local` host. The socket never reaches the
// network — mDNSResponder already answers those, so the query goes to the system resolver instead.
import { Buffer } from "./buffer.js";
import { EventEmitter } from "./events.js";
import { hostCall } from "./host.js";
const MDNS_PORT = 5353;
const A_RECORD = 1;
const ANY_RECORD = 255;
const IN_CLASS = 1;
class NameLookupSocket extends EventEmitter {
constructor() {
super();
this.port = MDNS_PORT;
this.closed = false;
}
bind(port, address, callback) {
if (typeof port === "function") return this.bind(undefined, undefined, port);
if (typeof address === "function") return this.bind(port, undefined, address);
if (typeof port === "number") this.port = port;
if (callback) this.once("listening", callback);
setTimeout(() => this.emit("listening"), 0);
return this;
}
address() {
return { address: "0.0.0.0", port: this.port, family: "IPv4" };
}
send(buffer, offset = 0, length, port, address, callback) {
if (port !== MDNS_PORT) {
throw new Error("dgram only answers mDNS name lookups in Tinycast extensions. See docs/extensions.md.");
}
const packet = Buffer.from(buffer);
this.answer(packet.subarray(offset, offset + (length ?? packet.length)));
callback?.(null);
return this;
}
close(callback) {
if (this.closed) return this;
this.closed = true;
if (callback) this.once("close", callback);
setTimeout(() => this.emit("close"), 0);
return this;
}
// The resolver does the multicasting, so joining a group is nothing.
addMembership() {}
dropMembership() {}
setMulticastTTL() {}
setMulticastLoopback() {}
setMulticastInterface() {}
setTTL() {}
ref() {
return this;
}
unref() {
return this;
}
async answer(packet) {
const query = decodeQuery(packet);
if (!query) return;
const answers = [];
for (const question of query.questions) {
if (question.class !== IN_CLASS) continue;
if (question.type !== A_RECORD && question.type !== ANY_RECORD) continue;
const addresses = await hostCall("dns", "resolve", [question.name]).catch(() => []);
for (const address of addresses) answers.push({ name: question.name, address });
}
// Silence, the same as a name nothing on the network claims.
if (this.closed || !answers.length) return;
const response = encodeResponse(packet, query, answers);
this.emit("message", response, {
address: "127.0.0.1", family: "IPv4", port: this.port, size: response.length
});
}
}
function decodeQuery(packet) {
if (packet.length < 12) return null;
const count = packet.readUInt16BE(4);
const questions = [];
let offset = 12;
for (let index = 0; index < count; index++) {
const labels = [];
for (;;) {
if (offset >= packet.length) return null;
const size = packet[offset];
// A query never compresses a name, so a pointer means this is not ours to answer.
if (size >= 0xc0) return null;
offset += 1;
if (size === 0) break;
labels.push(packet.subarray(offset, offset + size).toString("utf8"));
offset += size;
}
if (offset + 4 > packet.length) return null;
questions.push({
name: labels.join("."),
type: packet.readUInt16BE(offset),
class: packet.readUInt16BE(offset + 2) & 0x7fff,
});
offset += 4;
}
return questions.length ? { id: packet.readUInt16BE(0), questions, end: offset } : null;
}
function encodeResponse(packet, query, answers) {
const header = Buffer.alloc(12);
header.writeUInt16BE(query.id, 0);
header.writeUInt16BE(0x8400, 2);
header.writeUInt16BE(query.questions.length, 4);
header.writeUInt16BE(answers.length, 6);
return Buffer.concat([header, packet.subarray(12, query.end), ...answers.map(encodeRecord)]);
}
function encodeRecord({ name, address }) {
const record = Buffer.alloc(14);
record.writeUInt16BE(A_RECORD, 0);
record.writeUInt16BE(1, 2);
record.writeUInt32BE(120, 4);
record.writeUInt16BE(4, 8);
address.split(".").forEach((part, index) => (record[10 + index] = Number(part)));
return Buffer.concat([encodeName(name), record]);
}
function encodeName(name) {
const labels = name.split(".").filter(Boolean);
const out = Buffer.alloc(labels.reduce((total, label) => total + label.length + 1, 1));
let offset = 0;
for (const label of labels) {
out[offset] = label.length;
out.write(label, offset + 1);
offset += label.length + 1;
}
return out;
}
export const dgram = {
createSocket(options, listener) {
const socket = new NameLookupSocket();
const handler = typeof options === "function" ? options : listener;
if (handler) socket.on("message", handler);
return socket;
},
Socket: NameLookupSocket,
};
+2
View File
@@ -15,6 +15,7 @@ import { NavigationRoot, setFieldCommandHandler } from "./api/components.js";
import { Surface } from "./reconciler.js";
import { raycastApi } from "./api/index.js";
import { configureSystem, runToastAction } from "./api/system.js";
import { WebSocket } from "./websocket.js";
const reactModule = {
...React,
@@ -41,6 +42,7 @@ defineModule("react-dom", {
flushSync: (fn) => fn?.(),
version: React.version,
});
globalThis.WebSocket = WebSocket;
const sessions = new Map();
+57 -9
View File
@@ -22,6 +22,8 @@ import {
import { ReadableStream, TransformStream, WritableStream } from "./web-streams.js";
import { fileURLToPath, pathToFileURL, URL, URLSearchParams } from "./url.js";
import { punycode } from "./punycode.js";
import { upgradeToWebSocket } from "./websocket.js";
import { dgram } from "./dgram.js";
// ─── path ───────────────────────────────────────────────────────────
@@ -1242,6 +1244,7 @@ class ClientRequest extends EventEmitter {
flushHeaders() {}
async _send() {
if (String(this.getHeader("upgrade") ?? "").toLowerCase() === "websocket") return this._upgrade();
// Content negotiation belongs to the transport, which decodes for us and reports the result.
this.removeHeader("accept-encoding");
const body = this._chunks.length ? Buffer.concat(this._chunks) : null;
@@ -1265,21 +1268,64 @@ class ClientRequest extends EventEmitter {
if (!this._destroyed) this.emit("error", error instanceof Error ? error : new Error(String(error)));
}
}
/// The host opens the socket, so the 101 is synthesised — never with an extension, so no deflate.
async _upgrade() {
const headers = this.getHeaders();
try {
const { socket, protocol } = await upgradeToWebSocket({
url: this.url.replace(/^http/, "ws"),
protocols: splitList(headers["sec-websocket-protocol"]),
headers: Object.fromEntries(
Object.entries(headers).filter(([name]) => !HANDSHAKE_HEADERS.has(name)),
),
});
clearTimeout(this._timer);
if (this._destroyed) return socket.destroy();
const accept = new Hash("sha1").update(`${headers["sec-websocket-key"] ?? ""}${WEBSOCKET_GUID}`).digest("base64");
const response = new IncomingMessage({
status: 101,
statusText: "Switching Protocols",
headers: {
upgrade: "websocket",
connection: "Upgrade",
"sec-websocket-accept": accept,
...(protocol ? { "sec-websocket-protocol": protocol } : {}),
},
});
if (!this.emit("upgrade", response, socket, Buffer.alloc(0))) socket.destroy();
} catch (error) {
clearTimeout(this._timer);
if (!this._destroyed) this.emit("error", error instanceof Error ? error : new Error(String(error)));
}
}
}
function httpRequest(input, options, callback) {
if (typeof options === "function") return httpRequest(input, {}, options);
const WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
/// URLSession writes the handshake itself; forwarding these would have it refuse the request.
const HANDSHAKE_HEADERS = new Set([
"connection", "upgrade", "host", "sec-websocket-key", "sec-websocket-version",
"sec-websocket-extensions", "sec-websocket-protocol",
]);
function splitList(value) {
return String(value ?? "").split(",").map((item) => item.trim()).filter(Boolean);
}
function httpRequest(input, options, callback, scheme = "http:") {
if (typeof options === "function") return httpRequest(input, {}, options, scheme);
if (typeof input === "string" || input instanceof URL) {
return new ClientRequest(String(input), options ?? {}, callback);
}
const spec = input ?? {};
const host = spec.hostname ?? spec.host ?? "localhost";
const port = spec.port ? `:${spec.port}` : "";
return new ClientRequest(`${spec.protocol ?? "http:"}//${host}${port}${spec.path ?? "/"}`, spec, callback);
return new ClientRequest(`${spec.protocol ?? scheme}//${host}${port}${spec.path ?? "/"}`, spec, callback);
}
function httpGet(input, options, callback) {
return httpRequest(input, options, callback).end();
function httpGet(input, options, callback, scheme) {
return httpRequest(input, options, callback, scheme).end();
}
// ─── util ───────────────────────────────────────────────────────────
@@ -1534,8 +1580,9 @@ function makeUnsupported(label) {
const httpLike = (name) =>
unsupportedModule(name, {
request: httpRequest,
get: httpGet,
// The scheme rides with the module: `ws` and axios both pass an options bag with no protocol.
request: (input, options, callback) => httpRequest(input, options, callback, `${name}:`),
get: (input, options, callback) => httpGet(input, options, callback, `${name}:`),
validateHeaderName,
validateHeaderValue,
IncomingMessage,
@@ -1601,6 +1648,7 @@ export const nodeModules = {
perf_hooks: { performance: globalThis.performance },
http: httpLike("http"),
https: httpLike("https"),
dgram,
net: unsupportedModule("net"),
tls: unsupportedModule("tls"),
dns: unsupportedModule("dns"),
@@ -1624,10 +1672,10 @@ function requireStub(name) {
}
// Every remaining Node builtin resolves to a refuse-on-use stub. Bundles reference the whole
// long tail (dgram, http2, domain, repl, …) from dependencies that only touch them on paths an
// long tail (http2, domain, repl, …) from dependencies that only touch them on paths an
// extension never reaches, so a require-time throw would fail extensions that actually work.
const REMAINING_BUILTINS = [
"assert/strict", "console", "dgram", "diagnostics_channel", "dns/promises", "domain", "http2",
"assert/strict", "console", "diagnostics_channel", "dns/promises", "domain", "http2",
"inspector/promises", "path/posix", "path/win32", "readline/promises", "repl",
"stream/consumers", "sys", "trace_events", "util/types", "wasi", "sea", "sqlite", "test",
"test/reporters",
+312
View File
@@ -0,0 +1,312 @@
// WebSockets over `URLSessionWebSocketTask`: Swift owns the wire, JS reads by keeping one `receive`
// outstanding.
import { Buffer } from "./buffer.js";
import { hostCall } from "./host.js";
import { Duplex } from "./streams.js";
/// Sends are chained: two host calls can otherwise settle out of order.
class NativeSocket {
constructor(handlers) {
this.id = 0;
this.protocol = "";
this.closed = false;
this.tail = Promise.resolve();
this.handlers = handlers;
}
async open(spec) {
const opened = await hostCall("websocket", "open", [spec]);
this.id = opened.id;
this.protocol = opened.protocol ?? "";
this.pump();
return this;
}
send(message) {
return this.enqueue("send", { ...message });
}
/// Outside the queue: a pong that never arrives must not hold a later `close` back.
ping() {
if (this.closed) return Promise.resolve();
return hostCall("websocket", "ping", [{ id: this.id }]);
}
close(code, reason) {
if (this.closed) return;
this.closed = true;
this.enqueue("close", { code: code ?? 1000, reason: reason ?? "" }, { whenClosed: true });
}
enqueue(method, payload, { whenClosed = false } = {}) {
const result = this.tail.then(() =>
this.closed && !whenClosed ? undefined : hostCall("websocket", method, [{ id: this.id, ...payload }]),
);
this.tail = result.catch(() => {});
return result;
}
async pump() {
for (;;) {
let event;
try {
event = await hostCall("websocket", "receive", [this.id]);
} catch (error) {
this.closed = true;
this.handlers.closed(1006, String(error?.message ?? error), true);
return;
}
if (event.type === "close") {
this.closed = true;
this.handlers.closed(event.code ?? 1006, event.reason ?? "", Boolean(event.abnormal));
return;
}
const binary = event.type === "binary";
this.handlers.message(
binary ? Buffer.from(event.base64 ?? "", "base64") : String(event.text ?? ""),
binary,
);
}
}
}
function protocolList(protocols) {
if (!protocols) return [];
return (Array.isArray(protocols) ? protocols : [protocols]).map(String);
}
// ─── The WHATWG global ──────────────────────────────────────────────
export class WebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
constructor(url, protocols) {
this.url = String(url);
this.readyState = WebSocket.CONNECTING;
this.protocol = "";
this.extensions = "";
this.binaryType = "arraybuffer";
this.bufferedAmount = 0;
this.onopen = null;
this.onmessage = null;
this.onerror = null;
this.onclose = null;
this._listeners = new Map();
this._socket = null;
new NativeSocket({
message: (data, binary) => {
this._fire("message", { data: binary && this.binaryType === "arraybuffer" ? toArrayBuffer(data) : data });
},
closed: (code, reason, abnormal) => this._end(code, reason, abnormal),
})
.open({ url: this.url, protocols: protocolList(protocols), headers: {} })
.then(
(socket) => {
this._socket = socket;
this.protocol = socket.protocol;
// A `close()` during the handshake has to win over the open that lands after it.
if (this.readyState !== WebSocket.CONNECTING) return socket.close(1000, "");
this.readyState = WebSocket.OPEN;
this._fire("open", {});
},
(error) => this._end(1006, String(error?.message ?? error), true),
);
}
send(data) {
if (this.readyState !== WebSocket.OPEN) throw new Error("WebSocket is not open");
if (typeof data === "string") return void this.transmit({ text: data });
const bytes = ArrayBuffer.isView(data)
? Buffer.from(data.buffer, data.byteOffset, data.byteLength)
: Buffer.from(data);
this.transmit({ base64: bytes.toString("base64") });
}
transmit(message) {
this._socket.send(message).catch((error) => this._end(1006, String(error?.message ?? error), true));
}
close(code, reason) {
if (this.readyState === WebSocket.CLOSED || this.readyState === WebSocket.CLOSING) return;
const connecting = this.readyState === WebSocket.CONNECTING;
this.readyState = WebSocket.CLOSING;
this._socket?.close(code, reason);
if (connecting && !this._socket) this._end(code ?? 1000, reason ?? "", false);
}
addEventListener(type, listener) {
if (!this._listeners.has(type)) this._listeners.set(type, new Set());
this._listeners.get(type).add(listener);
}
removeEventListener(type, listener) {
this._listeners.get(type)?.delete(listener);
}
_end(code, reason, abnormal) {
if (this.readyState === WebSocket.CLOSED) return;
if (abnormal) this._fire("error", { message: reason });
this.readyState = WebSocket.CLOSED;
this._fire("close", { code, reason, wasClean: !abnormal });
}
_fire(type, detail) {
const event = { type, target: this, ...detail };
const handler = this[`on${type}`];
if (typeof handler === "function") handler.call(this, event);
for (const listener of this._listeners.get(type) ?? []) listener.call(this, event);
}
}
function toArrayBuffer(bytes) {
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
}
// ─── The `ws` adapter ───────────────────────────────────────────────
const CONTINUATION = 0x0;
const TEXT = 0x1;
const BINARY = 0x2;
const CLOSE = 0x8;
const PING = 0x9;
const PONG = 0xa;
/// A bundled `ws` frames its own traffic, so the socket it gets re-frames over the native task.
export async function upgradeToWebSocket({ url, protocols, headers }) {
const socket = new WebSocketSocket();
const native = await new NativeSocket({
message: (data, binary) => socket.deliver(data, binary),
closed: (code, reason, abnormal) => socket.conclude(code, reason, abnormal),
}).open({ url, protocols, headers });
socket.native = native;
return { socket, protocol: native.protocol };
}
class WebSocketSocket extends Duplex {
constructor() {
super({ read() {} });
this.native = null;
this.fragments = [];
this.fragmentOpcode = TEXT;
this.pending = Buffer.alloc(0);
}
_write(chunk, encoding, callback) {
this.pending = Buffer.concat([this.pending, Buffer.from(chunk)]);
while (this.readFrame());
callback(null);
}
/// `ws` destroys on every error path; the native task goes with it.
_destroy(error, callback) {
this.native?.close(1000, "");
callback?.(error ?? null);
}
setTimeout() {
return this;
}
setNoDelay() {
return this;
}
setKeepAlive() {
return this;
}
deliver(data, binary) {
const payload = binary ? data : Buffer.from(data, "utf8");
this.push(encodeFrame(binary ? BINARY : TEXT, payload));
}
conclude(code, reason, abnormal) {
// A frame may never carry 1005 or 1006; ending the socket is how `ws` reads them.
if (!abnormal && code !== 1005 && code !== 1006) {
const payload = Buffer.concat([Buffer.alloc(2), Buffer.from(String(reason ?? ""), "utf8")]);
payload.writeUInt16BE(code, 0);
this.push(encodeFrame(CLOSE, payload));
}
this.push(null);
}
/// False when `pending` is short of a whole frame.
readFrame() {
const buffer = this.pending;
if (buffer.length < 2) return false;
const masked = (buffer[1] & 0x80) !== 0;
const indicator = buffer[1] & 0x7f;
let offset = 2;
let length = indicator;
if (indicator === 126) {
if (buffer.length < 4) return false;
length = buffer.readUInt16BE(2);
offset = 4;
} else if (indicator === 127) {
if (buffer.length < 10) return false;
length = buffer.readUInt32BE(2) * 2 ** 32 + buffer.readUInt32BE(6);
offset = 10;
}
const mask = masked ? buffer.subarray(offset, offset + 4) : null;
if (masked) offset += 4;
if (buffer.length < offset + length) return false;
const payload = Buffer.from(buffer.subarray(offset, offset + length));
if (mask) for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i % 4];
this.pending = Buffer.from(buffer.subarray(offset + length));
this.handleFrame((buffer[0] & 0x80) !== 0, buffer[0] & 0x0f, payload);
return true;
}
handleFrame(final, opcode, payload) {
if (opcode === PONG) return;
if (opcode === PING) {
this.native?.ping().then(
() => this.push(encodeFrame(PONG, payload)),
(error) => this.destroy(error),
);
return;
}
if (opcode === CLOSE) {
const code = payload.length >= 2 ? payload.readUInt16BE(0) : 1000;
return void this.native?.close(code, payload.subarray(2).toString("utf8"));
}
if (!final) {
if (opcode !== CONTINUATION) this.fragmentOpcode = opcode;
this.fragments.push(payload);
return;
}
if (opcode === CONTINUATION) {
this.fragments.push(payload);
const whole = Buffer.concat(this.fragments);
this.fragments = [];
return void this.transmit(this.fragmentOpcode, whole);
}
this.transmit(opcode, payload);
}
transmit(opcode, payload) {
const message = opcode === BINARY ? { base64: payload.toString("base64") } : { text: payload.toString("utf8") };
this.native?.send(message)?.catch((error) => this.destroy(error));
}
}
function encodeFrame(opcode, payload) {
const length = payload.length;
const header = Buffer.alloc(length < 126 ? 2 : length < 65536 ? 4 : 10);
header[0] = 0x80 | opcode;
if (length < 126) {
header[1] = length;
} else if (length < 65536) {
header[1] = 126;
header.writeUInt16BE(length, 2);
} else {
header[1] = 127;
header.writeUInt32BE(0, 2);
header.writeUInt32BE(length, 6);
}
return Buffer.concat([header, payload]);
}
+54 -1
View File
@@ -21,6 +21,7 @@ import {
randomUUID,
} from "node:crypto";
import { cpus, freemem, homedir, loadavg, tmpdir, uptime } from "node:os";
import { lookup } from "node:dns/promises";
import * as fs from "node:fs";
import * as zlib from "node:zlib";
@@ -299,6 +300,8 @@ function syncHostCall(api, method, args) {
const oauthTokens = new Map();
const runningChildren = new Map();
const openSockets = new Map();
let nextSocketId = 1;
async function stubHostCall(api, method, args) {
switch (`${api}.${method}`) {
@@ -335,6 +338,32 @@ async function stubHostCall(api, method, args) {
runningChildren.delete(args[0]);
return exit;
}
// Node's own WebSocket stands in for `URLSessionWebSocketTask`: same one-message-at-a-time read.
case "websocket.open":
return openSocket(args[0]);
case "dns.resolve":
return lookup(args[0], { all: true, family: 4 }).then(
(found) => found.map((entry) => entry.address),
() => [],
);
case "websocket.receive": {
const entry = openSockets.get(args[0]);
if (!entry) throw new Error("harness: no socket");
return entry.queue.length ? entry.queue.shift() : new Promise((resolve) => entry.waiters.push(resolve));
}
case "websocket.send": {
const entry = openSockets.get(args[0].id);
entry?.socket.send(args[0].text ?? Buffer.from(args[0].base64, "base64"));
return null;
}
case "websocket.close": {
const entry = openSockets.get(args[0].id);
openSockets.delete(args[0].id);
entry?.socket.close(args[0].code, args[0].reason);
return null;
}
case "websocket.ping":
return null;
// Positional arguments throughout, matching `src/api/oauth.js`.
case "oauth.authorize":
return { authorizationCode: "auth-code-12345", state: args[1] ?? "" };
@@ -352,6 +381,30 @@ async function stubHostCall(api, method, args) {
}
}
async function openSocket(spec) {
const socket = new WebSocket(spec.url, spec.protocols ?? []);
socket.binaryType = "arraybuffer";
const entry = { socket, queue: [], waiters: [] };
const deliver = (event) => (entry.waiters.length ? entry.waiters.shift()(event) : entry.queue.push(event));
socket.addEventListener("message", (event) =>
deliver(
typeof event.data === "string"
? { type: "text", text: event.data }
: { type: "binary", base64: Buffer.from(event.data).toString("base64") },
),
);
socket.addEventListener("close", (event) =>
deliver({ type: "close", code: event.code, reason: event.reason, abnormal: !event.wasClean }),
);
await new Promise((resolve, reject) => {
socket.addEventListener("open", resolve, { once: true });
socket.addEventListener("error", () => reject(new Error(`connection to ${spec.url} failed`)), { once: true });
});
const id = nextSocketId++;
openSockets.set(id, entry);
return { id, protocol: socket.protocol ?? "" };
}
export function bootConfig(overrides = {}) {
return {
node: {
@@ -464,7 +517,7 @@ async function runExtension(dir, commandName) {
);
harness.start("s1", readFileSync(file, "utf8"), file, dir, target.mode === "view" ? "view" : "no-view", {});
await new Promise((resolve) => setTimeout(resolve, 1500));
await new Promise((resolve) => setTimeout(resolve, Number(process.env.EXT_TEST_SETTLE_MS ?? 1500)));
if (harness.state.failures.length) {
console.log("\n✗ failures:");
for (const failure of harness.state.failures) console.log(failure);
+2
View File
@@ -470,6 +470,8 @@ run slow ext-test -parse-as-library \
$E/Service/ExtensionOAuthKeychain.swift \
$E/Service/ExtensionOAuthSession.swift \
$E/Service/ExtensionRuntime.swift \
$E/Service/ExtensionNameResolver.swift \
$E/Service/ExtensionWebSocketBridge.swift \
$E/UI/ExtensionAnimatedImage.swift \
$E/UI/ExtensionImage.swift \
$E/UI/ExtensionScreen.swift \
+12
View File
@@ -26,6 +26,7 @@ struct ExtensionTests {
var huds: [String] = []
var oauthTokens: [String: String] = [:]
private let fetcher = ExtensionFetcher()
private let sockets = ExtensionWebSocketBridge()
func perform(api: String, method: String, arguments: [RenderValue]) async throws -> String {
calls.append("\(api).\(method)")
@@ -36,6 +37,13 @@ struct ExtensionTests {
if api == "fetch" {
return ExtensionRuntime.jsonString(from: try await fetcher.request(arguments.first))
}
if api == "websocket" {
return ExtensionRuntime.jsonString(
from: try await sockets.perform(method: method, arguments: arguments))
}
if api == "dns" {
return ExtensionRuntime.jsonString(from: await ExtensionNameResolver.resolve(arguments.first))
}
switch "\(api).\(method)" {
case "feedback.showToast":
toasts.append(arguments.first?.objectValue?["title"]?.stringValue ?? "")
@@ -71,6 +79,10 @@ struct ExtensionTests {
return ""
}
}
func sessionEnded() {
sockets.closeAll()
}
}
@MainActor
+8
View File
@@ -84,6 +84,7 @@
1BC52F49830E021342C037CA /* CalcTimestamp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBBB8484C670351136F3C535 /* CalcTimestamp.swift */; };
1C81DCEBEFFC1191751135F4 /* PalettePlacement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 802BD2F4CC057228F2528217 /* PalettePlacement.swift */; };
1CA10DE219BAB97BFC29E54A /* SettingsSidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D29ADBBE565419B1E98281 /* SettingsSidebarView.swift */; };
1D087872616CA6AB68E2053B /* ExtensionWebSocketBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5787319A2BC8BA63CCE29F72 /* ExtensionWebSocketBridge.swift */; };
1E04684F05DFA84A8B667F20 /* PasteboardFiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5C752F98428A767405AD12 /* PasteboardFiles.swift */; };
1E0A1B1B84673EF39871842C /* MarkdownCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 570CE37C7133E28F7495ED91 /* MarkdownCodeView.swift */; };
1E48F2BED1C62873F424F180 /* SnippetCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1847C927A1DA4EE0009201DF /* SnippetCoordinator.swift */; };
@@ -623,6 +624,7 @@
F71FC7195AC8D6626BEE8931 /* NotesPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91151402A1A4586B2496BF35 /* NotesPanel.swift */; };
F801D00F877421DC7045C5F3 /* RedactedPlaceholder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 131B15F5EF86A067FA6C36D7 /* RedactedPlaceholder.swift */; };
F85CE4CDCF7589ACAEF2CA20 /* AppIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90E23F92F4B4DEEE55077C7C /* AppIconView.swift */; };
F88C1B7A8E2C46404792C826 /* ExtensionNameResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40D65C644389618DF5F5E26 /* ExtensionNameResolver.swift */; };
F8D69CE2A8C04DEEC1810A1F /* UninstallRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D134673CB1B563A3758FC37 /* UninstallRunner.swift */; };
F8E1B044D84E96FB455155CD /* AIModelDiscoveryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4B27ECEB1C09B14C403FC8 /* AIModelDiscoveryService.swift */; };
F8EC987B884BD2ED6A6690A9 /* SnippetKeywordListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 301BA9123035D6BFB808F763 /* SnippetKeywordListener.swift */; };
@@ -896,6 +898,7 @@
570CE37C7133E28F7495ED91 /* MarkdownCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownCodeView.swift; sourceTree = "<group>"; };
57640B0DA3F71E66CECA871C /* ExtensionFormKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionFormKey.swift; sourceTree = "<group>"; };
57694B58B0A17CAC1D697FAA /* BundleNameCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleNameCache.swift; sourceTree = "<group>"; };
5787319A2BC8BA63CCE29F72 /* ExtensionWebSocketBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionWebSocketBridge.swift; sourceTree = "<group>"; };
582A70CF432F5E0144E57369 /* ExtensionRefreshState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionRefreshState.swift; sourceTree = "<group>"; };
58D62BFB4EC604AE6171BBA3 /* CountryZoneData.generated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountryZoneData.generated.swift; sourceTree = "<group>"; };
590A432CC25ED6372B453BFA /* ScheduleScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleScreen.swift; sourceTree = "<group>"; };
@@ -1175,6 +1178,7 @@
C330E1D58D9A9A71D35D4019 /* QuicklinkEditorPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuicklinkEditorPanel.swift; sourceTree = "<group>"; };
C3C83D437500107168E12647 /* QuickActionSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickActionSettings.swift; sourceTree = "<group>"; };
C3E30EFA3197FC5EF1E85C21 /* BundleSignature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleSignature.swift; sourceTree = "<group>"; };
C40D65C644389618DF5F5E26 /* ExtensionNameResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionNameResolver.swift; sourceTree = "<group>"; };
C41B7D28CD2AF8AD14DA6FC3 /* SettingsAnchor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAnchor.swift; sourceTree = "<group>"; };
C42ACB4D70CA089A6E948069 /* MenuSearchSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuSearchSession.swift; sourceTree = "<group>"; };
C4515F099338A535A8736AE0 /* MenuSearchCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuSearchCoordinator.swift; sourceTree = "<group>"; };
@@ -2813,12 +2817,14 @@
B9F568F2F6EF857274F1A9D8 /* ExtensionIconCache.swift */,
F023729C84682E9231F3D73B /* ExtensionInstaller.swift */,
DD8CDDBAC59480E1A7559278 /* ExtensionManager.swift */,
C40D65C644389618DF5F5E26 /* ExtensionNameResolver.swift */,
9F9A0A3867D1B8E1D3284471 /* ExtensionNodeShims.swift */,
CFE7A7AC9AEE81BAB9DA87FB /* ExtensionOAuthKeychain.swift */,
DE7DEB147E5D718E00D67755 /* ExtensionOAuthSession.swift */,
C54C4540AD6F188A2455473F /* ExtensionRuntime.swift */,
46CECB81C669337C8E6CF7FF /* ExtensionStorage.swift */,
410684C9DEB3754962C20C9C /* ExtensionStoreClient.swift */,
5787319A2BC8BA63CCE29F72 /* ExtensionWebSocketBridge.swift */,
387BE675F580496656E72559 /* SymbolCatalog.swift */,
);
path = Service;
@@ -3446,6 +3452,7 @@
2F2360730BE4992D5745FAD3 /* ExtensionListView.swift in Sources */,
23E8792D69F3F1C539BC3BC6 /* ExtensionManager.swift in Sources */,
45799EE1F41091B32544B6AE /* ExtensionManifest.swift in Sources */,
F88C1B7A8E2C46404792C826 /* ExtensionNameResolver.swift in Sources */,
552B7230E78D2C05793EC013 /* ExtensionNodeShims.swift in Sources */,
8FDF7F20B5FBA1B4D4FF563D /* ExtensionOAuthKeychain.swift in Sources */,
97F6E58046553BDCEF5AD447 /* ExtensionOAuthSession.swift in Sources */,
@@ -3469,6 +3476,7 @@
FC2F9731834BAD596116441A /* ExtensionStorePanel.swift in Sources */,
0120AF7AB3AD249C484C8A03 /* ExtensionStoreResponse.swift in Sources */,
62C75AA7315CDEFE82ECD21B /* ExtensionTintColors.swift in Sources */,
1D087872616CA6AB68E2053B /* ExtensionWebSocketBridge.swift in Sources */,
330540BBA991E36DD4C4BF53 /* ExtensionsSettingsView.swift in Sources */,
0E2C10EDA6444D3ACF8F6F71 /* Fallback.swift in Sources */,
DFED3F4C7B67FED3DBD016E5 /* FallbackActionsMenu.swift in Sources */,
@@ -128,6 +128,7 @@ final class ExtensionHostBridge: ExtensionHostAPI {
weak var context: ExtensionHostContext?
private let clipboardStore: ClipboardStore
private let fetcher = ExtensionFetcher()
private let sockets = ExtensionWebSocketBridge()
init(clipboardStore: ClipboardStore) {
self.clipboardStore = clipboardStore
@@ -147,6 +148,8 @@ final class ExtensionHostBridge: ExtensionHostAPI {
case "feedback": return try await feedback(method: method, arguments: arguments)
case "system": return try await system(method: method, arguments: arguments)
case "fetch": return try await fetcher.request(arguments.first)
case "websocket": return try await sockets.perform(method: method, arguments: arguments)
case "dns": return await ExtensionNameResolver.resolve(arguments.first)
case "proc": return try await ExtensionAsyncProcess.wait(arguments.first)
case "oauth": return try await oauth(method: method, arguments: arguments)
default: throw ExtensionHostError.unknown("\(api).\(method)")
@@ -160,6 +163,11 @@ final class ExtensionHostBridge: ExtensionHostAPI {
return (context, name)
}
/// Called wherever a command's context is discarded: nothing left open outlives its session.
func sessionEnded() {
sockets.closeAll()
}
// MARK: - Clipboard
private func clipboard(method: String, arguments: [RenderValue]) throws -> Any? {
@@ -0,0 +1,36 @@
import Foundation
/// Host-name lookups for the `dgram` shim: the system resolver answers `.local` through Bonjour.
enum ExtensionNameResolver {
static func resolve(_ name: RenderValue?) async -> [String] {
let host = name?.stringValue ?? ""
guard !host.isEmpty else { return [] }
return await Task.detached(priority: .userInitiated) { addresses(of: host) }.value
}
/// Blocking, hence the detached task.
private static func addresses(of host: String) -> [String] {
var hints = addrinfo()
hints.ai_family = AF_INET
hints.ai_socktype = SOCK_STREAM
var head: UnsafeMutablePointer<addrinfo>?
guard getaddrinfo(host, nil, &hints, &head) == 0, let first = head else { return [] }
defer { freeaddrinfo(first) }
var found: [String] = []
var entry: UnsafeMutablePointer<addrinfo>? = first
while let current = entry {
var text = [CChar](repeating: 0, count: Int(NI_MAXHOST))
if getnameinfo(
current.pointee.ai_addr, current.pointee.ai_addrlen, &text, socklen_t(text.count),
nil, 0, NI_NUMERICHOST) == 0
{
let digits = text.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }
let address = String(decoding: digits, as: UTF8.self)
if !found.contains(address) { found.append(address) }
}
entry = current.pointee.ai_next
}
return found
}
}
@@ -5,6 +5,8 @@ import JavaScriptCore
@MainActor
protocol ExtensionHostAPI: AnyObject, Sendable {
func perform(api: String, method: String, arguments: [RenderValue]) async throws -> String
/// The context is gone; release anything opened on its behalf.
func sessionEnded()
}
/// Where a running command's UI or failure lands. Every callback arrives on the main actor.
@@ -299,6 +301,8 @@ final class ExtensionRuntime: @unchecked Sendable {
/// Timers are global and React's scheduler rides them, so a context is never reused.
func shutdown() {
let hostAPI = self.hostAPI
Task { @MainActor in hostAPI.sessionEnded() }
queue.async {
for timer in self.timers.values { timer.cancel() }
self.timers.removeAll()
@@ -0,0 +1,174 @@
import Foundation
import Synchronization
/// One `URLSessionWebSocketTask` per socket, read by the single `receive` JS keeps in flight.
final class ExtensionWebSocketBridge: NSObject, Sendable, URLSessionWebSocketDelegate {
private struct Connection {
let task: URLSessionWebSocketTask
var opening: CheckedContinuation<String, Error>?
}
private let connections = Mutex<[Int: Connection]>([:])
private let sessionBox = Mutex<URLSession?>(nil)
enum SocketError: LocalizedError {
case badURL(String)
case closed
case unknown(String)
var errorDescription: String? {
switch self {
case .badURL(let url): return "Invalid WebSocket URL: \(url)"
case .closed: return "The WebSocket is closed."
case .unknown(let method): return "Unknown host call 'websocket.\(method)'."
}
}
}
func perform(method: String, arguments: [RenderValue]) async throws -> Any? {
let fields = arguments.first?.objectValue ?? [:]
switch method {
case "open": return try await open(fields)
case "receive": return try await receive(id: whole(arguments.first))
case "send": return try await send(fields)
case "ping": return try await ping(whole(fields["id"]))
case "close":
close(
id: whole(fields["id"]), code: whole(fields["code"], or: 1000),
reason: fields["reason"]?.stringValue ?? "")
return nil
default: throw SocketError.unknown(method)
}
}
private func whole(_ value: RenderValue?, or fallback: Int = 0) -> Int {
value?.doubleValue.flatMap(Int.init(exactly:)) ?? fallback
}
/// The context is thrown away between commands, so nothing would read these again.
func closeAll() {
let open = connections.withLock { state -> [Connection] in
let all = Array(state.values)
state.removeAll()
return all
}
for connection in open {
connection.opening?.resume(throwing: SocketError.closed)
connection.task.cancel(with: .goingAway, reason: nil)
}
}
// MARK: - Calls
private func open(_ fields: [String: RenderValue]) async throws -> [String: Any] {
let text = fields["url"]?.stringValue ?? ""
guard let url = URL(string: text), url.scheme == "ws" || url.scheme == "wss" else {
throw SocketError.badURL(text)
}
var request = URLRequest(url: url)
for (name, value) in fields["headers"]?.objectValue ?? [:] {
guard let header = value.stringValue else { continue }
request.setValue(header, forHTTPHeaderField: name)
}
let protocols = (fields["protocols"]?.arrayValue ?? []).compactMap(\.stringValue)
if !protocols.isEmpty {
request.setValue(protocols.joined(separator: ", "), forHTTPHeaderField: "Sec-WebSocket-Protocol")
}
let task = session().webSocketTask(with: request)
let id = task.taskIdentifier
let negotiated = try await withCheckedThrowingContinuation { continuation in
connections.withLock { $0[id] = Connection(task: task, opening: continuation) }
task.resume()
}
return ["id": id, "protocol": negotiated]
}
private func receive(id: Int) async throws -> [String: Any] {
guard let task = connections.withLock({ $0[id]?.task }) else { throw SocketError.closed }
do {
switch try await task.receive() {
case .string(let text): return ["type": "text", "text": text]
case .data(let data): return ["type": "binary", "base64": data.base64EncodedString()]
@unknown default: return ["type": "text", "text": ""]
}
} catch {
connections.withLock { $0[id] = nil }
let code = task.closeCode
let clean = code != .invalid
return [
"type": "close",
"code": clean ? code.rawValue : 1006,
"reason": String(data: task.closeReason ?? Data(), encoding: .utf8) ?? "",
"abnormal": !clean
]
}
}
private func send(_ fields: [String: RenderValue]) async throws -> Any? {
let id = whole(fields["id"])
guard let task = connections.withLock({ $0[id]?.task }) else { throw SocketError.closed }
if let base64 = fields["base64"]?.stringValue, let data = Data(base64Encoded: base64) {
try await task.send(.data(data))
} else {
try await task.send(.string(fields["text"]?.stringValue ?? ""))
}
return nil
}
private func ping(_ id: Int) async throws -> Any? {
guard let task = connections.withLock({ $0[id]?.task }) else { throw SocketError.closed }
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
task.sendPing { error in
if let error { continuation.resume(throwing: error) } else { continuation.resume() }
}
}
return nil
}
private func close(id: Int, code: Int, reason: String) {
guard let connection = connections.withLock({ $0.removeValue(forKey: id) }) else { return }
let closeCode = URLSessionWebSocketTask.CloseCode(rawValue: code) ?? .normalClosure
connection.task.cancel(with: closeCode, reason: reason.data(using: .utf8))
}
// MARK: - Session
/// Private and ephemeral, like every other networked surface: no cookie jar, no shared cache.
private func session() -> URLSession {
sessionBox.withLock { box in
if let existing = box { return existing }
let configuration = URLSessionConfiguration.ephemeral
configuration.httpCookieStorage = nil
configuration.urlCache = nil
let created = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
box = created
return created
}
}
// MARK: - URLSessionWebSocketDelegate
func urlSession(
_ session: URLSession, webSocketTask: URLSessionWebSocketTask,
didOpenWithProtocol protocolName: String?
) {
finishOpening(id: webSocketTask.taskIdentifier, result: .success(protocolName ?? ""))
}
/// The only place a failed handshake surfaces: `receive` is never reached when one fails.
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
finishOpening(
id: task.taskIdentifier,
result: .failure(error ?? SocketError.closed))
}
private func finishOpening(id: Int, result: Result<String, Error>) {
let continuation = connections.withLock { state -> CheckedContinuation<String, Error>? in
guard let opening = state[id]?.opening else { return nil }
state[id]?.opening = nil
if case .failure = result { state[id] = nil }
return opening
}
continuation?.resume(with: result)
}
}
File diff suppressed because one or more lines are too long
+27 -3
View File
@@ -93,6 +93,8 @@ same arrangement as `EmojiData.generated.swift`: building Tinycast never needs N
| `src/api/oauth.js` | `OAuth.PKCEClient`, `OAuth.TokenSet`, redirect url builders |
| `src/api/enums.generated.js` | Icon / Color / Toast.Style / … extracted from the real `@raycast/api` types |
| `src/node-shims.js` | `path`, `fs`, `os`, `child_process`, `crypto`, `zlib`, `util`, `events`, `buffer`, `punycode`, … |
| `src/websocket.js` | the `WebSocket` global, and the raw socket a bundled `ws` attaches to |
| `src/dgram.js` | a UDP socket that answers one thing: an mDNS lookup of a `.local` name |
| `src/url.js`, `src/punycode.js`, `src/buffer.js` | web/Node primitives JavaScriptCore lacks |
Two host-call flavours:
@@ -114,6 +116,8 @@ Two host-call flavours:
| `Service/ExtensionHostBridge.swift` | main-actor host APIs (clipboard, storage, cache, window, toasts, system, oauth) |
| `Service/ExtensionNodeShims.swift` | the synchronous `fs` / `os` / `child_process` / `crypto` / `zlib` services |
| `Service/ExtensionFetcher.swift` | `fetch` over `URLSession`, plus collecting async `exec` children and the shared PATH resolver |
| `Service/ExtensionWebSocketBridge.swift` | `URLSessionWebSocketTask` connections, opened and read from JS |
| `Service/ExtensionNameResolver.swift` | `getaddrinfo`, which is how a `.local` name resolves |
| `Service/ExtensionOAuthKeychain.swift` | secure OAuth token storage backed by macOS Keychain |
| `Service/ExtensionOAuthSession.swift` | PKCE state tracking, browser launch, and callback redirect resolution |
| `Service/ExtensionStorage.swift` | per-extension `LocalStorage`, `Cache` and preference values (one JSON file each) |
@@ -576,7 +580,7 @@ directions), `http`/`https` (`request`, `get` and `Agent`, buffered over the sam
as `fetch`), `stream` (`Readable`, `Writable`, `Duplex`, `Transform`, `PassThrough`, `pipeline`,
`finished`, plus `stream/promises` and `stream/web`), `util`, `events`, `buffer`, `url`, `querystring`, `punycode`, `assert`,
`string_decoder`, `timers`. Every other built-in resolves to a stub that throws only when used, so a
bundle that merely references `dgram` or `http2` still loads.
bundle that merely references `http2` or `domain` still loads.
**Streams** — the stream core is Node's real contract, not a stand-in: an extension that ships
`stream-chain` and `stream-json` to walk a package index builds object-mode pipelines out of it, and
@@ -615,6 +619,26 @@ A request calls it only for an `http.Agent` subclass, which is where axios-cooki
http-cookie-agent reads and writes its jar — Hide My Email is the reference case. URLSession folds
repeated `Set-Cookie` headers into one line, so the response splits it back into Node's array.
**WebSockets** — `WebSocket` is a global backed by `URLSessionWebSocketTask`. Swift owns the wire and
the framing, and JS reads a socket by keeping one `receive` call outstanding, so an inbound message
needs no push channel; sends are chained, because two host calls can otherwise settle out of order.
A bundled `ws` never looks at that global. It runs its handshake through `http.request` and waits for
an `upgrade` carrying a raw socket it frames itself, so the shim answers with one that re-frames RFC
6455 in both directions on top of the native task. The 101 it synthesises names no extension, which
is what keeps `permessage-deflate` — streaming zlib, which the shims have no answer for — off the
connection. Home Assistant is the reference case: it authenticates, subscribes, and re-renders on
every state push over that socket. The scheme rides with the module for the same reason: `ws` hands
`https.request` an options bag with no protocol in it, and a `wss:` URL that went out as `ws:` would
never connect.
**`.local` names** — Home Assistant's default URL is `homeassistant.local`, and the extension resolves
it itself with `multicast-dns` because Node cannot. macOS can: mDNSResponder answers `.local` through
`getaddrinfo` like any other name. So `dgram` hands out a socket that never reaches the network — it
decodes the query, asks the system resolver, and emits an answer packet back. Nothing joins a
multicast group, so no multicast entitlement and no Local Network prompt of our own. It answers an
address question and nothing else: a service enumeration, or anything sent to another port, throws.
**Bundled helpers** — compiled Mach-O files and shebang scripts live in `assets/`. GitHub's raw-file
downloads and some store zips lose their executable mode, so installation preserves Git tree mode
`100755`; discovery also repairs known executable payloads already installed as `644`. That covers
@@ -637,10 +661,10 @@ OAuth extensions it excluded are not counted yet — re-measure before quoting t
| **`menu-bar` commands** | The launcher lists them and explains why they don't open. |
| **Raycast's PKCE proxy (`oauth.raycast.com`)** | Extensions whose provider has no PKCE support exchange tokens through Raycast's proxy. `OAuth.PKCEClient` works; a provider that needs that proxy still fails. |
| **`AI`, `BrowserExtension`, `WindowManagement`** | Raycast services with no local equivalent. Importing them works; calling one throws with a clear reason. |
| **WebSocket** | No polyfill yet; `URLSessionWebSocketTask` could back one. |
| **A WebSocket to a host with a certificate macOS distrusts** | `ws`'s `rejectUnauthorized: false` is ignored — URLSession validates the chain either way. |
| **Aborting a `fetch` already in flight** | `AbortSignal` is complete — `timeout`, `abort` and `any` included — and `fetch` checks it on both sides of the host call, so a caller gets its `AbortError`. The request itself still runs to completion: the signal isn't carried across the bridge, so nothing cancels the `URLSessionTask`. A timeout bounds the caller, not the network. |
| **Streaming `child_process.spawn`** | `spawn` runs the child to completion and emits its output as one chunk (async-iterable, which is what `get-stream`/`execa` consume). True duplex streaming would need a bidirectional channel across the bridge. Extensions built on `execa`'s deeper stream API can still fail. |
| **`net` / `tls`** | Resolve but throw on use. Nothing bridges a socket. |
| **`net` / `tls`** | Resolve but throw on use. Nothing bridges a raw socket; a bundled `ws` reaches the network through the WebSocket bridge instead. |
| **Streaming HTTP** | The bridge answers a request with the whole body at once, so `http.request` delivers one chunk and `Response.body` replays bytes that already arrived. Server-sent events, network-level progress and backpressure onto the socket are all out of reach; `stream` itself is real enough to carry them the day the bridge is. |
| **Tool/AI-extension entry points (`tools/`)** | Not surfaced. |