@waniwani/kit 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -250,6 +250,8 @@ oney/
250
250
  │ └── ui.tsx export default function Component()
251
251
  ├── flows/
252
252
  │ └── split-payment.ts export default createFlow(...).compile() ← SDK
253
+ ├── api/
254
+ │ └── cal/slots.ts export default defineEndpoint({ ..., handler })
253
255
  └── lib/ anything else is just modules
254
256
  ```
255
257
 
@@ -263,6 +265,7 @@ unwired.
263
265
  | `tools/<name>.ts` | one MCP tool | `.ts`, `.tsx` and `.mts` are picked up |
264
266
  | `widgets/<name>/` | one MCP tool plus a `ui://` resource | needs `widget.ts` and `ui.tsx` |
265
267
  | `flows/<name>.ts` | one MCP tool, registered from the SDK unchanged | whatever `.compile()` returns |
268
+ | `api/<path>.ts` | one HTTP endpoint at `/api/<path>` | for the browser, invisible to the model |
266
269
  | anything else | plain modules | the CLI leaves it alone |
267
270
 
268
271
  The app folder imports `@waniwani/kit`, plus `@waniwani/sdk` when it uses flows,
@@ -327,6 +330,70 @@ export default createFlow({ id: "split_payment", title, description, state })
327
330
  `showWidget({ tool: "select-plan" })` names a widget by its folder name, and the
328
331
  build check verifies that the folder exists.
329
332
 
333
+ ### The api/ folder is for the browser
334
+
335
+ A widget runs in an iframe on another origin, and it can call its own server
336
+ without going through the model at all. Booking a slot, loading a calendar,
337
+ receiving a webhook: `api/` is where those live. The path comes from the file's
338
+ position, the folder name included, so there is nothing to keep in step with the
339
+ `fetch()` on the other side:
340
+
341
+ ```
342
+ api/cal/slots.ts → /api/cal/slots
343
+ api/webhooks/stripe.ts → /api/webhooks/stripe
344
+ api/cal/index.ts → /api/cal
345
+ ```
346
+
347
+ ```ts
348
+ // api/cal/slots.ts
349
+ import { defineEndpoint } from "@waniwani/kit";
350
+ import { fetchCalSlots } from "../../lib/cal.js";
351
+
352
+ export default defineEndpoint({
353
+ method: "post",
354
+ handler: async (req, res) => {
355
+ const { timeZone } = req.body;
356
+ res.json({ slots: await fetchCalSlots(regionFor(timeZone)) });
357
+ },
358
+ });
359
+ ```
360
+
361
+ The widget reaches it at the origin the host hands the view, which is the dev
362
+ port locally and the deployed origin inside ChatGPT or Claude:
363
+
364
+ ```tsx
365
+ const apiUrl = (path: string) => `${window.skybridge?.serverUrl ?? ""}${path}`;
366
+ const response = await fetch(apiUrl("/api/cal/slots"), { method: "POST", body });
367
+ ```
368
+
369
+ Four things arrive with every endpoint, so no app writes them:
370
+
371
+ | | what the runtime does |
372
+ |---|---|
373
+ | **CORS** | on by default, preflight included, advertising the methods `method` declares and no others |
374
+ | **JSON body** | `express.json()`, because the framework installs no parser of its own and `req.body` would be `undefined` |
375
+ | **method guard** | anything outside `method` gets a 405 and an `Allow` header, instead of reaching a handler written for a POST |
376
+ | **errors** | a handler that throws answers JSON with the message, logged as `[waniwani] endpoint "/api/..." failed`, so a `fetch()` waiting on JSON never receives an HTML error page |
377
+
378
+ `cors: false` and `json: false` opt out of the first two. Leaving `method` off
379
+ accepts every method.
380
+
381
+ **Reach for a tool instead when the model is the caller.** An endpoint appears in
382
+ no `tools/list`, costs no turn and no tokens, and the model cannot see that it
383
+ happened. That is what suits a calendar the widget paints for itself, and what
384
+ rules it out for anything the model has to reason about or quote back.
385
+
386
+ Endpoints share the process with `/mcp`, so `lib/` is one set of modules for
387
+ both, and the build check prints what it mounted:
388
+
389
+ ```
390
+ ✓ Build check passed — 1 widget, 1 flow, 2 endpoints
391
+ widget show-book-call
392
+ flow demo-qualification
393
+ api /api/cal/book
394
+ api /api/cal/slots
395
+ ```
396
+
330
397
  ### Styling is Tailwind, and only Tailwind
331
398
 
332
399
  A widget styles itself with utility classes in its `ui.tsx`. There is no
@@ -421,6 +488,7 @@ flowchart LR
421
488
  tools["tools/*.ts"]
422
489
  widgets["widgets/&lt;name&gt;/<br/>widget.ts + ui.tsx"]
423
490
  flows["flows/*.ts"]
491
+ api["api/**/*.ts"]
424
492
  end
425
493
 
426
494
  subgraph tpl["WaniWani-AI/mcp-distribution-template (public, separate repo)"]
@@ -461,6 +529,76 @@ flowchart LR
461
529
  project carrying a `vercel.json`, which is what lets `vercel deploy` inside it
462
530
  work with no special support.
463
531
 
