@tokamakdev/tok 0.1.1-beta.107 → 0.1.1-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.
@@ -1,23 +1,13 @@
1
1
  import http from "node:http";
2
- import { createRequire } from "node:module";
3
- import { pathToFileURL } from "node:url";
2
+ import { Readable } from "node:stream";
3
+ import { pipeline } from "node:stream/promises";
4
4
  import { WebSocketServer } from "ws";
5
+ import { decodeMessage, encodeMessage, messages } from "@tokamakdev/plugin/native-protocol";
6
+ import { DevWorker } from "./dev-worker.mjs";
5
7
  /** The header that carries the device's session token, which `tok dev`'s relay forwards. */
6
8
  export const SESSION_HEADER = "x-tokamak-session";
7
9
  /** How long a plugin call waits for the device to connect. */
8
10
  const CONNECT_TIMEOUT = 10_000;
9
- /** Calls `TokamakEvents` in the Worker its `TOKAMAK` service binding names, through the dev registry. */
10
- const CALLER = `export default {
11
- async fetch(request, env) {
12
- const { name, event } = await request.json();
13
- try {
14
- const { reply } = await env.TOKAMAK.dispatch(name, event);
15
- return Response.json({ reply: reply ?? null });
16
- } catch (error) {
17
- return new Response(error?.stack ?? String(error), { status: 500 });
18
- }
19
- },
20
- };`;
21
11
  /**
22
12
  * Listens on a loopback port, with `token`, for the device's dev WebSocket and the development
23
13
  * Worker's plugin calls. Delivers each event the device sends to `TokamakEvents` in the
@@ -32,6 +22,10 @@ export async function openDevSocket(workerName, token) {
32
22
  response.writeHead(401).end();
33
23
  return;
34
24
  }
25
+ if (request.url?.startsWith("/_tokamak/")) {
26
+ void forwardWorker(worker, request, response);
27
+ return;
28
+ }
35
29
  if (request.method === "POST" && (request.url === "/call" || request.url === "/subscribe")) {
36
30
  void forward(device, request, response);
37
31
  return;
@@ -44,6 +38,10 @@ export async function openDevSocket(workerName, token) {
44
38
  socket.end("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
45
39
  return;
46
40
  }
41
+ if (request.url?.startsWith("/_tokamak/")) {
42
+ void upgradeWorker(worker, request, socket, head, sockets);
43
+ return;
44
+ }
47
45
  sockets.handleUpgrade(request, socket, head, (websocket) => serve(websocket, worker, device));
48
46
  });
49
47
  await new Promise((resolve, reject) => {
@@ -61,70 +59,175 @@ export async function openDevSocket(workerName, token) {
61
59
  },
62
60
  };
63
61
  }
62
+ async function forwardWorker(worker, request, response) {
63
+ const abort = new AbortController();
64
+ response.once("close", () => abort.abort());
65
+ try {
66
+ const result = await worker.fetch(request, abort.signal);
67
+ response.writeHead(result.status, [...result.headers].flat());
68
+ if (result.body)
69
+ await pipeline(Readable.fromWeb(result.body), response);
70
+ else
71
+ response.end();
72
+ }
73
+ catch (error) {
74
+ if (response.headersSent)
75
+ response.destroy(error instanceof Error ? error : new Error(String(error)));
76
+ else
77
+ response.writeHead(502).end(String(error));
78
+ }
79
+ }
80
+ async function upgradeWorker(worker, request, socket, head, sockets) {
81
+ const abort = new AbortController();
82
+ let pending;
83
+ const release = () => {
84
+ const pair = pending;
85
+ pending = undefined;
86
+ if (!pair)
87
+ return;
88
+ pair.accept();
89
+ pair.close(1000, "Caller disconnected");
90
+ };
91
+ socket.once("close", () => { abort.abort(); release(); });
92
+ socket.once("error", () => socket.destroy());
93
+ try {
94
+ const result = await worker.fetch(request, abort.signal);
95
+ const pair = result.webSocket;
96
+ if (!pair) {
97
+ socket.end(`HTTP/1.1 ${result.status} ${result.statusText}\r\nConnection: close\r\n\r\n`);
98
+ return;
99
+ }
100
+ pending = pair;
101
+ if (socket.destroyed) {
102
+ release();
103
+ return;
104
+ }
105
+ sockets.handleUpgrade(request, socket, head, websocket => {
106
+ void worker.couple(websocket, pair).then(() => { pending = undefined; }, () => { release(); websocket.terminate(); });
107
+ });
108
+ }
109
+ catch {
110
+ release();
111
+ socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
112
+ }
113
+ }
64
114
  function serve(websocket, worker, device) {
65
115
  device.connect(websocket);
66
116
  // The device opens another dev WebSocket after this one fails.
67
117
  websocket.on("error", () => websocket.terminate());
68
118
  websocket.on("message", (data) => {
69
- const message = parseMessage(String(data));
70
- if (message?.type === "result") {
71
- device.receive(message.id, message.result);
119
+ let message;
120
+ try {
121
+ const bytes = Array.isArray(data) ? Buffer.concat(data) : new Uint8Array(data);
122
+ message = decodeMessage(bytes);
123
+ }
124
+ catch {
125
+ websocket.close(1002, "Invalid native message");
126
+ return;
127
+ }
128
+ const { header, payload } = message;
129
+ if (header.type === "result" && header.id !== undefined) {
130
+ device.receive(header.id, { payload, failed: header.failed === true, done: header.done === true });
131
+ return;
132
+ }
133
+ if (header.type === "pull" && header.id !== undefined) {
134
+ device.pull(header.id);
72
135
  return;
73
136
  }
74
- if (message?.type !== "event")
137
+ if (header.type !== "event" || header.id === undefined || !header.name)
75
138
  return;
76
- worker.dispatch(message.name, message.event).then((reply) => websocket.send(JSON.stringify({ type: "reply", id: message.id, reply })), (error) => websocket.send(JSON.stringify({ type: "error", id: message.id, message: String(error) })));
139
+ const send = (type, value) => {
140
+ if (websocket.readyState === websocket.OPEN)
141
+ websocket.send(encodeMessage({ type, id: header.id }, value));
142
+ };
143
+ worker.dispatch(header.name, payload).then((reply) => send("reply", reply), (error) => send("error", encoder.encode(String(error))));
77
144
  });
78
145
  }
146
+ const encoder = new TextEncoder();
79
147
  /**
80
- * Passes the plugin call `request` carries to the device. A call's response is its result; a
81
- * subscription's streams each result as a line of JSON until one is done, and closing it ends the
82
- * subscription.
148
+ * Relays opaque endpoint payloads; closing the response cancels the native request. A call with a
149
+ * body sends its later messages to the device as the device pulls them.
83
150
  */
