esoul-sdk 0.25.0 → 0.25.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  Releases before 0.20.0 are recorded in the repository history only.
4
4
 
5
+ **Versions say whether an app must change** (from the release after 0.25.0). A *breaking* release
6
+ moves the leftmost non-zero number (0.25.x → 0.26.0; after 1.0, 1.x → 2.0.0) and lists every change under
7
+ `### Breaking`: one bullet per API, the API in backticks first, then what an app must do. Every
8
+ other release — features, fixes — moves the last number. The Forge reads this: a compatible update
9
+ is offered with one Update button; a breaking one is reviewed by the agent against the app before
10
+ anything is installed. The release check refuses a version that does not match its notes
11
+ (src/lib/forge/release-notes.pure.ts).
12
+
13
+ ## 0.25.2
14
+
15
+ ### Added
16
+
17
+ - `EventDefinition.resolveConcurrent(state, eventData, { theirsSess })`: an event that writes a PATCH (sub-items with the version each built on — a CAD edit's per-node sets with their bases, a notes page's paragraphs) merges a concurrent write itself before it becomes a conflict. Called by the fence only when the write would land as a conflict; returns the rewritten event, with `detail` naming what could not be merged (kept on the conflict marker for the app's own resolution UI), or `null` to keep the plain conflict. The hook block notes merges paragraphs with, now declarable by any SDK app (docs/17 §4).
18
+
19
+ ## 0.25.1
20
+
21
+ ### Added
22
+
23
+ - `headlessBridge(ctx, { url, boot, calls, viewport })` (esoul-sdk/server): a web page opened in a
24
+ headless browser ON THE PLATFORM and driven over `postMessage`, for an app whose engine only
25
+ exists as a page (a WebAssembly CAD kernel, a canvas renderer). The person's open tab is one
26
+ place such a page can run; this is the other — on Vercel, with nobody's tab open, so an agent on
27
+ a phone or over MCP gets the same work done. The page is loaded top-level and is its own
28
+ `parent`, so an iframe bridge answers unchanged; the browser and the loaded page stay warm on the
29
+ platform between calls for the same url (the first call pays the launch, later ones only the
30
+ calls). `url` must be https and public; at most 32 calls; a Forge box has no browser, so there
31
+ the call throws and says so (docs/06-server.md, "The headless bridge").
32
+
5
33
  ## 0.25.0
6
34
 
7
35
  ### Added
package/api-reference.md CHANGED
@@ -1812,6 +1812,11 @@ interface EventDefinition<StateType extends ApplicationIdentifier> {
1812
1812
  conflictScope?: (eventData: any) => string[] | null | undefined;
1813
1813
  merge?: MergeDescription;
1814
1814
  baseMerge?: BaseMerge;
1815
+ resolveConcurrent?: (
1816
+ state: any,
1817
+ eventData: any,
1818
+ ctx: { theirsSess: string | null },
1819
+ ) => { eventData: any; detail?: unknown } | null;
1815
1820
  }
1816
1821
  ```
1817
1822
 
@@ -2515,11 +2520,11 @@ interface UsesDecl {
2515
2520
  ```
2516
2521
 
2517
2522
  ==============================================================================
2518
- ## `esoul-sdk/server` — 115 exports
2523
+ ## `esoul-sdk/server` — 119 exports
2519
2524
 
2520
2525
  Server code only (server.ts, ops, routes, tasks): the viewer, the app's database, files, connections, machines, charts, route tokens.
2521
2526
 
2522
- ### Functions and values (36)
2527
+ ### Functions and values (37)
2523
2528
 
2524
2529
  #### `APPROVAL_WAIT` — const · src/computer.ts
2525
2530
 
@@ -2617,6 +2622,14 @@ Read the sealed credentials of a plugin-declared connection (auto- refreshes exp
2617
2622
  function getPluginConnectionCredentials( _connectionId: string, _pluginId: string, ): Promise<PluginConnectionCredentials>
2618
2623
  ```
2619
2624
 
2625
+ #### `headlessBridge` — function · src/server.ts
2626
+
2627
+ Run a web page in the platform's headless browser and drive it over `postMessage` — see the `HeadlessBridgeArgs` note above: `url` (https, public), `boot` (the page's ready message), `calls` (each posted to the page's window, answered by the first message whose `[replyField]` equals its `id`). Warm between calls for the same url; not available in a Forge box.
2628
+
2629
+ ```ts
2630
+ function headlessBridge(_ctx: { pluginId: string; nodeId: string; origin?: string }, _args: HeadlessBridgeArgs): Promise<HeadlessBridgeResult>
2631
+ ```
2632
+
2620
2633
  #### `isCredentialUnavailable` — function · src/server.ts
2621
2634
 
2622
2635
  True for a `CredentialUnavailable`, also across bundle boundaries where `instanceof` fails.
@@ -2809,7 +2822,7 @@ WHO IS THIS, IN WORDS. The caller's own name and email, for an app with a reason
2809
2822
  function viewerProfile(_viewer: PluginViewer): Promise<ViewerProfile | null>
2810
2823
  ```
2811
2824
 
2812
- ### Types (79)
2825
+ ### Types (82)
2813
2826
 
2814
2827
  #### `AppQuestion` — interface · src/server.ts
2815
2828
 
@@ -3304,6 +3317,41 @@ interface GeneratedAppImage {
3304
3317
  }
3305
3318
  ```
3306
3319
 
3320
+ #### `HeadlessBridgeArgs` — interface · src/server.ts
3321
+
3322
+ ```ts
3323
+ interface HeadlessBridgeArgs {
3324
+ url: string;
3325
+ boot?: { field: string; oneOf: string[]; timeoutMs?: number };
3326
+ calls: HeadlessBridgeCall[];
3327
+ viewport?: { width: number; height: number };
3328
+ }
3329
+ ```
3330
+
3331
+ #### `HeadlessBridgeCall` — interface · src/server.ts
3332
+
3333
+ The headless bridge — a page opened in a headless browser ON THE PLATFORM and driven over `postMessage`, for an app whose engine only exists as a web page (a WebAssembly CAD kernel, a canvas renderer). "No computer should be required": the person's tab is one place the page can run; this is the other, on Vercel, with nobody's tab open.
3334
+
3335
+ ```ts
3336
+ interface HeadlessBridgeCall {
3337
+ id: string;
3338
+ message: Record<string, unknown>;
3339
+ replyField?: string;
3340
+ timeoutMs?: number;
3341
+ }
3342
+ ```
3343
+
3344
+ #### `HeadlessBridgeResult` — interface · src/server.ts
3345
+
3346
+ ```ts
3347
+ interface HeadlessBridgeResult {
3348
+ boot: Record<string, unknown> | null;
3349
+ replies: (Record<string, unknown> | null)[];
3350
+ timings: Record<string, number>;
3351
+ warm: boolean;
3352
+ }
3353
+ ```
3354
+
3307
3355
  #### `JsonOutcome` — interface · src/computer.ts
3308
3356
 
3309
3357
  Flat, like every outcome here. `ok` with `json`; otherwise `error` in words.
package/dist/server.d.ts CHANGED
@@ -681,6 +681,56 @@ export declare function mintRouteToken(_ctx: {
681
681
  ttlSeconds?: number;
682
682
  label?: string;
683
683
  }): Promise<RouteTokenGrant>;
684
+ /**
685
+ * The headless bridge — a page opened in a headless browser ON THE PLATFORM and driven over
686
+ * `postMessage`, for an app whose engine only exists as a web page (a WebAssembly CAD kernel, a
687
+ * canvas renderer). "No computer should be required": the person's tab is one place the page
688
+ * can run; this is the other, on Vercel, with nobody's tab open.
689
+ *
690
+ * `url` must be https and public. The page is loaded top-level, so it is its own `parent`: a
691
+ * bridge written for an iframe (`?parent=<origin>` + `window.parent.postMessage`) answers
692
+ * unchanged when the url names the page's own origin as the parent. `boot` waits for the page's
693
+ * ready message; each call posts `message` to the page's window and returns the first message
694
+ * whose `[replyField]` (default "id") equals the call's `id`. The page stays warm on the platform
695
+ * between calls for the same url — the first call pays the launch, later ones only the calls.
696
+ * Not available in a Forge preview (the box has no browser): keep the app open there instead.
697
+ */
698
+ export interface HeadlessBridgeCall {
699
+ id: string;
700
+ message: Record<string, unknown>;
701
+ replyField?: string;
702
+ timeoutMs?: number;
703
+ }
704
+ export interface HeadlessBridgeArgs {
705
+ url: string;
706
+ boot?: {
707
+ field: string;
708
+ oneOf: string[];
709
+ timeoutMs?: number;
710
+ };
711
+ calls: HeadlessBridgeCall[];
712
+ viewport?: {
713
+ width: number;
714
+ height: number;
715
+ };
716
+ }
717
+ export interface HeadlessBridgeResult {
718
+ boot: Record<string, unknown> | null;
719
+ replies: (Record<string, unknown> | null)[];
720
+ timings: Record<string, number>;
721
+ warm: boolean;
722
+ }
723
+ /**
724
+ * Run a web page in the platform's headless browser and drive it over `postMessage` — see the
725
+ * `HeadlessBridgeArgs` note above: `url` (https, public), `boot` (the page's ready message),
726
+ * `calls` (each posted to the page's window, answered by the first message whose `[replyField]`
727
+ * equals its `id`). Warm between calls for the same url; not available in a Forge box.
728
+ */
729
+ export declare function headlessBridge(_ctx: {
730
+ pluginId: string;
731
+ nodeId: string;
732
+ origin?: string;
733
+ }, _args: HeadlessBridgeArgs): Promise<HeadlessBridgeResult>;
684
734
  /** A question for the person (questions(ctx).ask). */
685
735
  export interface AppQuestion {
686
736
  /** Stable per question within this app instance: asking twice with one key is one question. */
package/dist/server.js CHANGED
@@ -266,6 +266,15 @@ export function renderChartImage(_ctx, _args) {
266
266
  export function mintRouteToken(_ctx, _args) {
267
267
  return hostOnly("mintRouteToken");
268
268
  }
269
+ /**
270
+ * Run a web page in the platform's headless browser and drive it over `postMessage` — see the
271
+ * `HeadlessBridgeArgs` note above: `url` (https, public), `boot` (the page's ready message),
272
+ * `calls` (each posted to the page's window, answered by the first message whose `[replyField]`
273
+ * equals its `id`). Warm between calls for the same url; not available in a Forge box.
274
+ */
275
+ export function headlessBridge(_ctx, _args) {
276
+ return hostOnly("headlessBridge");
277
+ }
269
278
  /**
270
279
  * THE PLATFORM'S QUESTIONS BELL. A question an app needs the person to answer
271
280
  * (an agent waiting on a decision) reaches them wherever they are — the bell,
package/dist/types.d.ts CHANGED
@@ -140,6 +140,21 @@ export interface EventDefinition<StateType extends ApplicationIdentifier> {
140
140
  * back to the normal conflict record. Pure. See docs/17-editing-and-merging.md.
141
141
  */
142
142
  baseMerge?: BaseMerge;
143
+ /**
144
+ * MERGE A CONCURRENT WRITE BEFORE IT BECOMES A CONFLICT. Called by the fence only when this event would be
145
+ * applied as a conflict — the sub-items it writes (`merge.scope`) were changed by a writer it never saw. The
146
+ * event knows its data: it merges what it can against the base it carries and the state it lands on, and
147
+ * returns the rewritten event. `detail` undefined → everything merged, no conflict marker; `detail` set → what
148
+ * could not be merged, stored on the marker for the resolution UI (the app's own shape); `null` → cannot reason
149
+ * about it (the plain conflict stands: applied and marked). Pure and deterministic — it runs in every fold.
150
+ * See docs/17-editing-and-merging.md §4.
151
+ */
152
+ resolveConcurrent?: (state: any, eventData: any, ctx: {
153
+ theirsSess: string | null;
154
+ }) => {
155
+ eventData: any;
156
+ detail?: unknown;
157
+ } | null;
143
158
  }
144
159
  export interface BaseMerge<V = any> {
145
160
  /** The value this event writes, as the state holds it right now (theirs). `undefined` → nothing to merge. */
package/docs/06-server.md CHANGED
@@ -313,3 +313,31 @@ your own nodeId) for your timeline; a route context has no `emit` of its own.
313
313
  the example stops streaming — nothing durable happened unless the handler wrote events. A
314
314
  task (docs/07) survives everything and costs seconds per hop. The stopwatch ships both and
315
315
  records which one drove each run; measure before you choose.
316
+
317
+ ## The headless bridge — a browser on the platform, for a page-shaped engine
318
+
319
+ Some engines exist only as a web page: a WebAssembly CAD kernel, a canvas renderer. The
320
+ person's open tab can run it; `headlessBridge(ctx, …)` runs it on the platform too, so an agent
321
+ with nobody's tab open (a phone with the app closed, an agent network, MCP) gets the same work
322
+ done — no computer of the person's involved.
323
+
324
+ ```ts
325
+ import { headlessBridge } from "esoul-sdk/server";
326
+
327
+ const r = await headlessBridge(ctx, {
328
+ url: "https://my-runtime.example/page.html?parent=https%3A%2F%2Fmy-runtime.example",
329
+ boot: { field: "type", oneOf: ["ready", "error"] }, // the page's own ready message
330
+ calls: [{ id: "replay", message: { myBridge: 1, id: "replay", method: "replay", args: { … } } }],
331
+ });
332
+ r.boot; // the ready message
333
+ r.replies[0]; // the first message whose `id` equals "replay"
334
+ r.timings; // launchMs / bootMs (cold only), per-call ms, totalMs
335
+ r.warm; // true when the page was already open on the platform
336
+ ```
337
+
338
+ The page is loaded top-level, so it is its own `parent`: a bridge written for an iframe answers
339
+ unchanged when `?parent=` names the page's own origin. The browser and the loaded page stay warm
340
+ on the platform between calls for the same url; the first call pays the launch and the page's
341
+ download, later calls only the calls. `url` must be https and public; at most 32 calls; replies
342
+ up to 24 MB. A Forge box has no browser: there the call throws and says so — keep the app open
343
+ in the preview and let the tab do the work.
@@ -153,3 +153,25 @@ recorded as a conflict. `baseMerge` runs in every fold: keep it pure, exactly li
153
153
  platform and needs no merge — prefer operations to whole-value saves where you can.
154
154
  - Device view state (which page is open, scroll, selection) belongs in component state or
155
155
  `sessionStorage`, never in a shared event another device would follow.
156
+
157
+ ## 4. Declare `resolveConcurrent` for patch-shaped events
158
+
159
+ `baseMerge` is for events that write one whole value. An event that writes a PATCH — a list of sub-items it
160
+ changed, each with the version it built on — can do better: declare `merge` with `scope` (the sub-item ids),
161
+ and `resolveConcurrent`. The platform calls it only when the patch would land as a conflict (a writer it never
162
+ saw changed one of its sub-items), with the state it lands on; it merges what it can against the bases it
163
+ carries and returns the rewritten event:
164
+
165
+ ```ts
166
+ merge: { item: (_d, ctx) => `${ctx.applicationId}:cad:model`, scope: (d) => d.delta.ops.map(keyOf), describe: (d) => d.label },
167
+ resolveConcurrent: (state, eventData, { theirsSess }) => {
168
+ const r = resolveEdit(state, eventData); // pure: three-way per sub-item against the base each op carries
169
+ if (!r) return null; // cannot reason about it → the plain conflict (applied and marked)
170
+ return r.clashes.length === 0
171
+ ? { eventData: r.eventData } // everything merged: applied, no marker
172
+ : { eventData: r.eventData, detail: { kind: "cad-edit", clashes: r.clashes, theirsSess } };
173
+ },
174
+ ```
175
+
176
+ What it returns is what the fold applies, on every device and on the server. Keep it pure and symmetric; a
177
+ property test over randomised edit pairs is the cheapest proof.
package/llms-full.txt CHANGED
@@ -1669,6 +1669,34 @@ the example stops streaming — nothing durable happened unless the handler wrot
1669
1669
  task (docs/07) survives everything and costs seconds per hop. The stopwatch ships both and
1670
1670
  records which one drove each run; measure before you choose.
1671
1671
 
1672
+ ## The headless bridge — a browser on the platform, for a page-shaped engine
1673
+
1674
+ Some engines exist only as a web page: a WebAssembly CAD kernel, a canvas renderer. The
1675
+ person's open tab can run it; `headlessBridge(ctx, …)` runs it on the platform too, so an agent
1676
+ with nobody's tab open (a phone with the app closed, an agent network, MCP) gets the same work
1677
+ done — no computer of the person's involved.
1678
+
1679
+ ```ts
1680
+ import { headlessBridge } from "esoul-sdk/server";
1681
+
1682
+ const r = await headlessBridge(ctx, {
1683
+ url: "https://my-runtime.example/page.html?parent=https%3A%2F%2Fmy-runtime.example",
1684
+ boot: { field: "type", oneOf: ["ready", "error"] }, // the page's own ready message
1685
+ calls: [{ id: "replay", message: { myBridge: 1, id: "replay", method: "replay", args: { … } } }],
1686
+ });
1687
+ r.boot; // the ready message
1688
+ r.replies[0]; // the first message whose `id` equals "replay"
1689
+ r.timings; // launchMs / bootMs (cold only), per-call ms, totalMs
1690
+ r.warm; // true when the page was already open on the platform
1691
+ ```
1692
+
1693
+ The page is loaded top-level, so it is its own `parent`: a bridge written for an iframe answers
1694
+ unchanged when `?parent=` names the page's own origin. The browser and the loaded page stay warm
1695
+ on the platform between calls for the same url; the first call pays the launch and the page's
1696
+ download, later calls only the calls. `url` must be https and public; at most 32 calls; replies
1697
+ up to 24 MB. A Forge box has no browser: there the call throws and says so — keep the app open
1698
+ in the preview and let the tab do the work.
1699
+
1672
1700
 
1673
1701
 
1674
1702
  ==============================================================================
@@ -3598,6 +3626,28 @@ recorded as a conflict. `baseMerge` runs in every fold: keep it pure, exactly li
3598
3626
  - Device view state (which page is open, scroll, selection) belongs in component state or
3599
3627
  `sessionStorage`, never in a shared event another device would follow.
3600
3628
 
3629
+ ## 4. Declare `resolveConcurrent` for patch-shaped events
3630
+
3631
+ `baseMerge` is for events that write one whole value. An event that writes a PATCH — a list of sub-items it
3632
+ changed, each with the version it built on — can do better: declare `merge` with `scope` (the sub-item ids),
3633
+ and `resolveConcurrent`. The platform calls it only when the patch would land as a conflict (a writer it never
3634
+ saw changed one of its sub-items), with the state it lands on; it merges what it can against the bases it
3635
+ carries and returns the rewritten event:
3636
+
3637
+ ```ts
3638
+ merge: { item: (_d, ctx) => `${ctx.applicationId}:cad:model`, scope: (d) => d.delta.ops.map(keyOf), describe: (d) => d.label },
3639
+ resolveConcurrent: (state, eventData, { theirsSess }) => {
3640
+ const r = resolveEdit(state, eventData); // pure: three-way per sub-item against the base each op carries
3641
+ if (!r) return null; // cannot reason about it → the plain conflict (applied and marked)
3642
+ return r.clashes.length === 0
3643
+ ? { eventData: r.eventData } // everything merged: applied, no marker
3644
+ : { eventData: r.eventData, detail: { kind: "cad-edit", clashes: r.clashes, theirsSess } };
3645
+ },
3646
+ ```
3647
+
3648
+ What it returns is what the fold applies, on every device and on the server. Keep it pure and symmetric; a
3649
+ property test over randomised edit pairs is the cheapest proof.
3650
+
3601
3651
 
3602
3652
 
3603
3653
  ==============================================================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "esoul-sdk",
3
- "version": "0.25.0",
3
+ "version": "0.25.2",
4
4
  "description": "Build a full product on ExternalSoul: your own tables with per-person rules, a viewer on every seam, app roles, access levels, realtime with audiences, durable tasks, and bindings to other apps.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",