@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 +138 -0
- package/cli/codegen.mjs +82 -9
- package/cli/env.mjs +37 -0
- package/cli/index.mjs +14 -2
- package/cli/log.mjs +6 -0
- package/cli/scan.mjs +44 -1
- package/cli/validate.mjs +79 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +6 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +76 -1
- package/dist/server.js.map +1 -1
- package/package.json +6 -1
- package/src/index.ts +48 -0
- package/src/server.ts +84 -2
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/<name>/<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, {
|
|
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 {
|
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA
|
|
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
|
|
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.
|
package/dist/server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;
|
|
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
|
//
|
package/dist/server.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,SAAS,EAAiB,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,IAAI,MAAM,MAAM,CAAC;AACxB,OAAO,OAAO,EAAE,EAAiD,MAAM,SAAS,CAAC;AACjF,OAAO,EAAE,SAAS,EAAiB,MAAM,kBAAkB,CAAC;AAiD5D;;;;;;;;GAQG;AACH,SAAS,eAAe,CAAC,GAA0B,EAAE,YAAsB;IAC1E,MAAM,MAAM,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,CAAC,GAAG,CAAC,GAAG,EAAE,eAAe,IAAI,EAAE,CAAC,EAAE,GAAG,YAAY,CAAC,CAAC,CAAC,CAAC;IAChF,OAAO,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC;AAC/C,CAAC;AAED;;;GAGG;AACH,SAAS,WAAW,CAAC,KAAa,EAAE,KAA4B,EAAE,QAAmB;IACpF,MAAM,MAAM,GAAG,EAAE,GAAG,QAAQ,EAAE,GAAG,KAAK,EAAE,CAAC;IACzC,OAAO;QACN,KAAK;QACL,YAAY,EAAE,MAAM,CAAC,QAAQ,IAAI,KAAK;QACtC,eAAe,EAAE,MAAM,CAAC,WAAW,IAAI,KAAK;QAC5C,aAAa,EAAE,MAAM,CAAC,SAAS,IAAI,KAAK;QACxC,cAAc,EAAE,MAAM,CAAC,UAAU,IAAI,KAAK;KAC1C,CAAC;AACH,CAAC;AAED,+EAA+E;AAC/E,SAAS,UAAU,CAAC,KAAc;IACjC,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC/B,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAe,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC;IAC9D,CAAC;IACD,IAAI,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,SAAS,IAAI,KAAK,EAAE,CAAC;QAC9D,OAAO,KAA2D,CAAC;IACpE,CAAC;IACD,MAAM,iBAAiB,GAAG,CAAC,KAAK,IAAI,EAAE,CAA4B,CAAC;IACnE,OAAO;QACN,iBAAiB;QACjB,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAe,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,iBAAiB,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC;KACtF,CAAC;AACH,CAAC;AAED;;;;GAIG;AACH,SAAS,iBAAiB,CAAC,IAAY;IACtC,OAAO,OAAO,IAAI,yMAAyM,CAAC;AAC7N,CAAC;AAED,SAAS,WAAW,CAAC,IAAY,EAAE,KAAc;IAChD,MAAM,OAAO,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IACvE,OAAO,CAAC,KAAK,CAAC,sBAAsB,IAAI,mBAAmB,EAAE,KAAK,CAAC,CAAC;IACpE,OAAO;QACN,OAAO,EAAE,IAAa;QACtB,OAAO,EAAE;YACR;gBACC,IAAI,EAAE,MAAe;gBACrB,IAAI,EAAE,OAAO,IAAI,oCAAoC,OAAO,oGAAoG;aAChK;SACD;KACD,CAAC;AACH,CAAC;AAED;;;;;GAKG;AACH,SAAS,WAAW,CAAC,OAAiB;IACrC,6EAA6E;IAC7E,+BAA+B;IAC/B,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,SAAS,CAAC,CAAC,CAAC;IAE9C,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;QACzB,IAAI,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC;YAAE,OAAO,IAAI,EAAE,CAAC;QACxC,GAAG,CAAC,SAAS,CAAC,OAAO,EAAE,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;QAC3C,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,GAAG,GAAG,CAAC,MAAM,cAAc,EAAE,CAAC,CAAC;IAC9D,CAAC,CAAC;AACH,CAAC;AAED;;;;;;;;;GASG;AACH,SAAS,oBAAoB,CAAC,IAAY;IACzC,OAAO,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;QACjC,OAAO,CAAC,KAAK,CAAC,wBAAwB,IAAI,WAAW,EAAE,KAAK,CAAC,CAAC;QAC9D,IAAI,GAAG,CAAC,WAAW;YAAE,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC;QACxC,6EAA6E;QAC7E,MAAM,MAAM,GAAG,OAAQ,KAA8B,EAAE,MAAM,KAAK,QAAQ;YACzE,CAAC,CAAE,KAA4B,CAAC,MAAM;YACtC,CAAC,CAAC,GAAG,CAAC;QACP,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC;YACvB,KAAK,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,uBAAuB;SACvE,CAAC,CAAC;IACJ,CAAC,CAAC;AACH,CAAC;AAED;;;;;;;GAOG;AACH,SAAS,iBAAiB,CACzB,MAAiB,EACjB,SAA6C;IAE7C,KAAK,MAAM,EAAE,IAAI,EAAE,GAAG,EAAE,IAAI,SAAS,EAAE,CAAC;QACvC,MAAM,OAAO,GAAG,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;QAEzF,MAAM,KAAK,GAAgD,EAAE,CAAC;QAC9D,2EAA2E;QAC3E,wEAAwE;QACxE,sDAAsD;QACtD,IAAI,GAAG,CAAC,IAAI,KAAK,KAAK;YAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC;QAC5E,IAAI,GAAG,CAAC,IAAI,KAAK,KAAK;YAAE,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC;QACnD,IAAI,OAAO;YAAE,KAAK,CAAC,IAAI,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC,CAAC;QAC9C,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,oBAAoB,CAAC,IAAI,CAAC,CAAC,CAAC;QAEpD,uEAAuE;QACvE,8CAA8C;QAC9C,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,GAAI,KAA0B,CAAC,CAAC;IAClD,CAAC;AACF,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,MAAiB,EAAE,QAAkB;IACtE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,GAAG,EAAE,EAAE,YAAY,GAAG,EAAE,EAAE,GAAG,QAAQ,CAAC;IAE9E,0EAA0E;IAC1E,wEAAwE;IACxE,6EAA6E;IAC7E,0CAA0C;IAC1C,iBAAiB,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;IAErC,6EAA6E;IAC7E,uCAAuC;IACvC,EAAE;IACF,4EAA4E;IAC5E,0EAA0E;IAC1E,wEAAwE;IACxE,mEAAmE;IACnE,KAAK,MAAM,EAAE,IAAI,EAAE,GAAG,EAAE,IAAI,OAAO,EAAE,CAAC;QACrC,MAAM,CAAC,YAAY,CAClB;YACC,IAAI;YACJ,KAAK,EAAE,GAAG,CAAC,KAAK;YAChB,WAAW,EAAE,GAAG,CAAC,WAAW;YAC5B,WAAW,EAAE,GAAG,CAAC,IAAI;YACrB,YAAY,EAAE,GAAG,CAAC,IAAI;YACtB,WAAW,EAAE,WAAW,CAAC,GAAG,CAAC,KAAK,EAAE,GAAG,CAAC,KAAK,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;YAClE,IAAI,EAAE;gBACL,SAAS,EAAE,IAAgB;gBAC3B,WAAW,EAAE,GAAG,CAAC,WAAW;gBAC5B,GAAG,EAAE;oBACJ,GAAG,GAAG,CAAC,GAAG;oBACV,eAAe,EAAE,eAAe,CAAC,GAAG,CAAC,GAAG,EAAE,YAAY,CAAC;iBACvD;aACD;SACD,EACD,KAAK,EAAE,KAAK,EAAE,EAAE;YACf,IAAI,CAAC;gBACJ,MAAM,IAAI,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,KAAc,CAAC,CAAC,CAAC,CAAC,KAAK,CAG9D,CAAC;gBACF,OAAO;oBACN,iBAAiB,EAAE,IAAI;oBACvB,OAAO,EAAE;wBACR;4BACC,IAAI,EAAE,MAAe;4BACrB,IAAI,EAAE,GAAG,CAAC,OAAO,EAAE,CAAC,IAAa,CAAC,IAAI,iBAAiB,CAAC,IAAI,CAAC;yBAC7D;qBACD;iBACD,CAAC;YACH,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBAChB,OAAO,WAAW,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;YACjC,CAAC;QACF,CAAC,CACD,CAAC;IACH,CAAC;IAED,KAAK,MAAM,EAAE,IAAI,EAAE,GAAG,EAAE,IAAI,KAAK,EAAE,CAAC;QACnC,MAAM,CAAC,YAAY,CAClB;YACC,IAAI;YACJ,KAAK,EAAE,GAAG,CAAC,KAAK;YAChB,WAAW,EAAE,GAAG,CAAC,WAAW;YAC5B,WAAW,EAAE,GAAG,CAAC,KAAK,IAAI,EAAE;YAC5B,YAAY,EAAE,GAAG,CAAC,MAAM;YACxB,WAAW,EAAE,WAAW,CAAC,GAAG,CAAC,KAAK,EAAE,GAAG,CAAC,KAAK,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC;SACnE,EACD,KAAK,EAAE,KAAK,EAAE,EAAE;YACf,IAAI,CAAC;gBACJ,OAAO,UAAU,CAAC,MAAM,GAAG,CAAC,GAAG,CAAC,KAAc,CAAC,CAAC,CAAC;YAClD,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBAChB,MAAM,OAAO,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;gBACvE,OAAO,CAAC,KAAK,CAAC,oBAAoB,IAAI,WAAW,EAAE,KAAK,CAAC,CAAC;gBAC1D,OAAO;oBACN,OAAO,EAAE,IAAa;oBACtB,OAAO,EAAE;wBACR;4BACC,IAAI,EAAE,MAAe;4BACrB,IAAI,EAAE,OAAO,IAAI,iBAAiB,OAAO,+EAA+E;yBACxH;qBACD;iBACD,CAAC;YACH,CAAC;QACF,CAAC,CACD,CAAC;IACH,CAAC;IAED,+EAA+E;IAC/E,6EAA6E;IAC7E,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QAC1B,MAAM,CAAC,YAAY,CAAC,EAAE,GAAG,IAAI,CAAC,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;IACxE,CAAC;IAED,4EAA4E;IAC5E,4EAA4E;IAC5E,iEAAiE;IACjE,OAAO,MAAM,CAAC;AACf,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@waniwani/kit",
|
|
3
|
-
"version": "0.1.
|
|
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.
|