@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/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
- /** A native transport that records the page's messages and replies as the native bridges do. */
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
- /** Every message the page posted, parsed. */
6
- readonly sent: Record<string, unknown>[] = [];
7
- onmessage: ((event: { data: string }) => void) | null = null;
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
- postMessage(message: string): void {
10
- this.sent.push(JSON.parse(message) as Record<string, unknown>);
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
- /** The last message's type, plugin, method and arguments. */
14
- lastRequest(): Record<string, unknown> {
15
- const { type, plugin, method, arguments: arguments_ } = this.sent.at(-1) ?? {};
16
- return { type, plugin, method, arguments: arguments_ };
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
- /** Replies to the last message. */
20
- respond(reply: { value?: unknown; error?: { name: string; message: string } }, done = true): void {
21
- const { session, id } = this.sent.at(-1) ?? {};
22
- this.deliver({ session, id, done, ...reply });
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
- /** Delivers `response` to the page. */
26
- deliver(response: Record<string, unknown>): void {
27
- this.onmessage?.({ data: JSON.stringify(response) });
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
- /** Installs a new fake as the page's native transport. */
32
- export function connectNative(): FakeNativeTransport {
33
- const transport = new FakeNativeTransport();
34
- globalThis.__tokamakNative = transport;
35
- return transport;
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
- /** Removes the page's native transport. */
39
- export function disconnectNative(): void {
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
+ }