gesso-vite-plugin 0.4.2 → 0.5.0
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/CHANGELOG.md +29 -0
- package/README.md +4 -0
- package/dist/gesso-channels.mjs +530 -0
- package/dist/gesso-channels.mjs.map +1 -0
- package/dist/index.d.ts +161 -1
- package/dist/index.js +761 -7
- package/dist/index.js.map +1 -1
- package/package.json +11 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# gesso-vite-plugin
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- f265910: An AI agent can drive a web application while it runs in development. `gesso-vite-plugin` serves MCP at `/__gesso/mcp` on the dev server and prints the `claude mcp add` line to connect; the agent then sees every channel the open page can reach, the ones its render worker feeds and the ones its application and channel workers serve, and its commands change the page as a click would. Messages travel down the HMR socket to the page, which answers them against the render worker, which asks each worker behind it over a `gesso:agent` port. A command marked `@confirm` is put to the person with the browser's dialog first. The endpoint refuses requests from browser pages, the newest open tab answers, and `agent: false` turns it off. A build carries none of it.
|
|
8
|
+
|
|
9
|
+
`gesso-framework/agent` gains what the bridge is made of: `serveAgentPort`, `remoteSurface` and `combineSurfaces` for a surface across threads, `connectDevAgent` for the page's half, and `AgentSurfaceLike` for a surface whose answers are promises, which `handleMcpMessage` and `mcpHandler` now accept. `WorkerApp.openRenderPort(key)` opens a port to the render worker. The scaffolded `AGENTS.md` says how to connect.
|
|
10
|
+
|
|
11
|
+
- c38f97e: A single-thread app can be driven by an AI agent too. `createSyncApp(...)` builders now answer `openRenderPort('gesso:agent')` from the page, with the channels fed there, whatever their channel workers serve, and the screen tools, so the dev server endpoint and WebMCP work exactly as they do for `createApp`. `useWebMcp(true | false | { confirm })` is the builder's form of `createApp({ webmcp })`. In a dev server, `gesso-vite-plugin` now hands a `createSyncApp` builder to the agent bridge and turns WebMCP on unless the app's own `useWebMcp(false)` says otherwise. The screen tools run a pending frame after each action on the main thread as they do in the render worker, so a background tab still reports what changed. `serveApplicationAgent` in `gesso-framework/agent` is the assembly both configurations share.
|
|
12
|
+
- 96f4bdc: A channel can describe itself. `describeChannel(token, schema)` attaches a JSON Schema of the channel's view and of each command, and `channelSchema(token)` reads it back, so anything that meets an application only at run time can ask a channel what it holds and what its commands take: an AI agent being handed the channel as tools, a devtools panel, a test that drives an app by its commands.
|
|
13
|
+
|
|
14
|
+
`gesso-vite-plugin` writes the schema for you. It reads each contract with TypeScript 7's checker and takes the descriptions from the JSDoc you already wrote: on the token, on each view key, on each command, and `@param` for its parameters. Four tags annotate a command for an agent: `@destructive`, `@idempotent`, `@confirm` and `@hidden`. A value that cannot cross a channel, such as a `Date`, a `Map` or an untyped `[]`, is reported as a build warning naming its path. The plugin needs `typescript` 7 or later installed, says so once if it is not, and `channelSchemas: false` turns the whole thing off.
|
|
15
|
+
|
|
16
|
+
- 88d93b3: A command may carry bytes. An `ArrayBuffer` or a typed array in a command's parameters is no longer reported as unable to cross a channel, because a command's argument is structured-cloned and bytes clone as themselves; a file can be sent as its bytes, with no base64 pass on the render thread. The schema describes such a field as a base64 string tagged `x-gesso-binary` with the type it becomes, and the agent surface decodes an agent's base64 back into that type before the command is sent. Bytes in a view key are still a warning, since a view is diffed.
|
|
17
|
+
- fa859bb: A desktop app serves its channels to AI agents. `serveDesktopAgent(channels, options)` in `gesso-electrobun/desktop` serves them over MCP from the main process with `Bun.serve`, which Cottontail provides, on `127.0.0.1:7310` or the next free port after it, refusing any request a web page sends, and reports the URL to connect. `messageBoxConfirm(Utils.showMessageBox)` puts a `@confirm` command to the person with the native dialog, Decline by default. The screen tools are not offered there, since the screen is in each window's render worker.
|
|
18
|
+
|
|
19
|
+
Electrobun bundles the main process with its own build, which takes no plugins, so `gesso-vite-plugin` now ships `gesso-channels`, a command that reads contracts with the same TypeScript 7 checker and writes a module describing them, for the main process to import once; `--check` fails when it is out of date.
|
|
20
|
+
|
|
21
|
+
The Electrobun template uses all of it: the counter is served to agents as the app starts, `hutch run channels` writes `src/shared/channels.described.ts` before every build, `hutch run typecheck` checks it is current, and the contract's JSDoc is written for an agent to read. The template moves to TypeScript 7, clearing the projected config's `baseUrl`, which TypeScript 7 removed, and to Vite 8, with `vite.config.ts` using `import.meta.dirname` and an explicit `.ts` import as Vite 8's config loader asks.
|
|
22
|
+
|
|
23
|
+
- cf3b16a: `createApp({ webmcp: true })` offers an application's channels to an AI agent in the browser through WebMCP. Once the app mounts, every tool the agent surface offers, a view tool per channel and a tool per command, is registered with `document.modelContext.registerTool` (or the older `navigator.modelContext`), and removed when the app is disposed. View tools carry `readOnlyHint`, `@destructive` commands carry `consequentialHint`, a call answers with the view it left or rejects with the sentence that says why, and a `@confirm` command is put to the person with `window.confirm` unless `webmcp: { confirm }` supplies the application's own dialog. In a browser without WebMCP nothing is registered and nothing fails. The code loads on demand, so the shell is no bigger for an app that does not ask.
|
|
24
|
+
|
|
25
|
+
`gesso-framework/agent` adds `registerWebMcpTools`, `connectWebMcp`, `pageModelContext` and `confirmInWindow`. `gesso-vite-plugin` turns `webmcp` on in a dev server; the app's own setting still decides.
|
|
26
|
+
|
|
27
|
+
### Patch Changes
|
|
28
|
+
|
|
29
|
+
- 4624080: Dependencies resolve their `worker` build ahead of their `browser` one. Application code runs in workers, and a package's browser build may reach for `document`: every markdown parser built on micromark imports `decode-named-character-reference`, whose browser build does, and a render worker that imported one died on start with "document is not defined". Pass `workerConditions: false` to resolve as Vite does by default.
|
|
30
|
+
- f3a9544: Workers are built as ES modules, matching the module workers the plugin constructs, so a dynamic import in a worker becomes a chunk loaded when it runs instead of being inlined into the worker. Vite's default, IIFE, cannot split. An application that sets `worker.format` keeps its choice.
|
|
31
|
+
|
|
3
32
|
## 0.4.2
|
|
4
33
|
|
|
5
34
|
## 0.4.1
|
package/README.md
CHANGED
|
@@ -33,6 +33,10 @@ Nothing in `gesso-core` or `gesso-framework` mentions Vite, and no bundler is im
|
|
|
33
33
|
|
|
34
34
|
One failure looks exactly like a bug in the framework: a save that reloads the whole page instead of replacing a module. The cause is a module reached from the main thread as well as from the render worker, which Vite cannot hot-replace because it propagates the invalidation to a main-thread importer that does not accept it. The plugin says so, and names the module, instead of leaving you to guess.
|
|
35
35
|
|
|
36
|
+
## Channels an agent can read
|
|
37
|
+
|
|
38
|
+
A channel contract is types and comments, and both are gone at run time. The plugin reads each contract with TypeScript 7's checker and attaches a JSON Schema of the view and every command to the token, with descriptions taken from your JSDoc, so `channelSchema(token)` can tell an AI agent what the application holds and what it can be asked to do. `channelSchemas: false` turns it off.
|
|
39
|
+
|
|
36
40
|
## Documentation
|
|
37
41
|
|
|
38
42
|
[The Vite plugin](https://gesso-docs.vercel.app/tooling/vite-plugin)
|
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
//#region src/contracts.ts
|
|
5
|
+
/**
|
|
6
|
+
* A module that describes channels when it is imported, for a bundle
|
|
7
|
+
* this plugin never sees.
|
|
8
|
+
*
|
|
9
|
+
* The plugin describes a contract by appending to it as Vite bundles
|
|
10
|
+
* it. An Electrobun main process is bundled by Electrobun's own build,
|
|
11
|
+
* which takes no plugins, so there the description is written ahead of
|
|
12
|
+
* time instead: `gesso-channels` reads the contracts with the same
|
|
13
|
+
* checker and writes this module, and the main process imports it once.
|
|
14
|
+
* A token is imported from its contract, so the schema lands on the
|
|
15
|
+
* same object the application serves.
|
|
16
|
+
*
|
|
17
|
+
* `outFile` and every `file` are absolute POSIX paths.
|
|
18
|
+
*/
|
|
19
|
+
function describedModule(contracts, outFile) {
|
|
20
|
+
const lines = [
|
|
21
|
+
"// Written by gesso-channels from the channel contracts it names below.",
|
|
22
|
+
"// Run it again after changing a contract rather than editing this file.",
|
|
23
|
+
"import { describeChannel } from 'gesso-framework';"
|
|
24
|
+
];
|
|
25
|
+
const from = outFile.slice(0, outFile.lastIndexOf("/"));
|
|
26
|
+
for (const contract of contracts) if (contract.channels.size > 0) {
|
|
27
|
+
const names = [...contract.channels.keys()].join(", ");
|
|
28
|
+
lines.push(`import { ${names} } from '${importPath(from, contract.file)}';`);
|
|
29
|
+
}
|
|
30
|
+
for (const contract of contracts) for (const [name, schema] of contract.channels) lines.push("", `describeChannel(${name}, ${JSON.stringify(schema, null, 2)});`);
|
|
31
|
+
return `${lines.join("\n")}\n`;
|
|
32
|
+
}
|
|
33
|
+
/** `./Counter` from `/app/src/shared` to `/app/src/shared/Counter.ts`, without the extension. */
|
|
34
|
+
function importPath(fromDir, file) {
|
|
35
|
+
const from = fromDir.split("/").filter(Boolean);
|
|
36
|
+
const to = file.replace(/\.[cm]?[jt]sx?$/, "").split("/").filter(Boolean);
|
|
37
|
+
let shared = 0;
|
|
38
|
+
while (shared < from.length && shared < to.length - 1 && from[shared] === to[shared]) shared++;
|
|
39
|
+
const up = from.length - shared;
|
|
40
|
+
const rest = to.slice(shared).join("/");
|
|
41
|
+
return up === 0 ? `./${rest}` : `${"../".repeat(up)}${rest}`;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The checker, held open across reads.
|
|
45
|
+
*
|
|
46
|
+
* Spawning it and loading a project costs about a hundred
|
|
47
|
+
* milliseconds; a question after that costs well under one. So one
|
|
48
|
+
* reader lives as long as the dev server or the build, is told which
|
|
49
|
+
* files changed between reads, and is closed at the end, because the
|
|
50
|
+
* process it holds would otherwise keep the build from exiting.
|
|
51
|
+
*/
|
|
52
|
+
var ContractReader = class {
|
|
53
|
+
api = null;
|
|
54
|
+
changed = /* @__PURE__ */ new Set();
|
|
55
|
+
/** Each contract's tsconfig, found once. */
|
|
56
|
+
configs = /* @__PURE__ */ new Map();
|
|
57
|
+
/**
|
|
58
|
+
* @param fs Callbacks the checker reads through before the disk, for
|
|
59
|
+
* a spec that keeps its project in memory.
|
|
60
|
+
*/
|
|
61
|
+
ts;
|
|
62
|
+
cwd;
|
|
63
|
+
fs;
|
|
64
|
+
constructor(ts, cwd, fs) {
|
|
65
|
+
this.ts = ts;
|
|
66
|
+
this.cwd = cwd;
|
|
67
|
+
this.fs = fs;
|
|
68
|
+
}
|
|
69
|
+
/** A file changed on disk since the last read. */
|
|
70
|
+
invalidate(file) {
|
|
71
|
+
this.changed.add(file);
|
|
72
|
+
}
|
|
73
|
+
/** Every channel `file` exports, described. */
|
|
74
|
+
read(file) {
|
|
75
|
+
const api = this.api ??= new this.ts.API({
|
|
76
|
+
cwd: this.cwd,
|
|
77
|
+
...this.fs === void 0 ? {} : { fs: this.fs }
|
|
78
|
+
});
|
|
79
|
+
this.changed.add(file);
|
|
80
|
+
let snapshot = api.updateSnapshot({ fileChanges: { changed: [...this.changed] } });
|
|
81
|
+
this.changed.clear();
|
|
82
|
+
let config = this.configs.get(file);
|
|
83
|
+
if (config === void 0) {
|
|
84
|
+
config = api.updateSnapshot({ openFiles: [file] }).getDefaultProjectForFile(file)?.configFileName;
|
|
85
|
+
snapshot = api.updateSnapshot({
|
|
86
|
+
...config === void 0 ? {} : { openProjects: [config] },
|
|
87
|
+
closeFiles: [file]
|
|
88
|
+
});
|
|
89
|
+
if (config !== void 0) this.configs.set(file, config);
|
|
90
|
+
}
|
|
91
|
+
const project = config === void 0 ? void 0 : snapshot.getProject(config);
|
|
92
|
+
const source = project?.program.getSourceFile(file);
|
|
93
|
+
if (project === void 0 || source === void 0) return {
|
|
94
|
+
channels: /* @__PURE__ */ new Map(),
|
|
95
|
+
warnings: [`TypeScript has no project that includes ${file}.`]
|
|
96
|
+
};
|
|
97
|
+
const checker = project.checker;
|
|
98
|
+
const module = checker.getSymbolAtLocation(source);
|
|
99
|
+
const describer = new Describer(this.ts, checker);
|
|
100
|
+
const channels = /* @__PURE__ */ new Map();
|
|
101
|
+
for (const exported of module === void 0 ? [] : checker.getExportsOfModule(module)) {
|
|
102
|
+
const type = checker.getTypeOfSymbol(exported);
|
|
103
|
+
if (type === void 0 || type.getSymbol()?.name !== "ChannelToken") continue;
|
|
104
|
+
const [view, commands] = checker.getTypeArguments(type);
|
|
105
|
+
channels.set(exported.name, describer.channel(exported, view, commands));
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
channels,
|
|
109
|
+
warnings: describer.warnings
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
close() {
|
|
113
|
+
this.api?.close();
|
|
114
|
+
this.api = null;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* Library types that are objects to the checker and not plain data to
|
|
119
|
+
* a channel. They compare by reference, so `provide` rejects them on
|
|
120
|
+
* their first value; describing their methods as properties would be
|
|
121
|
+
* describing something that never arrives.
|
|
122
|
+
*/
|
|
123
|
+
const NOT_PLAIN = /* @__PURE__ */ new Set([
|
|
124
|
+
"Date",
|
|
125
|
+
"Map",
|
|
126
|
+
"ReadonlyMap",
|
|
127
|
+
"Set",
|
|
128
|
+
"ReadonlySet",
|
|
129
|
+
"WeakMap",
|
|
130
|
+
"WeakSet",
|
|
131
|
+
"Promise",
|
|
132
|
+
"RegExp",
|
|
133
|
+
"Error",
|
|
134
|
+
"ArrayBuffer",
|
|
135
|
+
"SharedArrayBuffer",
|
|
136
|
+
"DataView",
|
|
137
|
+
"Blob",
|
|
138
|
+
"File",
|
|
139
|
+
"URL",
|
|
140
|
+
"Int8Array",
|
|
141
|
+
"Uint8Array",
|
|
142
|
+
"Uint8ClampedArray",
|
|
143
|
+
"Int16Array",
|
|
144
|
+
"Uint16Array",
|
|
145
|
+
"Int32Array",
|
|
146
|
+
"Uint32Array",
|
|
147
|
+
"Float32Array",
|
|
148
|
+
"Float64Array",
|
|
149
|
+
"BigInt64Array",
|
|
150
|
+
"BigUint64Array"
|
|
151
|
+
]);
|
|
152
|
+
/**
|
|
153
|
+
* Bytes, which a command may carry and a view key may not.
|
|
154
|
+
*
|
|
155
|
+
* A command's argument is structured-cloned onto the owning thread, and
|
|
156
|
+
* a typed array or an `ArrayBuffer` clones as itself: a file a person
|
|
157
|
+
* dropped crosses as its bytes, with no encoding pass on the render
|
|
158
|
+
* thread. A view key is diffed, and a differ that compares bytes by
|
|
159
|
+
* reference reports a change on every emission, so there they stay
|
|
160
|
+
* unplain. In a command's schema they are a base64 string, which is
|
|
161
|
+
* what an agent can write, tagged with the type the command expects so
|
|
162
|
+
* the agent surface can turn the string back into it.
|
|
163
|
+
*/
|
|
164
|
+
const BINARY = /* @__PURE__ */ new Set([
|
|
165
|
+
"ArrayBuffer",
|
|
166
|
+
"Int8Array",
|
|
167
|
+
"Uint8Array",
|
|
168
|
+
"Uint8ClampedArray",
|
|
169
|
+
"Int16Array",
|
|
170
|
+
"Uint16Array",
|
|
171
|
+
"Int32Array",
|
|
172
|
+
"Uint32Array",
|
|
173
|
+
"Float32Array",
|
|
174
|
+
"Float64Array",
|
|
175
|
+
"BigInt64Array",
|
|
176
|
+
"BigUint64Array"
|
|
177
|
+
]);
|
|
178
|
+
/** Turns checker types into JSON Schema for one module's channels. */
|
|
179
|
+
var Describer = class {
|
|
180
|
+
warnings = [];
|
|
181
|
+
flags;
|
|
182
|
+
/** Describing a command's parameters, where bytes are allowed; see `BINARY`. */
|
|
183
|
+
inCommand = false;
|
|
184
|
+
/** Types being described right now, to notice a type that contains itself. */
|
|
185
|
+
inProgress = /* @__PURE__ */ new Set();
|
|
186
|
+
/** Types found to contain themselves, which are described once in `$defs`. */
|
|
187
|
+
recursive = /* @__PURE__ */ new Set();
|
|
188
|
+
/** `$defs` for the schema being built. Reset per root, so each schema stands alone. */
|
|
189
|
+
defs = /* @__PURE__ */ new Map();
|
|
190
|
+
defNames = /* @__PURE__ */ new Map();
|
|
191
|
+
ts;
|
|
192
|
+
checker;
|
|
193
|
+
constructor(ts, checker) {
|
|
194
|
+
this.ts = ts;
|
|
195
|
+
this.checker = checker;
|
|
196
|
+
this.flags = ts.TypeFlags;
|
|
197
|
+
}
|
|
198
|
+
channel(token, view, commands) {
|
|
199
|
+
const out = {
|
|
200
|
+
view: this.root(view, `${token.name}.view`),
|
|
201
|
+
commands: {}
|
|
202
|
+
};
|
|
203
|
+
const description = this.checker.getDocumentationCommentOfSymbol(token);
|
|
204
|
+
if (description !== "") out.description = description;
|
|
205
|
+
if (commands !== void 0) for (const command of this.checker.getPropertiesOfType(commands)) {
|
|
206
|
+
const described = this.command(command, `${token.name}.commands.${command.name}`);
|
|
207
|
+
if (described !== null) out.commands[command.name] = described;
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
/** A schema that stands alone: its own `$defs`, if anything in it recurs. */
|
|
212
|
+
root(type, path) {
|
|
213
|
+
this.defs = /* @__PURE__ */ new Map();
|
|
214
|
+
const schema = this.describe(type, path);
|
|
215
|
+
if (this.defs.size > 0) return {
|
|
216
|
+
...schema,
|
|
217
|
+
$defs: Object.fromEntries(this.defs)
|
|
218
|
+
};
|
|
219
|
+
return schema;
|
|
220
|
+
}
|
|
221
|
+
command(symbol, path) {
|
|
222
|
+
const type = this.checker.getTypeOfSymbol(symbol);
|
|
223
|
+
const signatures = type === void 0 ? [] : this.checker.getSignaturesOfType(type, this.ts.SignatureKind.Call);
|
|
224
|
+
if (signatures.length === 0) {
|
|
225
|
+
this.warnings.push(`${path} is not a function, so it is not a command an agent can send.`);
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
if (signatures.length > 1) this.warnings.push(`${path} is overloaded; only its first signature is described.`);
|
|
229
|
+
const signature = signatures[0];
|
|
230
|
+
const tags = this.checker.getJsDocTagsOfSymbol(symbol);
|
|
231
|
+
const parameters = signature.getParameters();
|
|
232
|
+
this.defs = /* @__PURE__ */ new Map();
|
|
233
|
+
const properties = {};
|
|
234
|
+
const required = [];
|
|
235
|
+
this.inCommand = true;
|
|
236
|
+
parameters.forEach((parameter, index) => {
|
|
237
|
+
const parameterType = this.checker.getTypeOfSymbol(parameter);
|
|
238
|
+
const optional = parameterType !== void 0 && this.includesUndefined(parameterType);
|
|
239
|
+
const schema = parameterType === void 0 ? {} : this.describe(parameterType, `${path}(${parameter.name})`);
|
|
240
|
+
const description = this.parameterDescription(parameter, tags);
|
|
241
|
+
properties[parameter.name] = description === "" ? schema : {
|
|
242
|
+
...schema,
|
|
243
|
+
description
|
|
244
|
+
};
|
|
245
|
+
const rest = signature.hasRestParameter && index === parameters.length - 1;
|
|
246
|
+
if (!optional && !rest) required.push(parameter.name);
|
|
247
|
+
});
|
|
248
|
+
this.inCommand = false;
|
|
249
|
+
const input = {
|
|
250
|
+
type: "object",
|
|
251
|
+
properties,
|
|
252
|
+
additionalProperties: false
|
|
253
|
+
};
|
|
254
|
+
if (required.length > 0) input.required = required;
|
|
255
|
+
if (this.defs.size > 0) input.$defs = Object.fromEntries(this.defs);
|
|
256
|
+
const out = {
|
|
257
|
+
parameters: parameters.map((parameter) => parameter.name),
|
|
258
|
+
input
|
|
259
|
+
};
|
|
260
|
+
const description = this.checker.getDocumentationCommentOfSymbol(symbol);
|
|
261
|
+
if (description !== "") out.description = description;
|
|
262
|
+
if (signature.hasRestParameter) out.rest = true;
|
|
263
|
+
for (const annotation of [
|
|
264
|
+
"destructive",
|
|
265
|
+
"idempotent",
|
|
266
|
+
"confirm",
|
|
267
|
+
"hidden"
|
|
268
|
+
]) if (tags.some((tag) => tag.name === annotation)) out[annotation] = true;
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* A parameter's own comment, or failing that its `@param` line.
|
|
273
|
+
*
|
|
274
|
+
* The checker attaches `@param` text to the parameter of a method
|
|
275
|
+
* signature. A command written as a property, `move: (from: number)
|
|
276
|
+
* => void`, keeps its comment on the property, so the tag is read
|
|
277
|
+
* from there instead.
|
|
278
|
+
*/
|
|
279
|
+
parameterDescription(parameter, tags) {
|
|
280
|
+
const own = this.checker.getDocumentationCommentOfSymbol(parameter);
|
|
281
|
+
if (own !== "") return own;
|
|
282
|
+
for (const tag of tags) {
|
|
283
|
+
const match = tag.name === "param" ? /^\s*(\S+)\s*(?:-\s*)?([\s\S]*)$/.exec(tag.text ?? "") : null;
|
|
284
|
+
if (match !== null && match[1] === parameter.name) return match[2].trim();
|
|
285
|
+
}
|
|
286
|
+
return "";
|
|
287
|
+
}
|
|
288
|
+
includesUndefined(type) {
|
|
289
|
+
const { Undefined, Void } = this.flags;
|
|
290
|
+
if (type.isUnionType()) return (type.getTypes() ?? []).some((member) => (member.flags & (Undefined | Void)) !== 0);
|
|
291
|
+
return (type.flags & (Undefined | Void)) !== 0;
|
|
292
|
+
}
|
|
293
|
+
describe(type, path) {
|
|
294
|
+
const f = this.flags;
|
|
295
|
+
const flags = type.flags;
|
|
296
|
+
if (flags & (f.Any | f.Unknown)) return {};
|
|
297
|
+
if (flags & f.Never) {
|
|
298
|
+
this.warnings.push(`${path} is never, which describes no value. An empty array literal is never[] unless it says what it holds: write \`[] as Row[]\`.`);
|
|
299
|
+
return { not: {} };
|
|
300
|
+
}
|
|
301
|
+
if (flags & (f.String | f.TemplateLiteral | f.StringMapping)) return { type: "string" };
|
|
302
|
+
if (flags & f.Number) return { type: "number" };
|
|
303
|
+
if (flags & f.Boolean) return { type: "boolean" };
|
|
304
|
+
if (flags & f.Null) return { type: "null" };
|
|
305
|
+
if (flags & (f.Undefined | f.Void)) return {};
|
|
306
|
+
if (flags & f.BooleanLiteral) return { const: type.intrinsicName === "true" };
|
|
307
|
+
if (flags & (f.StringLiteral | f.NumberLiteral)) return { const: type.value };
|
|
308
|
+
if (flags & (f.BigInt | f.BigIntLiteral | f.ESSymbol | f.UniqueESSymbol)) return this.unplain(path, flags & f.BigIntLike ? "a bigint" : "a symbol");
|
|
309
|
+
if (type.isUnionType()) return this.union(type, path);
|
|
310
|
+
if (flags & f.TypeParameter) return {};
|
|
311
|
+
if (flags & (f.Object | f.Intersection)) {
|
|
312
|
+
if (this.checker.isArrayType(type)) {
|
|
313
|
+
const [element] = this.checker.getTypeArguments(type);
|
|
314
|
+
return {
|
|
315
|
+
type: "array",
|
|
316
|
+
items: element === void 0 ? {} : this.describe(element, `${path}[]`)
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
if (this.checker.isTupleType(type)) return this.tuple(type, path);
|
|
320
|
+
return this.named(type, path);
|
|
321
|
+
}
|
|
322
|
+
this.warnings.push(`${path} has a type this reader does not describe, so it is left open.`);
|
|
323
|
+
return {};
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* A union, with `undefined` taken out (optionality is said by
|
|
327
|
+
* `required`), `true | false` folded back into `boolean`, and a union
|
|
328
|
+
* of nothing but literals written as an `enum`.
|
|
329
|
+
*/
|
|
330
|
+
union(type, path) {
|
|
331
|
+
const f = this.flags;
|
|
332
|
+
let members = (type.getTypes() ?? []).filter((member) => (member.flags & (f.Undefined | f.Void)) === 0);
|
|
333
|
+
const booleans = members.filter((member) => member.flags & f.BooleanLiteral);
|
|
334
|
+
const schemas = [];
|
|
335
|
+
if (booleans.length === 2) {
|
|
336
|
+
members = members.filter((member) => !(member.flags & f.BooleanLiteral));
|
|
337
|
+
schemas.push({ type: "boolean" });
|
|
338
|
+
}
|
|
339
|
+
if (members.length > 0 && schemas.length === 0 && members.every((member) => member.flags & (f.StringLiteral | f.NumberLiteral))) return { enum: members.map((member) => member.value) };
|
|
340
|
+
members.sort((a, b) => Number((a.flags & f.Null) !== 0) - Number((b.flags & f.Null) !== 0));
|
|
341
|
+
schemas.push(...members.map((member) => this.describe(member, path)));
|
|
342
|
+
return schemas.length === 1 ? schemas[0] : { anyOf: schemas };
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* An object type, described once however often it appears, and by
|
|
346
|
+
* reference when it contains itself.
|
|
347
|
+
*/
|
|
348
|
+
named(type, path) {
|
|
349
|
+
const id = type.id;
|
|
350
|
+
if (this.inProgress.has(id)) {
|
|
351
|
+
this.recursive.add(id);
|
|
352
|
+
return { $ref: `#/$defs/${this.defName(type)}` };
|
|
353
|
+
}
|
|
354
|
+
this.inProgress.add(id);
|
|
355
|
+
const schema = this.object(type, path);
|
|
356
|
+
this.inProgress.delete(id);
|
|
357
|
+
if (this.recursive.has(id)) {
|
|
358
|
+
const name = this.defName(type);
|
|
359
|
+
this.defs.set(name, schema);
|
|
360
|
+
return { $ref: `#/$defs/${name}` };
|
|
361
|
+
}
|
|
362
|
+
return schema;
|
|
363
|
+
}
|
|
364
|
+
defName(type) {
|
|
365
|
+
let name = this.defNames.get(type.id);
|
|
366
|
+
if (name === void 0) {
|
|
367
|
+
const base = (type.getAliasSymbol() ?? type.getSymbol())?.name ?? "Type";
|
|
368
|
+
name = new Set(this.defNames.values()).has(base) ? `${base}${type.id}` : base;
|
|
369
|
+
this.defNames.set(type.id, name);
|
|
370
|
+
}
|
|
371
|
+
return name;
|
|
372
|
+
}
|
|
373
|
+
object(type, path) {
|
|
374
|
+
const checker = this.checker;
|
|
375
|
+
const symbolName = type.getSymbol()?.name;
|
|
376
|
+
if (symbolName !== void 0 && this.inCommand && BINARY.has(symbolName)) return {
|
|
377
|
+
type: "string",
|
|
378
|
+
contentEncoding: "base64",
|
|
379
|
+
"x-gesso-binary": symbolName
|
|
380
|
+
};
|
|
381
|
+
if (symbolName !== void 0 && NOT_PLAIN.has(symbolName)) return this.unplain(path, `a ${symbolName}`);
|
|
382
|
+
if (type.objectFlags & this.ts.ObjectFlags.Class) return this.unplain(path, `an instance of ${symbolName ?? "a class"}`);
|
|
383
|
+
if (checker.getSignaturesOfType(type, this.ts.SignatureKind.Call).length > 0) return this.unplain(path, "a function");
|
|
384
|
+
const properties = {};
|
|
385
|
+
const required = [];
|
|
386
|
+
for (const property of checker.getPropertiesOfType(type)) {
|
|
387
|
+
const propertyType = checker.getTypeOfSymbol(property);
|
|
388
|
+
const at = `${path}.${property.name}`;
|
|
389
|
+
if (propertyType === void 0) {
|
|
390
|
+
properties[property.name] = {};
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
const schema = this.describe(propertyType, at);
|
|
394
|
+
const description = checker.getDocumentationCommentOfSymbol(property);
|
|
395
|
+
properties[property.name] = description === "" ? schema : {
|
|
396
|
+
...schema,
|
|
397
|
+
description
|
|
398
|
+
};
|
|
399
|
+
if (!((property.flags & this.ts.SymbolFlags.Optional) !== 0 || this.includesUndefined(propertyType))) required.push(property.name);
|
|
400
|
+
}
|
|
401
|
+
const schema = {
|
|
402
|
+
type: "object",
|
|
403
|
+
properties
|
|
404
|
+
};
|
|
405
|
+
if (required.length > 0) schema.required = required;
|
|
406
|
+
const index = checker.getIndexInfosOfType(type).find((info) => info.keyType.flags & this.flags.String);
|
|
407
|
+
if (index !== void 0) schema.additionalProperties = this.describe(index.valueType, `${path}[key]`);
|
|
408
|
+
return schema;
|
|
409
|
+
}
|
|
410
|
+
tuple(type, path) {
|
|
411
|
+
const elements = this.checker.getTypeArguments(type);
|
|
412
|
+
const elementFlags = type.getTarget().elementFlags ?? [];
|
|
413
|
+
const { Required, Rest } = this.ts.ElementFlags;
|
|
414
|
+
const prefixItems = [];
|
|
415
|
+
let items = false;
|
|
416
|
+
let minItems = 0;
|
|
417
|
+
elements.forEach((element, index) => {
|
|
418
|
+
const schema = this.describe(element, `${path}[${index}]`);
|
|
419
|
+
if (elementFlags[index] & Rest) items = schema;
|
|
420
|
+
else {
|
|
421
|
+
prefixItems.push(schema);
|
|
422
|
+
if (elementFlags[index] & Required) minItems = index + 1;
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
return {
|
|
426
|
+
type: "array",
|
|
427
|
+
prefixItems,
|
|
428
|
+
items,
|
|
429
|
+
minItems
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
unplain(path, what) {
|
|
433
|
+
this.warnings.push(`${path} is ${what}, which is not plain data and cannot cross a channel.`);
|
|
434
|
+
return {};
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
//#endregion
|
|
438
|
+
//#region bin/gesso-channels.ts
|
|
439
|
+
/**
|
|
440
|
+
* `gesso-channels`: describes channel contracts for a bundle Vite does
|
|
441
|
+
* not build.
|
|
442
|
+
*
|
|
443
|
+
* gesso-channels src/shared/Counter.ts --out src/shared/channels.described.ts
|
|
444
|
+
* gesso-channels src/shared/*.ts --out src/shared/channels.described.ts --check
|
|
445
|
+
*
|
|
446
|
+
* `gesso-vite-plugin` describes a contract as Vite bundles it. An
|
|
447
|
+
* Electrobun main process is bundled by Electrobun's own build, which
|
|
448
|
+
* takes no plugins, so its channels would reach an agent undescribed:
|
|
449
|
+
* no descriptions, and arguments as a positional list. This reads the
|
|
450
|
+
* same contracts with the same checker and writes a module that
|
|
451
|
+
* describes them when imported; the main process imports it once.
|
|
452
|
+
*
|
|
453
|
+
* `--check` writes nothing and fails when the module on disk is not
|
|
454
|
+
* what it would write, for a gate that wants to know a contract
|
|
455
|
+
* changed without the module following.
|
|
456
|
+
*
|
|
457
|
+
* It needs TypeScript 7 in the project, whose checker does the reading.
|
|
458
|
+
*/
|
|
459
|
+
const USAGE = `Usage: gesso-channels <contract.ts>... --out <file> [--check]
|
|
460
|
+
|
|
461
|
+
Writes a module that attaches a JSON Schema to every channel the
|
|
462
|
+
contracts export, for a bundle gesso-vite-plugin does not build, such as
|
|
463
|
+
an Electrobun main process. Import the module once from that bundle.
|
|
464
|
+
|
|
465
|
+
--out <file> Where to write the module.
|
|
466
|
+
--check Write nothing; fail if the module is out of date.
|
|
467
|
+
`;
|
|
468
|
+
function fail(message) {
|
|
469
|
+
console.error(message);
|
|
470
|
+
process.exit(1);
|
|
471
|
+
}
|
|
472
|
+
async function main() {
|
|
473
|
+
const args = process.argv.slice(2);
|
|
474
|
+
const files = [];
|
|
475
|
+
let out;
|
|
476
|
+
let check = false;
|
|
477
|
+
for (let i = 0; i < args.length; i++) {
|
|
478
|
+
const arg = args[i];
|
|
479
|
+
if (arg === "--out") out = args[++i];
|
|
480
|
+
else if (arg === "--check") check = true;
|
|
481
|
+
else if (arg === "-h" || arg === "--help") {
|
|
482
|
+
console.log(USAGE);
|
|
483
|
+
return;
|
|
484
|
+
} else if (arg.startsWith("-")) fail(`Unknown option ${arg}.\n\n${USAGE}`);
|
|
485
|
+
else files.push(arg);
|
|
486
|
+
}
|
|
487
|
+
if (out === void 0 || files.length === 0) fail(USAGE);
|
|
488
|
+
let ts;
|
|
489
|
+
try {
|
|
490
|
+
ts = await import("typescript/unstable/sync");
|
|
491
|
+
} catch {
|
|
492
|
+
fail("gesso-channels needs TypeScript 7 or later in the project: npm install --save-dev typescript@^7.");
|
|
493
|
+
}
|
|
494
|
+
const cwd = realpathSync(process.cwd());
|
|
495
|
+
const outFile = resolve(realpathSync(dirname(resolve(cwd, out))), out.split(/[\\/]/).pop());
|
|
496
|
+
const reader = new ContractReader(ts, cwd);
|
|
497
|
+
const contracts = [];
|
|
498
|
+
let warned = false;
|
|
499
|
+
try {
|
|
500
|
+
for (const file of files) {
|
|
501
|
+
const path = realpathSync(resolve(cwd, file));
|
|
502
|
+
const reading = reader.read(path);
|
|
503
|
+
for (const warning of reading.warnings) {
|
|
504
|
+
console.warn(`gesso-channels: ${warning}`);
|
|
505
|
+
warned = true;
|
|
506
|
+
}
|
|
507
|
+
contracts.push({
|
|
508
|
+
file: path,
|
|
509
|
+
channels: reading.channels
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
} finally {
|
|
513
|
+
reader.close();
|
|
514
|
+
}
|
|
515
|
+
const described = contracts.reduce((count, contract) => count + contract.channels.size, 0);
|
|
516
|
+
if (described === 0) fail(`None of ${files.join(", ")} exports a channel.`);
|
|
517
|
+
const module = describedModule(contracts, outFile);
|
|
518
|
+
if (check) {
|
|
519
|
+
if ((existsSync(outFile) ? readFileSync(outFile, "utf8") : null) !== module) fail(`${out} is out of date. Run gesso-channels without --check to write it.`);
|
|
520
|
+
console.log(`${out} describes ${described} channel${described === 1 ? "" : "s"} and is current.`);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
writeFileSync(outFile, module);
|
|
524
|
+
console.log(`Wrote ${out}: ${described} channel${described === 1 ? "" : "s"} described${warned ? ", with warnings above" : ""}.`);
|
|
525
|
+
}
|
|
526
|
+
main();
|
|
527
|
+
//#endregion
|
|
528
|
+
export {};
|
|
529
|
+
|
|
530
|
+
//# sourceMappingURL=gesso-channels.mjs.map
|