@moonan_ai/sdk 0.0.0-stage → 0.0.4-alpha2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pheobe-Southwood
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 CHANGED
@@ -1,3 +1,71 @@
1
- # Temporary Holding Version
1
+ # @moonan_ai/sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Moonan 插件 SDK:契约、注册表与浏览器钩子。写 Moonan 插件只需要这一个包——Chord 的原语
4
+ (`remote`、`local`、`method`、`hostNamed`、`defineFacet`、`defineRegistry`)由它再导出。
5
+
6
+ Moonan 是一个本地运行的插件工作台:平台只提供宿主、端口、插件发现与切面装载,所有功能——包括
7
+ AI 对话——都是插件。
8
+
9
+ ```bash
10
+ npm i @moonan_ai/sdk
11
+ ```
12
+
13
+ ## 两个入口
14
+
15
+ | 入口 | 给谁用 |
16
+ |---|---|
17
+ | `@moonan_ai/sdk` | 服务端切面、以及需要契约/注册表的浏览器代码 |
18
+ | `@moonan_ai/sdk/web` | 浏览器切面:`Panels`、`useRemote`、`useReadable`、`useRefStatus` |
19
+
20
+ 浏览器切面不要自行打包 React、Chord 或本包:外壳的 import map 已经提供了它们,而且只有一份。
21
+
22
+ ## 服务端切面
23
+
24
+ ```js
25
+ import { Type, defineFacet, hostNamed, method, remote } from "@moonan_ai/sdk";
26
+
27
+ const Counter = remote("example.counter", {
28
+ version: 1,
29
+ input: Type.Object({}),
30
+ host: hostNamed("moonan"),
31
+ variants: { v1: { state: { n: Type.Number() }, methods: { bump: method() } } },
32
+ });
33
+
34
+ export default defineFacet((api) => {
35
+ api.serve(Counter, (_input, _principal, self) => {
36
+ const n = self.state("n", Type.Number(), 0);
37
+ return { variant: "v1", n, bump: (ctx) => n.change(ctx, (value) => value + 1) };
38
+ });
39
+ });
40
+ ```
41
+
42
+ ## 浏览器切面
43
+
44
+ ```js
45
+ import { Panels } from "@moonan_ai/sdk/web";
46
+ import { Type, defineFacet, local, method } from "@moonan_ai/sdk";
47
+ import { createElement as h } from "react";
48
+
49
+ const Root = local("example.counter.web", { version: 1, input: Type.Object({}), members: { noop: method() } });
50
+
51
+ export default defineFacet((api) => {
52
+ api.serve(Root, (_input, _p, self) => {
53
+ self.register(Panels, { counter: { id: "counter", title: "Counter", order: 10, component: () => h("div") } });
54
+ return { noop: () => undefined };
55
+ }, { lifetime: "host" });
56
+ });
57
+ ```
58
+
59
+ ## 约定
60
+
61
+ - 契约在两端**各写一份**:Chord 认指纹(名字、版本、成员、类型住在哪个宿主),不认模块实例。
62
+ - 注册表贡献(`Routes`、`Panels`)必须来自 `{ lifetime: "host" }` 的对象。
63
+ - 服务端宿主名固定为 `"moonan"`;写别的名字,`api.serve` 会当场拒绝。
64
+ - 浏览器切面是**单个 ESM 文件**:浏览器没有模块 hook,重载只换入口。
65
+
66
+ 完整写法:<https://github.com/Pheobe-Southwood/Moonan/blob/main/docs/plugins.md> ·
67
+ 词汇表:<https://github.com/Pheobe-Southwood/Moonan/blob/main/CONTEXT.md>
68
+
69
+ ## 许可
70
+
71
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `@moonan_ai/sdk` — everything a plugin needs on either side of the wire.
3
+ *
4
+ * The root entry is shared by the server facet and the browser facet: contracts,
5
+ * registries and the Chord primitives they are built from. Browser-only surface (the
6
+ * `Panels` registry and the React hooks) lives in `@moonan_ai/sdk/web`, because it needs
7
+ * React and the root entry must stay importable from a Node facet.
8
+ */
9
+ export { Manifest, FacetSetSchema, WebFacetSchema, } from "./manifest.js";
10
+ export { Routes } from "./routes.js";
11
+ /** Chord primitives, re-exported so a plugin needs two dependencies instead of three. */
12
+ export { ChordError, defineFacet, defineRegistry, forbidden, hostFamily, hostNamed, invalid, isChordError, local, method, opaque, remote, } from "@earendil-works/chord";
13
+ /** Explicit cancellation/context helpers; browser-safe, same entry points as chord. */
14
+ export { BACKGROUND_CONTEXT, awaitWithContext, traceIn, withAbortSignal, withCancel, withoutAbortSignal, } from "@earendil-works/chord/context";
15
+ /** The schema builder every contract is written with. */
16
+ export { Type } from "typebox";
17
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EACN,QAAQ,EACR,cAAc,EACd,cAAc,GAGd,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,MAAM,EAAqB,MAAM,aAAa,CAAC;AAExD,yFAAyF;AACzF,OAAO,EACN,UAAU,EACV,WAAW,EACX,cAAc,EACd,SAAS,EACT,UAAU,EACV,SAAS,EACT,OAAO,EACP,YAAY,EACZ,KAAK,EACL,MAAM,EACN,MAAM,EACN,MAAM,GAiBN,MAAM,uBAAuB,CAAC;AAE/B,uFAAuF;AACvF,OAAO,EACN,kBAAkB,EAClB,gBAAgB,EAChB,OAAO,EACP,eAAe,EACf,UAAU,EACV,kBAAkB,GAElB,MAAM,+BAA+B,CAAC;AAEvC,yDAAyD;AACzD,OAAO,EAAE,IAAI,EAA6B,MAAM,SAAS,CAAC"}
@@ -0,0 +1,66 @@
1
+ import { hostNamed, remote } from "@earendil-works/chord";
2
+ import { Type } from "typebox";
3
+ /**
4
+ * A browser facet: the module URL and a hash of its exact file contents.
5
+ *
6
+ * The URL alone cannot tell a client that the file changed; a server reload with the same
7
+ * path would otherwise look identical. The hash is what makes a browser reload only when
8
+ * its web code actually changed.
9
+ */
10
+ export const WebFacetSchema = Type.Object({
11
+ /** Absolute path on this server (`/facets/<key>/web.js`). */
12
+ url: Type.String(),
13
+ /** SHA-256 of the file contents, truncated for display. */
14
+ hash: Type.String(),
15
+ });
16
+ /**
17
+ * One plugin's facets, as the browser needs to know them.
18
+ *
19
+ * `name` is a display label derived from the directory or package; it is not an identity.
20
+ * The runtime identity of a facet is its module URL. A server-only plugin has no `web`, and
21
+ * a plugin that failed to resolve carries `error` instead — the browser still receives it,
22
+ * so the shell can report the failure rather than show a silently missing tab.
23
+ */
24
+ export const FacetSetSchema = Type.Object({
25
+ name: Type.String(),
26
+ version: Type.String(),
27
+ /** Where the facets came from: an installed package, or a directory on disk. */
28
+ source: Type.Union([Type.Literal("package"), Type.Literal("directory")]),
29
+ web: Type.Optional(WebFacetSchema),
30
+ error: Type.Optional(Type.String()),
31
+ });
32
+ /**
33
+ * The only object the platform publishes: which browser facets the browser host must load.
34
+ *
35
+ * This is the seam that keeps the platform free of feature state. The loader knows which
36
+ * modules exist; the browser cannot find that out alone (it has no filesystem and no
37
+ * registry to ask), so exactly one read-only object carries it across. Chord names a
38
+ * server `Manifest` object as how browsers learn of changes (spec §6); the particular
39
+ * fields here are Moonan application state, not protocol fields.
40
+ *
41
+ * Contract version 3 changed `web` from a bare URL string to `{ url, hash }`. There is no
42
+ * compatibility adapter: a browser is mapped to `upgrade_required` and must load the new
43
+ * shell (a non-client consumer gets `contract_mismatch`).
44
+ */
45
+ export const Manifest = remote("moonan.manifest", {
46
+ version: 3,
47
+ input: Type.Object({}),
48
+ host: hostNamed("moonan"),
49
+ variants: {
50
+ v1: {
51
+ state: {
52
+ /** The platform's own version, so a client can tell which build it is talking to. */
53
+ version: Type.String(),
54
+ facets: Type.Array(FacetSetSchema),
55
+ /**
56
+ * chord's code version (`host.codeVersion`): 1 at startup, one higher after every
57
+ * reload that committed. This is Moonan application state, not chord's removed
58
+ * `Shape.codeVersion`; a browser decides whether to reload from each `web.hash`,
59
+ * and never from this number as a protocol value.
60
+ */
61
+ revision: Type.Number(),
62
+ },
63
+ },
64
+ },
65
+ });
66
+ //# sourceMappingURL=manifest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manifest.js","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,uBAAuB,CAAC;AAC1D,OAAO,EAAE,IAAI,EAAe,MAAM,SAAS,CAAC;AAE5C;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,IAAI,CAAC,MAAM,CAAC;IACzC,6DAA6D;IAC7D,GAAG,EAAE,IAAI,CAAC,MAAM,EAAE;IAClB,2DAA2D;IAC3D,IAAI,EAAE,IAAI,CAAC,MAAM,EAAE;CACnB,CAAC,CAAC;AAGH;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,IAAI,CAAC,MAAM,CAAC;IACzC,IAAI,EAAE,IAAI,CAAC,MAAM,EAAE;IACnB,OAAO,EAAE,IAAI,CAAC,MAAM,EAAE;IACtB,gFAAgF;IAChF,MAAM,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC,EAAE,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC;IACxE,GAAG,EAAE,IAAI,CAAC,QAAQ,CAAC,cAAc,CAAC;IAClC,KAAK,EAAE,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC;CACnC,CAAC,CAAC;AAGH;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,QAAQ,GAAG,MAAM,CAAC,iBAAiB,EAAE;IACjD,OAAO,EAAE,CAAC;IACV,KAAK,EAAE,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC;IACtB,IAAI,EAAE,SAAS,CAAC,QAAQ,CAAC;IACzB,QAAQ,EAAE;QACT,EAAE,EAAE;YACH,KAAK,EAAE;gBACN,qFAAqF;gBACrF,OAAO,EAAE,IAAI,CAAC,MAAM,EAAE;gBACtB,MAAM,EAAE,IAAI,CAAC,KAAK,CAAC,cAAc,CAAC;gBAClC;;;;;mBAKG;gBACH,QAAQ,EAAE,IAAI,CAAC,MAAM,EAAE;aACvB;SACD;KACD;CACD,CAAC,CAAC"}
package/dist/routes.js ADDED
@@ -0,0 +1,11 @@
1
+ import { defineRegistry } from "@earendil-works/chord";
2
+ /**
3
+ * The server-side way out of the object protocol.
4
+ *
5
+ * Chord covers everything object-to-object; it has no opinion about plain HTTP, and the
6
+ * platform serves exactly one port for both. This registry is the hook between them:
7
+ * the keys are `"GET /path"` for one method, or `"/path"` for any. A request that matches
8
+ * nothing falls through to the built-in endpoints, the facet assets and the shell.
9
+ */
10
+ export const Routes = defineRegistry("moonan.routes", { conflict: "error" });
11
+ //# sourceMappingURL=routes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAcvD;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,MAAM,GAAG,cAAc,CAAe,eAAe,EAAE,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAC,CAAC"}
package/dist/web.js ADDED
@@ -0,0 +1,174 @@
1
+ import { defineRegistry, isChordError, } from "@earendil-works/chord";
2
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
3
+ export { isChordError } from "@earendil-works/chord";
4
+ /**
5
+ * A `gone` error that no new reference can fix: the consumer was built against a contract the
6
+ * provider no longer covers. For the shell's own contracts (the platform `Manifest`, or a
7
+ * protocol mismatch) the page needs the new build. For a plugin's contract, chord's remedy is
8
+ * to reload the web facets; the shell does that when the manifest reports changed facets.
9
+ */
10
+ export function isFatalContractError(error) {
11
+ return isChordError(error, "contract_mismatch") || isChordError(error, "upgrade_required");
12
+ }
13
+ /**
14
+ * The browser-side way into the shell's navigation.
15
+ *
16
+ * Chord registries are per host, and a panel component cannot cross the wire, so the
17
+ * registry lives in the browser host and is filled by the plugin's own browser facet.
18
+ * The shell subscribes and renders; it never imports a panel.
19
+ */
20
+ export const Panels = defineRegistry("moonan.panels", { conflict: "error" });
21
+ /**
22
+ * Reads a state member, falling back while it has no value yet.
23
+ *
24
+ * `hold` returns before the object is live, and chord reports that as `not_ready` when the
25
+ * member is read — a transition, not a failure. `not_in_variant` is the other expected one:
26
+ * the provider's variant does not carry this member. Anything else is a bug in the read
27
+ * path and is reported rather than hidden behind the fallback.
28
+ */
29
+ function readOr(readable, fallback) {
30
+ if (!readable)
31
+ return fallback;
32
+ try {
33
+ return readable.value;
34
+ }
35
+ catch (error) {
36
+ if (isChordError(error, "not_ready") || isChordError(error, "not_in_variant"))
37
+ return fallback;
38
+ console.error("[chord] reading state failed", error);
39
+ return fallback;
40
+ }
41
+ }
42
+ /**
43
+ * Acquires a reference from the browser host with `host.hold`.
44
+ *
45
+ * `hold` is the API chord designates for UI runtimes: synchronous, possibly still
46
+ * `loading`, and re-held by the runtime itself when the link comes back, so a reconnect
47
+ * costs no new reference. It is acquired in a layout effect rather than during render:
48
+ * that keeps the impossible-to-render intermediate commit off the screen, and makes the
49
+ * acquire/release pair symmetrical under StrictMode's mount → unmount → mount (a
50
+ * reference taken during render would leak the discarded one).
51
+ */
52
+ export function useRemote(host, contract, input, options = {}) {
53
+ // The input is a plain JSON address, and a panel commonly passes a fresh `{}` each
54
+ // render. Key on its content so identity churn does not re-acquire the reference.
55
+ const key = JSON.stringify(input);
56
+ const [ref, setRef] = useState(null);
57
+ const [attempt, setAttempt] = useState(0);
58
+ const retries = useRef(0);
59
+ const fatalReported = useRef(false);
60
+ const warned = useRef(false);
61
+ const onFatal = useRef(options.onFatal);
62
+ onFatal.current = options.onFatal;
63
+ useLayoutEffect(() => {
64
+ if (!host) {
65
+ setRef(null);
66
+ return;
67
+ }
68
+ let held;
69
+ try {
70
+ held = host.hold(contract, JSON.parse(key));
71
+ }
72
+ catch (error) {
73
+ // A type this host cannot place (an unknown name, a local type it does not serve).
74
+ console.error("[chord] hold failed", error);
75
+ setRef(null);
76
+ return;
77
+ }
78
+ setRef(held);
79
+ return () => {
80
+ setRef(null);
81
+ held.release();
82
+ };
83
+ }, [host, contract, key, attempt]);
84
+ useEffect(() => {
85
+ if (!ref)
86
+ return;
87
+ return ref.onStatus((status, error) => {
88
+ // A reconnect re-holds in place (`loading`, last values readable) and needs nothing.
89
+ // `gone` is terminal for this reference — the object was forced away while held —
90
+ // and chord's guidance for a UI is to take a new one. Bounded, so a provider that
91
+ // keeps answering `gone` cannot spin the page.
92
+ if (status === "live") {
93
+ retries.current = 0;
94
+ fatalReported.current = false;
95
+ warned.current = false;
96
+ return;
97
+ }
98
+ if (status !== "gone")
99
+ return;
100
+ // A contract that the provider no longer covers cannot be fixed by taking another
101
+ // reference. The shell owns the official remedy (reload the web facets when the
102
+ // manifest reports changed facets); a panel only reports it upward, and without an
103
+ // `onFatal` handler it says once what the developer can do.
104
+ if (isFatalContractError(error)) {
105
+ const handler = onFatal.current;
106
+ if (handler !== undefined) {
107
+ if (!fatalReported.current) {
108
+ fatalReported.current = true;
109
+ handler(error);
110
+ }
111
+ return;
112
+ }
113
+ if (!warned.current) {
114
+ warned.current = true;
115
+ console.warn("[chord] a reference is gone because its contract no longer binds. The shell reloads web facets when the Manifest reports changed facets; if this persists, the plugin's web code and contract are out of sync.", error);
116
+ }
117
+ return;
118
+ }
119
+ if (retries.current < 3) {
120
+ retries.current += 1;
121
+ setAttempt((value) => value + 1);
122
+ return;
123
+ }
124
+ if (!fatalReported.current) {
125
+ fatalReported.current = true;
126
+ onFatal.current?.(error);
127
+ }
128
+ });
129
+ }, [ref]);
130
+ return ref;
131
+ }
132
+ /**
133
+ * Follows a reference's status: `loading | live | released | gone`.
134
+ *
135
+ * `hold` returns before the object is live, so a panel renders from this instead of
136
+ * guessing. `gone` is the state that needs a new reference — a forced disposal, a host
137
+ * that closed, a contract the provider no longer serves.
138
+ */
139
+ export function useRefStatus(ref) {
140
+ const [status, setStatus] = useState(() => ref?.status ?? null);
141
+ // Layout effect: the reference is acquired in one, and this is what turns that commit
142
+ // into a painted one. With a passive effect the first paint would still say "loading".
143
+ useLayoutEffect(() => {
144
+ if (!ref) {
145
+ setStatus(null);
146
+ return;
147
+ }
148
+ setStatus(ref.status);
149
+ return ref.onStatus((next) => setStatus(next));
150
+ }, [ref]);
151
+ return status;
152
+ }
153
+ /** Renders the current value of a chord `Readable`, and every later one. */
154
+ export function useReadable(readable, fallback) {
155
+ const [value, setValue] = useState(() => readOr(readable, fallback));
156
+ useLayoutEffect(() => {
157
+ if (!readable) {
158
+ setValue(fallback);
159
+ return;
160
+ }
161
+ let cancelled = false;
162
+ setValue(readOr(readable, fallback));
163
+ const stop = readable.subscribe((next) => {
164
+ if (!cancelled)
165
+ setValue(next);
166
+ });
167
+ return () => {
168
+ cancelled = true;
169
+ stop();
170
+ };
171
+ }, [readable]);
172
+ return value;
173
+ }
174
+ //# sourceMappingURL=web.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"web.js","sourceRoot":"","sources":["../src/web.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "@moonan_ai/sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.4-alpha2",
4
+ "description": "Moonan plugin SDK: contracts, registries and browser hooks for plugin authors.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Pheobe-Southwood/Moonan.git",
10
+ "directory": "packages/sdk"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./src/index.ts",
15
+ "development": "./src/index.ts",
16
+ "default": "./dist/index.js"
17
+ },
18
+ "./web": {
19
+ "types": "./src/web.ts",
20
+ "development": "./src/web.ts",
21
+ "default": "./dist/web.js"
22
+ },
23
+ "./package.json": "./package.json"
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "src"
28
+ ],
29
+ "dependencies": {
30
+ "@earendil-works/chord": "1.4.0",
31
+ "typebox": "1.3.27"
32
+ },
33
+ "peerDependencies": {
34
+ "react": ">=19.0.0"
35
+ },
36
+ "peerDependenciesMeta": {
37
+ "react": {
38
+ "optional": true
39
+ }
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^22.20.5",
43
+ "@types/react": "^19.3.0",
44
+ "react": "^19.3.1"
45
+ },
46
+ "keywords": [
47
+ "moonan",
48
+ "plugin",
49
+ "chord",
50
+ "sdk"
51
+ ],
52
+ "homepage": "https://github.com/Pheobe-Southwood/Moonan#readme",
53
+ "bugs": {
54
+ "url": "https://github.com/Pheobe-Southwood/Moonan/issues"
55
+ },
56
+ "publishConfig": {
57
+ "access": "public"
58
+ },
59
+ "scripts": {
60
+ "typecheck": "tsc -p tsconfig.json --noEmit",
61
+ "build": "tsc -p tsconfig.build.json"
62
+ }
6
63
  }
