@tschk/moonshine 0.3.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/README.md ADDED
@@ -0,0 +1,18 @@
1
+ # `@tschk/moonshine`
2
+
3
+ Signal-only kernel of the Moonshine framework. Bun-first.
4
+
5
+ ```ts
6
+ import { createSignal, createMemo, createStore, batch } from "@tschk/moonshine";
7
+ import { createApp, useSignal } from "@tschk/moonshine/react";
8
+ import { state, derived, effect } from "@tschk/moonshine/runes";
9
+ ```
10
+
11
+ The default export contains only signals, memos, stores, and resources. Router,
12
+ server, compiler, and deployment packages are separate imports.
13
+
14
+ Optional compatibility subpaths (`/react`, `/runes`, `/router`, `/server`,
15
+ `/shaders`) remain available during the `0.3.x` line.
16
+
17
+ See [Moonshine README](../../README.md), [DESIGN.md](../../DESIGN.md), and
18
+ [docs/MANIFEST.md](../../docs/MANIFEST.md).
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@tschk/moonshine",
3
+ "version": "0.3.1",
4
+ "description": "Hyperminimal signal runtime — Bun-first; import /react for TSX apps",
5
+ "license": "ISC",
6
+ "type": "module",
7
+ "main": "./src/index.ts",
8
+ "types": "./src/index.ts",
9
+ "exports": {
10
+ ".": "./src/index.ts",
11
+ "./react": "./src/react.ts",
12
+ "./host-react": "./src/host-react.ts",
13
+ "./runes": "./src/runes.ts",
14
+ "./jsx-runtime": "./src/jsx-runtime.ts",
15
+ "./jsx-dev-runtime": "./src/jsx-runtime.ts",
16
+ "./router": "./src/router.ts",
17
+ "./server": "./src/server.ts",
18
+ "./shaders": "./src/shaders.ts"
19
+ },
20
+ "dependencies": {
21
+ "@tschk/moonshine-react": "^0.3.1",
22
+ "@tschk/moonshine-router": "^0.3.1",
23
+ "@tschk/moonshine-server": "^0.3.1",
24
+ "@tschk/moonshine-shaders": "^0.3.1"
25
+ },
26
+ "scripts": {
27
+ "test": "bun test",
28
+ "typecheck": "bunx tsc --noEmit",
29
+ "format": "bunx prettier --check .",
30
+ "lint": "bunx oxlint ."
31
+ },
32
+ "peerDependencies": {
33
+ "react": ">=18",
34
+ "react-dom": ">=18"
35
+ },
36
+ "peerDependenciesMeta": {
37
+ "react": {
38
+ "optional": true
39
+ },
40
+ "react-dom": {
41
+ "optional": true
42
+ }
43
+ },
44
+ "devDependencies": {
45
+ "@types/bun": "1.3.14",
46
+ "@types/react": "^19.1.0",
47
+ "@types/react-dom": "^19.1.0",
48
+ "react": "19.1.0",
49
+ "react-dom": "19.1.0",
50
+ "typescript": "7.0.2"
51
+ },
52
+ "files": [
53
+ "src",
54
+ "README.md",
55
+ "LICENSE"
56
+ ],
57
+ "keywords": [
58
+ "signals",
59
+ "moonshine",
60
+ "bun",
61
+ "react",
62
+ "runes"
63
+ ]
64
+ }
@@ -0,0 +1,79 @@
1
+ import { createElement, type ComponentType, type ReactNode } from "react";
2
+ import { createRoot, type Root } from "react-dom/client";
3
+
4
+ export type MoonshineAppOptions = {
5
+ /** React root component. */
6
+ root: ComponentType;
7
+ /** Optional CSS class applied to the mount host. */
8
+ className?: string;
9
+ };
10
+
11
+ export type MoonshineApp = {
12
+ /** Mount into a DOM container (selector or element). */
13
+ mount: (container: string | Element) => void;
14
+ /** Unmount and dispose the React root. */
15
+ unmount: () => void;
16
+ /** True after a successful mount and before unmount. */
17
+ readonly mounted: boolean;
18
+ };
19
+
20
+ /**
21
+ * Create a lightweight Moonshine app shell.
22
+ *
23
+ * ```ts
24
+ * const app = createMoonshineApp({ root: App });
25
+ * app.mount("#app");
26
+ * ```
27
+ */
28
+ export function createMoonshineApp(options: MoonshineAppOptions): MoonshineApp {
29
+ let reactRoot: Root | null = null;
30
+ let host: Element | null = null;
31
+ let mounted = false;
32
+
33
+ return {
34
+ get mounted() {
35
+ return mounted;
36
+ },
37
+ mount(container) {
38
+ if (mounted) {
39
+ throw new Error("createMoonshineApp: already mounted");
40
+ }
41
+
42
+ const el =
43
+ typeof container === "string"
44
+ ? document.querySelector(container)
45
+ : container;
46
+ if (!el) {
47
+ throw new Error(
48
+ `createMoonshineApp: mount target not found: ${String(container)}`,
49
+ );
50
+ }
51
+
52
+ host = el;
53
+ if (options.className) {
54
+ el.classList.add(...options.className.split(/\s+/).filter(Boolean));
55
+ }
56
+
57
+ reactRoot = createRoot(el);
58
+ reactRoot.render(createElement(options.root));
59
+ mounted = true;
60
+ },
61
+ unmount() {
62
+ if (!mounted || !reactRoot) return;
63
+ reactRoot.unmount();
64
+ reactRoot = null;
65
+ if (host && options.className) {
66
+ for (const cls of options.className.split(/\s+/).filter(Boolean)) {
67
+ host.classList.remove(cls);
68
+ }
69
+ }
70
+ host = null;
71
+ mounted = false;
72
+ },
73
+ };
74
+ }
75
+
76
+ /** @internal helper for tests / SSR stubs that only need an element tree. */
77
+ export function renderToNode(root: ComponentType): ReactNode {
78
+ return createElement(root);
79
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Shared React-host surface for framework adapters.
3
+ * Signals + hooks + resources + runes + jsx. No host router.
4
+ */
5
+ export {
6
+ batch,
7
+ createMemo,
8
+ createSignal,
9
+ createStore,
10
+ untrack,
11
+ } from "./signal";
12
+ export type { Memo, Signal, StoreSetter } from "./signal";
13
+
14
+ export { createResource } from "./resource";
15
+ export type {
16
+ CreateResourceOptions,
17
+ Resource,
18
+ ResourceStatus,
19
+ } from "./resource";
20
+
21
+ export {
22
+ createApp,
23
+ createIslandSignal,
24
+ createMoonshineApp,
25
+ renderToNode,
26
+ useResource,
27
+ useSignal,
28
+ useStore,
29
+ } from "./react";
30
+ export type { MoonshineApp, MoonshineAppOptions } from "./react";
31
+
32
+ export { jsx, jsxs, Fragment } from "./jsx-runtime";
33
+ export type { JSX } from "./jsx-runtime";
34
+
35
+ export { state, derived, effect } from "./runes";
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @tschk/moonshine — hyperminimal signal runtime (no React).
3
+ *
4
+ * Import what you need:
5
+ * import { createSignal, createMemo } from "@tschk/moonshine"
6
+ * import { createApp, useSignal } from "@tschk/moonshine/react"
7
+ * import { state, derived, effect } from "@tschk/moonshine/runes"
8
+ * import { MoonshineRouter } from "@tschk/moonshine/router"
9
+ * import { createMoonshineServer } from "@tschk/moonshine/server"
10
+ * import { useFragmentShader } from "@tschk/moonshine/shaders"
11
+ */
12
+
13
+ export {
14
+ batch,
15
+ createMemo,
16
+ createSignal,
17
+ createStore,
18
+ getStoreRoot,
19
+ untrack,
20
+ } from "./signal";
21
+ export type { Memo, Signal, StoreSetter } from "./signal";
22
+
23
+ export { createResource } from "./resource";
24
+ export type {
25
+ CreateResourceOptions,
26
+ Resource,
27
+ ResourceStatus,
28
+ } from "./resource";
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Thin JSX runtime re-export.
3
+ *
4
+ * Moonshine apps use the React JSX transform. Point TypeScript `jsxImportSource`
5
+ * at `@tschk/moonshine` (or import from `@tschk/moonshine/jsx-runtime`).
6
+ * The runtime itself is React's — use only when building TSX apps.
7
+ *
8
+ * tsconfig:
9
+ * ```json
10
+ * { "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "@tschk/moonshine" } }
11
+ * ```
12
+ */
13
+ export { jsx, jsxs, Fragment } from "react/jsx-runtime";
14
+ export type { JSX } from "react/jsx-runtime";
package/src/react.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * React bridge for `@tschk/moonshine`.
3
+ *
4
+ * ```ts
5
+ * import { createApp, useSignal, createSignal } from "@tschk/moonshine/react";
6
+ * ```
7
+ */
8
+ export * from "@tschk/moonshine-react";
@@ -0,0 +1,82 @@
1
+ import { createSignal, type Signal } from "./signal";
2
+
3
+ export type ResourceStatus = "pending" | "ready" | "errored";
4
+
5
+ export type Resource<T> = {
6
+ /** Current value (undefined until first success unless `initial` set). */
7
+ (): T | undefined;
8
+ /** true while a fetch is in flight. */
9
+ loading: Signal<boolean>;
10
+ /** Last error, if any. */
11
+ error: Signal<Error | undefined>;
12
+ /** Coarse status. */
13
+ status: Signal<ResourceStatus>;
14
+ /** Re-run the fetcher. */
15
+ refetch: () => Promise<T | undefined>;
16
+ peek: () => T | undefined;
17
+ subscribe: (listener: () => void) => () => void;
18
+ };
19
+
20
+ export type CreateResourceOptions<T> = {
21
+ /** Seed value before first fetch settles. */
22
+ initial?: T;
23
+ /** If false, do not fetch until `refetch()` (default true). */
24
+ immediate?: boolean;
25
+ /** Invoked when fetcher throws. */
26
+ onError?: (error: Error) => void;
27
+ };
28
+
29
+ /**
30
+ * Async resource backed by moonshine signals (Solid-inspired).
31
+ *
32
+ * ```ts
33
+ * const users = createResource(() => fetch("/api/users").then((r) => r.json()));
34
+ * users(); // data
35
+ * users.loading(); // boolean
36
+ * await users.refetch();
37
+ * ```
38
+ */
39
+ export function createResource<T>(
40
+ fetcher: () => Promise<T>,
41
+ options: CreateResourceOptions<T> = {},
42
+ ): Resource<T> {
43
+ const data = createSignal<T | undefined>(options.initial);
44
+ const loading = createSignal(false);
45
+ const error = createSignal<Error | undefined>(undefined);
46
+ const status = createSignal<ResourceStatus>(
47
+ options.initial !== undefined ? "ready" : "pending",
48
+ );
49
+
50
+ const refetch = async (): Promise<T | undefined> => {
51
+ loading.set(true);
52
+ status.set("pending");
53
+ error.set(undefined);
54
+ try {
55
+ const value = await fetcher();
56
+ data.set(() => value);
57
+ status.set("ready");
58
+ return value;
59
+ } catch (e) {
60
+ const err = e instanceof Error ? e : new Error(String(e));
61
+ error.set(err);
62
+ status.set("errored");
63
+ options.onError?.(err);
64
+ return undefined;
65
+ } finally {
66
+ loading.set(false);
67
+ }
68
+ };
69
+
70
+ if (options.immediate !== false) {
71
+ void refetch();
72
+ }
73
+
74
+ const read = (() => data()) as Resource<T>;
75
+ read.loading = loading;
76
+ read.error = error;
77
+ read.status = status;
78
+ read.refetch = refetch;
79
+ read.peek = () => data.peek();
80
+ read.subscribe = data.subscribe;
81
+ return read;
82
+ }
package/src/router.ts ADDED
@@ -0,0 +1,74 @@
1
+ import type { ReactNode } from "react";
2
+ import {
3
+ compilePattern,
4
+ createRouteGraph,
5
+ matchRoutes as matchRoutesOnGraph,
6
+ createMoonshineRouter,
7
+ navigate,
8
+ getLocation,
9
+ useLocation,
10
+ useParams,
11
+ useNavigate,
12
+ useRouter,
13
+ MoonshineRouter,
14
+ Link,
15
+ type MoonshineRouterProps,
16
+ type MoonshineRouterInstance,
17
+ } from "@tschk/moonshine-router";
18
+
19
+ export type RouteParams = Record<string, string>;
20
+
21
+ export type RouteMatch = {
22
+ path: string;
23
+ pattern: string;
24
+ params: RouteParams;
25
+ };
26
+
27
+ export type RouteDefinition = {
28
+ /** Path pattern, e.g. `/`, `/about`, `/users/:id`. */
29
+ path: string;
30
+ element: ReactNode;
31
+ };
32
+
33
+ /** Compile `/users/:id` → matcher with named params. */
34
+ export function matchPath(
35
+ pattern: string,
36
+ pathname: string,
37
+ ): RouteMatch | null {
38
+ const compiled = compilePattern(pattern);
39
+ const result = compiled.match(pathname);
40
+ if (!result) return null;
41
+ return { path: pathname, pattern, params: result.params };
42
+ }
43
+
44
+ /** Resolve first matching route definition. */
45
+ export function matchRoutes(
46
+ routes: RouteDefinition[],
47
+ pathname: string,
48
+ ): (RouteMatch & { element: ReactNode }) | null {
49
+ const graph = createRouteGraph(
50
+ routes.map((route) => ({ ...route, id: route.path, file: "" })),
51
+ );
52
+ const match = matchRoutesOnGraph(graph, pathname);
53
+ if (!match) return null;
54
+ return {
55
+ path: match.path,
56
+ pattern: match.pattern,
57
+ params: match.params,
58
+ element: match.route.element,
59
+ };
60
+ }
61
+
62
+ export {
63
+ createMoonshineRouter,
64
+ navigate,
65
+ getLocation,
66
+ useLocation,
67
+ useParams,
68
+ useNavigate,
69
+ useRouter,
70
+ MoonshineRouter,
71
+ Link,
72
+ type MoonshineRouterProps,
73
+ type MoonshineRouterInstance,
74
+ };
package/src/runes.ts ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Svelte-inspired approachability sugar over moonshine signals.
3
+ * Thin aliases — not real compiler runes.
4
+ *
5
+ * ```ts
6
+ * import { state, derived, effect } from "@tschk/moonshine/runes";
7
+ * ```
8
+ */
9
+ import {
10
+ collectDeps,
11
+ createMemo,
12
+ createSignal,
13
+ type Memo,
14
+ type Signal,
15
+ } from "./signal";
16
+
17
+ /** Alias for `createSignal` — readable `$state`-like naming. */
18
+ export function state<T>(initial: T): Signal<T> {
19
+ return createSignal(initial);
20
+ }
21
+
22
+ /** Alias for `createMemo` — readable `$derived`-like naming. */
23
+ export function derived<T>(fn: () => T): Memo<T> {
24
+ return createMemo(fn);
25
+ }
26
+
27
+ /**
28
+ * Run `fn` now and again whenever tracked signal/memo/store deps change.
29
+ * Returns an unsubscribe function.
30
+ *
31
+ * Dep tracking uses the same tracker as `createMemo` (reads during `fn`).
32
+ */
33
+ export function effect(fn: () => void): () => void {
34
+ let unsubs: Array<() => void> = [];
35
+ let disposed = false;
36
+ let scheduled = false;
37
+
38
+ const run = () => {
39
+ for (const u of unsubs.splice(0)) u();
40
+ if (disposed) return;
41
+
42
+ const deps = collectDeps(fn);
43
+ for (const dep of deps) {
44
+ unsubs.push(
45
+ dep.subscribe(() => {
46
+ if (disposed || scheduled) return;
47
+ scheduled = true;
48
+ queueMicrotask(() => {
49
+ scheduled = false;
50
+ if (!disposed) run();
51
+ });
52
+ }),
53
+ );
54
+ }
55
+ };
56
+
57
+ run();
58
+
59
+ return () => {
60
+ disposed = true;
61
+ for (const u of unsubs.splice(0)) u();
62
+ };
63
+ }
64
+
65
+ export {
66
+ createSignal,
67
+ createMemo,
68
+ createStore,
69
+ batch,
70
+ untrack,
71
+ } from "./signal";
72
+ export type { Signal, Memo, StoreSetter } from "./signal";
package/src/server.ts ADDED
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Light server-entry helpers.
3
+ *
4
+ * Thin page-module table on top of Bun.serve (or any fetch handler).
5
+ * Greenfield HTTP path — no metaframework required.
6
+ */
7
+
8
+ import {
9
+ callAction,
10
+ callLoader,
11
+ createRequestHandler,
12
+ createRouteContext,
13
+ errorResponse,
14
+ json,
15
+ redirect,
16
+ Redirect,
17
+ resolveStaticPath,
18
+ serializeData,
19
+ tryServeStatic,
20
+ type Action,
21
+ type ErrorBoundary,
22
+ type Loader,
23
+ type Middleware,
24
+ type RequestHandlerOptions,
25
+ type RouteContext,
26
+ type RouteModule,
27
+ } from "@tschk/moonshine-server";
28
+
29
+ export type MoonshineRequest = {
30
+ url: string;
31
+ method: string;
32
+ headers: Headers;
33
+ pathname: string;
34
+ searchParams: URLSearchParams;
35
+ };
36
+
37
+ export type MoonshinePageModule = {
38
+ /** Optional data loader hook. */
39
+ getConfig?: () => Promise<Record<string, unknown>> | Record<string, unknown>;
40
+ /** Render body for the path. May return a string (HTML), Response, or JSON-able. */
41
+ render: (req: MoonshineRequest) => Promise<unknown> | unknown;
42
+ };
43
+
44
+ export type MoonshineServerOptions = {
45
+ /** Map of pathname → page module (file-ish routing table). */
46
+ pages: Record<string, MoonshinePageModule>;
47
+ /** Called when no page or static file matches. */
48
+ notFound?: (req: MoonshineRequest) => Promise<Response> | Response;
49
+ /** Port for `Bun.serve` helper (default 3000). */
50
+ port?: number;
51
+ /**
52
+ * Directory of static assets (CSS/JS/images). Checked before pages.
53
+ * Path traversal outside the directory is rejected.
54
+ */
55
+ staticDir?: string;
56
+ };
57
+
58
+ /** Normalize a Fetch API Request into a Moonshine request bag. */
59
+ export function toMoonshineRequest(request: Request): MoonshineRequest {
60
+ const url = new URL(request.url);
61
+ return {
62
+ url: request.url,
63
+ method: request.method,
64
+ headers: request.headers,
65
+ pathname: url.pathname.replace(/\/+$/, "") || "/",
66
+ searchParams: url.searchParams,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Resolve a page module for a pathname.
72
+ * Supports exact keys and a trailing `/*` splat prefix.
73
+ */
74
+ export function resolvePage(
75
+ pages: Record<string, MoonshinePageModule>,
76
+ pathname: string,
77
+ ): MoonshinePageModule | null {
78
+ if (pages[pathname]) return pages[pathname]!;
79
+ if (pages["/"] && pathname === "") return pages["/"]!;
80
+
81
+ let best: { key: string; mod: MoonshinePageModule } | null = null;
82
+ for (const [key, mod] of Object.entries(pages)) {
83
+ if (!key.endsWith("/*")) continue;
84
+ const prefix = key.slice(0, -1);
85
+ const base = prefix.endsWith("/") ? prefix.slice(0, -1) || "/" : prefix;
86
+ if (
87
+ pathname === base ||
88
+ pathname.startsWith(base === "/" ? "/" : `${base}/`)
89
+ ) {
90
+ if (!best || key.length > best.key.length) best = { key, mod };
91
+ }
92
+ }
93
+ return best?.mod ?? null;
94
+ }
95
+
96
+ /** Handle one request against static dir + pages table. */
97
+ export async function handleMoonshineRequest(
98
+ request: Request,
99
+ options: Pick<MoonshineServerOptions, "pages" | "notFound" | "staticDir">,
100
+ ): Promise<Response> {
101
+ const req = toMoonshineRequest(request);
102
+
103
+ if (options.staticDir && req.method === "GET" && req.pathname !== "/") {
104
+ const staticRes = await tryServeStatic(options.staticDir, req.pathname);
105
+ if (staticRes) return staticRes;
106
+ }
107
+
108
+ const page = resolvePage(options.pages, req.pathname);
109
+
110
+ if (!page) {
111
+ if (options.notFound) return options.notFound(req);
112
+ return new Response("Not Found", { status: 404 });
113
+ }
114
+
115
+ const body = await page.render(req);
116
+ if (body instanceof Response) return body;
117
+ if (typeof body === "string") {
118
+ return new Response(body, {
119
+ headers: { "content-type": "text/html; charset=utf-8" },
120
+ });
121
+ }
122
+ return new Response(JSON.stringify(body), {
123
+ headers: { "content-type": "application/json; charset=utf-8" },
124
+ });
125
+ }
126
+
127
+ export type MoonshineServer = {
128
+ fetch: (request: Request) => Promise<Response>;
129
+ /** Start Bun.serve when running under Bun; returns the server handle. */
130
+ listen: () => unknown;
131
+ port: number;
132
+ };
133
+
134
+ /**
135
+ * Create a Bun-first server entry from a pages map (+ optional static dir).
136
+ *
137
+ * ```ts
138
+ * const server = createMoonshineServer({
139
+ * staticDir: "./public",
140
+ * pages: {
141
+ * "/": { render: () => "<h1>Home</h1><script type=module src=/client.js></script>" },
142
+ * },
143
+ * });
144
+ * server.listen();
145
+ * ```
146
+ */
147
+ export function createMoonshineServer(
148
+ options: MoonshineServerOptions,
149
+ ): MoonshineServer {
150
+ const port = options.port ?? 3000;
151
+
152
+ const fetch = (request: Request) => handleMoonshineRequest(request, options);
153
+
154
+ return {
155
+ fetch,
156
+ port,
157
+ listen() {
158
+ if (typeof Bun === "undefined") {
159
+ throw new Error("createMoonshineServer.listen() requires Bun");
160
+ }
161
+ return Bun.serve({
162
+ port,
163
+ fetch,
164
+ });
165
+ },
166
+ };
167
+ }
168
+
169
+ /** Define a page module with inference-friendly typing. */
170
+ export function definePage(page: MoonshinePageModule): MoonshinePageModule {
171
+ return page;
172
+ }
173
+
174
+ export {
175
+ callAction,
176
+ callLoader,
177
+ createRequestHandler,
178
+ createRouteContext,
179
+ errorResponse,
180
+ json,
181
+ redirect,
182
+ Redirect,
183
+ resolveStaticPath,
184
+ serializeData,
185
+ tryServeStatic,
186
+ };
187
+
188
+ export type {
189
+ Action,
190
+ ErrorBoundary,
191
+ Loader,
192
+ Middleware,
193
+ RequestHandlerOptions,
194
+ RouteContext,
195
+ RouteModule,
196
+ };
package/src/shaders.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Compat re-export — prefer `@tschk/moonshine-shaders`.
3
+ * Kept so existing `@tschk/moonshine/shaders` imports keep working.
4
+ */
5
+ export {
6
+ createFullscreenFragment,
7
+ useFragmentShader,
8
+ wrapFragmentSource,
9
+ } from "@tschk/moonshine-shaders";
10
+ export type {
11
+ FragmentShaderHandle,
12
+ UseFragmentShaderOptions,
13
+ } from "@tschk/moonshine-shaders";
package/src/signal.ts ADDED
@@ -0,0 +1,357 @@
1
+ type Listener = () => void;
2
+
3
+ export type Trackable = {
4
+ subscribe: (listener: Listener) => () => void;
5
+ };
6
+
7
+ /**
8
+ * A node in the reactive graph.
9
+ *
10
+ * `version` changes only when the node's *value* changes, which lets a consumer
11
+ * tell "something upstream fired" apart from "my input actually differs".
12
+ * `validate` brings the node's cached value up to date on demand, so reads pull
13
+ * rather than writes pushing all the way down.
14
+ */
15
+ type Source = Trackable & {
16
+ version: number;
17
+ validate: () => void;
18
+ };
19
+
20
+ /**
21
+ * Bumped once per write anywhere in the graph. A node that already validated at
22
+ * the current epoch cannot be stale, so repeated reads stay O(1).
23
+ */
24
+ let epoch = 0;
25
+ /** Monotonic stamp handed out whenever a node's value actually changes. */
26
+ let versionCounter = 0;
27
+
28
+ let batchDepth = 0;
29
+ const pending = new Set<Listener>();
30
+ let currentTracker: ((dep: Source) => void) | null = null;
31
+
32
+ /** Coalesce nested writes into one subscriber flush. */
33
+ export function batch(fn: () => void): void {
34
+ batchDepth++;
35
+ try {
36
+ fn();
37
+ } finally {
38
+ batchDepth--;
39
+ if (batchDepth === 0 && pending.size > 0) {
40
+ const queue = [...pending];
41
+ pending.clear();
42
+ for (const listener of queue) listener();
43
+ }
44
+ }
45
+ }
46
+
47
+ function notifyListeners(listeners: Set<Listener>): void {
48
+ if (batchDepth > 0) {
49
+ for (const listener of listeners) pending.add(listener);
50
+ return;
51
+ }
52
+ for (const listener of Array.from(listeners)) listener();
53
+ }
54
+
55
+ function track(dep: Source): void {
56
+ currentTracker?.(dep);
57
+ }
58
+
59
+ /** Read without subscribing the enclosing memo or effect to what you touch. */
60
+ export function untrack<T>(fn: () => T): T {
61
+ const previous = currentTracker;
62
+ currentTracker = null;
63
+ try {
64
+ return fn();
65
+ } finally {
66
+ currentTracker = previous;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Run `fn` while collecting signal/memo/store deps via the active tracker.
72
+ * Used by `@tschk/moonshine/runes` `effect`.
73
+ */
74
+ export function collectDeps(fn: () => void): Trackable[] {
75
+ const deps: Trackable[] = [];
76
+ const seen = new Set<Trackable>();
77
+ const previous = currentTracker;
78
+ currentTracker = (dep) => {
79
+ if (seen.has(dep)) return;
80
+ seen.add(dep);
81
+ deps.push(dep);
82
+ };
83
+ try {
84
+ fn();
85
+ } finally {
86
+ currentTracker = previous;
87
+ }
88
+ return deps;
89
+ }
90
+
91
+ export type Signal<T> = {
92
+ (): T;
93
+ set: (value: T | ((prev: T) => T)) => void;
94
+ peek: () => T;
95
+ subscribe: (listener: Listener) => () => void;
96
+ };
97
+
98
+ /**
99
+ * Solid-inspired signal: read by calling, write via `.set`.
100
+ * React apps subscribe with `useSignal` from `@tschk/moonshine/react`.
101
+ */
102
+ export function createSignal<T>(initial: T): Signal<T> {
103
+ let value = initial;
104
+ const listeners = new Set<Listener>();
105
+ const node: Source = {
106
+ version: ++versionCounter,
107
+ validate: () => {},
108
+ subscribe: (listener) => {
109
+ listeners.add(listener);
110
+ return () => {
111
+ listeners.delete(listener);
112
+ };
113
+ },
114
+ };
115
+
116
+ const read = (() => {
117
+ track(node);
118
+ return value;
119
+ }) as Signal<T>;
120
+
121
+ read.set = (next) => {
122
+ const resolved =
123
+ typeof next === "function" ? (next as (prev: T) => T)(value) : next;
124
+ if (Object.is(resolved, value)) return;
125
+ value = resolved;
126
+ node.version = ++versionCounter;
127
+ epoch++;
128
+ notifyListeners(listeners);
129
+ };
130
+ read.peek = () => value;
131
+ read.subscribe = node.subscribe;
132
+
133
+ return read;
134
+ }
135
+
136
+ export type Memo<T> = {
137
+ (): T;
138
+ peek: () => T;
139
+ subscribe: (listener: Listener) => () => void;
140
+ };
141
+
142
+ /**
143
+ * Derived value that recomputes when tracked dependencies change.
144
+ * Any `createSignal` / `createMemo` read during `compute` is tracked.
145
+ *
146
+ * Recomputation is pull-based: an unread memo with no subscribers costs nothing
147
+ * when its sources change, and a memo validates its whole dependency chain
148
+ * before answering, so it never reports a half-updated view of the graph.
149
+ */
150
+ export function createMemo<T>(compute: () => T): Memo<T> {
151
+ const listeners = new Set<Listener>();
152
+ let deps: Array<{ node: Source; version: number }> = [];
153
+ const unsubs: Array<() => void> = [];
154
+ let value!: T;
155
+ let hasValue = false;
156
+ let stale = true;
157
+ let checkedAt = -1;
158
+
159
+ const node: Source = {
160
+ version: ++versionCounter,
161
+ validate: () => validate(),
162
+ subscribe: (listener) => {
163
+ // Dependencies are only observed while somebody is listening, so the
164
+ // push path exists solely to serve real subscribers.
165
+ validate();
166
+ listeners.add(listener);
167
+ if (listeners.size === 1) observe();
168
+ return () => {
169
+ listeners.delete(listener);
170
+ if (listeners.size === 0) unobserve();
171
+ };
172
+ },
173
+ };
174
+
175
+ function observe(): void {
176
+ for (const unsub of unsubs.splice(0)) unsub();
177
+ for (const dep of deps) unsubs.push(dep.node.subscribe(onDepChange));
178
+ }
179
+
180
+ function unobserve(): void {
181
+ for (const unsub of unsubs.splice(0)) unsub();
182
+ }
183
+
184
+ function onDepChange(): void {
185
+ const before = node.version;
186
+ validate();
187
+ // Only wake downstream when the derived value actually moved.
188
+ if (node.version !== before) notifyListeners(listeners);
189
+ }
190
+
191
+ function recompute(): void {
192
+ const collected: Array<{ node: Source; version: number }> = [];
193
+ const seen = new Set<Source>();
194
+ const previous = currentTracker;
195
+ currentTracker = (dep) => {
196
+ if (seen.has(dep)) return;
197
+ seen.add(dep);
198
+ collected.push({ node: dep, version: dep.version });
199
+ };
200
+
201
+ let next: T;
202
+ try {
203
+ next = compute();
204
+ } finally {
205
+ currentTracker = previous;
206
+ }
207
+
208
+ deps = collected;
209
+ stale = false;
210
+ if (listeners.size > 0) observe();
211
+
212
+ if (!hasValue || !Object.is(next, value)) {
213
+ value = next;
214
+ hasValue = true;
215
+ node.version = ++versionCounter;
216
+ }
217
+ }
218
+
219
+ function validate(): void {
220
+ if (!stale && checkedAt === epoch) return;
221
+ checkedAt = epoch;
222
+ if (!stale) {
223
+ let changed = false;
224
+ for (const dep of deps) {
225
+ dep.node.validate();
226
+ if (dep.node.version !== dep.version) {
227
+ changed = true;
228
+ break;
229
+ }
230
+ }
231
+ if (!changed) return;
232
+ }
233
+ recompute();
234
+ }
235
+
236
+ const read = (() => {
237
+ // Validate before tracking, so the enclosing memo records the version this
238
+ // node settles on rather than the one it is about to leave behind.
239
+ validate();
240
+ track(node);
241
+ return value;
242
+ }) as Memo<T>;
243
+
244
+ read.peek = () => {
245
+ validate();
246
+ return value;
247
+ };
248
+ read.subscribe = node.subscribe;
249
+
250
+ return read;
251
+ }
252
+
253
+ export type StoreSetter<T extends object> = (fn: (state: T) => void) => void;
254
+
255
+ const storeRoots = new WeakMap<object, Trackable>();
256
+
257
+ /** Resolve the trackable root for a `createStore` proxy (used by `/react` hooks). */
258
+ export function getStoreRoot(store: object): Trackable | undefined {
259
+ return storeRoots.get(store);
260
+ }
261
+
262
+ /**
263
+ * Plain objects and arrays are the only things worth proxying. Wrapping a Date,
264
+ * Map or class instance would hand its methods a proxy as `this`, which breaks
265
+ * every implementation that reaches for internal slots.
266
+ */
267
+ function isWrappable(value: unknown): value is object {
268
+ if (value === null || typeof value !== "object") return false;
269
+ const proto = Object.getPrototypeOf(value);
270
+ return (
271
+ proto === Object.prototype || proto === Array.prototype || proto === null
272
+ );
273
+ }
274
+
275
+ /**
276
+ * Copy the caller's plain structure so later mutations go through the proxy,
277
+ * and pass everything else through by reference. `structuredClone` throws on
278
+ * functions and DOM nodes, and silently flattens class instances.
279
+ */
280
+ function clone<V>(value: V): V {
281
+ if (Array.isArray(value)) return value.map((entry) => clone(entry)) as V;
282
+ if (isWrappable(value)) {
283
+ const out: Record<string, unknown> = {};
284
+ for (const [key, entry] of Object.entries(value)) out[key] = clone(entry);
285
+ return out as V;
286
+ }
287
+ return value;
288
+ }
289
+
290
+ /**
291
+ * Nested reactive store (Solid-inspired).
292
+ * Mutate through the proxy (or `setStore`) to notify subscribers.
293
+ */
294
+ export function createStore<T extends object>(initial: T): [T, StoreSetter<T>] {
295
+ const listeners = new Set<Listener>();
296
+ const state = clone(initial);
297
+
298
+ const node: Source = {
299
+ version: ++versionCounter,
300
+ validate: () => {},
301
+ subscribe: (listener) => {
302
+ listeners.add(listener);
303
+ return () => {
304
+ listeners.delete(listener);
305
+ };
306
+ },
307
+ };
308
+
309
+ const notify = () => {
310
+ node.version = ++versionCounter;
311
+ epoch++;
312
+ notifyListeners(listeners);
313
+ };
314
+
315
+ // Cached so `store.user === store.user`, which React dependency arrays and
316
+ // `React.memo` both compare by identity.
317
+ const proxies = new WeakMap<object, object>();
318
+
319
+ const wrap = <V extends object>(target: V): V => {
320
+ const cached = proxies.get(target);
321
+ if (cached) return cached as V;
322
+ const proxy = new Proxy(target, {
323
+ get(obj, prop, receiver) {
324
+ track(node);
325
+ const value = Reflect.get(obj, prop, receiver);
326
+ if (isWrappable(value)) return wrap(value);
327
+ return value;
328
+ },
329
+ set(obj, prop, value, receiver) {
330
+ const prev = Reflect.get(obj, prop, receiver);
331
+ if (Object.is(prev, value)) return true;
332
+ const ok = Reflect.set(obj, prop, value, receiver);
333
+ if (ok) notify();
334
+ return ok;
335
+ },
336
+ deleteProperty(obj, prop) {
337
+ if (!Object.prototype.hasOwnProperty.call(obj, prop)) return true;
338
+ const ok = Reflect.deleteProperty(obj, prop);
339
+ if (ok) notify();
340
+ return ok;
341
+ },
342
+ });
343
+ proxies.set(target, proxy);
344
+ return proxy;
345
+ };
346
+
347
+ const proxy = wrap(state);
348
+ storeRoots.set(proxy, node);
349
+
350
+ const setStore: StoreSetter<T> = (fn) => {
351
+ batch(() => {
352
+ fn(proxy);
353
+ });
354
+ };
355
+
356
+ return [proxy, setStore];
357
+ }