janux 0.1.0 → 0.2.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/package.json +5 -2
- package/src/client/boot.test.ts +87 -0
- package/src/client/boot.ts +76 -2
- package/src/client/bridge.ts +55 -6
- package/src/client/glow.ts +74 -0
- package/src/client/index.ts +11 -0
- package/src/client/morph.ts +5 -0
- package/src/client/navigate.test.ts +172 -0
- package/src/client/navigate.ts +180 -0
- package/src/client/prefetch.ts +38 -0
- package/src/client/single-chunk.ts +14 -0
- package/src/client/webmcp.test.ts +201 -0
- package/src/client/webmcp.ts +165 -0
- package/src/define/factories.test.tsx +25 -0
- package/src/define/factories.ts +3 -2
- package/src/define/types.ts +9 -0
- package/src/index.ts +2 -1
- package/src/jsx-runtime.ts +3 -0
- package/src/render/server.test.ts +10 -0
- package/src/render/server.ts +3 -1
- package/src/runtime/effects.ts +7 -6
- package/src/runtime/instance.test.ts +23 -0
- package/src/runtime/instance.ts +8 -6
- package/src/runtime/intents.ts +7 -4
- package/src/state/mutation-gate.ts +32 -9
- package/src/state/reactive-state.test.ts +17 -11
- package/src/state/reactive-state.ts +8 -5
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import diff from 'diff-dom-streaming';
|
|
2
|
+
import { mountIsland, type MountContext } from './mount';
|
|
3
|
+
import { consumePrefetched } from './prefetch';
|
|
4
|
+
import { singleChunkStream } from './single-chunk';
|
|
5
|
+
|
|
6
|
+
export interface NavigateOptions {
|
|
7
|
+
signal?: AbortSignal;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const esc = (id: string): string =>
|
|
11
|
+
typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id;
|
|
12
|
+
|
|
13
|
+
function emitNavigate(phase: 'before' | 'after', from: string, to: string): void {
|
|
14
|
+
document.dispatchEvent(new CustomEvent('janux:navigate', { detail: { phase, from, to } }));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function throwIfAborted(signal?: AbortSignal): void {
|
|
18
|
+
if (signal?.aborted) throw new DOMException('navigation superseded', 'AbortError');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface PersistedIsland {
|
|
22
|
+
id: string;
|
|
23
|
+
node: Element;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* transition:persist strategy: mounted `persist` islands are lifted out of the
|
|
28
|
+
* document before the diff (live instance and DOM untouched) and grafted back
|
|
29
|
+
* over whatever the incoming page rendered for them — or disposed if it's gone.
|
|
30
|
+
*/
|
|
31
|
+
function extractPersisted(mount: MountContext): PersistedIsland[] {
|
|
32
|
+
return [...document.querySelectorAll('janux-island[data-jx-persist]')]
|
|
33
|
+
.filter((node) => mount.registry.mounted.has(node.getAttribute('data-jx') ?? ''))
|
|
34
|
+
.map((node) => {
|
|
35
|
+
node.remove();
|
|
36
|
+
|
|
37
|
+
return { id: node.getAttribute('data-jx')!, node };
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function restorePersisted(mount: MountContext, kept: PersistedIsland[]): Promise<void> {
|
|
42
|
+
await Promise.all(
|
|
43
|
+
kept.map(async ({ id, node }) => {
|
|
44
|
+
const incoming = document.querySelector(`janux-island[data-jx="${esc(id)}"]`);
|
|
45
|
+
|
|
46
|
+
if (incoming) incoming.replaceWith(node);
|
|
47
|
+
else await mount.registry.mounted.get(id)?.dispose();
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Store names still referenced by an island that survived the diff (persisted or newly resumed). */
|
|
53
|
+
function storesInUse(mount: MountContext): Set<string> {
|
|
54
|
+
const used = [...mount.registry.mounted.values()].flatMap((instance) =>
|
|
55
|
+
Object.values(instance.def.use ?? {}).map((store) => store.name),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
return new Set(used);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** After the diff: dispose route stores no surviving island still uses. Islands are swept by DOM state. */
|
|
62
|
+
async function disposeRouteStores(mount: MountContext): Promise<void> {
|
|
63
|
+
const { registry } = mount;
|
|
64
|
+
const keptStores = storesInUse(mount);
|
|
65
|
+
const dropped = [...registry.stores.entries()].filter(
|
|
66
|
+
([name, instance]) => instance.def.scope === 'route' && !keptStores.has(name),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
await Promise.all(dropped.map(([, instance]) => instance.dispose()));
|
|
70
|
+
dropped.forEach(([name]) => registry.stores.delete(name));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** App-store snapshots (already-embedded) survive; UI snapshots are replaced by the incoming page. */
|
|
74
|
+
function reindexSnapshots(mount: MountContext): void {
|
|
75
|
+
[...mount.registry.snapshots.keys()]
|
|
76
|
+
.filter((uri) => uri.startsWith('ui://'))
|
|
77
|
+
.forEach((uri) => mount.registry.snapshots.delete(uri));
|
|
78
|
+
document.querySelectorAll('script[type="application/janux+state"]').forEach((script) => {
|
|
79
|
+
const uri = script.getAttribute('data-uri');
|
|
80
|
+
|
|
81
|
+
if (!uri) return;
|
|
82
|
+
try {
|
|
83
|
+
mount.registry.snapshots.set(uri, JSON.parse(script.textContent ?? '{}'));
|
|
84
|
+
} catch {
|
|
85
|
+
document.dispatchEvent(new CustomEvent('janux:error', { detail: `invalid snapshot ${uri}` }));
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Islands marked `eager` mount without waiting for interaction (editors, event listeners…). */
|
|
91
|
+
export async function mountEagerIslands(mount: MountContext): Promise<void> {
|
|
92
|
+
const pending = [...document.querySelectorAll('janux-island[data-jx-eager]')].filter(
|
|
93
|
+
(node) => !mount.registry.mounted.has(node.getAttribute('data-jx') ?? ''),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
await Promise.all(pending.map((node) => mountIsland(node.getAttribute('data-jx')!, node, mount)));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function sweepDisconnected(mount: MountContext): Promise<void> {
|
|
100
|
+
const gone = [...mount.registry.mounted.entries()].filter(
|
|
101
|
+
([id]) => !document.querySelector(`janux-island[data-jx="${esc(id)}"]`)?.isConnected,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
await Promise.all(gone.map(([, instance]) => instance.dispose()));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function fetchStream(url: string, signal?: AbortSignal): Promise<ReadableStream<Uint8Array>> {
|
|
108
|
+
const cached = consumePrefetched(url);
|
|
109
|
+
|
|
110
|
+
if (cached) return cached;
|
|
111
|
+
const response = await fetch(url, { signal, headers: { accept: 'text/html' } });
|
|
112
|
+
|
|
113
|
+
if (!response.ok) throw new Error(`navigation fetch failed (${response.status})`);
|
|
114
|
+
|
|
115
|
+
// Buffer the whole page before diffing: a navigation fetches a complete,
|
|
116
|
+
// server-rendered page, so a single-chunk stream is deterministic and
|
|
117
|
+
// avoids the streaming diff's chunk-boundary edge cases (a swap is not SSR).
|
|
118
|
+
return singleChunkStream(await response.text());
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Diff-then-dispose: nothing is destroyed before the DOM actually changes, so
|
|
123
|
+
* an abort before/during the diff leaves the current page fully intact (kept
|
|
124
|
+
* persisted nodes are re-attached, no island disposed). Disposal happens after,
|
|
125
|
+
* driven by what the diff removed from the document.
|
|
126
|
+
*/
|
|
127
|
+
async function applyPage(mount: MountContext, stream: ReadableStream<Uint8Array>, signal?: AbortSignal): Promise<void> {
|
|
128
|
+
const kept = extractPersisted(mount);
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
throwIfAborted(signal);
|
|
132
|
+
// The Navigation API drives the transition; diff directly (its own would be skipped).
|
|
133
|
+
await diff(document, stream);
|
|
134
|
+
await restorePersisted(mount, kept);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
// Aborted/failed before the swap committed: put persisted nodes back untouched.
|
|
137
|
+
kept.forEach(({ node }) => {
|
|
138
|
+
if (!node.isConnected) document.body.appendChild(node);
|
|
139
|
+
});
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function runNavigation(url: string, mount: MountContext, options: NavigateOptions): Promise<void> {
|
|
145
|
+
const from = location.href;
|
|
146
|
+
|
|
147
|
+
emitNavigate('before', from, url);
|
|
148
|
+
try {
|
|
149
|
+
throwIfAborted(options.signal);
|
|
150
|
+
const stream = await fetchStream(url, options.signal);
|
|
151
|
+
|
|
152
|
+
throwIfAborted(options.signal);
|
|
153
|
+
await applyPage(mount, stream, options.signal);
|
|
154
|
+
reindexSnapshots(mount);
|
|
155
|
+
await sweepDisconnected(mount);
|
|
156
|
+
await disposeRouteStores(mount);
|
|
157
|
+
await mountEagerIslands(mount);
|
|
158
|
+
emitNavigate('after', from, url);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if ((error as any)?.name === 'AbortError') return;
|
|
161
|
+
document.dispatchEvent(new CustomEvent('janux:error', { detail: String(error) }));
|
|
162
|
+
location.href = url;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let navChain: Promise<void> = Promise.resolve();
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* SPA navigation: streams the next page's HTML and DIFFS it against the live
|
|
170
|
+
* document (diff-dom-streaming) — unchanged shells are never touched, persisted
|
|
171
|
+
* islands keep their live instances, and everything else re-resumes from the
|
|
172
|
+
* incoming snapshots, exactly like an initial load. Navigations are serialized:
|
|
173
|
+
* a superseded one (aborted by the Navigation API) finishes its cleanup before
|
|
174
|
+
* the next starts, so persisted islands are never lost to a race.
|
|
175
|
+
*/
|
|
176
|
+
export function performNavigation(url: string, mount: MountContext, options: NavigateOptions = {}): Promise<void> {
|
|
177
|
+
navChain = navChain.then(() => runNavigation(url, mount, options));
|
|
178
|
+
|
|
179
|
+
return navChain;
|
|
180
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
interface PrefetchEntry {
|
|
2
|
+
body: Promise<string>;
|
|
3
|
+
at: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const prefetched = new Map<string, PrefetchEntry>();
|
|
7
|
+
const PREFETCH_TTL = 30_000;
|
|
8
|
+
|
|
9
|
+
function isFresh(entry: PrefetchEntry | undefined): entry is PrefetchEntry {
|
|
10
|
+
return entry !== undefined && Date.now() - entry.at <= PREFETCH_TTL;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Warms the next page on link hover; entries expire after 30s. */
|
|
14
|
+
export function prefetch(url: string): void {
|
|
15
|
+
if (isFresh(prefetched.get(url))) return;
|
|
16
|
+
prefetched.set(url, {
|
|
17
|
+
at: Date.now(),
|
|
18
|
+
body: fetch(url, { headers: { accept: 'text/html' } }).then((response) =>
|
|
19
|
+
response.ok ? response.text() : Promise.reject(new Error('prefetch failed')),
|
|
20
|
+
),
|
|
21
|
+
});
|
|
22
|
+
prefetched.get(url)!.body.catch(() => prefetched.delete(url));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Returns a stream of the prefetched page if still fresh, evicting the entry either way. */
|
|
26
|
+
export function consumePrefetched(url: string): ReadableStream<Uint8Array> | undefined {
|
|
27
|
+
const entry = prefetched.get(url);
|
|
28
|
+
|
|
29
|
+
prefetched.delete(url);
|
|
30
|
+
if (!isFresh(entry)) return undefined;
|
|
31
|
+
|
|
32
|
+
return new ReadableStream({
|
|
33
|
+
async start(controller) {
|
|
34
|
+
controller.enqueue(new TextEncoder().encode(await entry.body));
|
|
35
|
+
controller.close();
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wraps already-buffered HTML into a one-chunk stream. Client navigation
|
|
3
|
+
* fetches a complete, server-rendered page, so streaming buys nothing here —
|
|
4
|
+
* and delivering it in a single chunk sidesteps the diff's chunk-boundary
|
|
5
|
+
* edge cases entirely, making every navigation deterministic.
|
|
6
|
+
*/
|
|
7
|
+
export function singleChunkStream(html: string): ReadableStream<Uint8Array> {
|
|
8
|
+
return new ReadableStream({
|
|
9
|
+
start(controller) {
|
|
10
|
+
controller.enqueue(new TextEncoder().encode(html));
|
|
11
|
+
controller.close();
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { GlobalRegistrator } from '@happy-dom/global-registrator';
|
|
2
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test';
|
|
3
|
+
import { component, intent } from '../define/factories';
|
|
4
|
+
import { jsx } from '../jsx-runtime';
|
|
5
|
+
import { int, schema } from '../schema';
|
|
6
|
+
import { renderToString } from '../render/server';
|
|
7
|
+
import { boot, type JanuxClient } from './boot';
|
|
8
|
+
import {
|
|
9
|
+
createModelContextPolyfill,
|
|
10
|
+
installWebMCP,
|
|
11
|
+
type ModelContextPolyfill,
|
|
12
|
+
type WebMCPHandle,
|
|
13
|
+
} from './webmcp';
|
|
14
|
+
|
|
15
|
+
beforeAll(() => GlobalRegistrator.register());
|
|
16
|
+
afterAll(() => GlobalRegistrator.unregister());
|
|
17
|
+
|
|
18
|
+
const counter = component({
|
|
19
|
+
name: 'counter',
|
|
20
|
+
state: schema({ n: int() }),
|
|
21
|
+
intents: {
|
|
22
|
+
inc: intent({ description: 'Increment', run: ({ state }) => (state.n += 1) }),
|
|
23
|
+
reset: intent({ guard: 'confirm', run: ({ state }) => (state.n = 0) }),
|
|
24
|
+
},
|
|
25
|
+
view: ({ state, intents }: any) =>
|
|
26
|
+
jsx('div', {
|
|
27
|
+
children: [
|
|
28
|
+
jsx('output', { children: `n=${state.n}` }),
|
|
29
|
+
jsx('button', { on: intents.inc, children: '+1' }),
|
|
30
|
+
],
|
|
31
|
+
}),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const ROUTE_MANIFEST = {
|
|
35
|
+
janux: '0.1.0',
|
|
36
|
+
resources: [],
|
|
37
|
+
tools: [
|
|
38
|
+
{
|
|
39
|
+
name: 'api.shop.pay',
|
|
40
|
+
description: 'Pay the order',
|
|
41
|
+
guard: 'confirm',
|
|
42
|
+
input: { type: 'object', properties: { total: { type: 'number' } } },
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
events: [],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const originalFetch = globalThis.fetch;
|
|
49
|
+
let manifestTools = ROUTE_MANIFEST.tools;
|
|
50
|
+
const fetchMock = mock(async (input: any, init?: RequestInit) => {
|
|
51
|
+
const url = String(input);
|
|
52
|
+
|
|
53
|
+
if (url.startsWith('/_janux/manifest')) {
|
|
54
|
+
return Response.json({ ...ROUTE_MANIFEST, tools: manifestTools });
|
|
55
|
+
}
|
|
56
|
+
if (url.startsWith('/_janux/api/')) return Response.json({ ok: true, result: 'paid' });
|
|
57
|
+
|
|
58
|
+
return originalFetch(input, init);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
async function serveAndBoot(): Promise<JanuxClient> {
|
|
62
|
+
const { html, snapshots } = await renderToString(jsx(counter as any, {}), {
|
|
63
|
+
initialState: { 'ui://counter#default': { n: 5 } },
|
|
64
|
+
});
|
|
65
|
+
const scripts = snapshots
|
|
66
|
+
.map(
|
|
67
|
+
(s) =>
|
|
68
|
+
`<script type="application/janux+state" data-uri="${s.uri}">${JSON.stringify({ state: s.state, sources: s.sources ?? {} })}</script>`,
|
|
69
|
+
)
|
|
70
|
+
.join('');
|
|
71
|
+
|
|
72
|
+
document.body.innerHTML = html + scripts;
|
|
73
|
+
|
|
74
|
+
return boot({ defs: [counter], webmcp: false });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function polyfillOf(): ModelContextPolyfill {
|
|
78
|
+
return (document as any).modelContext;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe('WebMCP integration', () => {
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
document.body.innerHTML = '';
|
|
84
|
+
delete (document as any).modelContext;
|
|
85
|
+
manifestTools = ROUTE_MANIFEST.tools;
|
|
86
|
+
fetchMock.mockClear();
|
|
87
|
+
globalThis.fetch = fetchMock as any;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
globalThis.fetch = originalFetch;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('polyfill: registers, lists, calls and honors AbortSignal', async () => {
|
|
95
|
+
const context = createModelContextPolyfill();
|
|
96
|
+
const controller = new AbortController();
|
|
97
|
+
|
|
98
|
+
context.registerTool(
|
|
99
|
+
{ name: 'echo', description: 'Echo', execute: (input) => input },
|
|
100
|
+
{ signal: controller.signal },
|
|
101
|
+
);
|
|
102
|
+
expect(context.listTools().map((tool) => tool.name)).toEqual(['echo']);
|
|
103
|
+
expect(await context.callTool('echo', { a: 1 })).toEqual({ a: 1 });
|
|
104
|
+
|
|
105
|
+
controller.abort();
|
|
106
|
+
expect(context.listTools()).toHaveLength(0);
|
|
107
|
+
expect(context.callTool('echo')).rejects.toThrow('unknown tool');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('polyfill: provideContext replaces the registered set', () => {
|
|
111
|
+
const context = createModelContextPolyfill();
|
|
112
|
+
|
|
113
|
+
context.registerTool({ name: 'old', execute: () => null });
|
|
114
|
+
context.provideContext!({ tools: [{ name: 'next', execute: () => null }] });
|
|
115
|
+
expect(context.listTools().map((tool) => tool.name)).toEqual(['next']);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('registers route manifest tools plus live local tools, with sanitized names', async () => {
|
|
119
|
+
const client = await serveAndBoot();
|
|
120
|
+
|
|
121
|
+
await client.call('counter.inc'); // mounts the island → local manifest is live
|
|
122
|
+
const handle = installWebMCP(client);
|
|
123
|
+
|
|
124
|
+
await handle.sync();
|
|
125
|
+
const names = polyfillOf()
|
|
126
|
+
.listTools()
|
|
127
|
+
.map((tool) => tool.name);
|
|
128
|
+
|
|
129
|
+
expect(names).toContain('api_shop_pay');
|
|
130
|
+
expect(names).toContain('counter_inc');
|
|
131
|
+
expect(names).toContain('counter_reset');
|
|
132
|
+
handle.dispose();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('executes UI tools through the bridge and annotates confirm guards', async () => {
|
|
136
|
+
const client = await serveAndBoot();
|
|
137
|
+
|
|
138
|
+
await client.call('counter.inc');
|
|
139
|
+
const handle = installWebMCP(client);
|
|
140
|
+
|
|
141
|
+
await handle.sync();
|
|
142
|
+
const result: any = await polyfillOf().callTool('counter_inc');
|
|
143
|
+
|
|
144
|
+
await client.settled();
|
|
145
|
+
expect(document.querySelector('output')!.textContent).toBe('n=7');
|
|
146
|
+
expect(result.content[0].type).toBe('text');
|
|
147
|
+
|
|
148
|
+
const reset = polyfillOf()
|
|
149
|
+
.listTools()
|
|
150
|
+
.find((tool) => tool.name === 'counter_reset')!;
|
|
151
|
+
|
|
152
|
+
expect(reset.description).toContain('human must approve');
|
|
153
|
+
handle.dispose();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('executes api.* tools over HTTP with the agent origin header', async () => {
|
|
157
|
+
const client = await serveAndBoot();
|
|
158
|
+
const handle = installWebMCP(client);
|
|
159
|
+
|
|
160
|
+
await handle.sync();
|
|
161
|
+
const result: any = await polyfillOf().callTool('api_shop_pay', { total: 25 });
|
|
162
|
+
|
|
163
|
+
expect(JSON.parse(result.content[0].text)).toEqual({ ok: true, result: 'paid' });
|
|
164
|
+
const apiCall = fetchMock.mock.calls.find(([url]) => String(url).startsWith('/_janux/api/'));
|
|
165
|
+
|
|
166
|
+
expect(String(apiCall![0])).toBe('/_janux/api/shop.pay');
|
|
167
|
+
expect((apiCall![1]!.headers as any)['x-janux-origin']).toBe('agent');
|
|
168
|
+
handle.dispose();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('re-syncs on SPA navigation without duplicating registrations', async () => {
|
|
172
|
+
const client = await serveAndBoot();
|
|
173
|
+
const handle = installWebMCP(client);
|
|
174
|
+
|
|
175
|
+
await handle.sync();
|
|
176
|
+
manifestTools = [{ ...ROUTE_MANIFEST.tools[0]!, name: 'api.shop.refund' }];
|
|
177
|
+
document.dispatchEvent(
|
|
178
|
+
new CustomEvent('janux:navigate', { detail: { phase: 'after', from: '/', to: '/next' } }),
|
|
179
|
+
);
|
|
180
|
+
await handle.sync();
|
|
181
|
+
const names = polyfillOf()
|
|
182
|
+
.listTools()
|
|
183
|
+
.map((tool) => tool.name);
|
|
184
|
+
|
|
185
|
+
expect(names).toContain('api_shop_refund');
|
|
186
|
+
expect(names).not.toContain('api_shop_pay');
|
|
187
|
+
handle.dispose();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('boot installs WebMCP by default (0 config)', async () => {
|
|
191
|
+
const { html } = await renderToString(jsx(counter as any, {}), {
|
|
192
|
+
initialState: { 'ui://counter#default': { n: 1 } },
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
document.body.innerHTML = html;
|
|
196
|
+
boot({ defs: [counter] });
|
|
197
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
198
|
+
expect(polyfillOf()).toBeDefined();
|
|
199
|
+
expect(polyfillOf().polyfilled).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { Manifest, ManifestTool } from '../manifest';
|
|
2
|
+
import type { JanuxBridge } from './bridge';
|
|
3
|
+
|
|
4
|
+
export interface WebMCPToolDescriptor {
|
|
5
|
+
name: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
inputSchema?: Record<string, unknown>;
|
|
8
|
+
execute: (input: unknown) => unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ModelContext {
|
|
12
|
+
registerTool(tool: WebMCPToolDescriptor, options?: { signal?: AbortSignal }): unknown;
|
|
13
|
+
provideContext?(context: { tools: WebMCPToolDescriptor[] }): unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ModelContextPolyfill extends ModelContext {
|
|
17
|
+
polyfilled: true;
|
|
18
|
+
/** Polyfill-only: enumerate registered tools (the real API keeps them internal). */
|
|
19
|
+
listTools(): WebMCPToolDescriptor[];
|
|
20
|
+
/** Polyfill-only: invoke a registered tool by name, gui-agent style. */
|
|
21
|
+
callTool(name: string, input?: unknown): Promise<unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface WebMCPHandle {
|
|
25
|
+
/** Re-reads the manifest and re-registers every tool. Runs automatically on SPA navigation. */
|
|
26
|
+
sync(): Promise<void>;
|
|
27
|
+
dispose(): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Spec-shaped stand-in for `document.modelContext` (WebMCP). Lets the same
|
|
32
|
+
* registration path — and any in-page agent or test — work in browsers that
|
|
33
|
+
* don't ship the API yet. Registrations honor AbortSignal like the real thing.
|
|
34
|
+
*/
|
|
35
|
+
export function createModelContextPolyfill(): ModelContextPolyfill {
|
|
36
|
+
const tools = new Map<string, WebMCPToolDescriptor>();
|
|
37
|
+
|
|
38
|
+
const registerTool = (tool: WebMCPToolDescriptor, options?: { signal?: AbortSignal }): void => {
|
|
39
|
+
if (options?.signal?.aborted) return;
|
|
40
|
+
tools.set(tool.name, tool);
|
|
41
|
+
options?.signal?.addEventListener('abort', () => {
|
|
42
|
+
if (tools.get(tool.name) === tool) tools.delete(tool.name);
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
polyfilled: true,
|
|
48
|
+
registerTool,
|
|
49
|
+
provideContext({ tools: next }) {
|
|
50
|
+
tools.clear();
|
|
51
|
+
next.forEach((tool) => registerTool(tool));
|
|
52
|
+
},
|
|
53
|
+
listTools: () => [...tools.values()],
|
|
54
|
+
async callTool(name, input) {
|
|
55
|
+
const tool = tools.get(name);
|
|
56
|
+
|
|
57
|
+
if (!tool) throw new Error(`WebMCP polyfill: unknown tool "${name}"`);
|
|
58
|
+
|
|
59
|
+
return tool.execute(input);
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The native context when the browser has one (Chrome 149+ behind a flag); the polyfill otherwise. */
|
|
65
|
+
function resolveModelContext(): ModelContext {
|
|
66
|
+
const doc = document as any;
|
|
67
|
+
const native = doc.modelContext ?? (navigator as any).modelContext;
|
|
68
|
+
|
|
69
|
+
if (native) return native;
|
|
70
|
+
|
|
71
|
+
return (doc.modelContext = createModelContextPolyfill());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The server knows the whole route (lazy islands, api() tools); unreachable → empty, fail-soft. */
|
|
75
|
+
async function routeTools(): Promise<ManifestTool[]> {
|
|
76
|
+
try {
|
|
77
|
+
const url = `/_janux/manifest?path=${encodeURIComponent(location.pathname)}`;
|
|
78
|
+
const response = await fetch(url, { headers: { accept: 'application/json' } });
|
|
79
|
+
|
|
80
|
+
if (!response.ok) return [];
|
|
81
|
+
|
|
82
|
+
return ((await response.json()) as Manifest).tools ?? [];
|
|
83
|
+
} catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function callServerTool(name: string, input: unknown): Promise<unknown> {
|
|
89
|
+
const response = await fetch(`/_janux/api/${name.slice('api.'.length)}`, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: { 'content-type': 'application/json', 'x-janux-origin': 'agent' },
|
|
92
|
+
body: JSON.stringify(input ?? {}),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return response.json();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function descriptorFor(tool: ManifestTool, bridge: JanuxBridge): WebMCPToolDescriptor {
|
|
99
|
+
const approval = tool.guard === 'confirm' ? ' Returns a proposal a human must approve.' : '';
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
name: tool.name.replace(/[^\w-]/g, '_'),
|
|
103
|
+
description: `${tool.description ?? `Janux tool ${tool.name}`}${approval}`,
|
|
104
|
+
inputSchema: tool.input ?? { type: 'object', properties: {} },
|
|
105
|
+
async execute(input) {
|
|
106
|
+
const result = tool.name.startsWith('api.')
|
|
107
|
+
? await callServerTool(tool.name, input)
|
|
108
|
+
: await bridge.call(tool.name, input);
|
|
109
|
+
|
|
110
|
+
return { content: [{ type: 'text', text: JSON.stringify(result ?? null) }] };
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Route manifest first, live local manifest on top (its `ready` state is current). */
|
|
116
|
+
function mergedTools(bridge: JanuxBridge, remote: ManifestTool[]): ManifestTool[] {
|
|
117
|
+
const byName = new Map(remote.map((tool) => [tool.name, tool]));
|
|
118
|
+
|
|
119
|
+
bridge.manifest().tools.forEach((tool) => byName.set(tool.name, tool));
|
|
120
|
+
|
|
121
|
+
return [...byName.values()];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Zero-config WebMCP: registers every mounted tool with `document.modelContext`
|
|
126
|
+
* (polyfilled when absent) and keeps the registration in sync across SPA
|
|
127
|
+
* navigations. Chrome's DevTools WebMCP panel then shows the Janux surface as-is.
|
|
128
|
+
*/
|
|
129
|
+
export function installWebMCP(bridge: JanuxBridge): WebMCPHandle {
|
|
130
|
+
const context = resolveModelContext();
|
|
131
|
+
let controller: AbortController | undefined;
|
|
132
|
+
let chain: Promise<void> = Promise.resolve();
|
|
133
|
+
|
|
134
|
+
const run = async (): Promise<void> => {
|
|
135
|
+
const tools = mergedTools(bridge, await routeTools());
|
|
136
|
+
|
|
137
|
+
controller?.abort();
|
|
138
|
+
controller = new AbortController();
|
|
139
|
+
for (const tool of tools) {
|
|
140
|
+
try {
|
|
141
|
+
await context.registerTool(descriptorFor(tool, bridge), { signal: controller.signal });
|
|
142
|
+
} catch {
|
|
143
|
+
// One rejected registration (schema quirks, duplicate names…) must not drop the rest.
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// Serialized like navigations: a re-sync never races the previous one's aborts.
|
|
149
|
+
const sync = (): Promise<void> => (chain = chain.then(run));
|
|
150
|
+
|
|
151
|
+
const onNavigate = (event: Event): void => {
|
|
152
|
+
if ((event as CustomEvent).detail?.phase === 'after') void sync();
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
document.addEventListener('janux:navigate', onNavigate);
|
|
156
|
+
void sync();
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
sync,
|
|
160
|
+
dispose() {
|
|
161
|
+
document.removeEventListener('janux:navigate', onNavigate);
|
|
162
|
+
void chain.then(() => controller?.abort());
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** @jsxImportSource .. */
|
|
2
|
+
import { describe, expect, it } from 'bun:test';
|
|
3
|
+
|
|
4
|
+
import { component } from './factories';
|
|
5
|
+
import { renderToString } from '../render/server';
|
|
6
|
+
|
|
7
|
+
const Island = component({
|
|
8
|
+
name: 'tag-island',
|
|
9
|
+
view: () => <p>hello</p>,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe('component() as a JSX tag', () => {
|
|
13
|
+
it('typechecks as an element and server-renders as an island', async () => {
|
|
14
|
+
const page = (
|
|
15
|
+
<main>
|
|
16
|
+
<Island eager />
|
|
17
|
+
</main>
|
|
18
|
+
);
|
|
19
|
+
const result = await renderToString(page);
|
|
20
|
+
|
|
21
|
+
expect(result.html).toContain('data-jx="tag-island#');
|
|
22
|
+
expect(result.html).toContain('data-jx-eager');
|
|
23
|
+
expect(result.html).toContain('<p>hello</p>');
|
|
24
|
+
});
|
|
25
|
+
});
|
package/src/define/factories.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
ComponentDef,
|
|
3
|
+
ComponentTag,
|
|
3
4
|
EffectDef,
|
|
4
5
|
IntentDef,
|
|
5
6
|
RefreshPolicy,
|
|
@@ -16,13 +17,13 @@ function assertName(name: unknown, kind: string): void {
|
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
/** Defines a bifacial component: view for humans, resource+tools for agents. */
|
|
19
|
-
export function component(def: ComponentInput):
|
|
20
|
+
export function component(def: ComponentInput): ComponentTag {
|
|
20
21
|
assertName(def.name, 'component()');
|
|
21
22
|
if (typeof def.view !== 'function') {
|
|
22
23
|
throw new Error(`Janux: component "${def.name}" requires a view`);
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
return Object.freeze({ ...def, kind: 'component' as const });
|
|
26
|
+
return Object.freeze({ ...def, kind: 'component' as const }) as ComponentTag;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
/** Defines a shared store: a bifacial component without a view. */
|
package/src/define/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { JxType } from '../schema';
|
|
2
|
+
import type { JanuxNode } from '../jsx-runtime';
|
|
2
3
|
|
|
3
4
|
export type GuardValue = 'auto' | 'confirm' | 'forbidden';
|
|
4
5
|
export type Guard = GuardValue | ((bag: { ctx: Ctx }) => GuardValue);
|
|
@@ -81,3 +82,11 @@ export interface ComponentDef {
|
|
|
81
82
|
scope?: 'app' | 'route';
|
|
82
83
|
persist?: 'local' | 'none';
|
|
83
84
|
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* ComponentDef with a phantom call signature so TSX accepts `<MyIsland />` as
|
|
88
|
+
* an element. The signature never exists at runtime: `component()` returns a
|
|
89
|
+
* frozen plain object, and the renderer checks `typeof $t === 'function'`
|
|
90
|
+
* before `isComponentDef`, so defs always take the island path.
|
|
91
|
+
*/
|
|
92
|
+
export type ComponentTag = ComponentDef & ((props?: Record<string, unknown>) => JanuxNode);
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export {
|
|
|
10
10
|
} from './define/factories';
|
|
11
11
|
export type {
|
|
12
12
|
ComponentDef,
|
|
13
|
+
ComponentTag,
|
|
13
14
|
IntentDef,
|
|
14
15
|
EffectDef,
|
|
15
16
|
SourceDef,
|
|
@@ -40,5 +41,5 @@ export {
|
|
|
40
41
|
export { signal, computed, effect as watch, batch, untrack } from './signals';
|
|
41
42
|
export { createInstance, type JanuxInstance, type InstanceOptions } from './runtime/instance';
|
|
42
43
|
export { createBus, type EventBus } from './runtime/bus';
|
|
43
|
-
export { JanuxIntentError, type AuditEntry, type Proposal } from './runtime/intents';
|
|
44
|
+
export { JanuxIntentError, resolveGuard, type AuditEntry, type Proposal } from './runtime/intents';
|
|
44
45
|
export { Fragment, jsx, jsxs, type JanuxNode } from './jsx-runtime';
|
package/src/jsx-runtime.ts
CHANGED