@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/testing.ts
CHANGED
|
@@ -1,43 +1,130 @@
|
|
|
1
1
|
import { dispatch as dispatchEvent, type Dispatched } from "./registry.js";
|
|
2
|
+
import { createNativeContext, type Availability, type NativeConnection, type NativePlatform, type Overflow, type TokamakContext } from "./context.js";
|
|
3
|
+
import { encodeJson, decodeJson } from "./codec.js";
|
|
4
|
+
import { nativeError, type NativeFailure } from "./native-protocol.mjs";
|
|
2
5
|
|
|
3
|
-
|
|
6
|
+
type Reply = (payload: Uint8Array, error: DOMException | undefined, done: boolean) => void;
|
|
7
|
+
|
|
8
|
+
/** A request the native endpoint received. */
|
|
9
|
+
export interface SentRequest {
|
|
10
|
+
type: string;
|
|
11
|
+
id: number;
|
|
12
|
+
plugin?: string;
|
|
13
|
+
method?: string;
|
|
14
|
+
payload?: Uint8Array;
|
|
15
|
+
/** A call's body. */
|
|
16
|
+
body?: ReadableStream<Uint8Array>;
|
|
17
|
+
/** A subscription's overflow policy. */
|
|
18
|
+
overflow?: Overflow;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A reply to a request: its value or failure, and whether it is the last. */
|
|
22
|
+
export interface Answer {
|
|
23
|
+
value?: unknown;
|
|
24
|
+
error?: NativeFailure;
|
|
25
|
+
done?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A request as `answer` sees it, with its decoded payload. */
|
|
29
|
+
export interface Received {
|
|
30
|
+
id: number;
|
|
31
|
+
type: string;
|
|
32
|
+
plugin?: string;
|
|
33
|
+
method?: string;
|
|
34
|
+
arguments: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** An invocation with an inspectable native endpoint. */
|
|
4
38
|
export class FakeNativeTransport {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
39
|
+
readonly sent: SentRequest[] = [];
|
|
40
|
+
private nextId = 1;
|
|
41
|
+
private readonly pending = new Map<number, Reply>();
|
|
42
|
+
private answering: ((request: Received) => Answer | undefined) | undefined;
|
|
43
|
+
readonly ctx: TokamakContext;
|
|
8
44
|
|
|
9
|
-
|
|
10
|
-
|
|
45
|
+
/** An invocation on `platform`, whose plugins' operations have `availability`. */
|
|
46
|
+
constructor(platform: NativePlatform = "ios", availability: Availability = {}) {
|
|
47
|
+
this.ctx = createNativeContext({ waitUntil: () => undefined }, this.connection(platform), availability);
|
|
11
48
|
}
|
|
12
49
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
50
|
+
private connection(platform: NativePlatform): NativeConnection {
|
|
51
|
+
return {
|
|
52
|
+
platform,
|
|
53
|
+
call: (plugin, method, payload, { body, signal } = {}) => new Promise((resolve, reject) => {
|
|
54
|
+
signal?.throwIfAborted();
|
|
55
|
+
const id = this.request({ type: "call", plugin, method, payload, body }, (value, error) => {
|
|
56
|
+
if (error) reject(error);
|
|
57
|
+
else resolve(value);
|
|
58
|
+
});
|
|
59
|
+
signal?.addEventListener("abort", () => {
|
|
60
|
+
this.cancel(id);
|
|
61
|
+
reject(signal.reason as Error);
|
|
62
|
+
}, { once: true });
|
|
63
|
+
}),
|
|
64
|
+
listen: (plugin, method, payload, { next, error, overflow = "error" }) => {
|
|
65
|
+
const id = this.request({ type: "subscribe", plugin, method, payload, overflow }, (value, failure, done) => {
|
|
66
|
+
if (failure) error(failure, done);
|
|
67
|
+
else next(value, done);
|
|
68
|
+
});
|
|
69
|
+
return () => { this.cancel(id); };
|
|
70
|
+
},
|
|
71
|
+
};
|
|
17
72
|
}
|
|
18
73
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
this.
|
|
74
|
+
private request(request: Omit<SentRequest, "id">, reply: Reply): number {
|
|
75
|
+
const id = this.nextId++;
|
|
76
|
+
this.sent.push({ ...request, id, payload: request.payload?.slice() });
|
|
77
|
+
this.pending.set(id, reply);
|
|
78
|
+
const { answering } = this;
|
|
79
|
+
if (answering) {
|
|
80
|
+
queueMicrotask(() => {
|
|
81
|
+
const { type, plugin, method, payload } = request;
|
|
82
|
+
const answer = answering({ id, type, plugin, method, arguments: payload?.byteLength ? decodeJson(payload) : undefined });
|
|
83
|
+
if (answer) this.deliver(id, answer);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return id;
|
|
23
87
|
}
|
|
24
88
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Answers each later request, as it arrives, with what `answer` returns for it. Undefined leaves
|
|
91
|
+
* the request pending, for `respond` or `deliver`.
|
|
92
|
+
*/
|
|
93
|
+
answer(answer: (request: Received) => Answer | undefined): void {
|
|
94
|
+
this.answering = answer;
|
|
28
95
|
}
|
|
29
|
-
}
|
|
30
96
|
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
97
|
+
/** Replies to the pending request `id`. A reply is the last unless `done` is false. */
|
|
98
|
+
deliver(id: number, { value, error, done = true }: Answer): void {
|
|
99
|
+
const reply = this.pending.get(id);
|
|
100
|
+
if (done) this.pending.delete(id);
|
|
101
|
+
reply?.(encodeJson(value), error && nativeError(error), done);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private cancel(id: number): void {
|
|
105
|
+
if (this.pending.delete(id)) this.sent.push({ type: "cancel", id });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
lastRequest(): Record<string, unknown> {
|
|
109
|
+
const { type, plugin, method, payload } = this.sent.at(-1) ?? {};
|
|
110
|
+
return { type, plugin, method, arguments: payload ? decodeJson(payload) : undefined };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
respond(reply: { value?: unknown; error?: NativeFailure }, done = true): void {
|
|
114
|
+
this.respondBytes(encodeJson(reply.value), done, reply.error && nativeError(reply.error));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
respondBytes(payload: Uint8Array, done = true, error?: DOMException): void {
|
|
118
|
+
const id = this.sent.at(-1)?.id;
|
|
119
|
+
if (id === undefined) throw new Error("No native request is pending");
|
|
120
|
+
const reply = this.pending.get(id);
|
|
121
|
+
if (done) this.pending.delete(id);
|
|
122
|
+
reply?.(payload.slice(), error, done);
|
|
123
|
+
}
|
|
36
124
|
}
|
|
37
125
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
Reflect.deleteProperty(globalThis, "__tokamakNative");
|
|
126
|
+
export function connectNative(platform?: NativePlatform, availability?: Availability): FakeNativeTransport {
|
|
127
|
+
return new FakeNativeTransport(platform, availability);
|
|
41
128
|
}
|
|
42
129
|
|
|
43
130
|
/**
|
|
@@ -48,7 +135,7 @@ export async function dispatch(
|
|
|
48
135
|
name: string,
|
|
49
136
|
event: unknown,
|
|
50
137
|
env: unknown = {},
|
|
51
|
-
ctx: unknown = { waitUntil: () => undefined },
|
|
138
|
+
ctx: unknown = createNativeContext({ waitUntil: () => undefined }),
|
|
52
139
|
): Promise<Dispatched & { reported: unknown[] }> {
|
|
53
140
|
const reported: unknown[] = [];
|
|
54
141
|
const previous = Reflect.get(globalThis, "reportError") as unknown;
|
package/src/webview.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { decodeJson, encodeJson, type JsonValue } from "./codec.js";
|
|
2
|
+
import type { NativeConnection } from "./context.js";
|
|
3
|
+
|
|
4
|
+
/** Controls the app's WebView. Operations reject with `InvalidStateError` when what they need is unavailable. */
|
|
5
|
+
export interface WebView {
|
|
6
|
+
/**
|
|
7
|
+
* Starts navigation to an app URL, or a path relative to the current page or the app origin.
|
|
8
|
+
* Resolves when navigation is accepted. Needs the WebView, not a loaded page.
|
|
9
|
+
*/
|
|
10
|
+
navigate(url: string): Promise<void>;
|
|
11
|
+
/** The committed page's absolute URL, or null when no page is loaded. */
|
|
12
|
+
getCurrentPage(): Promise<string | null>;
|
|
13
|
+
/** Dispatches a window `message` event in the current page. Messages are not queued. */
|
|
14
|
+
postMessage(data: JsonValue): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function webview(connection: NativeConnection): WebView {
|
|
18
|
+
const call = async <T>(method: string, value: unknown = null): Promise<T> =>
|
|
19
|
+
decodeJson<T>(await connection.call("tokamak", `webview.${method}`, encodeJson(value)));
|
|
20
|
+
return Object.freeze({
|
|
21
|
+
navigate: async (url: string) => { await call<null>("navigate", url); },
|
|
22
|
+
getCurrentPage: () => call<string | null>("getCurrentPage"),
|
|
23
|
+
postMessage: async (data: JsonValue) => { await call<null>("postMessage", data); },
|
|
24
|
+
});
|
|
25
|
+
}
|
package/src/worker.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { capabilitiesFor } from "./capabilities.mjs";
|
|
2
|
+
import { nativeAvailability, type TokamakContext } from "./context.js";
|
|
3
|
+
import type { TokamakEvent } from "./events.js";
|
|
4
|
+
import { dispatch } from "./registry.js";
|
|
5
|
+
|
|
6
|
+
export { capabilitiesFor } from "./capabilities.mjs";
|
|
7
|
+
export { createNativeContext, nativeAvailability, nativeConnection } from "./context.js";
|
|
8
|
+
export type { Availability, NativePlatform } from "./capabilities.mjs";
|
|
9
|
+
export type { TokamakContext } from "./context.js";
|
|
10
|
+
export type { WebView } from "./webview.js";
|
|
11
|
+
export type { EventReply, EventType, TokamakEvent } from "./events.js";
|
|
12
|
+
|
|
13
|
+
/** The application's native worker entrypoint. */
|
|
14
|
+
export class TokamakEntrypoint<Env = Tokamak.Env> {
|
|
15
|
+
constructor(
|
|
16
|
+
protected readonly ctx: TokamakContext,
|
|
17
|
+
protected readonly env: Env,
|
|
18
|
+
) {}
|
|
19
|
+
|
|
20
|
+
/** Runs the listeners registered for this event and resolves to their reply. */
|
|
21
|
+
event(event: TokamakEvent): unknown {
|
|
22
|
+
return dispatch(event.type, event.data, this.env, this.ctx).then(({ reply }) => reply);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The page module that tells generated clients which plugin operations the app's platform implements. */
|
|
27
|
+
export function capabilitiesModule(ctx: TokamakContext): Response {
|
|
28
|
+
const capabilities = capabilitiesFor(ctx[nativeAvailability], ctx.platform);
|
|
29
|
+
return new Response(`export default ${JSON.stringify(capabilities)};\n`, {
|
|
30
|
+
headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" },
|
|
31
|
+
});
|
|
32
|
+
}
|