@tokamakdev/plugin 0.1.0-beta.83 → 0.1.0-beta.85

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.
Files changed (3) hide show
  1. package/README.md +56 -3
  2. package/package.json +1 -1
  3. package/src/index.ts +66 -36
package/README.md CHANGED
@@ -1,17 +1,70 @@
1
1
  # @tokamakdev/plugin
2
2
 
3
- The shared base of tokamak plugins: the page's native transport, Worker events,
4
- and test fakes.
3
+ The shared base of tokamak plugins: the native transport, Worker events, and
4
+ test fakes.
5
5
 
6
6
  | Entry | Contents |
7
7
  | --- | --- |
8
- | `@tokamakdev/plugin` | `FrontendPlugin`, the base class of a plugin's page API |
8
+ | `@tokamakdev/plugin` | `Plugin`, the base class of a plugin's API, which the page and the Worker call alike |
9
9
  | `@tokamakdev/plugin/events` | `defineEvent`, which defines a plugin's Worker events |
10
10
  | `@tokamakdev/plugin/testing` | A fake native transport, and `dispatch`, which runs event listeners as the runtime does |
11
11
 
12
12
  A plugin lists `@tokamakdev/plugin` as a peer dependency, so an app has one
13
13
  copy of it, shared with `@tokamakdev/tok`, which depends on it.
14
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
+
15
68
  ## Worker events
16
69
 
17
70
  A plugin's native code delivers events to the app's Worker, whose listeners
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokamakdev/plugin",
3
- "version": "0.1.0-beta.83",
3
+ "version": "0.1.0-beta.85",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/mantty/tokamak.git",
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
+ }