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/dist/index.js CHANGED
@@ -1,3 +1,154 @@
1
+ import { defaultClientConditions } from "vite";
2
+ //#region src/agent.ts
3
+ /**
4
+ * The dev server's half of the agent bridge: MCP at `/__gesso/mcp`.
5
+ *
6
+ * An agent such as Claude Code connects to an MCP server by URL, and
7
+ * the dev server already has one. But the channels it should offer live
8
+ * in the page's workers, and a server cannot reach a worker. The page
9
+ * can, and it holds a socket to the server already, Vite's HMR
10
+ * connection. So this relays: each JSON-RPC message an agent posts goes
11
+ * down that socket to the page, `connectDevAgent` in `gesso-framework/agent`
12
+ * answers it against the render worker, and the answer comes back up
13
+ * and out as the HTTP response.
14
+ *
15
+ * MCP is spoken in the page rather than here, so this file imports
16
+ * nothing from the framework and parses nothing but enough JSON to
17
+ * know a request from a notification.
18
+ *
19
+ * Several tabs may be open. The newest to announce itself answers,
20
+ * which is the one a person most likely means, and a tab that closes
21
+ * hands back to the one before it.
22
+ */
23
+ /** The path an agent is pointed at. */
24
+ const AGENT_PATH = "/__gesso/mcp";
25
+ /** The HMR events, as `connectDevAgent` names them. */
26
+ const EVENTS = {
27
+ ready: "gesso:agent:ready",
28
+ request: "gesso:agent:request",
29
+ response: "gesso:agent:response"
30
+ };
31
+ function createAgentBridge(socket, options = {}) {
32
+ const timeoutMs = options.timeoutMs ?? 13e4;
33
+ /** Pages that announced themselves, newest last. */
34
+ const pages = [];
35
+ const pending = /* @__PURE__ */ new Map();
36
+ let next = 1;
37
+ socket.on(EVENTS.ready, (_data, client) => {
38
+ const at = pages.indexOf(client);
39
+ if (at >= 0) pages.splice(at, 1);
40
+ pages.push(client);
41
+ });
42
+ socket.on("vite:ws:disconnect", (_data, client) => {
43
+ const at = pages.indexOf(client);
44
+ if (at >= 0) pages.splice(at, 1);
45
+ });
46
+ socket.on(EVENTS.response, (data) => {
47
+ const resolve = pending.get(data.id);
48
+ if (resolve !== void 0) {
49
+ pending.delete(data.id);
50
+ resolve(data.response);
51
+ }
52
+ });
53
+ const relay = (message) => {
54
+ const page = pages[pages.length - 1];
55
+ const id = next++;
56
+ return new Promise((resolve) => {
57
+ const timer = setTimeout(() => {
58
+ pending.delete(id);
59
+ resolve(rpcError(message, -32001, `The page did not answer within ${Math.round(timeoutMs / 1e3)} seconds.`));
60
+ }, timeoutMs);
61
+ pending.set(id, (response) => {
62
+ clearTimeout(timer);
63
+ resolve(response);
64
+ });
65
+ page.send(EVENTS.request, {
66
+ id,
67
+ message
68
+ });
69
+ });
70
+ };
71
+ return {
72
+ get connected() {
73
+ return pages.length > 0;
74
+ },
75
+ middleware(request, response, next) {
76
+ if (request.url?.split("?")[0] !== "/__gesso/mcp") {
77
+ next();
78
+ return;
79
+ }
80
+ if (request.headers.origin !== void 0) {
81
+ send(response, 403, "text/plain", "The agent endpoint does not take requests from browser pages.");
82
+ return;
83
+ }
84
+ if (request.method !== "POST") {
85
+ response.setHeader("allow", "POST");
86
+ send(response, 405, "text/plain", "POST JSON-RPC messages here.");
87
+ return;
88
+ }
89
+ let body = "";
90
+ request.on("data", (chunk) => {
91
+ body += chunk.toString();
92
+ });
93
+ request.on("end", () => {
94
+ let message;
95
+ try {
96
+ message = JSON.parse(body);
97
+ } catch {
98
+ send(response, 400, "application/json", JSON.stringify(rpcError(null, -32700, "The body is not JSON.")));
99
+ return;
100
+ }
101
+ const notification = typeof message === "object" && message !== null && !("id" in message);
102
+ if (pages.length === 0) {
103
+ if (notification) {
104
+ send(response, 202);
105
+ return;
106
+ }
107
+ send(response, 200, "application/json", JSON.stringify(rpcError(message, -32e3, "No page is running the application. Open it in a browser from this dev server; the agent bridge runs in the page, because that is where the channels are.")));
108
+ return;
109
+ }
110
+ relay(message).then((answer) => {
111
+ if (answer === null || answer === void 0) send(response, 202);
112
+ else send(response, 200, "application/json", JSON.stringify(answer));
113
+ });
114
+ });
115
+ }
116
+ };
117
+ }
118
+ function send(response, status, type, body) {
119
+ response.statusCode = status;
120
+ if (type !== void 0) response.setHeader("content-type", type);
121
+ response.end(body);
122
+ }
123
+ function rpcError(message, code, text) {
124
+ const id = message?.id;
125
+ return {
126
+ jsonrpc: "2.0",
127
+ id: typeof id === "string" || typeof id === "number" ? id : null,
128
+ error: {
129
+ code,
130
+ message: text
131
+ }
132
+ };
133
+ }
134
+ /**
135
+ * What the shell module gets in a dev server: the app, handed to the
136
+ * page's half of the bridge once `gesso-framework/agent` has loaded.
137
+ * Loaded on demand, so the page pays for it only in development.
138
+ */
139
+ const SHELL_BRIDGE = `function __gessoAgent(app) {
140
+ if (typeof app.useWebMcp === 'function') {
141
+ // A single-thread builder: on by default here, as \`webmcp: true\`
142
+ // is for createApp, and a later useWebMcp(false) still decides.
143
+ app.useWebMcp(true);
144
+ }
145
+ if (import.meta.hot) {
146
+ void import('gesso-framework/agent').then(({ connectDevAgent }) => connectDevAgent(app, import.meta.hot));
147
+ }
148
+ return app;
149
+ }
150
+ `;
151
+ //#endregion
1
152
  //#region src/source.ts
