@ai-ecoverse/slicc-kernel 1.89.0 → 1.90.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,6 +45,7 @@ const { status, stdout, stderr } = await kernel.run(['bash', '-c', 'echo hi > he
45
45
  | `clipboard` | none | `{ read(stamp) => Promise<string>, write(text, stamp) => Promise<void> }`: the clipboard behind [`/dev/clipboard`](#devclipboard). `createNodeKernel` takes it too |
46
46
  | `approve` | none | `(req, { signal }) => 'once' \| 'always' \| 'deny'` (or a promise of one): decides a `sudo` request that needs approval (see [Sudo](#sudo)); without it, such a request is denied. `createNodeKernel` takes it too |
47
47
  | `cdp` | none | `({ runtime }) => Promise<CdpConnection>`: a browser-level DevTools connection for programs that drive a browser (see [Browser automation](#browser-automation-cdp)) |
48
+ | `facades` | none | `{ link: { port, hook? } }`: kernel-only loopback ports whose connections a hook on the page serves, with the connecting program's identity (see [Page facades](#page-facades)) |
48
49
  | `protect` | none | `{ paths, writers? }`: directories no process may change, root included, in every `users` mode, except processes with one of the `writers` tags (see [Protected paths](#protected-paths)). `createNodeKernel` takes it too |
49
50
 
50
51
  ### `kernel.run(argv, options?) → Promise<{ status, signal, stdout, stderr }>`
@@ -176,6 +177,7 @@ Attaches to a kernel over a port from `kernel.connect()`, in any realm: a worker
176
177
  | `fetch({ url, method, headers, body, signal })` | through the kernel's network transport (the one the page passed to `createKernel`), with a streaming body; `transport` is the same as a `NetworkTransport` |
177
178
  | `fs.watch(paths, { recursive }, onChange)` | resolves with `{ close() }`; `onChange` gets `{ paths }`, the changed paths at, below (or, without `recursive`, directly in) the watched ones, batched per task, or `{ overflow: true }` when there were too many to list, to rescan; it covers every change made through this kernel, by its processes and its clients |
178
179
  | `serveCdp(hook, { runtime })` | offers this client's browser to the kernel's programs (protocol 1.5): `hook` is the `cdp` hook of `createKernel`, asked once per program connection; resolves with `{ close() }`, and closing or detaching takes the offer back and closes its connections (see [Browser automation](#browser-automation-cdp)) |
180
+ | `serveFacade(name, hook) → { close() }` | serves a facade that `createKernel({ facades })` declared, the same way as its `hook` (protocol 1.11); the latest registration wins, over the embedder's hook too, and `close()` hands it back. An undeclared name is `EINVAL` |
179
181
  | `onTraits(listener) → () => void` | calls `listener(traits)` each time the kernel announces new transport traits (`kernel.updateTransportTraits()`, protocol 1.10), after `client.transport.traits` has been updated; the function it returns removes the listener |
180
182
  | `close({ kill })` | detaches: the client's processes keep running unless `kill` is set, which ends their process groups with `SIGKILL`; its terminals are hung up |
181
183
  | `closed` | resolves with the error that ended the client |
@@ -196,7 +198,7 @@ const { status, stdout } = await kernel.run(['bash', '-c', 'echo hi'], { cwd: '/
196
198
  kernel.terminate();
197
199
  ```
198
200
 
199
- `createNodeKernel({ root, modules, commands, env, network, worker, processMounts, fstabRetries, cdp, hostfs, hostfsOrigin, hostfsFetch, users, onDenial, approve })` takes the options of `createKernel` except `metadata`, and `fstabRetries`, the delays in ms between tries of a failing `/etc/fstab` line (default `[1000, 4000, 16000]`). `root` is an in-memory directory by default (`memoryRoot()` makes another), which keeps each file in 1 MiB chunks, and POSIX metadata stays in memory. Processes and threads run on `worker_threads`. The kernel has `run`, `openTerminal`, `users`, `setRoutes`, `updateTransportTraits` and `terminate` as above, plus `integrity.revoke(path?)`, `root`, `writeFile(path, data, { mode }?)` (creating the parent directories; `mode` sets the permission bits, `0` to `0o7777`, before the data is written), `chmod(path, mode)` and `readFile(path)` to put files in place and read results; like the `fs` of an attached client, they go through the mounts, as the processes see them. `nodeTransport()` is `fetchTransport()` with Node's `fetch`, which no CORS binds (`crossOrigin: 'any'`). `connect({ user })` and `attachKernel` work as in the browser: the port is a `worker_threads` `MessagePort`, which a worker thread can attach with, and `terminate()` ends every attached client.
201
+ `createNodeKernel({ root, modules, commands, env, network, worker, facades, processMounts, fstabRetries, cdp, hostfs, hostfsOrigin, hostfsFetch, users, onDenial, approve })` takes the options of `createKernel` except `metadata`, and `fstabRetries`, the delays in ms between tries of a failing `/etc/fstab` line (default `[1000, 4000, 16000]`). `root` is an in-memory directory by default (`memoryRoot()` makes another), which keeps each file in 1 MiB chunks, and POSIX metadata stays in memory. Processes and threads run on `worker_threads`. The kernel has `run`, `openTerminal`, `users`, `setRoutes`, `updateTransportTraits` and `terminate` as above, plus `integrity.revoke(path?)`, `root`, `writeFile(path, data, { mode }?)` (creating the parent directories; `mode` sets the permission bits, `0` to `0o7777`, before the data is written), `chmod(path, mode)` and `readFile(path)` to put files in place and read results; like the `fs` of an attached client, they go through the mounts, as the processes see them. `nodeTransport()` is `fetchTransport()` with Node's `fetch`, which no CORS binds (`crossOrigin: 'any'`). `connect({ user })` and `attachKernel` work as in the browser: the port is a `worker_threads` `MessagePort`, which a worker thread can attach with, and `terminate()` ends every attached client.
200
202
 
201
203
  By default `nodeTransport()` refuses destinations on the host itself, because Node can reach them and a page can't: local DevTools on 9222–9229, dev servers, local admin APIs, and the cloud metadata address.
202
204
  - **What it refuses:** loopback (`127.0.0.0/8`, `::1`, and their IPv4-mapped forms), unspecified addresses (`0.0.0.0/8`, `::`), `localhost` and `*.localhost`, and link-local addresses (`169.254.0.0/16`, `fe80::/10`), plus AWS's IPv6 metadata address `fd00:ec2::254`.
@@ -416,7 +418,7 @@ Programs drive a browser with the Chrome DevTools Protocol over a WebSocket, whi
416
418
  - **Sessions** are Chrome's flattened ones: `Target.attachToTarget` with `flatten: true`, then commands with a top-level `sessionId`. Messages pass unchanged, as text frames up to 256 MiB (a larger one closes the socket with `1009`). Each program WebSocket gets a host connection of its own: closing the socket (or exiting) closes it, which detaches its sessions, and a host that closes is passed on as a close frame (`1011` with its reason). The kernel then waits up to a second for the program's close frame before it hangs up, as RFC 6455 has it, so a program that answers the close can still report the code. `terminate()` closes them all.
417
419
  - **Runtime**: a program adds `runtime=<name>` to the query of the URL it opens, and the kernel gives it to the host; the kernel does not interpret it.
418
420
  - **No host**: `/json/*` and the WebSocket handshake answer `503` with `slicc-kernel: no CDP host is attached (createKernel({ cdp }) or client.serveCdp)`, and a host that refuses (an unknown runtime, say) gives `502` with its message.
419
- - **Kernel-only**: port `9222` takes connections only from programs in the kernel. `kernel.dial`, a client's `dial` and `loopbackFetch` (and so `9222.kernel.localhost` and slicc-node's tunnel) are refused with `ECONNREFUSED`, since the facade drives the user's real browser.
421
+ - **Kernel-only**: port `9222` is reserved (a program's `bind()` to it fails with `EADDRINUSE`, so none can squat it) and takes connections only from programs in the kernel. `kernel.dial`, a client's `dial` and `loopbackFetch` (and so `9222.kernel.localhost` and slicc-node's tunnel) are refused with `ECONNREFUSED`, since the facade drives the user's real browser.
420
422
 
421
423
  An embedder offers a browser with the `cdp` option of `createKernel` or `createNodeKernel`, or from an attached client with `client.serveCdp(hook, { runtime })`:
422
424
 
@@ -434,6 +436,31 @@ A connection is one browser-level CDP session; a host with only per-tab debuggin
434
436
 
435
437
  Process workers can also use the transport directly, without a socket: the `net-request`, `net-read` and `net-close` syscalls (`Module.sliccKernel.http` in a process) open a request and read its body in pieces. `net-open` starts a request and answers its handle at once, and `net-head` waits for its response head, so `net-close` can abort a request whose head has not come yet. Requests are per process and closed when it exits, and a request body has no size limit of its own (the transport's `maxRequestBody` applies to the proxy). `net-traits` answers the transport's `traits` with `crossOrigin`: `'cors'` when it is bound by CORS like `fetchTransport()`, `'any'` (the default for a transport that does not say) when it is not; without a transport it fails with `ENETUNREACH`.
436
438
 
439
+ ### Page facades
440
+
441
+ A facade is a loopback port that only programs in the kernel can reach, whose connections the embedder serves, much like the CDP facade, but as a plain duplex byte stream with no protocol of the kernel's. slicc's link layer is the first user.
442
+
443
+ ```ts
444
+ createKernel({ facades: { link: { port: 7400, hook } } });
445
+
446
+ type FacadeHook = (stamp: ConnectorStamp, info: { name: string }) => FacadeConnection | Promise<FacadeConnection>;
447
+
448
+ interface FacadeConnection {
449
+ send(bytes: Uint8Array): void;
450
+ onmessage: ((bytes: Uint8Array) => void) | null;
451
+ onclose: ((reason?: string) => void) | null;
452
+ close(reason?: string): void;
453
+ }
454
+
455
+ interface ConnectorStamp { pid: number; ppid: number; uid: number; gid: number; user?: string; groups: number[]; users: 'off' | 'report' | 'enforce'; tag?: string }
456
+ ```
457
+
458
+ - **Declared at boot:** `facades` names each facade (`[a-z][a-z0-9-]*`) and its port, from 1 to 65535. A port may not be 9222 (the CDP facade), 3128 (the realm proxy) or another facade's; anything else throws a `TypeError`. `createNodeKernel` takes `facades` too. The hook may be left out and supplied later by an attached client's `client.serveFacade(name, hook)`, which works only for a declared name.
459
+ - **Programs** find each facade in their environment as `SLICC_<NAME>_URL`, upper-cased with `-` as `_`, e.g. `SLICC_LINK_URL=tcp://127.0.0.1:7400`, and connect to it with an ordinary TCP `connect()`: a JS program's `ctx.net.connect`, an Emscripten program's socket, or a WASIX program's `sock_connect`. Every ABI ends in the same kernel call.
460
+ - **The stamp** is the connecting process's identity, taken by the kernel at `connect()` from its credentials, as Linux's `SO_PEERCRED` is: its pid and parent pid as `ps` shows them, effective uid and gid, user name, groups, the `users` mode and the process's tag (see `kernel.run`), the same fields as a `/dev/sealed` request's `caller`. A program cannot forge it. The hook gets it and `{ name }`, so one function can serve several facades, and decides: it resolves with a connection, whose bytes then pass both ways until either side closes, or it rejects, and the program's connection is closed. A connection while no hook is attached is closed at once, the same as a refusal. Bytes from the hook wait in the kernel until the program reads them, up to 16 MiB per connection: past that, the connection is closed and the hook's `close(reason)` gets `slicc-kernel: more than 16777216 bytes are waiting for the program to read`. A program that connects to anything else pays nothing for this: the stamp is built only for a facade's port.
461
+ - **Handing a connection on hands over the identity:** a facade connection that a process passes to another (a fork, an exec, an inherited descriptor) still speaks as the process that connected, as on Linux. So a privileged process should not pass one to a less trusted one.
462
+ - **Reserved:** a facade's port is reserved from the start. A program's `bind()` to it fails with `EADDRINUSE`, so no program can squat it and impersonate the page, and ephemeral ports skip it. Port 9222 is reserved the same way. Like 9222, a facade's port is kernel-only: the page's `kernel.dial`, a client's `dial` and `loopbackFetch` (and so `<port>.kernel.localhost`) are refused with `ECONNREFUSED`.
463
+
437
464
  ### Local proxy protocol
438
465
 
439
466
  The page and the proxy speak raw mode of SLICC's `/api/fetch-proxy`. Every request is one `POST {url}/api/fetch-proxy` with `credentials: 'omit'`:
@@ -470,7 +497,7 @@ Directories are renamed with `FileSystemHandle.move()` where available, else by
470
497
  - **Rules on a mount:**
471
498
  - a rename across mounts is `EXDEV`, so `mv` copies;
472
499
  - a symlink on the root file system that leads onto a mount, such as `~/proj` → `/mnt/drive/proj`, or a chain from a mount to the root and back, is followed onto the mount by every operation, in every runtime: reads, writes, `stat`, listings, `mkdir`, removals, renames, `chmod`, `chown` and times. Removing the link itself removes the link. Before [#547](https://github.com/ai-ecoverse/slicc-kernel/issues/547) was fixed, the kernel resolved such a link against the directory under the mount point for JS and WASI programs, the embedder's `fs` and attached clients (Emscripten programs weren't affected): reads failed with `ENOENT`, and writes went into that hidden directory, which shows again once the mount is gone;
473
- - a hard link (`link()`, `linkat()`, Python's `os.link`, `ctx.fs.link`) works within a mount whose driver offers `links`, as `tmpfs` does: both names are one file with one inode, `st_nlink` counts them, and the data goes with the last name and the last open descriptor. Across mounts it's `EXDEV`. It's `EPERM` on the root file system (OPFS or memory), on `fsa` folders, on `hostfs` until slicc-node offers links, and for directories; on the root the names are checked first, as Linux does: a missing source (or, when following, a dangling one) or target directory is `ENOENT`, a target directory that isn't one is `ENOTDIR`, and a taken name is `EEXIST`. `link()` links a symlink itself and `linkat(…, AT_SYMLINK_FOLLOW)` links its target, as on Linux. Descriptors the kernel holds (JS programs' files, and a WASI program's files once it forks or spawns with them) share one cache for all names of a file, so a write through one name is read at once through another, also after either name is removed or replaced while open; `link()` writes the source's pending changes back first. Within one WASI program, its own descriptors of a file's names share one buffer too. Once it starts a subprocess, the files it had open are kernel descriptors, and a later open of one of them, under that name or another (same inode), goes through the kernel and shares their cache; between programs, a WASI program's own descriptors see the other names' writes at open, as for one name across processes. A WASI program that removes or renames over the name it has open, while another link remains, keeps writing to the file through its descriptor, and the other names see those writes; `linkat(…, AT_SYMLINK_FOLLOW)` writes back the program's pending changes to the symlink's target first. `st_nlink` leaves out the names the kernel keeps in `.slicc-unlinked` for open descriptors, so `fstat` on a descriptor whose name was removed counts the names left: 0 once the last one is gone. The other names of a linked file keep the attributes the kernel has cached for them for up to the mount's `attrTtl` (`tmpfs` caches none). Emscripten programs (bash, coreutils `ln`) get `EMLINK` from their own C library, which never asks the kernel, but they see links other programs made: their open descriptors of one file's names share one buffer, and one that removes the name it has open while another link remains keeps writing to the file;
500
+ - a hard link (`link()`, `linkat()`, Python's `os.link`, `ctx.fs.link`) works within a mount whose driver offers `links`, as `tmpfs` does: both names are one file with one inode, `st_nlink` counts them, and the data goes with the last name and the last open descriptor. Across mounts it's `EXDEV`. It's `EPERM` on the root file system (OPFS or memory), on `fsa` folders, on `hostfs` until slicc-node offers links, and for directories; on the root the names are checked first, as Linux does: a missing source (or, when following, a dangling one) or target directory is `ENOENT`, a target directory that isn't one is `ENOTDIR`, and a taken name is `EEXIST`. `link()` links a symlink itself and `linkat(…, AT_SYMLINK_FOLLOW)` links its target, as on Linux. Descriptors the kernel holds (JS programs' files, and a WASI program's files once it forks or spawns with them) share one cache for all names of a file, so a write through one name is read at once through another, also after either name is removed or replaced while open; `link()` writes the source's pending changes back first. Within one WASI program, its own descriptors of a file's names share one buffer too. Once it starts a subprocess, the files it had open are kernel descriptors, and a later open of one of them, under that name or another (same inode), goes through the kernel and shares their cache; between programs, a WASI program's own descriptors see the other names' writes at open, as for one name across processes. A WASI program that removes or renames over the name it has open, while another link remains, keeps writing to the file through its descriptor, and the other names see those writes; `linkat(…, AT_SYMLINK_FOLLOW)` writes back the program's pending changes to the symlink's target first. `st_nlink` leaves out the names the kernel keeps in `.slicc-unlinked` for open descriptors, so `fstat` on a descriptor whose name was removed counts the names left: 0 once the last one is gone. The other names of a linked file keep the attributes the kernel has cached for them for up to the mount's `attrTtl` (`tmpfs` caches none). So it works for WASI, WASIX and JS programs, and for Emscripten programs built with a `__syscall_linkat` import (wasm-coreutils and wasm-tar from the next homescoop builds), which get `EMLINK` where the others get `EPERM` because the file system has no hard links (the root, `fsa`, `hostfs`), as Emscripten itself answers. Older Emscripten builds (wasm-coreutils 9.12.0-5's `ln`, wasm-tar 1.35.0-5) answer `EMLINK` from their own C library, which never asks the kernel, but they see links other programs made: their open descriptors of one file's names share one buffer, and one that removes the name it has open while another link remains keeps writing to the file;
474
501
  - `df` reports each mount from its driver's `statfs`;
475
502
  - each mount has its own device number;
476
503
  - without `chmod` support, `chmod` succeeds and changes nothing;
@@ -731,7 +758,7 @@ OPFS stays authoritative: an entry is consulted only for a path that exists in O
731
758
 
732
759
  The database is `slicc-kernel` (or the `metadata` option), schema version 1, with one object store, `entries`, keyed by absolute path (`keyPath: 'path'`) and an index `dir` on the parent directory of symlinks. A record looks like `{ path, mode?, atimeMs?, mtimeMs?, mtimeFor?, ctimeMs?, ino?, link?, dir?, uid?, gid?, heir?, writes?, epoch? }`, where `mtimeFor` is the OPFS `lastModified` an explicit `mtimeMs` belongs to, `uid`, `gid` and `heir` (`[uid, gid]`) are the owners of [File owners](#file-owners), `writes` counts the writes kernels with `integrity` made to a protected file (see [Protected paths](#protected-paths)), whose `lastModified` OPFS leaves alone for a write through a sync access handle, and `epoch`, on the entry of `/`, counts their renames and symlinks at or above a protected path. A future schema bumps the version and migrates in `onupgradeneeded`; an older kernel then closes its connection. The sidecar is keyed by path from the `root` handle, so use a different `metadata` name for each distinct root.
733
760
 
734
- Not covered: hard links (they fail with `EMLINK`, as Emscripten reports; OPFS cannot share contents between names). File owners are stored only with `users: 'report'` or `'enforce'` (see [File owners](#file-owners)). With `users: 'off'`, an Emscripten process built against the kernel's credential calls (its glue imports `slicc_cred_js`, as homescoop's H1 builds do) sees every file as owned by its own effective uid and gid: root's 0 by default, a user's own ids when it runs as one, and the new ids after a `setuid`. Owner checks such as git's `safe.directory`, ssh's key checks and gnupg's home directory check then pass, as WASIX programs already see through their libc. An older program, whose libc says it is uid 1000 whoever runs it, sees every file as owned by 1000:1000 as before, so its own checks pass too. The kernel decides this once, when it loads the program. Emscripten also doesn't enforce permission bits, so a file runs whether or not its exec bit is set; the bits are what `ls`, `stat` and `test` report. With `users: 'off'` a file or directory with no permission bits at all shows as `0666` or `0777` to Emscripten programs; with owners on they see the real mode (`0`), and the kernel, not Emscripten's own checks, decides who may read, write or enter it (`cd` asks the kernel for `x` on the directory), so root still enters a `000` directory.
761
+ Not covered: hard links (OPFS cannot share contents between names; they work only within a mount whose driver offers `links`, such as `tmpfs`). File owners are stored only with `users: 'report'` or `'enforce'` (see [File owners](#file-owners)). With `users: 'off'`, an Emscripten process built against the kernel's credential calls (its glue imports `slicc_cred_js`, as homescoop's H1 builds do) sees every file as owned by its own effective uid and gid: root's 0 by default, a user's own ids when it runs as one, and the new ids after a `setuid`. Owner checks such as git's `safe.directory`, ssh's key checks and gnupg's home directory check then pass, as WASIX programs already see through their libc. An older program, whose libc says it is uid 1000 whoever runs it, sees every file as owned by 1000:1000 as before, so its own checks pass too. The kernel decides this once, when it loads the program. Emscripten also doesn't enforce permission bits, so a file runs whether or not its exec bit is set; the bits are what `ls`, `stat` and `test` report. With `users: 'off'` a file or directory with no permission bits at all shows as `0666` or `0777` to Emscripten programs; with owners on they see the real mode (`0`), and the kernel, not Emscripten's own checks, decides who may read, write or enter it (`cd` asks the kernel for `x` on the directory), so root still enters a `000` directory.
735
762
 
736
763
  Reads retry briefly when a concurrent write has invalidated the OPFS file snapshot (`NotReadableError`).
737
764
 
@@ -43,6 +43,56 @@ function serveCdpPort(port, hook, request, onDone) {
43
43
  };
44
44
  }
45
45
 
46
+ // src/facade/port.ts
47
+ function serveFacadePort(port, hook, { name, stamp }, onDone) {
48
+ let conn;
49
+ let done = false;
50
+ const end = () => {
51
+ done = true;
52
+ port.close();
53
+ onDone?.();
54
+ };
55
+ const finish = () => {
56
+ if (done) return;
57
+ port.postMessage({ close: true });
58
+ end();
59
+ };
60
+ port.addEventListener(
61
+ "message",
62
+ ({ data }) => {
63
+ if (data.b !== void 0) conn?.send(data.b);
64
+ else if (data.close && !done) {
65
+ end();
66
+ conn?.close(typeof data.close === "string" ? data.close : void 0);
67
+ }
68
+ }
69
+ );
70
+ port.start?.();
71
+ new Promise((resolve) => resolve(hook(stamp, { name }))).then(
72
+ (opened) => {
73
+ conn = opened;
74
+ if (done) return opened.close();
75
+ opened.onmessage = (bytes) => {
76
+ if (done) return;
77
+ const copy = bytes.slice();
78
+ port.postMessage({ b: copy }, [copy.buffer]);
79
+ };
80
+ opened.onclose = () => finish();
81
+ port.postMessage({ open: true });
82
+ },
83
+ (e) => {
84
+ if (done) return;
85
+ port.postMessage({ error: e instanceof Error ? e.message : String(e) });
86
+ end();
87
+ }
88
+ );
89
+ return () => {
90
+ if (done) return;
91
+ conn?.close();
92
+ finish();
93
+ };
94
+ }
95
+
46
96
  // src/kernel/net/ws-queue.ts
47
97
  var INBOUND_LIMIT = 16 * 1024 * 1024;
48
98
  var MESSAGE_LIMIT = 64 * 1024 * 1024;
@@ -573,7 +623,7 @@ async function loopbackFetch(dial, input, options) {
573
623
  }
574
624
 
575
625
  // src/client/protocol.ts
576
- var PROTOCOL = [1, 10];
626
+ var PROTOCOL = [1, 11];
577
627
  function locksOf(scope = globalThis) {
578
628
  const locks = scope.navigator?.locks;
579
629
  return typeof locks?.request === "function" ? locks : void 0;
@@ -640,6 +690,8 @@ var Channel = class {
640
690
  watching = /* @__PURE__ */ new Map();
641
691
  cdpHooks = /* @__PURE__ */ new Map();
642
692
  cdpOpen = /* @__PURE__ */ new Set();
693
+ facadeHooks = /* @__PURE__ */ new Map();
694
+ facadeOpen = /* @__PURE__ */ new Set();
643
695
  protocol = PROTOCOL;
644
696
  nextId = 0;
645
697
  port;
@@ -662,6 +714,8 @@ var Channel = class {
662
714
  this.remote?.fail(error);
663
715
  for (const close of [...this.cdpOpen]) close();
664
716
  this.cdpOpen.clear();
717
+ for (const close of [...this.facadeOpen]) close();
718
+ this.facadeOpen.clear();
665
719
  for (const call of this.pending.values()) call.reject(error);
666
720
  this.pending.clear();
667
721
  this.release();
@@ -691,6 +745,10 @@ var Channel = class {
691
745
  this.openCdp(data);
692
746
  return;
693
747
  }
748
+ if (data.facadeOpen && data.port) {
749
+ this.openFacade(data);
750
+ return;
751
+ }
694
752
  if (data.watch !== void 0) {
695
753
  this.watching.get(data.watch)?.(data.change);
696
754
  return;
@@ -733,7 +791,36 @@ var Channel = class {
733
791
  const close = serveCdpPort(port, hook, request, () => this.cdpOpen.delete(close));
734
792
  this.cdpOpen.add(close);
735
793
  }
794
+ openFacade({ facadeOpen: { registration, ...request }, port }) {
795
+ const hook = registration === void 0 ? void 0 : this.facadeHooks.get(registration);
796
+ if (!hook || this.failure) {
797
+ port.postMessage({ error: "this client no longer serves the facade" });
798
+ port.close();
799
+ return;
800
+ }
801
+ const close = serveFacadePort(port, hook, request, () => this.facadeOpen.delete(close));
802
+ this.facadeOpen.add(close);
803
+ }
736
804
  };
805
+ async function serveFacadeOn(channel, name, hook) {
806
+ if (typeof hook !== "function") throw new TypeError("serveFacade: hook must be a function");
807
+ await since(channel, 11, "facade serving", async () => {
808
+ });
809
+ const { id, done } = channel.request({ op: "serve-facade", name });
810
+ channel.facadeHooks.set(id, hook);
811
+ try {
812
+ await done;
813
+ } catch (error) {
814
+ channel.facadeHooks.delete(id);
815
+ throw error;
816
+ }
817
+ return {
818
+ close: () => {
819
+ if (!channel.facadeHooks.delete(id)) return;
820
+ void channel.call({ op: "unserve-facade", registration: id }).catch(() => void 0);
821
+ }
822
+ };
823
+ }
737
824
  async function serveCdpOn(channel, hook, options) {
738
825
  await since(channel, 5, "CDP serving", async () => {
739
826
  });
@@ -967,6 +1054,7 @@ async function attachKernel(port, options = {}) {
967
1054
  loopbackFetch: (input, options2) => loopbackFetch((o) => dialOn(channel, o), input, options2),
968
1055
  fs: fsOn(channel),
969
1056
  serveCdp: (hook, options2 = {}) => serveCdpOn(channel, hook, options2),
1057
+ serveFacade: (name, hook) => serveFacadeOn(channel, name, hook),
970
1058
  onTraits(listener) {
971
1059
  channel.traitsListeners.add(listener);
972
1060
  return () => channel.traitsListeners.delete(listener);