@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.
package/README.md CHANGED
@@ -11,29 +11,119 @@ npx tok targets
11
11
 
12
12
  | Entry | Contents |
13
13
  | --- | --- |
14
- | `@tokamakdev/tok` | The `Config` type of the Vite plugin's options |
14
+ | `@tokamakdev/tok` | `TokamakEntrypoint`, `TokamakContext`, `TokamakEvent`, and the Vite plugin's `Config` type |
15
15
  | `@tokamakdev/tok/events` | The app's events, `onStart`, `onResume` and `onSuspend`, and `getLifecycleStage()` |
16
16
  | `@tokamakdev/tok/vite` | The Vite plugin, `tokamak()` |
17
+ | `@tokamakdev/tok/astro` | The Astro adapter, `tokamak()` |
17
18
  | `tok` | The CLI |
18
19
 
19
- The Vite plugin goes next to Cloudflare's, and its options are the app's
20
- tokamak configuration:
20
+ Create a plugin with `tok plugin my-plugin`. It starts as a TypeScript package
21
+ composing native context APIs. Add `--native apple` or `--native android` for a
22
+ small Swift or Kotlin implementation. Install it in an app as a workspace or
23
+ local npm dependency; Tokamak generates its clients and service bindings.
24
+ The generated README covers checking, local development, packing and publishing.
25
+
26
+ The Vite integration includes Cloudflare's plugin. Its options are the app's
27
+ tokamak configuration; the second argument accepts Cloudflare plugin options:
21
28
 
22
29
  ```ts
23
- import { cloudflare } from "@cloudflare/vite-plugin";
24
30
  import { tokamak } from "@tokamakdev/tok/vite";
25
31
  import { defineConfig } from "vite";
26
32
 
27
33
  export default defineConfig({
28
- plugins: [cloudflare(), tokamak({ name: "My App", version: "1.0.0" })],
34
+ plugins: [tokamak({ name: "My App", version: "1.0.0" })],
29
35
  });
30
36
  ```
31
37
 