2
153
  const IDENTIFIER = /[A-Za-z0-9_$]/;
3
154
  /**
@@ -233,6 +384,470 @@ function importedNames(clause) {
233
384
  return names;
234
385
  }
235
386
  //#endregion
387
+ //#region src/contracts.ts
388
+ /** The functions that declare a channel. */
389
+ const DECLARATIONS = ["channel", "defineChannel"];
390
+ /**
391
+ * Whether a module declares a channel, without asking the checker.
392
+ *
393
+ * The checker is a process round trip per question, and every module
394
+ * the application has passes through `transform`, so this is the gate:
395
+ * a call to `channel` or `defineChannel` imported from
396
+ * `gesso-framework`. A module that re-exports a token someone else
397
+ * declared is not a contract, and the module that declared it is the
398
+ * one described.
399
+ */
400
+ function declaresChannel(code) {
401
+ if (!code.includes("gesso-framework")) return false;
402
+ const blank = blankLiterals(code);
403
+ const imports = importSources(code, blank);
404
+ return DECLARATIONS.some((name) => imports.get(name) === "gesso-framework" && (findCalls(code, name, blank).length > 0 || new RegExp(`(?<![\\w$.])${name}\\s*<`).test(blank)));
405
+ }
406
+ /**
407
+ * The code appended to a contract module.
408
+ *
409
+ * An import at the bottom is still hoisted, and appending rather than
410
+ * rewriting leaves every line the author wrote where they wrote it, so
411
+ * the module's source map needs nothing.
412
+ */
413
+ function describeCalls(channels) {
414
+ const lines = ["import { describeChannel as __gessoDescribeChannel } from 'gesso-framework';"];
415
+ for (const [name, schema] of channels) lines.push(`__gessoDescribeChannel(${name}, ${JSON.stringify(schema)});`);
416
+ return `\n${lines.join("\n")}\n`;
417
+ }
418
+ /**
419
+ * A module that describes channels when it is imported, for a bundle
420
+ * this plugin never sees.
421
+ *
422
+ * The plugin describes a contract by appending to it as Vite bundles
423
+ * it. An Electrobun main process is bundled by Electrobun's own build,
424
+ * which takes no plugins, so there the description is written ahead of
425
+ * time instead: `gesso-channels` reads the contracts with the same
426
+ * checker and writes this module, and the main process imports it once.
427
+ * A token is imported from its contract, so the schema lands on the
428
+ * same object the application serves.
429
+ *
430
+ * `outFile` and every `file` are absolute POSIX paths.
431
+ */
432
+ function describedModule(contracts, outFile) {
433
+ const lines = [
434
+ "// Written by gesso-channels from the channel contracts it names below.",
435
+ "// Run it again after changing a contract rather than editing this file.",
436
+ "import { describeChannel } from 'gesso-framework';"
437
+ ];
438
+ const from = outFile.slice(0, outFile.lastIndexOf("/"));
439
+ for (const contract of contracts) if (contract.channels.size > 0) {
440
+ const names = [...contract.channels.keys()].join(", ");
441
+ lines.push(`import { ${names} } from '${importPath(from, contract.file)}';`);
442
+ }
443
+ for (const contract of contracts) for (const [name, schema] of contract.channels) lines.push("", `describeChannel(${name}, ${JSON.stringify(schema, null, 2)});`);
444
+ return `${lines.join("\n")}\n`;
445
+ }
446
+ /** `./Counter` from `/app/src/shared` to `/app/src/shared/Counter.ts`, without the extension. */
447
+ function importPath(fromDir, file) {
448
+ const from = fromDir.split("/").filter(Boolean);
449
+ const to = file.replace(/\.[cm]?[jt]sx?$/, "").split("/").filter(Boolean);
450
+ let shared = 0;
451
+ while (shared < from.length && shared < to.length - 1 && from[shared] === to[shared]) shared++;
452
+ const up = from.length - shared;
453
+ const rest = to.slice(shared).join("/");
454
+ return up === 0 ? `./${rest}` : `${"../".repeat(up)}${rest}`;
455
+ }
456
+ /**
457
+ * The checker, held open across reads.
458
+ *
459
+ * Spawning it and loading a project costs about a hundred
460
+ * milliseconds; a question after that costs well under one. So one
461
+ * reader lives as long as the dev server or the build, is told which
462
+ * files changed between reads, and is closed at the end, because the
463
+ * process it holds would otherwise keep the build from exiting.
464
+ */
465
+ var ContractReader = class {
466
+ api = null;
467
+ changed = /* @__PURE__ */ new Set();
468
+ /** Each contract's tsconfig, found once. */
469
+ configs = /* @__PURE__ */ new Map();
470
+ /**
471
+ * @param fs Callbacks the checker reads through before the disk, for
472
+ * a spec that keeps its project in memory.
473
+ */
474
+ ts;
475
+ cwd;
476
+ fs;
477
+ constructor(ts, cwd, fs) {
478
+ this.ts = ts;
479
+ this.cwd = cwd;
480
+ this.fs = fs;
481
+ }
482
+ /** A file changed on disk since the last read. */
483
+ invalidate(file) {
484
+ this.changed.add(file);
485
+ }
486
+ /** Every channel `file` exports, described. */
487
+ read(file) {
488
+ const api = this.api ??= new this.ts.API({
489
+ cwd: this.cwd,
490
+ ...this.fs === void 0 ? {} : { fs: this.fs }
491
+ });
492
+ this.changed.add(file);
493
+ let snapshot = api.updateSnapshot({ fileChanges: { changed: [...this.changed] } });
494
+ this.changed.clear();
495
+ let config = this.configs.get(file);
496
+ if (config === void 0) {
497
+ config = api.updateSnapshot({ openFiles: [file] }).getDefaultProjectForFile(file)?.configFileName;
498
+ snapshot = api.updateSnapshot({
499
+ ...config === void 0 ? {} : { openProjects: [config] },
500
+ closeFiles: [file]
501
+ });
502
+ if (config !== void 0) this.configs.set(file, config);
503
+ }
504
+ const project = config === void 0 ? void 0 : snapshot.getProject(config);
505
+ const source = project?.program.getSourceFile(file);
506
+ if (project === void 0 || source === void 0) return {
507
+ channels: /* @__PURE__ */ new Map(),
508
+ warnings: [`TypeScript has no project that includes ${file}.`]
509
+ };
510
+ const checker = project.checker;
511
+ const module = checker.getSymbolAtLocation(source);
512
+ const describer = new Describer(this.ts, checker);
513
+ const channels = /* @__PURE__ */ new Map();
514
+ for (const exported of module === void 0 ? [] : checker.getExportsOfModule(module)) {
515
+ const type = checker.getTypeOfSymbol(exported);
516
+ if (type === void 0 || type.getSymbol()?.name !== "ChannelToken") continue;
517
+ const [view, commands] = checker.getTypeArguments(type);
518
+ channels.set(exported.name, describer.channel(exported, view, commands));
519
+ }
520
+ return {
521
+ channels,
522
+ warnings: describer.warnings
523
+ };
524
+ }
525
+ close() {
526
+ this.api?.close();
527
+ this.api = null;
528
+ }
529
+ };
530
+ /**
531
+ * Library types that are objects to the checker and not plain data to
532
+ * a channel. They compare by reference, so `provide` rejects them on
533
+ * their first value; describing their methods as properties would be
534
+ * describing something that never arrives.
535
+ */
536
+ const NOT_PLAIN = /* @__PURE__ */ new Set([
537
+ "Date",
538
+ "Map",
539
+ "ReadonlyMap",
540
+ "Set",
541
+ "ReadonlySet",
542
+ "WeakMap",
543
+ "WeakSet",
544
+ "Promise",
545
+ "RegExp",
546
+ "Error",
547
+ "ArrayBuffer",
548
+ "SharedArrayBuffer",
549
+ "DataView",
550
+ "Blob",
551
+ "File",
552
+ "URL",
553
+ "Int8Array",
554
+ "Uint8Array",
555
+ "Uint8ClampedArray",
556
+ "Int16Array",
557
+ "Uint16Array",
558
+ "Int32Array",
559
+ "Uint32Array",
560
+ "Float32Array",
561
+ "Float64Array",
562
+ "BigInt64Array",
563
+ "BigUint64Array"
564
+ ]);
565
+ /**
566
+ * Bytes, which a command may carry and a view key may not.
567
+ *
568
+ * A command's argument is structured-cloned onto the owning thread, and
569
+ * a typed array or an `ArrayBuffer` clones as itself: a file a person
570
+ * dropped crosses as its bytes, with no encoding pass on the render
571
+ * thread. A view key is diffed, and a differ that compares bytes by
572
+ * reference reports a change on every emission, so there they stay
573
+ * unplain. In a command's schema they are a base64 string, which is
574
+ * what an agent can write, tagged with the type the command expects so
575
+ * the agent surface can turn the string back into it.
576
+ */
577
+ const BINARY = /* @__PURE__ */ new Set([
578
+ "ArrayBuffer",
579
+ "Int8Array",
580
+ "Uint8Array",
581
+ "Uint8ClampedArray",
582
+ "Int16Array",
583
+ "Uint16Array",
584
+ "Int32Array",
585
+ "Uint32Array",
586
+ "Float32Array",
587
+ "Float64Array",
588
+ "BigInt64Array",
589
+ "BigUint64Array"
590
+ ]);
591
+ /** Turns checker types into JSON Schema for one module's channels. */
592
+ var Describer = class {
593
+ warnings = [];
594
+ flags;
595
+ /** Describing a command's parameters, where bytes are allowed; see `BINARY`. */
596
+ inCommand = false;
597
+ /** Types being described right now, to notice a type that contains itself. */
598
+ inProgress = /* @__PURE__ */ new Set();
599
+ /** Types found to contain themselves, which are described once in `$defs`. */
600
+ recursive = /* @__PURE__ */ new Set();
601
+ /** `$defs` for the schema being built. Reset per root, so each schema stands alone. */
602
+ defs = /* @__PURE__ */ new Map();
603
+ defNames = /* @__PURE__ */ new Map();
604
+ ts;
605
+ checker;
606
+ constructor(ts, checker) {
607
+ this.ts = ts;
608
+ this.checker = checker;
609
+ this.flags = ts.TypeFlags;
610
+ }
611
+ channel(token, view, commands) {
612
+ const out = {
613
+ view: this.root(view, `${token.name}.view`),
614
+ commands: {}
615
+ };
616
+ const description = this.checker.getDocumentationCommentOfSymbol(token);
617
+ if (description !== "") out.description = description;
618
+ if (commands !== void 0) for (const command of this.checker.getPropertiesOfType(commands)) {
619
+ const described = this.command(command, `${token.name}.commands.${command.name}`);
620
+ if (described !== null) out.commands[command.name] = described;
621
+ }
622
+ return out;
623
+ }
624
+ /** A schema that stands alone: its own `$defs`, if anything in it recurs. */
625
+ root(type, path) {
626
+ this.defs = /* @__PURE__ */ new Map();
627
+ const schema = this.describe(type, path);
628
+ if (this.defs.size > 0) return {
629
+ ...schema,
630
+ $defs: Object.fromEntries(this.defs)
631
+ };
632
+ return schema;
633
+ }
634
+ command(symbol, path) {
635
+ const type = this.checker.getTypeOfSymbol(symbol);
636
+ const signatures = type === void 0 ? [] : this.checker.getSignaturesOfType(type, this.ts.SignatureKind.Call);
637
+ if (signatures.length === 0) {
638
+ this.warnings.push(`${path} is not a function, so it is not a command an agent can send.`);
639
+ return null;
640
+ }
641
+ if (signatures.length > 1) this.warnings.push(`${path} is overloaded; only its first signature is described.`);
642
+ const signature = signatures[0];
643
+ const tags = this.checker.getJsDocTagsOfSymbol(symbol);
644
+ const parameters = signature.getParameters();
645
+ this.defs = /* @__PURE__ */ new Map();
646
+ const properties = {};
647
+ const required = [];
648
+ this.inCommand = true;
649
+ parameters.forEach((parameter, index) => {
650
+ const parameterType = this.checker.getTypeOfSymbol(parameter);
651
+ const optional = parameterType !== void 0 && this.includesUndefined(parameterType);
652
+ const schema = parameterType === void 0 ? {} : this.describe(parameterType, `${path}(${parameter.name})`);
653
+ const description = this.parameterDescription(parameter, tags);
654
+ properties[parameter.name] = description === "" ? schema : {
655
+ ...schema,
656
+ description
657
+ };
658
+ const rest = signature.hasRestParameter && index === parameters.length - 1;
659
+ if (!optional && !rest) required.push(parameter.name);
660
+ });
661
+ this.inCommand = false;
662
+ const input = {
663
+ type: "object",
664
+ properties,
665
+ additionalProperties: false
666
+ };
667
+ if (required.length > 0) input.required = required;
668
+ if (this.defs.size > 0) input.$defs = Object.fromEntries(this.defs);
669
+ const out = {
670
+ parameters: parameters.map((parameter) => parameter.name),
671
+ input
672
+ };
673
+ const description = this.checker.getDocumentationCommentOfSymbol(symbol);
674
+ if (description !== "") out.description = description;
675
+ if (signature.hasRestParameter) out.rest = true;
676
+ for (const annotation of [
677
+ "destructive",
678
+ "idempotent",
679
+ "confirm",
680
+ "hidden"
681
+ ]) if (tags.some((tag) => tag.name === annotation)) out[annotation] = true;
682
+ return out;
683
+ }
684
+ /**
685
+ * A parameter's own comment, or failing that its `@param` line.
686
+ *
687
+ * The checker attaches `@param` text to the parameter of a method
688
+ * signature. A command written as a property, `move: (from: number)
689
+ * => void`, keeps its comment on the property, so the tag is read
690
+ * from there instead.
691
+ */
692
+ parameterDescription(parameter, tags) {
693
+ const own = this.checker.getDocumentationCommentOfSymbol(parameter);
694
+ if (own !== "") return own;
695
+ for (const tag of tags) {
696
+ const match = tag.name === "param" ? /^\s*(\S+)\s*(?:-\s*)?([\s\S]*)$/.exec(tag.text ?? "") : null;
697
+ if (match !== null && match[1] === parameter.name) return match[2].trim();
698
+ }
699
+ return "";
700
+ }
701
+ includesUndefined(type) {
702
+ const { Undefined, Void } = this.flags;
703
+ if (type.isUnionType()) return (type.getTypes() ?? []).some((member) => (member.flags & (Undefined | Void)) !== 0);
704
+ return (type.flags & (Undefined | Void)) !== 0;
705
+ }
706
+ describe(type, path) {
707
+ const f = this.flags;
708
+ const flags = type.flags;
709
+ if (flags & (f.Any | f.Unknown)) return {};
710
+ if (flags & f.Never) {
711
+ 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[]\`.`);
712
+ return { not: {} };
713
+ }
714
+ if (flags & (f.String | f.TemplateLiteral | f.StringMapping)) return { type: "string" };
715
+ if (flags & f.Number) return { type: "number" };
716
+ if (flags & f.Boolean) return { type: "boolean" };
717
+ if (flags & f.Null) return { type: "null" };
718
+ if (flags & (f.Undefined | f.Void)) return {};
719
+ if (flags & f.BooleanLiteral) return { const: type.intrinsicName === "true" };
720
+ if (flags & (f.StringLiteral | f.NumberLiteral)) return { const: type.value };
721
+ if (flags & (f.BigInt | f.BigIntLiteral | f.ESSymbol | f.UniqueESSymbol)) return this.unplain(path, flags & f.BigIntLike ? "a bigint" : "a symbol");
722
+ if (type.isUnionType()) return this.union(type, path);
723
+ if (flags & f.TypeParameter) return {};
724
+ if (flags & (f.Object | f.Intersection)) {
725
+ if (this.checker.isArrayType(type)) {
726
+ const [element] = this.checker.getTypeArguments(type);
727
+ return {
728
+ type: "array",
729
+ items: element === void 0 ? {} : this.describe(element, `${path}[]`)
730
+ };
731
+ }
732
+ if (this.checker.isTupleType(type)) return this.tuple(type, path);
733
+ return this.named(type, path);
734
+ }
735
+ this.warnings.push(`${path} has a type this reader does not describe, so it is left open.`);
736
+ return {};
737
+ }
738
+ /**
739
+ * A union, with `undefined` taken out (optionality is said by
740
+ * `required`), `true | false` folded back into `boolean`, and a union
741
+ * of nothing but literals written as an `enum`.
742
+ */
743
+ union(type, path) {
744
+ const f = this.flags;
745
+ let members = (type.getTypes() ?? []).filter((member) => (member.flags & (f.Undefined | f.Void)) === 0);
746
+ const booleans = members.filter((member) => member.flags & f.BooleanLiteral);
747
+ const schemas = [];
748
+ if (booleans.length === 2) {
749
+ members = members.filter((member) => !(member.flags & f.BooleanLiteral));
750
+ schemas.push({ type: "boolean" });
751
+ }
752
+ if (members.length > 0 && schemas.length === 0 && members.every((member) => member.flags & (f.StringLiteral | f.NumberLiteral))) return { enum: members.map((member) => member.value) };
753
+ members.sort((a, b) => Number((a.flags & f.Null) !== 0) - Number((b.flags & f.Null) !== 0));
754
+ schemas.push(...members.map((member) => this.describe(member, path)));
755
+ return schemas.length === 1 ? schemas[0] : { anyOf: schemas };
756
+ }
757
+ /**
758
+ * An object type, described once however often it appears, and by
759
+ * reference when it contains itself.
760
+ */
761
+ named(type, path) {
762
+ const id = type.id;
763
+ if (this.inProgress.has(id)) {
764
+ this.recursive.add(id);
765
+ return { $ref: `#/$defs/${this.defName(type)}` };
766
+ }
767
+ this.inProgress.add(id);
768
+ const schema = this.object(type, path);
769
+ this.inProgress.delete(id);
770
+ if (this.recursive.has(id)) {
771
+ const name = this.defName(type);
772
+ this.defs.set(name, schema);
773
+ return { $ref: `#/$defs/${name}` };
774
+ }
775
+ return schema;
776
+ }
777
+ defName(type) {
778
+ let name = this.defNames.get(type.id);
779
+ if (name === void 0) {
780
+ const base = (type.getAliasSymbol() ?? type.getSymbol())?.name ?? "Type";
781
+ name = new Set(this.defNames.values()).has(base) ? `${base}${type.id}` : base;
782
+ this.defNames.set(type.id, name);
783
+ }
784
+ return name;
785
+ }
786
+ object(type, path) {
787
+ const checker = this.checker;
788
+ const symbolName = type.getSymbol()?.name;
789
+ if (symbolName !== void 0 && this.inCommand && BINARY.has(symbolName)) return {
790
+ type: "string",
791
+ contentEncoding: "base64",
792
+ "x-gesso-binary": symbolName
793
+ };
794
+ if (symbolName !== void 0 && NOT_PLAIN.has(symbolName)) return this.unplain(path, `a ${symbolName}`);
795
+ if (type.objectFlags & this.ts.ObjectFlags.Class) return this.unplain(path, `an instance of ${symbolName ?? "a class"}`);
796
+ if (checker.getSignaturesOfType(type, this.ts.SignatureKind.Call).length > 0) return this.unplain(path, "a function");
797
+ const properties = {};
798
+ const required = [];
799
+ for (const property of checker.getPropertiesOfType(type)) {
800
+ const propertyType = checker.getTypeOfSymbol(property);
801
+ const at = `${path}.${property.name}`;
802
+ if (propertyType === void 0) {
803
+ properties[property.name] = {};
804
+ continue;
805
+ }
806
+ const schema = this.describe(propertyType, at);
807
+ const description = checker.getDocumentationCommentOfSymbol(property);
808
+ properties[property.name] = description === "" ? schema : {
809
+ ...schema,
810
+ description
811
+ };
812
+ if (!((property.flags & this.ts.SymbolFlags.Optional) !== 0 || this.includesUndefined(propertyType))) required.push(property.name);
813
+ }
814
+ const schema = {
815
+ type: "object",
816
+ properties
817
+ };
818
+ if (required.length > 0) schema.required = required;
819
+ const index = checker.getIndexInfosOfType(type).find((info) => info.keyType.flags & this.flags.String);
820
+ if (index !== void 0) schema.additionalProperties = this.describe(index.valueType, `${path}[key]`);
821
+ return schema;
822
+ }
823
+ tuple(type, path) {
824
+ const elements = this.checker.getTypeArguments(type);
825
+ const elementFlags = type.getTarget().elementFlags ?? [];
826
+ const { Required, Rest } = this.ts.ElementFlags;
827
+ const prefixItems = [];
828
+ let items = false;
829
+ let minItems = 0;
830
+ elements.forEach((element, index) => {
831
+ const schema = this.describe(element, `${path}[${index}]`);
832
+ if (elementFlags[index] & Rest) items = schema;
833
+ else {
834
+ prefixItems.push(schema);
835
+ if (elementFlags[index] & Required) minItems = index + 1;
836
+ }
837
+ });
838
+ return {
839
+ type: "array",
840
+ prefixItems,
841
+ items,
842
+ minItems
843
+ };
844
+ }
845
+ unplain(path, what) {
846
+ this.warnings.push(`${path} is ${what}, which is not plain data and cannot cross a channel.`);
847
+ return {};
848
+ }
849
+ };
850
+ //#endregion
236
851
  //#region src/render.ts
