@tapcue/extension-sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +28 -0
- package/package.json +45 -0
- package/src/capabilities.ts +1037 -0
- package/src/components.ts +277 -0
- package/src/define-extension.ts +205 -0
- package/src/errors.ts +90 -0
- package/src/i18n.ts +64 -0
- package/src/index.ts +13 -0
- package/src/json.ts +24 -0
- package/src/jsx-runtime.ts +118 -0
- package/src/manifest.ts +1572 -0
- package/src/overlay-jsx-runtime.ts +72 -0
- package/src/overlay.ts +130 -0
- package/src/permission-units.ts +109 -0
- package/src/reactive.ts +374 -0
- package/src/scene.ts +297 -0
- package/src/testing/http-fake.ts +240 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/test-host.ts +2330 -0
- package/src/types.ts +614 -0
- package/src/view-runtime.ts +270 -0
- package/src/view.ts +116 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The isolate-side reactive loop (docs/architecture/extension-ui-interactive.md §4, §8).
|
|
3
|
+
*
|
|
4
|
+
* `renderView` runs a `view` handler once to produce the initial `{view, model, actions}`.
|
|
5
|
+
* `applyAction` re-runs the handler to rebuild the signal graph, hydrates it from the model the
|
|
6
|
+
* shell handed back, dispatches one action, and returns the model cells that changed as a patch.
|
|
7
|
+
* The handler is a pure function of its request and context; all interactivity is signals it
|
|
8
|
+
* created plus actions it declared, which is why a cold isolate can rehydrate and act (§3).
|
|
9
|
+
*
|
|
10
|
+
* bootstrap.js calls these from `__tapcue.scopeView` / `__tapcue.scopeViewAction`; the fake test
|
|
11
|
+
* host calls them directly so an extension's view can be unit-tested with no host at all.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { ExtensionError } from "./errors.js";
|
|
15
|
+
import type { JsonValue } from "./json.js";
|
|
16
|
+
import { BuildContext, registerLiveBuild, runInBuild } from "./reactive.js";
|
|
17
|
+
import type { VisibleCleanup } from "./reactive.js";
|
|
18
|
+
import type { ActionContext, ExtensionContext } from "./types.js";
|
|
19
|
+
import type { ActionDescriptor, ActionPatch, DataModel, PatchOp, ViewNode, ViewResult } from "./view.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A view handler: a pure builder of a bound tree. Signals/actions register as it runs. It may be
|
|
23
|
+
* async — awaiting the initial data it binds (a forecast, a search) before describing itself — as
|
|
24
|
+
* long as signal/action creation order is deterministic across runs (an action dispatch re-runs it
|
|
25
|
+
* to rebuild the graph before hydrating from the model).
|
|
26
|
+
*/
|
|
27
|
+
export type ViewHandler<Request> = (
|
|
28
|
+
request: Request,
|
|
29
|
+
ctx: ExtensionContext,
|
|
30
|
+
) => ViewNode | Promise<ViewNode>;
|
|
31
|
+
|
|
32
|
+
function describeActions(build: BuildContext): ActionDescriptor[] {
|
|
33
|
+
return [...build.actions.values()].map((action) => {
|
|
34
|
+
const descriptor: ActionDescriptor = { id: action.id };
|
|
35
|
+
if (action.requires !== undefined) descriptor.requires = action.requires;
|
|
36
|
+
if (action.destructive !== undefined) descriptor.destructive = action.destructive;
|
|
37
|
+
return descriptor;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function modelOf(build: BuildContext): DataModel {
|
|
42
|
+
const model: DataModel = {};
|
|
43
|
+
for (const path of build.order) {
|
|
44
|
+
model[path] = build.values.get(path) as JsonValue;
|
|
45
|
+
}
|
|
46
|
+
return model;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function renderView<Request>(
|
|
50
|
+
view: ViewHandler<Request>,
|
|
51
|
+
request: Request,
|
|
52
|
+
ctx: ExtensionContext,
|
|
53
|
+
): Promise<ViewResult> {
|
|
54
|
+
const build = new BuildContext();
|
|
55
|
+
const root = await runInBuild(build, () => view(request, ctx));
|
|
56
|
+
return { kind: "view", view: root, model: modelOf(build), actions: describeActions(build) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function encode(value: JsonValue | undefined): string {
|
|
60
|
+
return JSON.stringify(value ?? null);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The cells that moved since `previous`, as patch ops, advancing `previous` as it goes.
|
|
65
|
+
*
|
|
66
|
+
* What a patch *is* — walk the build order, compare against the last snapshot, emit the changed
|
|
67
|
+
* cells — is the shape the shell's `ExtensionViewStore.apply` must mirror, so it is stated here
|
|
68
|
+
* rather than inline. The snapshot holds each cell already serialized, so an unchanged cell costs
|
|
69
|
+
* one encode and a string compare rather than re-encoding both sides.
|
|
70
|
+
*/
|
|
71
|
+
function diffModel(build: BuildContext, previous: Map<string, string>): PatchOp[] {
|
|
72
|
+
const ops: PatchOp[] = [];
|
|
73
|
+
for (const path of build.order) {
|
|
74
|
+
diffCell(path, build.values.get(path) as JsonValue, previous, ops);
|
|
75
|
+
}
|
|
76
|
+
return ops;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Emit the ops that turn a cell's previous value into `next`, and advance `previous`.
|
|
81
|
+
*
|
|
82
|
+
* An array cell is diffed **element by element** rather than replaced whole: a `<Table>`'s rows are
|
|
83
|
+
* one array signal, so without this a single row's number moving re-sends every row every tick. The
|
|
84
|
+
* extension builds the array in a stable order (a process list is pid-ordered), so element `i` is
|
|
85
|
+
* the same row across ticks and only the few that actually moved travel, as `s0[i]`. A cell that is
|
|
86
|
+
* not an array is set whole, as before.
|
|
87
|
+
*/
|
|
88
|
+
function diffCell(path: string, next: JsonValue, previous: Map<string, string>, ops: PatchOp[]): void {
|
|
89
|
+
if (Array.isArray(next)) {
|
|
90
|
+
const prevLen = Number(previous.get(`${path}#len`) ?? "-1");
|
|
91
|
+
for (let i = 0; i < next.length; i++) {
|
|
92
|
+
const key = `${path}[${i}]`;
|
|
93
|
+
const encoded = encode(next[i]);
|
|
94
|
+
if (previous.get(key) !== encoded) {
|
|
95
|
+
ops.push({ op: "set", path: key, value: next[i] });
|
|
96
|
+
previous.set(key, encoded);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// The array shrank: drop the tail, highest index first so a truncating apply stays in step.
|
|
100
|
+
for (let i = next.length; i < prevLen; i++) {
|
|
101
|
+
ops.push({ op: "remove", path: `${path}[${i}]` });
|
|
102
|
+
previous.delete(`${path}[${i}]`);
|
|
103
|
+
}
|
|
104
|
+
// A cell that used to be a scalar (prevLen === -1) needs its old whole value cleared, or a
|
|
105
|
+
// reader could keep it beside the new elements.
|
|
106
|
+
if (prevLen === -1 && previous.has(path)) {
|
|
107
|
+
previous.delete(path);
|
|
108
|
+
}
|
|
109
|
+
previous.set(`${path}#len`, String(next.length));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const encoded = encode(next);
|
|
114
|
+
if (previous.get(path) !== encoded) {
|
|
115
|
+
ops.push({ op: "set", path, value: next });
|
|
116
|
+
previous.set(path, encoded);
|
|
117
|
+
}
|
|
118
|
+
previous.delete(`${path}#len`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Every cell of `build`, serialized to the same keys `diffCell` compares against. */
|
|
122
|
+
function snapshot(build: BuildContext): Map<string, string> {
|
|
123
|
+
const baseline = new Map<string, string>();
|
|
124
|
+
for (const [path, value] of build.values) {
|
|
125
|
+
if (Array.isArray(value)) {
|
|
126
|
+
value.forEach((element, i) => baseline.set(`${path}[${i}]`, encode(element as JsonValue)));
|
|
127
|
+
baseline.set(`${path}#len`, String(value.length));
|
|
128
|
+
} else {
|
|
129
|
+
baseline.set(path, encode(value as JsonValue));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return baseline;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Where a kept-alive view's patches go, and when it should stop.
|
|
137
|
+
*
|
|
138
|
+
* The runtime supplies `signal` from the view's run window — aborted when the surface stops being
|
|
139
|
+
* shown — and turns each flush into a streamed patch frame.
|
|
140
|
+
*/
|
|
141
|
+
export interface ViewDriver {
|
|
142
|
+
/** Each flushed patch, in order: one per microtask batch of signal writes. */
|
|
143
|
+
onPatch(patch: PatchOp[]): void;
|
|
144
|
+
/** Aborts when the view stops being visible. */
|
|
145
|
+
signal: AbortSignal;
|
|
146
|
+
/** Called once the initial frame is out and the setups have started. */
|
|
147
|
+
onReady?(): void;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Render a view, then keep it alive for as long as its `whileVisible` setups have work.
|
|
152
|
+
*
|
|
153
|
+
* The initial frame is emitted before any setup runs, so a write on a setup's very first tick can
|
|
154
|
+
* never arrive before the frame it patches. After that the render is signal-driven: `build.onDirty`
|
|
155
|
+
* is wired, so any `signal.set()` a setup makes schedules one microtask flush that diffs the model
|
|
156
|
+
* and streams the cells that moved. Nothing is explicitly pushed.
|
|
157
|
+
*
|
|
158
|
+
* A view with no setups emits and returns — that is every ordinary pane, and it costs what it
|
|
159
|
+
* always did. There is no flag distinguishing the two; what happens follows from what the view
|
|
160
|
+
* left running.
|
|
161
|
+
*/
|
|
162
|
+
export async function driveView<Request>(
|
|
163
|
+
view: ViewHandler<Request>,
|
|
164
|
+
request: Request,
|
|
165
|
+
ctx: ExtensionContext,
|
|
166
|
+
emit: (result: ViewResult) => void,
|
|
167
|
+
driver: ViewDriver,
|
|
168
|
+
): Promise<void> {
|
|
169
|
+
const build = new BuildContext();
|
|
170
|
+
const root = await runInBuild(build, () => view(request, ctx));
|
|
171
|
+
const result: ViewResult = {
|
|
172
|
+
kind: "view",
|
|
173
|
+
view: root,
|
|
174
|
+
model: modelOf(build),
|
|
175
|
+
actions: describeActions(build),
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
if (build.visibleSetups.length === 0) {
|
|
179
|
+
emit(result);
|
|
180
|
+
driver.onReady?.();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Wired before emitting, so a set made on a setup's very first tick is never lost.
|
|
185
|
+
const last = snapshot(build);
|
|
186
|
+
let scheduled = false;
|
|
187
|
+
const flush = () => {
|
|
188
|
+
scheduled = false;
|
|
189
|
+
const ops = diffModel(build, last);
|
|
190
|
+
if (ops.length > 0) driver.onPatch(ops);
|
|
191
|
+
};
|
|
192
|
+
build.onDirty = () => {
|
|
193
|
+
if (scheduled || driver.signal.aborted) return;
|
|
194
|
+
scheduled = true;
|
|
195
|
+
queueMicrotask(flush);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// Setups are torn down and re-run as a unit, not once for the view's whole life. Two things
|
|
199
|
+
// ask for that: the visible window ending (the original reason), and a **setting the view
|
|
200
|
+
// follows changing** — a loop already armed at 5 s does not start ticking at 1 s because a
|
|
201
|
+
// value changed, it has to be re-armed. `whileVisible` has always promised idempotence, so
|
|
202
|
+
// re-running is within the contract it already signs.
|
|
203
|
+
let cleanups: VisibleCleanup[] = [];
|
|
204
|
+
const teardown = () => {
|
|
205
|
+
const previous = cleanups;
|
|
206
|
+
cleanups = [];
|
|
207
|
+
for (const cleanup of previous) {
|
|
208
|
+
if (typeof cleanup === "function") cleanup();
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
const startSetups = async () => {
|
|
212
|
+
cleanups = await Promise.all(build.visibleSetups.map((setup) => setup()));
|
|
213
|
+
};
|
|
214
|
+
const unregister = registerLiveBuild({
|
|
215
|
+
build,
|
|
216
|
+
async restartSetups() {
|
|
217
|
+
if (driver.signal.aborted) return;
|
|
218
|
+
teardown();
|
|
219
|
+
await startSetups();
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
emit(result);
|
|
224
|
+
await startSetups();
|
|
225
|
+
driver.onReady?.();
|
|
226
|
+
|
|
227
|
+
await new Promise<void>((resolve) => {
|
|
228
|
+
if (driver.signal.aborted) return resolve();
|
|
229
|
+
driver.signal.addEventListener("abort", () => resolve(), { once: true });
|
|
230
|
+
});
|
|
231
|
+
unregister();
|
|
232
|
+
build.onDirty = null;
|
|
233
|
+
teardown();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function applyAction<Request>(
|
|
237
|
+
view: ViewHandler<Request>,
|
|
238
|
+
request: Request,
|
|
239
|
+
ctx: ActionContext,
|
|
240
|
+
actionId: string,
|
|
241
|
+
args: JsonValue[],
|
|
242
|
+
model: DataModel,
|
|
243
|
+
): Promise<ActionPatch> {
|
|
244
|
+
const build = new BuildContext();
|
|
245
|
+
// Rebuild the signal graph and action registry deterministically, then hydrate it from the
|
|
246
|
+
// model the shell holds. Signal creation order must match the initial render for this to line
|
|
247
|
+
// up — the rules-of-hooks discipline (reactive.ts).
|
|
248
|
+
await runInBuild(build, () => view(request, ctx));
|
|
249
|
+
for (const [path, value] of Object.entries(model)) {
|
|
250
|
+
build.values.set(path, value);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const action = build.actions.get(actionId);
|
|
254
|
+
if (action === undefined) {
|
|
255
|
+
throw new ExtensionError({ code: "invalid-result", message: `unknown action: ${actionId}` });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Registered so an action that writes a setting writes it into *this* build too — the patch
|
|
259
|
+
// below is what tells the shell about it. Its setups are never run here (an action does not
|
|
260
|
+
// present a view), so there is nothing to restart. Unregistered whatever the action does:
|
|
261
|
+
// this build lives exactly as long as the call.
|
|
262
|
+
const unregister = registerLiveBuild({ build, async restartSetups() {} });
|
|
263
|
+
const before = snapshot(build);
|
|
264
|
+
try {
|
|
265
|
+
await action.run(ctx, args);
|
|
266
|
+
} finally {
|
|
267
|
+
unregister();
|
|
268
|
+
}
|
|
269
|
+
return { kind: "patch", patch: diffModel(build, before) };
|
|
270
|
+
}
|
package/src/view.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The interactive view wire format. See docs/architecture/extension-ui-interactive.md.
|
|
3
|
+
*
|
|
4
|
+
* These are the JSON shapes that cross the isolate ↔ shell boundary. The authoring
|
|
5
|
+
* API (`signal`, `action`, the JSX components) is sugar that compiles to exactly
|
|
6
|
+
* these — the shell only ever sees this. Nothing here is React: a `ViewNode` tree is
|
|
7
|
+
* a snapshot of bindings into a serializable `DataModel`, not a component instance.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { JsonValue } from "./json.js";
|
|
11
|
+
import type { PermissionUnit } from "./manifest.js";
|
|
12
|
+
|
|
13
|
+
/** A dotted path into the `DataModel`. `@item` / `@index` are `<For>`-scope locals. */
|
|
14
|
+
export type ModelPath = string;
|
|
15
|
+
|
|
16
|
+
/** A reference to a model cell the shell reads without waking the isolate. */
|
|
17
|
+
export interface Bind {
|
|
18
|
+
$bind: ModelPath;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A reference to a declared action; the event supplies the args. */
|
|
22
|
+
export interface ActionRef {
|
|
23
|
+
$action: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A reference into a bounded, shell-evaluable derivation (§4, the closed binding vocabulary). */
|
|
27
|
+
export interface DeriveRef {
|
|
28
|
+
$derive: Derivation;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The closed binding vocabulary. Starts tiny; grows on evidence (§12). */
|
|
32
|
+
export type Derivation =
|
|
33
|
+
| { op: "not"; of: PropValue }
|
|
34
|
+
| { op: "isEmpty"; of: PropValue }
|
|
35
|
+
| { op: "notEmpty"; of: PropValue }
|
|
36
|
+
| { op: "format"; of: PropValue; as: "number" | "percent" | "date" }
|
|
37
|
+
/**
|
|
38
|
+
* Index a table the view already produced. Pure, total (a missing key resolves to nothing),
|
|
39
|
+
* and bounded — the table is data, not a callback, and the shell only ever reads it.
|
|
40
|
+
*
|
|
41
|
+
* It exists because a pane that lets the user *change* something could previously display
|
|
42
|
+
* only facts that did not depend on what changed. `value` and `font` bind because the cell
|
|
43
|
+
* holds exactly what the widget draws; "the classification of whichever family is selected"
|
|
44
|
+
* is a lookup, and without one an extension either wakes the isolate on every keystroke or
|
|
45
|
+
* renders a value computed once and now stale. The fonts playground shipped the second kind:
|
|
46
|
+
* it read "Serif / Plain" beside a specimen set in a monospace face, and nothing errored,
|
|
47
|
+
* because a stale value looks exactly like a fresh one.
|
|
48
|
+
*/
|
|
49
|
+
| { op: "lookup"; of: PropValue; in: Record<string, PropValue> };
|
|
50
|
+
|
|
51
|
+
/** A prop value: a literal, or one tagged binding form. */
|
|
52
|
+
export type PropValue =
|
|
53
|
+
| string
|
|
54
|
+
| number
|
|
55
|
+
| boolean
|
|
56
|
+
| null
|
|
57
|
+
| Bind
|
|
58
|
+
| ActionRef
|
|
59
|
+
| DeriveRef
|
|
60
|
+
| PropValue[]
|
|
61
|
+
| { [key: string]: PropValue };
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A serialized element. `type` is a member of the closed component vocabulary (§5);
|
|
65
|
+
* an unknown type is dropped by the shell, never guessed at. Props may be literals or
|
|
66
|
+
* bindings; `children` may itself hold nested nodes; `slots` carry named node props
|
|
67
|
+
* (e.g. a `<Show>` fallback) that are trees rather than values.
|
|
68
|
+
*/
|
|
69
|
+
export interface ViewNode {
|
|
70
|
+
type: string;
|
|
71
|
+
props?: Record<string, PropValue>;
|
|
72
|
+
slots?: Record<string, ViewNode>;
|
|
73
|
+
children?: ViewNode[];
|
|
74
|
+
key?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The serializable state. Every value is JSON — that is what makes rehydration cheap (§3). */
|
|
78
|
+
export type DataModel = Record<ModelPath, JsonValue>;
|
|
79
|
+
|
|
80
|
+
/** A declared action, surfaced so the shell can gate on `requires` and confirm `destructive`. */
|
|
81
|
+
export interface ActionDescriptor {
|
|
82
|
+
id: string;
|
|
83
|
+
requires?: PermissionUnit;
|
|
84
|
+
destructive?: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** What a `view` handler produces on first render. */
|
|
88
|
+
export interface ViewResult {
|
|
89
|
+
kind: "view";
|
|
90
|
+
view: ViewNode;
|
|
91
|
+
model: DataModel;
|
|
92
|
+
actions: ActionDescriptor[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One model mutation. `set` is the only op v1 emits; `remove` reserved for structural change. */
|
|
96
|
+
export type PatchOp =
|
|
97
|
+
| { op: "set"; path: ModelPath; value: JsonValue }
|
|
98
|
+
| { op: "remove"; path: ModelPath };
|
|
99
|
+
|
|
100
|
+
/** What an action produces: model patches, and — only if structure changed — a fresh tree. */
|
|
101
|
+
export interface ActionPatch {
|
|
102
|
+
kind: "patch";
|
|
103
|
+
patch: PatchOp[];
|
|
104
|
+
view?: ViewNode;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Type guards the shell-side and test host share. */
|
|
108
|
+
export function isBind(value: unknown): value is Bind {
|
|
109
|
+
return typeof value === "object" && value !== null && "$bind" in value;
|
|
110
|
+
}
|
|
111
|
+
export function isActionRef(value: unknown): value is ActionRef {
|
|
112
|
+
return typeof value === "object" && value !== null && "$action" in value;
|
|
113
|
+
}
|
|
114
|
+
export function isDeriveRef(value: unknown): value is DeriveRef {
|
|
115
|
+
return typeof value === "object" && value !== null && "$derive" in value;
|
|
116
|
+
}
|