32
- It does nothing unless `tok` runs the build or development command. Then it
33
- makes the entry Worker import `src/tokamak.ts` and export the entrypoint the
34
- runtime delivers events through. In development it delivers the device's
35
- events to the development Worker through Cloudflare's dev registry, and passes
36
- the development Worker's plugin calls to the device.
38
+ In Astro, use `tokamak()` from `@tokamakdev/tok/astro` as the `adapter`, passing
39
+ Cloudflare adapter options as the second argument.
40
+
41
+ Web builds use the ordinary Cloudflare integration. When `tok` runs the
42
+ command, `src/tokamak.ts` becomes a separate native worker. Its default export
43
+ extends `TokamakEntrypoint`; public methods are checked against Tokamak's
44
+ RPC contract. The main worker calls it through `env.TOKAMAK`; the native worker
45
+ calls the main worker through `env.MAIN`. Development delivers native events
46
+ through Cloudflare's dev registry and gives only the native worker the device
47
+ relay. Vite reloads its modules and listeners when their source changes.
48
+ Run `wrangler types` to generate the project's Workers declarations before
49
+ building or starting development.
50
+
51
+ Tokamak generates `tokamak-env.d.ts` in the project root, refreshing it during
52
+ development. Include it in the project's TypeScript inputs alongside
53
+ `worker-configuration.d.ts`, and ignore the generated file in version control.
54
+ It adds the optional
55
+ `Cloudflare.Env.TOKAMAK` binding and exports `TokamakService` and `TokamakClient`
56
+ types from `virtual:tokamak/client`. Service methods return ordinary promises,
57
+ preserve the entrypoint's overloads, and expose only its public RPC operations.
58
+ `Tokamak.Env` describes the native worker's required `MAIN` service plus its
59
+ shared variables, KV, D1, and R2 bindings. `this.env` in a `TokamakEntrypoint`
60
+ and the `env` argument of native listeners use this type.
61
+
62
+ Workspace implementation and API-reference edits reload through Vite.
63
+ Installing, removing, or renaming plugins requires restarting `tok dev` so the
64
+ native shell and worker bindings use the same installed capabilities.
65
+
66
+ Plugin packages declare their native API factory in `tokamak-plugin.json`:
67
+
68
+ ```json
69
+ {
70
+ "schemaVersion": 2,
71
+ "id": "widgets",
72
+ "api": { "module": "./src/index.ts", "export": "widgets" }
73
+ }
74
+ ```
75
+
76
+ The factory accepts a `TokamakContext` from `@tokamakdev/plugin` and returns
77
+ an object with typed public methods. Its module path is relative to the package.
78
+ Published JavaScript can supply adjacent TypeScript declarations. Existing
79
+ native platform sections and build settings remain in the same manifest.
80
+
81
+ Installing the package generates `env.TOKAMAK_WIDGETS` and its named native
82
+ entrypoint. Page code imports the generated default client from
83
+ `<package-name>/client`; native code calls `widgets(ctx)` directly. Both callers
84
+ use the same declared methods and RPC value contract. Workspace dependencies
85
+ work without publishing, and API source edits reload through Vite.
86
+
87
+ A plugin can add `"web": { "module": "./web.ts", "export": "web" }` to the
88
+ manifest. That export is an object implementing any subset of its generated
89
+ asynchronous client methods. The generator checks the signatures. Web clients
90
+ use those implementations and reject omitted methods with `NotSupportedError`;
91
+ native clients always call the native worker.
92
+
93
+ Page code imports the generated client as the module's default export:
94
+
95
+ ```ts
96
+ // src/tokamak.ts
97
+ import { TokamakEntrypoint } from "@tokamakdev/tok";
98
+
99
+ export default class Native extends TokamakEntrypoint {
100
+ greeting(name: string): string {
101
+ return `Hello, ${name}`;
102
+ }
103
+ }
104
+ ```
105
+
106
+ ```ts
107
+ // Page code
108
+ import native from "virtual:tokamak/client";
109
+
110
+ const message = await native.greeting("Ada");
111
+ ```
112
+
113
+ Native clients call the Tokamak worker through the reserved `/_tokamak/app`
114
+ route. The native worker also receives other requests under `/_tokamak/` in
115
+ its `fetch` handler. Arguments and results support the RPC value and readable
116
+ byte-stream contract. Stream cancellation reaches its producer, and leaving
117
+ the page closes its calls. Web builds expose the same app-method names, rejecting calls with
118
+ `NotSupportedError`. The client bundle contains the callable interface and
119
+ transport.
120
+
121
+ The entrypoint receives its own bindings in `this.env` and native invocation
122
+ context in `this.ctx`. Its default `event()` runs registered listeners; an
123
+ override receives `{ type, data }` and can return a reply or promise. Native
124
+ events use this handler independently of page requests. `event()` is excluded
125
+ from the service binding's public methods. Listener callbacks receive the same
126
+ invocation context and register follow-up work with `ctx.waitUntil()`.
37
127
 
38
128
  The package installs the `tok` binary for the current machine through an