237
852
  const MARKER$1 = "/* gesso-vite-plugin: hot replacement */";
238
853
  /**
@@ -368,10 +983,12 @@ function findShellCall(code, blank = blankLiterals(code)) {
368
983
  function transformShell(code, options) {
369
984
  const shell = findShellCall(code, blankLiterals(code));
370
985
  if (shell === null) return null;
371
- if (options.entries === null && !options.overlay) return null;
986
+ if (options.entries === null && !options.overlay && options.agent !== true) return null;
372
987
  const { call } = shell;
373
988
  const args = code.slice(call.argsStart, call.argsEnd).trim();
374
- return `${`${code.slice(0, call.argsStart)}__gessoOptions(${args === "" ? "{}" : args})${code.slice(call.argsEnd)}`}\n${prelude(options)}`;
989
+ const created = `${code.slice(call.start, call.argsStart)}__gessoOptions(${args === "" ? "{}" : args})${code.slice(call.argsEnd, call.end)}`;
990
+ const wrapped = options.agent === true ? `__gessoAgent(${created})` : created;
991
+ return `${`${code.slice(0, call.start)}${wrapped}${code.slice(call.end)}`}\n${prelude(options)}${options.agent === true ? SHELL_BRIDGE : ""}`;
375
992
  }
376
993
  /**
377
994
  * The appended half: the worker construction, and in development the
@@ -394,6 +1011,7 @@ function prelude(options) {
394
1011
  ...entries === null ? [] : [` renderWorker: () => new Worker(new URL(${JSON.stringify(entries.renderWorker)}, import.meta.url), { type: 'module', name: options.workerName }),`],
395
1012
  ...entries?.appLogicWorker == null ? [] : [` appLogicWorker: () => new Worker(new URL(${JSON.stringify(entries.appLogicWorker)}, import.meta.url), { type: 'module', name: options.workerName }),`],
396
1013
  ...options.overlay ? [" onError: __gessoReportError,"] : [],
1014
+ ...options.agent === true ? [" webmcp: true,"] : [],
397
1015
  " ...options",
398
1016
  " };",
399
1017
  "}"
@@ -452,9 +1070,42 @@ addEventListener('unhandledrejection', event => {
452
1070
  __gessoReportError(reason?.message ?? String(reason), reason?.stack, 'window');
453
1071
  });
454
1072
  `;
1073
+ /**
1074
+ * The single-thread shell in a dev server: `createSyncApp(Root)`
1075
+ * wrapped so the agent bridge gets the builder, which answers an agent
1076
+ * port from the page itself. There are no workers to write and no
1077
+ * overlay to wire here, so this is all the plugin does to such a shell.
1078
+ */
1079
+ function transformSyncShell(code) {
1080
+ if (code.includes(MARKER)) return null;
1081
+ const blank = blankLiterals(code);
1082
+ if (importSources(code, blank).get("createSyncApp") !== "gesso-framework") return null;
1083
+ const call = findCall(code, "createSyncApp", blank);
1084
+ if (call === null) return null;
1085
+ const wrapped = `__gessoAgent(${code.slice(call.start, call.end)})`;
1086
+ return `${code.slice(0, call.start)}${wrapped}${code.slice(call.end)}\n${MARKER}\n${SHELL_BRIDGE}`;
1087
+ }
455
1088
  //#endregion
