janux 0.1.0 → 0.2.1
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 +12 -0
- package/src/client/morph.ts +5 -0
- package/src/client/navigate-tool.test.ts +55 -0
- package/src/client/navigate-tool.ts +78 -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 +222 -0
- package/src/client/webmcp.ts +169 -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,169 @@
|
|
|
1
|
+
import type { Manifest, ManifestTool } from '../manifest';
|
|
2
|
+
import type { JanuxBridge } from './bridge';
|
|
3
|
+
import { createNavigateTool } from './navigate-tool';
|
|
4
|
+
|
|
5
|
+
export interface WebMCPToolDescriptor {
|
|
6
|
+
name: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
inputSchema?: Record<string, unknown>;
|
|
9
|
+
execute: (input: unknown) => unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ModelContext {
|
|
13
|
+
registerTool(tool: WebMCPToolDescriptor, options?: { signal?: AbortSignal }): unknown;
|
|
14
|
+
provideContext?(context: { tools: WebMCPToolDescriptor[] }): unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ModelContextPolyfill extends ModelContext {
|
|
18
|
+
polyfilled: true;
|
|
19
|
+
/** Polyfill-only: enumerate registered tools (the real API keeps them internal). */
|
|
20
|
+
listTools(): WebMCPToolDescriptor[];
|
|
21
|
+
/** Polyfill-only: invoke a registered tool by name, gui-agent style. */
|
|
22
|
+
callTool(name: string, input?: unknown): Promise<unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface WebMCPHandle {
|
|
26
|
+
/** Re-reads the manifest and re-registers every tool. Runs automatically on SPA navigation. */
|
|
27
|
+
sync(): Promise<void>;
|
|
28
|
+
dispose(): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Spec-shaped stand-in for `document.modelContext` (WebMCP). Lets the same
|
|
33
|
+
* registration path — and any in-page agent or test — work in browsers that
|
|
34
|
+
* don't ship the API yet. Registrations honor AbortSignal like the real thing.
|
|
35
|
+
*/
|
|
36
|
+
export function createModelContextPolyfill(): ModelContextPolyfill {
|
|
37
|
+
const tools = new Map<string, WebMCPToolDescriptor>();
|
|
38
|
+
|
|
39
|
+
const registerTool = (tool: WebMCPToolDescriptor, options?: { signal?: AbortSignal }): void => {
|
|
40
|
+
if (options?.signal?.aborted) return;
|
|
41
|
+
tools.set(tool.name, tool);
|
|
42
|
+
options?.signal?.addEventListener('abort', () => {
|
|
43
|
+
if (tools.get(tool.name) === tool) tools.delete(tool.name);
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
polyfilled: true,
|
|
49
|
+
registerTool,
|
|
50
|
+
provideContext({ tools: next }) {
|
|
51
|
+
tools.clear();
|
|
52
|
+
next.forEach((tool) => registerTool(tool));
|
|
53
|
+
},
|
|
54
|
+
listTools: () => [...tools.values()],
|
|
55
|
+
async callTool(name, input) {
|
|
56
|
+
const tool = tools.get(name);
|
|
57
|
+
|
|
58
|
+
if (!tool) throw new Error(`WebMCP polyfill: unknown tool "${name}"`);
|
|
59
|
+
|
|
60
|
+
return tool.execute(input);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The native context when the browser has one (Chrome 149+ behind a flag); the polyfill otherwise. */
|
|
66
|
+
function resolveModelContext(): ModelContext {
|
|
67
|
+
const doc = document as any;
|
|
68
|
+
const native = doc.modelContext ?? (navigator as any).modelContext;
|
|
69
|
+
|
|
70
|
+
if (native) return native;
|
|
71
|
+
|
|
72
|
+
return (doc.modelContext = createModelContextPolyfill());
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The server knows the whole route (lazy islands, api() tools); unreachable → empty, fail-soft. */
|
|
76
|
+
async function routeTools(): Promise<ManifestTool[]> {
|
|
77
|
+
try {
|
|
78
|
+
const url = `/_janux/manifest?path=${encodeURIComponent(location.pathname)}`;
|
|
79
|
+
const response = await fetch(url, { headers: { accept: 'application/json' } });
|
|
80
|
+
|
|
81
|
+
if (!response.ok) return [];
|
|
82
|
+
|
|
83
|
+
return ((await response.json()) as Manifest).tools ?? [];
|
|
84
|
+
} catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function callServerTool(name: string, input: unknown): Promise<unknown> {
|
|
90
|
+
const response = await fetch(`/_janux/api/${name.slice('api.'.length)}`, {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: { 'content-type': 'application/json', 'x-janux-origin': 'agent' },
|
|
93
|
+
body: JSON.stringify(input ?? {}),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return response.json();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function descriptorFor(tool: ManifestTool, bridge: JanuxBridge): WebMCPToolDescriptor {
|
|
100
|
+
const approval = tool.guard === 'confirm' ? ' Returns a proposal a human must approve.' : '';
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
name: tool.name.replace(/[^\w-]/g, '_'),
|
|
104
|
+
description: `${tool.description ?? `Janux tool ${tool.name}`}${approval}`,
|
|
105
|
+
inputSchema: tool.input ?? { type: 'object', properties: {} },
|
|
106
|
+
async execute(input) {
|
|
107
|
+
const result = tool.name.startsWith('api.')
|
|
108
|
+
? await callServerTool(tool.name, input)
|
|
109
|
+
: await bridge.call(tool.name, input);
|
|
110
|
+
|
|
111
|
+
return { content: [{ type: 'text', text: JSON.stringify(result ?? null) }] };
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Route manifest first, live local manifest on top (its `ready` state is current). */
|
|
117
|
+
function mergedTools(bridge: JanuxBridge, remote: ManifestTool[]): ManifestTool[] {
|
|
118
|
+
const byName = new Map(remote.map((tool) => [tool.name, tool]));
|
|
119
|
+
|
|
120
|
+
bridge.manifest().tools.forEach((tool) => byName.set(tool.name, tool));
|
|
121
|
+
|
|
122
|
+
return [...byName.values()];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Zero-config WebMCP: registers every mounted tool with `document.modelContext`
|
|
127
|
+
* (polyfilled when absent) and keeps the registration in sync across SPA
|
|
128
|
+
* navigations. Chrome's DevTools WebMCP panel then shows the Janux surface as-is.
|
|
129
|
+
*/
|
|
130
|
+
export function installWebMCP(bridge: JanuxBridge): WebMCPHandle {
|
|
131
|
+
const context = resolveModelContext();
|
|
132
|
+
let controller: AbortController | undefined;
|
|
133
|
+
let chain: Promise<void> = Promise.resolve();
|
|
134
|
+
|
|
135
|
+
const run = async (): Promise<void> => {
|
|
136
|
+
const tools = mergedTools(bridge, await routeTools());
|
|
137
|
+
// An app tool named `navigate` owns the name; the built-in steps aside.
|
|
138
|
+
const taken = tools.some((tool) => tool.name.replace(/[^\w-]/g, '_') === 'navigate');
|
|
139
|
+
const descriptors = [...(taken ? [] : [createNavigateTool()]), ...tools.map((tool) => descriptorFor(tool, bridge))];
|
|
140
|
+
|
|
141
|
+
controller?.abort();
|
|
142
|
+
controller = new AbortController();
|
|
143
|
+
for (const descriptor of descriptors) {
|
|
144
|
+
try {
|
|
145
|
+
await context.registerTool(descriptor, { signal: controller.signal });
|
|
146
|
+
} catch {
|
|
147
|
+
// One rejected registration (schema quirks, duplicate names…) must not drop the rest.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// Serialized like navigations: a re-sync never races the previous one's aborts.
|
|
153
|
+
const sync = (): Promise<void> => (chain = chain.then(run));
|
|
154
|
+
|
|
155
|
+
const onNavigate = (event: Event): void => {
|
|
156
|
+
if ((event as CustomEvent).detail?.phase === 'after') void sync();
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
document.addEventListener('janux:navigate', onNavigate);
|
|
160
|
+
void sync();
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
sync,
|
|
164
|
+
dispose() {
|
|
165
|
+
document.removeEventListener('janux:navigate', onNavigate);
|
|
166
|
+
void chain.then(() => controller?.abort());
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -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
|
@@ -75,6 +75,16 @@ describe('renderToString', () => {
|
|
|
75
75
|
expect(result.html).toBe('<input value="x"/>');
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
+
it('marks persist and eager islands with data attributes (SPA navigation)', async () => {
|
|
79
|
+
const persisted = await renderToString(jsx(cart as any, { persist: true }));
|
|
80
|
+
const eager = await renderToString(jsx(cart as any, { eager: true }));
|
|
81
|
+
const plain = await renderToString(jsx(cart as any, {}));
|
|
82
|
+
|
|
83
|
+
expect(persisted.html).toContain('data-jx="cart#default" data-jx-persist>');
|
|
84
|
+
expect(eager.html).toContain('data-jx="cart#default" data-jx-eager>');
|
|
85
|
+
expect(plain.html).toContain('data-jx="cart#default">');
|
|
86
|
+
});
|
|
87
|
+
|
|
78
88
|
it('escapes attacker-controlled island keys (XSS regression)', async () => {
|
|
79
89
|
const evil = 'x"><img src=x onerror=alert(1)>';
|
|
80
90
|
const result = await renderToString(jsx(cart as any, { key: evil }));
|
package/src/render/server.ts
CHANGED
|
@@ -69,8 +69,10 @@ async function renderIsland(def: ComponentDef, props: any, scope: RenderScope):
|
|
|
69
69
|
await loadSources(instance);
|
|
70
70
|
scope.registry.islands.push({ def, key, instance });
|
|
71
71
|
const inner = await renderNode(def.view!(instance.bag), scope);
|
|
72
|
+
const persist = props.persist ? ' data-jx-persist' : '';
|
|
73
|
+
const eager = props.eager ? ' data-jx-eager' : '';
|
|
72
74
|
|
|
73
|
-
return `<janux-island data-jx="${escapeHtml(`${def.name}#${key}`)}">${inner}</janux-island>`;
|
|
75
|
+
return `<janux-island data-jx="${escapeHtml(`${def.name}#${key}`)}"${persist}${eager}>${inner}</janux-island>`;
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
async function renderElement(node: JanuxNode, scope: RenderScope): Promise<string> {
|
package/src/runtime/effects.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { computed, effect as watch, untrack } from '../signals';
|
|
2
|
-
import {
|
|
2
|
+
import { withGate, type MutationGate } from '../state/mutation-gate';
|
|
3
3
|
import { parseDuration } from '../define/factories';
|
|
4
4
|
import type { Cleanup, EffectDef, RunBag } from '../define/types';
|
|
5
5
|
import type { PendingTracker } from './settled';
|
|
@@ -8,8 +8,8 @@ interface EffectRuntime {
|
|
|
8
8
|
dispose: () => void;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
function runEffectBody(def: EffectDef, bag: RunBag, tracker: PendingTracker): Cleanup {
|
|
12
|
-
const result =
|
|
11
|
+
function runEffectBody(def: EffectDef, bag: RunBag, tracker: PendingTracker, gate: MutationGate): Cleanup {
|
|
12
|
+
const result = withGate(gate, () => def.run(bag));
|
|
13
13
|
|
|
14
14
|
if (result instanceof Promise) {
|
|
15
15
|
tracker.track(result);
|
|
@@ -20,7 +20,7 @@ function runEffectBody(def: EffectDef, bag: RunBag, tracker: PendingTracker): Cl
|
|
|
20
20
|
return typeof result === 'function' ? result : undefined;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
function startOne(def: EffectDef, bag: RunBag, tracker: PendingTracker): EffectRuntime {
|
|
23
|
+
function startOne(def: EffectDef, bag: RunBag, tracker: PendingTracker, gate: MutationGate): EffectRuntime {
|
|
24
24
|
const debounceMs = def.debounce ? parseDuration(def.debounce) : 0;
|
|
25
25
|
let cleanup: Cleanup;
|
|
26
26
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -29,7 +29,7 @@ function startOne(def: EffectDef, bag: RunBag, tracker: PendingTracker): EffectR
|
|
|
29
29
|
|
|
30
30
|
const runNow = (): void => {
|
|
31
31
|
cleanup?.();
|
|
32
|
-
cleanup = untrack(() => runEffectBody(def, bag, tracker));
|
|
32
|
+
cleanup = untrack(() => runEffectBody(def, bag, tracker, gate));
|
|
33
33
|
};
|
|
34
34
|
|
|
35
35
|
const schedule = (): void => {
|
|
@@ -73,8 +73,9 @@ export function startEffects(
|
|
|
73
73
|
defs: Record<string, EffectDef> | undefined,
|
|
74
74
|
bag: RunBag,
|
|
75
75
|
tracker: PendingTracker,
|
|
76
|
+
gate: MutationGate,
|
|
76
77
|
): () => void {
|
|
77
|
-
const running = Object.values(defs ?? {}).map((def) => startOne(def, bag, tracker));
|
|
78
|
+
const running = Object.values(defs ?? {}).map((def) => startOne(def, bag, tracker, gate));
|
|
78
79
|
|
|
79
80
|
return () => running.forEach((runtime) => runtime.dispose());
|
|
80
81
|
}
|
|
@@ -48,6 +48,29 @@ describe('instance: state, derived, intents', () => {
|
|
|
48
48
|
expect(cart.derived.count).toBe(3);
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
+
it('async intents may mutate state after awaits (regression)', async () => {
|
|
52
|
+
const def = component({
|
|
53
|
+
name: 'async-cart',
|
|
54
|
+
state: schema({ items: list({ id: str(), qty: int() }), coupon: str().nullable() }),
|
|
55
|
+
intents: {
|
|
56
|
+
addThenCoupon: intent({
|
|
57
|
+
run: async ({ state }: any) => {
|
|
58
|
+
state.items.push({ id: 'a', qty: 1 });
|
|
59
|
+
await Promise.resolve();
|
|
60
|
+
state.coupon = 'SAVE10';
|
|
61
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
62
|
+
state.items[0].qty = 2;
|
|
63
|
+
},
|
|
64
|
+
}),
|
|
65
|
+
},
|
|
66
|
+
view: noopView,
|
|
67
|
+
});
|
|
68
|
+
const cart = createInstance(def);
|
|
69
|
+
|
|
70
|
+
await cart.intents.addThenCoupon!({});
|
|
71
|
+
expect(cart.snapshot()).toEqual({ items: [{ id: 'a', qty: 2 }], coupon: 'SAVE10' });
|
|
72
|
+
});
|
|
73
|
+
|
|
51
74
|
it('validates intent input against its schema', async () => {
|
|
52
75
|
const cart = createInstance(cartDef());
|
|
53
76
|
|
package/src/runtime/instance.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { buildDefault, toJsonSchema, validate } from '../schema';
|
|
2
2
|
import { computed, untrack, type ReadonlySig } from '../signals';
|
|
3
3
|
import { createReactiveState } from '../state/reactive-state';
|
|
4
|
-
import {
|
|
4
|
+
import { createGate, withGate } from '../state/mutation-gate';
|
|
5
5
|
import type { ComponentDef, Ctx, Origin, RunBag, StoreHandle } from '../define/types';
|
|
6
6
|
import { createBus, type EventBus } from './bus';
|
|
7
7
|
import { createPendingTracker } from './settled';
|
|
@@ -79,7 +79,8 @@ export function createInstance(def: ComponentDef, options: InstanceOptions = {})
|
|
|
79
79
|
const bus = options.bus ?? createBus();
|
|
80
80
|
const tracker = createPendingTracker();
|
|
81
81
|
const initial = options.initial ?? (def.state ? (buildDefault(def.state) as any) : {});
|
|
82
|
-
const
|
|
82
|
+
const gate = createGate();
|
|
83
|
+
const state = createReactiveState(initial as Record<string, unknown>, gate);
|
|
83
84
|
const sourcesRuntime = createSources(def.sources, ctx, bus, tracker, options.initialSources);
|
|
84
85
|
const { readers: derived, dispose: disposeDerived } = derivedReaders(def, state.proxy);
|
|
85
86
|
const emit = makeEmit(def, bus);
|
|
@@ -98,6 +99,7 @@ export function createInstance(def: ComponentDef, options: InstanceOptions = {})
|
|
|
98
99
|
ctx,
|
|
99
100
|
};
|
|
100
101
|
const hooks = {
|
|
102
|
+
gate,
|
|
101
103
|
onAudit: options.onAudit,
|
|
102
104
|
onProposal: options.onProposal,
|
|
103
105
|
trackPending: tracker.track,
|
|
@@ -113,7 +115,7 @@ export function createInstance(def: ComponentDef, options: InstanceOptions = {})
|
|
|
113
115
|
|
|
114
116
|
let stopEffects: (() => void) | undefined;
|
|
115
117
|
const busUnsubs = Object.entries(def.on ?? {}).map(([event, handler]) =>
|
|
116
|
-
bus.on(event, (payload) =>
|
|
118
|
+
bus.on(event, (payload) => withGate(gate, () => handler({ ...bag, event: payload }))),
|
|
117
119
|
);
|
|
118
120
|
|
|
119
121
|
return {
|
|
@@ -148,15 +150,15 @@ export function createInstance(def: ComponentDef, options: InstanceOptions = {})
|
|
|
148
150
|
},
|
|
149
151
|
async attach() {
|
|
150
152
|
sourcesRuntime.start();
|
|
151
|
-
stopEffects = startEffects(def.effects, bag, tracker);
|
|
152
|
-
await
|
|
153
|
+
stopEffects = startEffects(def.effects, bag, tracker, gate);
|
|
154
|
+
await withGate(gate, () => def.lifecycle?.attach?.(bag));
|
|
153
155
|
},
|
|
154
156
|
async dispose() {
|
|
155
157
|
stopEffects?.();
|
|
156
158
|
sourcesRuntime.dispose();
|
|
157
159
|
disposeDerived();
|
|
158
160
|
busUnsubs.forEach((unsub) => unsub());
|
|
159
|
-
await
|
|
161
|
+
await withGate(gate, () => def.lifecycle?.detach?.(bag));
|
|
160
162
|
},
|
|
161
163
|
handle(): StoreHandle {
|
|
162
164
|
return { state: state.proxy, derived, intents: bindHumanIntents(intents) };
|
package/src/runtime/intents.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { validate } from '../schema';
|
|
2
|
-
import {
|
|
2
|
+
import { withGate, type MutationGate } from '../state/mutation-gate';
|
|
3
3
|
import type { ComponentDef, Ctx, GuardValue, IntentDef, Origin, RunBag } from '../define/types';
|
|
4
4
|
|
|
5
5
|
export interface AuditEntry {
|
|
@@ -10,6 +10,8 @@ export interface AuditEntry {
|
|
|
10
10
|
ok: boolean;
|
|
11
11
|
error?: string;
|
|
12
12
|
at: number;
|
|
13
|
+
/** Verified Web Bot Auth key id, when the caller is an authenticated agent. */
|
|
14
|
+
agent?: string;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export interface Proposal {
|
|
@@ -20,6 +22,7 @@ export interface Proposal {
|
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export interface IntentHooks {
|
|
25
|
+
gate: MutationGate;
|
|
23
26
|
onAudit?: (entry: AuditEntry) => void;
|
|
24
27
|
onProposal?: (proposal: Proposal) => void;
|
|
25
28
|
trackPending: <T>(work: Promise<T>) => Promise<T>;
|
|
@@ -71,8 +74,8 @@ function audit(hooks: IntentHooks, entry: Omit<AuditEntry, 'at'>): void {
|
|
|
71
74
|
hooks.onAudit?.({ ...entry, at: Date.now() });
|
|
72
75
|
}
|
|
73
76
|
|
|
74
|
-
async function execute(def: IntentDef, bag: RunBag, input: unknown): Promise<unknown> {
|
|
75
|
-
return
|
|
77
|
+
async function execute(def: IntentDef, bag: RunBag, input: unknown, gate: MutationGate): Promise<unknown> {
|
|
78
|
+
return withGate(gate, () => def.run({ ...bag, input }));
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
function propose(tool: string, input: unknown, run: () => Promise<unknown>, hooks: IntentHooks) {
|
|
@@ -102,7 +105,7 @@ export async function invokeIntent(
|
|
|
102
105
|
}
|
|
103
106
|
checkInvocable(tool, def, bag);
|
|
104
107
|
const parsed = parseInput(tool, def, input);
|
|
105
|
-
const run = () => hooks.trackPending(execute(def, bag, parsed));
|
|
108
|
+
const run = () => hooks.trackPending(execute(def, bag, parsed, hooks.gate));
|
|
106
109
|
|
|
107
110
|
if (origin === 'agent' && guard === 'confirm') {
|
|
108
111
|
audit(hooks, { tool, origin, guard, input: parsed, ok: true });
|
|
@@ -1,17 +1,40 @@
|
|
|
1
|
-
|
|
1
|
+
export interface MutationGate {
|
|
2
|
+
depth: number;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function createGate(): MutationGate {
|
|
6
|
+
return { depth: 0 };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Runs `fn` with mutations enabled on ONE instance's gate. For async bodies
|
|
11
|
+
* the gate stays open until the promise settles, so `run()` may mutate after
|
|
12
|
+
* `await`s. Per-instance scoping means an in-flight async intent never opens
|
|
13
|
+
* the door for other components' state. A development guardrail, not an
|
|
14
|
+
* isolation boundary.
|
|
15
|
+
*/
|
|
16
|
+
export function withGate<T>(gate: MutationGate, fn: () => T): T {
|
|
17
|
+
gate.depth += 1;
|
|
18
|
+
let result: T;
|
|
2
19
|
|
|
3
|
-
/** Runs `fn` with state mutations enabled (used by intent/effect/event runners). */
|
|
4
|
-
export function allowMutations<T>(fn: () => T): T {
|
|
5
|
-
mutationDepth += 1;
|
|
6
20
|
try {
|
|
7
|
-
|
|
8
|
-
}
|
|
9
|
-
|
|
21
|
+
result = fn();
|
|
22
|
+
} catch (error) {
|
|
23
|
+
gate.depth -= 1;
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
if (result instanceof Promise) {
|
|
27
|
+
return result.finally(() => {
|
|
28
|
+
gate.depth -= 1;
|
|
29
|
+
}) as T;
|
|
10
30
|
}
|
|
31
|
+
gate.depth -= 1;
|
|
32
|
+
|
|
33
|
+
return result;
|
|
11
34
|
}
|
|
12
35
|
|
|
13
|
-
export function assertMutable(path: string): void {
|
|
14
|
-
if (
|
|
36
|
+
export function assertMutable(gate: MutationGate, path: string): void {
|
|
37
|
+
if (gate.depth > 0) return;
|
|
15
38
|
|
|
16
39
|
throw new Error(
|
|
17
40
|
`Janux: illegal mutation of "${path}" outside an intent, effect or event handler. ` +
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it, mock } from 'bun:test';
|
|
2
2
|
import { effect } from '../signals';
|
|
3
|
-
import {
|
|
3
|
+
import { createGate, withGate } from './mutation-gate';
|
|
4
4
|
import { createReactiveState } from './reactive-state';
|
|
5
5
|
|
|
6
6
|
const initial = () => ({
|
|
@@ -10,14 +10,16 @@ const initial = () => ({
|
|
|
10
10
|
|
|
11
11
|
describe('reactive state', () => {
|
|
12
12
|
it('reads initial data through the proxy', () => {
|
|
13
|
-
const
|
|
13
|
+
const gate = createGate();
|
|
14
|
+
const state = createReactiveState(initial(), gate);
|
|
14
15
|
|
|
15
16
|
expect(state.proxy.items[0]!.qty).toBe(1);
|
|
16
17
|
expect(state.proxy.coupon).toBe(null);
|
|
17
18
|
});
|
|
18
19
|
|
|
19
20
|
it('throws on mutation outside a run context', () => {
|
|
20
|
-
const
|
|
21
|
+
const gate = createGate();
|
|
22
|
+
const state = createReactiveState(initial(), gate);
|
|
21
23
|
|
|
22
24
|
expect(() => {
|
|
23
25
|
state.proxy.coupon = 'SAVE10';
|
|
@@ -26,14 +28,15 @@ describe('reactive state', () => {
|
|
|
26
28
|
});
|
|
27
29
|
|
|
28
30
|
it('mutates inside allowMutations and notifies path readers', () => {
|
|
29
|
-
const
|
|
31
|
+
const gate = createGate();
|
|
32
|
+
const state = createReactiveState(initial(), gate);
|
|
30
33
|
const runs = mock(() => {});
|
|
31
34
|
|
|
32
35
|
effect(() => {
|
|
33
36
|
runs();
|
|
34
37
|
state.proxy.items[0]!.qty;
|
|
35
38
|
});
|
|
36
|
-
|
|
39
|
+
withGate(gate, () => {
|
|
37
40
|
state.proxy.items[0]!.qty = 5;
|
|
38
41
|
});
|
|
39
42
|
expect(runs).toHaveBeenCalledTimes(2);
|
|
@@ -41,16 +44,17 @@ describe('reactive state', () => {
|
|
|
41
44
|
});
|
|
42
45
|
|
|
43
46
|
it('notifies list readers on push and filter-reassign', () => {
|
|
44
|
-
const
|
|
47
|
+
const gate = createGate();
|
|
48
|
+
const state = createReactiveState(initial(), gate);
|
|
45
49
|
const lengths: number[] = [];
|
|
46
50
|
|
|
47
51
|
effect(() => {
|
|
48
52
|
lengths.push(state.proxy.items.length);
|
|
49
53
|
});
|
|
50
|
-
|
|
54
|
+
withGate(gate, () => {
|
|
51
55
|
state.proxy.items.push({ id: 'b', qty: 2 });
|
|
52
56
|
});
|
|
53
|
-
|
|
57
|
+
withGate(gate, () => {
|
|
54
58
|
state.proxy.items = state.proxy.items.filter((item) => item.id === 'b');
|
|
55
59
|
});
|
|
56
60
|
expect(lengths).toEqual([1, 2, 1]);
|
|
@@ -58,21 +62,23 @@ describe('reactive state', () => {
|
|
|
58
62
|
});
|
|
59
63
|
|
|
60
64
|
it('does not notify readers of untouched sibling paths', () => {
|
|
61
|
-
const
|
|
65
|
+
const gate = createGate();
|
|
66
|
+
const state = createReactiveState(initial(), gate);
|
|
62
67
|
const runs = mock(() => {});
|
|
63
68
|
|
|
64
69
|
effect(() => {
|
|
65
70
|
runs();
|
|
66
71
|
state.proxy.coupon;
|
|
67
72
|
});
|
|
68
|
-
|
|
73
|
+
withGate(gate, () => {
|
|
69
74
|
state.proxy.items[0]!.qty = 9;
|
|
70
75
|
});
|
|
71
76
|
expect(runs).toHaveBeenCalledTimes(1);
|
|
72
77
|
});
|
|
73
78
|
|
|
74
79
|
it('snapshot is plain data detached from the proxy', () => {
|
|
75
|
-
const
|
|
80
|
+
const gate = createGate();
|
|
81
|
+
const state = createReactiveState(initial(), gate);
|
|
76
82
|
const snap = state.snapshot();
|
|
77
83
|
|
|
78
84
|
snap.items[0]!.qty = 99;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { batch, signal, untrack, type Sig } from '../signals';
|
|
2
|
-
import { assertMutable } from './mutation-gate';
|
|
2
|
+
import { assertMutable, createGate, type MutationGate } from './mutation-gate';
|
|
3
3
|
|
|
4
4
|
const MUTATING_ARRAY_METHODS = new Set([
|
|
5
5
|
'push',
|
|
@@ -34,7 +34,10 @@ function plainObject(value: object): Record<string, unknown> {
|
|
|
34
34
|
return Object.fromEntries(Object.entries(value).map(([key, v]) => [key, plainify(v)]));
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
export function createReactiveState<T extends object>(
|
|
37
|
+
export function createReactiveState<T extends object>(
|
|
38
|
+
initial: T,
|
|
39
|
+
gate: MutationGate = createGate(),
|
|
40
|
+
): ReactiveState<T> {
|
|
38
41
|
const versions = new Map<string, Sig<number>>();
|
|
39
42
|
let data = structuredClone(initial);
|
|
40
43
|
|
|
@@ -78,7 +81,7 @@ export function createReactiveState<T extends object>(initial: T): ReactiveState
|
|
|
78
81
|
|
|
79
82
|
const wrapArrayMethod = (target: unknown[], path: string, method: string) => {
|
|
80
83
|
return (...args: unknown[]) => {
|
|
81
|
-
assertMutable(path);
|
|
84
|
+
assertMutable(gate, path);
|
|
82
85
|
const result = (target as any)[method](...args.map(plainify));
|
|
83
86
|
|
|
84
87
|
touch(path);
|
|
@@ -112,7 +115,7 @@ export function createReactiveState<T extends object>(initial: T): ReactiveState
|
|
|
112
115
|
if (typeof key === 'symbol') return Reflect.set(raw, key, value);
|
|
113
116
|
const target = childPath(path, key);
|
|
114
117
|
|
|
115
|
-
assertMutable(target);
|
|
118
|
+
assertMutable(gate, target);
|
|
116
119
|
Reflect.set(raw, key, plainify(value));
|
|
117
120
|
touch(target);
|
|
118
121
|
|
|
@@ -123,7 +126,7 @@ export function createReactiveState<T extends object>(initial: T): ReactiveState
|
|
|
123
126
|
if (typeof key === 'symbol') return Reflect.deleteProperty(raw, key);
|
|
124
127
|
const target = childPath(path, key);
|
|
125
128
|
|
|
126
|
-
assertMutable(target);
|
|
129
|
+
assertMutable(gate, target);
|
|
127
130
|
Reflect.deleteProperty(raw, key);
|
|
128
131
|
touch(target);
|
|
129
132
|
|