@code-yeongyu/senpi-codemode 2026.9.28-2 → 2026.9.28-3
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/CHANGELOG.md +14 -0
- package/package.json +4 -4
- package/src/bridge/protocol.ts +14 -0
- package/src/kernels/js/inline-worker.ts +2 -1
- package/src/kernels/js/local-module-loader.ts +2 -0
- package/src/kernels/js/webview-host.ts +59 -0
- package/src/kernels/js/worker-core.js +17 -0
- package/src/kernels/js/worker-host.ts +2 -1
- package/src/kernels/js/worker-runtime.js +2 -1
- package/src/kernels/js/worker-slot.ts +14 -5
- package/src/kernels/js/worker-webview-client.js +79 -0
- package/src/kernels/js/worker-webview.js +204 -0
- package/src/kernels/shared/subprocess-queue.ts +2 -0
- package/src/skill/bun-1-4/references/runtime-apis.md +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,20 @@
|
|
|
12
12
|
|
|
13
13
|
### Removed
|
|
14
14
|
|
|
15
|
+
## [2026.9.28-3] - 2026-09-28
|
|
16
|
+
|
|
17
|
+
### Breaking Changes
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- `new Bun.WebView()` in a JavaScript eval cell no longer fails with `Bun.WebView with backend "chrome" is only available on the main thread` (every call on Windows and Linux, `backend: "chrome"` on macOS). Cells see a `Bun` whose `WebView` (also through `import { WebView } from "bun"`) hands Chrome-backed views to the process main thread with the same API: navigation, input, `evaluate`, screenshots, `cdp()` and its events, `console` capture, `url`/`title`/`loading`, `close()` and `await using`. The macOS default (WebKit) stays a native view in the kernel worker. `Bun.WebView.closeAll()` in a cell closes only that kernel's views. ([#2248](https://github.com/code-yeongyu/senpi/issues/2248))
|
|
26
|
+
|
|
27
|
+
### Removed
|
|
28
|
+
|
|
15
29
|
## [2026.9.28-2] - 2026-09-28
|
|
16
30
|
|
|
17
31
|
### Breaking Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@code-yeongyu/senpi-codemode",
|
|
3
|
-
"version": "2026.9.28-
|
|
3
|
+
"version": "2026.9.28-3",
|
|
4
4
|
"description": "Source-only senpi extension package for codemode evaluation tools",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -30,14 +30,14 @@
|
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@babel/parser": "8.0.4",
|
|
33
|
-
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.9.28-
|
|
33
|
+
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.9.28-3",
|
|
34
34
|
"typebox": "1.3.34"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
|
-
"@code-yeongyu/senpi": "2026.9.28-
|
|
37
|
+
"@code-yeongyu/senpi": "2026.9.28-3"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@code-yeongyu/senpi": "2026.9.28-
|
|
40
|
+
"@code-yeongyu/senpi": "2026.9.28-3"
|
|
41
41
|
},
|
|
42
42
|
"keywords": [
|
|
43
43
|
"senpi",
|
package/src/bridge/protocol.ts
CHANGED
|
@@ -66,6 +66,19 @@ const hostToKernelMessageSchema = Type.Union([
|
|
|
66
66
|
Type.Object({
|
|
67
67
|
type: Type.Literal("close"),
|
|
68
68
|
}),
|
|
69
|
+
/** JS only: the kernel's private port to the main-thread Bun.WebView service, sent in the transfer list. */
|
|
70
|
+
Type.Object({
|
|
71
|
+
type: Type.Literal("webview-port"),
|
|
72
|
+
requestId: Type.String({ minLength: 1 }),
|
|
73
|
+
ok: Type.Literal(true),
|
|
74
|
+
port: Type.Unknown(),
|
|
75
|
+
}),
|
|
76
|
+
Type.Object({
|
|
77
|
+
type: Type.Literal("webview-port"),
|
|
78
|
+
requestId: Type.String({ minLength: 1 }),
|
|
79
|
+
ok: Type.Literal(false),
|
|
80
|
+
error: bridgeErrorSchema,
|
|
81
|
+
}),
|
|
69
82
|
...kernelToolHostToKernelSchemas,
|
|
70
83
|
]);
|
|
71
84
|
|
|
@@ -106,6 +119,7 @@ const kernelToHostMessageSchema = Type.Union([
|
|
|
106
119
|
durationMs: Type.Integer({ minimum: 0 }),
|
|
107
120
|
}),
|
|
108
121
|
Type.Object({ type: Type.Literal("closed") }),
|
|
122
|
+
Type.Object({ type: Type.Literal("webview-connect"), requestId: Type.String({ minLength: 1 }) }),
|
|
109
123
|
...kernelToolKernelToHostSchemas,
|
|
110
124
|
]);
|
|
111
125
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { dirname, join } from "node:path";
|
|
2
2
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
3
|
+
import type { MessagePort } from "node:worker_threads";
|
|
3
4
|
import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
4
5
|
import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
|
|
5
6
|
import type { JavaScriptKernelMode } from "./kernel-contract.ts";
|
|
@@ -18,7 +19,7 @@ export function resolveInlineWorkerEntryUrl(options: JavaScriptInlineWorkerEntry
|
|
|
18
19
|
|
|
19
20
|
export interface WorkerLike {
|
|
20
21
|
readonly mode: JavaScriptKernelMode;
|
|
21
|
-
postMessage(message: HostToKernelMessage): void;
|
|
22
|
+
postMessage(message: HostToKernelMessage, transfer?: readonly MessagePort[]): void;
|
|
22
23
|
onMessage(handler: (message: KernelToHostMessage) => void): () => void;
|
|
23
24
|
onError(handler: (error: Error) => void): () => void;
|
|
24
25
|
terminate(): Promise<void>;
|
|
@@ -77,6 +77,8 @@ function loaderPrelude(context: RuntimeModuleContext): string {
|
|
|
77
77
|
"globalThis.__senpi_import__ = async (source, options) => {",
|
|
78
78
|
" const context = globalThis.__senpi_module_context__;",
|
|
79
79
|
" const specifier = String(source);",
|
|
80
|
+
" const kernelBun = globalThis[Symbol.for('senpi.kernel.bun')];",
|
|
81
|
+
" if (specifier === 'bun' && kernelBun) return { ...(await import('bun')), WebView: kernelBun.WebView, default: kernelBun };",
|
|
80
82
|
" const match = /^([a-z][a-z0-9+.-]*):\\/\\/(.*)$/i.exec(specifier);",
|
|
81
83
|
" let target = specifier;",
|
|
82
84
|
" if (match) {",
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { MessagePort } from "node:worker_threads";
|
|
2
|
+
import type { WebViewServiceConnection } from "@code-yeongyu/senpi";
|
|
3
|
+
import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
4
|
+
import { bridgeError } from "./worker-host.ts";
|
|
5
|
+
|
|
6
|
+
export type WebViewConnector = () => Promise<WebViewServiceConnection>;
|
|
7
|
+
|
|
8
|
+
// Loaded on the first WebView a cell asks for, so starting a kernel never pays for the host barrel.
|
|
9
|
+
const connectThroughHost: WebViewConnector = async () => (await import("@code-yeongyu/senpi")).connectWebViewService();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The WebView clients one worker generation asked for. Chrome-backed `Bun.WebView`s only run on the
|
|
13
|
+
* process main thread, so the worker gets a private port to the main-thread service; retiring the
|
|
14
|
+
* generation (reset, close, crash, timeout) releases every view created through those ports.
|
|
15
|
+
*/
|
|
16
|
+
export class KernelWebViewClients {
|
|
17
|
+
readonly #post: (message: HostToKernelMessage, transfer: readonly MessagePort[]) => void;
|
|
18
|
+
readonly #connect: WebViewConnector;
|
|
19
|
+
readonly #connections = new Set<WebViewServiceConnection>();
|
|
20
|
+
#retired = false;
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
post: (message: HostToKernelMessage, transfer: readonly MessagePort[]) => void,
|
|
24
|
+
connect: WebViewConnector = connectThroughHost,
|
|
25
|
+
) {
|
|
26
|
+
this.#post = post;
|
|
27
|
+
this.#connect = connect;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
consume(message: KernelToHostMessage): boolean {
|
|
31
|
+
if (message.type !== "webview-connect") return false;
|
|
32
|
+
void this.#grant(message.requestId);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async release(): Promise<void> {
|
|
37
|
+
this.#retired = true;
|
|
38
|
+
const connections = [...this.#connections];
|
|
39
|
+
this.#connections.clear();
|
|
40
|
+
await Promise.allSettled(connections.map((connection) => connection.release()));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async #grant(requestId: string): Promise<void> {
|
|
44
|
+
let connection: WebViewServiceConnection;
|
|
45
|
+
try {
|
|
46
|
+
connection = await this.#connect();
|
|
47
|
+
} catch (error) {
|
|
48
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
49
|
+
if (!this.#retired) this.#post({ type: "webview-port", requestId, ok: false, error: bridgeError(cause) }, []);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (this.#retired) {
|
|
53
|
+
await connection.release();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
this.#connections.add(connection);
|
|
57
|
+
this.#post({ type: "webview-port", requestId, ok: true, port: connection.port }, [connection.port]);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -3,6 +3,7 @@ import { kernelToolError } from "./kernel-tools-errors.js";
|
|
|
3
3
|
import { createKernelToolPump } from "./kernel-tools-pump.js";
|
|
4
4
|
import { hostDeniedError, hostToolRefusal } from "./kernel-tools-scope.js";
|
|
5
5
|
import { JsWorkerRuntime } from "./worker-runtime.js";
|
|
6
|
+
import { installKernelWebView } from "./worker-webview.js";
|
|
6
7
|
|
|
7
8
|
// Mirrors INTERRUPT_ACK_OP and CHILD_LIFECYCLE_OP in src/bridge/reserved.ts (this worker file cannot import TypeScript).
|
|
8
9
|
const INTERRUPT_ACK_OP = "interrupt-ack";
|
|
@@ -25,6 +26,7 @@ export function createWorkerCore(transport, options) {
|
|
|
25
26
|
let runtime = null;
|
|
26
27
|
let activeCell = null;
|
|
27
28
|
const pendingTools = new Map();
|
|
29
|
+
const pendingWebViewPorts = new Map();
|
|
28
30
|
const nestedInvokes = new Map();
|
|
29
31
|
const kernelTools = createKernelToolPump({
|
|
30
32
|
getRuntime: () => runtime,
|
|
@@ -73,6 +75,13 @@ export function createWorkerCore(transport, options) {
|
|
|
73
75
|
return await promise;
|
|
74
76
|
}
|
|
75
77
|
|
|
78
|
+
function requestWebViewPort() {
|
|
79
|
+
const requestId = crypto.randomUUID();
|
|
80
|
+
const promise = new Promise((resolve, reject) => pendingWebViewPorts.set(requestId, { resolve, reject }));
|
|
81
|
+
emit({ type: "webview-connect", requestId });
|
|
82
|
+
return promise;
|
|
83
|
+
}
|
|
84
|
+
|
|
76
85
|
function interruptCell(reason) {
|
|
77
86
|
if (!activeCell || !runtime) return;
|
|
78
87
|
emit({ type: "status", event: { op: INTERRUPT_ACK_OP, cellId: activeCell.cellId } });
|
|
@@ -92,8 +101,16 @@ export function createWorkerCore(transport, options) {
|
|
|
92
101
|
runtime?.kernelTools.setCollisionNames(message.hostToolNames ?? [], message.foreignLanguageNames ?? []);
|
|
93
102
|
return;
|
|
94
103
|
}
|
|
104
|
+
if (message.type === "webview-port") {
|
|
105
|
+
const pending = pendingWebViewPorts.get(message.requestId);
|
|
106
|
+
pendingWebViewPorts.delete(message.requestId);
|
|
107
|
+
if (message.ok) pending?.resolve(message.port);
|
|
108
|
+
else pending?.reject(errorFromBridge(message.error));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
95
111
|
if (message.type === "init") {
|
|
96
112
|
applySessionEnvironment(message.sessionEnv);
|
|
113
|
+
installKernelWebView(requestWebViewPort);
|
|
97
114
|
runtime = new JsWorkerRuntime({
|
|
98
115
|
cwd: options.cwd,
|
|
99
116
|
parallelPoolWidth: options.parallelPoolWidth,
|
|
@@ -88,7 +88,8 @@ export function bridgeError(error: Error): {
|
|
|
88
88
|
function wrapNodeWorker(worker: Worker, mode: JavaScriptKernelMode): WorkerLike {
|
|
89
89
|
return {
|
|
90
90
|
mode,
|
|
91
|
-
postMessage: (message) =>
|
|
91
|
+
postMessage: (message, transfer) =>
|
|
92
|
+
worker.postMessage(message, transfer === undefined ? undefined : [...transfer]),
|
|
92
93
|
onMessage(handler) {
|
|
93
94
|
const listener = (message: KernelToHostMessage): void => handler(message);
|
|
94
95
|
worker.on("message", listener);
|
|
@@ -6,6 +6,7 @@ import { encodeDisplayImage, resolveDisplayOps } from "./display-image.js";
|
|
|
6
6
|
import { terminateProcessTrees } from "./process-tree.js";
|
|
7
7
|
import { awaitMaybePromise, indirectEval, wrapUserCode } from "./worker-indirect-eval.js";
|
|
8
8
|
import { installShellCapture } from "./worker-shell-capture.js";
|
|
9
|
+
import { bindKernelBun } from "./worker-webview.js";
|
|
9
10
|
import { createWorkpool } from "./workpool.js";
|
|
10
11
|
import { inKernelToolInvoke } from "./kernel-tools-context.js";
|
|
11
12
|
import { kernelToolError } from "./kernel-tools-errors.js";
|
|
@@ -57,7 +58,7 @@ export class JsWorkerRuntime {
|
|
|
57
58
|
({ prelude, code: cellCode } = prepared);
|
|
58
59
|
}
|
|
59
60
|
if (prelude) indirectEval(prelude, `${cellId}:prelude`);
|
|
60
|
-
const value = await awaitMaybePromise(indirectEval(wrapUserCode(cellCode), cellId));
|
|
61
|
+
const value = await awaitMaybePromise(indirectEval(bindKernelBun(wrapUserCode(cellCode)), cellId));
|
|
61
62
|
await this.#drainPendingDisplays();
|
|
62
63
|
return value;
|
|
63
64
|
} finally {
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import type { MessagePort } from "node:worker_threads";
|
|
1
2
|
import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
2
3
|
import type { WorkerLike } from "./inline-worker.ts";
|
|
3
4
|
import { retireWorker, type WorkerRetirement } from "./interrupt-bounds.ts";
|
|
4
5
|
import type { JavaScriptKernelMode } from "./kernel-contract.ts";
|
|
5
6
|
import type { JavaScriptKernelOptions } from "./local-module-loader.ts";
|
|
7
|
+
import { KernelWebViewClients } from "./webview-host.ts";
|
|
6
8
|
import { WorkerStartupCancelledError } from "./worker-host.ts";
|
|
7
9
|
import { startWorkerWithInlineFallback } from "./worker-startup.ts";
|
|
8
10
|
|
|
@@ -17,6 +19,7 @@ export class WorkerSlot {
|
|
|
17
19
|
readonly #options: JavaScriptKernelOptions;
|
|
18
20
|
readonly #listeners: WorkerSlotListeners;
|
|
19
21
|
#worker: WorkerLike | null = null;
|
|
22
|
+
#webViews: KernelWebViewClients | null = null;
|
|
20
23
|
#mode: JavaScriptKernelMode = "worker";
|
|
21
24
|
#generation = 0;
|
|
22
25
|
#ready: Promise<void> | null = null;
|
|
@@ -43,8 +46,8 @@ export class WorkerSlot {
|
|
|
43
46
|
return this.#generation;
|
|
44
47
|
}
|
|
45
48
|
|
|
46
|
-
postMessage(message: HostToKernelMessage): void {
|
|
47
|
-
this.#worker?.postMessage(message);
|
|
49
|
+
postMessage(message: HostToKernelMessage, transfer?: readonly MessagePort[]): void {
|
|
50
|
+
this.#worker?.postMessage(message, transfer);
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
async ensureReady(): Promise<void> {
|
|
@@ -89,16 +92,22 @@ export class WorkerSlot {
|
|
|
89
92
|
this.#startupAbort = null;
|
|
90
93
|
this.#ready = null;
|
|
91
94
|
const worker = this.#worker;
|
|
95
|
+
const webViews = this.#webViews;
|
|
92
96
|
this.#worker = null;
|
|
93
|
-
|
|
94
|
-
|
|
97
|
+
this.#webViews = null;
|
|
98
|
+
const retirement = worker ? await retireWorker(worker) : "terminated";
|
|
99
|
+
await webViews?.release();
|
|
100
|
+
return retirement;
|
|
95
101
|
}
|
|
96
102
|
|
|
97
103
|
#publish(worker: WorkerLike, generation: number): void {
|
|
98
104
|
if (!this.#listeners.isOpen() || generation !== this.#generation) throw new WorkerStartupCancelledError();
|
|
99
105
|
this.#worker = worker;
|
|
106
|
+
const webViews = new KernelWebViewClients((message, transfer) => worker.postMessage(message, transfer));
|
|
107
|
+
this.#webViews = webViews;
|
|
100
108
|
worker.onMessage((message) => {
|
|
101
|
-
if (this.#isCurrent(worker, generation)
|
|
109
|
+
if (!this.#isCurrent(worker, generation) || webViews.consume(message)) return;
|
|
110
|
+
this.#listeners.onMessage(message);
|
|
102
111
|
});
|
|
103
112
|
worker.onError((error) => {
|
|
104
113
|
if (this.#isCurrent(worker, generation)) this.#listeners.onCrash(error);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// The kernel worker's end of its private port to the main-thread Bun.WebView service. Mirrors
|
|
2
|
+
// the message shapes in packages/coding-agent/src/core/webview/webview-wire.ts (this worker file
|
|
3
|
+
// cannot import TypeScript); keep the two in sync.
|
|
4
|
+
|
|
5
|
+
export const DELIVER_EVENT = Symbol("senpi.webview.deliver");
|
|
6
|
+
|
|
7
|
+
export function webViewError(wire) {
|
|
8
|
+
const error = new Error(wire?.message ?? "Bun.WebView request failed");
|
|
9
|
+
if (typeof wire?.name === "string") error.name = wire.name;
|
|
10
|
+
if (typeof wire?.code === "string") error.code = wire.code;
|
|
11
|
+
return error;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class WebViewPortClient {
|
|
15
|
+
#port;
|
|
16
|
+
#pending = new Map();
|
|
17
|
+
#views = new Map();
|
|
18
|
+
#nextId = 1;
|
|
19
|
+
#closed = false;
|
|
20
|
+
#onClosed;
|
|
21
|
+
|
|
22
|
+
constructor(port, onClosed) {
|
|
23
|
+
this.#port = port;
|
|
24
|
+
this.#onClosed = onClosed;
|
|
25
|
+
port.on("message", message => this.#receive(message));
|
|
26
|
+
port.on("close", () => this.#fail(new Error("Bun.WebView service connection closed")));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
register(viewId, view) {
|
|
30
|
+
this.#views.set(viewId, new WeakRef(view));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
forget(viewId) {
|
|
34
|
+
this.#views.delete(viewId);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
request(message) {
|
|
38
|
+
if (this.#closed) return Promise.reject(new Error("Bun.WebView service connection closed"));
|
|
39
|
+
const id = this.#nextId++;
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
this.#pending.set(id, { resolve, reject });
|
|
42
|
+
try {
|
|
43
|
+
this.#port.postMessage({ ...message, id });
|
|
44
|
+
} catch (error) {
|
|
45
|
+
this.#pending.delete(id);
|
|
46
|
+
reject(error);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
post(message) {
|
|
52
|
+
if (this.#closed) return;
|
|
53
|
+
try {
|
|
54
|
+
this.#port.postMessage(message);
|
|
55
|
+
} catch {
|
|
56
|
+
// A closed port has already released every view on the service side.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#receive(message) {
|
|
61
|
+
if (message?.kind === "reply") {
|
|
62
|
+
const pending = this.#pending.get(message.id);
|
|
63
|
+
if (!pending) return;
|
|
64
|
+
this.#pending.delete(message.id);
|
|
65
|
+
if (message.ok) pending.resolve({ value: message.value, state: message.state });
|
|
66
|
+
else pending.reject(webViewError(message.error));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (message?.kind === "event") this.#views.get(message.viewId)?.deref()?.[DELIVER_EVENT](message.event);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#fail(error) {
|
|
73
|
+
if (this.#closed) return;
|
|
74
|
+
this.#closed = true;
|
|
75
|
+
for (const pending of this.#pending.values()) pending.reject(error);
|
|
76
|
+
this.#pending.clear();
|
|
77
|
+
this.#onClosed();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { DELIVER_EVENT, WebViewPortClient, webViewError } from "./worker-webview-client.js";
|
|
2
|
+
|
|
3
|
+
// Bun constructs "chrome"-backend WebViews only on the process main thread, and this kernel runs in
|
|
4
|
+
// a worker where `globalThis.Bun` and `Bun.WebView` are non-writable. Cells therefore run with a
|
|
5
|
+
// `Bun` parameter bound to a stand-in whose `WebView` forwards Chrome-backed views to the
|
|
6
|
+
// main-thread service over a private port; every other `Bun.*` member is the real one.
|
|
7
|
+
const KERNEL_BUN = Symbol.for("senpi.kernel.bun");
|
|
8
|
+
|
|
9
|
+
export function bindKernelBun(source) {
|
|
10
|
+
if (!globalThis[KERNEL_BUN]) return source;
|
|
11
|
+
return `((Bun) => ${source})(globalThis[Symbol.for("senpi.kernel.bun")])`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function installKernelWebView(requestPort) {
|
|
15
|
+
const bun = globalThis.Bun;
|
|
16
|
+
if (typeof bun !== "object" || bun === null || typeof bun.WebView !== "function") return;
|
|
17
|
+
globalThis[KERNEL_BUN] = shadowBun(bun, createKernelWebView(bun.WebView, requestPort));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function shadowBun(real, WebView) {
|
|
21
|
+
return new Proxy(
|
|
22
|
+
{},
|
|
23
|
+
{
|
|
24
|
+
get: (_target, key) => (key === "WebView" ? WebView : Reflect.get(real, key)),
|
|
25
|
+
set: (_target, key, value) => Reflect.set(real, key, value),
|
|
26
|
+
has: (_target, key) => key === "WebView" || Reflect.has(real, key),
|
|
27
|
+
ownKeys: () => Reflect.ownKeys(real),
|
|
28
|
+
getOwnPropertyDescriptor: (_target, key) => {
|
|
29
|
+
if (key === "WebView") return { value: WebView, writable: false, enumerable: true, configurable: true };
|
|
30
|
+
const descriptor = Reflect.getOwnPropertyDescriptor(real, key);
|
|
31
|
+
return descriptor && { ...descriptor, configurable: true };
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The macOS default (WebKit) stays a native worker view: its host process serves any thread.
|
|
38
|
+
function needsMainThread(options) {
|
|
39
|
+
const backend = options?.backend;
|
|
40
|
+
if (backend === undefined) return process.platform !== "darwin";
|
|
41
|
+
return backend === "chrome" || (typeof backend === "object" && backend !== null && backend.type === "chrome");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function closedError(method) {
|
|
45
|
+
const error = new Error(`Invalid state: WebView.${method}: view is closed`);
|
|
46
|
+
error.code = "ERR_INVALID_STATE";
|
|
47
|
+
return error;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function createKernelWebView(NativeWebView, requestPort) {
|
|
51
|
+
let connecting = null;
|
|
52
|
+
const proxied = new WeakSet();
|
|
53
|
+
const live = new Set();
|
|
54
|
+
const natives = new Set();
|
|
55
|
+
const finalizer = new FinalizationRegistry(({ client, viewId }) => client.post({ kind: "close", viewId }));
|
|
56
|
+
|
|
57
|
+
function connect() {
|
|
58
|
+
if (!connecting) {
|
|
59
|
+
const attempt = requestPort().then(port => new WebViewPortClient(port, () => (connecting = null)));
|
|
60
|
+
attempt.catch(() => {
|
|
61
|
+
if (connecting === attempt) connecting = null;
|
|
62
|
+
});
|
|
63
|
+
connecting = attempt;
|
|
64
|
+
}
|
|
65
|
+
return connecting;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
class WebView extends EventTarget {
|
|
69
|
+
static closeAll() {
|
|
70
|
+
for (const view of [...live]) view.close();
|
|
71
|
+
for (const ref of [...natives]) ref.deref()?.close();
|
|
72
|
+
natives.clear();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
static [Symbol.hasInstance](value) {
|
|
76
|
+
return value instanceof NativeWebView || proxied.has(value);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#viewId = crypto.randomUUID();
|
|
80
|
+
#state = { url: "", title: "", loading: false };
|
|
81
|
+
#listening = new Set();
|
|
82
|
+
#console;
|
|
83
|
+
#ready;
|
|
84
|
+
#closed = false;
|
|
85
|
+
onNavigated = null;
|
|
86
|
+
onNavigationFailed = null;
|
|
87
|
+
|
|
88
|
+
constructor(options = {}) {
|
|
89
|
+
if (!needsMainThread(options)) {
|
|
90
|
+
const view = new NativeWebView(options);
|
|
91
|
+
natives.add(new WeakRef(view));
|
|
92
|
+
return view;
|
|
93
|
+
}
|
|
94
|
+
super();
|
|
95
|
+
const { console: capture, ...rest } = options;
|
|
96
|
+
this.#console = capture;
|
|
97
|
+
proxied.add(this);
|
|
98
|
+
live.add(this);
|
|
99
|
+
const viewId = this.#viewId;
|
|
100
|
+
this.#ready = connect().then(async client => {
|
|
101
|
+
client.register(viewId, this);
|
|
102
|
+
finalizer.register(this, { client, viewId }, this);
|
|
103
|
+
const reply = await client.request({ kind: "create", viewId, options: rest, captureConsole: capture !== undefined });
|
|
104
|
+
this.#apply(reply.state);
|
|
105
|
+
return client;
|
|
106
|
+
});
|
|
107
|
+
this.#ready.catch(() => {});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
get url() {
|
|
111
|
+
return this.#state.url;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
get title() {
|
|
115
|
+
return this.#state.title;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
get loading() {
|
|
119
|
+
return this.#state.loading;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
navigate(url) { return this.#call("navigate", [url]); }
|
|
123
|
+
evaluate(script) { return this.#call("evaluate", [script]); }
|
|
124
|
+
click(...args) { return this.#call("click", args); }
|
|
125
|
+
type(text) { return this.#call("type", [text]); }
|
|
126
|
+
press(...args) { return this.#call("press", args); }
|
|
127
|
+
scroll(dx, dy) { return this.#call("scroll", [dx, dy]); }
|
|
128
|
+
scrollTo(...args) { return this.#call("scrollTo", args); }
|
|
129
|
+
resize(width, height) { return this.#call("resize", [width, height]); }
|
|
130
|
+
back() { return this.#call("back", []); }
|
|
131
|
+
forward() { return this.#call("forward", []); }
|
|
132
|
+
reload() { return this.#call("reload", []); }
|
|
133
|
+
cdp(...args) { return this.#call("cdp", args); }
|
|
134
|
+
|
|
135
|
+
async screenshot(options) {
|
|
136
|
+
const value = await this.#call("screenshot", [options]);
|
|
137
|
+
if (options?.encoding === "buffer" && value instanceof Uint8Array) return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
addEventListener(type, listener, options) {
|
|
142
|
+
super.addEventListener(type, listener, options);
|
|
143
|
+
if (this.#closed || this.#listening.has(type)) return;
|
|
144
|
+
this.#listening.add(type);
|
|
145
|
+
const viewId = this.#viewId;
|
|
146
|
+
this.#ready.then(client => client.post({ kind: "listen", viewId, event: type }), () => {});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
close() {
|
|
150
|
+
if (this.#closed) return;
|
|
151
|
+
this.#closed = true;
|
|
152
|
+
live.delete(this);
|
|
153
|
+
const viewId = this.#viewId;
|
|
154
|
+
this.#ready.then(
|
|
155
|
+
client => {
|
|
156
|
+
client.post({ kind: "close", viewId });
|
|
157
|
+
client.forget(viewId);
|
|
158
|
+
finalizer.unregister(this);
|
|
159
|
+
},
|
|
160
|
+
() => {},
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
[Symbol.dispose]() {
|
|
165
|
+
this.close();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
[Symbol.asyncDispose]() {
|
|
169
|
+
this.close();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
[DELIVER_EVENT](event) {
|
|
173
|
+
if (event.type === "console") {
|
|
174
|
+
const [type, ...args] = event.args;
|
|
175
|
+
if (typeof this.#console === "function") this.#console(type, ...args);
|
|
176
|
+
else this.#console?.[type]?.(...args);
|
|
177
|
+
} else if (event.type === "navigated") {
|
|
178
|
+
this.#state = { ...this.#state, url: event.url, title: event.title };
|
|
179
|
+
this.onNavigated?.(event.url, event.title);
|
|
180
|
+
} else if (event.type === "navigation-failed") {
|
|
181
|
+
this.onNavigationFailed?.(webViewError(event.error));
|
|
182
|
+
} else if (event.type === "dom") {
|
|
183
|
+
this.dispatchEvent(new MessageEvent(event.name, { data: event.data }));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async #call(method, args) {
|
|
188
|
+
if (this.#closed) throw closedError(method);
|
|
189
|
+
const client = await this.#ready;
|
|
190
|
+
if (this.#closed) throw closedError(method);
|
|
191
|
+
const trimmed = [...args];
|
|
192
|
+
while (trimmed.length > 0 && trimmed.at(-1) === undefined) trimmed.pop();
|
|
193
|
+
const reply = await client.request({ kind: "call", viewId: this.#viewId, method, args: trimmed });
|
|
194
|
+
this.#apply(reply.state);
|
|
195
|
+
return reply.value;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
#apply(state) {
|
|
199
|
+
if (state) this.#state = state;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return WebView;
|
|
204
|
+
}
|
|
@@ -34,6 +34,7 @@ await Bun.write("page.png", await view.screenshot());
|
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
- macOS: system WebKit, nothing to install. macOS/Linux/Windows: can drive installed Chrome/Chromium/Edge.
|
|
37
|
+
- In the eval js kernel, Chrome-backed views (the default off macOS, or `backend: "chrome"`) are served by the process main thread; the API is the same, a constructor error surfaces on the first awaited call, and `Bun.WebView.closeAll()` closes only this kernel's views. A kernel reset closes every view the kernel left open.
|
|
37
38
|
- Clicks/scrolls are trusted user input (`event.isTrusted === true`).
|
|
38
39
|
- Extends `EventTarget`; screenshots are `Blob`s; `.cdp(method, params?)` escape hatch for raw Chrome DevTools Protocol.
|
|
39
40
|
|