@tokamakdev/plugin 0.1.0-beta.107 → 0.1.0-beta.112
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 +59 -32
- package/package.json +7 -2
- package/src/bindings.ts +260 -0
- package/src/capabilities.d.mts +11 -0
- package/src/capabilities.mjs +8 -0
- package/src/codec.ts +15 -0
- package/src/context.ts +91 -0
- package/src/entrypoint.js +21 -8
- package/src/events.ts +40 -18
- package/src/index.ts +60 -155
- package/src/native-protocol.d.mts +24 -0
- package/src/native-protocol.mjs +77 -0
- package/src/registry.ts +28 -1
- package/src/resources.ts +101 -0
- package/src/rpc/binding.mjs +109 -0
- package/src/rpc/channel.mjs +219 -0
- package/src/rpc/client.mjs +29 -0
- package/src/rpc/endpoint.mjs +47 -0
- package/src/rpc/frames.mjs +35 -0
- package/src/rpc/http.mjs +28 -0
- package/src/rpc/streams.mjs +52 -0
- package/src/rpc/values.mjs +136 -0
- package/src/streams.ts +120 -0
- package/src/testing.ts +114 -27
- package/src/webview.ts +25 -0
- package/src/worker.ts +32 -0
package/README.md
CHANGED
|
@@ -1,49 +1,65 @@
|
|
|
1
1
|
# @tokamakdev/plugin
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
test fakes.
|
|
3
|
+
Native plugin APIs, endpoint codecs, event contracts and test fixtures.
|
|
5
4
|
|
|
6
5
|
| Entry | Contents |
|
|
7
6
|
| --- | --- |
|
|
8
|
-
| `@tokamakdev/plugin` | `Plugin`,
|
|
9
|
-
| `@tokamakdev/plugin/
|
|
10
|
-
| `@tokamakdev/plugin/
|
|
7
|
+
| `@tokamakdev/plugin` | `Plugin`, `TokamakContext`, JSON codec and value types |
|
|
8
|
+
| `@tokamakdev/plugin/streams` | Bounded push streams and typed JSON stream decoding |
|
|
9
|
+
| `@tokamakdev/plugin/events` | `defineEvent` |
|
|
10
|
+
| `@tokamakdev/plugin/testing` | Invocation fixture and event dispatch |
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
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`:
|
|
12
|
+
Plugin packages declare this package as a peer dependency. Native APIs require
|
|
13
|
+
the receiving Tokamak worker invocation's context:
|
|
22
14
|
|
|
23
15
|
```ts
|
|
24
|
-
import { Plugin } from "@tokamakdev/plugin";
|
|
16
|
+
import { Plugin, type TokamakContext } from "@tokamakdev/plugin";
|
|
25
17
|
|
|
26
18
|
class Alarms extends Plugin {
|
|
27
|
-
constructor() {
|
|
28
|
-
|
|
29
|
-
}
|
|
19
|
+
constructor(ctx: TokamakContext) { super(ctx, "alarms"); }
|
|
20
|
+
set(at: number): Promise<void> { return this.call("set", { at }); }
|
|
21
|
+
rings(): ReadableStream<Uint8Array> { return this.stream("rings"); }
|
|
22
|
+
}
|
|
23
|
+
export function alarms(ctx: TokamakContext) { return new Alarms(ctx); }
|
|
24
|
+
```
|
|
30
25
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
26
|
+
`call` uses the standard JSON endpoint codec. `callBytes` sends and receives
|
|
27
|
+
opaque bytes. `stream` exposes JSON values as a cancellable byte stream;
|
|
28
|
+
`decodeValues<T>` consumes it. Errors terminate a stream. A slow consumer
|
|
29
|
+
exceeding its bounded buffer receives `QuotaExceededError`.
|
|
34
30
|
|
|
35
|
-
|
|
36
|
-
return this.listen("onRing", next, error);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
31
|
+
Declare the factory in `tokamak-plugin.json` alongside native build metadata:
|
|
39
32
|
|
|
40
|
-
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"schemaVersion": 2,
|
|
36
|
+
"id": "alarms",
|
|
37
|
+
"api": { "module": "src/index.ts", "export": "alarms" }
|
|
38
|
+
}
|
|
41
39
|
```
|
|
42
40
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
41
|
+
Tooling generates the `example-alarms/client` default export for pages and the
|
|
42
|
+
`env.TOKAMAK_ALARMS` binding for the main worker. Public API methods use the
|
|
43
|
+
supported service-value and byte-stream types. Callback parameters, native
|
|
44
|
+
objects and arbitrary classes are rejected at build time.
|
|
45
|
+
|
|
46
|
+
An optional `web` module/export reference supplies an object implementing
|
|
47
|
+
frontend methods. Those methods return promises; omitted methods reject with
|
|
48
|
+
`NotSupportedError`. Browser code belongs in that module, separate from the
|
|
49
|
+
native factory. A native factory never tests browser globals.
|
|
50
|
+
|
|
51
|
+
In `src/tokamak.ts`, call `alarms(ctx).set(at)` using the context passed to an
|
|
52
|
+
event handler, or `alarms(this.ctx)` in a `TokamakEntrypoint` method. Dependencies
|
|
53
|
+
and build tooling belong to the same app project.
|
|
54
|
+
|
|
55
|
+
Tests construct an invocation without installing global transports:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
const native = connectNative();
|
|
59
|
+
const result = alarms(native.ctx).set(123);
|
|
60
|
+
native.respond({ value: null });
|
|
61
|
+
await result;
|
|
62
|
+
```
|
|
47
63
|
|
|
48
64
|
## Showing UI
|
|
49
65
|
|
|
@@ -67,7 +83,7 @@ so only calls that would show UI fail:
|
|
|
67
83
|
|
|
68
84
|
## Worker events
|
|
69
85
|
|
|
70
|
-
A plugin's native code delivers events to the app's
|
|
86
|
+
A plugin's native code delivers events to the app's Tokamak worker, whose listeners
|
|
71
87
|
register in `src/tokamak.ts` and run even when no page is loaded, such as when
|
|
72
88
|
the system starts the app in the background.
|
|
73
89
|
|
|
@@ -118,3 +134,14 @@ import { dispatch } from "@tokamakdev/plugin/testing";
|
|
|
118
134
|
onRing(() => 5);
|
|
119
135
|
const { reply, reported } = await dispatch("alarms.ring", { id: "a1" }, env);
|
|
120
136
|
```
|
|
137
|
+
## WebView controls
|
|
138
|
+
|
|
139
|
+
Inside the Tokamak worker, use `this.ctx.webview.navigate("/recipes")`,
|
|
140
|
+
`await this.ctx.webview.getCurrentPage()`, or
|
|
141
|
+
`await this.ctx.webview.postMessage({ recipeId: "42" })`. A page receives messages
|
|
142
|
+
through its standard `window.addEventListener("message", handler)`.
|
|
143
|
+
|
|
144
|
+
Navigation accepts URLs within the app's origin and resolves when the native
|
|
145
|
+
view accepts the navigation. Messaging resolves after dispatch into the current
|
|
146
|
+
page. These operations reject with `InvalidStateError` when no page is loaded;
|
|
147
|
+
they neither create a view nor queue messages for a later page.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokamakdev/plugin",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.112",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/mantty/tokamak.git",
|
|
@@ -9,8 +9,13 @@
|
|
|
9
9
|
"type": "module",
|
|
10
10
|
"exports": {
|
|
11
11
|
".": "./src/index.ts",
|
|
12
|
+
"./streams": "./src/streams.ts",
|
|
13
|
+
"./bindings": "./src/bindings.ts",
|
|
12
14
|
"./events": "./src/events.ts",
|
|
13
|
-
"./
|
|
15
|
+
"./worker": "./src/worker.ts",
|
|
16
|
+
"./testing": "./src/testing.ts",
|
|
17
|
+
"./native-protocol": "./src/native-protocol.mjs",
|
|
18
|
+
"./capabilities": "./src/capabilities.mjs"
|
|
14
19
|
},
|
|
15
20
|
"files": [
|
|
16
21
|
"src"
|
package/src/bindings.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { nativeConnection, type TokamakContext } from "./context.js";
|
|
2
|
+
import { decodeJson, encodeJson } from "./codec.js";
|
|
3
|
+
import { nativeError, type NativeFailure } from "./native-protocol.mjs";
|
|
4
|
+
import { NativeReference, ownerOf } from "./resources.js";
|
|
5
|
+
import { byteStream } from "./streams.js";
|
|
6
|
+
|
|
7
|
+
export type { TokamakContext } from "./context.js";
|
|
8
|
+
export { NativeReference } from "./resources.js";
|
|
9
|
+
|
|
10
|
+
/** The reference `object` views: pass it to another class's constructor or to a plugin's native endpoint. */
|
|
11
|
+
export let referenceOf: (object: NativeObject) => NativeReference;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A native object that a generated binding returned. Its class mirrors the native class; every
|
|
15
|
+
* member is asynchronous. The invocation that received it owns it until `release`, the
|
|
16
|
+
* invocation's end, or garbage collection.
|
|
17
|
+
*/
|
|
18
|
+
export class NativeObject {
|
|
19
|
+
readonly #reference: NativeReference;
|
|
20
|
+
|
|
21
|
+
static {
|
|
22
|
+
referenceOf = (object) => object.#reference;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Views the object `reference` holds as this class. The native side checks the type at each call. */
|
|
26
|
+
constructor(reference: NativeReference) {
|
|
27
|
+
this.#reference = reference;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Releases `object` for every view of it. Later calls on it reject with `InvalidStateError`. */
|
|
32
|
+
export function release(object: NativeObject): void {
|
|
33
|
+
referenceOf(object).close();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type NativeClass = new (reference: NativeReference) => NativeObject;
|
|
37
|
+
|
|
38
|
+
/** The classes of a binding module, by native name. */
|
|
39
|
+
export type Classes = Readonly<Record<string, NativeClass>>;
|
|
40
|
+
|
|
41
|
+
/** The calls of one generated binding module, whose native plugin is `plugin`. */
|
|
42
|
+
export class Bindings {
|
|
43
|
+
readonly #plugin: string;
|
|
44
|
+
readonly #classes: () => Classes;
|
|
45
|
+
|
|
46
|
+
/** `classes` returns the module's classes once they are defined. */
|
|
47
|
+
constructor(plugin: string, classes: () => Classes) {
|
|
48
|
+
this.#plugin = plugin;
|
|
49
|
+
this.#classes = classes;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Calls `member` of `target`, the object or, for a static member, the invocation's context.
|
|
54
|
+
* Aborting `signal` cancels an asynchronous member's task.
|
|
55
|
+
*/
|
|
56
|
+
async call<T>(target: TokamakContext | NativeObject, member: string, args: readonly unknown[], signal?: AbortSignal): Promise<T> {
|
|
57
|
+
const ctx = contextOf(target);
|
|
58
|
+
const reply = await ctx[nativeConnection].call(this.#plugin, member, await request(ctx, target, args), { signal });
|
|
59
|
+
return this.#decode(ctx, reply) as T;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The key of the first of `overloads` whose parameters take `args`: each parameter lists the
|
|
64
|
+
* kinds of value it takes, a class or a `typeof` name, `"array"`, `"bytes"`, `"date"`, `"object"`
|
|
65
|
+
* (a plain object), `"null"` or `"any"`.
|
|
66
|
+
*/
|
|
67
|
+
overload(args: readonly unknown[], overloads: readonly (readonly [string, readonly (readonly Kind[])[]])[]): string {
|
|
68
|
+
const found = overloads.find(([, params]) =>
|
|
69
|
+
params.length === args.length && params.every((kinds, index) => kinds.some((kind) => takes(kind, args[index]))));
|
|
70
|
+
if (!found) throw new TypeError("No overload takes these arguments");
|
|
71
|
+
return found[0];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The elements of the native sequence `member` of `target` returns. Cancelling the stream ends it. */
|
|
75
|
+
stream<T>(target: TokamakContext | NativeObject, member: string, args: readonly unknown[]): ReadableStream<T> {
|
|
76
|
+
const ctx = contextOf(target);
|
|
77
|
+
const elements = byteStream((next, error, close) => {
|
|
78
|
+
let stop: (() => void) | undefined;
|
|
79
|
+
let stopped = false;
|
|
80
|
+
request(ctx, target, args).then((payload) => {
|
|
81
|
+
if (stopped) return;
|
|
82
|
+
stop = ctx[nativeConnection].listen(this.#plugin, member, payload, {
|
|
83
|
+
next: (value, done) => { if (done) close(); else next(value); },
|
|
84
|
+
error,
|
|
85
|
+
});
|
|
86
|
+
}, error);
|
|
87
|
+
return () => {
|
|
88
|
+
stopped = true;
|
|
89
|
+
stop?.();
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
return elements.pipeThrough(new TransformStream<Uint8Array, T>({
|
|
93
|
+
transform: (element, controller) => { controller.enqueue(this.#decode(ctx, element) as T); },
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A native object implementing `protocol` whose `methods`, where `handlers` has them, call
|
|
99
|
+
* `handlers` in order. Its platform defaults remain for the methods `handlers` lacks.
|
|
100
|
+
*/
|
|
101
|
+
async implement<T extends NativeObject>(
|
|
102
|
+
ctx: TokamakContext,
|
|
103
|
+
Protocol: new (reference: NativeReference) => T,
|
|
104
|
+
protocol: string,
|
|
105
|
+
methods: readonly string[],
|
|
106
|
+
handlers: object,
|
|
107
|
+
): Promise<T> {
|
|
108
|
+
const handled = methods.filter((method) => typeof Reflect.get(handlers, method) === "function");
|
|
109
|
+
const payload = encodeJson({ scope: await scopeOf(ctx), methods: handled });
|
|
110
|
+
const reference = await NativeReference.acquire(ctx, this.#plugin, protocol, payload, (value) => {
|
|
111
|
+
const { method, arguments: args } = decodeJson<{ method: string; arguments: Wire[] }>(value);
|
|
112
|
+
const classes = this.#classes();
|
|
113
|
+
try {
|
|
114
|
+
const handler = Reflect.get(handlers, method) as (...values: unknown[]) => unknown;
|
|
115
|
+
Promise.resolve(handler.apply(handlers, args.map((arg) => fromWire(arg, ctx, classes)))).catch((error: unknown) => {
|
|
116
|
+
reportError(error);
|
|
117
|
+
});
|
|
118
|
+
} catch (error) {
|
|
119
|
+
reportError(error);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
return new Protocol(reference);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#decode(ctx: TokamakContext, payload: Uint8Array): unknown {
|
|
126
|
+
return fromWire(decodeJson<Wire>(payload), ctx, this.#classes());
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A kind of value an overload's parameter takes. */
|
|
131
|
+
type Kind = string | (abstract new (reference: NativeReference) => NativeObject);
|
|
132
|
+
|
|
133
|
+
/** The kinds named by strings other than `typeof` results. */
|
|
134
|
+
const named = new Map<string, (value: unknown) => boolean>([
|
|
135
|
+
["any", () => true],
|
|
136
|
+
["null", (value) => value === null || value === undefined],
|
|
137
|
+
["array", (value) => Array.isArray(value)],
|
|
138
|
+
["bytes", (value) => value instanceof Uint8Array],
|
|
139
|
+
["date", (value) => value instanceof Date],
|
|
140
|
+
["object", (value) => typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof NativeObject)],
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
function takes(kind: Kind, value: unknown): boolean {
|
|
144
|
+
if (typeof kind === "function") return value instanceof kind;
|
|
145
|
+
const takes = named.get(kind);
|
|
146
|
+
return takes ? takes(value) : typeof value === kind;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The Android application `Context`. */
|
|
150
|
+
export function applicationContext(ctx: TokamakContext): Promise<NativeReference> {
|
|
151
|
+
return root(ctx, "context");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The Android activity the app shows. Rejects with `NeedsUIError` while the app cannot show UI. */
|
|
155
|
+
export function foregroundActivity(ctx: TokamakContext): Promise<NativeReference> {
|
|
156
|
+
return root(ctx, "activity");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The Apple object a native screen is presented from: the top `UIViewController` on iOS, the
|
|
161
|
+
* `NSWindow` on macOS. Rejects with `NeedsUIError` while the app cannot show UI.
|
|
162
|
+
*/
|
|
163
|
+
export function presenter(ctx: TokamakContext): Promise<NativeReference> {
|
|
164
|
+
return root(ctx, "presenter");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function root(ctx: TokamakContext, name: string): Promise<NativeReference> {
|
|
168
|
+
const payload = encodeJson({ scope: await scopeOf(ctx), name });
|
|
169
|
+
const id = decodeJson<string>(await ctx[nativeConnection].call("tokamak", "resource.root", payload));
|
|
170
|
+
return NativeReference.scoped(ctx, id);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const scopes = new WeakMap<TokamakContext, Promise<string>>();
|
|
174
|
+
|
|
175
|
+
/** The scope that keeps the objects the invocation `ctx` receives until it ends. */
|
|
176
|
+
function scopeOf(ctx: TokamakContext): Promise<string> {
|
|
177
|
+
let scope = scopes.get(ctx);
|
|
178
|
+
if (!scope) {
|
|
179
|
+
// The subscription stays open for the invocation, whose end releases the scope's objects.
|
|
180
|
+
scope = new Promise((resolve, reject) => {
|
|
181
|
+
ctx[nativeConnection].listen("tokamak", "resource.scope", encodeJson(null), {
|
|
182
|
+
next: (value) => { resolve(decodeJson<string>(value)); },
|
|
183
|
+
error: reject,
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
scopes.set(ctx, scope);
|
|
187
|
+
}
|
|
188
|
+
return scope;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function contextOf(target: TokamakContext | NativeObject): TokamakContext {
|
|
192
|
+
return target instanceof NativeObject ? ownerOf(referenceOf(target)) : target;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function request(ctx: TokamakContext, target: TokamakContext | NativeObject, args: readonly unknown[]): Promise<Uint8Array> {
|
|
196
|
+
return encodeJson({
|
|
197
|
+
scope: await scopeOf(ctx),
|
|
198
|
+
target: target instanceof NativeObject ? toWire(target, ctx) : undefined,
|
|
199
|
+
arguments: args.map((arg) => toWire(arg, ctx)),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Binding values as JSON. Objects tagged `#` carry what JSON lacks: native objects, dates, bytes,
|
|
205
|
+
* errors, non-finite numbers, and records whose own keys include `#`.
|
|
206
|
+
*/
|
|
207
|
+
type Wire = null | boolean | number | string | Wire[] | { [key: string]: Wire };
|
|
208
|
+
|
|
209
|
+
function toWire(value: unknown, ctx: TokamakContext): Wire {
|
|
210
|
+
if (value === null || value === undefined) return null;
|
|
211
|
+
if (typeof value === "number" && !Number.isFinite(value)) return { "#": "number", value: String(value) };
|
|
212
|
+
if (typeof value !== "object") return value as Wire;
|
|
213
|
+
return objectToWire(value, ctx);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function objectToWire(value: object, ctx: TokamakContext): Wire {
|
|
217
|
+
const reference = value instanceof NativeObject ? referenceOf(value) : value;
|
|
218
|
+
if (reference instanceof NativeReference) return { "#": "object", id: reference.idFor(ctx) };
|
|
219
|
+
if (value instanceof Date) return { "#": "date", value: value.getTime() };
|
|
220
|
+
if (value instanceof Uint8Array) return { "#": "bytes", value: toBase64(value) };
|
|
221
|
+
if (Array.isArray(value)) return value.map((item) => toWire(item, ctx));
|
|
222
|
+
const fields = Object.fromEntries(Object.entries(value).map(([key, item]) => [key, toWire(item, ctx)]));
|
|
223
|
+
return "#" in fields ? { "#": "record", value: fields } : fields;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
type Tagged = Record<string, Wire>;
|
|
227
|
+
|
|
228
|
+
/** The decoder of each tag. */
|
|
229
|
+
const tags = new Map<string, (value: Tagged, ctx: TokamakContext, classes: Classes) => unknown>([
|
|
230
|
+
["object", (value, ctx, classes) => {
|
|
231
|
+
const Class = (value.types as string[]).map((type) => classes[type]).find(Boolean) ?? NativeObject;
|
|
232
|
+
return new Class(NativeReference.scoped(ctx, value.id as string));
|
|
233
|
+
}],
|
|
234
|
+
["number", (value) => Number(value.value)],
|
|
235
|
+
["date", (value) => new Date(value.value as number)],
|
|
236
|
+
["bytes", (value) => fromBase64(value.value as string)],
|
|
237
|
+
["error", (value) => nativeError(value as unknown as NativeFailure)],
|
|
238
|
+
["record", (value, ctx, classes) => fields(value.value as Tagged, ctx, classes)],
|
|
239
|
+
]);
|
|
240
|
+
|
|
241
|
+
function fromWire(value: Wire, ctx: TokamakContext, classes: Classes): unknown {
|
|
242
|
+
if (Array.isArray(value)) return value.map((item) => fromWire(item, ctx, classes));
|
|
243
|
+
if (value === null || typeof value !== "object") return value;
|
|
244
|
+
const decode = typeof value["#"] === "string" ? tags.get(value["#"]) : undefined;
|
|
245
|
+
return decode ? decode(value, ctx, classes) : fields(value, ctx, classes);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function fields(value: Record<string, Wire>, ctx: TokamakContext, classes: Classes): Record<string, unknown> {
|
|
249
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, fromWire(item, ctx, classes)]));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function toBase64(bytes: Uint8Array): string {
|
|
253
|
+
let binary = "";
|
|
254
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
255
|
+
return btoa(binary);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function fromBase64(text: string): Uint8Array {
|
|
259
|
+
return Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
|
|
260
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** A native platform the host runs on. */
|
|
2
|
+
export type NativePlatform = "ios" | "macos" | "android" | "windows" | "linux";
|
|
3
|
+
|
|
4
|
+
/** The native platforms of each plugin operation, by plugin ID and operation name. */
|
|
5
|
+
export type Availability = Readonly<Record<string, Readonly<Record<string, readonly NativePlatform[]>>>>;
|
|
6
|
+
|
|
7
|
+
/** Whether each operation of each plugin in `availability` has an implementation on `platform`. */
|
|
8
|
+
export function capabilitiesFor(
|
|
9
|
+
availability: Availability,
|
|
10
|
+
platform: NativePlatform | undefined,
|
|
11
|
+
): Record<string, Record<string, boolean>>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Whether each operation of each plugin in `availability` has an implementation on `platform`. */
|
|
2
|
+
export function capabilitiesFor(availability, platform) {
|
|
3
|
+
return Object.fromEntries(Object.entries(availability).map(([plugin, operations]) => [
|
|
4
|
+
plugin,
|
|
5
|
+
Object.fromEntries(Object.entries(operations).map(([operation, platforms]) =>
|
|
6
|
+
[operation, platform !== undefined && platforms.includes(platform)])),
|
|
7
|
+
]));
|
|
8
|
+
}
|
package/src/codec.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
|
|
2
|
+
|
|
3
|
+
const encoder = new TextEncoder();
|
|
4
|
+
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false });
|
|
5
|
+
|
|
6
|
+
/** Encodes the standard structured-value endpoint payload. */
|
|
7
|
+
export function encodeJson(value: unknown): Uint8Array {
|
|
8
|
+
return encoder.encode(JSON.stringify(value ?? null));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Decodes the standard structured-value endpoint payload. */
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- the endpoint signature supplies the decoded type.
|
|
13
|
+
export function decodeJson<T>(payload: Uint8Array): T {
|
|
14
|
+
return JSON.parse(decoder.decode(payload)) as T;
|
|
15
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { capabilitiesFor, type Availability, type NativePlatform } from "./capabilities.mjs";
|
|
2
|
+
import { webview, type WebView } from "./webview.js";
|
|
3
|
+
|
|
4
|
+
/** The native connection supplied by the host. */
|
|
5
|
+
export const nativeConnection = Symbol.for("tokamak.native");
|
|
6
|
+
|
|
7
|
+
/** Each plugin operation's native platforms, by plugin ID, which the plugins' manifests declare. */
|
|
8
|
+
export const nativeAvailability = Symbol.for("tokamak.availability");
|
|
9
|
+
|
|
10
|
+
export type { Availability, NativePlatform } from "./capabilities.mjs";
|
|
11
|
+
|
|
12
|
+
/** A native call's request body and cancellation. */
|
|
13
|
+
export interface CallOptions {
|
|
14
|
+
/** Bytes the native endpoint reads one chunk at a time, as it needs them. */
|
|
15
|
+
readonly body?: ReadableStream<Uint8Array>;
|
|
16
|
+
/** Cancels the call, which then rejects with the signal's reason. */
|
|
17
|
+
readonly signal?: AbortSignal;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** What a subscription does with a value that arrives while its buffer is full. */
|
|
21
|
+
export type Overflow = "error" | "dropOldest";
|
|
22
|
+
|
|
23
|
+
/** Receives a native subscription's values and failures. */
|
|
24
|
+
export interface NativeListener {
|
|
25
|
+
readonly next: (value: Uint8Array, done: boolean) => void;
|
|
26
|
+
readonly error: (error: DOMException, done: boolean) => void;
|
|
27
|
+
/** What happens to values a slow reader has not taken; `"error"` by default. */
|
|
28
|
+
readonly overflow?: Overflow;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface NativeConnection {
|
|
32
|
+
/** The platform the host runs on. */
|
|
33
|
+
readonly platform?: NativePlatform;
|
|
34
|
+
call(plugin: string, method: string, payload: Uint8Array, options?: CallOptions): Promise<Uint8Array>;
|
|
35
|
+
/** Subscribes `listener` until the returned function is called. */
|
|
36
|
+
listen(plugin: string, method: string, payload: Uint8Array, listener: NativeListener): () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The receiving invocation and its native connection. */
|
|
40
|
+
export interface TokamakContext {
|
|
41
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
42
|
+
readonly webview: WebView;
|
|
43
|
+
/** The native platform the app runs on, or undefined without a native host. */
|
|
44
|
+
readonly platform: NativePlatform | undefined;
|
|
45
|
+
readonly [nativeConnection]: NativeConnection;
|
|
46
|
+
readonly [nativeAvailability]: Availability;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function unavailable(): never {
|
|
50
|
+
throw new DOMException("The native device is not connected.", "NotSupportedError");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const disconnected: NativeConnection = {
|
|
54
|
+
call: () => Promise.resolve().then(unavailable),
|
|
55
|
+
listen: unavailable,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The receiving execution context with its native connection, `connection` or the one the host
|
|
60
|
+
* attached to `ctx`, and the plugins' `availability`. Without a connection, native calls reject
|
|
61
|
+
* with `NotSupportedError`.
|
|
62
|
+
*/
|
|
63
|
+
export function createNativeContext(
|
|
64
|
+
ctx: Pick<TokamakContext, "waitUntil"> & { readonly [nativeConnection]?: NativeConnection },
|
|
65
|
+
connection = ctx[nativeConnection],
|
|
66
|
+
availability: Availability = {},
|
|
67
|
+
): TokamakContext {
|
|
68
|
+
const native = connection ?? disconnected;
|
|
69
|
+
return Object.assign(ctx, {
|
|
70
|
+
[nativeConnection]: native,
|
|
71
|
+
[nativeAvailability]: availability,
|
|
72
|
+
platform: native.platform,
|
|
73
|
+
webview: webview(native),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Whether each operation of `plugin` has an implementation on the platform `ctx` runs on. It does
|
|
79
|
+
* not report permission or promise that a call succeeds.
|
|
80
|
+
*/
|
|
81
|
+
export function capabilitiesOf(ctx: TokamakContext, plugin: string): Readonly<Record<string, boolean>> {
|
|
82
|
+
return Object.freeze(capabilitiesFor({ [plugin]: ctx[nativeAvailability][plugin] ?? {} }, ctx.platform)[plugin] ?? {});
|
|
83
|
+
}
|
|
84
|
+
declare global {
|
|
85
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace -- the generated app declarations supply the native bindings.
|
|
86
|
+
namespace Tokamak {
|
|
87
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- the app's generated types declare the members.
|
|
88
|
+
interface Env {}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
package/src/entrypoint.js
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
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
1
|
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
4
2
|
|
|
5
|
-
import { dispatch } from "./registry.ts";
|
|
3
|
+
import { dispatch, eventCodec, listenedEvents } from "./registry.ts";
|
|
4
|
+
import { TokamakEntrypoint } from "./worker.ts";
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
/**
|
|
7
|
+
* The host's event adapter for the application's native entrypoint. Event data and replies are
|
|
8
|
+
* bytes until this endpoint decodes and encodes them with the event's codec.
|
|
9
|
+
*/
|
|
10
|
+
export function eventEntrypoint(Worker, nativeContext) {
|
|
11
|
+
return class extends WorkerEntrypoint {
|
|
12
|
+
async dispatch(type, payload) {
|
|
13
|
+
const ctx = nativeContext(this.ctx);
|
|
14
|
+
const worker = new Worker(ctx, this.env);
|
|
15
|
+
const codec = eventCodec(type);
|
|
16
|
+
const data = codec.decode(payload);
|
|
17
|
+
// Worker classes without an event handler use registered listeners.
|
|
18
|
+
const listening = typeof worker.event !== "function" || worker.event === TokamakEntrypoint.prototype.event;
|
|
19
|
+
const reply = typeof worker.event === "function"
|
|
20
|
+
? await worker.event({ type, data })
|
|
21
|
+
: (await dispatch(type, data, this.env, ctx)).reply;
|
|
22
|
+
return { reply: codec.encode(reply), listened: listening ? listenedEvents() : undefined };
|
|
23
|
+
}
|
|
24
|
+
};
|
|
12
25
|
}
|
package/src/events.ts
CHANGED
|
@@ -1,35 +1,57 @@
|
|
|
1
|
-
import { register, type RegisteredListener } from "./registry.js";
|
|
1
|
+
import { register, setCodec, type EventCodec, type RegisteredListener } from "./registry.js";
|
|
2
|
+
import type { TokamakContext } from "./context.js";
|
|
3
|
+
|
|
4
|
+
export type { EventCodec } from "./registry.js";
|
|
2
5
|
|
|
3
6
|
declare global {
|
|
4
|
-
// eslint-disable-next-line @typescript-eslint/no-namespace --
|
|
5
|
-
namespace
|
|
6
|
-
|
|
7
|
-
interface
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace -- plugins add their events by declaration merging.
|
|
8
|
+
namespace Tokamak {
|
|
9
|
+
/** Each event's data, by event type. A plugin adds its events as `"<plugin>.<name>"`. */
|
|
10
|
+
interface Events {
|
|
11
|
+
/** The runtime started, including when the system started the app in the background. */
|
|
12
|
+
start: { readonly foreground: boolean };
|
|
13
|
+
/** The app moved into the foreground, other than when it started there. */
|
|
14
|
+
resume: Readonly<Record<string, never>>;
|
|
15
|
+
/** The app moved out of the foreground. */
|
|
16
|
+
suspend: Readonly<Record<string, never>>;
|
|
17
|
+
}
|
|
18
|
+
/** The reply of each event that takes one, by event type. */
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- plugins declare the members.
|
|
20
|
+
interface EventReplies {}
|
|
8
21
|
}
|
|
9
22
|
}
|
|
10
23
|
|
|
24
|
+
/** The type of a lifecycle or plugin event. */
|
|
25
|
+
export type EventType = keyof Tokamak.Events;
|
|
26
|
+
|
|
27
|
+
/** What listeners of the event `Type` may reply. */
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- listeners of events without replies return void.
|
|
29
|
+
export type EventReply<Type extends EventType> = Type extends keyof Tokamak.EventReplies ? Tokamak.EventReplies[Type] : void;
|
|
30
|
+
|
|
31
|
+
/** One lifecycle or plugin event delivered by the native host. */
|
|
32
|
+
export type TokamakEvent = { [Type in EventType]: { readonly type: Type; readonly data: Tokamak.Events[Type] } }[EventType];
|
|
33
|
+
|
|
11
34
|
/** The invocation a listener runs in. */
|
|
12
|
-
export
|
|
13
|
-
/** Keeps the event running until `promise` settles or the event's deadline passes. */
|
|
14
|
-
waitUntil(promise: Promise<unknown>): void;
|
|
15
|
-
}
|
|
35
|
+
export type EventContext = TokamakContext;
|
|
16
36
|
|
|
17
|
-
/** Receives an event
|
|
37
|
+
/** Receives an event's data with the Worker's `env`, and may return a `Reply`. */
|
|
18
38
|
export type Listener<T, Reply = void> = (
|
|
19
39
|
event: T,
|
|
20
|
-
env:
|
|
40
|
+
env: Tokamak.Env,
|
|
21
41
|
ctx: EventContext,
|
|
22
42
|
) => Reply | undefined | Promise<Reply | undefined>;
|
|
23
43
|
|
|
24
44
|
/**
|
|
25
|
-
* Defines the event `
|
|
26
|
-
*
|
|
27
|
-
* `undefined`.
|
|
45
|
+
* Defines the event `type`, whose data and reply travel as JSON unless `codec` encodes them, and
|
|
46
|
+
* returns the function that registers its listeners. The event's reply is the first value, in
|
|
47
|
+
* registration order, that a listener returns other than `undefined`.
|
|
28
48
|
*/
|
|
29
|
-
export function defineEvent<
|
|
30
|
-
|
|
31
|
-
|
|
49
|
+
export function defineEvent<Type extends EventType>(
|
|
50
|
+
type: Type,
|
|
51
|
+
codec?: EventCodec<Tokamak.Events[Type], EventReply<Type>>,
|
|
52
|
+
): (listener: Listener<Tokamak.Events[Type], EventReply<Type>>) => void {
|
|
53
|
+
if (codec) setCodec(type, codec);
|
|
32
54
|
return (listener) => {
|
|
33
|
-
register(
|
|
55
|
+
register(type, listener as RegisteredListener);
|
|
34
56
|
};
|
|
35
57
|
}
|