532
+ ### Deploying is a git push
533
+
534
+ `waniwani build` writes a Vercel Build Output tree inside `.waniwani/`: the
535
+ bundled function, the static assets, the routing config. A git-connected project
536
+ builds that tree itself on push, and the first build writes the config it needs
537
+ into the app repo:
538
+
539
+ ```json
540
+ // vercel.json, generated once, yours to edit afterwards
541
+ {
542
+ "framework": null,
543
+ "buildCommand": "waniwani build && rm -rf .vercel/output && cp -R .waniwani/.vercel/output .vercel/output",
544
+ "routes": [{ "src": "/api(/.*)?", "dest": "/mcp" }]
545
+ }
546
+ ```
547
+
548
+ Each line answers something Vercel would otherwise get wrong.
549
+
550
+ `framework: null` stops the project's preset from hunting for a dependency the
551
+ repo does not have, which is what produces `No Next.js version detected` on a
552
+ repo holding no framework at all.
553
+
554
+ The `buildCommand` moves the tree from `.waniwani/`, which is gitignored and
555
+ absent from the clone, up to the one path where Vercel adopts the Build Output
556
+ API and serves the function as built.
557
+
558
+ The `routes` entry exists because Vercel reserves a root `api/` directory: it
559
+ compiles every file under one into a serverless function of its own, and an
560
+ endpoint module is not a Vercel handler. That entry is emitted ahead of Vercel's
561
+ filesystem layer, so `/api/*` reaches the server the kit built and the functions
562
+ Vercel made are never routed to. Deleting the directory during the build is not
563
+ an alternative, since the file list is read before the build command runs:
564
+
565
+ ```
566
+ Error: File not found: /vercel/path0/api/cal/book.ts
567
+ ```
568
+
569
+ Deploying without a build works too, once `build` has run:
570
+
571
+ ```bash
572
+ cd .waniwani && vercel deploy --prebuilt
573
+ ```
574
+
575
+ Environment variables live on the platform for both, since `.env` is read from
576
+ disk and a hosted build has no such file. A project that sets its variables for
577
+ production alone gets previews with none, which for an app whose flow reads
578
+ `WANIWANI_API_KEY` at import time means a function that fails to boot.
579
+
580
+ ### Secrets live in the app's .env
581
+
582
+ `.env` and `.env.local` sit next to `waniwani.config.ts`, and every command reads
583
+ them before it runs anything. A variable already exported in the shell or set by
584
+ CI wins over both files, and a hosted deploy sets its variables on the platform
585
+ and reads no file at all.
586
+
587
+ Loading them this early is what lets a module build its client at import time:
588
+
589
+ ```ts
590
+ // lib/waniwani.ts
591
+ export const wani = waniwani({ apiKey: process.env.WANIWANI_API_KEY });
592
+ ```
593
+
594
+ The generated project runs from `.waniwani/`, one level below the file, and a
595
+ module's imports are evaluated before any line of the module that pulled it in,
596
+ so neither `dotenv/config` nor a load inside generated code arrives in time.
597
+ `waniwani check` reads the same files for the same reason: it imports every
598
+ server-safe module for real, and a flow whose store comes from
599
+ `WANIWANI_API_KEY` would otherwise fail its own build check over a variable
600
+ sitting in the file next to it.
601
+
464
602
  ### What the build check catches
465
603
 
466
604
  Errors that would otherwise surface as a 500 at request time, or as a widget
