effectweb 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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -0
  3. package/dist/AsyncContent.d.ts +20 -0
  4. package/dist/AsyncContent.js +75 -0
  5. package/dist/actions.d.ts +27 -0
  6. package/dist/actions.js +31 -0
  7. package/dist/cache.d.ts +32 -0
  8. package/dist/cache.js +147 -0
  9. package/dist/collection.d.ts +15 -0
  10. package/dist/collection.js +24 -0
  11. package/dist/component.d.ts +33 -0
  12. package/dist/component.js +58 -0
  13. package/dist/diagnostics.d.ts +32 -0
  14. package/dist/diagnostics.js +57 -0
  15. package/dist/dom.d.ts +66 -0
  16. package/dist/dom.js +617 -0
  17. package/dist/effectEvent.d.ts +18 -0
  18. package/dist/effectEvent.js +67 -0
  19. package/dist/errors.d.ts +5 -0
  20. package/dist/errors.js +23 -0
  21. package/dist/form.d.ts +19 -0
  22. package/dist/form.js +13 -0
  23. package/dist/index.d.ts +22 -0
  24. package/dist/index.js +21 -0
  25. package/dist/jsx-runtime.d.ts +1 -0
  26. package/dist/jsx-runtime.js +1 -0
  27. package/dist/jsx.d.ts +86 -0
  28. package/dist/jsx.js +1 -0
  29. package/dist/load.d.ts +13 -0
  30. package/dist/load.js +11 -0
  31. package/dist/mount.d.ts +25 -0
  32. package/dist/mount.js +61 -0
  33. package/dist/pages.d.ts +40 -0
  34. package/dist/pages.js +86 -0
  35. package/dist/program.d.ts +48 -0
  36. package/dist/program.js +154 -0
  37. package/dist/query.d.ts +22 -0
  38. package/dist/query.js +12 -0
  39. package/dist/resource.d.ts +34 -0
  40. package/dist/resource.js +59 -0
  41. package/dist/runtime.d.ts +16 -0
  42. package/dist/runtime.js +27 -0
  43. package/dist/session.d.ts +65 -0
  44. package/dist/session.js +186 -0
  45. package/dist/share.d.ts +2 -0
  46. package/dist/share.js +40 -0
  47. package/dist/state.d.ts +2 -0
  48. package/dist/state.js +7 -0
  49. package/dist/task.d.ts +53 -0
  50. package/dist/task.js +70 -0
  51. package/dist/tasks.d.ts +69 -0
  52. package/dist/tasks.js +126 -0
  53. package/dist/testing.d.ts +26 -0
  54. package/dist/testing.js +44 -0
  55. package/package.json +137 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TeleVecha contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,9 @@
