@tokamakdev/plugin 0.1.0-beta.107 → 0.1.0-beta.114
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 +59 -32
- package/package.json +7 -2
- package/src/bindings.ts +260 -0
- package/src/capabilities.d.mts +11 -0
- package/src/capabilities.mjs +8 -0
- package/src/codec.ts +15 -0
- package/src/context.ts +91 -0
- package/src/entrypoint.js +21 -8
- package/src/events.ts +40 -18
- package/src/index.ts +60 -155
- package/src/native-protocol.d.mts +24 -0
- package/src/native-protocol.mjs +77 -0
- package/src/registry.ts +28 -1
- package/src/resources.ts +101 -0
- package/src/rpc/binding.mjs +109 -0
- package/src/rpc/channel.mjs +219 -0
- package/src/rpc/client.mjs +29 -0
- package/src/rpc/endpoint.mjs +47 -0
- package/src/rpc/frames.mjs +35 -0
- package/src/rpc/http.mjs +28 -0
- package/src/rpc/streams.mjs +52 -0
- package/src/rpc/values.mjs +136 -0
- package/src/streams.ts +120 -0
- package/src/testing.ts +114 -27
- package/src/webview.ts +25 -0
- package/src/worker.ts +32 -0
package/src/index.ts
CHANGED
|
@@ -1,173 +1,78 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { capabilitiesOf, nativeConnection, type CallOptions, type NativeConnection, type Overflow, type TokamakContext } from "./context.js";
|
|
2
|
+
import { byteStream, type JsonStream } from "./streams.js";
|
|
3
|
+
import { decodeJson, encodeJson } from "./codec.js";
|
|
4
|
+
import { NativeReference } from "./resources.js";
|
|
5
|
+
|
|
6
|
+
export type { TokamakContext } from "./worker.js";
|
|
7
|
+
export type { CallOptions, NativePlatform, Overflow } from "./context.js";
|
|
8
|
+
export { capabilitiesOf } from "./context.js";
|
|
9
|
+
export type { JsonStream } from "./streams.js";
|
|
10
|
+
export type { JsonValue } from "./codec.js";
|
|
11
|
+
export { decodeJson, encodeJson } from "./codec.js";
|
|
12
|
+
export { NativeReference } from "./resources.js";
|
|
13
|
+
|
|
14
|
+
/** Whether the app is on screen. */
|
|
15
|
+
export type LifecycleStage = "foreground" | "background";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The app's stage now, which can differ from the last lifecycle event delivered. Rejects with
|
|
19
|
+
* `NotSupportedError` without a native host.
|
|
20
|
+
*/
|
|
21
|
+
export async function lifecycleStage(ctx: TokamakContext): Promise<LifecycleStage> {
|
|
22
|
+
return decodeJson<LifecycleStage>(await ctx[nativeConnection].call("tokamak", "lifecycleStage", encodeJson(null)));
|
|
4
23
|
}
|
|
5
24
|
|
|
6
|
-
|
|
7
|
-
name: string;
|
|
8
|
-
message: string;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
interface NativeResponse {
|
|
12
|
-
session: string;
|
|
13
|
-
id: number;
|
|
14
|
-
value?: unknown;
|
|
15
|
-
error?: NativeError;
|
|
16
|
-
done: boolean;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
interface PendingCall {
|
|
20
|
-
kind: "call";
|
|
21
|
-
resolve(value: unknown): void;
|
|
22
|
-
reject(error: DOMException): void;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
interface PendingSubscription {
|
|
26
|
-
kind: "subscription";
|
|
27
|
-
next(value: unknown): void;
|
|
28
|
-
error(error: DOMException): void;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
type Pending = PendingCall | PendingSubscription;
|
|
32
|
-
|
|
33
|
-
/** Calls `method` of `plugin` from the Worker, resolving with its value. */
|
|
34
|
-
type WorkerCall = (plugin: string, method: string, arguments_: unknown) => Promise<unknown>;
|
|
35
|
-
|
|
36
|
-
/** Subscribes to `method` of `plugin` from the Worker, returning the function that unsubscribes. */
|
|
37
|
-
type WorkerListen = (
|
|
38
|
-
plugin: string,
|
|
39
|
-
method: string,
|
|
40
|
-
arguments_: unknown,
|
|
41
|
-
next: (value: unknown) => void,
|
|
42
|
-
error: (error: DOMException) => void,
|
|
43
|
-
) => () => void;
|
|
44
|
-
|
|
45
|
-
declare global {
|
|
46
|
-
/** The page's native transport, which the shells define. */
|
|
47
|
-
var __tokamakNative: NativeTransport | undefined;
|
|
48
|
-
/** The Worker's native calls, which the runtime and `tok dev` define. */
|
|
49
|
-
var __tokamakNativeCall: WorkerCall | undefined;
|
|
50
|
-
var __tokamakNativeListen: WorkerListen | undefined;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
let nextRequestId = 1;
|
|
54
|
-
const session = `${String(Date.now())}-${String(Math.random())}`;
|
|
55
|
-
let connected: NativeTransport | undefined;
|
|
56
|
-
const pending = new Map<number, Pending>();
|
|
57
|
-
|
|
58
|
-
/** The base class of a plugin's API, which the page and the Worker call alike. */
|
|
25
|
+
/** Native endpoint calls owned by the receiving invocation. */
|
|
59
26
|
export abstract class Plugin {
|
|
60
|
-
|
|
27
|
+
private readonly connection: NativeConnection;
|
|
61
28
|
|
|
62
|
-
protected
|
|
63
|
-
|
|
29
|
+
protected constructor(private readonly ctx: TokamakContext, private readonly pluginId: string) {
|
|
30
|
+
const connection = Reflect.get(ctx, nativeConnection) as NativeConnection | undefined;
|
|
31
|
+
if (!connection) throw new TypeError("A Tokamak invocation context is required");
|
|
32
|
+
this.connection = connection;
|
|
64
33
|
}
|
|
65
34
|
|
|
66
|
-
protected call<T>(method: string,
|
|
67
|
-
|
|
68
|
-
if (page) {
|
|
69
|
-
return new Promise<T>((resolve, reject) => {
|
|
70
|
-
send(page, this.pluginId, method, arguments_, { kind: "call", resolve, reject });
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
const call = globalThis.__tokamakNativeCall;
|
|
74
|
-
if (call) return call(this.pluginId, method, arguments_) as Promise<T>;
|
|
75
|
-
return Promise.reject(unavailable());
|
|
35
|
+
protected async call<T>(method: string, value: unknown = null, options?: CallOptions): Promise<T> {
|
|
36
|
+
return decodeJson<T>(await this.callBytes(method, encodeJson(value), options));
|
|
76
37
|
}
|
|
77
38
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
): () => void {
|
|
85
|
-
const page = pageTransport();
|
|
86
|
-
if (page) {
|
|
87
|
-
const id = send(page, this.pluginId, method, arguments_, {
|
|
88
|
-
kind: "subscription",
|
|
89
|
-
next,
|
|
90
|
-
error,
|
|
91
|
-
});
|
|
92
|
-
return () => {
|
|
93
|
-
if (!pending.delete(id)) return;
|
|
94
|
-
page.postMessage(JSON.stringify({ type: "cancel", session, id }));
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
const listen = globalThis.__tokamakNativeListen;
|
|
98
|
-
if (listen) return listen(this.pluginId, method, arguments_, next as (value: unknown) => void, error);
|
|
99
|
-
throw unavailable();
|
|
39
|
+
/**
|
|
40
|
+
* Whether each operation has an implementation on the platform the app runs on. It does not
|
|
41
|
+
* report permission or promise that a call succeeds.
|
|
42
|
+
*/
|
|
43
|
+
get capabilities(): Readonly<Record<string, boolean>> {
|
|
44
|
+
return capabilitiesOf(this.ctx, this.pluginId);
|
|
100
45
|
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function unavailable(): DOMException {
|
|
104
|
-
return new DOMException("Native plugin transport is unavailable", "NotSupportedError");
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** The page's native transport, connected on first use. */
|
|
108
|
-
function pageTransport(): NativeTransport | undefined {
|
|
109
|
-
const transport = globalThis.__tokamakNative;
|
|
110
|
-
if (!transport || typeof transport.postMessage !== "function") return undefined;
|
|
111
|
-
if (connected !== transport) connect(transport);
|
|
112
|
-
return transport;
|
|
113
|
-
}
|
|
114
46
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const page: { addEventListener?: (type: string, listener: () => void) => void } = globalThis;
|
|
118
|
-
page.addEventListener?.("pagehide", cancelSubscriptions);
|
|
47
|
+
protected callBytes(method: string, payload: Uint8Array, options?: CallOptions): Promise<Uint8Array> {
|
|
48
|
+
return this.connection.call(this.pluginId, method, payload, options);
|
|
119
49
|
}
|
|
120
|
-
connected = transport;
|
|
121
|
-
transport.onmessage = ({ data }) => {
|
|
122
|
-
receive(JSON.parse(data) as NativeResponse);
|
|
123
|
-
};
|
|
124
|
-
transport.postMessage(JSON.stringify({ type: "reset", session }));
|
|
125
|
-
}
|
|
126
50
|
|
|
127
|
-
/**
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
plugin: string,
|
|
131
|
-
method: string,
|
|
132
|
-
arguments_: unknown,
|
|
133
|
-
request: Pending,
|
|
134
|
-
): number {
|
|
135
|
-
const id = nextRequestId++;
|
|
136
|
-
const type = request.kind === "call" ? "call" : "subscribe";
|
|
137
|
-
pending.set(id, request);
|
|
138
|
-
try {
|
|
139
|
-
transport.postMessage(
|
|
140
|
-
JSON.stringify({ type, session, id, plugin, method, arguments: arguments_ }),
|
|
141
|
-
);
|
|
142
|
-
} catch (error) {
|
|
143
|
-
pending.delete(id);
|
|
144
|
-
throw error;
|
|
51
|
+
/** Retains the native object that the subscription `method` holds open, without copying it. */
|
|
52
|
+
protected resource(method: string, value: unknown = null): Promise<NativeReference> {
|
|
53
|
+
return NativeReference.acquire(this.ctx, this.pluginId, method, encodeJson(value));
|
|
145
54
|
}
|
|
146
|
-
return id;
|
|
147
|
-
}
|
|
148
55
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if (!request) return;
|
|
153
|
-
if (response.done) pending.delete(response.id);
|
|
154
|
-
|
|
155
|
-
if (response.error) {
|
|
156
|
-
const error = new DOMException(response.error.message, response.error.name);
|
|
157
|
-
if (request.kind === "call") request.reject(error);
|
|
158
|
-
else request.error(error);
|
|
159
|
-
return;
|
|
56
|
+
/** The ID that passes `reference` to this plugin's native endpoint, which finds the same object. */
|
|
57
|
+
protected reference(reference: NativeReference): string {
|
|
58
|
+
return reference.idFor(this.ctx);
|
|
160
59
|
}
|
|
161
60
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
61
|
+
/**
|
|
62
|
+
* A stream of newline-delimited JSON values, consumed with decodeValues. `overflow` says what
|
|
63
|
+
* happens to values a slow reader has not taken: ending the stream, or dropping the oldest.
|
|
64
|
+
*/
|
|
65
|
+
protected stream<T>(method: string, value: unknown = null, overflow: Overflow = "error"): JsonStream<T> {
|
|
66
|
+
return byteStream((next, error, close) => this.connection.listen(this.pluginId, method, encodeJson(value), {
|
|
67
|
+
next(payload, done) {
|
|
68
|
+
const line = new Uint8Array(payload.byteLength + 1);
|
|
69
|
+
line.set(payload);
|
|
70
|
+
line[payload.byteLength] = 10;
|
|
71
|
+
next(line);
|
|
72
|
+
if (done) close();
|
|
73
|
+
},
|
|
74
|
+
error,
|
|
75
|
+
overflow,
|
|
76
|
+
}), overflow);
|
|
172
77
|
}
|
|
173
78
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface MessageHeader {
|
|
2
|
+
type: string;
|
|
3
|
+
id?: number;
|
|
4
|
+
plugin?: string;
|
|
5
|
+
method?: string;
|
|
6
|
+
name?: string;
|
|
7
|
+
overflow?: string;
|
|
8
|
+
failed?: boolean;
|
|
9
|
+
done?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface NativeMessage {
|
|
12
|
+
header: MessageHeader;
|
|
13
|
+
payload: Uint8Array;
|
|
14
|
+
}
|
|
15
|
+
export function encodeMessage(header: MessageHeader, payload?: Uint8Array): Uint8Array<ArrayBuffer>;
|
|
16
|
+
export function decodeMessage(bytes: Uint8Array): NativeMessage;
|
|
17
|
+
export function messages(body: AsyncIterable<Uint8Array> | Iterable<Uint8Array> | null): AsyncGenerator<NativeMessage>;
|
|
18
|
+
/** A failed endpoint payload. */
|
|
19
|
+
export interface NativeFailure {
|
|
20
|
+
name: string;
|
|
21
|
+
message: string;
|
|
22
|
+
cause?: unknown;
|
|
23
|
+
}
|
|
24
|
+
export function nativeError(failure: NativeFailure): DOMException;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const encoder = new TextEncoder();
|
|
2
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
3
|
+
|
|
4
|
+
/** A length-delimited routing header followed by an opaque endpoint payload. */
|
|
5
|
+
export function encodeMessage(header, payload = new Uint8Array()) {
|
|
6
|
+
const metadata = encoder.encode(JSON.stringify(header));
|
|
7
|
+
const length = 4 + metadata.byteLength + payload.byteLength;
|
|
8
|
+
if (length > 0xffffffff) throw new RangeError("Native message exceeds 4 GiB");
|
|
9
|
+
const result = new Uint8Array(4 + length);
|
|
10
|
+
const view = new DataView(result.buffer);
|
|
11
|
+
view.setUint32(0, length, true);
|
|
12
|
+
view.setUint32(4, metadata.byteLength, true);
|
|
13
|
+
result.set(metadata, 8);
|
|
14
|
+
result.set(payload, 8 + metadata.byteLength);
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function decodeMessage(bytes) {
|
|
19
|
+
if (bytes.byteLength < 8) throw new TypeError("Incomplete native message");
|
|
20
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
21
|
+
const length = view.getUint32(0, true);
|
|
22
|
+
const metadata = view.getUint32(4, true);
|
|
23
|
+
if (length !== bytes.byteLength - 4 || metadata > length - 4) {
|
|
24
|
+
throw new TypeError("Invalid native message length");
|
|
25
|
+
}
|
|
26
|
+
const header = JSON.parse(decoder.decode(bytes.subarray(8, 8 + metadata)));
|
|
27
|
+
if (header === null || typeof header !== "object" || Array.isArray(header) || typeof header.type !== "string" ||
|
|
28
|
+
(header.id !== undefined && (!Number.isSafeInteger(header.id) || header.id < 0)) ||
|
|
29
|
+
["plugin", "method", "name", "overflow"].some(key => header[key] !== undefined && typeof header[key] !== "string") ||
|
|
30
|
+
["failed", "done"].some(key => header[key] !== undefined && typeof header[key] !== "boolean")) {
|
|
31
|
+
throw new TypeError("Invalid native routing header");
|
|
32
|
+
}
|
|
33
|
+
return { header, payload: bytes.subarray(8 + metadata) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Reads messages independently of HTTP chunk boundaries. */
|
|
37
|
+
export async function* messages(body) {
|
|
38
|
+
let prefix = new Uint8Array(4);
|
|
39
|
+
let used = 0;
|
|
40
|
+
let remaining = 0;
|
|
41
|
+
let parts = [];
|
|
42
|
+
for await (const chunk of body ?? []) {
|
|
43
|
+
let offset = 0;
|
|
44
|
+
while (offset < chunk.byteLength) {
|
|
45
|
+
if (used < 4) {
|
|
46
|
+
const count = Math.min(4 - used, chunk.byteLength - offset);
|
|
47
|
+
prefix.set(chunk.subarray(offset, offset + count), used);
|
|
48
|
+
used += count;
|
|
49
|
+
offset += count;
|
|
50
|
+
if (used < 4) continue;
|
|
51
|
+
remaining = new DataView(prefix.buffer).getUint32(0, true);
|
|
52
|
+
if (remaining < 4) throw new TypeError("Invalid native message length");
|
|
53
|
+
parts.push(prefix);
|
|
54
|
+
}
|
|
55
|
+
const count = Math.min(remaining, chunk.byteLength - offset);
|
|
56
|
+
parts.push(chunk.subarray(offset, offset + count));
|
|
57
|
+
remaining -= count;
|
|
58
|
+
offset += count;
|
|
59
|
+
if (remaining) continue;
|
|
60
|
+
const frame = new Uint8Array(parts.reduce((length, part) => length + part.byteLength, 0));
|
|
61
|
+
let position = 0;
|
|
62
|
+
for (const part of parts) { frame.set(part, position); position += part.byteLength; }
|
|
63
|
+
yield decodeMessage(frame);
|
|
64
|
+
prefix = new Uint8Array(4);
|
|
65
|
+
used = 0;
|
|
66
|
+
parts = [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (used) throw new TypeError("Incomplete native message");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The error a failed endpoint payload describes, with its details as the `cause`. */
|
|
73
|
+
export function nativeError({ name, message, cause }) {
|
|
74
|
+
const error = new DOMException(message, name);
|
|
75
|
+
if (cause !== undefined) Object.defineProperty(error, "cause", { value: cause, writable: true, configurable: true });
|
|
76
|
+
return error;
|
|
77
|
+
}
|
package/src/registry.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
|
+
import { decodeJson, encodeJson } from "./codec.js";
|
|
2
|
+
|
|
1
3
|
/** A registered listener. */
|
|
2
4
|
export type RegisteredListener = (event: unknown, env: unknown, ctx: unknown) => unknown;
|
|
3
5
|
|
|
6
|
+
/** How an event's data and reply travel as bytes. An empty reply is no reply. */
|
|
7
|
+
export interface EventCodec<T, Reply> {
|
|
8
|
+
decode(payload: Uint8Array): T;
|
|
9
|
+
encode(reply: Reply): Uint8Array;
|
|
10
|
+
}
|
|
11
|
+
|
|
4
12
|
/** What a dispatch returns. */
|
|
5
13
|
export interface Dispatched {
|
|
6
14
|
/** The first value, in registration order, that a listener returned other than `undefined`. */
|
|
@@ -9,8 +17,18 @@ export interface Dispatched {
|
|
|
9
17
|
listened: string[];
|
|
10
18
|
}
|
|
11
19
|
|
|
20
|
+
const json: EventCodec<unknown, unknown> = {
|
|
21
|
+
decode: decodeJson,
|
|
22
|
+
encode: (reply) => reply === undefined ? new Uint8Array() : encodeJson(reply),
|
|
23
|
+
};
|
|
24
|
+
|
|
12
25
|
/** Every listener, by event name, in registration order. */
|
|
13
26
|
const listeners = new Map<string, RegisteredListener[]>();
|
|
27
|
+
const codecs = new Map<string, EventCodec<unknown, unknown>>();
|
|
28
|
+
|
|
29
|
+
export function listenedEvents(): string[] {
|
|
30
|
+
return [...listeners.keys()];
|
|
31
|
+
}
|
|
14
32
|
|
|
15
33
|
export function register(name: string, listener: RegisteredListener): void {
|
|
16
34
|
const registered = listeners.get(name);
|
|
@@ -18,6 +36,15 @@ export function register(name: string, listener: RegisteredListener): void {
|
|
|
18
36
|
else listeners.set(name, [listener]);
|
|
19
37
|
}
|
|
20
38
|
|
|
39
|
+
export function setCodec(name: string, codec: EventCodec<unknown, unknown>): void {
|
|
40
|
+
codecs.set(name, codec);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The codec of the event `name`: the one its definition gave, or JSON. */
|
|
44
|
+
export function eventCodec(name: string): EventCodec<unknown, unknown> {
|
|
45
|
+
return codecs.get(name) ?? json;
|
|
46
|
+
}
|
|
47
|
+
|
|
21
48
|
/**
|
|
22
49
|
* Runs the listeners of the event `name` and waits for them to settle. A listener that throws is
|
|
23
50
|
* reported with `reportError`, and the others still run.
|
|
@@ -36,5 +63,5 @@ export async function dispatch(
|
|
|
36
63
|
if (result.status === "rejected") reportError(result.reason);
|
|
37
64
|
else if (reply === undefined) reply = result.value;
|
|
38
65
|
}
|
|
39
|
-
return { reply, listened:
|
|
66
|
+
return { reply, listened: listenedEvents() };
|
|
40
67
|
}
|
package/src/resources.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { nativeConnection, type TokamakContext } from "./context.js";
|
|
2
|
+
import { decodeJson, encodeJson } from "./codec.js";
|
|
3
|
+
|
|
4
|
+
// Workers have FinalizationRegistry from compatibility date 2025-05-05 unless `disable_weak_ref` is set.
|
|
5
|
+
const collected = typeof FinalizationRegistry === "function"
|
|
6
|
+
? new FinalizationRegistry<() => void>((release) => { release(); })
|
|
7
|
+
: undefined;
|
|
8
|
+
|
|
9
|
+
/** The invocation that owns `reference`. */
|
|
10
|
+
export let ownerOf: (reference: NativeReference) => TokamakContext;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A native object that stays in native memory. The receiving invocation owns it until `close`,
|
|
14
|
+
* the invocation's end, or garbage collection where the runtime has `FinalizationRegistry`.
|
|
15
|
+
*/
|
|
16
|
+
export class NativeReference {
|
|
17
|
+
readonly #ctx: TokamakContext;
|
|
18
|
+
readonly #id: string;
|
|
19
|
+
#release: (() => void) | undefined;
|
|
20
|
+
|
|
21
|
+
static {
|
|
22
|
+
ownerOf = (reference) => reference.#ctx;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private constructor(ctx: TokamakContext, id: string, release: () => void) {
|
|
26
|
+
this.#ctx = ctx;
|
|
27
|
+
this.#id = id;
|
|
28
|
+
this.#release = release;
|
|
29
|
+
collected?.register(this, release, this);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Retains the object that the native subscription `method` of `plugin` holds open: its first
|
|
34
|
+
* value is the object's ID, and later values go to `next`.
|
|
35
|
+
*/
|
|
36
|
+
static async acquire(
|
|
37
|
+
ctx: TokamakContext,
|
|
38
|
+
plugin: string,
|
|
39
|
+
method: string,
|
|
40
|
+
payload: Uint8Array,
|
|
41
|
+
next: (value: Uint8Array) => void = () => {},
|
|
42
|
+
): Promise<NativeReference> {
|
|
43
|
+
let release = () => {};
|
|
44
|
+
try {
|
|
45
|
+
// The subscription's listeners never hold the reference, so garbage collection can release it.
|
|
46
|
+
const id = await new Promise<Uint8Array>((resolve, reject) => {
|
|
47
|
+
let settled = false;
|
|
48
|
+
release = ctx[nativeConnection].listen(plugin, method, payload, {
|
|
49
|
+
next(value, done) {
|
|
50
|
+
if (settled) {
|
|
51
|
+
if (!done) next(value);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
settled = true;
|
|
55
|
+
if (done) reject(new DOMException("The native object was not retained", "InvalidStateError"));
|
|
56
|
+
else resolve(value);
|
|
57
|
+
},
|
|
58
|
+
// A later failure ends the subscription: the released object fails where it is used.
|
|
59
|
+
error(error) {
|
|
60
|
+
if (settled) return;
|
|
61
|
+
settled = true;
|
|
62
|
+
reject(error);
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
return new NativeReference(ctx, decodeJson<string>(id), release);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
release();
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The object retained as `id` in the scope of the invocation `ctx`, which ends with it. */
|
|
74
|
+
static scoped(ctx: TokamakContext, id: string): NativeReference {
|
|
75
|
+
return new NativeReference(ctx, id, () => {
|
|
76
|
+
// A failed release changes nothing: the invocation's end has released the object.
|
|
77
|
+
ctx[nativeConnection].call("tokamak", "resource.release", encodeJson(id)).catch(() => undefined);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Copies the current contents of a native byte buffer. Other native objects reject with `TypeError`. */
|
|
82
|
+
async bytes(): Promise<Uint8Array> {
|
|
83
|
+
return this.#ctx[nativeConnection].call("tokamak", "resource.read", encodeJson(this.idFor(this.#ctx)));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Releases the native object. Later calls do nothing. */
|
|
87
|
+
close(): void {
|
|
88
|
+
const release = this.#release;
|
|
89
|
+
if (!release) return;
|
|
90
|
+
this.#release = undefined;
|
|
91
|
+
collected?.unregister(this);
|
|
92
|
+
release();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The ID a plugin's native endpoint uses to find the object, within the invocation `ctx` that owns it. */
|
|
96
|
+
idFor(ctx: TokamakContext): string {
|
|
97
|
+
if (!this.#release) throw new DOMException("The native reference is closed", "InvalidStateError");
|
|
98
|
+
if (ctx !== this.#ctx) throw new DOMException("The native reference belongs to another invocation", "InvalidStateError");
|
|
99
|
+
return this.#id;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { RpcTarget, WorkerEntrypoint } from 'cloudflare:workers';
|
|
2
|
+
import { invoke } from './client.mjs';
|
|
3
|
+
import { serveRpc } from './endpoint.mjs';
|
|
4
|
+
|
|
5
|
+
const original = Symbol.for('tokamak.dev.service');
|
|
6
|
+
|
|
7
|
+
class Port extends RpcTarget {
|
|
8
|
+
#socket;
|
|
9
|
+
|
|
10
|
+
constructor(socket) { super(); this.#socket = socket; }
|
|
11
|
+
receive(frame) { this.#socket.dispatchEvent(new MessageEvent('message', { data: frame })); }
|
|
12
|
+
[Symbol.dispose]() { this.#socket.close(); }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Workerd owns these ports for the lifetime of the calling invocation. */
|
|
16
|
+
class Socket extends EventTarget {
|
|
17
|
+
#peer;
|
|
18
|
+
readyState = 0;
|
|
19
|
+
|
|
20
|
+
connect(peer) {
|
|
21
|
+
if (this.readyState === 3) { peer[Symbol.dispose](); return; }
|
|
22
|
+
this.#peer = peer;
|
|
23
|
+
this.readyState = 1;
|
|
24
|
+
this.dispatchEvent(new Event('open'));
|
|
25
|
+
}
|
|
26
|
+
fail(error) { this.dispatchEvent(new ErrorEvent('error', { error })); this.close(); }
|
|
27
|
+
send(frame) { return this.#peer.receive(frame); }
|
|
28
|
+
close() {
|
|
29
|
+
if (this.readyState === 3) return;
|
|
30
|
+
this.readyState = 3;
|
|
31
|
+
this.#peer?.[Symbol.dispose]();
|
|
32
|
+
this.dispatchEvent(new Event('close'));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Open the receiving invocation using workerd's internal RPC ownership. */
|
|
37
|
+
export function createRpcPort(peer, createWorker, methods, ctx) {
|
|
38
|
+
const socket = new Socket();
|
|
39
|
+
socket.connect(peer.dup());
|
|
40
|
+
ctx.waitUntil(serveRpc(socket, createWorker, methods, ctx));
|
|
41
|
+
return new Port(socket);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Enforce Tokamak's RPC contract over a development worker's service binding. */
|
|
45
|
+
export function createBinding(service, methods) {
|
|
46
|
+
const binding = {
|
|
47
|
+
...Object.fromEntries(methods.map(method => [method, async (...args) => {
|
|
48
|
+
const socket = new Socket();
|
|
49
|
+
service.__tokamakRpc(new Port(socket)).then(peer => socket.connect(peer), error => socket.fail(error));
|
|
50
|
+
return invoke(socket, method, args);
|
|
51
|
+
}])),
|
|
52
|
+
async fetch(input, init) {
|
|
53
|
+
const response = await service.fetch(input, init);
|
|
54
|
+
if (response.status === 101) {
|
|
55
|
+
response.webSocket?.accept();
|
|
56
|
+
response.webSocket?.close(1000);
|
|
57
|
+
throw new TypeError('Service binding WebSocket upgrades are not supported');
|
|
58
|
+
}
|
|
59
|
+
return response;
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
Object.defineProperty(binding, original, { value: service });
|
|
63
|
+
return binding;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Install generated local bindings before the application's modules are evaluated. */
|
|
67
|
+
export function installBindings(env, bindings) {
|
|
68
|
+
for (const [name, methods] of Object.entries(bindings)) {
|
|
69
|
+
const binding = env[name];
|
|
70
|
+
const service = Object.getOwnPropertyDescriptor(binding, original)?.value ?? binding;
|
|
71
|
+
env[name] = createBinding(service, methods);
|
|
72
|
+
}
|
|
73
|
+
return env;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function instance(Type, ctx, env) {
|
|
77
|
+
return typeof Type === 'function' ? new Type(ctx, env) : Type;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Route development RPC through the shared contract and preserve standard Worker handlers. */
|
|
81
|
+
export function developmentEntrypoint(Type, methods, bindings) {
|
|
82
|
+
class Entry extends WorkerEntrypoint {
|
|
83
|
+
__tokamakRpc(peer) {
|
|
84
|
+
const env = installBindings(this.env, bindings);
|
|
85
|
+
return createRpcPort(peer, ctx => instance(Type, ctx, env), methods, this.ctx);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
for (const name of ['fetch', 'queue', 'scheduled', 'email', 'tail', 'trace', 'tailStream', 'test']) {
|
|
89
|
+
if (typeof (typeof Type === 'function' ? Type.prototype : Type)?.[name] !== 'function') continue;
|
|
90
|
+
Object.defineProperty(Entry.prototype, name, {
|
|
91
|
+
value(event) {
|
|
92
|
+
const env = installBindings(this.env, bindings);
|
|
93
|
+
const worker = instance(Type, this.ctx, env);
|
|
94
|
+
return worker[name](event, env, this.ctx);
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return Entry;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Install local bindings for host event dispatch. */
|
|
102
|
+
export function developmentEvents(Type, bindings) {
|
|
103
|
+
return class extends Type {
|
|
104
|
+
dispatch(name, data) {
|
|
105
|
+
installBindings(this.env, bindings);
|
|
106
|
+
return super.dispatch(name, data);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|