@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.
- package/README.md +56 -3
- package/package.json +1 -1
- 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
|
|
4
|
-
|
|
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` | `
|
|
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
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
|
-
|
|
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
|
|
63
|
+
return pageTransport() !== undefined || globalThis.__tokamakNativeCall !== undefined;
|
|
47
64
|
}
|
|
48
65
|
|
|
49
66
|
protected call<T>(method: string, arguments_: unknown = null): Promise<T> {
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
129
|
+
transport: NativeTransport,
|
|
96
130
|
plugin: string,
|
|
97
131
|
method: string,
|
|
98
132
|
arguments_: unknown,
|
|
99
133
|
request: Pending,
|
|
100
|
-
):
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|