@tokamakdev/plugin 0.1.0-beta.82 → 0.1.0-beta.84

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 no scene is in
63
+ the foreground. Call it where the plugin would show UI, and only when the
64
+ UI would appear, such as before `requestWhenInUseAuthorization` while the
65
+ authorization is not determined. A hidden macOS app still shows system
66
+ 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokamakdev/plugin",
3
- "version": "0.1.0-beta.82",
3
+ "version": "0.1.0-beta.84",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/mantty/tokamak.git",
@@ -9,7 +9,7 @@
9
9
  "type": "module",
10
10
  "exports": {
11
11
  ".": "./src/index.ts",
12
- "./worker": "./src/worker.ts",
12
+ "./events": "./src/events.ts",
13
13
  "./testing": "./src/testing.ts"
14
14
  },
15
15
  "files": [
@@ -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 CHANGED
@@ -30,8 +30,24 @@ interface PendingSubscription {
30
30
 
31
31
  type Pending = PendingCall | PendingSubscription;
32
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
+
33
45
  declare global {
46
+ /** The page's native transport, which the shells define. */
34
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;
35
51
  }
36
52
 
37
53
  let nextRequestId = 1;
@@ -39,17 +55,24 @@ const session = `${String(Date.now())}-${String(Math.random())}`;
39
55
  let connected: NativeTransport | undefined;
40
56
  const pending = new Map<number, Pending>();
41
57
 
42
- export abstract class FrontendPlugin {
58
+ /** The base class of a plugin's API, which the page and the Worker call alike. */
59
+ export abstract class Plugin {
43
60
  protected constructor(private readonly pluginId: string) {}
44
61
 
45
62
  protected get hasNativeTransport(): boolean {
46
- return nativeTransport() !== undefined;
63
+ return pageTransport() !== undefined || globalThis.__tokamakNativeCall !== undefined;
47
64
  }
48
65
 
49
66
  protected call<T>(method: string, arguments_: unknown = null): Promise<T> {
50
- return new Promise<T>((resolve, reject) => {
51
- send("call", this.pluginId, method, arguments_, { kind: "call", resolve, reject });
52
- });
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());
53
76
  }
54
77
 
55
78
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- T types the native values, as in call.
@@ -59,47 +82,58 @@ export abstract class FrontendPlugin {
59
82
  error: (error: DOMException) => void,
60
83
  arguments_: unknown = null,
61
84
  ): () => void {
62
- const { transport, id } = send("subscribe", this.pluginId, method, arguments_, {
63
- kind: "subscription",
64
- next,
65
- error,
66
- });
67
- return () => {
68
- if (!pending.delete(id)) return;
69
- transport.postMessage(JSON.stringify({ type: "cancel", session, id }));
70
- };
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();
71
100
  }
72
101
  }
73
102
 
74
- function nativeTransport(): NativeTransport | undefined {
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 {
75
109
  const transport = globalThis.__tokamakNative;
76
110
  if (!transport || typeof transport.postMessage !== "function") return undefined;
77
- if (connected !== transport) {
78
- connected = transport;
79
- transport.onmessage = ({ data }) => {
80
- receive(JSON.parse(data) as NativeResponse);
81
- };
82
- transport.postMessage(JSON.stringify({ type: "reset", session }));
83
- }
111
+ if (connected !== transport) connect(transport);
84
112
  return transport;
85
113
  }
86
114
 
87
- function requireNativeTransport(): NativeTransport {
88
- const transport = nativeTransport();
89
- if (transport) return transport;
90
- throw new DOMException("Native plugin transport is unavailable", "NotSupportedError");
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 }));
91
125
  }
92
126
 
93
127
  /** Posts a call or subscription, keeping `request` pending unless posting throws. */
94
128
  function send(
95
- type: "call" | "subscribe",
129
+ transport: NativeTransport,
96
130
  plugin: string,
97
131
  method: string,
98
132
  arguments_: unknown,
99
133
  request: Pending,
100
- ): { transport: NativeTransport; id: number } {
101
- const transport = requireNativeTransport();
134
+ ): number {
102
135
  const id = nextRequestId++;
136
+ const type = request.kind === "call" ? "call" : "subscribe";
103
137
  pending.set(id, request);
104
138
  try {
105
139
  transport.postMessage(
@@ -109,7 +143,7 @@ function send(
109
143
  pending.delete(id);
110
144
  throw error;
111
145
  }
112
- return { transport, id };
146
+ return id;
113
147
  }
114
148
 
115
149
  function receive(response: NativeResponse): void {
@@ -129,15 +163,11 @@ function receive(response: NativeResponse): void {
129
163
  else request.next(response.value);
130
164
  }
131
165
 
132
- const eventTarget: {
133
- addEventListener?: (type: string, listener: () => void) => void;
134
- } = globalThis;
135
-
136
- eventTarget.addEventListener?.("pagehide", () => {
166
+ function cancelSubscriptions(): void {
137
167
  if (!connected) return;
138
168
  for (const [id, request] of pending) {
139
169
  if (request.kind !== "subscription") continue;
140
170
  pending.delete(id);
141
171
  connected.postMessage(JSON.stringify({ type: "cancel", session, id }));
142
172
  }
143
- });
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 CHANGED
@@ -1,3 +1,5 @@
1
+ import { dispatch as dispatchEvent, type Dispatched } from "./registry.js";
2
+
1
3
  /** A native transport that records the page's messages and replies as the native bridges do. */
2
4
  export class FakeNativeTransport {
3
5
  /** Every message the page posted, parsed. */
@@ -37,3 +39,23 @@ export function connectNative(): FakeNativeTransport {
37
39
  export function disconnectNative(): void {
38
40
  Reflect.deleteProperty(globalThis, "__tokamakNative");
39
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
+ }
package/src/worker.ts DELETED
@@ -1,17 +0,0 @@
1
- declare const process:
2
- | { readonly env: { readonly NODE_ENV?: string; readonly TOKAMAK_RUNTIME?: string } }
3
- | undefined;
4
-
5
- /**
6
- * Whether the Worker serves the tokamak runtime's calls to endpoints under `/tokamak/`: in a
7
- * tokamak app, where the runtime sets `TOKAMAK_RUNTIME`, and in development. Those endpoints
8
- * refuse requests otherwise.
9
- */
10
- export function acceptsRuntimeCalls(): boolean {
11
- // One expression: Rollup 4.63.0 drops callers' checks when this is an early return followed
12
- // by a return of `typeof process !== "undefined" && false`.
13
- return (
14
- typeof process !== "undefined" &&
15
- (process.env.NODE_ENV === "development" || process.env.TOKAMAK_RUNTIME === "true")
16
- );
17
- }