39
129
  optional dependency (`@tokamakdev/tok-darwin-arm64`, `-darwin-x64`,
@@ -0,0 +1,5 @@
1
+ import { type Options as CloudflareOptions } from "@astrojs/cloudflare";
2
+ import type { AstroIntegration } from "astro";
3
+ import { type TokamakOptions } from "./native-worker.mjs";
4
+ /** Astro's Cloudflare adapter, with Tokamak's native worker and development relay. */
5
+ export declare function tokamak(options?: TokamakOptions, cloudflareOptions?: CloudflareOptions): AstroIntegration;
package/dist/astro.mjs ADDED
@@ -0,0 +1,160 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+ import cloudflare, {} from "@astrojs/cloudflare";
6
+ import { sessionDrivers } from "astro/config";
7
+ import { normalizePath } from "vite";
8
+ import { experimental_readRawConfig, unstable_readConfig } from "wrangler";
9
+ import { createNativeWorker } from "./native-worker.mjs";
10
+ import { createClientPlugin } from "./client.mjs";
11
+ /** Astro's Cloudflare adapter, with Tokamak's native worker and development relay. */
12
+ export function tokamak(options = {}, cloudflareOptions = {}) {
13
+ const output = process.env.TOKAMAK_VITE_OUTPUT;
14
+ const client = createClientPlugin(options.module, !!output, output ? () => native.graph().types : undefined);
15
+ if (!output) {
16
+ const adapter = cloudflare(cloudflareOptions);
17
+ let sessionBinding;
18
+ return {
19
+ ...adapter,
20
+ hooks: {
21
+ ...adapter.hooks,
22
+ async "astro:config:setup"(context) {
23
+ const types = {
24
+ name: "tokamak:astro-types",
25
+ config(vite) {
26
+ const root = path.resolve(vite.root ?? process.cwd());
27
+ const source = configuration(root, cloudflareOptions.configPath, false);
28
+ const raw = source ? experimental_readRawConfig({ config: source }, { useRedirectIfAvailable: false }).rawConfig : {};
29
+ const environment = process.env.CLOUDFLARE_ENV;
30
+ const selected = source ? unstable_readConfig({ config: source, env: environment }, { hideWarnings: true }) : {};
31
+ const main = (environment ? raw.env?.[environment]?.main : undefined)
32
+ ?? raw.main ?? "@astrojs/cloudflare/entrypoints/server";
33
+ client.configure({
34
+ ...selected,
35
+ // Type inspection needs the server source, not a client-optimized module.
36
+ main: main === "@astrojs/cloudflare/entrypoints/server"
37
+ ? fileURLToPath(import.meta.resolve(main)) : main,
38
+ kv_namespaces: [...selected.kv_namespaces ?? [], ...sessionBinding ? [{ binding: sessionBinding }] : []],
39
+ });
40
+ },
41
+ };
42
+ context.updateConfig({ vite: { plugins: [client, types] } });
43
+ await adapter.hooks["astro:config:setup"]?.(context);
44
+ },
45
+ async "astro:config:done"(context) {
46
+ await adapter.hooks["astro:config:done"]?.(context);
47
+ const driver = context.config.session?.driver;
48
+ const entry = typeof driver === "string" ? driver : driver?.entrypoint.toString();
49
+ if (entry === "cloudflareKVBinding" || entry?.endsWith("cloudflare-kv-binding") ||
50
+ entry === sessionDrivers.cloudflareKVBinding().entrypoint) {
51
+ sessionBinding = cloudflareOptions.sessionKVBindingName ?? "SESSION";
52
+ }
53
+ },
54
+ },
55
+ };
56
+ }
57
+ if (cloudflareOptions.auxiliaryWorkers?.length) {
58
+ throw new Error("Tokamak hosts the main and native workers; additional auxiliary workers are not supported.");
59
+ }
60
+ const generated = path.join(path.dirname(cloudflareOptions.configPath ?? "."), `.tokamak.${randomUUID()}.json`);
61
+ let generatedPath;
62
+ const cleanup = () => {
63
+ if (generatedPath)
64
+ fs.rmSync(generatedPath, { force: true });
65
+ process.off("exit", cleanup);
66
+ };
67
+ const servers = new Set();
68
+ const lifecycle = {
69
+ name: "tokamak:astro-config",
70
+ configureServer({ httpServer }) {
71
+ if (!httpServer)
72
+ return;
73
+ servers.add(httpServer);
74
+ httpServer.once("close", () => {
75
+ servers.delete(httpServer);
76
+ if (!servers.size)
77
+ cleanup();
78
+ });
79
+ },
80
+ buildEnd(error) {
81
+ if (error && this.environment.mode === "build")
82
+ cleanup();
83
+ },
84
+ };
85
+ const native = createNativeWorker(options, output, (root, graph) => {
86
+ const source = configuration(root, cloudflareOptions.configPath);
87
+ generatedPath = path.resolve(root, generated);
88
+ const rawConfig = experimental_readRawConfig({ config: source }, { useRedirectIfAvailable: false }).rawConfig;
89
+ const environment = process.env.CLOUDFLARE_ENV;
90
+ const selected = unstable_readConfig({ config: source, env: environment }, { hideWarnings: true });
91
+ selected.main = (environment ? rawConfig.env?.[environment]?.main : undefined)
92
+ ?? rawConfig.main ?? "@astrojs/cloudflare/entrypoints/server";
93
+ const main = selected.main;
94
+ graph.configure(selected);
95
+ const environments = [[undefined, rawConfig], ...Object.entries(rawConfig.env ?? {})];
96
+ for (const [env, config] of environments) {
97
+ const resolved = unstable_readConfig({ config: source, env }, { hideWarnings: true });
98
+ if (!resolved.name)
99
+ throw new Error("Tokamak requires a name in the main Worker's configuration.");
100
+ const services = graph.services(resolved.name);
101
+ const reserved = config.services?.find(binding => services.some(service => service.binding === binding.binding));
102
+ if (reserved) {
103
+ throw new Error(`${reserved.binding} is reserved for a generated native worker binding.`);
104
+ }
105
+ config.services = [...config.services ?? [], ...services];
106
+ if (selected.main !== main)
107
+ config.main = selected.main;
108
+ }
109
+ // Keeping the generated configuration beside its source preserves relative paths and .dev.vars.
110
+ const content = `${JSON.stringify(rawConfig, null, 2)}\n`;
111
+ if (!fs.existsSync(generatedPath) || fs.readFileSync(generatedPath, "utf8") !== content) {
112
+ fs.writeFileSync(generatedPath, content);
113
+ }
114
+ process.off("exit", cleanup);
115
+ process.once("exit", cleanup);
116
+ });
117
+ const adapter = cloudflare({
118
+ ...cloudflareOptions,
119
+ configPath: generated,
120
+ auxiliaryWorkers: [{
121
+ viteEnvironment: { name: "tokamak" },
122
+ config: (worker, context) => native.graph().native(worker, context),
123
+ }],
124
+ });
125
+ return {
126
+ ...adapter,
127
+ name: "@tokamakdev/tok/astro",
128
+ hooks: {
129
+ ...adapter.hooks,
130
+ async "astro:config:setup"(context) {
131
+ const source = configuration(fileURLToPath(context.config.root), cloudflareOptions.configPath);
132
+ context.addWatchFile(pathToFileURL(source));
133
+ context.updateConfig({
134
+ vite: {
135
+ server: { fs: { deny: [normalizePath(source)] } },
136
+ plugins: [native.plugin, client, lifecycle],
137
+ },
138
+ });
139
+ await adapter.hooks["astro:config:setup"]?.(context);
140
+ },
141
+ async "astro:build:done"(context) {
142
+ try {
143
+ await adapter.hooks["astro:build:done"]?.(context);
144
+ }
145
+ finally {
146
+ cleanup();
147
+ }
148
+ },
149
+ },
150
+ };
151
+ }
152
+ function configuration(root, configured, required = true) {
153
+ if (configured)
154
+ return path.resolve(root, configured);
155
+ const file = ["wrangler.jsonc", "wrangler.json", "wrangler.toml"]
156
+ .map(name => path.join(root, name)).find(file => fs.existsSync(file));
157
+ if (!file && required)
158
+ throw new Error("Tokamak requires a Wrangler configuration in the Astro project root.");
159
+ return file;
160
+ }
@@ -0,0 +1,6 @@
1
+ import { type Plugin } from "vite";
2
+ import { WorkerTypes, type BindingConfiguration } from "./worker-types.mjs";
3
+ /** The app's public methods, without its native implementation. */
4
+ export declare function createClientPlugin(module: string | undefined, native: boolean, shared?: () => WorkerTypes): Plugin & {
5
+ configure(config: BindingConfiguration): void;
6
+ };
@@ -0,0 +1,100 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { normalizePath } from "vite";
4
+ import { findModule } from "./worker-contract.mjs";
5
+ import { WorkerTypes } from "./worker-types.mjs";
6
+ const MODULE = "virtual:tokamak/client";
7
+ /** The Tokamak worker's page module of each plugin operation's support on the app's platform. */
8
+ const CAPABILITIES = "/_tokamak/capabilities.js";
9
+ const TRANSPORT = normalizePath(fileURLToPath(new URL("./rpc/client.mjs", import.meta.resolve("@tokamakdev/plugin/events"))));
10
+ /** The app's public methods, without its native implementation. */
11
+ export function createClientPlugin(module, native, shared) {
12
+ let root;
13
+ /** Whether `id` is a generated client: the app's, a named entrypoint's or a plugin's. */
14
+ const isClient = (id) => id === MODULE || id.startsWith(`${MODULE}/`) ||
15
+ contracts.definitions.some(plugin => plugin.client === id);
16
+ let contracts;
17
+ let files = [];
18
+ const clients = new Set();
19
+ return {
20
+ name: "tokamak:client",
21
+ enforce: "pre",
22
+ config(config) {
23
+ clients.clear();
24
+ files = [];
25
+ root = path.resolve(config.root ?? process.cwd());
26
+ contracts = shared?.() ?? new WorkerTypes(root, findModule(root, module));
27
+ },
28
+ configure(config) { contracts.configure(config); },
29
+ buildStart() {
30
+ for (const file of contracts.metadata)
31
+ this.addWatchFile(file);
32
+ },
33
+ resolveId(id) {
34
+ if (id === CAPABILITIES)
35
+ return { id, external: true };
36
+ if (isClient(id))
37
+ return `\0${id}`;
38
+ },
39
+ async load(id) {
40
+ const name = id.slice(1);
41
+ if (!id.startsWith("\0") || !isClient(name))
42
+ return;
43
+ await contracts.resolve(source => this.resolve(source, undefined, { skipSelf: true }));
44
+ const service = contracts.services().find(service => service.client === name);
45
+ if (!service)
46
+ throw new Error(`${name} names no Tokamak worker entrypoint.`);
47
+ clients.add(id);
48
+ files = [...contracts.native.files, ...contracts.plugins.flatMap(plugin => plugin.files)]
49
+ .filter(file => file !== normalizePath(path.join(root, "tokamak-env.d.ts")));
50
+ for (const file of files)
51
+ this.addWatchFile(file);
52
+ const methods = JSON.stringify(service.contract.methods);
53
+ const route = JSON.stringify(service.route);
54
+ const plugin = service.plugin;
55
+ if (native && plugin)
56
+ return `import { createClient } from ${JSON.stringify(TRANSPORT)};
57
+ import capabilities from ${JSON.stringify(CAPABILITIES)};
58
+ export default createClient(${methods}, ${route}, capabilities[${JSON.stringify(plugin.definition.id)}]);`;
59
+ // The app's own operations run wherever its Tokamak worker does.
60
+ if (native)
61
+ return `import { createClient } from ${JSON.stringify(TRANSPORT)};
62
+ export default createClient(${methods}, ${route}, Object.fromEntries(${methods}.map(method => [method, true])));`;
63
+ const browser = plugin?.definition.web;
64
+ const fallback = browser
65
+ ? `import { ${JSON.stringify(browser.export)} as browser } from ${JSON.stringify(browser.module)};`
66
+ : "const browser = {};";
67
+ const webMethods = JSON.stringify(plugin?.webMethods ?? []);
68
+ return `${fallback}
69
+ async function unavailable() { throw new DOMException("This operation requires a native app", "NotSupportedError"); }
70
+ const supported = new Set(${webMethods});
71
+ const client = Object.fromEntries(${methods}.map(method => [method, supported.has(method) ? (...args) => Promise.resolve().then(() => browser[method](...args)) : unavailable]));
72
+ export default Object.assign(client, { capabilities: Object.freeze(Object.fromEntries(${methods}.map(method => [method, supported.has(method)]))) });`;
73
+ },
74
+ hotUpdate({ file, timestamp, server }) {
75
+ if (contracts.metadata.includes(file)) {
76
+ if (this.environment.name === "client") {
77
+ try {
78
+ contracts.discover();
79
+ }
80
+ catch (error) {
81
+ server.config.logger.error(String(error));
82
+ throw error;
83
+ }
84
+ void server.restart().catch(error => server.config.logger.error(String(error)));
85
+ }
86
+ return [];
87
+ }
88
+ if (!files.includes(file))
89
+ return;
90
+ contracts.refresh({ file, timestamp });
91
+ for (const id of clients) {
92
+ const client = this.environment.moduleGraph.getModuleById(id);
93
+ if (!client)
94
+ continue;
95
+ this.environment.moduleGraph.invalidateModule(client);
96
+ this.environment.hot.send({ type: "full-reload" });
97
+ }
98
+ },
99
+ };
100
+ }
@@ -1,12 +1,9 @@
1
1
  /** The header that carries the device's session token, which `tok dev`'s relay forwards. */
2
2
  export declare const SESSION_HEADER = "x-tokamak-session";
3
- /** A plugin's result, as the page's native transport receives it. */
3
+ /** The native endpoint's opaque result and completion status. */
4
4
  export interface PluginResult {
5
- value?: unknown;
6
- error?: {
7
- name: string;
8
- message: string;
9
- };
5
+ payload: Uint8Array;
6
+ failed: boolean;
10
7
  done: boolean;
11
8
  }
12
9
  /** The loopback server the device's dev WebSocket reaches through `tok dev`'s relay. */