456
1089
  //#region src/index.ts
457
1090
  /**
1091
+ * The export conditions a Gesso application resolves its dependencies
1092
+ * with: `worker` ahead of Vite's defaults.
1093
+ *
1094
+ * A Gesso application's code runs in workers, and Vite resolves every
1095
+ * dependency with the `browser` condition, which is the right call for
1096
+ * a page and the wrong one for a worker: a package's browser build may
1097
+ * reach for `document`. `decode-named-character-reference`, which every
1098
+ * markdown parser built on micromark imports, does exactly that, and a
1099
+ * render worker that imported one died on start with "document is not
1100
+ * defined". The same package publishes a `worker` build, as packages
1101
+ * with a DOM-only browser build tend to. A package's own export order
1102
+ * decides between the two, and those list `worker` first.
1103
+ *
1104
+ * The main thread resolves the same way. A worker build runs in a page
1105
+ * as well, and the shell imports little beyond Gesso itself.
1106
+ */
1107
+ const workerConditions = ["worker", ...defaultClientConditions];
1108
+ /**
458
1109
  * Where a render worker entry is looked for, beside the shell.
459
1110
  *
460
1111
  * Ordered so that a name that says what the file is beats the generic
@@ -486,14 +1137,99 @@ function gesso(options = {}) {
486
1137
  let renderWorkerId = null;
487
1138
  /** Files already reported as reloading, so a save is not a stream of warnings. */