package/cli/codegen.mjs CHANGED
@@ -590,7 +590,7 @@ function templateStyleDomains(template) {
590
590
 
591
591
  // ------------------------------------------------------------ generated files
592
592
 
593
- function generateServerApp(app, layout, { runtime, styleDomains }) {
593
+ function generateServerApp(app, layout, { runtime, styleDomains, version }) {
594
594
  const from = appFrom(layout);
595
595
 
596
596
  const imports = [
@@ -603,6 +603,9 @@ function generateServerApp(app, layout, { runtime, styleDomains }) {
603
603
  (w) => `import widget_${camel(w.name)} from "${from}/widgets/${w.name}/widget.js";`,
604
604
  ),
605
605
  ...app.flows.map((f) => `import flow_${camel(f.name)} from "${from}/flows/${f.name}.js";`),
606
+ ...app.endpoints.map(
607
+ (e) => `import endpoint_${camel(e.segments.join("-"))} from "${from}/api/${e.segments.join("/")}.js";`,
608
+ ),
606
609
  ].filter(Boolean);
607
610
 
608
611
  const list = (items) => (items.length === 0 ? "[]" : `[\n\t\t${items.join(",\n\t\t")},\n\t]`);
@@ -615,10 +618,12 @@ ${imports.join("\n")}
615
618
  // on whether this is a generated build or an ejected project.
616
619
  loadEnv({ path: ["../.env", ".env"], quiet: true });
617
620
 
621
+ // The version the app's package.json carries is the fallback, so a bumped
622
+ // release shows up in the connector UI without a second edit here.
618
623
  export const app = {
619
624
  name: config.name,
620
625
  title: config.title,
621
- version: config.version ?? "0.0.0",
626
+ version: config.version ?? ${JSON.stringify(version ?? "0.0.0")},
622
627
  instructions: config.instructions,
623
628
  };
624
629
 
@@ -627,6 +632,13 @@ export async function registerApp(server: McpServer): Promise<void> {
627
632
  tools: ${list(app.tools.map((t) => `{ name: "${t.name}", def: tool_${camel(t.name)} }`))},
628
633
  widgets: ${list(app.widgets.map((w) => `{ name: "${w.name}", def: widget_${camel(w.name)} }`))},
629
634
  flows: ${list(app.flows.map((f) => `flow_${camel(f.name)}`))},
635
+ // Served by the same Express app as /mcp, at the path each file's position
636
+ // produced. For the browser — a widget's fetch — not for the model.
637
+ endpoints: ${list(
638
+ app.endpoints.map(
639
+ (e) => `{ path: "${e.path}", def: endpoint_${camel(e.segments.join("-"))} }`,
640
+ ),
641
+ )},
630
642
  // Read off the template's ${STYLE_ENTRY}, which every view imports.
631
643
  styleDomains: ${list(styleDomains.map((origin) => `"${origin}"`))},
632
644
  });
@@ -863,6 +875,51 @@ function readProvenance(root) {
863
875
  }
864
876
  }
865
877
 
878
+ /**
879
+ * What a git-connected Vercel project needs at the app root, written there when
880
+ * the app has none.
881
+ *
882
+ * The build output lands in `.waniwani/`, which is gitignored and absent from
883
+ * the clone, so a hosted build has to run the kit itself and move the tree to
884
+ * the one path where Vercel adopts the Build Output API. Every line here is
885
+ * about this kit's own layout, which is why the file is generated rather than
886
+ * taken from the template: the template knows nothing about `waniwani build` or
887
+ * `.waniwani/`.
888
+ *
889
+ * The `routes` entry is the part that is not obvious. Vercel reserves a root
890
+ * `api/` directory and compiles every file under it into a serverless function
891
+ * of its own, which for an app folder means one broken function per endpoint
892
+ * (`defineEndpoint({ ... })` is an object, not a Vercel handler) sitting in the
893
+ * filesystem layer ahead of the server that actually serves them. A legacy
894
+ * `routes` entry is emitted before that layer, so `/api/*` reaches the kit's
895
+ * function and Vercel's own are never routed to. There is no way to stop it
896
+ * building them: it reads the file list before the build command runs, so a
897
+ * build that deletes the directory fails with `File not found`, and
898
+ * `outputDirectory` does not suppress it either.
899
+ */
900
+ const VERCEL_JSON = {
901
+ $schema: "https://openapi.vercel.sh/vercel.json",
902
+ // Otherwise the project's framework preset decides, and a preset looking for a
903
+ // dependency an app folder does not have fails the build outright.
904
+ framework: null,
905
+ buildCommand:
906
+ "waniwani build && rm -rf .vercel/output && cp -R .waniwani/.vercel/output .vercel/output",
907
+ // Ahead of Vercel's filesystem layer, which is where its own api/ functions sit.
908
+ routes: [{ src: "/api(/.*)?", dest: "/mcp" }],
909
+ };
910
+
911
+ /**
912
+ * @returns true when the file was written, for the CLI to report
913
+ */
914
+ function ensureVercelJson(appRoot) {
915
+ const file = join(appRoot, "vercel.json");
916
+ // An app that has edited its own deploy config keeps it. Overwriting would
917
+ // throw away a `maxDuration`, a region, or a cron someone needed.
918
+ if (existsSync(file)) return false;
919
+ writeFileSync(file, `${JSON.stringify(VERCEL_JSON, null, 2)}\n`);
920
+ return true;
921
+ }
922
+
866
923
  /** Keep `.waniwani/` out of the app repo, the way `.next/` is kept out. */
867
924
  function ignoreBuildOutput(appRoot) {
868
925
  const file = join(appRoot, ".gitignore");
@@ -978,9 +1035,18 @@ export function generate(app, { template, layout: layoutName = "build", outDir }
978
1035
  }
979
1036
  }
980
1037
 
1038
+ const appPackageJsonPath = join(app.root, "package.json");
1039
+ const appPackageJson = existsSync(appPackageJsonPath)
1040
+ ? JSON.parse(readFileSync(appPackageJsonPath, "utf-8"))
1041
+ : undefined;
1042
+
981
1043
  emit(
982
1044
  "src/waniwani.ts",
983
- generateServerApp(app, layout, { runtime, styleDomains: templateStyleDomains(template) }),
1045
+ generateServerApp(app, layout, {
1046
+ runtime,
1047
+ styleDomains: templateStyleDomains(template),
1048
+ version: appPackageJson?.version,
1049
+ }),
984
1050
  );
985
1051
  // `src/views/` is shared: the template's own views sit alongside the app's,
986
1052
  // so it cannot be wiped. Only the entries a previous build wrote are
@@ -994,11 +1060,6 @@ export function generate(app, { template, layout: layoutName = "build", outDir }
994
1060
  emit(`src/views/${widget.name}.tsx`, generateWidgetShim(widget, layout));
995
1061
  }
996
1062
 
997
- const appPackageJsonPath = join(app.root, "package.json");
998
- const appPackageJson = existsSync(appPackageJsonPath)
999
- ? JSON.parse(readFileSync(appPackageJsonPath, "utf-8"))
1000
- : undefined;
1001
-
1002
1063
  const { packageJson, overrides } = generatePackageJson(app, appPackageJson, template, layout);
1003
1064
 
1004
1065
  emit("tsconfig.json", `${JSON.stringify(generateTsconfig(template, layout), null, 2)}\n`);
@@ -1039,11 +1100,23 @@ export function generate(app, { template, layout: layoutName = "build", outDir }
1039
1100
  )}\n`,
1040
1101
  );
1041
1102
 
1103
+ let vercelJson = false;
1042
1104
  if (layoutName === "build") {
1043
1105
  // A .gitignore inside the output would stop `vercel deploy` uploading
1044
1106
  // anything, so the ignore goes in the app repo instead.
1045
1107
  ignoreBuildOutput(app.root);
1108
+ // Same reasoning for the deploy config: what Vercel reads on a git build is
1109
+ // the app repo's root, not the output directory.
1110
+ vercelJson = ensureVercelJson(app.root);
1046
1111
  }
1047
1112
 
1048
- return { outDir: root, written, overrides, fromTemplate, moved, manifest: Boolean(manifest) };
1113
+ return {
1114
+ outDir: root,
1115
+ written,
1116
+ overrides,
1117
+ fromTemplate,
1118
+ moved,
1119
+ vercelJson,
1120
+ manifest: Boolean(manifest),
1121
+ };
1049
1122
  }
package/cli/env.mjs ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The app's `.env`, loaded into this CLI's own environment.
3
+ *
4
+ * Two problems make this the only place it can happen.
5
+ *
6
+ * The framework runs from the generated project, so `process.cwd()` is
7
+ * `.waniwani/` and a bare `dotenv/config` looks for `.waniwani/.env` — a file
8
+ * nobody wrote. The app's `.env` sits one level up, next to `waniwani.config.ts`.
9
+ *
10
+ * Loading it from generated code inside that project is too late anyway. ESM
11
+ * evaluates a module's imports before its body, so by the time any line of
12
+ * `src/waniwani.ts` runs, every app module it imports has already been
13
+ * evaluated — and a module that builds an API client at import time has already
14
+ * read the empty environment and captured it. `createFlow(...).compile()` is the
15
+ * loud version of this: it throws at import time when `WANIWANI_API_KEY` is
16
+ * absent.
17
+ *
18
+ * Loading here, before anything is spawned, puts the variables in the
19
+ * environment every child process inherits, whatever order its modules load in.
20
+ * Hosted deploys set their variables on the platform and never reach this path.
21
+ */
22
+
23
+ import { join } from "node:path";
24
+ import dotenv from "dotenv";
25
+
26
+ const loaded = new Set();
27
+
28
+ /**
29
+ * `.env.local` before `.env`: the first file to define a variable wins, and
30
+ * dotenv never overwrites one already in the environment, so a value exported in
31
+ * the shell or set by CI outranks both files.
32
+ */
33
+ export function loadAppEnv(root) {
34
+ if (loaded.has(root)) return;
35
+ loaded.add(root);
36
+ dotenv.config({ path: [join(root, ".env.local"), join(root, ".env")], quiet: true });
37
+ }
package/cli/index.mjs CHANGED
@@ -19,6 +19,7 @@ import { existsSync, readFileSync, watch } from "node:fs";
19
19
  import { dirname, join, resolve } from "node:path";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { existingPlumbing, generate } from "./codegen.mjs";
22
+ import { loadAppEnv } from "./env.mjs";
22
23
  import { init } from "./init.mjs";
23
24
  import { banner, bold, dim, green, printReport, red, yellow } from "./log.mjs";
24
25
  import { scanApp } from "./scan.mjs";
@@ -142,7 +143,12 @@ async function prepare(appRoot, flags, { quiet = false } = {}) {
142
143
  console.log(`${yellow("!")} ${dim("GitHub unreachable — using the cached template")}`);
143
144
  }
144
145
 
145
- const { outDir, overrides, fromTemplate, manifest } = generate(app, { template });
146
+ const { outDir, overrides, fromTemplate, manifest, vercelJson } = generate(app, { template });
147
+ // Written into the app's own repo rather than the output, so it is worth a
148
+ // line even outside debug: it is a tracked file that appeared.
149
+ if (!quiet && vercelJson) {
150
+ console.log(`${green("+")} ${bold("vercel.json")} ${dim("— deploy config for a git-connected project")}`);
151
+ }
146
152
  if (!quiet && DEBUG) {
147
153
  console.log(
148
154
  `${dim(`${fromTemplate.length} files copied`)} ${dim(
@@ -268,7 +274,7 @@ function watchApp(appRoot, template) {
268
274
  }, 120);
269
275
  };
