a plugin can serve a websocket
Bun's route table is fixed at serve() and WS_ROUTES is read once before it, so a plugin installed afterwards could never add a socket route. That was a hard ceiling, not a preference: /minesweeper had to live in the platform and plugins/desktop could not stream a display at all. The way out was already in the tree and had one consumer. dev-proxy serves a socket with no WS_ROUTES entry, through a WILDCARD route whose handler calls server.upgrade itself. Nothing has to become dynamic, because a plugin name is a path segment rather than a route — one wildcard declared at boot serves every plugin that will ever be installed. Authorisation is the PLUGIN's own capability, asked as if the socket were a GET against its HTTP prefix. That is the safety argument: there is no second permission to grant, forget, or drift, so granting a plugin grants its socket and revoking it closes it. It is also why plugin-ws is exempt from the boot totality check rather than carrying a registry entry — one entry covering every plugin's socket would mean granting one plugin granted them all. A target is a TCP address and never a URL, which keeps the relay ignorant of what it carries. That ignorance is the only reason one relay can serve an RFB display and a line-oriented sidecar. Loopback only and not configurable: a plugin naming an arbitrary host would be a request-forgery primitive handed to every plugin author. sidecarPort() is new because createSidecarProxy learned announced ports per-proxy and that state was unreachable from anywhere else — a second subscription would have been a second thing to keep in step. Verified: boots past the totality gate, and both an existing and a non-existent plugin refuse 401 before revealing which. NOT yet verified: that it relays a byte. Minesweeper is about to be the first caller, which is what proves it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
+15
-1
@@ -30,6 +30,7 @@ import './servers/api/chat/opencode/sidecar-server'; // subscribe to the opencod
|
|||||||
import type { SidecarRegistration } from './servers/sidecar/registration-protocol';
|
import type { SidecarRegistration } from './servers/sidecar/registration-protocol';
|
||||||
import { toShellUsername } from './servers/data-path';
|
import { toShellUsername } from './servers/data-path';
|
||||||
import { devProxyWebsocket, upgradeDevProxyWs } from './servers/api/dev-proxy/websocket';
|
import { devProxyWebsocket, upgradeDevProxyWs } from './servers/api/dev-proxy/websocket';
|
||||||
|
import { pluginWsWebsocket, upgradePluginWs } from './servers/api/plugin-ws/websocket';
|
||||||
|
|
||||||
// Build static file routes from public/
|
// Build static file routes from public/
|
||||||
// A snapshot of ./public, taken once at boot: one exact route per file, each holding a `Bun.file`
|
// A snapshot of ./public, taken once at boot: one exact route per file, each holding a `Bun.file`
|
||||||
@@ -156,6 +157,7 @@ async function handleSidecarQueueCommand(ws: ServerWebSocket<WSData>, msg: Recor
|
|||||||
const handlers: Record<string, any> = {
|
const handlers: Record<string, any> = {
|
||||||
terminal: terminalWebsocket,
|
terminal: terminalWebsocket,
|
||||||
'dev-proxy': devProxyWebsocket,
|
'dev-proxy': devProxyWebsocket,
|
||||||
|
'plugin-ws': pluginWsWebsocket,
|
||||||
chat: chatWebsocket,
|
chat: chatWebsocket,
|
||||||
'chat-notify': chatNotifyWebsocket,
|
'chat-notify': chatNotifyWebsocket,
|
||||||
'task-runner': taskRunnerWebsocket,
|
'task-runner': taskRunnerWebsocket,
|
||||||
@@ -195,7 +197,7 @@ const WS_ROUTES: Record<string, UpgradableProvider> = {
|
|||||||
// Every provider named by a served route, plus the two that upgrade through their own handlers rather than
|
// Every provider named by a served route, plus the two that upgrade through their own handlers rather than
|
||||||
// `upgradeWs`: `sidecar` (loopback process-to-process, its own route below) and `dev-proxy` (upgraded from
|
// `upgradeWs`: `sidecar` (loopback process-to-process, its own route below) and `dev-proxy` (upgraded from
|
||||||
// inside the proxy router). Named explicitly so their absence from WS_ROUTES cannot read as an oversight.
|
// inside the proxy router). Named explicitly so their absence from WS_ROUTES cannot read as an oversight.
|
||||||
const SERVED_WS_PROVIDERS = [...new Set([...Object.values(WS_ROUTES), 'sidecar', 'dev-proxy'])];
|
const SERVED_WS_PROVIDERS = [...new Set([...Object.values(WS_ROUTES), 'sidecar', 'dev-proxy', 'plugin-ws'])];
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Both doors into the platform, checked against the permission registry before either opens.
|
Both doors into the platform, checked against the permission registry before either opens.
|
||||||
@@ -487,6 +489,18 @@ const server = serve({
|
|||||||
if (req.headers.get('upgrade') === 'websocket') return upgradeDevProxyWs(req, server);
|
if (req.headers.get('upgrade') === 'websocket') return upgradeDevProxyWs(req, server);
|
||||||
return honoServer.fetch(req, server);
|
return honoServer.fetch(req, server);
|
||||||
},
|
},
|
||||||
|
/*
|
||||||
|
Every plugin's socket, through one wildcard declared at boot.
|
||||||
|
|
||||||
|
This is the route that made a plugin websocket possible at all. `WS_ROUTES` is read once before
|
||||||
|
`serve()`, so nothing installed later can add an entry to it — but a wildcard does not need to,
|
||||||
|
because the plugin name is a path segment rather than a route. The same trick `dev-proxy` above has
|
||||||
|
been using all along; it simply had no second consumer until now.
|
||||||
|
*/
|
||||||
|
'/api/plugin-ws/*': (req: Request, server: any) => {
|
||||||
|
if (req.headers.get('upgrade') === 'websocket') return upgradePluginWs(req, server);
|
||||||
|
return honoServer.fetch(req, server);
|
||||||
|
},
|
||||||
'/api/sidecar/register': (req: Request, server: any) => {
|
'/api/sidecar/register': (req: Request, server: any) => {
|
||||||
const ok = server.upgrade(req, {
|
const ok = server.upgrade(req, {
|
||||||
data: { provider: 'sidecar', userId: 0, email: '', username: '' },
|
data: { provider: 'sidecar', userId: 0, email: '', username: '' },
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { sidecarPort } from '@@/sidecar-registry';
|
||||||
|
import { snapshotPlugins } from '@@/plugins/mount';
|
||||||
|
|
||||||
|
/*
|
||||||
|
Where a plugin's socket actually goes.
|
||||||
|
|
||||||
|
A target is a TCP address and never a URL, and that is what keeps the relay ignorant of the protocol on
|
||||||
|
the other side. The ignorance is the point: it is the only reason one relay can serve an RFB display and a
|
||||||
|
line-oriented sidecar without knowing which is which. The moment this returned a scheme or a path, the
|
||||||
|
relay would have to care what it was carrying.
|
||||||
|
|
||||||
|
Two forms, because two things genuinely differ:
|
||||||
|
|
||||||
|
{ port: 5900 } a fixed port a plugin's own service listens on — `x11vnc` for a desktop, or
|
||||||
|
anything the plugin starts itself and knows the number of in advance.
|
||||||
|
{ sidecar: 'name' } resolved at connect time, because a sidecar binds an EPHEMERAL port and announces
|
||||||
|
it. A manifest cannot know that number, which is why the indirection exists rather
|
||||||
|
than being a convenience.
|
||||||
|
|
||||||
|
**Host is always loopback and is not configurable.** A plugin socket that could name an arbitrary host
|
||||||
|
would be a request-forgery primitive handed to every plugin author and reachable by anyone the plugin is
|
||||||
|
granted to. There is no case for it that a sidecar does not already serve.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PluginWsTarget = { host: string; port: number };
|
||||||
|
|
||||||
|
export async function pluginWsTargetFor(appName: string): Promise<PluginWsTarget | null> {
|
||||||
|
const { states } = await snapshotPlugins();
|
||||||
|
// Installed, not merely present on disk. A plugin directory that exists but is not installed has no
|
||||||
|
// routes and no permission, so it must have no socket either.
|
||||||
|
const state = states.find((st) => st.plugin.appName === appName && st.install);
|
||||||
|
const socket = state?.plugin.manifest.websocket;
|
||||||
|
if (!socket) return null;
|
||||||
|
|
||||||
|
if ('port' in socket) return { host: '127.0.0.1', port: socket.port };
|
||||||
|
|
||||||
|
// A sidecar that has not announced its port yet is indistinguishable from one that never will, and both
|
||||||
|
// are correctly a refusal — there is nothing to relay onto. The caller says "not accepting connections",
|
||||||
|
// which is true either way.
|
||||||
|
const port = sidecarPort(socket.sidecar);
|
||||||
|
return port ? { host: '127.0.0.1', port } : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import type { ServerWebSocket, Socket } from 'bun';
|
||||||
|
import { resolveAuthToken } from '@@/auth-token';
|
||||||
|
import { isTokenBlacklisted } from 'officerdb';
|
||||||
|
import { isApiRequestAllowed } from '@@/permissions/authorize';
|
||||||
|
import { pluginWsTargetFor } from './target';
|
||||||
|
|
||||||
|
/*
|
||||||
|
One websocket door for every plugin, so a plugin can have a socket at all.
|
||||||
|
|
||||||
|
── Why this exists ──
|
||||||
|
|
||||||
|
Bun's route table is fixed at `serve()`, and `WS_ROUTES` is a static object read at boot. So a plugin
|
||||||
|
installed afterwards had no way to add a socket route, and "a websocket provider is a platform concern"
|
||||||
|
became a hard ceiling rather than a preference: `/minesweeper` had to live in the platform and
|
||||||
|
`plugins/desktop` could not do the same thing at all.
|
||||||
|
|
||||||
|
The way out was already in the tree. `dev-proxy` serves a socket without a `WS_ROUTES` entry, through a
|
||||||
|
WILDCARD route whose handler calls `server.upgrade` itself. Nothing about the route table needs to become
|
||||||
|
dynamic — one wildcard declared at boot can serve every plugin that will ever be installed, because the
|
||||||
|
plugin name is a path segment rather than a route.
|
||||||
|
|
||||||
|
── What it does and does not read ──
|
||||||
|
|
||||||
|
Bytes, both ways, without interpreting them. Same as the terminal's relay onto the pty sidecar and the
|
||||||
|
minesweeper relay onto x11vnc: frame boundaries carry no meaning to a stream protocol, so chunks are
|
||||||
|
forwarded exactly as they arrive.
|
||||||
|
|
||||||
|
A plugin's target is a TCP address, never a URL. That is deliberate — it keeps this file ignorant of
|
||||||
|
whatever protocol is on the other side, which is the only reason one relay can serve an RFB display and a
|
||||||
|
line-oriented sidecar without knowing which is which.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PluginWSData = {
|
||||||
|
provider: 'plugin-ws';
|
||||||
|
plugin: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
userId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Relay = { socket: Socket | null; queue: Uint8Array[]; done: boolean };
|
||||||
|
const relays = new Map<ServerWebSocket<PluginWSData>, Relay>();
|
||||||
|
|
||||||
|
const closeClient = (ws: ServerWebSocket<PluginWSData>, code: number, reason: string) => {
|
||||||
|
const relay = relays.get(ws);
|
||||||
|
relays.delete(ws);
|
||||||
|
if (relay) relay.done = true;
|
||||||
|
try {
|
||||||
|
relay?.socket?.end();
|
||||||
|
} catch {
|
||||||
|
/* already gone */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ws.close(code, reason);
|
||||||
|
} catch {
|
||||||
|
/* already closing */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pluginWsWebsocket = {
|
||||||
|
async open(ws: ServerWebSocket<PluginWSData>) {
|
||||||
|
/*
|
||||||
|
The queue is not an optimisation. Bun requires `server.upgrade` to be synchronous, so the client is
|
||||||
|
already connected and may already be talking by the time `Bun.connect` resolves — and an RFB client
|
||||||
|
sends its protocol version immediately. Dropping those bytes desynchronises the stream in a way that
|
||||||
|
looks like a corrupt display rather than a lost packet. Learned by dev-proxy first.
|
||||||
|
*/
|
||||||
|
const relay: Relay = { socket: null, queue: [], done: false };
|
||||||
|
relays.set(ws, relay);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const socket = await Bun.connect({
|
||||||
|
hostname: ws.data.host,
|
||||||
|
port: ws.data.port,
|
||||||
|
socket: {
|
||||||
|
data: (_s, chunk) => {
|
||||||
|
try {
|
||||||
|
ws.send(chunk);
|
||||||
|
} catch {
|
||||||
|
/* client gone; the close handler tidies up */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
close: () => closeClient(ws, 1011, 'upstream closed'),
|
||||||
|
error: () => closeClient(ws, 1011, 'upstream error'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Between the await above and here the client may have gone. Writing to a socket nobody is reading
|
||||||
|
// would keep the upstream connection open for as long as the plugin's process lives.
|
||||||
|
if (relay.done) {
|
||||||
|
socket.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
relay.socket = socket;
|
||||||
|
for (const chunk of relay.queue) socket.write(chunk);
|
||||||
|
relay.queue.length = 0;
|
||||||
|
} catch {
|
||||||
|
// A plugin whose process is not listening is the ordinary case, not an exception: a sidecar that has
|
||||||
|
// not booted yet, or a plugin whose service is stopped. The client is told, and told which plugin.
|
||||||
|
closeClient(ws, 1011, `${ws.data.plugin} is not accepting connections`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
message(ws: ServerWebSocket<PluginWSData>, message: string | Uint8Array) {
|
||||||
|
const relay = relays.get(ws);
|
||||||
|
if (!relay) return;
|
||||||
|
const bytes = typeof message === 'string' ? new TextEncoder().encode(message) : message;
|
||||||
|
if (relay.socket) relay.socket.write(bytes);
|
||||||
|
else relay.queue.push(bytes);
|
||||||
|
},
|
||||||
|
|
||||||
|
close(ws: ServerWebSocket<PluginWSData>) {
|
||||||
|
const relay = relays.get(ws);
|
||||||
|
relays.delete(ws);
|
||||||
|
if (relay) relay.done = true;
|
||||||
|
try {
|
||||||
|
relay?.socket?.end();
|
||||||
|
} catch {
|
||||||
|
/* already gone */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/api/plugin-ws/<plugin>` with an upgrade header.
|
||||||
|
*
|
||||||
|
* Authorisation is PER PLUGIN, which is why `plugin-ws` is exempt from the boot-time totality check rather
|
||||||
|
* than carrying a registry entry of its own. One entry covering every plugin's socket would be a hole:
|
||||||
|
* granting one plugin would grant them all.
|
||||||
|
*
|
||||||
|
* So the decision is delegated to the plugin's OWN capability, by asking the same question its HTTP routes
|
||||||
|
* are asked — `isApiRequestAllowed(user, 'GET', '/api/<plugin>')`. That is deliberate and it is the whole
|
||||||
|
* safety argument: there is no second permission to grant, to forget, or to drift out of step. Granting a
|
||||||
|
* plugin grants its socket, and revoking it closes it.
|
||||||
|
*
|
||||||
|
* Async, like `upgradeWs`. `server.upgrade` must be called synchronously with respect to the request, but
|
||||||
|
* Bun accepts a route handler that returns a promise — which is what lets the token resolve, the blacklist
|
||||||
|
* check and the permission decision all happen before anything is upgraded.
|
||||||
|
*/
|
||||||
|
export async function upgradePluginWs(req: Request, server: any): Promise<Response | undefined> {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const match = url.pathname.match(/^\/api\/plugin-ws\/([a-z0-9-]{1,64})\/?$/);
|
||||||
|
if (!match) return undefined;
|
||||||
|
const plugin = match[1]!;
|
||||||
|
|
||||||
|
// A browser cannot set headers on a WebSocket upgrade, so the token arrives as a query parameter — the
|
||||||
|
// concession every other socket here already makes. Verified through the same resolver as the HTTP doors,
|
||||||
|
// so an API key that works against /api works here too.
|
||||||
|
const token = url.searchParams.get('token');
|
||||||
|
if (!token) return new Response('Unauthorized', { status: 401 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await resolveAuthToken(token);
|
||||||
|
if (!user) return new Response('Unauthorized', { status: 401 });
|
||||||
|
if (user.jti && (await isTokenBlacklisted(user.jti))) return new Response('Unauthorized', { status: 401 });
|
||||||
|
|
||||||
|
// The plugin's own gate, asked as if this were a GET against its HTTP prefix.
|
||||||
|
if (!(await isApiRequestAllowed(user.id, 'GET', `/api/${plugin}`)).allowed) {
|
||||||
|
return new Response('Forbidden', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = await pluginWsTargetFor(plugin);
|
||||||
|
// 404 for both "no such plugin" and "no socket declared", identically. A caller must not be able to
|
||||||
|
// discover which plugins are installed by which refusal it gets — the same reasoning as SendOff's
|
||||||
|
// recipient lookup.
|
||||||
|
if (!target) return new Response('Not found', { status: 404 });
|
||||||
|
|
||||||
|
const ok = server.upgrade(req, {
|
||||||
|
data: {
|
||||||
|
provider: 'plugin-ws',
|
||||||
|
plugin,
|
||||||
|
host: target.host,
|
||||||
|
port: target.port,
|
||||||
|
userId: user.id,
|
||||||
|
} satisfies PluginWSData,
|
||||||
|
});
|
||||||
|
return ok ? undefined : new Response('Upgrade failed', { status: 400 });
|
||||||
|
} catch {
|
||||||
|
// Any failure to establish WHO is calling denies. Never open a socket on a maybe.
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,12 @@ const EXEMPT_WS_PROVIDERS: Record<string, string> = {
|
|||||||
// same cookie the HTTP side issues after verifying the OWNER's token — so it carries proof of the owner
|
// same cookie the HTTP side issues after verifying the OWNER's token — so it carries proof of the owner
|
||||||
// without carrying an account through `upgradeWs`. Refused outright without that cookie.
|
// without carrying an account through `upgradeWs`. Refused outright without that cookie.
|
||||||
'dev-proxy': 'dev server HMR relay; gated by the owner-only cookie the dev proxy issues',
|
'dev-proxy': 'dev server HMR relay; gated by the owner-only cookie the dev proxy issues',
|
||||||
|
// One relay serving every plugin's socket, so a single registry entry here would be a HOLE: granting one
|
||||||
|
// plugin would grant them all. `upgradePluginWs` therefore asks the PLUGIN's own capability, as if the
|
||||||
|
// socket were a GET against its HTTP prefix — which means the decision is already in the registry, just
|
||||||
|
// not under this provider's name. Exempt because the check is per-request and per-plugin, never because
|
||||||
|
// it is absent.
|
||||||
|
'plugin-ws': "relayed per plugin; authorised by that plugin's own capability in upgradePluginWs",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -282,6 +282,26 @@ export type PluginManifest = {
|
|||||||
* a missing one that cannot be installed here refuses the install rather than proceeding to a plugin
|
* a missing one that cannot be installed here refuses the install rather than proceeding to a plugin
|
||||||
* that runs and quietly produces nothing.
|
* that runs and quietly produces nothing.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* A websocket this plugin serves, relayed by the platform.
|
||||||
|
*
|
||||||
|
* Declaring this makes `/api/plugin-ws/<appName>` a byte relay onto the address below. It exists because
|
||||||
|
* Bun's route table is fixed at `serve()`, so a plugin installed afterwards could never add a socket route
|
||||||
|
* — which is why `/minesweeper` had to live in the platform and `plugins/desktop` could not stream a
|
||||||
|
* display at all.
|
||||||
|
*
|
||||||
|
* Authorisation is the plugin's OWN capability, asked as if the socket were a GET against its HTTP prefix.
|
||||||
|
* There is deliberately no separate socket permission: granting the plugin grants its socket, and nothing
|
||||||
|
* can drift out of step because there is only one decision.
|
||||||
|
*
|
||||||
|
* `{ port }` for a fixed loopback port the plugin's own service listens on. `{ sidecar }` when the target
|
||||||
|
* is a sidecar, whose port is ephemeral and announced at registration — a manifest cannot know that
|
||||||
|
* number, which is the whole reason the indirection exists.
|
||||||
|
*
|
||||||
|
* The host is always loopback and cannot be set. A plugin naming an arbitrary host would be a
|
||||||
|
* request-forgery primitive reachable by anyone the plugin is granted to.
|
||||||
|
*/
|
||||||
|
websocket?: { port: number } | { sidecar: string };
|
||||||
osDependencies?: OsDependency[];
|
osDependencies?: OsDependency[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -120,6 +120,25 @@ export function unregisterSidecar(id: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Every sidecar's announced HTTP port, by name.
|
||||||
|
|
||||||
|
`createSidecarProxy` already learns this for the prefix it serves, by subscribing to `<name>:server`. That
|
||||||
|
is per-proxy state and unreachable from anywhere else, which was fine while the only consumer was the
|
||||||
|
proxy itself — and stopped being fine when a plugin needed a WEBSOCKET relayed onto the same sidecar. A
|
||||||
|
second subscription in a second place would be a second thing to keep in step.
|
||||||
|
|
||||||
|
So the port is recorded here, once, from the same event. A sidecar that has not announced yet is absent
|
||||||
|
rather than zero: "not listening" and "listening on nothing" are different answers and only one of them is
|
||||||
|
true.
|
||||||
|
*/
|
||||||
|
const sidecarPorts = new Map<string, number>();
|
||||||
|
|
||||||
|
/** The port a named sidecar announced, or null if it has not announced one. */
|
||||||
|
export function sidecarPort(name: string): number | null {
|
||||||
|
return sidecarPorts.get(name) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export function handleSidecarMessage(id: string, msg: SidecarEvent): void {
|
export function handleSidecarMessage(id: string, msg: SidecarEvent): void {
|
||||||
// Check if this is a response to a pending request
|
// Check if this is a response to a pending request
|
||||||
if ('id' in msg && msg.id && pending.has(msg.id)) {
|
if ('id' in msg && msg.id && pending.has(msg.id)) {
|
||||||
@@ -130,6 +149,13 @@ export function handleSidecarMessage(id: string, msg: SidecarEvent): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record any `<name>:server` announcement before dispatching, so `sidecarPort` is current by the time a
|
||||||
|
// subscriber reacts to the same event.
|
||||||
|
if ('type' in msg && typeof msg.type === 'string' && msg.type.endsWith(':server')) {
|
||||||
|
const port = (msg as { port?: number }).port;
|
||||||
|
if (typeof port === 'number') sidecarPorts.set(msg.type.slice(0, -':server'.length), port);
|
||||||
|
}
|
||||||
|
|
||||||
// Otherwise dispatch as event
|
// Otherwise dispatch as event
|
||||||
dispatchEvent(msg);
|
dispatchEvent(msg);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user