package/src/index.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * `@moonan_ai/sdk` — everything a plugin needs on either side of the wire.
3
+ *
4
+ * The root entry is shared by the server facet and the browser facet: contracts,
5
+ * registries and the Chord primitives they are built from. Browser-only surface (the
6
+ * `Panels` registry and the React hooks) lives in `@moonan_ai/sdk/web`, because it needs
7
+ * React and the root entry must stay importable from a Node facet.
8
+ */
9
+ export {
10
+ Manifest,
11
+ FacetSetSchema,
12
+ WebFacetSchema,
13
+ type FacetSetEntry,
14
+ type WebFacet,
15
+ } from "./manifest.js";
16
+ export { Routes, type RouteHandler } from "./routes.js";
17
+
18
+ /** Chord primitives, re-exported so a plugin needs two dependencies instead of three. */
19
+ export {
20
+ ChordError,
21
+ defineFacet,
22
+ defineRegistry,
23
+ forbidden,
24
+ hostFamily,
25
+ hostNamed,
26
+ invalid,
27
+ isChordError,
28
+ local,
29
+ method,
30
+ opaque,
31
+ remote,
32
+ type AnyContract,
33
+ type Ctx,
34
+ type Facet,
35
+ type FacetApi,
36
+ type Host,
37
+ type InputOf,
38
+ type JsonValue,
39
+ type LocalContract,
40
+ type Readable,
41
+ type Ref,
42
+ type RefStatus,
43
+ type Registry,
44
+ type RegistryTable,
45
+ type RegistryView,
46
+ type RemoteContract,
47
+ type Shape,
48
+ } from "@earendil-works/chord";
49
+
50
+ /** Explicit cancellation/context helpers; browser-safe, same entry points as chord. */
51
+ export {
52
+ BACKGROUND_CONTEXT,
53
+ awaitWithContext,
54
+ traceIn,
55
+ withAbortSignal,
56
+ withCancel,
57
+ withoutAbortSignal,
58
+ type Context,
59
+ } from "@earendil-works/chord/context";
60
+
61
+ /** The schema builder every contract is written with. */
62
+ export { Type, type Static, type TSchema } from "typebox";
@@ -0,0 +1,70 @@
1
+ import { hostNamed, remote } from "@earendil-works/chord";
2
+ import { Type, type Static } from "typebox";
3
+
4
+ /**
5
+ * A browser facet: the module URL and a hash of its exact file contents.
6
+ *
7
+ * The URL alone cannot tell a client that the file changed; a server reload with the same
8
+ * path would otherwise look identical. The hash is what makes a browser reload only when
9
+ * its web code actually changed.
10
+ */
11
+ export const WebFacetSchema = Type.Object({
12
+ /** Absolute path on this server (`/facets/<key>/web.js`). */
13
+ url: Type.String(),
14
+ /** SHA-256 of the file contents, truncated for display. */
15
+ hash: Type.String(),
16
+ });
17
+ export type WebFacet = Static<typeof WebFacetSchema>;
18
+
19
+ /**
20
+ * One plugin's facets, as the browser needs to know them.
21
+ *
22
+ * `name` is a display label derived from the directory or package; it is not an identity.
23
+ * The runtime identity of a facet is its module URL. A server-only plugin has no `web`, and
24
+ * a plugin that failed to resolve carries `error` instead — the browser still receives it,
25
+ * so the shell can report the failure rather than show a silently missing tab.
26
+ */
27
+ export const FacetSetSchema = Type.Object({
28
+ name: Type.String(),
29
+ version: Type.String(),
30
+ /** Where the facets came from: an installed package, or a directory on disk. */
31
+ source: Type.Union([Type.Literal("package"), Type.Literal("directory")]),
32
+ web: Type.Optional(WebFacetSchema),
33
+ error: Type.Optional(Type.String()),
34
+ });
35
+ export type FacetSetEntry = Static<typeof FacetSetSchema>;
36
+
37
+ /**
38
+ * The only object the platform publishes: which browser facets the browser host must load.
39
+ *
40
+ * This is the seam that keeps the platform free of feature state. The loader knows which
41
+ * modules exist; the browser cannot find that out alone (it has no filesystem and no
42
+ * registry to ask), so exactly one read-only object carries it across. Chord names a
43
+ * server `Manifest` object as how browsers learn of changes (spec §6); the particular
44
+ * fields here are Moonan application state, not protocol fields.
45
+ *
46
+ * Contract version 3 changed `web` from a bare URL string to `{ url, hash }`. There is no
47
+ * compatibility adapter: a browser is mapped to `upgrade_required` and must load the new
48
+ * shell (a non-client consumer gets `contract_mismatch`).
49
+ */
50
+ export const Manifest = remote("moonan.manifest", {
51
+ version: 3,
52
+ input: Type.Object({}),
53
+ host: hostNamed("moonan"),
54
+ variants: {
55
+ v1: {
56
+ state: {
57
+ /** The platform's own version, so a client can tell which build it is talking to. */
58
+ version: Type.String(),
59
+ facets: Type.Array(FacetSetSchema),
60
+ /**
61
+ * chord's code version (`host.codeVersion`): 1 at startup, one higher after every
62
+ * reload that committed. This is Moonan application state, not chord's removed
63
+ * `Shape.codeVersion`; a browser decides whether to reload from each `web.hash`,
64
+ * and never from this number as a protocol value.
65
+ */
66
+ revision: Type.Number(),
67
+ },
68
+ },
69
+ },
70
+ });
package/src/routes.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { defineRegistry } from "@earendil-works/chord";
2
+ import type { Ctx } from "@earendil-works/chord";
3
+ import type { IncomingMessage, ServerResponse } from "node:http";
4
+
5
+ /**
6
+ * One HTTP handler a plugin contributes.
7
+ *
8
+ * `ctx` is the root the platform opened for this request: a handler that calls into an
9
+ * object passes it along, so the call belongs to the request and ends with it. Handlers
10
+ * are short — a response that needs to outlive the request (a stream, a long poll) is
11
+ * the wrong shape here, and should hold its state in a cell and answer in another call.
12
+ */
13
+ export type RouteHandler = (request: IncomingMessage, response: ServerResponse, ctx: Ctx) => void | Promise<void>;
14
+
15
+ /**
16
+ * The server-side way out of the object protocol.
17
+ *
18
+ * Chord covers everything object-to-object; it has no opinion about plain HTTP, and the
19
+ * platform serves exactly one port for both. This registry is the hook between them:
20
+ * the keys are `"GET /path"` for one method, or `"/path"` for any. A request that matches
21
+ * nothing falls through to the built-in endpoints, the facet assets and the shell.
22
+ */
23
+ export const Routes = defineRegistry<RouteHandler>("moonan.routes", { conflict: "error" });
package/src/web.ts ADDED
@@ -0,0 +1,230 @@
1
+ import {
2
+ defineRegistry,
3
+ isChordError,
4
+ type AnyContract,
5
+ type Host,
6
+ type InputOf,
7
+ type Readable,
8
+ type Ref,
9
+ type RefStatus,
10
+ } from "@earendil-works/chord";
11
+ import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
12
+
13
+ export type { InputOf, Readable, Ref, RefStatus } from "@earendil-works/chord";
14
+ export { isChordError } from "@earendil-works/chord";
15
+
16
+ /**
17
+ * A `gone` error that no new reference can fix: the consumer was built against a contract the
18
+ * provider no longer covers. For the shell's own contracts (the platform `Manifest`, or a
19
+ * protocol mismatch) the page needs the new build. For a plugin's contract, chord's remedy is
20
+ * to reload the web facets; the shell does that when the manifest reports changed facets.
21
+ */
22
+ export function isFatalContractError(error: unknown): boolean {
23
+ return isChordError(error, "contract_mismatch") || isChordError(error, "upgrade_required");
24
+ }
25
+
26
+ /** Options of {@link useRemote}. */
27
+ export interface UseRemoteOptions {
28
+ /**
29
+ * Called once when the reference can no longer be recovered: a non-retryable contract
30
+ * error, or a `gone` reference after the bounded retries. The shell uses this to reload
31
+ * the page instead of rendering a permanently loading screen.
32
+ */
33
+ onFatal?: (error: unknown) => void;
34
+ }
35
+
36
+ /**
37
+ * What the shell hands to a panel: the browser chord host, and nothing else.
38
+ *
39
+ * Every reference a panel holds comes from here — `useRemote(host, …)`. The shell owns
40
+ * no application state to pass down, which is what keeps it from acquiring opinions.
41
+ */
42
+ export interface PanelProps {
43
+ host: Host<unknown>;
44
+ }
45
+
46
+ /** One top-level page a browser facet contributes. */
47
+ export interface PanelContribution {
48
+ id: string;
49
+ /** The tab label. Data, not a shell dictionary key: the shell has no dictionary. */
50
+ title: string;
51
+ /** Ascending; ties keep registration order. */
52
+ order?: number;
53
+ component: (props: PanelProps) => ReactNode;
54
+ }
55
+
56
+ /**
57
+ * The browser-side way into the shell's navigation.
58
+ *
59
+ * Chord registries are per host, and a panel component cannot cross the wire, so the
60
+ * registry lives in the browser host and is filled by the plugin's own browser facet.
61
+ * The shell subscribes and renders; it never imports a panel.
62
+ */
63
+ export const Panels = defineRegistry<PanelContribution>("moonan.panels", { conflict: "error" });
64
+
65
+ /**
66
+ * Reads a state member, falling back while it has no value yet.
67
+ *
68
+ * `hold` returns before the object is live, and chord reports that as `not_ready` when the
69
+ * member is read — a transition, not a failure. `not_in_variant` is the other expected one:
70
+ * the provider's variant does not carry this member. Anything else is a bug in the read
71
+ * path and is reported rather than hidden behind the fallback.
72
+ */
73
+ function readOr<T>(readable: Readable<T> | undefined, fallback: T): T {
74
+ if (!readable) return fallback;
75
+ try {
76
+ return readable.value;
77
+ } catch (error) {
78
+ if (isChordError(error, "not_ready") || isChordError(error, "not_in_variant")) return fallback;
79
+ console.error("[chord] reading state failed", error);
80
+ return fallback;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Acquires a reference from the browser host with `host.hold`.
86
+ *
87
+ * `hold` is the API chord designates for UI runtimes: synchronous, possibly still
88
+ * `loading`, and re-held by the runtime itself when the link comes back, so a reconnect
89
+ * costs no new reference. It is acquired in a layout effect rather than during render:
90
+ * that keeps the impossible-to-render intermediate commit off the screen, and makes the
91
+ * acquire/release pair symmetrical under StrictMode's mount → unmount → mount (a
92
+ * reference taken during render would leak the discarded one).
93
+ */
94
+ export function useRemote<C extends AnyContract>(
95
+ host: Host<unknown> | null | undefined,
96
+ contract: C,
97
+ input: InputOf<C>,
98
+ options: UseRemoteOptions = {},
99
+ ): Ref<C> | null {
100
+ // The input is a plain JSON address, and a panel commonly passes a fresh `{}` each
101
+ // render. Key on its content so identity churn does not re-acquire the reference.
102
+ const key = JSON.stringify(input);
103
+ const [ref, setRef] = useState<Ref<C> | null>(null);
104
+ const [attempt, setAttempt] = useState(0);
105
+ const retries = useRef(0);
106
+ const fatalReported = useRef(false);
107
+ const warned = useRef(false);
108
+ const onFatal = useRef(options.onFatal);
109
+ onFatal.current = options.onFatal;
110
+
111
+ useLayoutEffect(() => {
112
+ if (!host) {
113
+ setRef(null);
114
+ return;
115
+ }
116
+ let held: Ref<C>;
117
+ try {
118
+ held = host.hold(contract, JSON.parse(key) as InputOf<C>);
119
+ } catch (error) {
120
+ // A type this host cannot place (an unknown name, a local type it does not serve).
121
+ console.error("[chord] hold failed", error);
122
+ setRef(null);
123
+ return;
124
+ }
125
+ setRef(held);
126
+ return () => {
127
+ setRef(null);
128
+ held.release();
129
+ };
130
+ }, [host, contract, key, attempt]);
131
+
132
+ useEffect(() => {
133
+ if (!ref) return;
134
+ return ref.onStatus((status: RefStatus, error: unknown) => {
135
+ // A reconnect re-holds in place (`loading`, last values readable) and needs nothing.
136
+ // `gone` is terminal for this reference — the object was forced away while held —
137
+ // and chord's guidance for a UI is to take a new one. Bounded, so a provider that
138
+ // keeps answering `gone` cannot spin the page.
139
+ if (status === "live") {
140
+ retries.current = 0;
141
+ fatalReported.current = false;
142
+ warned.current = false;
143
+ return;
144
+ }
145
+ if (status !== "gone") return;
146
+
147
+ // A contract that the provider no longer covers cannot be fixed by taking another
148
+ // reference. The shell owns the official remedy (reload the web facets when the
149
+ // manifest reports changed facets); a panel only reports it upward, and without an
150
+ // `onFatal` handler it says once what the developer can do.
151
+ if (isFatalContractError(error)) {
152
+ const handler = onFatal.current;
153
+ if (handler !== undefined) {
154
+ if (!fatalReported.current) {
155
+ fatalReported.current = true;
156
+ handler(error);
157
+ }
158
+ return;
159
+ }
160
+ if (!warned.current) {
161
+ warned.current = true;
162
+ console.warn(
163
+ "[chord] a reference is gone because its contract no longer binds. The shell reloads web facets when the Manifest reports changed facets; if this persists, the plugin's web code and contract are out of sync.",
164
+ error,
165
+ );
166
+ }
167
+ return;
168
+ }
169
+
170
+ if (retries.current < 3) {
171
+ retries.current += 1;
172
+ setAttempt((value) => value + 1);
173
+ return;
174
+ }
175
+ if (!fatalReported.current) {
176
+ fatalReported.current = true;
177
+ onFatal.current?.(error);
178
+ }
179
+ });
180
+ }, [ref]);
181
+
182
+ return ref;
183
+ }
184
+
185
+ /**
186
+ * Follows a reference's status: `loading | live | released | gone`.
187
+ *
188
+ * `hold` returns before the object is live, so a panel renders from this instead of
189
+ * guessing. `gone` is the state that needs a new reference — a forced disposal, a host
190
+ * that closed, a contract the provider no longer serves.
191
+ */
192
+ export function useRefStatus<C extends AnyContract>(ref: Ref<C> | null): RefStatus | null {
193
+ const [status, setStatus] = useState<RefStatus | null>(() => ref?.status ?? null);
194
+
195
+ // Layout effect: the reference is acquired in one, and this is what turns that commit
196
+ // into a painted one. With a passive effect the first paint would still say "loading".
197
+ useLayoutEffect(() => {
198
+ if (!ref) {
199
+ setStatus(null);
200
+ return;
201
+ }
202
+ setStatus(ref.status);
203
+ return ref.onStatus((next: RefStatus) => setStatus(next));
204
+ }, [ref]);
205
+
206
+ return status;
207
+ }
208
+
209
+ /** Renders the current value of a chord `Readable`, and every later one. */
210
+ export function useReadable<T>(readable: Readable<T> | undefined, fallback: T): T {
211
+ const [value, setValue] = useState<T>(() => readOr(readable, fallback));
212
+
213
+ useLayoutEffect(() => {
214
+ if (!readable) {
215
+ setValue(fallback);
216
+ return;
217
+ }
218
+ let cancelled = false;
219
+ setValue(readOr(readable, fallback));
220
+ const stop = readable.subscribe((next: T) => {
221
+ if (!cancelled) setValue(next);
222
+ });
223
+ return () => {
224
+ cancelled = true;
225
+ stop();
226
+ };
227
+ }, [readable]);
228
+
229
+ return value;
230
+ }