84
151
  async function forward(device, request, response) {
152
+ const frames = messages(request);
85
153
  let call;
86
154
  try {
87
- call = JSON.parse(await readBody(request));
155
+ const first = await frames.next();
156
+ if (first.done)
157
+ throw new TypeError("Missing native request");
158
+ call = first.value;
159
+ if (!call.header.plugin || !call.header.method)
160
+ throw new TypeError("Missing native endpoint");
88
161
  }
89
162
  catch {
90
163
  response.writeHead(400).end();
91
164
  return;
92
165
  }
93
- response.writeHead(200, { "content-type": "application/json" });
94
- if (request.url === "/call") {
95
- response.end(JSON.stringify(await new Promise((resolve) => device.send("call", call, resolve))));
96
- return;
97
- }
98
- response.flushHeaders();
99
- const cancel = device.send("subscribe", call, (result) => {
100
- response.write(`${JSON.stringify(result)}\n`);
101
- if (result.done)
102
- response.end();
166
+ response.writeHead(200, { "content-type": "application/octet-stream" });
167
+ const subscribe = request.url === "/subscribe";
168
+ const type = subscribe ? "subscribe" : call.header.type === "callWithBody" ? "callWithBody" : "call";
169
+ if (subscribe)
170
+ response.flushHeaders();
171
+ const write = subscribe ? subscriptionWriter(response, call.header.overflow, () => { cancel(); }) : undefined;
172
+ const cancel = device.send(type, call, ({ payload, failed, done }) => {
173
+ const frame = encodeMessage({ type: "result", failed, done }, payload);
174
+ if (write)
175
+ write(frame, done);
176
+ else
177
+ response.end(frame);
178
+ }, async () => {
179
+ const next = await frames.next().catch(() => ({ done: true, value: undefined }));
180
+ return next.done ? failure("DataCloneError", "The call's body ended early") : chunkResult(next.value);
103
181
  });
104
182
  response.once("close", () => {
105
183
  if (!response.writableEnded)
106
184
  cancel();
107
185
  });
108
186
  }
109
- function readBody(request) {
110
- return new Promise((resolve, reject) => {
111
- const chunks = [];
112
- request.on("data", (chunk) => chunks.push(chunk));
113
- request.once("end", () => resolve(Buffer.concat(chunks).toString()));
114
- request.once("error", reject);
115
- });
187
+ function chunkResult({ header, payload }) {
188
+ return { payload, failed: header.failed === true, done: header.done === true };
116
189
  }
117
- function parseMessage(text) {
118
- try {
119
- return JSON.parse(text);
120
- }
121
- catch {
122
- return undefined;
123
- }
190
+ /** The most a subscription's response holds while the Worker is not reading it. */
191
+ const SUBSCRIPTION_BUFFER = 1024 * 1024;
192
+ /**
193
+ * Writes a subscription's frames to `response`, holding those it cannot take yet. Past
194
+ * `SUBSCRIPTION_BUFFER`, `overflow` drops the oldest held frame or ends the subscription with
195
+ * `fail`.
196
+ */
197
+ function subscriptionWriter(response, overflow, fail) {
198
+ const held = [];
199
+ let bytes = 0;
200
+ let writable = true;
201
+ const flush = () => {
202
+ while (writable && held.length) {
203
+ const { frame, done } = held.shift();
204
+ bytes -= frame.byteLength;
205
+ if (done)
206
+ response.end(frame);
207
+ else
208
+ writable = response.write(frame);
209
+ }
210
+ };
211
+ response.on("drain", () => {
212
+ writable = true;
213
+ flush();
214
+ });
215
+ return (frame, done) => {
216
+ held.push({ frame, done });
217
+ bytes += frame.byteLength;
218
+ while (bytes > SUBSCRIPTION_BUFFER && held.length > 1) {
219
+ if (overflow !== "dropOldest") {
220
+ response.destroy(new Error("Native subscription consumer is too slow"));
221
+ fail();
222
+ return;
223
+ }
224
+ bytes -= held.shift().frame.byteLength;
225
+ }
226
+ flush();
227
+ };
124
228
  }
125
- /** A failed result, which ends a subscription. */
126
229
  function failure(name, message) {
127
- return { error: { name, message }, done: true };
230
+ return { payload: encoder.encode(JSON.stringify({ name, message })), failed: true, done: true };
128
231
  }
129
232
  /** The device, through its latest dev WebSocket, and the plugin calls waiting for its results. */
130
233
  class Device {
@@ -151,15 +254,24 @@ class Device {
151
254
  const pending = this.#pending.get(id);
152
255
  if (!pending)
153
256
  return;
154
- if (result.done || pending.type === "call")
257
+ if (result.done || pending.type !== "subscribe")
155
258
  this.#pending.delete(id);
156
259
  pending.receive(result);
157
260
  }
261
+ /** Sends the device the next chunk of the body of the call `id`, which it pulled. */
262
+ pull(id) {
263
+ const pending = this.#pending.get(id);
264
+ void pending?.pull?.().then(({ payload, failed, done }) => {
265
+ if (this.#pending.get(id) === pending)
266
+ pending.socket.send(encodeMessage({ type: "chunk", id, failed, done }, payload));
267
+ });
268
+ }
158
269
  /**
159
270
  * Sends a call or subscription once the device is connected, waiting up to 10 seconds for it,
160
- * and passes each of its results to `receive`. Returns the function that unsubscribes.
271
+ * and passes each of its results to `receive`; `pull` reads the next chunk of a call's body.
272
+ * Returns the function that cancels the request.
161
273
  */
162
- send(type, request, receive) {
274
+ send(type, request, receive, pull) {
163
275
  const id = this.#nextId++;
164
276
  let cancelled = false;
165
277
  void this.#connection().then((socket) => {
@@ -169,8 +281,8 @@ class Device {
169
281
  receive(failure("NetworkError", "The device is not connected"));
170
282
  return;
171
283
  }
172
- this.#pending.set(id, { type, socket, receive });
173
- socket.send(JSON.stringify({ type, id, ...request }));
284
+ this.#pending.set(id, { type, socket, receive, pull });
285
+ socket.send(encodeMessage({ ...request.header, type, id }, request.payload));
174
286
  });
175
287
  return () => {
176
288
  cancelled = true;
@@ -178,7 +290,7 @@ class Device {
178
290
  if (!pending)
179
291
  return;
180
292
  this.#pending.delete(id);
181
- pending.socket.send(JSON.stringify({ type: "unsubscribe", id }));
293
+ pending.socket.send(encodeMessage({ type: "cancel", id }));
182
294
  };
183
295
  }
184
296
  /** The open dev WebSocket, once the device connects, or undefined after 10 seconds without. */
@@ -198,44 +310,3 @@ class Device {
198
310
  });
199
311
  }
200
312
  }
201
- /** The development Worker's `TokamakEvents`, called through a Miniflare session started on first use. */
202
- class DevWorker {
203
- name;
204
- #miniflare;
205
- constructor(name) {
206
- this.name = name;
207
- }
208
- async dispatch(name, event) {
209
- this.#miniflare ??= this.#start().catch((error) => {
210
- this.#miniflare = undefined;
211
- throw error;
212
- });
213
- const miniflare = await this.#miniflare;
214
- const response = await miniflare.dispatchFetch("http://tokamak/", {
215
- method: "POST",
216
- body: JSON.stringify({ name, event }),
217
- });
218
- if (!response.ok)
219
- throw new Error(await response.text());
220
- return (await response.json()).reply;
221
- }
222
- async dispose() {
223
- await (await this.#miniflare?.catch(() => undefined))?.dispose();
224
- }
225
- /** A Miniflare session from the Miniflare that Cloudflare's plugin uses. */
226
- async #start() {
227
- if (!this.name)
228
- throw new Error("the Wrangler configuration names no Worker to deliver events to");
229
- const require = createRequire(import.meta.resolve("@cloudflare/vite-plugin"));
230
- const module = (await import(pathToFileURL(require.resolve("miniflare")).href));
231
- const miniflare = new module.Miniflare(module.convertV4MiniflareOptions({
232
- modules: true,
233
- script: CALLER,
234
- compatibilityDate: "2025-01-01",
235
- serviceBindings: { TOKAMAK: { name: this.name, entrypoint: "TokamakEvents" } },
236
- unsafeDevRegistryPath: module.getDefaultDevRegistryPath(),
237
- }));
238
- await miniflare.ready;
239
- return miniflare;
240
- }
241
- }
@@ -0,0 +1,21 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ import type { WebSocket } from "ws";
3
+ export interface WorkerSocket {
4
+ accept(): void;
5
+ close(code: number, reason: string): void;
6
+ }
7
+ interface WorkerResponse extends Response {
8
+ webSocket?: WorkerSocket | null;
9
+ }
10
+ /** The native Vite worker, reached through Miniflare's development registry. */
11
+ export declare class DevWorker {
12
+ #private;
13
+ private readonly name;
14
+ constructor(name: string | undefined);
15
+ /** Delivers the event `name` with its encoded data, resolving to the encoded reply. */
16
+ dispatch(name: string, event: Uint8Array): Promise<Uint8Array>;
17
+ fetch(request: IncomingMessage, signal: AbortSignal): Promise<WorkerResponse>;
18
+ couple(socket: WebSocket, pair: WorkerSocket): Promise<void>;
19
+ dispose(): Promise<void>;
20
+ }
21
+ export {};
@@ -0,0 +1,88 @@
1
+ import { createRequire } from "node:module";
2
+ import { Readable } from "node:stream";
3
+ import { pathToFileURL } from "node:url";
4
+ const CALLER = `export default {
5
+ async fetch(request, env) {
6
+ if (new URL(request.url).pathname.startsWith("/_tokamak/")) return env.TOKAMAK.fetch(request);
7
+ const name = request.headers.get("x-tokamak-event");
8
+ try {
9
+ const { reply } = await env.EVENTS.dispatch(name, new Uint8Array(await request.arrayBuffer()));
10
+ return new Response(reply);
11
+ } catch (error) {
12
+ return new Response(error?.stack ?? String(error), { status: 500 });
13
+ }
14
+ },
15
+ };`;
16
+ /** The native Vite worker, reached through Miniflare's development registry. */
17
+ export class DevWorker {
18
+ name;
19
+ #session;
20
+ constructor(name) {
21
+ this.name = name;
22
+ }
23
+ /** Delivers the event `name` with its encoded data, resolving to the encoded reply. */
24
+ async dispatch(name, event) {
25
+ const { runtime } = await this.#connect();
26
+ const response = await runtime.dispatchFetch("http://tokamak/", {
27
+ method: "POST", headers: { "x-tokamak-event": name }, body: event,
28
+ });
29
+ if (!response.ok)
30
+ throw new Error(await response.text());
31
+ return new Uint8Array(await response.arrayBuffer());
32
+ }
33
+ async fetch(request, signal) {
34
+ const { runtime } = await this.#connect();
35
+ const headers = new Headers();
36
+ for (const [name, value] of Object.entries(request.headers)) {
37
+ if (value !== undefined)
38
+ headers.set(name, [value].flat().join(", "));
39
+ }
40
+ const host = headers.get("x-forwarded-host") ?? headers.get("host");
41
+ const protocol = headers.get("x-forwarded-proto") ?? "http";
42
+ const url = new URL(request.url ?? "/", `${protocol}://${host}`);
43
+ for (const name of ["x-tokamak-session", "x-tokamak-dev-socket", "x-forwarded-host", "x-forwarded-proto"])
44
+ headers.delete(name);
45
+ return runtime.dispatchFetch(url.href, {
46
+ method: request.method, headers, signal,
47
+ body: request.method === "GET" || request.method === "HEAD" ? undefined : Readable.toWeb(request),
48
+ duplex: "half",
49
+ });
50
+ }
51
+ async couple(socket, pair) {
52
+ const { module } = await this.#connect();
53
+ await module.coupleWebSocket(socket, pair);
54
+ }
55
+ async dispose() {
56
+ await (await this.#session?.catch(() => undefined))?.runtime.dispose();
57
+ }
58
+ #connect() {
59
+ return this.#session ??= this.#start().catch((error) => {
60
+ this.#session = undefined;
61
+ throw error;
62
+ });
63
+ }
64
+ async #start() {
65
+ if (!this.name)
66
+ throw new Error("the Wrangler configuration names no native Worker");
67
+ const require = createRequire(import.meta.resolve("@cloudflare/vite-plugin"));
68
+ const module = (await import(pathToFileURL(require.resolve("miniflare")).href));
69
+ const runtime = new module.Miniflare(module.convertV4MiniflareOptions({
70
+ modules: true,
71
+ script: CALLER,
72
+ compatibilityDate: "2025-01-01",
73
+ serviceBindings: {
74
+ TOKAMAK: { name: this.name },
75
+ EVENTS: { name: this.name, entrypoint: "TokamakEvents" },
76
+ },
77
+ unsafeDevRegistryPath: module.getDefaultDevRegistryPath(),
78
+ }));
79
+ try {
80
+ await runtime.ready;
81
+ }
82
+ catch (error) {
83
+ await runtime.dispose();
84
+ throw error;
85
+ }
86
+ return { runtime, module };
87
+ }
88
+ }
package/dist/events.d.mts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type LifecycleStage, type TokamakContext } from "@tokamakdev/plugin";
1
2
  /** Runs once per runtime start, including when the system starts the app in the background. */
