@janux/server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/package.json +43 -0
- package/src/api.ts +103 -0
- package/src/html-shell.ts +67 -0
- package/src/index.ts +4 -0
- package/src/router.ts +81 -0
- package/src/server.test.ts +136 -0
- package/src/server.ts +207 -0
package/README.md
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
# @janux/server
|
|
2
|
+
|
|
3
|
+
Fullstack server for [Janux](https://github.com/aralroca/Janux): `api()` server functions that are simultaneously validated HTTP endpoints, typed client stubs and agent tools; file-system router; HTML shell with the 0-KB-JS static-page guarantee; `/_janux/*` endpoints (manifest, agent, proposals).
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@janux/server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Janux fullstack server: SSR, api() RPC endpoints that double as agent tools, manifest service and MCP surface.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/aralroca/Janux.git"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"author": {
|
|
11
|
+
"name": "Aral Roca Gomez",
|
|
12
|
+
"email": "contact@aralroca.com"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "./src/index.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.ts"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"janux": "0.1.0"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/aralroca/Janux#readme",
|
|
23
|
+
"bugs": "https://github.com/aralroca/Janux/issues",
|
|
24
|
+
"keywords": [
|
|
25
|
+
"janux",
|
|
26
|
+
"agentic-ui",
|
|
27
|
+
"mcp",
|
|
28
|
+
"webmcp",
|
|
29
|
+
"ai-agents",
|
|
30
|
+
"fullstack",
|
|
31
|
+
"framework",
|
|
32
|
+
"bun",
|
|
33
|
+
"vite",
|
|
34
|
+
"resumability",
|
|
35
|
+
"copilot"
|
|
36
|
+
],
|
|
37
|
+
"files": [
|
|
38
|
+
"src"
|
|
39
|
+
],
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { JxType, validate, toJsonSchema, JanuxIntentError } from 'janux';
|
|
2
|
+
import type { Ctx, Guard, GuardValue, Origin } from 'janux';
|
|
3
|
+
|
|
4
|
+
export interface ApiDef {
|
|
5
|
+
description?: string;
|
|
6
|
+
input?: JxType;
|
|
7
|
+
output?: JxType;
|
|
8
|
+
guard?: Guard;
|
|
9
|
+
run: (bag: { input: any; ctx: Ctx }) => unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ApiTool extends ApiDef {
|
|
13
|
+
name: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const API_MARK = Symbol.for('janux.api');
|
|
17
|
+
|
|
18
|
+
export type CallableApi = ((input?: unknown) => Promise<unknown>) & ApiDef;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Defines a server function that is simultaneously an HTTP endpoint, a typed
|
|
22
|
+
* client stub and an agent tool. The returned value is directly callable on
|
|
23
|
+
* the server (SSR sources, other apis); client bundles swap it for a fetch stub.
|
|
24
|
+
*/
|
|
25
|
+
export function api(def: ApiDef): CallableApi {
|
|
26
|
+
if (typeof def.run !== 'function') throw new Error('Janux: api() requires run()');
|
|
27
|
+
const callable = (input?: unknown) =>
|
|
28
|
+
invokeApi({ ...def, name: 'inline' }, input, {}, 'human');
|
|
29
|
+
|
|
30
|
+
return Object.assign(callable, def, { [API_MARK]: true }) as CallableApi;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isApi(value: unknown): value is ApiDef {
|
|
34
|
+
return value !== null && value !== undefined && API_MARK in Object(value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Collects api() exports from modules into namespaced tools: `{shop: mod}` → `api.shop.searchOrders`. */
|
|
38
|
+
export function collectApis(modules: Record<string, Record<string, unknown>>): ApiTool[] {
|
|
39
|
+
return Object.entries(modules).flatMap(([moduleName, mod]) =>
|
|
40
|
+
Object.entries(mod)
|
|
41
|
+
.filter(([, value]) => isApi(value))
|
|
42
|
+
.map(([exportName, value]) => {
|
|
43
|
+
if (`${moduleName}.${exportName}`.includes('__')) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`Janux: api name "${moduleName}.${exportName}" may not contain "__" (reserved for tool wire names)`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { ...(value as ApiDef), name: `${moduleName}.${exportName}` };
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function resolveApiGuard(tool: ApiTool, ctx: Ctx): GuardValue {
|
|
55
|
+
const guard = tool.guard ?? 'auto';
|
|
56
|
+
|
|
57
|
+
return typeof guard === 'function' ? guard({ ctx }) : guard;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseApiInput(tool: ApiTool, input: unknown): unknown {
|
|
61
|
+
if (!tool.input) return undefined;
|
|
62
|
+
const result = validate(tool.input, input ?? {});
|
|
63
|
+
|
|
64
|
+
if (!result.ok) {
|
|
65
|
+
const detail = result.errors.map((e) => `${e.path}: ${e.message}`).join('; ');
|
|
66
|
+
|
|
67
|
+
throw new JanuxIntentError('invalid_input', `Invalid input for "${tool.name}" — ${detail}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return result.value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Single invocation pipeline for api() tools: guard → validate → run → validate output. */
|
|
74
|
+
export async function invokeApi(tool: ApiTool, input: unknown, ctx: Ctx, origin: Origin): Promise<unknown> {
|
|
75
|
+
const guard = resolveApiGuard(tool, ctx);
|
|
76
|
+
|
|
77
|
+
if (origin === 'agent' && guard === 'forbidden') {
|
|
78
|
+
throw new JanuxIntentError('forbidden', `Tool "${tool.name}" is not available`);
|
|
79
|
+
}
|
|
80
|
+
const parsed = parseApiInput(tool, input);
|
|
81
|
+
const result = await tool.run({ input: parsed, ctx });
|
|
82
|
+
|
|
83
|
+
if (tool.output) {
|
|
84
|
+
const check = validate(tool.output, result);
|
|
85
|
+
|
|
86
|
+
if (!check.ok) throw new Error(`Janux: api "${tool.name}" returned an invalid output`);
|
|
87
|
+
|
|
88
|
+
return check.value;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function apiManifestTools(tools: ApiTool[], ctx: Ctx) {
|
|
95
|
+
return tools
|
|
96
|
+
.filter((tool) => resolveApiGuard(tool, ctx) !== 'forbidden')
|
|
97
|
+
.map((tool) => ({
|
|
98
|
+
name: `api.${tool.name}`,
|
|
99
|
+
description: tool.description,
|
|
100
|
+
guard: resolveApiGuard(tool, ctx),
|
|
101
|
+
input: tool.input ? toJsonSchema(tool.input) : undefined,
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export interface ShellOptions {
|
|
2
|
+
html: string;
|
|
3
|
+
title?: string;
|
|
4
|
+
snapshots: { uri: string; state: Record<string, unknown>; sources?: Record<string, unknown> }[];
|
|
5
|
+
islandNames: string[];
|
|
6
|
+
islandModules?: Record<string, string>;
|
|
7
|
+
runtimeUrl?: string;
|
|
8
|
+
manifestUrl?: string;
|
|
9
|
+
stylesheets?: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function safeJson(value: unknown): string {
|
|
13
|
+
return JSON.stringify(value).replace(/</g, '\\u003c');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function safeAttr(value: string): string {
|
|
17
|
+
return value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function stateScripts(snapshots: ShellOptions['snapshots']): string {
|
|
21
|
+
return snapshots
|
|
22
|
+
.map((snapshot) => {
|
|
23
|
+
const payload = safeJson({ state: snapshot.state, sources: snapshot.sources ?? {} });
|
|
24
|
+
|
|
25
|
+
return `<script type="application/janux+state" data-uri="${safeAttr(snapshot.uri)}">${payload}</script>`;
|
|
26
|
+
})
|
|
27
|
+
.join('\n');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function runtimeScripts(options: ShellOptions): string {
|
|
31
|
+
if (options.islandNames.length === 0) return '';
|
|
32
|
+
const modules = Object.fromEntries(
|
|
33
|
+
options.islandNames.map((name) => [name, options.islandModules?.[name] ?? '']),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
return [
|
|
37
|
+
`<script>window.__JANUX_ISLANDS__=${safeJson(modules)}</script>`,
|
|
38
|
+
options.runtimeUrl ? `<script type="module" src="${options.runtimeUrl}"></script>` : '',
|
|
39
|
+
].join('\n');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Full HTML document. Static pages (no islands) ship ZERO JavaScript:
|
|
44
|
+
* no runtime script, no import map, no state — just HTML.
|
|
45
|
+
*/
|
|
46
|
+
export function htmlDocument(options: ShellOptions): string {
|
|
47
|
+
const manifestLink = options.manifestUrl
|
|
48
|
+
? `<link rel="janux-manifest" href="${options.manifestUrl}">`
|
|
49
|
+
: '';
|
|
50
|
+
const styleLinks = (options.stylesheets ?? [])
|
|
51
|
+
.map((href) => `<link rel="stylesheet" href="${safeAttr(href)}">`)
|
|
52
|
+
.join('');
|
|
53
|
+
|
|
54
|
+
return [
|
|
55
|
+
'<!doctype html>',
|
|
56
|
+
'<html>',
|
|
57
|
+
`<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${options.title ?? 'Janux app'}</title>${manifestLink}${styleLinks}</head>`,
|
|
58
|
+
'<body>',
|
|
59
|
+
options.html,
|
|
60
|
+
options.islandNames.length > 0 ? stateScripts(options.snapshots) : '',
|
|
61
|
+
runtimeScripts(options),
|
|
62
|
+
'</body>',
|
|
63
|
+
'</html>',
|
|
64
|
+
]
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
.join('\n');
|
|
67
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { api, collectApis, invokeApi, apiManifestTools, isApi, type ApiDef, type ApiTool } from './api';
|
|
2
|
+
export { createJanuxServer, type ServerOptions, type AgentMount, type AgentDeps } from './server';
|
|
3
|
+
export { createFsRouter, type RouteMatch } from './router';
|
|
4
|
+
export { htmlDocument, type ShellOptions } from './html-shell';
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export interface Route {
|
|
5
|
+
pattern: string;
|
|
6
|
+
segments: string[];
|
|
7
|
+
filePath: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface RouteMatch {
|
|
11
|
+
filePath: string;
|
|
12
|
+
pattern: string;
|
|
13
|
+
params: Record<string, string>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const PAGE_EXTENSIONS = /\.(tsx|jsx|ts|js)$/;
|
|
17
|
+
|
|
18
|
+
function walk(dir: string): string[] {
|
|
19
|
+
return readdirSync(dir).flatMap((entry) => {
|
|
20
|
+
const full = join(dir, entry);
|
|
21
|
+
|
|
22
|
+
if (statSync(full).isDirectory()) return walk(full);
|
|
23
|
+
|
|
24
|
+
return PAGE_EXTENSIONS.test(entry) && !entry.startsWith('_') ? [full] : [];
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function patternFor(dir: string, filePath: string): string {
|
|
29
|
+
const raw = relative(dir, filePath).replace(PAGE_EXTENSIONS, '');
|
|
30
|
+
const withoutIndex = raw === 'index' ? '' : raw.replace(/\/index$/, '');
|
|
31
|
+
|
|
32
|
+
return `/${withoutIndex}`.replace(/\/+/g, '/');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function matchSegments(routeSegments: string[], pathSegments: string[]) {
|
|
36
|
+
if (routeSegments.length !== pathSegments.length) return undefined;
|
|
37
|
+
const params: Record<string, string> = {};
|
|
38
|
+
const matched = routeSegments.every((segment, index) => {
|
|
39
|
+
const value = pathSegments[index]!;
|
|
40
|
+
const dynamic = /^\[(.+)\]$/.exec(segment);
|
|
41
|
+
|
|
42
|
+
if (dynamic) {
|
|
43
|
+
params[dynamic[1]!] = decodeURIComponent(value);
|
|
44
|
+
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return segment === value;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return matched ? params : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** File-system router: `routes/index.tsx` → `/`, `routes/orders/[id].tsx` → `/orders/:id`. */
|
|
55
|
+
export function createFsRouter(dir: string) {
|
|
56
|
+
const routes: Route[] = walk(dir)
|
|
57
|
+
.map((filePath) => {
|
|
58
|
+
const pattern = patternFor(dir, filePath);
|
|
59
|
+
|
|
60
|
+
return { pattern, filePath, segments: pattern.split('/').filter(Boolean) };
|
|
61
|
+
})
|
|
62
|
+
.sort((a, b) => a.pattern.localeCompare(b.pattern));
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
routes,
|
|
66
|
+
match(pathname: string): RouteMatch | undefined {
|
|
67
|
+
const pathSegments = pathname.split('/').filter(Boolean);
|
|
68
|
+
const staticFirst = [...routes].sort(
|
|
69
|
+
(a, b) => Number(a.pattern.includes('[')) - Number(b.pattern.includes('[')),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
return staticFirst
|
|
73
|
+
.map((route) => {
|
|
74
|
+
const params = matchSegments(route.segments, pathSegments);
|
|
75
|
+
|
|
76
|
+
return params ? { filePath: route.filePath, pattern: route.pattern, params } : undefined;
|
|
77
|
+
})
|
|
78
|
+
.find(Boolean);
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { component, intent, jsx, schema, str, int, list } from 'janux';
|
|
3
|
+
import { api } from './api';
|
|
4
|
+
import { createJanuxServer } from './server';
|
|
5
|
+
|
|
6
|
+
const orders = [
|
|
7
|
+
{ id: 'o1', status: 'pending' },
|
|
8
|
+
{ id: 'o2', status: 'paid' },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const shopApis = {
|
|
12
|
+
searchOrders: api({
|
|
13
|
+
description: 'Search orders by status',
|
|
14
|
+
input: schema({ status: str() }),
|
|
15
|
+
run: ({ input }) => orders.filter((order) => order.status === input.status),
|
|
16
|
+
}),
|
|
17
|
+
refundOrder: api({
|
|
18
|
+
description: 'Refund an order. Irreversible.',
|
|
19
|
+
input: schema({ orderId: str() }),
|
|
20
|
+
guard: 'confirm',
|
|
21
|
+
run: ({ input }) => ({ refunded: input.orderId }),
|
|
22
|
+
}),
|
|
23
|
+
internal: api({ guard: 'forbidden', run: () => 'secret' }),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const cart = component({
|
|
27
|
+
name: 'cart',
|
|
28
|
+
state: schema({ items: list({ id: str(), qty: int() }) }),
|
|
29
|
+
intents: { add: intent({ input: schema({ id: str() }), run: ({ state, input }) => state.items.push({ id: input.id, qty: 1 }) }) },
|
|
30
|
+
view: ({ state }: any) => jsx('p', { children: `${state.items.length} items` }),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function Landing() {
|
|
34
|
+
return jsx('main', { children: jsx('h1', { children: 'Welcome' }) });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const server = createJanuxServer({
|
|
38
|
+
routes: {
|
|
39
|
+
'/': () => jsx(Landing as any, {}),
|
|
40
|
+
'/shop': () => jsx('div', { children: jsx(cart as any, {}) }),
|
|
41
|
+
},
|
|
42
|
+
apis: { shop: shopApis },
|
|
43
|
+
runtimeUrl: '/_janux/client.js',
|
|
44
|
+
islandModules: { cart: '/islands/cart.js' },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const get = (path: string) => server.fetch(new Request(`http://test${path}`));
|
|
48
|
+
const post = (path: string, body: unknown, headers: Record<string, string> = {}) =>
|
|
49
|
+
server.fetch(
|
|
50
|
+
new Request(`http://test${path}`, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
body: JSON.stringify(body),
|
|
53
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
describe('pages', () => {
|
|
58
|
+
it('serves static pages with ZERO JavaScript', async () => {
|
|
59
|
+
const html = await (await get('/')).text();
|
|
60
|
+
|
|
61
|
+
expect(html).toContain('<h1>Welcome</h1>');
|
|
62
|
+
expect(html).not.toContain('<script');
|
|
63
|
+
expect(html).toContain('rel="janux-manifest"');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('serves island pages with snapshots, island map and runtime', async () => {
|
|
67
|
+
const html = await (await get('/shop')).text();
|
|
68
|
+
|
|
69
|
+
expect(html).toContain('<janux-island data-jx="cart#default">');
|
|
70
|
+
expect(html).toContain('data-uri="ui://cart#default"');
|
|
71
|
+
expect(html).toContain('window.__JANUX_ISLANDS__={"cart":"/islands/cart.js"}');
|
|
72
|
+
expect(html).toContain('src="/_janux/client.js"');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('404s unknown routes', async () => {
|
|
76
|
+
expect((await get('/nope')).status).toBe(404);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('api endpoints', () => {
|
|
81
|
+
it('runs api tools with validated input', async () => {
|
|
82
|
+
const res = await post('/_janux/api/shop.searchOrders', { status: 'paid' });
|
|
83
|
+
const body: any = await res.json();
|
|
84
|
+
|
|
85
|
+
expect(body).toEqual({ ok: true, result: [{ id: 'o2', status: 'paid' }] });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('rejects invalid input with 400', async () => {
|
|
89
|
+
const res = await post('/_janux/api/shop.searchOrders', {});
|
|
90
|
+
|
|
91
|
+
expect(res.status).toBe(400);
|
|
92
|
+
expect(((await res.json()) as any).error).toContain('status: required');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('human origin runs confirm tools directly', async () => {
|
|
96
|
+
const body: any = await (await post('/_janux/api/shop.refundOrder', { orderId: 'o1' })).json();
|
|
97
|
+
|
|
98
|
+
expect(body.result).toEqual({ refunded: 'o1' });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('agent origin gets a proposal for confirm tools; approve executes it', async () => {
|
|
102
|
+
const res = await post('/_janux/api/shop.refundOrder', { orderId: 'o1' }, { 'x-janux-origin': 'agent' });
|
|
103
|
+
const proposal: any = ((await res.json()) as any).result;
|
|
104
|
+
|
|
105
|
+
expect(proposal.status).toBe('proposal');
|
|
106
|
+
const approved: any = await (await post('/_janux/approve', { id: proposal.id })).json();
|
|
107
|
+
|
|
108
|
+
expect(approved.result).toEqual({ refunded: 'o1' });
|
|
109
|
+
expect((await post('/_janux/approve', { id: proposal.id })).status).toBe(404);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('agent origin cannot call forbidden tools', async () => {
|
|
113
|
+
const res = await post('/_janux/api/shop.internal', {}, { 'x-janux-origin': 'agent' });
|
|
114
|
+
|
|
115
|
+
expect(res.status).toBe(403);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('manifest endpoint', () => {
|
|
120
|
+
it('merges mounted islands and api tools per route', async () => {
|
|
121
|
+
const manifest: any = await (await get('/_janux/manifest?path=/shop')).json();
|
|
122
|
+
const toolNames = manifest.tools.map((tool: any) => tool.name);
|
|
123
|
+
|
|
124
|
+
expect(manifest.resources.map((r: any) => r.uri)).toContain('ui://cart');
|
|
125
|
+
expect(toolNames).toContain('cart.add');
|
|
126
|
+
expect(toolNames).toContain('api.shop.searchOrders');
|
|
127
|
+
expect(toolNames).not.toContain('api.shop.internal');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('static routes expose only api tools', async () => {
|
|
131
|
+
const manifest: any = await (await get('/_janux/manifest?path=/')).json();
|
|
132
|
+
|
|
133
|
+
expect(manifest.resources).toEqual([]);
|
|
134
|
+
expect(manifest.tools.every((tool: any) => tool.name.startsWith('api.'))).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
});
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { buildManifest, renderToString, validate, type ComponentDef, type Ctx } from 'janux';
|
|
2
|
+
import { apiManifestTools, collectApis, invokeApi, resolveApiGuard, type ApiTool } from './api';
|
|
3
|
+
import { createFsRouter } from './router';
|
|
4
|
+
import { htmlDocument } from './html-shell';
|
|
5
|
+
|
|
6
|
+
export interface AgentMount {
|
|
7
|
+
handle(req: Request, deps: AgentDeps): Promise<Response>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface AgentDeps {
|
|
11
|
+
tools: ApiTool[];
|
|
12
|
+
invoke: (tool: string, input: unknown) => Promise<unknown>;
|
|
13
|
+
manifestFor: (path: string) => Promise<unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ServerOptions {
|
|
17
|
+
routesDir?: string;
|
|
18
|
+
loadRoute?: (filePath: string) => Promise<Record<string, unknown>>;
|
|
19
|
+
routes?: Record<string, (props: { ctx: Ctx; params: Record<string, string> }) => unknown>;
|
|
20
|
+
apis?: Record<string, Record<string, unknown>>;
|
|
21
|
+
storeDefs?: Record<string, ComponentDef>;
|
|
22
|
+
agent?: AgentMount;
|
|
23
|
+
ctxFor?: (req: Request) => Ctx | Promise<Ctx>;
|
|
24
|
+
runtimeUrl?: string;
|
|
25
|
+
islandModules?: Record<string, string>;
|
|
26
|
+
title?: string;
|
|
27
|
+
stylesheets?: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface PendingApiProposal {
|
|
31
|
+
id: string;
|
|
32
|
+
tool: string;
|
|
33
|
+
input: unknown;
|
|
34
|
+
execute: () => Promise<unknown>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function json(body: unknown, status = 200): Response {
|
|
38
|
+
return new Response(JSON.stringify(body), {
|
|
39
|
+
status,
|
|
40
|
+
headers: { 'content-type': 'application/json' },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const MAX_PENDING_PROPOSALS = 100;
|
|
45
|
+
|
|
46
|
+
function evictOldestProposal(proposals: Map<string, PendingApiProposal>): void {
|
|
47
|
+
if (proposals.size < MAX_PENDING_PROPOSALS) return;
|
|
48
|
+
const oldest = proposals.keys().next().value;
|
|
49
|
+
|
|
50
|
+
if (oldest) proposals.delete(oldest);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function assertValidInput(tool: { name: string; input?: any }, input: unknown): unknown {
|
|
54
|
+
const result = validate(tool.input, input ?? {});
|
|
55
|
+
|
|
56
|
+
if (!result.ok) {
|
|
57
|
+
const detail = result.errors.map((e: any) => `${e.path}: ${e.message}`).join('; ');
|
|
58
|
+
|
|
59
|
+
throw Object.assign(new Error(`Invalid input for "${tool.name}" — ${detail}`), {
|
|
60
|
+
code: 'invalid_input',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return result.value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function errorStatus(error: unknown): number {
|
|
68
|
+
const code = (error as any)?.code;
|
|
69
|
+
|
|
70
|
+
return code === 'forbidden' ? 403 : code === 'invalid_input' ? 400 : 500;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let proposalSeq = 0;
|
|
74
|
+
|
|
75
|
+
/** The Janux fullstack server: pages, api() endpoints, manifest, proposals and the agent mount. */
|
|
76
|
+
export function createJanuxServer(options: ServerOptions = {}) {
|
|
77
|
+
const apiTools = collectApis(options.apis ?? {});
|
|
78
|
+
const router = options.routesDir ? createFsRouter(options.routesDir) : undefined;
|
|
79
|
+
const loadRoute = options.loadRoute ?? ((filePath: string) => import(/* @vite-ignore */ filePath));
|
|
80
|
+
const proposals = new Map<string, PendingApiProposal>();
|
|
81
|
+
|
|
82
|
+
const resolveCtx = async (req: Request): Promise<Ctx> => (await options.ctxFor?.(req)) ?? {};
|
|
83
|
+
|
|
84
|
+
const findRoute = (pathname: string) => {
|
|
85
|
+
if (options.routes?.[pathname]) {
|
|
86
|
+
return { render: options.routes[pathname]!, params: {} as Record<string, string> };
|
|
87
|
+
}
|
|
88
|
+
const match = router?.match(pathname);
|
|
89
|
+
|
|
90
|
+
return match ? { load: match.filePath, params: match.params } : undefined;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const renderPage = async (pathname: string, ctx: Ctx) => {
|
|
94
|
+
const route = findRoute(pathname);
|
|
95
|
+
|
|
96
|
+
if (!route) return undefined;
|
|
97
|
+
const render = 'render' in route ? route.render : ((await loadRoute(route.load)) as any).default;
|
|
98
|
+
const vnode = render({ ctx, params: route.params });
|
|
99
|
+
|
|
100
|
+
return renderToString(vnode, { ctx, storeDefs: options.storeDefs });
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const manifestFor = async (pathname: string, ctx: Ctx): Promise<unknown> => {
|
|
104
|
+
const result = await renderPage(pathname, ctx);
|
|
105
|
+
const entries = result
|
|
106
|
+
? [
|
|
107
|
+
...result.registry.islands.map(({ def, key, instance }) => ({ def, key, instance })),
|
|
108
|
+
...[...result.registry.stores.values()].map((instance) => ({ def: instance.def, instance })),
|
|
109
|
+
]
|
|
110
|
+
: [];
|
|
111
|
+
const base = buildManifest(entries, ctx);
|
|
112
|
+
|
|
113
|
+
return { ...base, tools: [...base.tools, ...apiManifestTools(apiTools, ctx)] };
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const invokeTool = async (name: string, input: unknown, ctx: Ctx): Promise<unknown> => {
|
|
117
|
+
const tool = apiTools.find((candidate) => `api.${candidate.name}` === name || candidate.name === name);
|
|
118
|
+
|
|
119
|
+
if (!tool) throw Object.assign(new Error(`Unknown api tool "${name}"`), { code: 'invalid_input' });
|
|
120
|
+
|
|
121
|
+
return invokeApi(tool, input, ctx, 'agent');
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const handleApi = async (req: Request, name: string): Promise<Response> => {
|
|
125
|
+
const tool = apiTools.find((candidate) => candidate.name === name);
|
|
126
|
+
|
|
127
|
+
if (!tool) return json({ ok: false, error: `Unknown api "${name}"` }, 404);
|
|
128
|
+
const origin = req.headers.get('x-janux-origin') === 'agent' ? 'agent' : 'human';
|
|
129
|
+
const input = await req.json().catch(() => ({}));
|
|
130
|
+
const ctx = await resolveCtx(req);
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
if (origin === 'agent' && resolveApiGuard(tool, ctx) === 'confirm') {
|
|
134
|
+
const parsed = tool.input ? assertValidInput(tool, input) : input;
|
|
135
|
+
const id = `prop_api_${(proposalSeq += 1)}`;
|
|
136
|
+
|
|
137
|
+
evictOldestProposal(proposals);
|
|
138
|
+
proposals.set(id, { id, tool: tool.name, input: parsed, execute: () => invokeApi(tool, parsed, ctx, 'human') });
|
|
139
|
+
|
|
140
|
+
return json({ ok: true, result: { status: 'proposal', id, tool: tool.name, input: parsed } });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return json({ ok: true, result: await invokeApi(tool, input, ctx, origin) });
|
|
144
|
+
} catch (error) {
|
|
145
|
+
return json({ ok: false, error: String(error) }, errorStatus(error));
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const handleApprove = async (req: Request): Promise<Response> => {
|
|
150
|
+
const { id } = (await req.json().catch(() => ({}))) as { id?: string };
|
|
151
|
+
const proposal = id ? proposals.get(id) : undefined;
|
|
152
|
+
|
|
153
|
+
if (!proposal) return json({ ok: false, error: 'unknown proposal' }, 404);
|
|
154
|
+
proposals.delete(proposal.id);
|
|
155
|
+
|
|
156
|
+
return json({ ok: true, result: await proposal.execute() });
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const handlePage = async (req: Request, pathname: string): Promise<Response> => {
|
|
160
|
+
const ctx = await resolveCtx(req);
|
|
161
|
+
const result = await renderPage(pathname, ctx);
|
|
162
|
+
|
|
163
|
+
if (!result) return new Response('Not found', { status: 404 });
|
|
164
|
+
const islandNames = [...new Set(result.registry.islands.map(({ def }) => def.name))];
|
|
165
|
+
const html = htmlDocument({
|
|
166
|
+
html: result.html,
|
|
167
|
+
title: options.title,
|
|
168
|
+
snapshots: result.snapshots,
|
|
169
|
+
islandNames,
|
|
170
|
+
islandModules: options.islandModules,
|
|
171
|
+
runtimeUrl: islandNames.length > 0 ? options.runtimeUrl : undefined,
|
|
172
|
+
manifestUrl: `/_janux/manifest?path=${encodeURIComponent(pathname)}`,
|
|
173
|
+
stylesheets: options.stylesheets,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const fetch = async (req: Request): Promise<Response> => {
|
|
180
|
+
const url = new URL(req.url);
|
|
181
|
+
const { pathname } = url;
|
|
182
|
+
|
|
183
|
+
if (pathname.startsWith('/_janux/api/')) return handleApi(req, pathname.slice('/_janux/api/'.length));
|
|
184
|
+
if (pathname === '/_janux/approve') return handleApprove(req);
|
|
185
|
+
if (pathname === '/_janux/reject') {
|
|
186
|
+
const { id } = (await req.json().catch(() => ({}))) as { id?: string };
|
|
187
|
+
|
|
188
|
+
return json({ ok: id ? proposals.delete(id) : false });
|
|
189
|
+
}
|
|
190
|
+
if (pathname === '/_janux/manifest') {
|
|
191
|
+
return json(await manifestFor(url.searchParams.get('path') ?? '/', await resolveCtx(req)));
|
|
192
|
+
}
|
|
193
|
+
if (pathname === '/_janux/agent' && options.agent) {
|
|
194
|
+
const ctx = await resolveCtx(req);
|
|
195
|
+
|
|
196
|
+
return options.agent.handle(req, {
|
|
197
|
+
tools: apiTools,
|
|
198
|
+
invoke: (tool, input) => invokeTool(tool, input, ctx),
|
|
199
|
+
manifestFor: (path) => manifestFor(path, ctx),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return handlePage(req, pathname);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
return { fetch, apiTools, manifestFor };
|
|
207
|
+
}
|