janux 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 +11 -0
- package/package.json +46 -0
- package/src/client/api-stub.ts +18 -0
- package/src/client/boot.test.ts +185 -0
- package/src/client/boot.ts +130 -0
- package/src/client/bridge.ts +106 -0
- package/src/client/dom.ts +47 -0
- package/src/client/index.ts +7 -0
- package/src/client/morph.ts +65 -0
- package/src/client/mount.ts +87 -0
- package/src/client/registry.ts +42 -0
- package/src/define/factories.ts +78 -0
- package/src/define/types.ts +83 -0
- package/src/index.ts +44 -0
- package/src/jsx-dev-runtime.ts +1 -0
- package/src/jsx-runtime.ts +35 -0
- package/src/manifest/index.ts +107 -0
- package/src/render/html.ts +68 -0
- package/src/render/server.test.ts +132 -0
- package/src/render/server.ts +139 -0
- package/src/runtime/bus.ts +25 -0
- package/src/runtime/effects.ts +80 -0
- package/src/runtime/instance.test.ts +224 -0
- package/src/runtime/instance.ts +171 -0
- package/src/runtime/intents.ts +121 -0
- package/src/runtime/settled.ts +56 -0
- package/src/runtime/sources.ts +92 -0
- package/src/schema/builders.ts +46 -0
- package/src/schema/defaults.ts +33 -0
- package/src/schema/index.test.ts +100 -0
- package/src/schema/index.ts +5 -0
- package/src/schema/json-schema.ts +42 -0
- package/src/schema/types.ts +65 -0
- package/src/schema/validate.ts +106 -0
- package/src/signals/index.test.ts +99 -0
- package/src/signals/index.ts +129 -0
- package/src/state/mutation-gate.ts +20 -0
- package/src/state/reactive-state.test.ts +83 -0
- package/src/state/reactive-state.ts +139 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { Fragment, type JanuxNode } from '../jsx-runtime';
|
|
2
|
+
import { createInstance, type JanuxInstance } from '../runtime/instance';
|
|
3
|
+
import type { EventBus } from '../runtime/bus';
|
|
4
|
+
import type { ComponentDef, Ctx } from '../define/types';
|
|
5
|
+
import { escapeHtml, renderAttrs, VOID_ELEMENTS } from './html';
|
|
6
|
+
|
|
7
|
+
export interface IslandRecord {
|
|
8
|
+
def: ComponentDef;
|
|
9
|
+
key: string;
|
|
10
|
+
instance: JanuxInstance;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface RenderRegistry {
|
|
14
|
+
islands: IslandRecord[];
|
|
15
|
+
stores: Map<string, JanuxInstance>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RenderOptions {
|
|
19
|
+
ctx?: Ctx;
|
|
20
|
+
bus?: EventBus;
|
|
21
|
+
storeDefs?: Record<string, ComponentDef>;
|
|
22
|
+
initialState?: Record<string, Record<string, unknown>>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface RenderScope extends RenderOptions {
|
|
26
|
+
registry: RenderRegistry;
|
|
27
|
+
keySeq: Map<string, number>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isComponentDef(type: unknown): type is ComponentDef {
|
|
31
|
+
return typeof type === 'object' && type !== null && 'kind' in (type as any);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function nextKey(scope: RenderScope, def: ComponentDef, explicit?: string): string {
|
|
35
|
+
if (explicit) return explicit;
|
|
36
|
+
const seq = (scope.keySeq.get(def.name) ?? 0) + 1;
|
|
37
|
+
|
|
38
|
+
scope.keySeq.set(def.name, seq);
|
|
39
|
+
|
|
40
|
+
return seq === 1 ? 'default' : `n${seq}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function loadSources(instance: JanuxInstance): Promise<void> {
|
|
44
|
+
const readers = Object.values(instance.sources) as { refresh: () => Promise<void> }[];
|
|
45
|
+
|
|
46
|
+
await Promise.all(readers.map((reader) => reader.refresh()));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function storeInstances(scope: RenderScope): Record<string, JanuxInstance> {
|
|
50
|
+
Object.entries(scope.storeDefs ?? {}).forEach(([alias, def]) => {
|
|
51
|
+
if (scope.registry.stores.has(alias)) return;
|
|
52
|
+
const initial = scope.initialState?.[`store://${def.name}`];
|
|
53
|
+
|
|
54
|
+
scope.registry.stores.set(alias, createInstance(def, { bus: scope.bus, ctx: scope.ctx, initial }));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return Object.fromEntries(scope.registry.stores);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function renderIsland(def: ComponentDef, props: any, scope: RenderScope): Promise<string> {
|
|
61
|
+
const key = nextKey(scope, def, props.key ?? props.id);
|
|
62
|
+
const stores = storeInstances(scope);
|
|
63
|
+
const useStores = Object.fromEntries(
|
|
64
|
+
Object.keys(def.use ?? {}).map((alias) => [alias, stores[alias]!]),
|
|
65
|
+
);
|
|
66
|
+
const initial = scope.initialState?.[`ui://${def.name}#${key}`] ?? props.initial;
|
|
67
|
+
const instance = createInstance(def, { key, ctx: scope.ctx, bus: scope.bus, initial, stores: useStores });
|
|
68
|
+
|
|
69
|
+
await loadSources(instance);
|
|
70
|
+
scope.registry.islands.push({ def, key, instance });
|
|
71
|
+
const inner = await renderNode(def.view!(instance.bag), scope);
|
|
72
|
+
|
|
73
|
+
return `<janux-island data-jx="${escapeHtml(`${def.name}#${key}`)}">${inner}</janux-island>`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function renderElement(node: JanuxNode, scope: RenderScope): Promise<string> {
|
|
77
|
+
const tag = node.$t as string;
|
|
78
|
+
const attrs = renderAttrs(node.$p);
|
|
79
|
+
|
|
80
|
+
if (VOID_ELEMENTS.has(tag)) return `<${tag}${attrs}/>`;
|
|
81
|
+
const children =
|
|
82
|
+
typeof node.$p.dangerHTML === 'string'
|
|
83
|
+
? node.$p.dangerHTML
|
|
84
|
+
: await renderNode(node.$p.children, scope);
|
|
85
|
+
|
|
86
|
+
return `<${tag}${attrs}>${children}</${tag}>`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function renderNode(node: unknown, scope: RenderScope): Promise<string> {
|
|
90
|
+
if (node === null || node === undefined || typeof node === 'boolean') return '';
|
|
91
|
+
if (typeof node === 'string' || typeof node === 'number') return escapeHtml(node);
|
|
92
|
+
if (Array.isArray(node)) {
|
|
93
|
+
const parts = await Promise.all(node.map((child) => renderNode(child, scope)));
|
|
94
|
+
|
|
95
|
+
return parts.join('');
|
|
96
|
+
}
|
|
97
|
+
const jsxNode = node as JanuxNode;
|
|
98
|
+
|
|
99
|
+
if (jsxNode.$t === Fragment) return renderNode(jsxNode.$p.children, scope);
|
|
100
|
+
if (typeof jsxNode.$t === 'function') {
|
|
101
|
+
return renderNode((jsxNode.$t as any)(jsxNode.$p), scope);
|
|
102
|
+
}
|
|
103
|
+
if (isComponentDef(jsxNode.$t)) return renderIsland(jsxNode.$t, jsxNode.$p, scope);
|
|
104
|
+
|
|
105
|
+
return renderElement(jsxNode, scope);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface Snapshot {
|
|
109
|
+
uri: string;
|
|
110
|
+
state: Record<string, unknown>;
|
|
111
|
+
sources?: Record<string, { value: unknown }>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface RenderResult {
|
|
115
|
+
html: string;
|
|
116
|
+
registry: RenderRegistry;
|
|
117
|
+
snapshots: Snapshot[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Server-renders a page tree: static components inline, bifacial components as islands. */
|
|
121
|
+
export async function renderToString(node: unknown, options: RenderOptions = {}): Promise<RenderResult> {
|
|
122
|
+
const registry: RenderRegistry = { islands: [], stores: new Map() };
|
|
123
|
+
const scope: RenderScope = { ...options, registry, keySeq: new Map() };
|
|
124
|
+
const html = await renderNode(node, scope);
|
|
125
|
+
const islandSnapshots = registry.islands
|
|
126
|
+
.filter(({ def }) => def.state || def.sources)
|
|
127
|
+
.map(({ instance }) => ({
|
|
128
|
+
uri: instance.uri,
|
|
129
|
+
state: instance.snapshot(),
|
|
130
|
+
sources: instance.sourcesSnapshot(),
|
|
131
|
+
}));
|
|
132
|
+
const storeSnapshots = [...registry.stores.values()].map((instance) => ({
|
|
133
|
+
uri: instance.uri,
|
|
134
|
+
state: instance.snapshot(),
|
|
135
|
+
sources: instance.sourcesSnapshot(),
|
|
136
|
+
}));
|
|
137
|
+
|
|
138
|
+
return { html, registry, snapshots: [...islandSnapshots, ...storeSnapshots] };
|
|
139
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type BusHandler = (payload: unknown) => void;
|
|
2
|
+
|
|
3
|
+
export interface EventBus {
|
|
4
|
+
emit(event: string, payload: unknown): void;
|
|
5
|
+
on(event: string, handler: BusHandler): () => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Typed event bus: local component events, store events and server events share it. */
|
|
9
|
+
export function createBus(): EventBus {
|
|
10
|
+
const handlers = new Map<string, Set<BusHandler>>();
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
emit(event, payload) {
|
|
14
|
+
handlers.get(event)?.forEach((handler) => handler(payload));
|
|
15
|
+
},
|
|
16
|
+
on(event, handler) {
|
|
17
|
+
const set = handlers.get(event) ?? new Set();
|
|
18
|
+
|
|
19
|
+
handlers.set(event, set);
|
|
20
|
+
set.add(handler);
|
|
21
|
+
|
|
22
|
+
return () => set.delete(handler);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { computed, effect as watch, untrack } from '../signals';
|
|
2
|
+
import { allowMutations } from '../state/mutation-gate';
|
|
3
|
+
import { parseDuration } from '../define/factories';
|
|
4
|
+
import type { Cleanup, EffectDef, RunBag } from '../define/types';
|
|
5
|
+
import type { PendingTracker } from './settled';
|
|
6
|
+
|
|
7
|
+
interface EffectRuntime {
|
|
8
|
+
dispose: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function runEffectBody(def: EffectDef, bag: RunBag, tracker: PendingTracker): Cleanup {
|
|
12
|
+
const result = allowMutations(() => def.run(bag));
|
|
13
|
+
|
|
14
|
+
if (result instanceof Promise) {
|
|
15
|
+
tracker.track(result);
|
|
16
|
+
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return typeof result === 'function' ? result : undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function startOne(def: EffectDef, bag: RunBag, tracker: PendingTracker): EffectRuntime {
|
|
24
|
+
const debounceMs = def.debounce ? parseDuration(def.debounce) : 0;
|
|
25
|
+
let cleanup: Cleanup;
|
|
26
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
27
|
+
let releaseTimer: (() => void) | undefined;
|
|
28
|
+
let disposers: (() => void)[] = [];
|
|
29
|
+
|
|
30
|
+
const runNow = (): void => {
|
|
31
|
+
cleanup?.();
|
|
32
|
+
cleanup = untrack(() => runEffectBody(def, bag, tracker));
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const schedule = (): void => {
|
|
36
|
+
if (debounceMs === 0) return runNow();
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
releaseTimer?.();
|
|
39
|
+
releaseTimer = tracker.add();
|
|
40
|
+
timer = setTimeout(() => {
|
|
41
|
+
releaseTimer?.();
|
|
42
|
+
releaseTimer = undefined;
|
|
43
|
+
runNow();
|
|
44
|
+
}, debounceMs);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
if (def.when) {
|
|
48
|
+
const watched = computed(() => def.when!(bag.state));
|
|
49
|
+
let first = true;
|
|
50
|
+
const stop = watch(() => {
|
|
51
|
+
watched.value;
|
|
52
|
+
untrack(() => (first ? runNow() : schedule()));
|
|
53
|
+
first = false;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
disposers = [stop, watched.dispose];
|
|
57
|
+
} else {
|
|
58
|
+
runNow();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
dispose() {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
releaseTimer?.();
|
|
65
|
+
disposers.forEach((dispose) => dispose());
|
|
66
|
+
cleanup?.();
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Starts all declared effects; each runs on attach and on `when` changes (debounced). */
|
|
72
|
+
export function startEffects(
|
|
73
|
+
defs: Record<string, EffectDef> | undefined,
|
|
74
|
+
bag: RunBag,
|
|
75
|
+
tracker: PendingTracker,
|
|
76
|
+
): () => void {
|
|
77
|
+
const running = Object.values(defs ?? {}).map((def) => startOne(def, bag, tracker));
|
|
78
|
+
|
|
79
|
+
return () => running.forEach((runtime) => runtime.dispose());
|
|
80
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from 'bun:test';
|
|
2
|
+
import { component, intent, effect, source, store, onEvent } from '../define/factories';
|
|
3
|
+
import { int, list, schema, str } from '../schema';
|
|
4
|
+
import { createBus } from './bus';
|
|
5
|
+
import { createInstance } from './instance';
|
|
6
|
+
import type { Proposal } from './intents';
|
|
7
|
+
|
|
8
|
+
const noopView = () => null;
|
|
9
|
+
|
|
10
|
+
const cartDef = () =>
|
|
11
|
+
component({
|
|
12
|
+
name: 'cart',
|
|
13
|
+
state: schema({ items: list({ id: str(), qty: int().min(1) }), coupon: str().nullable() }),
|
|
14
|
+
derived: { count: (s: any) => s.items.reduce((acc: number, i: any) => acc + i.qty, 0) },
|
|
15
|
+
emits: { 'cart.cleared': schema({}) },
|
|
16
|
+
intents: {
|
|
17
|
+
addItem: intent({
|
|
18
|
+
input: schema({ id: str(), qty: int().min(1).default(1) }),
|
|
19
|
+
run: ({ state, input }) => state.items.push(input),
|
|
20
|
+
}),
|
|
21
|
+
clear: intent({
|
|
22
|
+
guard: 'confirm',
|
|
23
|
+
run: ({ state, emit }) => {
|
|
24
|
+
state.items = [];
|
|
25
|
+
emit('cart.cleared', {});
|
|
26
|
+
},
|
|
27
|
+
}),
|
|
28
|
+
hidden: intent({ guard: 'forbidden', run: () => 'secret' }),
|
|
29
|
+
},
|
|
30
|
+
view: noopView,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('instance: state, derived, intents', () => {
|
|
34
|
+
it('initializes from schema defaults and exposes uri', () => {
|
|
35
|
+
const cart = createInstance(cartDef());
|
|
36
|
+
|
|
37
|
+
expect(cart.uri).toBe('ui://cart');
|
|
38
|
+
expect(cart.snapshot()).toEqual({ items: [], coupon: null });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('runs intents, mutates state and updates derived', async () => {
|
|
42
|
+
const cart = createInstance(cartDef(), { key: 'main' });
|
|
43
|
+
|
|
44
|
+
await cart.intents.addItem!({ id: 'a', qty: 2 });
|
|
45
|
+
await cart.intents.addItem!({ id: 'b' });
|
|
46
|
+
expect(cart.uri).toBe('ui://cart#main');
|
|
47
|
+
expect(cart.snapshot().items).toHaveLength(2);
|
|
48
|
+
expect(cart.derived.count).toBe(3);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('validates intent input against its schema', async () => {
|
|
52
|
+
const cart = createInstance(cartDef());
|
|
53
|
+
|
|
54
|
+
expect(cart.intents.addItem!({ id: 'a', qty: 0 })).rejects.toThrow(/below min 1/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('records audit entries for every invocation', async () => {
|
|
58
|
+
const entries: any[] = [];
|
|
59
|
+
const cart = createInstance(cartDef(), { onAudit: (entry) => entries.push(entry) });
|
|
60
|
+
|
|
61
|
+
await cart.intents.addItem!({ id: 'a' });
|
|
62
|
+
expect(entries).toEqual([
|
|
63
|
+
expect.objectContaining({ tool: 'cart.addItem', origin: 'human', guard: 'auto', ok: true }),
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('instance: guards', () => {
|
|
69
|
+
it('agent + confirm returns a proposal; executing it applies the change', async () => {
|
|
70
|
+
let proposal: Proposal | undefined;
|
|
71
|
+
const cart = createInstance(cartDef(), { onProposal: (p) => (proposal = p) });
|
|
72
|
+
|
|
73
|
+
const result: any = await cart.intents.clear!({}, { origin: 'agent' });
|
|
74
|
+
|
|
75
|
+
expect(result.status).toBe('proposal');
|
|
76
|
+
expect(cart.snapshot().items).toEqual([]);
|
|
77
|
+
await proposal!.execute();
|
|
78
|
+
expect(cart.snapshot().items).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('human origin runs confirm intents directly', async () => {
|
|
82
|
+
const cart = createInstance(cartDef());
|
|
83
|
+
|
|
84
|
+
await cart.intents.addItem!({ id: 'a' });
|
|
85
|
+
await cart.intents.clear!({});
|
|
86
|
+
expect(cart.snapshot().items).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('agent cannot invoke forbidden intents', () => {
|
|
90
|
+
const cart = createInstance(cartDef());
|
|
91
|
+
|
|
92
|
+
expect(cart.intents.hidden!({}, { origin: 'agent' })).rejects.toThrow(/not available/);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('instance: sources, effects, settled', () => {
|
|
97
|
+
const catalogDef = (query: () => unknown) =>
|
|
98
|
+
component({
|
|
99
|
+
name: 'shop',
|
|
100
|
+
state: schema({ picks: int() }),
|
|
101
|
+
sources: { catalog: source({ query, refresh: onEvent('inventory.changed') }) },
|
|
102
|
+
intents: {
|
|
103
|
+
pick: intent({
|
|
104
|
+
ready: ({ sources }) => !sources.catalog!.pending,
|
|
105
|
+
run: ({ state }) => (state.picks += 1),
|
|
106
|
+
}),
|
|
107
|
+
},
|
|
108
|
+
view: noopView,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('loads sources async, gates ready intents, resolves settled()', async () => {
|
|
112
|
+
const shop = createInstance(catalogDef(async () => ['p1']));
|
|
113
|
+
|
|
114
|
+
await shop.attach();
|
|
115
|
+
expect(shop.intents.pick!({})).rejects.toThrow(/not ready/);
|
|
116
|
+
await shop.settled();
|
|
117
|
+
expect(shop.sources.catalog.value).toEqual(['p1']);
|
|
118
|
+
await shop.intents.pick!({});
|
|
119
|
+
expect(shop.snapshot().picks).toBe(1);
|
|
120
|
+
await shop.dispose();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('re-queries sources when a refresh event fires', async () => {
|
|
124
|
+
const bus = createBus();
|
|
125
|
+
const query = mock(async () => 'data');
|
|
126
|
+
const shop = createInstance(catalogDef(query), { bus });
|
|
127
|
+
|
|
128
|
+
await shop.attach();
|
|
129
|
+
await shop.settled();
|
|
130
|
+
bus.emit('inventory.changed', {});
|
|
131
|
+
await shop.settled();
|
|
132
|
+
expect(query).toHaveBeenCalledTimes(2);
|
|
133
|
+
await shop.dispose();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('runs effects on attach, on change (debounced) and cleans up', async () => {
|
|
137
|
+
const runs = mock(() => {});
|
|
138
|
+
const cleanups = mock(() => {});
|
|
139
|
+
const def = component({
|
|
140
|
+
name: 'fx',
|
|
141
|
+
state: schema({ n: int() }),
|
|
142
|
+
effects: {
|
|
143
|
+
track: effect({
|
|
144
|
+
when: (s: any) => s.n,
|
|
145
|
+
debounce: '10ms',
|
|
146
|
+
run: () => {
|
|
147
|
+
runs();
|
|
148
|
+
|
|
149
|
+
return cleanups;
|
|
150
|
+
},
|
|
151
|
+
}),
|
|
152
|
+
},
|
|
153
|
+
intents: { bump: intent({ run: ({ state }) => (state.n += 1) }) },
|
|
154
|
+
view: noopView,
|
|
155
|
+
});
|
|
156
|
+
const fx = createInstance(def);
|
|
157
|
+
|
|
158
|
+
await fx.attach();
|
|
159
|
+
expect(runs).toHaveBeenCalledTimes(1);
|
|
160
|
+
await fx.intents.bump!({});
|
|
161
|
+
await fx.settled();
|
|
162
|
+
expect(runs).toHaveBeenCalledTimes(2);
|
|
163
|
+
expect(cleanups).toHaveBeenCalledTimes(1);
|
|
164
|
+
await fx.dispose();
|
|
165
|
+
expect(cleanups).toHaveBeenCalledTimes(2);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('instance: events and stores', () => {
|
|
170
|
+
it('emits validated events across instances on a shared bus', async () => {
|
|
171
|
+
const bus = createBus();
|
|
172
|
+
const cart = createInstance(cartDef(), { bus });
|
|
173
|
+
const listener = store({
|
|
174
|
+
name: 'analytics',
|
|
175
|
+
state: schema({ clears: int() }),
|
|
176
|
+
on: { 'cart.cleared': ({ state }) => (state.clears += 1) },
|
|
177
|
+
});
|
|
178
|
+
const analytics = createInstance(listener, { bus });
|
|
179
|
+
|
|
180
|
+
await cart.intents.clear!({});
|
|
181
|
+
expect(analytics.snapshot().clears).toBe(1);
|
|
182
|
+
expect(analytics.uri).toBe('store://analytics');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('consumer intents reach store intents through use', async () => {
|
|
186
|
+
const session = createInstance(
|
|
187
|
+
store({
|
|
188
|
+
name: 'session',
|
|
189
|
+
state: schema({ locale: str().default('en') }),
|
|
190
|
+
intents: {
|
|
191
|
+
setLocale: intent({
|
|
192
|
+
input: schema({ locale: str() }),
|
|
193
|
+
run: ({ state, input }) => (state.locale = input.locale),
|
|
194
|
+
}),
|
|
195
|
+
},
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
const header = createInstance(
|
|
199
|
+
component({
|
|
200
|
+
name: 'header',
|
|
201
|
+
intents: {
|
|
202
|
+
toSpanish: intent({ run: ({ use }) => use.session!.intents.setLocale!({ locale: 'es' }) }),
|
|
203
|
+
},
|
|
204
|
+
view: noopView,
|
|
205
|
+
}),
|
|
206
|
+
{ stores: { session } },
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
await header.intents.toSpanish!({});
|
|
210
|
+
expect(session.snapshot().locale).toBe('es');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('exposes an agent resource projection', async () => {
|
|
214
|
+
const cart = createInstance(cartDef());
|
|
215
|
+
|
|
216
|
+
await cart.intents.addItem!({ id: 'a', qty: 2 });
|
|
217
|
+
const resource: any = cart.resource();
|
|
218
|
+
|
|
219
|
+
expect(resource.uri).toBe('ui://cart');
|
|
220
|
+
expect(resource.state.items).toEqual([{ id: 'a', qty: 2 }]);
|
|
221
|
+
expect(resource.derived.count).toBe(2);
|
|
222
|
+
expect(resource.sync).toBe('idle');
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { buildDefault, toJsonSchema, validate } from '../schema';
|
|
2
|
+
import { computed, untrack, type ReadonlySig } from '../signals';
|
|
3
|
+
import { createReactiveState } from '../state/reactive-state';
|
|
4
|
+
import { allowMutations } from '../state/mutation-gate';
|
|
5
|
+
import type { ComponentDef, Ctx, Origin, RunBag, StoreHandle } from '../define/types';
|
|
6
|
+
import { createBus, type EventBus } from './bus';
|
|
7
|
+
import { createPendingTracker } from './settled';
|
|
8
|
+
import { createSources } from './sources';
|
|
9
|
+
import { startEffects } from './effects';
|
|
10
|
+
import { invokeIntent, type AuditEntry, type Proposal } from './intents';
|
|
11
|
+
|
|
12
|
+
export interface InstanceOptions {
|
|
13
|
+
key?: string;
|
|
14
|
+
initial?: Record<string, unknown>;
|
|
15
|
+
initialSources?: Record<string, { value: unknown }>;
|
|
16
|
+
ctx?: Ctx;
|
|
17
|
+
bus?: EventBus;
|
|
18
|
+
stores?: Record<string, JanuxInstance>;
|
|
19
|
+
onAudit?: (entry: AuditEntry) => void;
|
|
20
|
+
onProposal?: (proposal: Proposal) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface JanuxInstance {
|
|
24
|
+
def: ComponentDef;
|
|
25
|
+
uri: string;
|
|
26
|
+
state: any;
|
|
27
|
+
derived: Record<string, unknown>;
|
|
28
|
+
sources: Record<string, any>;
|
|
29
|
+
intents: Record<string, (input?: unknown, opts?: { origin?: Origin }) => Promise<unknown>>;
|
|
30
|
+
emit: (event: string, payload: unknown) => void;
|
|
31
|
+
bag: RunBag;
|
|
32
|
+
snapshot(): Record<string, unknown>;
|
|
33
|
+
sourcesSnapshot(): Record<string, { value: unknown }>;
|
|
34
|
+
resource(): Record<string, unknown>;
|
|
35
|
+
settled(): Promise<void>;
|
|
36
|
+
attach(): Promise<void>;
|
|
37
|
+
dispose(): Promise<void>;
|
|
38
|
+
handle(): StoreHandle;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function derivedReaders(def: ComponentDef, proxy: any) {
|
|
42
|
+
const computeds = new Map<string, ReadonlySig<unknown>>(
|
|
43
|
+
Object.entries(def.derived ?? {}).map(([name, fn]) => [name, computed(() => fn(proxy))]),
|
|
44
|
+
);
|
|
45
|
+
const readers: Record<string, unknown> = {};
|
|
46
|
+
|
|
47
|
+
computeds.forEach((sig, name) => {
|
|
48
|
+
Object.defineProperty(readers, name, { get: () => sig.value, enumerable: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return { readers, dispose: () => computeds.forEach((sig) => sig.dispose()) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function storeHandles(stores: Record<string, JanuxInstance>): Record<string, StoreHandle> {
|
|
55
|
+
return Object.fromEntries(
|
|
56
|
+
Object.entries(stores).map(([alias, instance]) => [alias, instance.handle()]),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function makeEmit(def: ComponentDef, bus: EventBus) {
|
|
61
|
+
return (event: string, payload: unknown): void => {
|
|
62
|
+
const schema = def.emits?.[event];
|
|
63
|
+
|
|
64
|
+
if (!schema) throw new Error(`Janux: "${def.name}" does not declare event "${event}"`);
|
|
65
|
+
const result = validate(schema, payload ?? {});
|
|
66
|
+
|
|
67
|
+
if (!result.ok) throw new Error(`Janux: invalid payload for "${event}"`);
|
|
68
|
+
bus.emit(event, result.value);
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function evaluateDerived(readers: Record<string, unknown>): Record<string, unknown> {
|
|
73
|
+
return untrack(() => JSON.parse(JSON.stringify({ ...readers })));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Creates a live component/store instance. Call `attach()` to start sources, effects and lifecycle. */
|
|
77
|
+
export function createInstance(def: ComponentDef, options: InstanceOptions = {}): JanuxInstance {
|
|
78
|
+
const ctx = options.ctx ?? {};
|
|
79
|
+
const bus = options.bus ?? createBus();
|
|
80
|
+
const tracker = createPendingTracker();
|
|
81
|
+
const initial = options.initial ?? (def.state ? (buildDefault(def.state) as any) : {});
|
|
82
|
+
const state = createReactiveState(initial as Record<string, unknown>);
|
|
83
|
+
const sourcesRuntime = createSources(def.sources, ctx, bus, tracker, options.initialSources);
|
|
84
|
+
const { readers: derived, dispose: disposeDerived } = derivedReaders(def, state.proxy);
|
|
85
|
+
const emit = makeEmit(def, bus);
|
|
86
|
+
const use = storeHandles(options.stores ?? {});
|
|
87
|
+
const scheme = def.kind === 'store' ? 'store' : 'ui';
|
|
88
|
+
const uri = `${scheme}://${def.name}${options.key ? `#${options.key}` : ''}`;
|
|
89
|
+
|
|
90
|
+
const intents: JanuxInstance['intents'] = {};
|
|
91
|
+
const bag: RunBag = {
|
|
92
|
+
state: state.proxy,
|
|
93
|
+
derived,
|
|
94
|
+
sources: sourcesRuntime.readers,
|
|
95
|
+
intents,
|
|
96
|
+
use,
|
|
97
|
+
emit,
|
|
98
|
+
ctx,
|
|
99
|
+
};
|
|
100
|
+
const hooks = {
|
|
101
|
+
onAudit: options.onAudit,
|
|
102
|
+
onProposal: options.onProposal,
|
|
103
|
+
trackPending: tracker.track,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
Object.entries(def.intents ?? {}).forEach(([name, intentDef]) => {
|
|
107
|
+
const invoke = (input?: unknown, opts?: { origin?: Origin }) =>
|
|
108
|
+
invokeIntent(def.name, name, intentDef, bag, input, opts?.origin ?? 'human', hooks);
|
|
109
|
+
|
|
110
|
+
(invoke as any).$intent = { component: def.name, key: options.key, name };
|
|
111
|
+
intents[name] = invoke;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
let stopEffects: (() => void) | undefined;
|
|
115
|
+
const busUnsubs = Object.entries(def.on ?? {}).map(([event, handler]) =>
|
|
116
|
+
bus.on(event, (payload) => allowMutations(() => handler({ ...bag, event: payload }))),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
def,
|
|
121
|
+
uri,
|
|
122
|
+
state: state.proxy,
|
|
123
|
+
derived,
|
|
124
|
+
sources: sourcesRuntime.readers,
|
|
125
|
+
intents,
|
|
126
|
+
emit,
|
|
127
|
+
bag,
|
|
128
|
+
snapshot: () => state.snapshot(),
|
|
129
|
+
sourcesSnapshot() {
|
|
130
|
+
return untrack(() =>
|
|
131
|
+
Object.fromEntries(
|
|
132
|
+
Object.entries(sourcesRuntime.readers)
|
|
133
|
+
.filter(([, reader]) => !reader.pending && reader.error === null)
|
|
134
|
+
.map(([name, reader]) => [name, { value: reader.value }]),
|
|
135
|
+
),
|
|
136
|
+
);
|
|
137
|
+
},
|
|
138
|
+
settled: () => tracker.settled(),
|
|
139
|
+
resource() {
|
|
140
|
+
return {
|
|
141
|
+
uri,
|
|
142
|
+
description: def.description,
|
|
143
|
+
schema: def.state ? toJsonSchema(def.state) : undefined,
|
|
144
|
+
state: state.snapshot(),
|
|
145
|
+
derived: evaluateDerived(derived),
|
|
146
|
+
sync: tracker.count > 0 ? 'pending' : 'idle',
|
|
147
|
+
};
|
|
148
|
+
},
|
|
149
|
+
async attach() {
|
|
150
|
+
sourcesRuntime.start();
|
|
151
|
+
stopEffects = startEffects(def.effects, bag, tracker);
|
|
152
|
+
await allowMutations(() => def.lifecycle?.attach?.(bag));
|
|
153
|
+
},
|
|
154
|
+
async dispose() {
|
|
155
|
+
stopEffects?.();
|
|
156
|
+
sourcesRuntime.dispose();
|
|
157
|
+
disposeDerived();
|
|
158
|
+
busUnsubs.forEach((unsub) => unsub());
|
|
159
|
+
await allowMutations(() => def.lifecycle?.detach?.(bag));
|
|
160
|
+
},
|
|
161
|
+
handle(): StoreHandle {
|
|
162
|
+
return { state: state.proxy, derived, intents: bindHumanIntents(intents) };
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function bindHumanIntents(intents: JanuxInstance['intents']) {
|
|
168
|
+
return Object.fromEntries(
|
|
169
|
+
Object.entries(intents).map(([name, invoke]) => [name, (input?: unknown) => invoke(input)]),
|
|
170
|
+
);
|
|
171
|
+
}
|