@tokamakdev/tok 0.1.1-beta.107 → 0.1.1-beta.114

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/dist/vite.mjs CHANGED
@@ -1,213 +1,35 @@
1
- import { X509Certificate } from "node:crypto";
2
- import fs from "node:fs";
3
- import path from "node:path";
4
- import { fileURLToPath } from "node:url";
5
- import { normalizePath, } from "vite";
6
- import { openDevSocket } from "./dev-socket.mjs";
7
- /** Modules tried in order when the options name none. */
8
- const DEFAULT_MODULES = ["src/tokamak.ts", "src/tokamak.js"];
9
- /** Wrangler configuration files Cloudflare's plugin tries in order. */
10
- const WRANGLER_FILES = ["wrangler.jsonc", "wrangler.json", "wrangler.toml"];
11
- 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";
12
- /** The module that defines the development Worker's native calls. */
13
- const NATIVE_CALLS = normalizePath(fileURLToPath(new URL("./native.mjs", import.meta.url)));
14
- /**
15
- * Reports the app's tokamak configuration, and the development server's
16
- * address, HTTPS certificate, Worker name and dev socket port, to `tok`, and
17
- * makes the entry Worker import `module` and export `TokamakEvents`. In
18
- * development, the entry Worker also defines native calls that reach the
19
- * device through the dev socket. Without `TOKAMAK_VITE_OUTPUT`, which `tok`
20
- * sets, it adds no hooks.
21
- */
22
- export function tokamak({ module, ...config } = {}) {
1
+ import { cloudflare } from "@cloudflare/vite-plugin";
2
+ import { createNativeWorker } from "./native-worker.mjs";
3
+ import { createClientPlugin } from "./client.mjs";
4
+ /** Cloudflare's Vite integration, with a separate native worker when run by tok. */
5
+ export function tokamak(options = {}, cloudflareOptions = {}) {
23
6
  const output = process.env.TOKAMAK_VITE_OUTPUT;
24
- if (!output) {
25
- return [];
7
+ if (output && cloudflareOptions.auxiliaryWorkers?.length) {
8
+ throw new Error("Tokamak hosts the main and native workers; additional auxiliary workers are not supported.");
26
9
  }
27
- let file;
28
- const events = import.meta.resolve("@tokamakdev/plugin/events");
29
- /** The module that defines `TokamakEvents`. */
30
- const entrypoint = normalizePath(fileURLToPath(new URL("./entrypoint.js", events)));
31
- /** The module that holds event listeners. */
32
- const registryFile = normalizePath(fileURLToPath(new URL("./registry.ts", events)));
33
- /** The module IDs of each entry environment's inputs. */
34
- const entryIds = new Map();
35
- /** The entry environments whose input exports `TokamakEvents`. */
36
- const exporting = new Set();
37
- /** The development server's dev socket, once open. */
38
- let devSocket;
39
- const token = process.env.TOKAMAK_SESSION_TOKEN;
40
- return [
41
- {
42
- name: "tokamak",
43
- // Keeps @tokamakdev/plugin out of pre-bundled dependencies, so they
44
- // share the one module that holds event listeners.
45
- configEnvironment() {
46
- return { optimizeDeps: { exclude: ["@tokamakdev/plugin"] } };
47
- },
48
- configResolved({ root }) {
49
- file = findModule(root, module);
50
- writeJson(output, "config.json", { root, config });
51
- },
52
- // Runs before plugins that load the entry Worker, whose native calls
53
- // need the dev socket's port.
54
- configureServer: {
55
- order: "pre",
56
- async handler(server) {
57
- const httpServer = server.httpServer;
58
- // A middleware-mode server beside a reported one, such as the server
59
- // Astro syncs content with, leaves the report in place.
60
- if (!httpServer) {
61
- if (!fs.existsSync(path.join(output, "server.json"))) {
62
- writeJson(output, "server.json", { error: MIDDLEWARE_MODE_ERROR });
63
- }
64
- return;
65
- }
66
- const worker = await readWorker(server.config.root);
67
- const socket = await openDevSocket(worker?.name, token);
68
- devSocket = socket;
69
- httpServer.once("close", () => void socket.close());
70
- httpServer.once("listening", () => {
71
- const urls = server.resolvedUrls;
72
- writeJson(output, "server.json", {
73
- url: urls?.local[0] ?? urls?.network[0],
74
- workerName: worker?.topLevelName ?? worker?.name,
75
- certificates: endEntityCertificates(server.config.server.https),
76
- socketPort: socket.port,
77
- });
78
- });
79
- },
80
- },
81
- async buildApp(builder) {
82
- const environments = Object.values(builder.environments);
83
- if (!environments.some((environment) => buildsEntryWorker(environment.config))) {
84
- throw new Error("no environment builds the entry Worker to export TokamakEvents from");
85
- }
86
- },
87
- },
88
- {
89
- name: "tokamak:entry",
90
- applyToEnvironment: ({ config }) => buildsEntryWorker(config),
91
- async transform(code, id) {
92
- const environment = this.environment;
93
- const ids = entryIds.get(environment.name) ??
94
- Promise.all(inputs(environment.config.build).map(async (input) => (await this.resolve(input))?.id));
95
- entryIds.set(environment.name, ids);
96
- if (!(await ids).includes(id)) {
97
- return;
98
- }
99
- exporting.add(environment.name);
100
- // In Cloudflare's Worker entry, which dev and build both start from,
101
- // this follows the imports of its polyfills and of the app's entry.
102
- const imports = file ? `import ${JSON.stringify(file)};\n` : "";
103
- const native = environment.mode === "dev" && devSocket && token ? nativeCalls(devSocket.port, token) : "";
104
- return {
105
- code: `${code}\n${imports}export { TokamakEvents } from ${JSON.stringify(entrypoint)};\n${native}`,
106
- map: null,
10
+ const native = output ? createNativeWorker(options, output) : undefined;
11
+ const client = createClientPlugin(options.module, !!output, native ? () => native.graph().types : undefined);
12
+ return [...native ? [native.plugin] : [], client, ...cloudflare({
13
+ ...cloudflareOptions,
14
+ config(worker) {
15
+ const custom = typeof cloudflareOptions.config === "function"
16
+ ? cloudflareOptions.config(worker)
17
+ : cloudflareOptions.config;
18
+ const configured = {
19
+ ...worker, ...custom,
20
+ services: [...custom?.services ?? [], ...worker.services ?? []],
107
21
  };
22
+ client.configure(configured);
23
+ if (!native)
24
+ return custom;
25
+ const graph = native.graph();
26
+ graph.configure(configured);
27
+ // Cloudflare merges returned service entries with those already in the config.
28
+ return { ...custom, main: configured.main, services: [...custom?.services ?? [], ...graph.services(graph.names().main)] };
108
29
  },
109
- // A module that registers listeners adds them again each time it
110
- // evaluates, so when an update re-evaluates one, the registry and every
111
- // module that imports it evaluate again too.
112
- async hotUpdate({ modules, timestamp }) {
113
- const graph = this.environment.moduleGraph;
114
- const registry = graph.getModuleById(registryFile);
115
- if (!registry) {
116
- return;
117
- }
118
- const entries = (await entryIds.get(this.environment.name)) ?? [];
119
- const registering = [...withImporters(registry)].filter((module) => !entries.some((id) => id === module.id));
120
- const updated = new Set(modules.flatMap((changed) => [...withImporters(changed)]));
121
- if (registering.some((module) => updated.has(module))) {
122
- graph.invalidateModule(registry, new Set(), timestamp, true);
123
- }
124
- },
125
- generateBundle() {
126
- if (!exporting.has(this.environment.name)) {
127
- this.error(`the input of the ${this.environment.name} environment did not export TokamakEvents`);
128
- }
129
- },
130
- },
131
- ];
132
- }
133
- /**
134
- * Code that defines the development Worker's native calls on the dev socket's
135
- * `port`, with the session `token`, which only the entry Worker's environment
136
- * sees.
137
- */
138
- function nativeCalls(port, token) {
139
- return `import { waitUntil as __tokamakWaitUntil } from "cloudflare:workers";
140
- import { defineNativeCalls as __tokamakDefineNativeCalls } from ${JSON.stringify(NATIVE_CALLS)};
141
- __tokamakDefineNativeCalls(${String(port)}, ${JSON.stringify(token)}, __tokamakWaitUntil);
142
- `;
143
- }
144
- /** `module` and every module that imports it, directly or through others. */
145
- function withImporters(module) {
146
- const found = new Set();
147
- const pending = [module];
148
- for (let current = pending.pop(); current; current = pending.pop()) {
149
- if (!found.has(current)) {
150
- found.add(current);
151
- pending.push(...current.importers);
152
- }
153
- }
154
- return found;
155
- }
156
- /** The module `name` under `root`, or else the first default under `root` that exists. */
157
- function findModule(root, name) {
158
- const candidates = (name ? [name] : DEFAULT_MODULES).map((candidate) => normalizePath(path.resolve(root, candidate)));
159
- const file = candidates.find((candidate) => fs.existsSync(candidate));
160
- if (name && !file) {
161
- throw new Error(`tokamak module not found: ${candidates[0]}`);
162
- }
163
- return file;
164
- }
165
- /**
166
- * The Worker in the Wrangler configuration file in `root`, read as
167
- * Cloudflare's plugin reads it without `configPath`, in the `CLOUDFLARE_ENV`
168
- * environment: its name, and its name without the environment's suffix.
169
- */
170
- async function readWorker(root) {
171
- const file = WRANGLER_FILES.map((name) => path.join(root, name)).find((candidate) => fs.existsSync(candidate));
172
- if (!file) {
173
- return undefined;
174
- }
175
- const { unstable_readConfig } = await import("wrangler");
176
- return unstable_readConfig({ config: file, env: process.env.CLOUDFLARE_ENV }, { hideWarnings: true });
177
- }
178
- /**
179
- * The PEM of the end-entity certificate of each chain in `https.cert`, which
180
- * Vite reads from a file when a string names one.
181
- */
182
- function endEntityCertificates(https) {
183
- const cert = typeof https?.cert === "string" ? readFileIfExists(https.cert) : https?.cert;
184
- return cert && [cert].flat().map((chain) => new X509Certificate(chain).toString()).join("");
185
- }
186
- function readFileIfExists(file) {
187
- try {
188
- return fs.readFileSync(path.resolve(file));
189
- }
190
- catch {
191
- return file;
192
- }
193
- }
194
- /**
195
- * Whether an environment builds from the entry Worker: Cloudflare's plugin
196
- * gives a manifest to its entry and prerender Workers, and Astro to its server
197
- * and prerender environments.
198
- */
199
- function buildsEntryWorker(config) {
200
- return config.consumer === "server" && Boolean(config.build.manifest);
201
- }
202
- /** The modules an environment's build starts from. */
203
- function inputs(build) {
204
- const { input } = build.rollupOptions;
205
- return typeof input === "string" ? [input] : Object.values(input ?? {});
206
- }
207
- /** Writes `value` to `name` whole, as `tok` reads the file as soon as it exists. */
208
- function writeJson(output, name, value) {
209
- const file = path.join(output, name);
210
- fs.mkdirSync(output, { recursive: true });
211
- fs.writeFileSync(`${file}.tmp`, `${JSON.stringify(value, null, 2)}\n`);
212
- fs.renameSync(`${file}.tmp`, file);
30
+ auxiliaryWorkers: native ? [{
31
+ viteEnvironment: { name: "tokamak" },
32
+ config: (worker, context) => native.graph().native(worker, context),
33
+ }] : cloudflareOptions.auxiliaryWorkers,
34
+ })];
213
35
  }
@@ -0,0 +1,25 @@
1
+ import type { PluginDefinition } from "./plugin-manifest.mjs";
2
+ /** The callable prototype methods and source dependencies of a Worker entrypoint. */
3
+ export interface WorkerContract {
4
+ methods: string[];
5
+ files: string[];
6
+ declarations: string;
7
+ }
8
+ export interface PluginContract extends WorkerContract {
9
+ definition: PluginDefinition;
10
+ webMethods: string[];
11
+ /** Every operation of the native SDK, including those only the Tokamak worker can call. */
12
+ operations: string[];
13
+ }
14
+ export declare function findModule(root: string, name: string | undefined): string | undefined;
15
+ /** Resolve the application's types before generating its service adapters. */
16
+ export declare function readWorkerContract(entry: string, root: string): WorkerContract;
17
+ /** Check every WorkerEntrypoint exported by an application service bundle. */
18
+ export declare function readServiceContracts(entry: string, root: string): Record<string, WorkerContract>;
19
+ /** Analyze both entrypoints in one TypeScript program. */
20
+ export declare function readWorkerContracts(entry: string | undefined, main: string | undefined, root: string, validate: boolean, plugins?: PluginDefinition[]): {
21
+ native: WorkerContract;
22
+ named: Record<string, WorkerContract>;
23
+ main: Record<string, WorkerContract>;
24
+ plugins: PluginContract[];
25
+ };
@@ -0,0 +1,343 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import ts from "typescript";
5
+ import { checkRpcMethod, WorkerOnlyMethod } from "./rpc-contract.mjs";
6
+ import { serviceTypes } from "./service-types.mjs";
7
+ export function findModule(root, name) {
8
+ const candidates = (name ? [name] : ["src/tokamak.ts", "src/tokamak.js"])
9
+ .map(candidate => path.resolve(root, candidate).split(path.sep).join("/"));
10
+ const file = candidates.find(candidate => fs.existsSync(candidate));
11
+ if (name && !file)
12
+ throw new Error(`tokamak module not found: ${candidates[0]}`);
13
+ return file;
14
+ }
15
+ /** Resolve the application's types before generating its service adapters. */
16
+ export function readWorkerContract(entry, root) {
17
+ return readWorkerContracts(entry, undefined, root, true).native;
18
+ }
19
+ /** Check every WorkerEntrypoint exported by an application service bundle. */
20
+ export function readServiceContracts(entry, root) {
21
+ return readWorkerContracts(undefined, entry, root, true).main;
22
+ }
23
+ /** Analyze both entrypoints in one TypeScript program. */
24
+ export function readWorkerContracts(entry, main, root, validate, plugins = []) {
25
+ const entries = [entry, main, ...plugins.flatMap(plugin => [plugin.api.module, plugin.web?.module])]
26
+ .filter((file) => !!file);
27
+ const project = entries.length ? workerProgram(entries, root) : undefined;
28
+ const native = entry && project ? workerModule(entry, project) : undefined;
29
+ return {
30
+ native: native ? classContract(native, "default", true, validate)
31
+ : { methods: [], files: [], declarations: serviceTypes("{}", []) },
32
+ named: native ? namedContracts(native, validate) : {},
33
+ main: main && project ? serviceContracts(workerModule(main, project), validate) : {},
34
+ plugins: project ? plugins.map(plugin => pluginContract(workerModule(plugin.api.module, project), plugin, validate)) : [],
35
+ };
36
+ }
37
+ /** The Tokamak worker's named entrypoints: its other exported concrete entrypoint classes. */
38
+ function namedContracts(module, validate) {
39
+ const contracts = {};
40
+ for (const [name, type] of module.exports) {
41
+ const instance = type.getConstructSignatures()[0]?.getReturnType();
42
+ const abstract = instance?.symbol?.declarations?.some(node => ts.getModifiers(node)?.some(modifier => modifier.kind === ts.SyntaxKind.AbstractKeyword));
43
+ if (name === "default" || !instance || abstract ||
44
+ !applicationPrototypes(module.program, module.checker, instance, module.entry, true))
45
+ continue;
46
+ if (name === "TokamakEvents" || name.startsWith("TOKAMAK_")) {
47
+ throw new Error(`Tokamak entrypoint ${name} has a name the generated worker reserves.`);
48
+ }
49
+ contracts[name] = classContract(module, name, true, validate);
50
+ }
51
+ return contracts;
52
+ }
53
+ function serviceContracts(module, validate) {
54
+ const { entry } = module;
55
+ const contracts = {};
56
+ for (const [name, type] of module.exports) {
57
+ const instance = type.getConstructSignatures()[0]?.getReturnType();
58
+ if (instance && applicationPrototypes(module.program, module.checker, instance, entry)) {
59
+ contracts[name] = classContract(module, name, false, validate);
60
+ }
61
+ else if (name === "default" && (type.flags & ts.TypeFlags.Object) && !type.getCallSignatures().length && !instance) {
62
+ contracts[name] = { methods: [], files: module.files, declarations: serviceTypes("{}", [], "Main") };
63
+ }
64
+ else if (name === "default") {
65
+ throw new Error(`Service entrypoint ${entry} must export a handler object or extend WorkerEntrypoint.`);
66
+ }
67
+ }
68
+ return contracts;
69
+ }
70
+ function workerProgram(entries, root) {
71
+ const config = projectConfig(root);
72
+ const modules = new Map(entries.map(entry => [entry,
73
+ ts.resolveModuleName(entry, path.join(root, "tokamak.ts"), config.options, ts.sys).resolvedModule?.resolvedFileName ?? entry,
74
+ ]));
75
+ const program = ts.createProgram({
76
+ rootNames: [...new Set([...modules.values(), ...config.fileNames.filter((file) => /\.d\.[cm]?ts$/.test(file))])],
77
+ options: config.options,
78
+ projectReferences: config.projectReferences,
79
+ });
80
+ const syntax = program.getSyntacticDiagnostics();
81
+ if (syntax.length)
82
+ throw diagnostics(syntax);
83
+ const checker = program.getTypeChecker();
84
+ const files = [...new Set([
85
+ ...entries,
86
+ ...config.configFiles,
87
+ ...program.getSourceFiles().filter((file) => !program.isSourceFileDefaultLibrary(file)).map((file) => file.fileName),
88
+ ])].sort();
89
+ return { program, checker, files, modules };
90
+ }
91
+ function workerModule(entry, project) {
92
+ const { program, checker } = project;
93
+ const source = program.getSourceFile(project.modules.get(entry) ?? entry);
94
+ if (!source)
95
+ throw new Error(`Cannot read Tokamak entrypoint ${entry}.`);
96
+ const module = checker.getSymbolAtLocation(source);
97
+ const exports = new Map((module ? checker.getExportsOfModule(module) : []).map(exported => {
98
+ const symbol = exported.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(exported) : exported;
99
+ return [exported.name, checker.getTypeOfSymbolAtLocation(symbol, source)];
100
+ }));
101
+ return { ...project, entry, source, exports };
102
+ }
103
+ function pluginContract(module, definition, validate) {
104
+ const { checker, program, source } = module;
105
+ const signatures = module.exports.get(definition.api.export)?.getCallSignatures() ?? [];
106
+ const signature = signatures[0];
107
+ const parameter = signature?.parameters[0];
108
+ const parameterType = parameter && checker.getTypeOfSymbol(parameter);
109
+ const symbol = nativeSdkExport(program, checker, module.entry, "TokamakContext");
110
+ const context = symbol && checker.getDeclaredTypeOfSymbol(symbol);
111
+ if (signatures.length !== 1 || signature.typeParameters?.length || signature.parameters.length !== 1 ||
112
+ !parameterType || parameterType.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown) || !context ||
113
+ !checker.isTypeAssignableTo(parameterType, context) || !checker.isTypeAssignableTo(context, parameterType)) {
114
+ throw new Error(`Plugin ${definition.id} factory must accept one TokamakContext.`);
115
+ }
116
+ const api = signature.getReturnType();
117
+ if (!apiObject(checker, api)) {
118
+ throw new Error(`Plugin ${definition.id} factory must return a synchronous object with declared methods.`);
119
+ }
120
+ const reference = nativeSdkExport(program, checker, module.entry, "NativeReference", "@tokamakdev/plugin");
121
+ const methods = [];
122
+ const operations = [];
123
+ for (const property of api.getProperties()) {
124
+ if (privateProperty(property))
125
+ continue;
126
+ if (property.name === "capabilities")
127
+ continue;
128
+ assertMethodName(property.name);
129
+ if (["fetch", "event", "queue", "scheduled", "email", "tail", "trace", "tailStream", "test"].includes(property.name)) {
130
+ throw new Error(`Plugin method ${property.name} is reserved for a Worker handler.`);
131
+ }
132
+ const method = checker.getTypeOfSymbolAtLocation(property, source);
133
+ if (!method.getCallSignatures().length || String(property.escapedName).startsWith("__@")) {
134
+ throw new Error(`Plugin ${definition.id}.${property.name} must be a method.`);
135
+ }
136
+ operations.push(property.name);
137
+ try {
138
+ checkRpcMethod(checker, method, `${definition.id}.${property.name}`, reference);
139
+ }
140
+ catch (error) {
141
+ // Methods that pass native references are available only in the Tokamak worker.
142
+ if (error instanceof WorkerOnlyMethod)
143
+ continue;
144
+ if (validate)
145
+ throw error;
146
+ }
147
+ methods.push(methodDeclaration(checker, property, method));
148
+ }
149
+ methods.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
150
+ const unknown = Object.keys(definition.operations).find(name => !operations.includes(name));
151
+ if (unknown)
152
+ throw new Error(`${definition.manifest}: operations.${unknown} names no method of ${definition.api.export}.`);
153
+ const webMethods = browserMethods(module, definition, methods, validate);
154
+ for (const method of methods) {
155
+ method.documentation = [method.documentation, availability(definition, method.name, webMethods.includes(method.name))]
156
+ .filter(Boolean).join("\n\n");
157
+ }
158
+ return {
159
+ definition, methods: methods.map(method => method.name), files: [...module.files, definition.manifest],
160
+ webMethods, operations: operations.sort(),
161
+ declarations: serviceTypes(`ReturnType<typeof import(${JSON.stringify(definition.api.module)}, { with: { "resolution-mode": "import" } })[${JSON.stringify(definition.api.export)}]>`, methods),
162
+ };
163
+ }
164
+ const platformNames = {
165
+ ios: "iOS", macos: "macOS", android: "Android", windows: "Windows", linux: "Linux",
166
+ };
167
+ /** Where the operation `name` is implemented, and what it needs there, for editor documentation. */
168
+ function availability(definition, name, web) {
169
+ const operation = definition.operations[name];
170
+ const platforms = operation?.platforms ?? definition.platforms.map(platform => ({ name: platform, minimum: undefined }));
171
+ const native = platforms.map(({ name, minimum }) => {
172
+ const version = name === "android" ? `API ${minimum ?? ""}` : minimum;
173
+ return minimum ? `${platformNames[name]} ${version}+` : platformNames[name];
174
+ });
175
+ const foreground = operation?.foreground.map(platform => platformNames[platform]) ?? [];
176
+ return [
177
+ `Native: ${native.join(", ") || "none"}. Web: ${web ? "browser implementation" : "not supported"}.`,
178
+ foreground.length ? `May need the app in the foreground on ${foreground.join(", ")}, to show UI.` : "",
179
+ ].filter(Boolean).join(" ");
180
+ }
181
+ function browserMethods(project, definition, methods, validate) {
182
+ if (!definition.web)
183
+ return [];
184
+ const { checker } = project;
185
+ const module = workerModule(definition.web.module, project);
186
+ const api = module.exports.get(definition.web.export);
187
+ if (!api || !apiObject(checker, api)) {
188
+ throw new Error(`Plugin ${definition.id} web export must be an object implementing its frontend methods.`);
189
+ }
190
+ const clientModule = checker.getAmbientModules().find(symbol => symbol.name === JSON.stringify(definition.client));
191
+ const clientSymbol = clientModule && checker.getExportsOfModule(clientModule).find(symbol => symbol.name === "TokamakClient");
192
+ const client = clientSymbol && checker.getDeclaredTypeOfSymbol(clientSymbol);
193
+ return api.getProperties().filter(property => !privateProperty(property)).map(property => {
194
+ const method = methods.find(method => method.name === property.name);
195
+ if (!method)
196
+ throw new Error(`Plugin ${definition.id} web method ${property.name} has no native API method.`);
197
+ if (validate) {
198
+ const expected = client?.getProperty(property.name);
199
+ const actual = checker.getTypeOfSymbolAtLocation(property, module.source);
200
+ checkRpcMethod(checker, actual, `${definition.id}.web.${property.name}`);
201
+ if (!expected || !checker.isTypeAssignableTo(actual, checker.getTypeOfSymbol(expected))) {
202
+ throw new Error(`Plugin ${definition.id} web method ${property.name} must implement its generated asynchronous frontend signature.`);
203
+ }
204
+ }
205
+ return property.name;
206
+ }).sort();
207
+ }
208
+ function privateProperty(property) {
209
+ return property.declarations?.some(node => {
210
+ const name = node.name;
211
+ return Boolean(ts.getCombinedModifierFlags(node) & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected)) ||
212
+ Boolean(name && ts.isPrivateIdentifier(name));
213
+ }) ?? false;
214
+ }
215
+ function apiObject(checker, type) {
216
+ if (type.isUnionOrIntersection())
217
+ return type.types.every(member => apiObject(checker, member));
218
+ return Boolean(type.flags & ts.TypeFlags.Object) && !type.getCallSignatures().length &&
219
+ !type.getConstructSignatures().length && !type.getProperty("then") && !checker.getIndexInfosOfType(type).length;
220
+ }
221
+ function classContract(module, name, native, validate) {
222
+ const { entry, program, checker, source } = module;
223
+ const constructor = module.exports.get(name)?.getConstructSignatures()[0];
224
+ const instance = constructor?.getReturnType();
225
+ const declarations = instance?.symbol?.declarations ?? [];
226
+ const concrete = declarations.some((node) => (ts.isClassDeclaration(node) || ts.isClassExpression(node)) &&
227
+ !ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.AbstractKeyword));
228
+ if (!instance || !concrete || constructor?.typeParameters?.length) {
229
+ throw new Error(name === "default" ? `Tokamak entrypoint ${entry} must default-export a concrete class.`
230
+ : `Tokamak entrypoint ${name} must be a concrete class.`);
231
+ }
232
+ const prototypes = applicationPrototypes(program, checker, instance, entry, native);
233
+ if (!prototypes) {
234
+ throw new Error(`Tokamak entrypoint ${entry} must extend TokamakEntrypoint from @tokamakdev/tok or WorkerEntrypoint from cloudflare:workers; include its type declarations.`);
235
+ }
236
+ const methods = [];
237
+ for (const property of instance.getProperties()) {
238
+ if (!property.declarations?.some((node) => prototypes.has(node.parent)))
239
+ continue;
240
+ if (!exposedMethod(property))
241
+ continue;
242
+ const type = checker.getTypeOfSymbolAtLocation(property, source);
243
+ if (validate)
244
+ checkRpcMethod(checker, type, property.name);
245
+ methods.push(methodDeclaration(checker, property, type));
246
+ }
247
+ methods.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
248
+ return {
249
+ methods: methods.map((method) => method.name),
250
+ declarations: serviceTypes(`InstanceType<typeof import(${JSON.stringify(entry.split(path.sep).join("/"))}, { with: { "resolution-mode": "import" } })[${JSON.stringify(name)}]>`, methods, native ? "Tokamak" : "Main"),
251
+ files: module.files,
252
+ };
253
+ }
254
+ function methodDeclaration(checker, property, type) {
255
+ return {
256
+ name: property.name,
257
+ overloads: type.getCallSignatures().length,
258
+ documentation: [
259
+ ts.displayPartsToString(property.getDocumentationComment(checker)),
260
+ ...property.getJsDocTags(checker).map(tag => `@${tag.name} ${ts.displayPartsToString(tag.text)}`.trim()),
261
+ ].filter(Boolean).join("\n"),
262
+ };
263
+ }
264
+ function applicationPrototypes(program, checker, instance, entry, allowNative = false) {
265
+ const module = checker.getAmbientModules().find((symbol) => symbol.name === '"cloudflare:workers"');
266
+ const exported = module && checker.getExportsOfModule(module).find((symbol) => symbol.name === "WorkerEntrypoint");
267
+ const base = exported && (exported.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(exported) : exported);
268
+ const native = allowNative ? nativeSdkExport(program, checker, entry, "TokamakEntrypoint") : undefined;
269
+ const prototypes = new Set();
270
+ let current = instance;
271
+ const seen = new Set();
272
+ while (current && !seen.has(current)) {
273
+ const symbol = current.getSymbol();
274
+ if (symbol && (symbol === base || symbol === native))
275
+ return prototypes;
276
+ seen.add(current);
277
+ for (const node of current.getSymbol()?.declarations ?? [])
278
+ prototypes.add(node);
279
+ current = checker.getBaseTypes(current)?.[0];
280
+ }
281
+ return undefined;
282
+ }
283
+ /** The symbol `name` exported by the core SDK module `specifier`, which `@tokamakdev/tok` uses for `entry`. */
284
+ function nativeSdkExport(program, checker, entry, name, specifier = "@tokamakdev/plugin/worker") {
285
+ const options = program.getCompilerOptions();
286
+ const sdk = ts.resolveModuleName("@tokamakdev/tok", entry, options, ts.sys).resolvedModule?.resolvedFileName;
287
+ const resolved = ts.resolveModuleName(specifier, sdk ?? fileURLToPath(import.meta.url), options, ts.sys).resolvedModule;
288
+ const source = resolved && program.getSourceFile(resolved.resolvedFileName);
289
+ const module = source && checker.getSymbolAtLocation(source);
290
+ const exported = module && checker.getExportsOfModule(module).find(symbol => symbol.name === name);
291
+ return exported && (exported.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(exported) : exported);
292
+ }
293
+ function exposedMethod(property) {
294
+ const declarations = property.declarations?.filter(ts.isMethodDeclaration) ?? [];
295
+ if (!declarations.length || declarations.some((node) => ts.isPrivateIdentifier(node.name)) ||
296
+ String(property.escapedName).startsWith("__@"))
297
+ return false;
298
+ if (declarations.some((node) => ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.PrivateKeyword || modifier.kind === ts.SyntaxKind.ProtectedKeyword))) {
299
+ throw new Error(`Method ${property.name} is on the public JavaScript prototype; use a #private method for a local helper.`);
300
+ }
301
+ assertMethodName(property.name);
302
+ return !["fetch", "event", "queue", "scheduled", "email", "tail", "trace", "tailStream", "test"].includes(property.name);
303
+ }
304
+ function assertMethodName(name) {
305
+ if (["then", "connect", "dup", "constructor", "alarm", "webSocketMessage", "webSocketClose", "webSocketError", "__tokamakRpc"].includes(name)) {
306
+ throw new Error(`Method ${name} is reserved by the service binding contract.`);
307
+ }
308
+ }
309
+ function projectConfig(root) {
310
+ const configFiles = [];
311
+ const host = {
312
+ ...ts.sys,
313
+ readFile(file) { configFiles.push(file); return ts.sys.readFile(file); },
314
+ };
315
+ const file = ts.findConfigFile(root, ts.sys.fileExists);
316
+ const loaded = file ? ts.readConfigFile(file, host.readFile) : { config: {} };
317
+ if (loaded.error)
318
+ throw diagnostics([loaded.error]);
319
+ const config = ts.parseJsonConfigFileContent(loaded.config, host, file ? path.dirname(file) : root, undefined, file);
320
+ // An explicit entrypoint can live outside a project's include patterns.
321
+ const errors = config.errors.filter((error) => error.code !== 18003);
322
+ if (errors.length)
323
+ throw diagnostics(errors);
324
+ config.options = {
325
+ target: ts.ScriptTarget.ES2023,
326
+ module: ts.ModuleKind.ESNext,
327
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
328
+ strict: true,
329
+ types: [],
330
+ ...config.options,
331
+ allowJs: true,
332
+ checkJs: true,
333
+ noEmit: true,
334
+ };
335
+ return { ...config, configFiles };
336
+ }
337
+ function diagnostics(errors) {
338
+ return new Error(ts.formatDiagnostics(errors, {
339
+ getCanonicalFileName: (name) => name,
340
+ getCurrentDirectory: ts.sys.getCurrentDirectory,
341
+ getNewLine: () => "\n",
342
+ }));
343
+ }
@@ -0,0 +1,43 @@
1
+ import type { PluginConfig } from "@cloudflare/vite-plugin";
2
+ import { type EnvironmentOptions, type Plugin } from "vite";
3
+ import type { Availability, NativePlatform } from "@tokamakdev/plugin/capabilities";
4
+ import type { WorkerContract } from "./worker-contract.mjs";
5
+ import { WorkerTypes, type WorkerConfiguration } from "./worker-types.mjs";
6
+ type ServiceConfiguration = Pick<WorkerConfiguration, "name" | "topLevelName" | "services" | "main" | "configPath" | "vars" | "kv_namespaces" | "d1_databases" | "r2_buckets">;
7
+ type Auxiliary = NonNullable<PluginConfig["auxiliaryWorkers"]>[number];
8
+ type AuxiliaryCustomizer = Extract<Extract<Auxiliary, {
9
+ config: unknown;
10
+ }>["config"], (...args: never[]) => unknown>;
11
+ export interface WorkerGraph {
12
+ generated: string;
13
+ /** Connects development invocations to a device on `platform` through the relay on `port`. */
14
+ relay(port: number, token: string, platform: NativePlatform | undefined): void;
15
+ /** Each plugin operation's native platforms, by plugin ID. */
16
+ availability(): Availability;
17
+ configure(config: ServiceConfiguration): void;
18
+ services(name: string): NonNullable<ServiceConfiguration["services"]>;
19
+ native: AuxiliaryCustomizer;
20
+ names(): {
21
+ main: string;
22
+ native: string;
23
+ app: string;
24
+ };
25
+ contract(): WorkerContract;
26
+ types: WorkerTypes;
27
+ cloudflare: PluginConfig;
28
+ plugin: Plugin & {
29
+ config: Extract<Plugin["config"], (...args: never[]) => unknown>;
30
+ configEnvironment: {
31
+ order: "post";
32
+ handler(name: string, config: EnvironmentOptions): EnvironmentOptions;
33
+ };
34
+ buildStart: Extract<Plugin["buildStart"], (...args: never[]) => unknown>;
35
+ hotUpdate: Extract<Plugin["hotUpdate"], (...args: never[]) => unknown>;
36
+ resolveId: Extract<Plugin["resolveId"], (...args: never[]) => unknown>;
37
+ load: Extract<Plugin["load"], (...args: never[]) => unknown>;
38
+ };
39
+ }
40
+ /** Configure the two bundles through Cloudflare's public auxiliary-worker API. */
41
+ export declare function createWorkerGraph(root: string, entry: string | undefined, output: string): WorkerGraph;
42
+ export declare function workerName(main: string): string;
43
+ export {};