1
+ # effectweb
2
+
3
+ Immutable Effect models and JSX compiled to direct DOM updates, without signals, proxies, or virtual DOM.
4
+
5
+ Use with `effectweb-compiler/vite` and Effect `4.0.0-rc.112`. The package includes programs, named tasks, async presentation, query caching, DOM lifetimes, and `effectweb/testing` helpers.
6
+
7
+ See [setup and example](https://github.com/DerpyCrabs/EffectWeb#vite-setup) and the [authoring guide](https://github.com/DerpyCrabs/EffectWeb/blob/main/docs/authoring.md).
8
+
9
+ Early API; runtime and compiler versions advance together. Client-side only. Persistence and multi-tab coordination belong to the application.
@@ -0,0 +1,20 @@
1
+ import { Cause } from 'effect';
2
+ import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
3
+ import { type Slot, type View } from './index.js';
4
+ import type { JSX } from './jsx.js';
5
+ export interface AsyncContentProps<A, E> {
6
+ result: AsyncResult.AsyncResult<A, E>;
7
+ content: Slot<A>;
8
+ pending?: JSX.Element;
9
+ empty?: JSX.Element;
10
+ failure?: Slot<Cause.Cause<E>>;
11
+ refreshing?: JSX.Element;
12
+ pendingDelay?: number;
13
+ }
14
+ type Props = AsyncContentProps<unknown, unknown>;
15
+ /** Presentation only: resource owners choose identity, loading, caching and cancellation. */
16
+ export declare const AsyncContent: {
17
+ <A, E>(props: AsyncContentProps<A, E>): JSX.Element;
18
+ readonly build: View<Props, never>["build"];
19
+ };
20
+ export {};
@@ -0,0 +1,75 @@
1
+ import * as _tv_dom from "./dom.js";
2
+ import { Cause, Effect, Option } from "effect";
3
+ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4
+ import { component } from "./component.js";
5
+ import { view } from "./index.js";
6
+ import { effectCommand } from "./program.js";
7
+ const implementation = component({
8
+ init: (props) => ({
9
+ props,
10
+ pending: false,
11
+ visible: false
12
+ }),
13
+ receive(model, props) {
14
+ const pending = props.result.waiting && Option.isNone(AsyncResult.value(props.result));
15
+ if (!pending)
16
+ return {
17
+ model: {
18
+ props,
19
+ pending: false,
20
+ visible: false
21
+ },
22
+ cancel: ["pending"]
23
+ };
24
+ if (model.pending && model.props.pendingDelay === props.pendingDelay)
25
+ return { model: {
26
+ ...model,
27
+ props
28
+ } };
29
+ const delay = Math.max(0, props.pendingDelay ?? 0);
30
+ return {
31
+ model: {
32
+ props,
33
+ pending: true,
34
+ visible: delay === 0
35
+ },
36
+ cancel: ["pending"],
37
+ commands: delay === 0 ? [] : [effectCommand("pending", () => Effect.sleep(delay), {
38
+ onSuccess: () => ({ type: "ShowPending" }),
39
+ onFailure: () => ({ type: "ShowPending" })
40
+ })]
41
+ };
42
+ },
43
+ update: (model) => ({ model: {
44
+ ...model,
45
+ visible: true
46
+ } }),
47
+ view: _tv_dom.compiled((_tv_scope_1, _tv_parent_2, _tv_before_3) => {
48
+ const _tv_derived_4 = _tv_scope_1.derive(() => [_tv_scope_1.value?.props], () => ((_tv_capture_5) => _tv_capture_5.props)(_tv_scope_1.value));
49
+ const _tv_derived_6 = _tv_scope_1.derive(() => [_tv_derived_4()?.result], () => ((_tv_capture_7) => AsyncResult.value(_tv_capture_7.result))(_tv_derived_4()));
50
+ const _tv_derived_8 = _tv_scope_1.derive(() => [_tv_derived_4()?.result, _tv_derived_4()?.result?.waiting], () => ((_tv_capture_9) => AsyncResult.isFailure(_tv_capture_9.result) && !_tv_capture_9.result.waiting ? _tv_capture_9.result : undefined)(_tv_derived_4()));
51
+ _tv_dom.branch(_tv_scope_1, _tv_parent_2, _tv_before_3, () => Option.isSome(_tv_derived_6()), (_tv_scope_1, _tv_parent_10, _tv_before_11) => {
52
+ _tv_dom.text(_tv_scope_1, _tv_parent_10, _tv_before_11, () => [_tv_derived_4(), _tv_derived_6()?.value], () => _tv_derived_4().content(_tv_derived_6().value));
53
+ _tv_dom.branch(_tv_scope_1, _tv_parent_10, _tv_before_11, () => _tv_derived_4().result.waiting, (_tv_scope_1, _tv_parent_12, _tv_before_13) => {
54
+ _tv_dom.text(_tv_scope_1, _tv_parent_12, _tv_before_13, () => [_tv_derived_4()?.refreshing], () => _tv_derived_4().refreshing);
55
+ }, (_tv_scope_1, _tv_parent_12, _tv_before_13) => { });
56
+ _tv_dom.branch(_tv_scope_1, _tv_parent_10, _tv_before_11, () => _tv_derived_8() && _tv_derived_4().failure, (_tv_scope_1, _tv_parent_14, _tv_before_15) => {
57
+ _tv_dom.text(_tv_scope_1, _tv_parent_14, _tv_before_15, () => [_tv_derived_4(), _tv_derived_8()?.cause], () => _tv_derived_4().failure(_tv_derived_8().cause));
58
+ }, (_tv_scope_1, _tv_parent_14, _tv_before_15) => { });
59
+ }, (_tv_scope_1, _tv_parent_10, _tv_before_11) => {
60
+ _tv_dom.branch(_tv_scope_1, _tv_parent_10, _tv_before_11, () => _tv_derived_8() && _tv_derived_4().failure, (_tv_scope_1, _tv_parent_16, _tv_before_17) => {
61
+ _tv_dom.text(_tv_scope_1, _tv_parent_16, _tv_before_17, () => [_tv_derived_4(), _tv_derived_8()?.cause], () => _tv_derived_4().failure(_tv_derived_8().cause));
62
+ }, (_tv_scope_1, _tv_parent_16, _tv_before_17) => {
63
+ _tv_dom.branch(_tv_scope_1, _tv_parent_16, _tv_before_17, () => _tv_scope_1.value.pending, (_tv_scope_1, _tv_parent_18, _tv_before_19) => {
64
+ _tv_dom.branch(_tv_scope_1, _tv_parent_18, _tv_before_19, () => _tv_scope_1.value.visible, (_tv_scope_1, _tv_parent_20, _tv_before_21) => {
65
+ _tv_dom.text(_tv_scope_1, _tv_parent_20, _tv_before_21, () => [_tv_derived_4()?.pending], () => _tv_derived_4().pending);
66
+ }, (_tv_scope_1, _tv_parent_20, _tv_before_21) => { });
67
+ }, (_tv_scope_1, _tv_parent_18, _tv_before_19) => {
68
+ _tv_dom.text(_tv_scope_1, _tv_parent_18, _tv_before_19, () => [_tv_derived_4()?.empty], () => _tv_derived_4().empty);
69
+ });
70
+ });
71
+ });
72
+ })
73
+ });
74
+ /** Presentation only: resource owners choose identity, loading, caching and cancellation. */
75
+ export const AsyncContent = implementation;
@@ -0,0 +1,27 @@
1
+ import type { Send, Transition } from './program.js';
2
+ type Handler<Model> = (model: Model, ...args: never[]) => Transition<Model, unknown>;
3
+ type Arguments<F> = F extends (model: never, ...args: infer Args) => unknown ? Args : never;
4
+ type HandlerMessage<Handlers> = {
5
+ [Name in keyof Handlers]: {
6
+ readonly type: Name;
7
+ readonly args: Arguments<Handlers[Name]>;
8
+ };
9
+ }[keyof Handlers];
10
+ export type ActionMessage<Definition> = Definition extends {
11
+ update: (model: never, message: infer Message) => unknown;
12
+ } ? Message : never;
13
+ type Creators<Handlers> = {
14
+ [Name in keyof Handlers]: (...args: Arguments<Handlers[Name]>) => Extract<HandlerMessage<Handlers>, {
15
+ type: Name;
16
+ }>;
17
+ };
18
+ type Dispatch<Handlers> = {
19
+ [Name in keyof Handlers]: (...args: Arguments<Handlers[Name]>) => void;
20
+ };
21
+ /** Declare action names and payloads once. Dispatch still crosses the ordinary program message queue. */
22
+ export declare function defineActions<Model>(): <Handlers extends Record<string, Handler<Model>>>(handlers: Handlers) => {
23
+ message: Creators<Handlers>;
24
+ bind(this: void, send: Send<HandlerMessage<Handlers>>): Dispatch<Handlers>;
25
+ update(this: void, model: Model, action: HandlerMessage<Handlers>): ReturnType<Handlers[keyof Handlers]>;
26
+ };
27
+ export {};
@@ -0,0 +1,31 @@
1
+ /** Declare action names and payloads once. Dispatch still crosses the ordinary program message queue. */
2
+ export function defineActions() {
3
+ return (handlers) => {
4
+ const message = Object.create(null);
5
+ for (const name of Object.keys(handlers)) {
6
+ Object.defineProperty(message, name, {
7
+ enumerable: true,
8
+ value: (...args) => ({ type: name, args }),
9
+ });
10
+ }
11
+ return {
12
+ message,
13
+ bind(send) {
14
+ const dispatch = Object.create(null);
15
+ for (const name of Object.keys(handlers)) {
16
+ Object.defineProperty(dispatch, name, {
17
+ enumerable: true,
18
+ value: (...args) => send({ type: name, args }),
19
+ });
20
+ }
21
+ return dispatch;
22
+ },
23
+ update(model, action) {
24
+ if (!Object.hasOwn(handlers, action.type))
25
+ throw new Error(`Unknown action: ${String(action.type)}`);
26
+ const handler = handlers[action.type];
27
+ return handler(model, ...action.args);
28
+ },
29
+ };
30
+ };
31
+ }
@@ -0,0 +1,32 @@
1
+ import { Effect } from 'effect';
2
+ import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
3
+ import * as Atom from 'effect/unstable/reactivity/Atom';
4
+ import * as AtomRegistry from 'effect/unstable/reactivity/AtomRegistry';
5
+ import { type UiLoad, type UiPage } from './load.js';
6
+ import type { Query } from './query.js';
7
+ import type { UiRuntime } from './runtime.js';
8
+ export { loadEffect, type UiLoad, type UiPage } from './load.js';
9
+ export { shareValue } from './share.js';
10
+ export declare function makeUiModel(): UiModel<never>;
11
+ export declare function makeUiModel<R>(runtime: UiRuntime<R>): UiModel<R>;
12
+ declare function createUiModel<R>(runtime?: UiRuntime<R>): {
13
+ registry: AtomRegistry.AtomRegistry;
14
+ generation: Atom.Writable<number, number>;
15
+ resource<A, E = unknown>(key: string, load: () => Effect.Effect<A, E>): Atom.Atom<AsyncResult.AsyncResult<A, E>>;
16
+ query: <Args, A, E>(definition: Query<Args, A, E, R>, args: Args) => Atom.Atom<AsyncResult.AsyncResult<A, E>>;
17
+ /** Prefetch and views observe the same atom; failure types and shared cancellation remain intact. */
18
+ prefetch<Args, A, E>(definition: Query<Args, A, E, R>, args: Args): Effect.Effect<A, E>;
19
+ invalidateQuery<Args, A, E>(definition: Query<Args, A, E, R>, ...selected: [] | [Args]): void;
20
+ invalidate(prefix: string): void;
21
+ resetResources(): void;
22
+ dispose(): void;
23
+ };
24
+ export type UiModel<R = never> = ReturnType<typeof createUiModel<R>>;
25
+ /** Cursor progress and prior successful pages belong to the resource, including retries. */
26
+ export declare function makePagedResource<A, Cursor>(registry: AtomRegistry.AtomRegistry, load: (cursor: Cursor | undefined) => UiLoad<UiPage<A, Cursor>>, itemKey: (item: A) => string): {
27
+ atom: Atom.Atom<AsyncResult.AsyncResult<UiPage<A, Cursor> & {
28
+ pageCount: number;
29
+ }, unknown>>;
30
+ more(): void;
31
+ refresh(): void;
32
+ };
package/dist/cache.js ADDED
@@ -0,0 +1,147 @@
1
+ import { Effect, Option } from 'effect';
2
+ import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
3
+ import * as Atom from 'effect/unstable/reactivity/Atom';
4
+ import * as AtomRegistry from 'effect/unstable/reactivity/AtomRegistry';
5
+ import { loadEffect } from './load.js';
6
+ import { shareValue } from './share.js';
7
+ export { loadEffect } from './load.js';
8
+ export { shareValue } from './share.js';
9
+ export function makeUiModel(runtime) {
10
+ return createUiModel(runtime);
11
+ }
12
+ function createUiModel(runtime) {
13
+ const registry = AtomRegistry.make({ defaultIdleTTL: 30_000 });
14
+ const generation = Atom.keepAlive(Atom.make(0));
15
+ const resources = new Map();
16
+ const acquire = (key, load) => {
17
+ let entry = resources.get(key);
18
+ if (entry)
19
+ entry.load = load;
20
+ else {
21
+ const next = {
22
+ load,
23
+ atom: Atom.make((get) => {
24
+ const previous = Option.flatMap(get.self(), AsyncResult.value);
25
+ return loadEffect(() => next.load()).pipe(Effect.map((value) => {
26
+ next.loadedAt = Date.now();
27
+ return Option.isSome(previous) ? shareValue(previous.value, value) : value;
28
+ }));
29
+ }),
30
+ };
31
+ entry = next;
32
+ resources.set(key, entry);
33
+ }
34
+ // Bound definitions as well as the registry's values. Mounted resources stay shared.
35
+ if (resources.size > 512) {
36
+ for (const [oldKey, old] of resources) {
37
+ if (resources.size <= 512)
38
+ break;
39
+ if (oldKey !== key && !registry.getNodes().has(old.atom))
40
+ resources.delete(oldKey);
41
+ }
42
+ }
43
+ return { entry, atom: entry.atom };
44
+ };
45
+ const queryKey = (definition, args) => `query:${definition.id}:${definition.key(args)}`;
46
+ const selectQuery = (definition, args) => {
47
+ const { entry, atom } = acquire(queryKey(definition, args), () => {
48
+ const effect = Effect.suspend(() => definition.load(args));
49
+ return runtime ? runtime.provide(effect) : effect;
50
+ });
51
+ entry.queryId = definition.id;
52
+ if (registry.getNodes().has(atom) &&
53
+ entry.loadedAt !== undefined &&
54
+ Date.now() - entry.loadedAt >= definition.staleTime) {
55
+ const current = registry.get(atom);
56
+ if (!current.waiting)
57
+ registry.refresh(atom);
58
+ }
59
+ return atom;
60
+ };
61
+ return {
62
+ registry,
63
+ generation,
64
+ resource(key, load) {
65
+ return acquire(key, load).atom;
66
+ },
67
+ query: selectQuery,
68
+ /** Prefetch and views observe the same atom; failure types and shared cancellation remain intact. */
69
+ prefetch(definition, args) {
70
+ return Effect.suspend(() => AtomRegistry.getResult(registry, selectQuery(definition, args), { suspendOnWaiting: true }));
71
+ },
72
+ invalidateQuery(definition, ...selected) {
73
+ if (selected.length) {
74
+ const entry = resources.get(queryKey(definition, selected[0]));
75
+ if (entry)
76
+ registry.refresh(entry.atom);
77
+ }
78
+ else {
79
+ for (const entry of resources.values())
80
+ if (entry.queryId === definition.id)
81
+ registry.refresh(entry.atom);
82
+ }
83
+ },
84
+ invalidate(prefix) {
85
+ for (const [key, entry] of resources)
86
+ if (key.startsWith(prefix))
87
+ registry.refresh(entry.atom);
88
+ },
89
+ resetResources() {
90
+ Atom.batch(() => {
91
+ for (const entry of resources.values()) {
92
+ entry.load = () => Effect.interrupt;
93
+ registry.refresh(entry.atom);
94
+ }
95
+ resources.clear();
96
+ registry.update(generation, (value) => value + 1);
97
+ });
98
+ },
99
+ dispose() {
100
+ registry.dispose();
101
+ resources.clear();
102
+ },
103
+ };
104
+ }
105
+ /** Cursor progress and prior successful pages belong to the resource, including retries. */
106
+ export function makePagedResource(registry, load, itemKey) {
107
+ const requested = Atom.make(1);
108
+ const atom = Atom.make((get) => {
109
+ const count = get(requested);
110
+ const previous = Option.getOrUndefined(Option.flatMap(get.self(), AsyncResult.value));
111
+ const append = previous && count === previous.pageCount + 1 ? previous : undefined;
112
+ return loadEffect(() => load(append?.next)).pipe(Effect.map((page) => {
113
+ const known = new Set(append?.items.map(itemKey));
114
+ const incoming = page.items.filter((item) => {
115
+ const key = itemKey(item);
116
+ if (known.has(key))
117
+ return false;
118
+ known.add(key);
119
+ return true;
120
+ });
121
+ return {
122
+ ...page,
123
+ items: append ? [...append.items, ...incoming] : incoming,
124
+ pageCount: append ? count : 1,
125
+ };
126
+ }));
127
+ }).pipe(Atom.setIdleTTL(0));
128
+ return {
129
+ atom,
130
+ more() {
131
+ const result = registry.get(atom);
132
+ const previous = Option.getOrUndefined(AsyncResult.value(result));
133
+ if (result.waiting || !previous || previous.next === undefined)
134
+ return;
135
+ Atom.batch(() => {
136
+ registry.set(requested, previous.pageCount + 1);
137
+ registry.refresh(atom);
138
+ });
139
+ },
140
+ refresh() {
141
+ Atom.batch(() => {
142
+ registry.set(requested, 1);
143
+ registry.refresh(atom);
144
+ });
145
+ },
146
+ };
147
+ }
@@ -0,0 +1,15 @@
1
+ export type Identity = string | number;
2
+ /** Identity belongs to the domain collection, not to each place that renders it. */
3
+ export interface Rows<A> {
4
+ readonly items: readonly A[];
5
+ readonly identity: (item: A, index: number) => Identity;
6
+ readonly length: number;
7
+ map<B>(render: (item: A, index: number) => B): B[];
8
+ filter(predicate: (item: A, index: number) => boolean): Rows<A>;
9
+ slice(start?: number, end?: number): Rows<A>;
10
+ }
11
+ export declare function collection<A>(identity: (item: A, index: number) => Identity): {
12
+ from: (items: readonly A[]) => Rows<A>;
13
+ };
14
+ /** Ordered protocol structures such as keyboard rows and rich-text fragments have positional identity. */
15
+ export declare function sequence<A>(items: readonly A[]): Rows<A>;
@@ -0,0 +1,24 @@
1
+ export function collection(identity) {
2
+ const cache = new WeakMap();
3
+ const from = (items) => {
4
+ const cached = cache.get(items);
5
+ if (cached)
6
+ return cached;
7
+ const rows = {
8
+ items,
9
+ identity,
10
+ length: items.length,
11
+ map: (render) => items.map(render),
12
+ filter: (predicate) => from(items.filter(predicate)),
13
+ slice: (start, end) => from(items.slice(start, end)),
14
+ };
15
+ cache.set(items, rows);
16
+ return rows;
17
+ };
18
+ return { from };
19
+ }
20
+ const positions = collection((_item, index) => index);
21
+ /** Ordered protocol structures such as keyboard rows and rich-text fragments have positional identity. */
22
+ export function sequence(items) {
23
+ return positions.from(items);
24
+ }
@@ -0,0 +1,33 @@
1
+ import type { View } from './dom.js';
2
+ import type { Transition } from './program.js';
3
+ import { type UiRuntime } from './runtime.js';
4
+ /** Local fields with no command lifecycle. Sends are shallow patches, never updater callbacks. */
5
+ export declare function localComponent<Props, State extends object>(definition: {
6
+ init: (props: Props) => State & {
7
+ readonly props?: never;
8
+ };
9
+ view: View<State & {
10
+ readonly props: Props;
11
+ }, Partial<State> & {
12
+ readonly props?: never;
13
+ }>;
14
+ }): View<Props, never>;
15
+ /** A child model receives immutable parent inputs and owns its own message/command loop. */
16
+ export declare function component<Props, Model extends {
17
+ readonly props: Props;
18
+ }, Message, R = never>(definition: {
19
+ init: (props: Props) => Model;
20
+ receive?: (model: Model, props: Props) => Transition<Model, Message, R>;
21
+ update: (model: Model, message: Message) => Transition<Model, Message, R>;
22
+ view: View<Model, Message>;
23
+ } & ([R] extends [never] ? {
24
+ runtime?: UiRuntime<R>;
25
+ } : {
26
+ runtime: UiRuntime<R>;
27
+ })): View<Props, never>;
28
+ /** Mount an existing program without introducing a second state owner. */
29
+ export declare function programView<Props, Model, Message>(definition: {
30
+ create: (props: Props) => import('./program').Program<Model, Message>;
31
+ receive: (source: import('./program').Program<Model, Message>, props: Props) => void;
32
+ view: View<Model, Message>;
33
+ }): View<Props, never>;
@@ -0,0 +1,58 @@
1
+ import { compiled, Scope } from './dom.js';
2
+ import { mapCommand, program } from './program.js';
3
+ import { patchModel } from './state.js';
4
+ import { defaultUiRuntime } from './runtime.js';
5
+ /** Local fields with no command lifecycle. Sends are shallow patches, never updater callbacks. */
6
+ export function localComponent(definition) {
7
+ return component({
8
+ init: (props) => ({ ...definition.init(props), props }),
9
+ update: (model, patch) => {
10
+ const next = patchModel(model, patch);
11
+ return { model: next === model ? model : { ...next, props: model.props } };
12
+ },
13
+ view: definition.view,
14
+ });
15
+ }
16
+ /** A child model receives immutable parent inputs and owns its own message/command loop. */
17
+ export function component(definition) {
18
+ const runtime = definition.runtime ?? defaultUiRuntime;
19
+ return compiled((scope, parent, before) => {
20
+ const wrap = (next) => ({
21
+ model: next.model,
22
+ ...(next.cancel ? { cancel: next.cancel } : {}),
23
+ ...(next.commands
24
+ ? {
25
+ commands: next.commands.map((command) => mapCommand(runtime.command(command), (message) => ({ type: 'Message', message }))),
26
+ }
27
+ : {}),
28
+ });
29
+ const source = program({
30
+ initial: definition.init(scope.value),
31
+ onDefect: scope.report,
32
+ update: (model, envelope) => wrap(envelope.type === 'Input'
33
+ ? (definition.receive?.(model, envelope.props) ?? {
34
+ model: Object.is(model.props, envelope.props)
35
+ ? model
36
+ : { ...model, props: envelope.props },
37
+ })
38
+ : definition.update(model, envelope.message)),
39
+ });
40
+ const child = new Scope(source.model(), (message) => source.send({ type: 'Message', message }), scope.report);
41
+ scope.cleanups.push(() => source.dispose(), () => child.dispose(), () => unsubscribe());
42
+ const unsubscribe = source.subscribe((model) => child.set(model));
43
+ definition.view.build(child, parent, before);
44
+ scope.jobs.push(() => source.send({ type: 'Input', props: scope.value }));
45
+ source.send({ type: 'Input', props: scope.value });
46
+ });
47
+ }
48
+ /** Mount an existing program without introducing a second state owner. */
49
+ export function programView(definition) {
50
+ return compiled((scope, parent, before) => {
51
+ const source = definition.create(scope.value);
52
+ const child = new Scope(source.model(), source.send, scope.report);
53
+ const unsubscribe = source.subscribe((model) => child.set(model));
54
+ scope.cleanups.push(() => source.dispose(), () => child.dispose(), () => unsubscribe());
55
+ definition.view.build(child, parent, before);
56
+ scope.jobs.push(() => definition.receive(source, scope.value));
57
+ });
58
+ }
@@ -0,0 +1,32 @@
1
+ /** Emitted only by development builds. No model values are retained in diagnostic events. */
2
+ export interface BindingSource {
3
+ readonly file: string;
4
+ readonly line: number;
5
+ readonly column: number;
6
+ readonly expression: string;
7
+ readonly dependencies: readonly string[];
8
+ }
9
+ export interface BindingUpdate {
10
+ readonly source: BindingSource;
11
+ readonly changed: readonly string[];
12
+ readonly kind: 'derive' | 'binding';
13
+ }
14
+ /** Each registration has its own lifetime; observers can be stopped in any order. */
15
+ export declare function observeBindings(next: (update: BindingUpdate) => void): () => void;
16
+ export declare function traceBinding(source: BindingSource | undefined, previous: readonly unknown[] | undefined, next: readonly unknown[], kind: BindingUpdate['kind']): void;
17
+ export interface ProgramUpdate {
18
+ readonly program: number;
19
+ readonly name?: string;
20
+ readonly kind: 'update' | 'start' | 'cancel' | 'complete' | 'defect' | 'dispose';
21
+ readonly slot?: string;
22
+ /** Message discriminant only. Payloads, model values and resource keys are never retained. */
23
+ readonly message?: string;
24
+ }
25
+ export declare const nextProgramId: () => number;
26
+ export declare const hasProgramObservers: () => boolean;
27
+ export declare function traceProgram(event: ProgramUpdate): void;
28
+ /** Opt-in bounded metadata history. Inspection only: no command replay or payload snapshots. */
29
+ export declare function observePrograms(limit?: number): {
30
+ events: () => ProgramUpdate[];
31
+ dispose: () => boolean;
32
+ };
@@ -0,0 +1,57 @@
1
+ import { reportError, reportSafely } from './errors.js';
2
+ const observers = new Map();
3
+ /** Each registration has its own lifetime; observers can be stopped in any order. */
4
+ export function observeBindings(next) {
5
+ const token = Symbol();
6
+ observers.set(token, next);
7
+ return () => {
8
+ observers.delete(token);
9
+ };
10
+ }
11
+ export function traceBinding(source, previous, next, kind) {
12
+ if (!source || !observers.size)
13
+ return;
14
+ const update = {
15
+ source,
16
+ kind,
17
+ changed: source.dependencies.filter((_, index) => !previous || !Object.is(previous[index], next[index])),
18
+ };
19
+ // Observers registered during dispatch start with the next update.
20
+ // oxlint-disable-next-line unicorn/no-useless-spread
21
+ for (const [token, observer] of [...observers]) {
22
+ if (!observers.has(token))
23
+ continue;
24
+ try {
25
+ observer(update);
26
+ }
27
+ catch (error) {
28
+ reportSafely(reportError, error);
29
+ }
30
+ }
31
+ }
32
+ let programId = 0;
33
+ export const nextProgramId = () => ++programId;
34
+ const programObservers = new Set();
35
+ export const hasProgramObservers = () => programObservers.size > 0;
36
+ export function traceProgram(event) {
37
+ for (const observer of programObservers) {
38
+ try {
39
+ observer(event);
40
+ }
41
+ catch (error) {
42
+ reportSafely(reportError, error);
43
+ }
44
+ }
45
+ }
46
+ /** Opt-in bounded metadata history. Inspection only: no command replay or payload snapshots. */
47
+ export function observePrograms(limit = 100) {
48
+ const capacity = Math.max(1, Math.min(1000, Math.floor(limit) || 100));
49
+ const entries = [];
50
+ const receive = (event) => {
51
+ entries.push(event);
52
+ if (entries.length > capacity)
53
+ entries.shift();
54
+ };
55
+ programObservers.add(receive);
56
+ return { events: () => entries.slice(), dispose: () => programObservers.delete(receive) };
57
+ }
package/dist/dom.d.ts ADDED
@@ -0,0 +1,66 @@
1
+ import { type ReportError } from './errors.js';
2
+ import { type BindingSource } from './diagnostics.js';
3
+ import type { Rows } from './index.js';
4
+ import type { JSX } from './jsx.js';
5
+ import type { DomMount } from './mount.js';
6
+ import type { Program, Send } from './program.js';
7
+ type Dependencies = () => readonly unknown[];
8
+ type Cleanup = () => void;
9
+ type Build<M, E> = (scope: Scope<M, E>, parent: Node, before: Node | null) => void;
10
+ /** Compiler implementation. No implicit tracking, proxies, or per-binding subscriptions. */
11
+ export declare class Scope<M, E> {
12
+ value: M;
13
+ readonly send: Send<E>;
14
+ readonly report: ReportError;
15
+ readonly jobs: Cleanup[];
16
+ readonly cleanups: Cleanup[];
17
+ disposed: boolean;
18
+ private revision;
19
+ constructor(value: M, send: Send<E>, report?: ReportError);
20
+ derive<A>(dependencies: Dependencies, compute: () => A, source?: BindingSource): () => A;
21
+ watch(dependencies: Dependencies, apply: () => void, source?: BindingSource): void;
22
+ set(value: M): void;
23
+ dispose(): void;
24
+ }
25
+ export interface View<M, E> {
26
+ (props: M): JSX.Element;
27
+ (props: {
28
+ model: M;
29
+ send: Send<E>;
30
+ }): JSX.Element;
31
+ readonly build: Build<M, E>;
32
+ }
33
+ declare const contentBrand: unique symbol;
34
+ /** Owned markup accepted wherever JSX can render content. */
35
+ export interface CompiledContent {
36
+ readonly [contentBrand]: true;
37
+ }
38
+ /** A compiled template with a typed placement value, captured in its declaring view. */
39
+ export interface Slot<A = void> {
40
+ (value: A): JSX.Element;
41
+ readonly [contentBrand]: [A] extends [void] ? true : false;
42
+ }
43
+ /** Compiler marker: declare inside view(), or directly in a compiled component prop. */
44
+ export declare function slot<A = void>(_render: (value: A) => JSX.Element): Slot<A>;
45
+ /** Compiler output. Captures follow their declaring scope; placements own independent scopes. */
46
+ export declare function compiledSlot<M, E, A>(owner: Scope<M, E>, build: Build<A, E>): Slot<A>;
47
+ /** This declaration is a compiler marker, never a component setup callback. */
48
+ export declare function view<M, E>(_render: (model: M, send: Send<E>) => JSX.Element): View<M, E>;
49
+ export declare function compiled<M, E>(build: Build<M, E>): View<M, E>;
50
+ export declare function mountView<M, E>(parent: Node, definition: View<M, E>, source: Program<M, E>, options?: {
51
+ onError?: ReportError;
52
+ }): () => void;
53
+ export declare function element(parent: Node, before: Node | null, tag: string): Element;
54
+ export declare function attribute(element: Element, name: string, value: unknown): void;
55
+ export declare function text<M, E>(scope: Scope<M, E>, parent: Node, before: Node | null, dependencies: Dependencies, read: () => unknown, source?: BindingSource): void;
56
+ export declare function event<M, E>(scope: Scope<M, E>, element: Element, name: string, handler: (event: Event) => unknown): void;
57
+ export declare function branch<M, E>(scope: Scope<M, E>, parent: Node, before: Node | null, choose: () => boolean, yes: Build<M, E>, no: Build<M, E>): void;
58
+ export declare function each<M, E, A>(scope: Scope<M, E>, parent: Node, before: Node | null, read: () => Rows<A> | readonly A[], outer: Dependencies, indexUsed: boolean, build: Build<readonly [A, number], E>): void;
59
+ export declare function invoke<M, E>(scope: Scope<M, E>, parent: Node, before: Node | null, dependencies: Dependencies, read: () => readonly unknown[], build: Build<readonly unknown[], E>): void;
60
+ export declare function child<M, E, C, F>(scope: Scope<M, E>, parent: Node, before: Node | null, definition: View<C, F>, dependencies: Dependencies, model: () => C, send: Send<F>): void;
61
+ export declare function attach<M, E>(scope: Scope<M, E>, element: Element, dependencies: Dependencies, read: () => DomMount | undefined): void;
62
+ export declare function portal<M, E>(scope: Scope<M, E>, build: Build<M, E>): void;
63
+ /** Build static markup once per document/namespace, then clone native nodes at each use. */
64
+ export declare function template(build: (parent: Node, before: Node | null) => void): (parent: Node, before: Node | null) => void;
65
+ export declare function literal(parent: Node, before: Node | null, value: string): void;
66
+ export {};