@tokamakdev/plugin 0.1.0-beta.100

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 ADDED
@@ -0,0 +1,120 @@
1
+ # @tokamakdev/plugin
2
+
3
+ The shared base of tokamak plugins: the native transport, Worker events, and
4
+ test fakes.
5
+
6
+ | Entry | Contents |
7
+ | --- | --- |
8
+ | `@tokamakdev/plugin` | `Plugin`, the base class of a plugin's API, which the page and the Worker call alike |
9
+ | `@tokamakdev/plugin/events` | `defineEvent`, which defines a plugin's Worker events |
10
+ | `@tokamakdev/plugin/testing` | A fake native transport, and `dispatch`, which runs event listeners as the runtime does |
11
+
12
+ A plugin lists `@tokamakdev/plugin` as a peer dependency, so an app has one
13
+ copy of it, shared with `@tokamakdev/tok`, which depends on it.
14
+
15
+ ## Calls
16
+
17
+ A plugin's API extends `Plugin`, whose `call` and `listen` reach its native
18
+ code from the page through the shell's bridge, and from the Worker through the
19
+ runtime. `hasNativeTransport` is false outside a tokamak app, such as on the
20
+ web and Cloudflare, where the plugin uses its web implementation or rejects
21
+ with `NotSupportedError`:
22
+
23
+ ```ts
24
+ import { Plugin } from "@tokamakdev/plugin";
25
+
26
+ class Alarms extends Plugin {
27
+ constructor() {
28
+ super("alarms");
29
+ }
30
+
31
+ set(at: number): Promise<void> {
32
+ return this.call("set", { at });
33
+ }
34
+
35
+ onRing(next: (id: string) => void, error: (error: DOMException) => void): () => void {
36
+ return this.listen("onRing", next, error);
37
+ }
38
+ }
39
+
40
+ export const alarms = new Alarms();
41
+ ```
42
+
43
+ The native side runs each call and subscription on the main thread, for the
44
+ page and the Worker alike. A `subscribe` that throws, as for a method the
45
+ plugin does not have, ends the subscription; a failure it replies with does
46
+ not.
47
+
48
+ ## Showing UI
49
+
50
+ Calls come at any time, including while the app is off screen. Native code
51
+ that would show UI then fails with `NeedsUIError` instead, through the host,
52
+ so only calls that would show UI fail:
53
+
54
+ - **Android:** `requestPermissions` throws `NeedsUIError` when a permission
55
+ would prompt while the app is in the background, and answers at once,
56
+ without UI, when none would. The host remembers the permissions the user has
57
+ refused for good, as Android reports them the same way as permissions never
58
+ asked for; `wouldPrompt` tells them apart. Other UI goes in the
59
+ activity `requireForegroundActivity()` returns, which throws `NeedsUIError`
60
+ in the background; `authenticateOwner` uses it. `isInForeground` reports the
61
+ app's stage.
62
+ - **Apple:** `requireUI()` throws `NeedsUIError` on iOS while the app is in
63
+ the background. Call it on the main thread where the plugin decides to show
64
+ UI, and only when the UI would appear, such as before
65
+ `requestWhenInUseAuthorization` while the authorization is not determined.
66
+ A hidden macOS app still shows system prompts, so it never throws there.
67
+
68
+ ## Worker events
69
+
70
+ A plugin's native code delivers events to the app's Worker, whose listeners
71
+ register in `src/tokamak.ts` and run even when no page is loaded, such as when
72
+ the system starts the app in the background.
73
+
74
+ The plugin defines each event in its own `/events` entry, which apps import:
75
+
76
+ ```ts
77
+ // events/index.ts, exported as "./events" in package.json
78
+ import { defineEvent } from "@tokamakdev/plugin/events";
79
+
80
+ /** Receives each alarm as it rings. A returned number snoozes it for that many minutes. */
81
+ export const onRing = defineEvent<{ id: string }, number>("alarms.ring");
82
+ ```
83
+
84
+ `defineEvent<T, Reply>(name)` returns the function that registers listeners,
85
+ typed with the event's data `T` and the `Reply` its listeners may return. The
86
+ name is the plugin's ID, a `.`, and the event's name. The event's reply is the
87
+ first value, in registration order, that a listener returns other than
88
+ `undefined`.
89
+
90
+ The native side emits the event through the host the plugin is created with,
91
+ which prefixes the plugin's ID, so listeners of `alarms.ring` receive
92
+ `host.emit("ring", …)`. Events are JSON:
93
+
94
+ ```swift
95
+ host.emit("ring", event: ["id": alarm.id], timeout: 10) { result in
96
+ // `result` is the reply, parsed from JSON, or nil when no listener replies.
97
+ }
98
+ ```
99
+
100
+ ```kotlin
101
+ // Blocks, so call it off the main thread.
102
+ val reply = host.emit("ring", JSONObject().put("id", alarm.id).toString(), 10_000)
103
+ // `reply` is the reply's JSON, "null" when no listener replies.
104
+ ```
105
+
106
+ `timeout` includes starting the runtime when the app is not running. `emit`
107
+ fails when the Worker cannot run, or when a listener has not returned by the
108
+ timeout; it is not retried. The runtime starts no Worker for an event without
109
+ listeners, so emit each event the plugin defines whether or not the app
110
+ listens. Plugin IDs contain no `.`.
111
+
112
+ Test listeners with `dispatch` from `@tokamakdev/plugin/testing`, which runs
113
+ them as the runtime does and returns what they threw:
114
+
115
+ ```ts
116
+ import { dispatch } from "@tokamakdev/plugin/testing";
117
+
118
+ onRing(() => 5);
119
+ const { reply, reported } = await dispatch("alarms.ring", { id: "a1" }, env);
120
+ ```
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@tokamakdev/plugin",
3
+ "version": "0.1.0-beta.100",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/mantty/tokamak.git",
7
+ "directory": "plugins/core"
8
+ },
9
+ "type": "module",
10
+ "exports": {
11
+ ".": "./src/index.ts",
12
+ "./events": "./src/events.ts",
13
+ "./testing": "./src/testing.ts"
14
+ },
15
+ "files": [
16
+ "src"
17
+ ],
18
+ "publishConfig": {
19
+ "access": "public"
20
+ }
21
+ }
@@ -0,0 +1,12 @@
1
+ // The tokamak Vite plugin exports this class from the entry Worker in tokamak builds and
2
+ // development, where the runtime and `tok dev` call `dispatch` to deliver events.
3
+ import { WorkerEntrypoint } from "cloudflare:workers";
4
+
5
+ import { dispatch } from "./registry.ts";
6
+
7
+ export class TokamakEvents extends WorkerEntrypoint {
8
+ /** Runs the listeners of the event `name` and returns `{ reply, listened }`. */
9
+ dispatch(name, event) {
10
+ return dispatch(name, event, this.env, this.ctx);
11
+ }
12
+ }
package/src/events.ts ADDED
@@ -0,0 +1,35 @@
1
+ import { register, type RegisteredListener } from "./registry.js";
2
+
3
+ declare global {
4
+ // eslint-disable-next-line @typescript-eslint/no-namespace -- `wrangler types` declares the Worker's bindings here.
5
+ namespace Cloudflare {
6
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- the app's `wrangler types` declares the members.
7
+ interface Env {}
8
+ }
9
+ }
10
+
11
+ /** The invocation a listener runs in. */
12
+ export interface EventContext {
13
+ /** Keeps the event running until `promise` settles or the event's deadline passes. */
14
+ waitUntil(promise: Promise<unknown>): void;
15
+ }
16
+
17
+ /** Receives an event of type `T` with the Worker's `env`, and may return a `Reply`. */
18
+ export type Listener<T, Reply = void> = (
19
+ event: T,
20
+ env: Cloudflare.Env,
21
+ ctx: EventContext,
22
+ ) => Reply | undefined | Promise<Reply | undefined>;
23
+
24
+ /**
25
+ * Defines the event `name` and returns the function that registers its listeners. The event's
26
+ * reply is the first value, in registration order, that a listener returns other than
27
+ * `undefined`.
28
+ */
29
+ export function defineEvent<T, Reply = void>(
30
+ name: string,
31
+ ): (listener: Listener<T, Reply>) => void {
32
+ return (listener) => {
33
+ register(name, listener as RegisteredListener);
34
+ };
35
+ }
package/src/index.ts ADDED
@@ -0,0 +1,173 @@
1
+ interface NativeTransport {
2
+ postMessage(message: string): void;
3
+ onmessage: ((event: { data: string }) => void) | null;
4
+ }
5
+
6
+ interface NativeError {
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. */
59
+ export abstract class Plugin {
60
+ protected constructor(private readonly pluginId: string) {}
61
+
62
+ protected get hasNativeTransport(): boolean {
63
+ return pageTransport() !== undefined || globalThis.__tokamakNativeCall !== undefined;
64
+ }
65
+
66
+ protected call<T>(method: string, arguments_: unknown = null): Promise<T> {
67
+ const page = pageTransport();
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());
76
+ }
77
+
78
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- T types the native values, as in call.
79
+ protected listen<T>(
80
+ method: string,
81
+ next: (value: T) => void,
82
+ error: (error: DOMException) => void,
83
+ arguments_: unknown = null,
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();
100
+ }
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
+
115
+ function connect(transport: NativeTransport): void {
116
+ if (!connected) {
117
+ const page: { addEventListener?: (type: string, listener: () => void) => void } = globalThis;
118
+ page.addEventListener?.("pagehide", cancelSubscriptions);
119
+ }
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
+
127
+ /** Posts a call or subscription, keeping `request` pending unless posting throws. */
128
+ function send(
129
+ transport: NativeTransport,
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;
145
+ }
146
+ return id;
147
+ }
148
+
149
+ function receive(response: NativeResponse): void {
150
+ if (response.session !== session) return;
151
+ const request = pending.get(response.id);
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;
160
+ }
161
+
162
+ if (request.kind === "call") request.resolve(response.value);
163
+ else request.next(response.value);
164
+ }
165
+
166
+ function cancelSubscriptions(): void {
167
+ if (!connected) return;
168
+ for (const [id, request] of pending) {
169
+ if (request.kind !== "subscription") continue;
170
+ pending.delete(id);
171
+ connected.postMessage(JSON.stringify({ type: "cancel", session, id }));
172
+ }
173
+ }
@@ -0,0 +1,40 @@
1
+ /** A registered listener. */
2
+ export type RegisteredListener = (event: unknown, env: unknown, ctx: unknown) => unknown;
3
+
4
+ /** What a dispatch returns. */
5
+ export interface Dispatched {
6
+ /** The first value, in registration order, that a listener returned other than `undefined`. */
7
+ reply: unknown;
8
+ /** The names of the events that have listeners. */
9
+ listened: string[];
10
+ }
11
+
12
+ /** Every listener, by event name, in registration order. */
13
+ const listeners = new Map<string, RegisteredListener[]>();
14
+
15
+ export function register(name: string, listener: RegisteredListener): void {
16
+ const registered = listeners.get(name);
17
+ if (registered) registered.push(listener);
18
+ else listeners.set(name, [listener]);
19
+ }
20
+
21
+ /**
22
+ * Runs the listeners of the event `name` and waits for them to settle. A listener that throws is
23
+ * reported with `reportError`, and the others still run.
24
+ */
25
+ export async function dispatch(
26
+ name: string,
27
+ event: unknown,
28
+ env: unknown,
29
+ ctx: unknown,
30
+ ): Promise<Dispatched> {
31
+ const results = await Promise.allSettled(
32
+ (listeners.get(name) ?? []).map(async (listener) => await listener(event, env, ctx)),
33
+ );
34
+ let reply: unknown;
35
+ for (const result of results) {
36
+ if (result.status === "rejected") reportError(result.reason);
37
+ else if (reply === undefined) reply = result.value;
38
+ }
39
+ return { reply, listened: [...listeners.keys()] };
40
+ }
package/src/testing.ts ADDED
@@ -0,0 +1,61 @@
1
+ import { dispatch as dispatchEvent, type Dispatched } from "./registry.js";
2
+
3
+ /** A native transport that records the page's messages and replies as the native bridges do. */
4
+ export class FakeNativeTransport {
5
+ /** Every message the page posted, parsed. */
6
+ readonly sent: Record<string, unknown>[] = [];
7
+ onmessage: ((event: { data: string }) => void) | null = null;
8
+
9
+ postMessage(message: string): void {
10
+ this.sent.push(JSON.parse(message) as Record<string, unknown>);
11
+ }
12
+
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_ };
17
+ }
18
+
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 });
23
+ }
24
+
25
+ /** Delivers `response` to the page. */
26
+ deliver(response: Record<string, unknown>): void {
27
+ this.onmessage?.({ data: JSON.stringify(response) });
28
+ }
29
+ }
30
+
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;
36
+ }
37
+
38
+ /** Removes the page's native transport. */
39
+ export function disconnectNative(): void {
40
+ Reflect.deleteProperty(globalThis, "__tokamakNative");
41
+ }
42
+
43
+ /**
44
+ * Runs the listeners of the event `name` as the runtime does, returning the reply, the events
45
+ * that have listeners, and what listeners threw, which the runtime reports with `reportError`.
46
+ */
47
+ export async function dispatch(
48
+ name: string,
49
+ event: unknown,
50
+ env: unknown = {},
51
+ ctx: unknown = { waitUntil: () => undefined },
52
+ ): Promise<Dispatched & { reported: unknown[] }> {
53
+ const reported: unknown[] = [];
54
+ const previous = Reflect.get(globalThis, "reportError") as unknown;
55
+ globalThis.reportError = (error: unknown) => reported.push(error);
56
+ try {
57
+ return { ...(await dispatchEvent(name, event, env, ctx)), reported };
58
+ } finally {
59
+ Reflect.set(globalThis, "reportError", previous);
60
+ }
61
+ }