270
276
 
271
- for (const dir of ["tools", "widgets", "flows"]) {
277
+ for (const dir of ["tools", "widgets", "flows", "api"]) {
272
278
  const path = join(appRoot, dir);
273
279
  if (existsSync(path)) {
274
280
  watch(path, { recursive: true }, rebuild);
@@ -333,6 +339,12 @@ async function main() {
333
339
  banner(PACKAGE_VERSION);
334
340
  }
335
341
 
342
+ // Before anything is spawned, so every child inherits the app's variables
343
+ // whatever order its modules evaluate in. `init` has no app to read yet.
344
+ if (command !== "init") {
345
+ loadAppEnv(appRoot);
346
+ }
347
+
336
348
  if (command === "init") {
337
349
  // Whether a directory was named matters only here: with none, the answer to
338
350
  // the one question decides where the app goes.
package/cli/log.mjs CHANGED
@@ -150,6 +150,7 @@ export function printReport(app, report) {
150
150
  [app.widgets.length, "widget"],
151
151
  [app.tools.length, "tool"],
152
152
  [app.flows.length, "flow"],
153
+ [app.endpoints.length, "endpoint"],
153
154
  ]
154
155
  .filter(([count]) => count > 0)
155
156
  .map(([count, label]) => `${count} ${label}${count === 1 ? "" : "s"}`);
@@ -165,6 +166,11 @@ export function printReport(app, report) {
165
166
  for (const flow of app.flows) {
166
167
  console.log(` ${dim("flow ")} ${flow.name}`);
167
168
  }
169
+ // The path, not the filename: what a widget writes into a `fetch()` is the
170
+ // thing worth checking against this line.
171
+ for (const endpoint of app.endpoints) {
172
+ console.log(` ${dim("api ")} ${endpoint.path}`);
173
+ }
168
174
  if (report.warnings.length > 0) {
169
175
  console.log("");
170
176
  printGroup(report.warnings, "└", yellow);
package/cli/scan.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  * tools/<name>.ts
6
6
  * widgets/<name>/{widget.ts,ui.tsx}
7
7
  * flows/<name>.ts
8
+ * api/<path>.ts
8
9
  *
9
10
  * There is no CSS in that list. Styling is Tailwind, from the distribution
10
11
  * template's `src/index.css` — its `@theme` tokens and its `dark` variant — and
@@ -37,6 +38,42 @@ function stripExt(path) {
37
38
  return basename(path, extname(path));
38
39
  }
39
40
 
41
+ /**
42
+ * Every code file under `dir`, depth first, as `{ file, segments }` where
43
+ * `segments` is its path below `dir` with the extension gone.
44
+ *
45
+ * `api/` is the one convention folder that nests: an HTTP path has more than
46
+ * one segment, and the only place it can come from without a registry is the
47
+ * filesystem.
48
+ */
49
+ function listTree(dir, trail = []) {
50
+ if (!existsSync(dir)) return [];
51
+
52
+ return readdirSync(dir)
53
+ .filter((entry) => !entry.startsWith(".") && !entry.startsWith("_"))
54
+ .flatMap((entry) => {
55
+ const path = join(dir, entry);
56
+ if (statSync(path).isDirectory()) {
57
+ return listTree(path, [...trail, entry]);
58
+ }
59
+ if (!CODE_EXT.has(extname(path))) return [];
60
+ return [{ file: path, segments: [...trail, stripExt(path)] }];
61
+ });
62
+ }
63
+
64
+ /**
65
+ * The URL an endpoint file is served at: its position under the app root,
66
+ * `/api` included, since that is the folder's name.
67
+ *
68
+ * `index` names the directory itself, so `api/cal/index.ts` answers `/api/cal`
69
+ * — the one place a filename is not taken verbatim, and the convention every
70
+ * web framework already uses.
71
+ */
72
+ function endpointPath(segments) {
73
+ const parts = segments.at(-1) === "index" ? segments.slice(0, -1) : segments;
74
+ return `/api/${parts.join("/")}`.replace(/\/$/, "") || "/api";
75
+ }
76
+
40
77
  export function scanApp(root) {
41
78
  const configFile = [join(root, "waniwani.config.ts"), join(root, "waniwani.config.js")].find(
42
79
  existsSync,
@@ -57,6 +94,12 @@ export function scanApp(root) {
57
94
  .filter((file) => CODE_EXT.has(extname(file)))
58
95
  .map((file) => ({ name: stripExt(file), file }));
59
96
 
97
+ const endpoints = listTree(join(root, "api")).map(({ file, segments }) => ({
98
+ path: endpointPath(segments),
99
+ segments,
100
+ file,
101
+ }));
102
+
60
103
  // The two paths an author is most likely to expect the kit to pick up. It
61
104
  // imports neither, so a file at either one is styling that never reaches the
62
105
  // browser — the kind of silent no-op the build check exists to name.
@@ -65,5 +108,5 @@ export function scanApp(root) {
65
108
  ...widgets.map((widget) => join(widget.dir, "styles.css")),
66
109
  ].filter(existsSync);
67
110
 
68
- return { root, configFile, tools, widgets, flows, strayStyles };
111
+ return { root, configFile, tools, widgets, flows, endpoints, strayStyles };
69
112
  }
package/cli/validate.mjs CHANGED
@@ -8,9 +8,19 @@
8
8
 
9
9
  import { readFileSync } from "node:fs";
10
10
  import { relative } from "node:path";
11
+ import { loadAppEnv } from "./env.mjs";
11
12
 
12
13
  const NAME_RE = /^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$/;
13
14
 
15
+ /**
16
+ * What may appear in an endpoint path segment. Deliberately narrower than what
17
+ * a filesystem allows: the segment becomes a URL path, and a file called
18
+ * `Book Call.ts` would be served at a URL nobody would guess.
19
+ */
20
+ const SEGMENT_RE = /^[a-zA-Z0-9._-]+$/;
21
+
22
+ const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options"]);
23
+
14
24
  class Report {
15
25
  constructor(root) {
16
26
  this.root = root;
@@ -113,6 +123,49 @@ function checkStructure(app, report) {
113
123
  seen.set(entry.name, entry);
114
124
  }
115
125
 
126
+ // An endpoint's file position is its URL, so a segment that cannot appear in
127
+ // a URL is a file served somewhere unguessable, and two files resolving to
128
+ // one path means the second mount is dead — Express answers from the first.
129
+ const paths = new Map();
130
+ // The generator names one import per endpoint, camel-cased from the path, so
131
+ // two paths that camel-case alike (`api/cal-slots.ts`, `api/cal/slots.ts`)
132
+ // would emit the same identifier twice and fail in generated code the author
133
+ // cannot open.
134
+ const identifiers = new Map();
135
+ for (const endpoint of app.endpoints) {
136
+ const where = rel(root, endpoint.file);
137
+
138
+ const identifier = endpoint.segments.join("-").replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
139
+ const clash = identifiers.get(identifier);
140
+ if (clash) {
141
+ report.error(
142
+ where,
143
+ `this path generates the same import name as ${clash}`,
144
+ "rename one of the two — the generator derives an identifier from the path",
145
+ );
146
+ }
147
+ identifiers.set(identifier, where);
148
+
149
+ for (const segment of endpoint.segments) {
150
+ if (SEGMENT_RE.test(segment)) continue;
151
+ report.error(
152
+ where,
153
+ `"${segment}" cannot be part of a URL path`,
154
+ "use letters, digits, dashes, dots and underscores — the file's position is the endpoint's path",
155
+ );
156
+ }
157
+
158
+ const previous = paths.get(endpoint.path);
159
+ if (previous) {
160
+ report.error(
161
+ where,
162
+ `${endpoint.path} is already served by ${previous}`,
163
+ "two files resolve to one path — Express answers from the first, so this one never runs",
164
+ );
165
+ }
166
+ paths.set(endpoint.path, where);
167
+ }
168
+
116
169
  // Flows point at widgets by name. Catching a typo here beats catching it
117
170
  // when a user is halfway through a conversation.
118
171
  const widgetNames = new Set(app.widgets.map((w) => w.name));
@@ -188,6 +241,27 @@ async function checkModules(app, report) {
188
241
  }
189
242
  }
190
243
 
244
+ for (const endpoint of app.endpoints) {
245
+ const where = rel(root, endpoint.file);
246
+ const def = await load(endpoint.file, where, report);
247
+ if (!def) continue;
248
+ if (typeof def.handler !== "function") {
249
+ report.error(
250
+ where,
251
+ "endpoint is missing handler()",
252
+ "export default defineEndpoint({ handler: (req, res) => { ... } })",
253
+ );
254
+ }
255
+ for (const method of def.method ? [def.method].flat() : []) {
256
+ if (HTTP_METHODS.has(method)) continue;
257
+ report.error(
258
+ where,
259
+ `"${method}" is not an HTTP method`,
260
+ `one of: ${[...HTTP_METHODS].join(", ")}`,
261
+ );
262
+ }
263
+ }
264
+
191
265
  for (const flow of app.flows) {
192
266
  const where = rel(root, flow.file);
193
267
  const def = await load(flow.file, where, report);
@@ -238,6 +312,11 @@ async function load(file, where, report) {
238
312
  }
239
313
 
240
314
  export async function validateApp(app) {
315
+ // The check imports every server-safe module for real, and a module that
316
+ // builds a client at import time reads the environment while doing it. An app
317
+ // that runs fine would otherwise fail its own build check over a variable
318
+ // sitting in the file next to it.
319
+ loadAppEnv(app.root);
241
320
  const report = new Report(app.root);
242
321
  checkStructure(app, report);
243
322
  // Importing broken modules produces noise on top of structural errors.
package/dist/index.d.ts CHANGED
@@ -12,7 +12,9 @@
12
12
  * widgets/<name>/widget.ts export default defineWidget({ ... })
13
13
  * widgets/<name>/ui.tsx export default function Component() { ... }
14
14
  * flows/<name>.ts export default createFlow({ ... }).compile()
15
+ * api/<path>.ts export default defineEndpoint({ ... })
15
16
  */
17
+ import type { RequestHandler } from "express";
16
18
  import type { z } from "zod";
17
19
  /** A Zod object shape — `{ name: z.string() }`, not `z.object({ ... })`. */
18
20
  export type Shape = z.ZodRawShape;
@@ -103,4 +105,43 @@ export type WidgetDefinition<S extends Shape = Shape> = {
103
105
  load?: (input: Infer<S>) => Infer<S> | Promise<Infer<S>>;
104
106
  };
105
107
  export declare function defineWidget<S extends Shape>(def: WidgetDefinition<S>): WidgetDefinition<S>;
108
+ /**
109
+ * HTTP methods an endpoint can be restricted to. `undefined` accepts every
110
+ * method, which is what an Express `use()` mount does.
111
+ */
112
+ export type HttpMethod = "get" | "post" | "put" | "patch" | "delete" | "head" | "options";
113
+ /**
114
+ * A plain HTTP endpoint served by the same server as the MCP tools.
115
+ *
116
+ * This exists for the browser, not for the model. A widget runs in a
117
+ * cross-origin iframe and can `fetch()` its own server at
118
+ * `window.skybridge.serverUrl` — for a booking, a price lookup, a webhook
119
+ * receiver — and the model never sees the call. Anything the *model* should be
120
+ * able to reach belongs in `tools/`, not here.
121
+ *
122
+ * The path comes from the file's location, `/api` prefix included:
123
+ * `api/cal/slots.ts` is served at `/api/cal/slots`.
124
+ */
125
+ export type EndpointDefinition = {
126
+ /**
127
+ * Restrict the endpoint to these methods, answering anything else with 405.
128
+ * Defaults to accepting every method.
129
+ */
130
+ method?: HttpMethod | HttpMethod[];
131
+ /**
132
+ * Answer cross-origin requests, preflight included. On by default: a widget
133
+ * is served from a different origin than the server it calls, so an endpoint
134
+ * without CORS is one the widget cannot reach.
135
+ */
136
+ cors?: boolean;
137
+ /**
138
+ * Parse a JSON request body into `req.body`. On by default — the framework
139
+ * installs no body parser of its own, so an endpoint without this reads
140
+ * `req.body` as `undefined`.
141
+ */
142
+ json?: boolean;
143
+ /** An Express handler. Throwing is safe: the runtime answers 500 and logs. */
144
+ handler: RequestHandler;
145
+ };
146
+ export declare function defineEndpoint(def: EndpointDefinition): EndpointDefinition;
106
147
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * widgets/<name>/widget.ts export default defineWidget({ ... })
13
13
  * widgets/<name>/ui.tsx export default function Component() { ... }
14
14
  * flows/<name>.ts export default createFlow({ ... }).compile()
15
+ * api/<path>.ts export default defineEndpoint({ ... })
15
16
  */
16
17
  export function defineApp(config) {
17
18
  return config;
@@ -22,4 +23,7 @@ export function defineTool(def) {
22
23
  export function defineWidget(def) {
23
24
  return def;
24
25
  }
26
+ export function defineEndpoint(def) {
27
+ return def;
28
+ }
25
29
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AA0CH,MAAM,UAAU,SAAS,CAAC,MAAiB;IAC1C,OAAO,MAAM,CAAC;AACf,CAAC;AAwBD,MAAM,UAAU,UAAU,CACzB,GAAyB;IAEzB,OAAO,GAAG,CAAC;AACZ,CAAC;AAuCD,MAAM,UAAU,YAAY,CAAkB,GAAwB;IACrE,OAAO,GAAG,CAAC;AACZ,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AA2CH,MAAM,UAAU,SAAS,CAAC,MAAiB;IAC1C,OAAO,MAAM,CAAC;AACf,CAAC;AAwBD,MAAM,UAAU,UAAU,CACzB,GAAyB;IAEzB,OAAO,GAAG,CAAC;AACZ,CAAC;AAuCD,MAAM,UAAU,YAAY,CAAkB,GAAwB;IACrE,OAAO,GAAG,CAAC;AACZ,CAAC;AA4CD,MAAM,UAAU,cAAc,CAAC,GAAuB;IACrD,OAAO,GAAG,CAAC;AACZ,CAAC"}
package/dist/server.d.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * hands them to `registerApp()`. Nothing else.
12
12
  */
13
13
  import { McpServer } from "skybridge/server";
14
- import type { Shape, ToolHints, WidgetCsp } from "./index.js";
14
+ import type { EndpointDefinition, Shape, ToolHints, WidgetCsp } from "./index.js";
15
15
  /**
16
16
  * The manifest holds definitions with unrelated schemas side by side, so the
17
17
  * handler signatures are widened here. `never` in the parameter position
@@ -50,6 +50,11 @@ export type Manifest = {
50
50
  def: AnyWidgetDefinition;
51
51
  }>;
52
52
  flows: CompiledFlow[];
53
+ /** HTTP endpoints, each with the path its file position produced. */
54
+ endpoints?: Array<{
55
+ path: string;
56
+ def: EndpointDefinition;
57
+ }>;
53
58
  /**
54
59
  * Origins the template's Tailwind entry loads from, read off it at build
55
60
  * time. Every view imports that stylesheet, so every widget needs them.
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,SAAS,EAAiB,MAAM,kBAAkB,CAAC;AAC5D,OAAO,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE9D;;;;GAIG;AACH,KAAK,iBAAiB,GAAG;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,KAAK,CAAC;IACd,MAAM,CAAC,EAAE,KAAK,CAAC;IACf,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,GAAG,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC;CAC/B,CAAC;AAEF,KAAK,mBAAmB,GAAG;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,KAAK,CAAC;IACZ,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,GAAG,CAAC,EAAE,SAAS,CAAC;IAChB,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,KAAK,KAAK,MAAM,CAAC;IAClC,IAAI,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC;CACjC,CAAC;AAEF,+EAA+E;AAC/E,MAAM,MAAM,YAAY,GAAG;IAC1B,IAAI,EAAE,MAAM,CAAC;IAEb,MAAM,EAAE,GAAG,CAAC;IAEZ,OAAO,EAAE,GAAG,CAAC;CACb,CAAC;AAEF,MAAM,MAAM,QAAQ,GAAG;IACtB,KAAK,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,iBAAiB,CAAA;KAAE,CAAC,CAAC;IACvD,OAAO,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,mBAAmB,CAAA;KAAE,CAAC,CAAC;IAC3D,KAAK,EAAE,YAAY,EAAE,CAAC;IACtB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;CACxB,CAAC;AAqEF;;;;;;;GAOG;AACH,wBAAsB,WAAW,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,SAAS,CAAC,CA0F3F"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAIH,OAAO,EAAE,SAAS,EAAiB,MAAM,kBAAkB,CAAC;AAC5D,OAAO,KAAK,EAAE,kBAAkB,EAAc,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE9F;;;;GAIG;AACH,KAAK,iBAAiB,GAAG;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,KAAK,CAAC;IACd,MAAM,CAAC,EAAE,KAAK,CAAC;IACf,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,GAAG,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC;CAC/B,CAAC;AAEF,KAAK,mBAAmB,GAAG;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,KAAK,CAAC;IACZ,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,GAAG,CAAC,EAAE,SAAS,CAAC;IAChB,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,KAAK,KAAK,MAAM,CAAC;IAClC,IAAI,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC;CACjC,CAAC;AAEF,+EAA+E;AAC/E,MAAM,MAAM,YAAY,GAAG;IAC1B,IAAI,EAAE,MAAM,CAAC;IAEb,MAAM,EAAE,GAAG,CAAC;IAEZ,OAAO,EAAE,GAAG,CAAC;CACb,CAAC;AAEF,MAAM,MAAM,QAAQ,GAAG;IACtB,KAAK,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,iBAAiB,CAAA;KAAE,CAAC,CAAC;IACvD,OAAO,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,mBAAmB,CAAA;KAAE,CAAC,CAAC;IAC3D,KAAK,EAAE,YAAY,EAAE,CAAC;IACtB,qEAAqE;IACrE,SAAS,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,kBAAkB,CAAA;KAAE,CAAC,CAAC;IAC7D;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;CACxB,CAAC;AA6IF;;;;;;;GAOG;AACH,wBAAsB,WAAW,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,SAAS,CAAC,CAgG3F"}
package/dist/server.js CHANGED
@@ -10,6 +10,8 @@
10
10
  * against that seam — `src/waniwani.ts` — which imports the app's modules and
11
11
  * hands them to `registerApp()`. Nothing else.
12
12
  */
13
+ import cors from "cors";
14
+ import express, {} from "express";
13
15
  import { McpServer } from "skybridge/server";
14
16
  /**
15
17
  * The origins a widget may load assets from: its own, plus the ones its
@@ -73,6 +75,74 @@ function widgetError(name, error) {
73
75
  ],
74
76
  };
75
77
  }
78
+ /**
79
+ * Answer anything outside the declared methods with 405 rather than running the
80
+ * handler. An endpoint is mounted with `use()`, which matches every method, so
81
+ * without this a `GET /api/cal/book` would reach a handler written for a POST
82
+ * body and fail somewhere less obvious.
83
+ */
84
+ function methodGuard(allowed) {
85
+ // A preflight never carries the real method, and answering it 405 blocks the
86
+ // request it was asking about.
87
+ const pass = new Set([...allowed, "OPTIONS"]);
88
+ return (req, res, next) => {
89
+ if (pass.has(req.method))
90
+ return next();
91
+ res.setHeader("Allow", allowed.join(", "));
92
+ res.status(405).json({ error: `${req.method} not allowed` });
93
+ };
94
+ }
95
+ /**
96
+ * The last link in every endpoint's chain: a 4-arity middleware, which is how
97
+ * Express recognises an error handler.
98
+ *
99
+ * Scoped to the endpoint's own mount path rather than the app, so it cannot
100
+ * catch anything the endpoint did not cause. Without it a rejected handler — or
101
+ * a malformed JSON body, which the parser reports the same way — reaches
102
+ * Express's default handler and answers an HTML error page to a `fetch()` that
103
+ * is waiting for JSON.
104
+ */
105
+ function endpointErrorHandler(path) {
106
+ return (error, _req, res, next) => {
107
+ console.error(`[waniwani] endpoint "${path}" failed:`, error);
108
+ if (res.headersSent)
109
+ return next(error);
110
+ // `express.json()` rejects a malformed body with a 400 already on the error.
111
+ const status = typeof error?.status === "number"
112
+ ? error.status
113
+ : 500;
114
+ res.status(status).json({
115
+ error: error instanceof Error ? error.message : "Internal server error",
116
+ });
117
+ };
118
+ }
119
+ /**
120
+ * Mount an app's HTTP endpoints on the server's Express app.
121
+ *
122
+ * Everything the framework does not do for us is done here, once, rather than
123
+ * left to each endpoint to remember: CORS (a widget calls from another origin),
124
+ * a JSON body parser (the framework installs none, so `req.body` would be
125
+ * `undefined`), the method guard, and the error envelope.
126
+ */
127
+ function registerEndpoints(server, endpoints) {
128
+ for (const { path, def } of endpoints) {
129
+ const methods = def.method ? [def.method].flat().map((m) => m.toUpperCase()) : undefined;
130
+ const chain = [];
131
+ // The preflight answer names the methods the guard below actually accepts.
132
+ // Browsers cache it, so advertising a method that then answers 405 is a
133
+ // contradiction the widget author has to debug twice.
134
+ if (def.cors !== false)
135
+ chain.push(cors(methods ? { methods } : undefined));
136
+ if (def.json !== false)
137
+ chain.push(express.json());
138
+ if (methods)
139
+ chain.push(methodGuard(methods));
140
+ chain.push(def.handler, endpointErrorHandler(path));
141
+ // The error handler's arity is what makes Express treat it as one, and
142
+ // `use()` is typed for request handlers only.
143
+ server.use(path, ...chain);
144
+ }
145
+ }
76
146
  /**
77
147
  * Register an app's tools, widgets and flows onto a server the template built.
78
148
  *
@@ -82,7 +152,12 @@ function widgetError(name, error) {
82
152
  * app's own tools sit alongside it.
83
153
  */
84
154
  export async function registerApp(server, manifest) {
85
- const { tools, widgets, flows, styleDomains = [] } = manifest;
155
+ const { tools, widgets, flows, endpoints = [], styleDomains = [] } = manifest;
156
+ // Before the tools, because Express matches in registration order and the
157
+ // framework mounts `/mcp` after this function returns. Nothing here can
158
+ // shadow it — `/api/...` and `/mcp` do not overlap — but the ordering is the
159
+ // reason an endpoint is reachable at all.
160
+ registerEndpoints(server, endpoints);
86
161
  // Widgets: one `data` schema drives the input schema, the structured output,
87
162
  // and the type the component receives.
88
163
  //
@@ -1 +1 @@
1
- {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waniwani/kit",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Build an MCP app as a folder: tools, widgets and flows, with one CLI and one shared server runtime.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,6 +24,7 @@
24
24
  },
25
25
  "//files": "`src` ships alongside `dist` on purpose: `waniwani eject` vendors the runtime as readable TypeScript, and it reads it out of the installed package.",
26
26
  "//dependencies": "tsx, typescript and the @types packages are here rather than in devDependencies because the underlying framework shells out to tsc and tsx by bare name and resolves types from the app repo tree, while declaring none of them. An app repo owns no build config, so this package is the only thing that can put them there. nodemon is the fourth of that set and arrives on its own, as a peer of it.",
27
+ "//express": "express and cors back the api/ convention: the runtime mounts each endpoint with a JSON body parser and CORS, and an app's handlers are typed against express. Both are already skybridge's own dependencies at these ranges, so declaring them here adds no second copy — it stops an app from depending on a transitive hoist.",
27
28
  "files": [
28
29
  "dist",
29
30
  "src",
@@ -41,12 +42,16 @@
41
42
  "dependencies": {
42
43
  "@modelcontextprotocol/sdk": "^1.29.0",
43
44
  "@tailwindcss/vite": "^4.3.3",
45
+ "@types/cors": "^2.8.19",
46
+ "@types/express": "^5.0.6",
44
47
  "@types/node": "^24.12.0",
45
48
  "@types/react": "^19.2.14",
46
49
  "@types/react-dom": "^19.2.3",
47
50
  "@vitejs/plugin-react": "^6.0.3",
48
51
  "@waniwani/sdk": "^0.19.5",
52
+ "cors": "^2.8.6",
49
53
  "dotenv": "^17.4.1",
54
+ "express": "^5.2.1",
50
55
  "skybridge": "^1.3.5",
51
56
  "tailwindcss": "^4.3.3",
52
57
  "tsx": "^4.20.6",
package/src/index.ts CHANGED
@@ -12,8 +12,10 @@
12
12
  * widgets/<name>/widget.ts export default defineWidget({ ... })
13
13
  * widgets/<name>/ui.tsx export default function Component() { ... }
14
14
  * flows/<name>.ts export default createFlow({ ... }).compile()
15
+ * api/<path>.ts export default defineEndpoint({ ... })
15
16
  */
16
17
 
18
+ import type { RequestHandler } from "express";
17
19
  import type { z } from "zod";
18
20
 
19
21
  /** A Zod object shape — `{ name: z.string() }`, not `z.object({ ... })`. */
@@ -126,3 +128,49 @@ export type WidgetDefinition<S extends Shape = Shape> = {
126
128
  export function defineWidget<S extends Shape>(def: WidgetDefinition<S>): WidgetDefinition<S> {
127
129
  return def;
128
130
  }
131
+
132
+ // ------------------------------------------------------------------ endpoints
133
+
134
+ /**
135
+ * HTTP methods an endpoint can be restricted to. `undefined` accepts every
136
+ * method, which is what an Express `use()` mount does.
137
+ */
138
+ export type HttpMethod = "get" | "post" | "put" | "patch" | "delete" | "head" | "options";
139
+
140
+ /**
141
+ * A plain HTTP endpoint served by the same server as the MCP tools.
142
+ *
143
+ * This exists for the browser, not for the model. A widget runs in a
144
+ * cross-origin iframe and can `fetch()` its own server at
145
+ * `window.skybridge.serverUrl` — for a booking, a price lookup, a webhook
146
+ * receiver — and the model never sees the call. Anything the *model* should be
147
+ * able to reach belongs in `tools/`, not here.
148
+ *
149
+ * The path comes from the file's location, `/api` prefix included:
150
+ * `api/cal/slots.ts` is served at `/api/cal/slots`.
151
+ */
152
+ export type EndpointDefinition = {
153
+ /**
154
+ * Restrict the endpoint to these methods, answering anything else with 405.
155
+ * Defaults to accepting every method.
156
+ */
157
+ method?: HttpMethod | HttpMethod[];
158
+ /**
159
+ * Answer cross-origin requests, preflight included. On by default: a widget
160
+ * is served from a different origin than the server it calls, so an endpoint
161
+ * without CORS is one the widget cannot reach.
162
+ */
163
+ cors?: boolean;
164
+ /**
165
+ * Parse a JSON request body into `req.body`. On by default — the framework
166
+ * installs no body parser of its own, so an endpoint without this reads
167
+ * `req.body` as `undefined`.
168
+ */
169
+ json?: boolean;
170
+ /** An Express handler. Throwing is safe: the runtime answers 500 and logs. */
171
+ handler: RequestHandler;
172
+ };
173
+
174
+ export function defineEndpoint(def: EndpointDefinition): EndpointDefinition {
175
+ return def;
176
+ }
package/src/server.ts CHANGED
@@ -11,8 +11,10 @@
11
11
  * hands them to `registerApp()`. Nothing else.
12
12
  */
13
13
 
14
+ import cors from "cors";
15
+ import express, { type ErrorRequestHandler, type RequestHandler } from "express";
14
16
  import { McpServer, type ViewName } from "skybridge/server";
15
- import type { Shape, ToolHints, WidgetCsp } from "./index.js";
17
+ import type { EndpointDefinition, HttpMethod, Shape, ToolHints, WidgetCsp } from "./index.js";
16
18
 
17
19
  /**
18
20
  * The manifest holds definitions with unrelated schemas side by side, so the
@@ -51,6 +53,8 @@ export type Manifest = {
51
53
  tools: Array<{ name: string; def: AnyToolDefinition }>;
52
54
  widgets: Array<{ name: string; def: AnyWidgetDefinition }>;
53
55
  flows: CompiledFlow[];
56
+ /** HTTP endpoints, each with the path its file position produced. */
57
+ endpoints?: Array<{ path: string; def: EndpointDefinition }>;
54
58
  /**
55
59
  * Origins the template's Tailwind entry loads from, read off it at build
56
60
  * time. Every view imports that stylesheet, so every widget needs them.
@@ -125,6 +129,78 @@ function widgetError(name: string, error: unknown) {
125
129
  };
126
130
  }
127
131
 
132
+ /**
133
+ * Answer anything outside the declared methods with 405 rather than running the
134
+ * handler. An endpoint is mounted with `use()`, which matches every method, so
135
+ * without this a `GET /api/cal/book` would reach a handler written for a POST
136
+ * body and fail somewhere less obvious.
137
+ */
138
+ function methodGuard(allowed: string[]): RequestHandler {
139
+ // A preflight never carries the real method, and answering it 405 blocks the
140
+ // request it was asking about.
141
+ const pass = new Set([...allowed, "OPTIONS"]);
142
+
143
+ return (req, res, next) => {
144
+ if (pass.has(req.method)) return next();
145
+ res.setHeader("Allow", allowed.join(", "));
146
+ res.status(405).json({ error: `${req.method} not allowed` });
147
+ };
148
+ }
149
+
150
+ /**
151
+ * The last link in every endpoint's chain: a 4-arity middleware, which is how
152
+ * Express recognises an error handler.
153
+ *
154
+ * Scoped to the endpoint's own mount path rather than the app, so it cannot
155
+ * catch anything the endpoint did not cause. Without it a rejected handler — or
156
+ * a malformed JSON body, which the parser reports the same way — reaches
157
+ * Express's default handler and answers an HTML error page to a `fetch()` that
158
+ * is waiting for JSON.
159
+ */
160
+ function endpointErrorHandler(path: string): ErrorRequestHandler {
161
+ return (error, _req, res, next) => {
162
+ console.error(`[waniwani] endpoint "${path}" failed:`, error);
163
+ if (res.headersSent) return next(error);
164
+ // `express.json()` rejects a malformed body with a 400 already on the error.
165
+ const status = typeof (error as { status?: unknown })?.status === "number"
166
+ ? (error as { status: number }).status
167
+ : 500;
168
+ res.status(status).json({
169
+ error: error instanceof Error ? error.message : "Internal server error",
170
+ });
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Mount an app's HTTP endpoints on the server's Express app.
176
+ *
177
+ * Everything the framework does not do for us is done here, once, rather than
178
+ * left to each endpoint to remember: CORS (a widget calls from another origin),
179
+ * a JSON body parser (the framework installs none, so `req.body` would be
180
+ * `undefined`), the method guard, and the error envelope.
181
+ */
182
+ function registerEndpoints(
183
+ server: McpServer,
184
+ endpoints: NonNullable<Manifest["endpoints"]>,
185
+ ): void {
186
+ for (const { path, def } of endpoints) {
187
+ const methods = def.method ? [def.method].flat().map((m) => m.toUpperCase()) : undefined;
188
+
189
+ const chain: Array<RequestHandler | ErrorRequestHandler> = [];
190
+ // The preflight answer names the methods the guard below actually accepts.
191
+ // Browsers cache it, so advertising a method that then answers 405 is a
192
+ // contradiction the widget author has to debug twice.
193
+ if (def.cors !== false) chain.push(cors(methods ? { methods } : undefined));
194
+ if (def.json !== false) chain.push(express.json());
195
+ if (methods) chain.push(methodGuard(methods));
196
+ chain.push(def.handler, endpointErrorHandler(path));
197
+
198
+ // The error handler's arity is what makes Express treat it as one, and
199
+ // `use()` is typed for request handlers only.
200
+ server.use(path, ...(chain as RequestHandler[]));
201
+ }
202
+ }
203
+
128
204
  /**
129
205
  * Register an app's tools, widgets and flows onto a server the template built.
130
206
  *
@@ -134,7 +210,13 @@ function widgetError(name: string, error: unknown) {
134
210
  * app's own tools sit alongside it.
135
211
  */
136
212
  export async function registerApp(server: McpServer, manifest: Manifest): Promise<McpServer> {
137
- const { tools, widgets, flows, styleDomains = [] } = manifest;
213
+ const { tools, widgets, flows, endpoints = [], styleDomains = [] } = manifest;
214
+
215
+ // Before the tools, because Express matches in registration order and the
216
+ // framework mounts `/mcp` after this function returns. Nothing here can
217
+ // shadow it — `/api/...` and `/mcp` do not overlap — but the ordering is the
218
+ // reason an endpoint is reachable at all.
219
+ registerEndpoints(server, endpoints);
138
220
 
139
221
  // Widgets: one `data` schema drives the input schema, the structured output,
140
222
  // and the type the component receives.