2
3
  export declare const onStart: (listener: import("@tokamakdev/plugin/events").Listener<{
3
4
  readonly foreground: boolean;
@@ -6,11 +7,10 @@ export declare const onStart: (listener: import("@tokamakdev/plugin/events").Lis
6
7
  export declare const onResume: (listener: import("@tokamakdev/plugin/events").Listener<Readonly<Record<string, never>>, void>) => void;
7
8
  /** Runs each time the app moves out of the foreground. */
8
9
  export declare const onSuspend: (listener: import("@tokamakdev/plugin/events").Listener<Readonly<Record<string, never>>, void>) => void;
9
- /** Whether the app is on screen. */
10
- export type LifecycleStage = "foreground" | "background";
10
+ export type { LifecycleStage } from "@tokamakdev/plugin";
11
11
  /**
12
12
  * The app's stage now, which can differ from the last event delivered: an `onSuspend` listener
13
13
  * that runs late reads `"foreground"` once the user has returned. Rejects with
14
14
  * `NotSupportedError` outside a tokamak Worker.
15
15
  */
16
- export declare function getLifecycleStage(): Promise<LifecycleStage>;
16
+ export declare function getLifecycleStage(ctx: TokamakContext): Promise<LifecycleStage>;
package/dist/events.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { Plugin } from "@tokamakdev/plugin";
1
+ import { lifecycleStage } from "@tokamakdev/plugin";
2
2
  import { defineEvent } from "@tokamakdev/plugin/events";
3
3
  /** Runs once per runtime start, including when the system starts the app in the background. */
4
4
  export const onStart = defineEvent("start");
@@ -6,21 +6,11 @@ export const onStart = defineEvent("start");
6
6
  export const onResume = defineEvent("resume");
7
7
  /** Runs each time the app moves out of the foreground. */
8
8
  export const onSuspend = defineEvent("suspend");
9
- /** The functions the runtime answers itself, as the reserved plugin `tokamak`. */
10
- class Tokamak extends Plugin {
11
- constructor() {
12
- super("tokamak");
13
- }
14
- lifecycleStage() {
15
- return this.call("lifecycleStage");
16
- }
17
- }
18
- const tokamak = new Tokamak();
19
9
  /**
20
10
  * The app's stage now, which can differ from the last event delivered: an `onSuspend` listener
21
11
  * that runs late reads `"foreground"` once the user has returned. Rejects with
22
12
  * `NotSupportedError` outside a tokamak Worker.
23
13
  */
24
- export function getLifecycleStage() {
25
- return tokamak.lifecycleStage();
14
+ export function getLifecycleStage(ctx) {
15
+ return lifecycleStage(ctx);
26
16
  }
package/dist/index.d.mts CHANGED
@@ -1,3 +1,5 @@
1
+ export { TokamakEntrypoint } from "@tokamakdev/plugin/worker";
2
+ export type { TokamakContext, TokamakEvent, WebView } from "@tokamakdev/plugin/worker";
1
3
  /** A setting value. */
2
4
  export type Value = string | number | boolean;
3
5
  /** One platform's settings: the shared keys and the settings its platform pack declares. */
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- export {};
1
+ export { TokamakEntrypoint } from "@tokamakdev/plugin/worker";
@@ -0,0 +1,12 @@
1
+ import { type Plugin } from "vite";
2
+ import type { Config } from "./index.mjs";
3
+ import { type WorkerGraph } from "./worker-graph.mjs";
4
+ export type TokamakOptions = Config & {
5
+ /** Native worker entrypoint, relative to the Vite root; defaults to src/tokamak.ts or .js. */
6
+ module?: string;
7
+ };
8
+ /** The native worker and its development relay. */
9
+ export declare function createNativeWorker(options: TokamakOptions, output: string, prepare?: (root: string, graph: WorkerGraph) => void | Promise<void>): {
10
+ plugin: Plugin;
11
+ graph(): WorkerGraph;
12
+ };
@@ -0,0 +1,101 @@
1
+ import { X509Certificate } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { normalizePath } from "vite";
5
+ import { capabilitiesFor } from "@tokamakdev/plugin/capabilities";
6
+ import { openDevSocket } from "./dev-socket.mjs";
7
+ import { findModule } from "./worker-contract.mjs";
8
+ import { createWorkerGraph } from "./worker-graph.mjs";
9
+ const CAPABILITIES = "/_tokamak/capabilities.js";
10
+ const MIDDLEWARE_MODE_ERROR = "Vite is running in middleware mode, so tokamak cannot find the development server's address; tok dev needs Vite's own dev server";
11
+ /** The native worker and its development relay. */
12
+ export function createNativeWorker(options, output, prepare) {
13
+ const { module, ...config } = options;
14
+ const token = process.env.TOKAMAK_SESSION_TOKEN;
15
+ const platform = process.env.TOKAMAK_PLATFORM;
16
+ let graph;
17
+ const plugin = {
18
+ name: "tokamak",
19
+ enforce: "pre",
20
+ async config(vite, environment) {
21
+ const root = normalizePath(path.resolve(vite.root ?? process.cwd()));
22
+ graph = createWorkerGraph(root, findModule(root, module), path.join(output, "worker"));
23
+ graph.plugin.config.call(this, vite, environment);
24
+ await prepare?.(root, graph);
25
+ writeJson(output, "config.json", { root, config });
26
+ },
27
+ configEnvironment: {
28
+ order: "post",
29
+ handler(name, config) {
30
+ return graph.plugin.configEnvironment.handler(name, config);
31
+ },
32
+ },
33
+ buildStart(options) {
34
+ return graph.plugin.buildStart.call(this, options);
35
+ },
36
+ hotUpdate(options) {
37
+ return graph.plugin.hotUpdate.call(this, options);
38
+ },
39
+ resolveId(...args) {
40
+ return graph.plugin.resolveId.call(this, ...args);
41
+ },
42
+ load(...args) {
43
+ return graph.plugin.load.call(this, ...args);
44
+ },
45
+ configureServer: {
46
+ order: "pre",
47
+ async handler(server) {
48
+ const devSocket = await serveNativeWorker(server, graph.names(), output, token);
49
+ if (devSocket && token)
50
+ graph.relay(devSocket.port, token, platform);
51
+ // A page loaded from this server, not the device, still gets the device's capabilities.
52
+ server.middlewares.use(CAPABILITIES, (_request, response) => {
53
+ const capabilities = capabilitiesFor(graph.availability(), platform);
54
+ response.setHeader("content-type", "text/javascript; charset=utf-8");
55
+ response.end(`export default ${JSON.stringify(capabilities)};\n`);
56
+ });
57
+ },
58
+ },
59
+ };
60
+ return { plugin, graph: () => graph };
61
+ }
62
+ async function serveNativeWorker(server, names, output, token) {
63
+ const httpServer = server.httpServer;
64
+ if (!httpServer) {
65
+ if (!fs.existsSync(path.join(output, "server.json"))) {
66
+ writeJson(output, "server.json", { error: MIDDLEWARE_MODE_ERROR });
67
+ }
68
+ return;
69
+ }
70
+ const socket = await openDevSocket(names.native, token);
71
+ httpServer.once("close", () => void socket.close());
72
+ httpServer.once("listening", () => {
73
+ const urls = server.resolvedUrls;
74
+ writeJson(output, "server.json", {
75
+ url: urls?.local[0] ?? urls?.network[0],
76
+ workerName: names.app,
77
+ certificates: endEntityCertificates(server.config.server.https),
78
+ socketPort: socket.port,
79
+ });
80
+ });
81
+ return socket;
82
+ }
83
+ function endEntityCertificates(https) {
84
+ const cert = typeof https?.cert === "string" ? readFileIfExists(https.cert) : https?.cert;
85
+ return cert && [cert].flat().map((chain) => new X509Certificate(chain).toString()).join("");
86
+ }
87
+ function readFileIfExists(file) {
88
+ try {
89
+ return fs.readFileSync(path.resolve(file));
90
+ }
91
+ catch {
92
+ return file;
93
+ }
94
+ }
95
+ /** Write reports atomically while tok watches for them. */
96
+ function writeJson(output, name, value) {
97
+ const file = path.join(output, name);
98
+ fs.mkdirSync(output, { recursive: true });
99
+ fs.writeFileSync(`${file}.tmp`, `${JSON.stringify(value, null, 2)}\n`);
100
+ fs.renameSync(`${file}.tmp`, file);
101
+ }
package/dist/native.d.mts CHANGED
@@ -1,6 +1,19 @@
1
+ import type { NativePlatform } from "@tokamakdev/plugin/capabilities";
1
2
  /**
2
- * Defines `__tokamakNativeCall` and `__tokamakNativeListen`, which post to the plugin's loopback
3
- * `port` with the session `token`. `waitUntil` keeps each listener's invocation running, as
4
- * `waitUntil` from `cloudflare:workers` does.
3
+ * The development invocation `ctx`'s native connection, which reaches the byte endpoints of a
4
+ * device on `platform` through the relay on `port`. Its subscriptions are `ctx`'s `waitUntil` work.
5
5
  */
6
- export declare function defineNativeCalls(port: number, token: string, waitUntil: (promise: Promise<unknown>) => void): void;
6
+ export declare function connectDevice(port: number, token: string, ctx: {
7
+ waitUntil(promise: Promise<unknown>): void;
8
+ }, platform?: NativePlatform): {
9
+ platform: NativePlatform | undefined;
10
+ call(plugin: string, method: string, payload: Uint8Array, { body, signal }?: {
11
+ body?: ReadableStream<Uint8Array>;
12
+ signal?: AbortSignal;
13
+ }): Promise<Uint8Array<ArrayBufferLike>>;
14
+ listen(plugin: string, method: string, payload: Uint8Array, { next, error, overflow }: {
15
+ next(value: Uint8Array, done: boolean): void;
16
+ error(error: DOMException, done: boolean): void;
17
+ overflow?: string;
18
+ }): () => void;
19
+ };