488
1139
  const reported = /* @__PURE__ */ new Set();
1140
+ /** The checker, started by the first contract, or null once it is known to be unavailable. */
1141
+ let contracts;
1142
+ let root = "";
1143
+ const readerFor = (warn) => contracts ??= import("typescript/unstable/sync").then((ts) => new ContractReader(ts, root), () => {
1144
+ warn("Channels are not described, because TypeScript 7 is not installed. Add `typescript` (7 or later) to devDependencies, or pass `channelSchemas: false` to stop this message.");
1145
+ return null;
1146
+ });
1147
+ const closeContracts = async () => {
1148
+ (await contracts)?.close();
1149
+ contracts = void 0;
1150
+ };
1151
+ /** The module with its channels described, or null when it declares none. */
1152
+ const describeContract = async (code, id, warn) => {
1153
+ if (options.channelSchemas === false || !SOURCE.test(id) || id.includes("/node_modules/") || !declaresChannel(code)) return null;
1154
+ const reader = await readerFor(warn);
1155
+ if (reader === null) return null;
1156
+ const reading = reader.read(id.replace(/\?.*$/, ""));
1157
+ reading.warnings.forEach(warn);
1158
+ return reading.channels.size > 0 ? code + describeCalls(reading.channels) : null;
1159
+ };
1160
+ /**
1161
+ * The same description, for a worker's bundle.
1162
+ *
1163
+ * A build bundles each worker with `worker.plugins` rather than with
1164
+ * the plugins of the page, and a contract is imported by workers far
1165
+ * more often than by the page, so without this a build would describe
1166
+ * almost nothing. A dev server runs every module through the main
1167
+ * plugins and does not need it. Each worker bundle closes the checker
1168
+ * when it is done, because nothing guarantees another bundle follows
1169
+ * to close it; the next contract starts it again.
1170
+ */
1171
+ const workerContracts = {
1172
+ name: "gesso:channel-schemas",
1173
+ enforce: "pre",
1174
+ async transform(code, id) {
1175
+ const described = await describeContract(code, id, (message) => this.warn(message));
1176
+ return described === null ? null : {
1177
+ code: described,
1178
+ map: null
1179
+ };
1180
+ },
1181
+ async closeBundle() {
1182
+ await closeContracts();
1183
+ }
1184
+ };
489
1185
  return {
490
1186
  name: "gesso",
491
1187
  enforce: "pre",
1188
+ config(config) {
1189
+ return {
1190
+ ...options.workerConditions === false ? {} : { resolve: { conditions: [...workerConditions] } },
1191
+ worker: {
1192
+ ...config.worker?.format === void 0 ? { format: "es" } : {},
1193
+ ...options.channelSchemas === false ? {} : { plugins: () => [workerContracts] }
1194
+ }
1195
+ };
1196
+ },
492
1197
  configResolved(config) {
493
1198
  serving = config.command === "serve";
1199
+ root = config.root;
494
1200
  },
495
- async transform(code, id) {
1201
+ /**
1202
+ * The agent endpoint, and a line saying where it is once the server
1203
+ * is listening, because an endpoint nobody knows the address of is
1204
+ * one nobody connects to.
1205
+ */
1206
+ configureServer(server) {
1207
+ if (options.agent === false) return;
1208
+ const bridge = createAgentBridge(server.ws);
1209
+ server.middlewares.use((request, response, next) => bridge.middleware(request, response, next));
1210
+ server.httpServer?.once("listening", () => {
1211
+ const base = server.resolvedUrls?.local[0];
1212
+ if (base !== void 0) {
1213
+ const url = new URL(AGENT_PATH, base).href;
1214
+ server.config.logger.info(` ➜ Agents: ${url}\n claude mcp add --transport http ${agentName(server.config.root)} ${url}`);
1215
+ }
1216
+ });
1217
+ },
1218
+ watchChange(id) {
1219
+ contracts?.then((reader) => reader?.invalidate(id));
1220
+ },
1221
+ /**
1222
+ * The checker is a child process, and a build that left it running
1223
+ * would never exit. A dev server calls this when it closes, so the
1224
+ * one hook covers both.
1225
+ */
1226
+ async closeBundle() {
1227
+ await closeContracts();
1228
+ },
1229
+ async transform(source, id) {
496
1230
  if (!SOURCE.test(id) || id.includes("/node_modules/")) return null;
1231
+ const described = await describeContract(source, id, (message) => this.warn(message));
1232
+ const code = described ?? source;
497
1233
  if (serving && options.hmr !== false) {
498
1234
  const wiring = transformRenderWorker(code);
499
1235
  if (wiring.skipped !== null) this.warn(`${wiring.skipped} Saving it will reload the page rather than replace the tree.`);
@@ -506,13 +1242,27 @@ function gesso(options = {}) {
506
1242
  }
507
1243
  }
508
1244
  const call = findShellCall(code);
509
- if (call === null) return null;
1245
+ if (call === null) {
1246
+ const sync = serving && options.agent !== false ? transformSyncShell(code) : null;
1247
+ if (sync !== null) return {
1248
+ code: sync,
1249
+ map: null
1250
+ };
1251
+ return described === null ? null : {
1252
+ code,
1253
+ map: null
1254
+ };
1255
+ }
510
1256
  shellId = id;
511
1257
  const shell = transformShell(code, {
512
1258
  entries: call.needsWorkers ? await resolveEntries(this, id, options) : null,
513
- overlay: serving && options.overlay !== false
1259
+ overlay: serving && options.overlay !== false,
1260
+ agent: serving && options.agent !== false
514
1261
  });
515
- return shell === null ? null : {
1262
+ return shell === null ? described === null ? null : {
1263
+ code,
1264
+ map: null
1265
+ } : {
516
1266
  code: shell,
517
1267
  map: null
518
1268
  };
@@ -539,6 +1289,10 @@ function gesso(options = {}) {
539
1289
  }
540
1290
  };
541
1291
  }
1292
+ /** A name for the MCP server: the project's directory, which is what a person calls the app. */
1293
+ function agentName(root) {
1294
+ return (root.split("/").filter(Boolean).pop() ?? "gesso").replace(/[^A-Za-z0-9_-]/g, "-");
1295
+ }
542
1296
  /** Every module that transitively imports this one, plus this one. */
543
1297
  function importerClosure(module) {
544
1298
  const seen = /* @__PURE__ */ new Set();
@@ -579,6 +1333,6 @@ async function firstResolved(context, importer, names) {
579
1333
  return null;
580
1334
  }
581
1335
  //#endregion
582
- export { blankLiterals, gesso as default, gesso, findCall, findCalls, findShellCall, firstArgumentName, importSources, transformRenderWorker, transformShell };
1336
+ export { ContractReader, blankLiterals, gesso as default, gesso, describedModule, findCall, findCalls, findShellCall, firstArgumentName, importSources, transformRenderWorker, transformShell, transformSyncShell, workerConditions };
583
1337
 
584
1338
  //# sourceMappingURL=index.js.map