@tokamakdev/plugin 0.1.0-beta.106 → 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 CHANGED
@@ -1,49 +1,65 @@
1
1
  # @tokamakdev/plugin
2
2
 
3
- The shared base of tokamak plugins: the native transport, Worker events, and
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`, 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 |
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
- 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`:
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
- super("alarms");
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
- set(at: number): Promise<void> {
32
- return this.call("set", { at });
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
- onRing(next: (id: string) => void, error: (error: DOMException) => void): () => void {
36
- return this.listen("onRing", next, error);
37
- }
38
- }
31
+ Declare the factory in `tokamak-plugin.json` alongside native build metadata:
39
32
 
40
- export const alarms = new Alarms();
33
+ ```json
34
+ {
35
+ "schemaVersion": 2,
36
+ "id": "alarms",
37
+ "api": { "module": "src/index.ts", "export": "alarms" }
38
+ }
41
39
  ```
42
40
 
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.
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 Worker, whose listeners
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.106",
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
- "./testing": "./src/testing.ts"
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"
@@ -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
- 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
- }
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 -- `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 {}
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 interface EventContext {
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 of type `T` with the Worker's `env`, and may return a `Reply`. */
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: Cloudflare.Env,
40
+ env: Tokamak.Env,
21
41
  ctx: EventContext,
22
42
  ) => Reply | undefined | Promise<Reply | undefined>;
23
43
 
24
44
  /**
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`.
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<T, Reply = void>(
30
- name: string,
31
- ): (listener: Listener<T, Reply>) => void {
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(name, listener as RegisteredListener);
55
+ register(type, listener as RegisteredListener);
34
56
  };
35
57
  }