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,87 @@
|
|
|
1
|
+
import { effect as watch } from '../signals';
|
|
2
|
+
import { createInstance, type JanuxInstance } from '../runtime/instance';
|
|
3
|
+
import type { ComponentDef } from '../define/types';
|
|
4
|
+
import type { EventBus } from '../runtime/bus';
|
|
5
|
+
import { toDomNodes } from './dom';
|
|
6
|
+
import { morph } from './morph';
|
|
7
|
+
import { resolveDef, type ClientRegistry } from './registry';
|
|
8
|
+
|
|
9
|
+
export interface MountContext {
|
|
10
|
+
registry: ClientRegistry;
|
|
11
|
+
bus: EventBus;
|
|
12
|
+
ctx: Record<string, unknown>;
|
|
13
|
+
inflight: Set<Promise<unknown>>;
|
|
14
|
+
onProposal: (proposal: unknown) => void;
|
|
15
|
+
onAudit?: (entry: unknown) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function ensureStores(def: ComponentDef, mount: MountContext): Promise<Record<string, JanuxInstance>> {
|
|
19
|
+
const entries = await Promise.all(
|
|
20
|
+
Object.entries(def.use ?? {}).map(async ([alias, storeDef]) => {
|
|
21
|
+
return [alias, await ensureStore(storeDef, mount)] as const;
|
|
22
|
+
}),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
return Object.fromEntries(entries);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function ensureStore(storeDef: ComponentDef, mount: MountContext): Promise<JanuxInstance> {
|
|
29
|
+
const { registry } = mount;
|
|
30
|
+
const existing = registry.stores.get(storeDef.name);
|
|
31
|
+
|
|
32
|
+
if (existing) return existing;
|
|
33
|
+
const snap = registry.snapshots.get(`store://${storeDef.name}`) as any;
|
|
34
|
+
const instance = createInstance(storeDef, {
|
|
35
|
+
bus: mount.bus,
|
|
36
|
+
ctx: mount.ctx,
|
|
37
|
+
initial: snap?.state,
|
|
38
|
+
initialSources: snap?.sources,
|
|
39
|
+
onProposal: mount.onProposal as any,
|
|
40
|
+
onAudit: mount.onAudit as any,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
registry.stores.set(storeDef.name, instance);
|
|
44
|
+
await instance.attach();
|
|
45
|
+
|
|
46
|
+
return instance;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function startRenderLoop(instance: JanuxInstance, root: Element): () => void {
|
|
50
|
+
return watch(() => {
|
|
51
|
+
morph(root, toDomNodes(instance.def.view!(instance.bag)));
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Resumes one island: instance from its SSR snapshot, reactive render loop, no replay. */
|
|
56
|
+
export async function mountIsland(id: string, root: Element, mount: MountContext): Promise<JanuxInstance> {
|
|
57
|
+
const { registry } = mount;
|
|
58
|
+
const mounted = registry.mounted.get(id);
|
|
59
|
+
|
|
60
|
+
if (mounted) return mounted;
|
|
61
|
+
const [name, key = 'default'] = id.split('#');
|
|
62
|
+
const def = await resolveDef(registry, name!);
|
|
63
|
+
const snap = (registry.snapshots.get(`ui://${id}`) ?? registry.snapshots.get(`ui://${name}`)) as any;
|
|
64
|
+
const instance = createInstance(def, {
|
|
65
|
+
key,
|
|
66
|
+
bus: mount.bus,
|
|
67
|
+
ctx: mount.ctx,
|
|
68
|
+
initial: snap?.state,
|
|
69
|
+
initialSources: snap?.sources,
|
|
70
|
+
stores: await ensureStores(def, mount),
|
|
71
|
+
onProposal: mount.onProposal as any,
|
|
72
|
+
onAudit: mount.onAudit as any,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
registry.mounted.set(id, instance);
|
|
76
|
+
const stopRender = startRenderLoop(instance, root);
|
|
77
|
+
const dispose = instance.dispose.bind(instance);
|
|
78
|
+
|
|
79
|
+
instance.dispose = async () => {
|
|
80
|
+
stopRender();
|
|
81
|
+
registry.mounted.delete(id);
|
|
82
|
+
await dispose();
|
|
83
|
+
};
|
|
84
|
+
await instance.attach();
|
|
85
|
+
|
|
86
|
+
return instance;
|
|
87
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ComponentDef } from '../define/types';
|
|
2
|
+
import type { JanuxInstance } from '../runtime/instance';
|
|
3
|
+
|
|
4
|
+
export type IslandLoader = () => Promise<unknown>;
|
|
5
|
+
|
|
6
|
+
export interface ClientRegistry {
|
|
7
|
+
defs: Map<string, ComponentDef>;
|
|
8
|
+
loaders: Map<string, IslandLoader>;
|
|
9
|
+
mounted: Map<string, JanuxInstance>;
|
|
10
|
+
stores: Map<string, JanuxInstance>;
|
|
11
|
+
snapshots: Map<string, Record<string, unknown>>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createClientRegistry(): ClientRegistry {
|
|
15
|
+
return {
|
|
16
|
+
defs: new Map(),
|
|
17
|
+
loaders: new Map(),
|
|
18
|
+
mounted: new Map(),
|
|
19
|
+
stores: new Map(),
|
|
20
|
+
snapshots: new Map(),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Island/store modules call this on import so boot can resolve defs by name. */
|
|
25
|
+
export function registerDef(registry: ClientRegistry, def: ComponentDef): void {
|
|
26
|
+
registry.defs.set(def.name, def);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function resolveDef(registry: ClientRegistry, name: string): Promise<ComponentDef> {
|
|
30
|
+
const existing = registry.defs.get(name);
|
|
31
|
+
|
|
32
|
+
if (existing) return existing;
|
|
33
|
+
const loader = registry.loaders.get(name);
|
|
34
|
+
|
|
35
|
+
if (!loader) throw new Error(`Janux: unknown island "${name}" (no loader registered)`);
|
|
36
|
+
await loader();
|
|
37
|
+
const loaded = registry.defs.get(name);
|
|
38
|
+
|
|
39
|
+
if (!loaded) throw new Error(`Janux: island module for "${name}" did not register its def`);
|
|
40
|
+
|
|
41
|
+
return loaded;
|
|
42
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ComponentDef,
|
|
3
|
+
EffectDef,
|
|
4
|
+
IntentDef,
|
|
5
|
+
RefreshPolicy,
|
|
6
|
+
SourceDef,
|
|
7
|
+
} from './types';
|
|
8
|
+
|
|
9
|
+
type ComponentInput = Omit<ComponentDef, 'kind'>;
|
|
10
|
+
type StoreInput = Omit<ComponentDef, 'kind' | 'view'>;
|
|
11
|
+
|
|
12
|
+
function assertName(name: unknown, kind: string): void {
|
|
13
|
+
if (typeof name === 'string' && /^[a-z][a-z0-9-]*$/.test(name)) return;
|
|
14
|
+
|
|
15
|
+
throw new Error(`Janux: ${kind} needs a kebab-case "name", got ${JSON.stringify(name)}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Defines a bifacial component: view for humans, resource+tools for agents. */
|
|
19
|
+
export function component(def: ComponentInput): ComponentDef {
|
|
20
|
+
assertName(def.name, 'component()');
|
|
21
|
+
if (typeof def.view !== 'function') {
|
|
22
|
+
throw new Error(`Janux: component "${def.name}" requires a view`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return Object.freeze({ ...def, kind: 'component' as const });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Defines a shared store: a bifacial component without a view. */
|
|
29
|
+
export function store(def: StoreInput): ComponentDef {
|
|
30
|
+
assertName(def.name, 'store()');
|
|
31
|
+
|
|
32
|
+
return Object.freeze({ ...def, kind: 'store' as const, scope: def.scope ?? 'app' });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function intent(def: IntentDef): IntentDef {
|
|
36
|
+
if (typeof def.run !== 'function') throw new Error('Janux: intent() requires run()');
|
|
37
|
+
|
|
38
|
+
return def;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function effect(def: EffectDef): EffectDef {
|
|
42
|
+
if (typeof def.run !== 'function') throw new Error('Janux: effect() requires run()');
|
|
43
|
+
|
|
44
|
+
return def;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function source(def: SourceDef): SourceDef {
|
|
48
|
+
if (typeof def.query !== 'function') throw new Error('Janux: source() requires query()');
|
|
49
|
+
|
|
50
|
+
return def;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Refresh policy builder: `every('5m').orOn('inventory.changed')`. */
|
|
54
|
+
export function every(interval: string): RefreshPolicy & { orOn: (event: string) => RefreshPolicy } {
|
|
55
|
+
const policy = { everyMs: parseDuration(interval), events: [] as string[] };
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
...policy,
|
|
59
|
+
orOn(event: string) {
|
|
60
|
+
return { ...policy, events: [...policy.events, event] };
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Event-only refresh policy: `on('inventory.changed')`. */
|
|
66
|
+
export function onEvent(event: string): RefreshPolicy {
|
|
67
|
+
return { events: [event] };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const DURATION_UNITS: Record<string, number> = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 };
|
|
71
|
+
|
|
72
|
+
export function parseDuration(input: string): number {
|
|
73
|
+
const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)$/.exec(input);
|
|
74
|
+
|
|
75
|
+
if (!match) throw new Error(`Janux: invalid duration "${input}" (use e.g. 300ms, 2s, 5m, 1h)`);
|
|
76
|
+
|
|
77
|
+
return Number(match[1]) * DURATION_UNITS[match[2]!]!;
|
|
78
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { JxType } from '../schema';
|
|
2
|
+
|
|
3
|
+
export type GuardValue = 'auto' | 'confirm' | 'forbidden';
|
|
4
|
+
export type Guard = GuardValue | ((bag: { ctx: Ctx }) => GuardValue);
|
|
5
|
+
export type Ctx = Record<string, unknown>;
|
|
6
|
+
export type Origin = 'human' | 'agent';
|
|
7
|
+
export type Cleanup = (() => void) | undefined;
|
|
8
|
+
|
|
9
|
+
export interface RunBag {
|
|
10
|
+
state: any;
|
|
11
|
+
derived: Record<string, unknown>;
|
|
12
|
+
sources: Record<string, SourceReader>;
|
|
13
|
+
intents: Record<string, (input?: unknown) => Promise<unknown>>;
|
|
14
|
+
use: Record<string, StoreHandle>;
|
|
15
|
+
emit: (event: string, payload: unknown) => void;
|
|
16
|
+
ctx: Ctx;
|
|
17
|
+
input?: any;
|
|
18
|
+
event?: any;
|
|
19
|
+
signal?: AbortSignal;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SourceReader {
|
|
23
|
+
readonly value: any;
|
|
24
|
+
readonly pending: boolean;
|
|
25
|
+
readonly error: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface StoreHandle {
|
|
29
|
+
state: any;
|
|
30
|
+
derived: Record<string, unknown>;
|
|
31
|
+
intents: Record<string, (input?: unknown) => Promise<unknown>>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface IntentDef {
|
|
35
|
+
description?: string;
|
|
36
|
+
input?: JxType;
|
|
37
|
+
guard?: Guard;
|
|
38
|
+
server?: boolean;
|
|
39
|
+
prefetch?: 'eager' | 'visible' | 'idle';
|
|
40
|
+
ready?: (bag: RunBag) => boolean;
|
|
41
|
+
run: (bag: RunBag) => unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface EffectDef {
|
|
45
|
+
description?: string;
|
|
46
|
+
when?: (state: any) => unknown;
|
|
47
|
+
debounce?: string;
|
|
48
|
+
run: (bag: RunBag) => Cleanup | void | Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface RefreshPolicy {
|
|
52
|
+
everyMs?: number;
|
|
53
|
+
events: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface SourceDef {
|
|
57
|
+
description?: string;
|
|
58
|
+
query: (bag: { ctx: Ctx }) => unknown;
|
|
59
|
+
refresh?: RefreshPolicy;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface LifecycleDef {
|
|
63
|
+
attach?: (bag: RunBag) => void | Promise<void>;
|
|
64
|
+
detach?: (bag: RunBag) => void | Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ComponentDef {
|
|
68
|
+
kind: 'component' | 'store';
|
|
69
|
+
name: string;
|
|
70
|
+
description?: string;
|
|
71
|
+
state?: JxType;
|
|
72
|
+
derived?: Record<string, (state: any) => unknown>;
|
|
73
|
+
sources?: Record<string, SourceDef>;
|
|
74
|
+
effects?: Record<string, EffectDef>;
|
|
75
|
+
intents?: Record<string, IntentDef>;
|
|
76
|
+
emits?: Record<string, JxType>;
|
|
77
|
+
on?: Record<string, (bag: RunBag) => void>;
|
|
78
|
+
lifecycle?: LifecycleDef;
|
|
79
|
+
use?: Record<string, ComponentDef>;
|
|
80
|
+
view?: (bag: RunBag) => unknown;
|
|
81
|
+
scope?: 'app' | 'route';
|
|
82
|
+
persist?: 'local' | 'none';
|
|
83
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export {
|
|
2
|
+
component,
|
|
3
|
+
store,
|
|
4
|
+
intent,
|
|
5
|
+
effect,
|
|
6
|
+
source,
|
|
7
|
+
every,
|
|
8
|
+
onEvent,
|
|
9
|
+
parseDuration,
|
|
10
|
+
} from './define/factories';
|
|
11
|
+
export type {
|
|
12
|
+
ComponentDef,
|
|
13
|
+
IntentDef,
|
|
14
|
+
EffectDef,
|
|
15
|
+
SourceDef,
|
|
16
|
+
Ctx,
|
|
17
|
+
Guard,
|
|
18
|
+
GuardValue,
|
|
19
|
+
Origin,
|
|
20
|
+
RunBag,
|
|
21
|
+
StoreHandle,
|
|
22
|
+
} from './define/types';
|
|
23
|
+
export { renderToString, type RenderResult } from './render/server';
|
|
24
|
+
export { buildManifest, type Manifest, type ManifestEntry } from './manifest';
|
|
25
|
+
export {
|
|
26
|
+
schema,
|
|
27
|
+
str,
|
|
28
|
+
int,
|
|
29
|
+
num,
|
|
30
|
+
bool,
|
|
31
|
+
money,
|
|
32
|
+
enums,
|
|
33
|
+
list,
|
|
34
|
+
obj,
|
|
35
|
+
JxType,
|
|
36
|
+
validate,
|
|
37
|
+
buildDefault,
|
|
38
|
+
toJsonSchema,
|
|
39
|
+
} from './schema';
|
|
40
|
+
export { signal, computed, effect as watch, batch, untrack } from './signals';
|
|
41
|
+
export { createInstance, type JanuxInstance, type InstanceOptions } from './runtime/instance';
|
|
42
|
+
export { createBus, type EventBus } from './runtime/bus';
|
|
43
|
+
export { JanuxIntentError, type AuditEntry, type Proposal } from './runtime/intents';
|
|
44
|
+
export { Fragment, jsx, jsxs, type JanuxNode } from './jsx-runtime';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Fragment, jsx, jsxs, jsxDEV, type JanuxNode, type JanuxType } from './jsx-runtime';
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ComponentDef } from './define/types';
|
|
2
|
+
|
|
3
|
+
export type JanuxType = string | ((props: any) => unknown) | ComponentDef | symbol;
|
|
4
|
+
|
|
5
|
+
export interface JanuxNode {
|
|
6
|
+
$t: JanuxType;
|
|
7
|
+
$p: Record<string, unknown>;
|
|
8
|
+
$k?: string | number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const Fragment = Symbol.for('janux.fragment');
|
|
12
|
+
|
|
13
|
+
export function jsx(type: JanuxType, props: Record<string, unknown> | null, key?: string | number): JanuxNode {
|
|
14
|
+
return { $t: type, $p: props ?? {}, $k: key };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const jsxs = jsx;
|
|
18
|
+
|
|
19
|
+
export function jsxDEV(
|
|
20
|
+
type: JanuxType,
|
|
21
|
+
props: Record<string, unknown> | null,
|
|
22
|
+
key?: string | number,
|
|
23
|
+
): JanuxNode {
|
|
24
|
+
return jsx(type, props, key);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export declare namespace JSX {
|
|
28
|
+
type Element = JanuxNode;
|
|
29
|
+
interface IntrinsicElements {
|
|
30
|
+
[element: string]: Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
interface ElementChildrenAttribute {
|
|
33
|
+
children: unknown;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { toJsonSchema } from '../schema';
|
|
2
|
+
import type { ComponentDef, Ctx } from '../define/types';
|
|
3
|
+
import { resolveGuard } from '../runtime/intents';
|
|
4
|
+
import type { JanuxInstance } from '../runtime/instance';
|
|
5
|
+
|
|
6
|
+
export interface ManifestTool {
|
|
7
|
+
name: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
guard: string;
|
|
10
|
+
input?: Record<string, unknown>;
|
|
11
|
+
ready?: boolean;
|
|
12
|
+
server?: boolean;
|
|
13
|
+
prefetch?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ManifestResource {
|
|
17
|
+
uri: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
schema?: Record<string, unknown>;
|
|
20
|
+
readers?: string[];
|
|
21
|
+
opaque?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Manifest {
|
|
25
|
+
janux: string;
|
|
26
|
+
resources: ManifestResource[];
|
|
27
|
+
tools: ManifestTool[];
|
|
28
|
+
events: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function uriFor(def: ComponentDef, key?: string): string {
|
|
32
|
+
const scheme = def.kind === 'store' ? 'store' : 'ui';
|
|
33
|
+
|
|
34
|
+
return `${scheme}://${def.name}${key && key !== 'default' ? `#${key}` : ''}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toolsFor(def: ComponentDef, ctx: Ctx, instance?: JanuxInstance): ManifestTool[] {
|
|
38
|
+
return Object.entries(def.intents ?? {})
|
|
39
|
+
.filter(([, intentDef]) => resolveGuard(intentDef, ctx) !== 'forbidden')
|
|
40
|
+
.map(([name, intentDef]) => ({
|
|
41
|
+
name: `${def.name}.${name}`,
|
|
42
|
+
description: intentDef.description,
|
|
43
|
+
guard: resolveGuard(intentDef, ctx),
|
|
44
|
+
input: intentDef.input ? toJsonSchema(intentDef.input) : undefined,
|
|
45
|
+
ready: instance && intentDef.ready ? safeReady(intentDef, instance) : true,
|
|
46
|
+
server: intentDef.server || undefined,
|
|
47
|
+
prefetch: intentDef.prefetch,
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function safeReady(intentDef: NonNullable<ComponentDef['intents']>[string], instance: JanuxInstance): boolean {
|
|
52
|
+
try {
|
|
53
|
+
return intentDef.ready!(instance.bag) === true;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function resourceFor(def: ComponentDef, key?: string, readers?: string[]): ManifestResource[] {
|
|
60
|
+
if (!def.state) return [];
|
|
61
|
+
|
|
62
|
+
return [
|
|
63
|
+
{
|
|
64
|
+
uri: uriFor(def, key),
|
|
65
|
+
description: def.description,
|
|
66
|
+
schema: toJsonSchema(def.state),
|
|
67
|
+
readers,
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ManifestEntry {
|
|
73
|
+
def: ComponentDef;
|
|
74
|
+
key?: string;
|
|
75
|
+
instance?: JanuxInstance;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Builds the agent-facing manifest from mounted components and stores.
|
|
80
|
+
* The mounted component tree IS the MCP tree (RFC §5.1).
|
|
81
|
+
*/
|
|
82
|
+
export function buildManifest(entries: ManifestEntry[], ctx: Ctx = {}): Manifest {
|
|
83
|
+
const readersByStore = storeReaders(entries);
|
|
84
|
+
const resources = entries.flatMap(({ def, key }) =>
|
|
85
|
+
resourceFor(def, key, def.kind === 'store' ? readersByStore.get(def.name) : undefined),
|
|
86
|
+
);
|
|
87
|
+
const tools = entries.flatMap(({ def, instance }) => toolsFor(def, ctx, instance));
|
|
88
|
+
const events = [...new Set(entries.flatMap(({ def }) => Object.keys(def.emits ?? {})))];
|
|
89
|
+
|
|
90
|
+
return { janux: '0.1', resources, tools, events };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function storeReaders(entries: ManifestEntry[]): Map<string, string[]> {
|
|
94
|
+
const readers = new Map<string, string[]>();
|
|
95
|
+
|
|
96
|
+
entries
|
|
97
|
+
.filter(({ def }) => def.kind === 'component')
|
|
98
|
+
.forEach(({ def, key }) => {
|
|
99
|
+
Object.values(def.use ?? {}).forEach((storeDef) => {
|
|
100
|
+
const list = readers.get(storeDef.name) ?? [];
|
|
101
|
+
|
|
102
|
+
readers.set(storeDef.name, [...list, uriFor(def, key)]);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return readers;
|
|
107
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export const VOID_ELEMENTS = new Set([
|
|
2
|
+
'area',
|
|
3
|
+
'base',
|
|
4
|
+
'br',
|
|
5
|
+
'col',
|
|
6
|
+
'embed',
|
|
7
|
+
'hr',
|
|
8
|
+
'img',
|
|
9
|
+
'input',
|
|
10
|
+
'link',
|
|
11
|
+
'meta',
|
|
12
|
+
'source',
|
|
13
|
+
'track',
|
|
14
|
+
'wbr',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const ESCAPES: Record<string, string> = {
|
|
18
|
+
'&': '&',
|
|
19
|
+
'<': '<',
|
|
20
|
+
'>': '>',
|
|
21
|
+
'"': '"',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function escapeHtml(value: unknown): string {
|
|
25
|
+
return String(value).replace(/[&<>"]/g, (char) => ESCAPES[char]!);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function intentMarker(value: unknown): string | undefined {
|
|
29
|
+
const meta = (value as any)?.$intent;
|
|
30
|
+
|
|
31
|
+
if (!meta) return undefined;
|
|
32
|
+
const key = meta.key ? `#${meta.key}` : '';
|
|
33
|
+
|
|
34
|
+
return `${meta.component}${key}:${meta.name}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function attrFor(name: string, value: unknown): string {
|
|
38
|
+
if (value === false || value === null || value === undefined) return '';
|
|
39
|
+
if (value === true) return ` ${name}`;
|
|
40
|
+
|
|
41
|
+
return ` ${name}="${escapeHtml(value)}"`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const VALID_ATTR_NAME = /^[a-zA-Z][\w-]*$/;
|
|
45
|
+
|
|
46
|
+
/** Maps a JSX prop to an HTML attribute pair; `on`/`intent` become data markers for delegation. */
|
|
47
|
+
function propToAttr(name: string, value: unknown): [string, unknown] | undefined {
|
|
48
|
+
if (name === 'children' || name === 'key' || name === 'dangerHTML') return undefined;
|
|
49
|
+
if (name === 'on') return ['data-jxa', intentMarker(value)];
|
|
50
|
+
if (name === 'intent') return ['data-jxform', intentMarker(value)];
|
|
51
|
+
if (name === 'class' || name === 'className') return ['class', value];
|
|
52
|
+
if (typeof value === 'function') return undefined;
|
|
53
|
+
if (!VALID_ATTR_NAME.test(name)) return undefined;
|
|
54
|
+
|
|
55
|
+
return [name, value];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function attrEntries(props: Record<string, unknown>): [string, unknown][] {
|
|
59
|
+
return Object.entries(props)
|
|
60
|
+
.map(([name, value]) => propToAttr(name, value))
|
|
61
|
+
.filter((entry): entry is [string, unknown] => entry !== undefined);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function renderAttrs(props: Record<string, unknown>): string {
|
|
65
|
+
return attrEntries(props)
|
|
66
|
+
.map(([name, value]) => attrFor(name, value))
|
|
67
|
+
.join('');
|
|
68
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { component, intent, source, store } from '../define/factories';
|
|
3
|
+
import { jsx, Fragment } from '../jsx-runtime';
|
|
4
|
+
import { int, list, schema, str } from '../schema';
|
|
5
|
+
import { buildManifest } from '../manifest';
|
|
6
|
+
import { renderToString } from './server';
|
|
7
|
+
|
|
8
|
+
function PriceTag({ amount }: { amount: number }) {
|
|
9
|
+
return jsx('span', { class: 'price', children: `${amount}¢` });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const cart = component({
|
|
13
|
+
name: 'cart',
|
|
14
|
+
description: 'Shopping cart',
|
|
15
|
+
state: schema({ items: list({ id: str(), qty: int() }) }),
|
|
16
|
+
derived: { count: (s: any) => s.items.length },
|
|
17
|
+
sources: { catalog: source({ query: async () => ['p1', 'p2'] }) },
|
|
18
|
+
emits: { 'cart.cleared': schema({}) },
|
|
19
|
+
intents: {
|
|
20
|
+
addItem: intent({
|
|
21
|
+
description: 'Add product',
|
|
22
|
+
input: schema({ id: str() }),
|
|
23
|
+
run: ({ state, input }) => state.items.push({ id: input.id, qty: 1 }),
|
|
24
|
+
}),
|
|
25
|
+
checkout: intent({ guard: 'confirm', run: () => {} }),
|
|
26
|
+
admin: intent({ guard: 'forbidden', run: () => {} }),
|
|
27
|
+
},
|
|
28
|
+
view: ({ state, sources, intents }: any) =>
|
|
29
|
+
jsx('section', {
|
|
30
|
+
children: [
|
|
31
|
+
jsx('p', { children: `${state.items.length} items / ${sources.catalog.value.length} products` }),
|
|
32
|
+
jsx('button', { on: intents.checkout, children: 'Pay' }),
|
|
33
|
+
],
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('renderToString', () => {
|
|
38
|
+
it('renders static components with zero islands', async () => {
|
|
39
|
+
const page = jsx('main', { children: jsx(PriceTag as any, { amount: 100 }) });
|
|
40
|
+
const result = await renderToString(page);
|
|
41
|
+
|
|
42
|
+
expect(result.html).toBe('<main><span class="price">100¢</span></main>');
|
|
43
|
+
expect(result.registry.islands).toHaveLength(0);
|
|
44
|
+
expect(result.snapshots).toHaveLength(0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('escapes text and attribute content', async () => {
|
|
48
|
+
const page = jsx('p', { title: '"><script>', children: '<script>alert(1)</script>' });
|
|
49
|
+
const result = await renderToString(page);
|
|
50
|
+
|
|
51
|
+
expect(result.html).toBe(
|
|
52
|
+
'<p title=""><script>"><script>alert(1)</script></p>',
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('renders islands with loaded sources, markers and snapshots', async () => {
|
|
57
|
+
const page = jsx(Fragment, { children: [jsx('h1', { children: 'Shop' }), jsx(cart as any, {})] });
|
|
58
|
+
const result = await renderToString(page, { initialState: { 'ui://cart#default': { items: [{ id: 'a', qty: 1 }] } } });
|
|
59
|
+
|
|
60
|
+
expect(result.html).toContain('<janux-island data-jx="cart#default">');
|
|
61
|
+
expect(result.html).toContain('1 items / 2 products');
|
|
62
|
+
expect(result.html).toContain('data-jxa="cart#default:checkout"');
|
|
63
|
+
expect(result.snapshots).toEqual([
|
|
64
|
+
{
|
|
65
|
+
uri: 'ui://cart#default',
|
|
66
|
+
state: { items: [{ id: 'a', qty: 1 }] },
|
|
67
|
+
sources: { catalog: { value: ['p1', 'p2'] } },
|
|
68
|
+
},
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('renders void elements self-closed and skips function props', async () => {
|
|
73
|
+
const result = await renderToString(jsx('input', { value: 'x', onInput: () => {} }));
|
|
74
|
+
|
|
75
|
+
expect(result.html).toBe('<input value="x"/>');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('escapes attacker-controlled island keys (XSS regression)', async () => {
|
|
79
|
+
const evil = 'x"><img src=x onerror=alert(1)>';
|
|
80
|
+
const result = await renderToString(jsx(cart as any, { key: evil }));
|
|
81
|
+
|
|
82
|
+
expect(result.html).not.toContain('"><img');
|
|
83
|
+
expect(result.html).toContain('data-jx="cart#x"><img');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('drops invalid attribute names (attribute injection regression)', async () => {
|
|
87
|
+
const result = await renderToString(jsx('div', { 'onmouseover=alert(1) x': 'y', title: 'ok' }));
|
|
88
|
+
|
|
89
|
+
expect(result.html).toBe('<div title="ok"></div>');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('buildManifest', () => {
|
|
94
|
+
const session = store({
|
|
95
|
+
name: 'session',
|
|
96
|
+
state: schema({ locale: str().default('en') }),
|
|
97
|
+
intents: { setLocale: intent({ input: schema({ locale: str() }), run: () => {} }) },
|
|
98
|
+
});
|
|
99
|
+
const header = component({ name: 'header', use: { session }, view: () => null });
|
|
100
|
+
|
|
101
|
+
it('projects mounted components as resources, tools and events', () => {
|
|
102
|
+
const manifest = buildManifest([{ def: cart, key: 'default' }, { def: session }, { def: header }]);
|
|
103
|
+
|
|
104
|
+
expect(manifest.resources.map((r) => r.uri)).toEqual(['ui://cart', 'store://session']);
|
|
105
|
+
expect(manifest.tools.map((t) => `${t.name}:${t.guard}`)).toEqual([
|
|
106
|
+
'cart.addItem:auto',
|
|
107
|
+
'cart.checkout:confirm',
|
|
108
|
+
'session.setLocale:auto',
|
|
109
|
+
]);
|
|
110
|
+
expect(manifest.events).toEqual(['cart.cleared']);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('hides forbidden tools and lists store readers', () => {
|
|
114
|
+
const manifest = buildManifest([{ def: cart }, { def: session }, { def: header }]);
|
|
115
|
+
const sessionResource = manifest.resources.find((r) => r.uri === 'store://session');
|
|
116
|
+
|
|
117
|
+
expect(manifest.tools.find((t) => t.name === 'cart.admin')).toBeUndefined();
|
|
118
|
+
expect(sessionResource!.readers).toEqual(['ui://header']);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('serializes tool input schemas as JSON Schema', () => {
|
|
122
|
+
const manifest = buildManifest([{ def: cart }]);
|
|
123
|
+
const addItem = manifest.tools.find((t) => t.name === 'cart.addItem')!;
|
|
124
|
+
|
|
125
|
+
expect(addItem.input).toEqual({
|
|
126
|
+
type: 'object',
|
|
127
|
+
properties: { id: { type: 'string' } },
|
|
128
|
+
required: ['id'],
|
|
129
|
+
additionalProperties: false,
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
});
|