@jigs.design/runtime 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +29 -2
- package/dist/frame-policy.d.ts +11 -0
- package/dist/frame-policy.js +17 -0
- package/dist/frame.d.ts +42 -0
- package/dist/frame.js +258 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/parse.d.ts +9 -0
- package/dist/parse.js +150 -0
- package/dist/protocol.d.ts +132 -0
- package/dist/protocol.js +4 -0
- package/dist/runtime-core.d.ts +21 -0
- package/dist/runtime-core.js +735 -0
- package/dist/tree-store.d.ts +56 -0
- package/dist/tree-store.js +340 -0
- package/dist/types.d.ts +168 -0
- package/dist/types.js +22 -0
- package/dist/urls.d.ts +14 -0
- package/dist/urls.js +59 -0
- package/dist/validate.js +33 -0
- package/package.json +28 -3
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { ComponentSchema } from "./types.js";
|
|
2
|
+
import { Op, WireValue } from "./protocol.js";
|
|
3
|
+
//#region src/tree-store.d.ts
|
|
4
|
+
export interface TreeNode {
|
|
5
|
+
children: ReadonlyArray<number>;
|
|
6
|
+
id: number;
|
|
7
|
+
props: Readonly<Record<string, WireValue>>;
|
|
8
|
+
text: string;
|
|
9
|
+
type: string;
|
|
10
|
+
}
|
|
11
|
+
interface TreeSchemas {
|
|
12
|
+
components: Readonly<Record<string, ComponentSchema>>;
|
|
13
|
+
imageHosts: ReadonlyArray<string>;
|
|
14
|
+
}
|
|
15
|
+
export interface TreeStore {
|
|
16
|
+
apply: (ops: ReadonlyArray<Op>) => void;
|
|
17
|
+
get: (id: number) => TreeNode | undefined;
|
|
18
|
+
/** What the store dropped since the last program change, newest last. */
|
|
19
|
+
getIssues: () => ReadonlyArray<string>;
|
|
20
|
+
reset: () => void;
|
|
21
|
+
/** The schemas the next ops are checked against. Clears the issues. */
|
|
22
|
+
setSchemas: (schemas: TreeSchemas) => void;
|
|
23
|
+
subscribe: (listener: () => void) => () => void;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Most nodes a tree may hold, the root aside. The runtime refuses to render
|
|
27
|
+
* more (it keeps its own copy of the number), so only a worker that broke out
|
|
28
|
+
* of the runtime gets past it here, and the store drops the creates over it.
|
|
29
|
+
* The count is what a whole message leaves: the runtime may create a row's
|
|
30
|
+
* new nodes before it removes the old ones further on, so mid-message the
|
|
31
|
+
* store may hold up to twice this (`MAX_DRAFT_NODES`).
|
|
32
|
+
*/
|
|
33
|
+
export declare const MAX_NODES = 10000;
|
|
34
|
+
/**
|
|
35
|
+
* Most ops one message may carry. A render of `MAX_NODES` nodes needs at most
|
|
36
|
+
* three per node (remove the old one, create and place the new one), so the
|
|
37
|
+
* runtime stays under it; a message over it is dropped whole.
|
|
38
|
+
*/
|
|
39
|
+
export declare const MAX_OPS: number;
|
|
40
|
+
/** Longest text a text node may hold, in UTF-16 code units, as for a string prop. */
|
|
41
|
+
export declare const MAX_TEXT = 10000;
|
|
42
|
+
/**
|
|
43
|
+
* The page's copy of the sandboxed UI, built from worker ops. Ops are
|
|
44
|
+
* untrusted: components the program did not declare and props that fail
|
|
45
|
+
* their schema are dropped, and each drop is recorded as an issue. The tree
|
|
46
|
+
* holds at most `MAX_NODES` nodes once a message is applied, a message at
|
|
47
|
+
* most `MAX_OPS` ops, and a text node at most `MAX_TEXT` characters.
|
|
48
|
+
* `apply` takes time linear in its ops for what the runtime sends, and
|
|
49
|
+
* stops a message that splices or walks the tree for longer than that.
|
|
50
|
+
*
|
|
51
|
+
* Nodes are replaced, never mutated, so `get(id)` works as a
|
|
52
|
+
* `useSyncExternalStore` snapshot.
|
|
53
|
+
*/
|
|
54
|
+
export declare function createTreeStore(): TreeStore;
|
|
55
|
+
//#endregion
|
|
56
|
+
export {};
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import "./protocol.js";
|
|
2
|
+
import { acceptsProp } from "./validate.js";
|
|
3
|
+
//#region src/tree-store.ts
|
|
4
|
+
const ROOT_TYPE = "#root";
|
|
5
|
+
const TEXT_TYPE = "#text";
|
|
6
|
+
const MAX_ISSUES = 20;
|
|
7
|
+
/**
|
|
8
|
+
* Most nodes a tree may hold, the root aside. The runtime refuses to render
|
|
9
|
+
* more (it keeps its own copy of the number), so only a worker that broke out
|
|
10
|
+
* of the runtime gets past it here, and the store drops the creates over it.
|
|
11
|
+
* The count is what a whole message leaves: the runtime may create a row's
|
|
12
|
+
* new nodes before it removes the old ones further on, so mid-message the
|
|
13
|
+
* store may hold up to twice this (`MAX_DRAFT_NODES`).
|
|
14
|
+
*/
|
|
15
|
+
const MAX_NODES = 1e4;
|
|
16
|
+
/**
|
|
17
|
+
* Most nodes the store holds while one message is applied: the old tree and
|
|
18
|
+
* a new one, each up to `MAX_NODES`. Creates past it are dropped at once.
|
|
19
|
+
*/
|
|
20
|
+
const MAX_DRAFT_NODES = 2 * MAX_NODES;
|
|
21
|
+
/**
|
|
22
|
+
* Most ops one message may carry. A render of `MAX_NODES` nodes needs at most
|
|
23
|
+
* three per node (remove the old one, create and place the new one), so the
|
|
24
|
+
* runtime stays under it; a message over it is dropped whole.
|
|
25
|
+
*/
|
|
26
|
+
const MAX_OPS = 3 * MAX_NODES;
|
|
27
|
+
/** Longest text a text node may hold, in UTF-16 code units, as for a string prop. */
|
|
28
|
+
const MAX_TEXT = 1e4;
|
|
29
|
+
/** A removed child's slot in a draft, until the draft is compacted. */
|
|
30
|
+
const HOLE = -1;
|
|
31
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
32
|
+
const isId = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
33
|
+
/** Whether an op from the frame has the fields its kind needs; anything else is dropped. */
|
|
34
|
+
function isWellFormed(op) {
|
|
35
|
+
const fields = op;
|
|
36
|
+
if (!isRecord(fields) || !isId(fields.id)) return false;
|
|
37
|
+
switch (op.op) {
|
|
38
|
+
case "create": return typeof fields.type === "string" && isRecord(fields.props);
|
|
39
|
+
case "createText":
|
|
40
|
+
case "text": return typeof fields.text === "string" && fields.text.length <= 1e4;
|
|
41
|
+
case "place": return isId(fields.parent) && isId(fields.index);
|
|
42
|
+
case "remove": return true;
|
|
43
|
+
case "set": return isRecord(fields.props);
|
|
44
|
+
default: return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const rootNode = () => ({
|
|
48
|
+
children: [],
|
|
49
|
+
id: 0,
|
|
50
|
+
props: {},
|
|
51
|
+
text: "",
|
|
52
|
+
type: ROOT_TYPE
|
|
53
|
+
});
|
|
54
|
+
const compact = (draft) => {
|
|
55
|
+
if (draft.holes > 0) {
|
|
56
|
+
draft.items = draft.items.filter((child) => child !== HOLE);
|
|
57
|
+
draft.holes = 0;
|
|
58
|
+
draft.at = null;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Puts `id` at `index` among a draft's live children, and says whether that
|
|
63
|
+
* took constant time. The runtime either appends or fills the slot of the
|
|
64
|
+
* child it just removed at that index, which do. Any other order compacts
|
|
65
|
+
* and splices, which is linear in the parent's children.
|
|
66
|
+
*/
|
|
67
|
+
function insert(draft, id, index) {
|
|
68
|
+
if (index >= draft.items.length - draft.holes) {
|
|
69
|
+
draft.at?.set(id, draft.items.length);
|
|
70
|
+
draft.items.push(id);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
if (draft.holes === 1 && draft.items[index] === HOLE) {
|
|
74
|
+
draft.items[index] = id;
|
|
75
|
+
draft.holes = 0;
|
|
76
|
+
draft.at?.set(id, index);
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
compact(draft);
|
|
80
|
+
draft.items.splice(index, 0, id);
|
|
81
|
+
draft.at = null;
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Most ops in one message that take time linear in a parent's children (see
|
|
86
|
+
* `insert`). The runtime sends none, so this only stops a worker that broke
|
|
87
|
+
* out of it from spending `MAX_OPS * MAX_NODES` steps of the page's time.
|
|
88
|
+
*/
|
|
89
|
+
const MAX_SLOW_OPS = 1e3;
|
|
90
|
+
/**
|
|
91
|
+
* Most ancestors one message may walk to rule out cycles, 64 per op on
|
|
92
|
+
* average. The runtime places a subtree before its parent, so its walks stop
|
|
93
|
+
* at once or climb a shallow tree; a worker that broke out of it could
|
|
94
|
+
* otherwise re-place a leaf under a chain `MAX_NODES` deep, over and over.
|
|
95
|
+
*/
|
|
96
|
+
const MAX_WALK_STEPS = 64 * MAX_OPS;
|
|
97
|
+
/**
|
|
98
|
+
* The page's copy of the sandboxed UI, built from worker ops. Ops are
|
|
99
|
+
* untrusted: components the program did not declare and props that fail
|
|
100
|
+
* their schema are dropped, and each drop is recorded as an issue. The tree
|
|
101
|
+
* holds at most `MAX_NODES` nodes once a message is applied, a message at
|
|
102
|
+
* most `MAX_OPS` ops, and a text node at most `MAX_TEXT` characters.
|
|
103
|
+
* `apply` takes time linear in its ops for what the runtime sends, and
|
|
104
|
+
* stops a message that splices or walks the tree for longer than that.
|
|
105
|
+
*
|
|
106
|
+
* Nodes are replaced, never mutated, so `get(id)` works as a
|
|
107
|
+
* `useSyncExternalStore` snapshot.
|
|
108
|
+
*/
|
|
109
|
+
function createTreeStore() {
|
|
110
|
+
const nodes = /* @__PURE__ */ new Map([[0, rootNode()]]);
|
|
111
|
+
const parents = /* @__PURE__ */ new Map();
|
|
112
|
+
/** Children being rebuilt by the current `apply`, by parent. */
|
|
113
|
+
const drafts = /* @__PURE__ */ new Map();
|
|
114
|
+
/** Ops of the current `apply` that took linear time; see `MAX_SLOW_OPS`. */
|
|
115
|
+
let slow = 0;
|
|
116
|
+
/** Ancestors the current `apply` walked; see `MAX_WALK_STEPS`. */
|
|
117
|
+
let walked = 0;
|
|
118
|
+
/** Nodes the current `apply` created, oldest first. */
|
|
119
|
+
let created = [];
|
|
120
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
121
|
+
let schemas = {
|
|
122
|
+
components: {},
|
|
123
|
+
imageHosts: []
|
|
124
|
+
};
|
|
125
|
+
let issues = [];
|
|
126
|
+
const report = (issue) => {
|
|
127
|
+
if (issues.length < MAX_ISSUES && !issues.includes(issue)) issues = [...issues, issue];
|
|
128
|
+
};
|
|
129
|
+
const reportTooMany = () => {
|
|
130
|
+
report(`The program drew more than ${MAX_NODES} nodes; the rest were dropped`);
|
|
131
|
+
};
|
|
132
|
+
const schemaFor = (type) => Object.hasOwn(schemas.components, type) ? schemas.components[type] : void 0;
|
|
133
|
+
const checkedProps = (type, incoming, base) => {
|
|
134
|
+
const schema = schemaFor(type);
|
|
135
|
+
const props = { ...base };
|
|
136
|
+
if (schema === void 0) return props;
|
|
137
|
+
for (const [name, value] of Object.entries(incoming)) if (value === null) delete props[name];
|
|
138
|
+
else if (acceptsProp(schema, name, value, schemas.imageHosts)) props[name] = value;
|
|
139
|
+
else {
|
|
140
|
+
delete props[name];
|
|
141
|
+
report(`<${type}> dropped an invalid \`${name}\``);
|
|
142
|
+
}
|
|
143
|
+
return props;
|
|
144
|
+
};
|
|
145
|
+
const update = (id, change) => {
|
|
146
|
+
const node = nodes.get(id);
|
|
147
|
+
if (node !== void 0) nodes.set(id, change(node));
|
|
148
|
+
};
|
|
149
|
+
const draftOf = (parent) => {
|
|
150
|
+
let draft = drafts.get(parent);
|
|
151
|
+
if (draft === void 0) {
|
|
152
|
+
const items = [...nodes.get(parent)?.children ?? []];
|
|
153
|
+
draft = {
|
|
154
|
+
at: new Map(items.map((child, index) => [child, index])),
|
|
155
|
+
holes: 0,
|
|
156
|
+
items
|
|
157
|
+
};
|
|
158
|
+
drafts.set(parent, draft);
|
|
159
|
+
}
|
|
160
|
+
return draft;
|
|
161
|
+
};
|
|
162
|
+
const detach = (id) => {
|
|
163
|
+
const parent = parents.get(id);
|
|
164
|
+
if (parent === void 0) return;
|
|
165
|
+
const draft = draftOf(parent);
|
|
166
|
+
if (draft.at === null) slow++;
|
|
167
|
+
const slot = draft.at === null ? draft.items.indexOf(id) : draft.at.get(id) ?? -1;
|
|
168
|
+
if (slot !== -1) {
|
|
169
|
+
draft.items[slot] = HOLE;
|
|
170
|
+
draft.holes++;
|
|
171
|
+
draft.at?.delete(id);
|
|
172
|
+
}
|
|
173
|
+
parents.delete(id);
|
|
174
|
+
};
|
|
175
|
+
/**
|
|
176
|
+
* Writes each draft back as its parent's `children`. With `pruned`, some
|
|
177
|
+
* nodes were removed without leaving a hole, so children are kept only
|
|
178
|
+
* while they exist.
|
|
179
|
+
*/
|
|
180
|
+
const commitDrafts = (pruned) => {
|
|
181
|
+
for (const [parent, draft] of drafts) {
|
|
182
|
+
compact(draft);
|
|
183
|
+
const children = pruned ? draft.items.filter((child) => nodes.has(child)) : draft.items;
|
|
184
|
+
update(parent, (node) => ({
|
|
185
|
+
...node,
|
|
186
|
+
children
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
drafts.clear();
|
|
190
|
+
};
|
|
191
|
+
/** Whether `id` is `ancestor` or sits below it; placing there would create a cycle. */
|
|
192
|
+
const isWithin = (id, ancestor) => {
|
|
193
|
+
let current = id;
|
|
194
|
+
while (current !== void 0) {
|
|
195
|
+
if (current === ancestor) return true;
|
|
196
|
+
walked++;
|
|
197
|
+
current = parents.get(current);
|
|
198
|
+
}
|
|
199
|
+
return false;
|
|
200
|
+
};
|
|
201
|
+
const removeSubtree = (id) => {
|
|
202
|
+
const children = drafts.get(id)?.items ?? nodes.get(id)?.children ?? [];
|
|
203
|
+
drafts.delete(id);
|
|
204
|
+
for (const child of children) if (child !== HOLE) {
|
|
205
|
+
parents.delete(child);
|
|
206
|
+
removeSubtree(child);
|
|
207
|
+
}
|
|
208
|
+
nodes.delete(id);
|
|
209
|
+
};
|
|
210
|
+
/**
|
|
211
|
+
* Drops this message's newest creates, with what they hold, until the
|
|
212
|
+
* tree is back under `MAX_NODES`. Says whether it dropped any.
|
|
213
|
+
*/
|
|
214
|
+
const trimToCap = () => {
|
|
215
|
+
if (nodes.size <= 10001) return false;
|
|
216
|
+
reportTooMany();
|
|
217
|
+
for (let index = created.length - 1; index >= 0; index--) {
|
|
218
|
+
if (nodes.size <= 10001) break;
|
|
219
|
+
const id = created[index] ?? 0;
|
|
220
|
+
if (nodes.has(id)) {
|
|
221
|
+
parents.delete(id);
|
|
222
|
+
removeSubtree(id);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return true;
|
|
226
|
+
};
|
|
227
|
+
const applyOne = (op) => {
|
|
228
|
+
if (!isWellFormed(op)) {
|
|
229
|
+
report("The sandbox sent a malformed operation; it was dropped");
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (op.op === "create" || op.op === "createText") {
|
|
233
|
+
if (op.id === 0 || nodes.has(op.id)) {
|
|
234
|
+
report("The sandbox reused a node id; the operation was dropped");
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (nodes.size > MAX_DRAFT_NODES) {
|
|
238
|
+
reportTooMany();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
created.push(op.id);
|
|
242
|
+
}
|
|
243
|
+
switch (op.op) {
|
|
244
|
+
case "createText":
|
|
245
|
+
nodes.set(op.id, {
|
|
246
|
+
children: [],
|
|
247
|
+
id: op.id,
|
|
248
|
+
props: {},
|
|
249
|
+
text: String(op.text),
|
|
250
|
+
type: TEXT_TYPE
|
|
251
|
+
});
|
|
252
|
+
return;
|
|
253
|
+
case "create":
|
|
254
|
+
if (schemaFor(op.type) === void 0) {
|
|
255
|
+
report(`<${op.type}> is not a component this program may draw`);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
nodes.set(op.id, {
|
|
259
|
+
children: [],
|
|
260
|
+
id: op.id,
|
|
261
|
+
props: checkedProps(op.type, op.props, {}),
|
|
262
|
+
text: "",
|
|
263
|
+
type: op.type
|
|
264
|
+
});
|
|
265
|
+
return;
|
|
266
|
+
case "set":
|
|
267
|
+
update(op.id, (node) => node.type === ROOT_TYPE || node.type === TEXT_TYPE ? node : {
|
|
268
|
+
...node,
|
|
269
|
+
props: checkedProps(node.type, op.props, node.props)
|
|
270
|
+
});
|
|
271
|
+
return;
|
|
272
|
+
case "text":
|
|
273
|
+
update(op.id, (node) => node.type === TEXT_TYPE ? {
|
|
274
|
+
...node,
|
|
275
|
+
text: String(op.text)
|
|
276
|
+
} : node);
|
|
277
|
+
return;
|
|
278
|
+
case "place":
|
|
279
|
+
if (!nodes.has(op.id) || !nodes.has(op.parent) || op.id === 0 || isWithin(op.parent, op.id)) return;
|
|
280
|
+
detach(op.id);
|
|
281
|
+
if (!insert(draftOf(op.parent), op.id, op.index)) slow++;
|
|
282
|
+
parents.set(op.id, op.parent);
|
|
283
|
+
return;
|
|
284
|
+
case "remove":
|
|
285
|
+
if (op.id === 0) return;
|
|
286
|
+
detach(op.id);
|
|
287
|
+
removeSubtree(op.id);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
const notify = () => {
|
|
292
|
+
for (const listener of listeners) listener();
|
|
293
|
+
};
|
|
294
|
+
return {
|
|
295
|
+
apply: (ops) => {
|
|
296
|
+
if (!Array.isArray(ops)) return;
|
|
297
|
+
if (ops.length > 3e4) {
|
|
298
|
+
report(`The sandbox sent ${ops.length} operations at once, over the limit of ${MAX_OPS}; the update was dropped`);
|
|
299
|
+
notify();
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
slow = 0;
|
|
303
|
+
walked = 0;
|
|
304
|
+
created = [];
|
|
305
|
+
for (const op of ops) {
|
|
306
|
+
if (slow > MAX_SLOW_OPS || walked > MAX_WALK_STEPS) {
|
|
307
|
+
report("The sandbox reordered too many nodes at once; the rest of the update was dropped");
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
applyOne(op);
|
|
311
|
+
}
|
|
312
|
+
const pruned = trimToCap();
|
|
313
|
+
created = [];
|
|
314
|
+
commitDrafts(pruned);
|
|
315
|
+
notify();
|
|
316
|
+
},
|
|
317
|
+
get: (id) => nodes.get(id),
|
|
318
|
+
getIssues: () => issues,
|
|
319
|
+
reset: () => {
|
|
320
|
+
nodes.clear();
|
|
321
|
+
parents.clear();
|
|
322
|
+
drafts.clear();
|
|
323
|
+
nodes.set(0, rootNode());
|
|
324
|
+
issues = [];
|
|
325
|
+
notify();
|
|
326
|
+
},
|
|
327
|
+
setSchemas: (next) => {
|
|
328
|
+
schemas = next;
|
|
329
|
+
issues = [];
|
|
330
|
+
},
|
|
331
|
+
subscribe: (listener) => {
|
|
332
|
+
listeners.add(listener);
|
|
333
|
+
return () => {
|
|
334
|
+
listeners.delete(listener);
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
//#endregion
|
|
340
|
+
export { MAX_NODES, MAX_OPS, MAX_TEXT, createTreeStore };
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
//#region src/types.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Types the server and the browser share. The server builds these values;
|
|
4
|
+
* the browser receives them as JSON and checks them with `parse.ts`.
|
|
5
|
+
*/
|
|
6
|
+
/** The version of the program format. The runtime refuses any other. */
|
|
7
|
+
export declare const PROGRAM_FORMAT = 1;
|
|
8
|
+
export declare const pickleErrorCodes: readonly ["BAD_INPUT", "UNAUTHORIZED", "FORBIDDEN", "NOT_FOUND", "CONFLICT", "RATE_LIMITED", "INTERNAL", "UNKNOWN_PROCEDURE", "UNAVAILABLE", "NETWORK", "DECLINED"];
|
|
9
|
+
export type PickleErrorCode = (typeof pickleErrorCodes)[number];
|
|
10
|
+
/** A failure as the spec and the browser see it. */
|
|
11
|
+
export interface PickleErrorData {
|
|
12
|
+
code: PickleErrorCode;
|
|
13
|
+
message: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Where a diagnostic points. Every span has a line; one about something the
|
|
17
|
+
* compiler can place in the source also has its range, as offsets into the
|
|
18
|
+
* source the line counts (JavaScript string indices), and the column of its
|
|
19
|
+
* start.
|
|
20
|
+
*/
|
|
21
|
+
export interface Span {
|
|
22
|
+
/** 1-based, on `line`. */
|
|
23
|
+
column?: number;
|
|
24
|
+
/** Just past the range. */
|
|
25
|
+
end?: number;
|
|
26
|
+
line: number;
|
|
27
|
+
/** The range's first character. */
|
|
28
|
+
start?: number;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A rewrite that makes a diagnostic's problem go away: replace each edit's
|
|
32
|
+
* span with its text. The server attaches one only when there is a single
|
|
33
|
+
* way to write it, and applies it itself, so its diagnostic is `info`.
|
|
34
|
+
*/
|
|
35
|
+
export interface DiagnosticFix {
|
|
36
|
+
edits: Array<{
|
|
37
|
+
span: Span;
|
|
38
|
+
text: string;
|
|
39
|
+
}>;
|
|
40
|
+
title: string;
|
|
41
|
+
}
|
|
42
|
+
export interface Diagnostic {
|
|
43
|
+
code: string;
|
|
44
|
+
fix?: DiagnosticFix;
|
|
45
|
+
message: string;
|
|
46
|
+
/** `error` marks something the compiler had to change; the program still runs. */
|
|
47
|
+
severity: "error" | "hint" | "info" | "warning";
|
|
48
|
+
span: Span;
|
|
49
|
+
}
|
|
50
|
+
export interface NumberPropSchema {
|
|
51
|
+
kind: "number";
|
|
52
|
+
max?: number;
|
|
53
|
+
min?: number;
|
|
54
|
+
}
|
|
55
|
+
/** How the view checks one prop before a registry component sees it. */
|
|
56
|
+
export type PropSchema = {
|
|
57
|
+
kind: "boolean";
|
|
58
|
+
} | {
|
|
59
|
+
kind: "enum";
|
|
60
|
+
values: ReadonlyArray<string>;
|
|
61
|
+
} | {
|
|
62
|
+
kind: "handler";
|
|
63
|
+
} | {
|
|
64
|
+
kind: "image";
|
|
65
|
+
} | {
|
|
66
|
+
kind: "link";
|
|
67
|
+
} | {
|
|
68
|
+
kind: "list";
|
|
69
|
+
} | NumberPropSchema | {
|
|
70
|
+
kind: "string";
|
|
71
|
+
};
|
|
72
|
+
export type ChildrenKind = "block" | "inline" | "none";
|
|
73
|
+
export interface ComponentSchema {
|
|
74
|
+
children: ChildrenKind;
|
|
75
|
+
props: Readonly<Record<string, PropSchema>>;
|
|
76
|
+
}
|
|
77
|
+
/** `action` is a mutation the embedding page handles instead of the server. */
|
|
78
|
+
export type ProcedureKind = "action" | "mutation" | "query";
|
|
79
|
+
export interface ProcedureEntry {
|
|
80
|
+
invalidates: ReadonlyArray<string>;
|
|
81
|
+
kind: ProcedureKind;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* One query a mutation refreshed, as the server sends it with the mutation's
|
|
85
|
+
* data. Without `input`, every input of the query refetches. With it, only
|
|
86
|
+
* the queries whose input contains those values.
|
|
87
|
+
*/
|
|
88
|
+
export interface Invalidation {
|
|
89
|
+
input?: JsonValue;
|
|
90
|
+
name: string;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* A compiled spec. Opaque to apps: pass it from the server to the view and
|
|
94
|
+
* nowhere else. The view trusts its schemas and image hosts.
|
|
95
|
+
*/
|
|
96
|
+
export interface Program {
|
|
97
|
+
code: string;
|
|
98
|
+
/** Prop schemas of the components the program draws. */
|
|
99
|
+
components: Readonly<Record<string, ComponentSchema>>;
|
|
100
|
+
format: typeof PROGRAM_FORMAT;
|
|
101
|
+
imageHosts: ReadonlyArray<string>;
|
|
102
|
+
/** Procedures the program calls, with their kind and `invalidates` list. */
|
|
103
|
+
procedures: Readonly<Record<string, ProcedureEntry>>;
|
|
104
|
+
/** Text nodes pulled out of the code, so a prose edit swaps a table. */
|
|
105
|
+
prose: Readonly<Record<string, string>>;
|
|
106
|
+
}
|
|
107
|
+
export interface Message {
|
|
108
|
+
content: string;
|
|
109
|
+
role: "assistant" | "user";
|
|
110
|
+
}
|
|
111
|
+
export type SessionEvent = {
|
|
112
|
+
diagnostics: ReadonlyArray<Diagnostic>;
|
|
113
|
+
/**
|
|
114
|
+
* True on the first program of a new UI: the model wrote a whole
|
|
115
|
+
* document rather than editing the last one, or a stored one was
|
|
116
|
+
* restored. The view starts it from fresh state.
|
|
117
|
+
*/
|
|
118
|
+
fresh: boolean;
|
|
119
|
+
program: Program;
|
|
120
|
+
revision: number;
|
|
121
|
+
type: "program";
|
|
122
|
+
} | {
|
|
123
|
+
diagnostics: ReadonlyArray<Diagnostic>;
|
|
124
|
+
revision: number;
|
|
125
|
+
source: string;
|
|
126
|
+
type: "done";
|
|
127
|
+
} | {
|
|
128
|
+
error: PickleErrorData;
|
|
129
|
+
type: "error";
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* Where a chat block is: still arriving, being repaired after it closed with
|
|
133
|
+
* errors, or final.
|
|
134
|
+
*/
|
|
135
|
+
export type ChatBlockStatus = "done" | "repairing" | "streaming";
|
|
136
|
+
/**
|
|
137
|
+
* A pickle in a chat reply: one fenced `pickle` block, compiled on its own.
|
|
138
|
+
* The server sends it again whenever its program changes, under the same
|
|
139
|
+
* `key`.
|
|
140
|
+
*/
|
|
141
|
+
export interface ChatBlock {
|
|
142
|
+
/** Lines refer to `source`, which starts with its pragma line. */
|
|
143
|
+
diagnostics: ReadonlyArray<Diagnostic>;
|
|
144
|
+
/** Set when the block could not be built, e.g. an edit to a missing UI. */
|
|
145
|
+
error?: PickleErrorData;
|
|
146
|
+
/** The id the model gave the block, e.g. "monthly-spending". */
|
|
147
|
+
id: string;
|
|
148
|
+
/** `${id}@${version}`: unique in a conversation. */
|
|
149
|
+
key: string;
|
|
150
|
+
program: Program;
|
|
151
|
+
/** The checked source, with its pragma. Store this, not the program. */
|
|
152
|
+
source: string;
|
|
153
|
+
status: ChatBlockStatus;
|
|
154
|
+
/** 1 for the first block with this id in the conversation, then 2, 3… */
|
|
155
|
+
version: number;
|
|
156
|
+
}
|
|
157
|
+
/** A value that travels as JSON. */
|
|
158
|
+
export type JsonValue = boolean | null | number | string | ReadonlyArray<JsonValue> | {
|
|
159
|
+
readonly [key: string]: JsonValue;
|
|
160
|
+
};
|
|
161
|
+
/** App options sent with a turn; the server parses them with its `options` schema. */
|
|
162
|
+
export type SessionOptions = Readonly<Record<string, JsonValue>>;
|
|
163
|
+
/** What a session request carries. */
|
|
164
|
+
export interface SessionBody {
|
|
165
|
+
messages: ReadonlyArray<Message>;
|
|
166
|
+
options?: SessionOptions;
|
|
167
|
+
}
|
|
168
|
+
//#endregion
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//#region src/types.ts
|
|
2
|
+
/**
|
|
3
|
+
* Types the server and the browser share. The server builds these values;
|
|
4
|
+
* the browser receives them as JSON and checks them with `parse.ts`.
|
|
5
|
+
*/
|
|
6
|
+
/** The version of the program format. The runtime refuses any other. */
|
|
7
|
+
const PROGRAM_FORMAT = 1;
|
|
8
|
+
const pickleErrorCodes = [
|
|
9
|
+
"BAD_INPUT",
|
|
10
|
+
"UNAUTHORIZED",
|
|
11
|
+
"FORBIDDEN",
|
|
12
|
+
"NOT_FOUND",
|
|
13
|
+
"CONFLICT",
|
|
14
|
+
"RATE_LIMITED",
|
|
15
|
+
"INTERNAL",
|
|
16
|
+
"UNKNOWN_PROCEDURE",
|
|
17
|
+
"UNAVAILABLE",
|
|
18
|
+
"NETWORK",
|
|
19
|
+
"DECLINED"
|
|
20
|
+
];
|
|
21
|
+
//#endregion
|
|
22
|
+
export { PROGRAM_FORMAT, pickleErrorCodes };
|
package/dist/urls.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//#region src/urls.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* A hostname, or `*.example.com` for its subdomains (not the apex),
|
|
4
|
+
* optionally followed by a path prefix that ends in `/`, such as
|
|
5
|
+
* `images.example.com/products/`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function isImageHostEntry(entry: string): boolean;
|
|
8
|
+
/** The entry with its host lowercased; the path keeps its case. */
|
|
9
|
+
export declare function normalizeImageHost(entry: string): string;
|
|
10
|
+
/** An `https` URL with no credentials or port, on one of `hosts`. */
|
|
11
|
+
export declare function isAllowedImage(src: string, hosts: ReadonlyArray<string>): boolean;
|
|
12
|
+
/** An absolute `http` or `https` URL. Anything else, such as `javascript:`, is refused. */
|
|
13
|
+
export declare function isAllowedLink(href: string): boolean;
|
|
14
|
+
//#endregion
|
package/dist/urls.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
//#region src/urls.ts
|
|
2
|
+
const HOST = /^(\*\.)?[a-z0-9-]+(\.[a-z0-9-]+)*$/;
|
|
3
|
+
const PATH_PREFIX = /^\/(?:[A-Za-z0-9._~!$&'()*+,;=:@%/-]*\/)?$/;
|
|
4
|
+
/** An image host entry split into its host and optional path prefix. */
|
|
5
|
+
function splitEntry(entry) {
|
|
6
|
+
const slash = entry.indexOf("/");
|
|
7
|
+
return slash === -1 ? {
|
|
8
|
+
host: entry.toLowerCase(),
|
|
9
|
+
path: "/"
|
|
10
|
+
} : {
|
|
11
|
+
host: entry.slice(0, slash).toLowerCase(),
|
|
12
|
+
path: entry.slice(slash)
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A hostname, or `*.example.com` for its subdomains (not the apex),
|
|
17
|
+
* optionally followed by a path prefix that ends in `/`, such as
|
|
18
|
+
* `images.example.com/products/`.
|
|
19
|
+
*/
|
|
20
|
+
function isImageHostEntry(entry) {
|
|
21
|
+
const parts = splitEntry(entry);
|
|
22
|
+
return HOST.test(parts.host) && PATH_PREFIX.test(parts.path);
|
|
23
|
+
}
|
|
24
|
+
/** The entry with its host lowercased; the path keeps its case. */
|
|
25
|
+
function normalizeImageHost(entry) {
|
|
26
|
+
const parts = splitEntry(entry);
|
|
27
|
+
return entry.includes("/") ? `${parts.host}${parts.path}` : parts.host;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* An encoded `/`, `\`, `.` or `%`: a server that decodes it after matching can
|
|
31
|
+
* be walked out of the prefix with `..%2F`, or with `..%252F` if it decodes
|
|
32
|
+
* twice, and the URL parser leaves it alone. So a prefix can't serve object
|
|
33
|
+
* keys that hold an encoded `/`, such as Firebase Storage's `o/a%2Fb.jpg`.
|
|
34
|
+
*/
|
|
35
|
+
const ENCODED_SEPARATOR = /%(?:2f|5c|2e|25)/i;
|
|
36
|
+
function matchesHost(url, entry) {
|
|
37
|
+
if (!isImageHostEntry(entry)) return false;
|
|
38
|
+
const parts = splitEntry(entry);
|
|
39
|
+
return (parts.host.startsWith("*.") ? url.hostname.endsWith(parts.host.slice(1)) : url.hostname === parts.host) && url.pathname.startsWith(parts.path) && (parts.path === "/" || !ENCODED_SEPARATOR.test(url.pathname));
|
|
40
|
+
}
|
|
41
|
+
function parseUrl(text) {
|
|
42
|
+
try {
|
|
43
|
+
return new URL(text);
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** An `https` URL with no credentials or port, on one of `hosts`. */
|
|
49
|
+
function isAllowedImage(src, hosts) {
|
|
50
|
+
const url = parseUrl(src);
|
|
51
|
+
return url !== null && url.protocol === "https:" && url.username === "" && url.password === "" && url.port === "" && hosts.some((entry) => matchesHost(url, entry));
|
|
52
|
+
}
|
|
53
|
+
/** An absolute `http` or `https` URL. Anything else, such as `javascript:`, is refused. */
|
|
54
|
+
function isAllowedLink(href) {
|
|
55
|
+
const url = parseUrl(href);
|
|
56
|
+
return url !== null && (url.protocol === "https:" || url.protocol === "http:") && url.username === "" && url.password === "";
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
export { isAllowedImage, isAllowedLink, isImageHostEntry, normalizeImageHost };
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { isAllowedImage, isAllowedLink } from "./urls.js";
|
|
2
|
+
//#region src/validate.ts
|
|
3
|
+
const MAX_ROWS = 500;
|
|
4
|
+
const MAX_ROW_KEYS = 20;
|
|
5
|
+
const MAX_CELL = 500;
|
|
6
|
+
const MAX_STRING = 1e4;
|
|
7
|
+
const isCell = (value) => value === null || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value) || typeof value === "string" && value.length <= MAX_CELL;
|
|
8
|
+
function isRows(value) {
|
|
9
|
+
return Array.isArray(value) && value.length <= MAX_ROWS && value.every((row) => typeof row === "object" && row !== null && !Array.isArray(row) && Object.getPrototypeOf(row) === Object.prototype && Object.keys(row).length <= MAX_ROW_KEYS && Object.values(row).every((cell) => isCell(cell)));
|
|
10
|
+
}
|
|
11
|
+
function isFnRef(value) {
|
|
12
|
+
return typeof value === "object" && value !== null && "fn" in value && typeof value.fn === "string";
|
|
13
|
+
}
|
|
14
|
+
/** Whether one wire value passes its prop schema. */
|
|
15
|
+
function acceptsValue(schema, value, imageHosts) {
|
|
16
|
+
switch (schema.kind) {
|
|
17
|
+
case "boolean": return typeof value === "boolean";
|
|
18
|
+
case "enum": return typeof value === "string" && schema.values.includes(value);
|
|
19
|
+
case "handler": return isFnRef(value);
|
|
20
|
+
case "image": return typeof value === "string" && isAllowedImage(value, imageHosts);
|
|
21
|
+
case "link": return typeof value === "string" && isAllowedLink(value);
|
|
22
|
+
case "list": return typeof value === "object" && value !== null && "rows" in value && isRows(value.rows);
|
|
23
|
+
case "number": return typeof value === "number" && Number.isFinite(value) && (schema.min === void 0 || value >= schema.min) && (schema.max === void 0 || value <= schema.max);
|
|
24
|
+
case "string": return typeof value === "string" && value.length <= MAX_STRING;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Whether a prop named `name` with `value` may reach a component drawn with `schema`. */
|
|
28
|
+
function acceptsProp(schema, name, value, imageHosts) {
|
|
29
|
+
const prop = Object.hasOwn(schema.props, name) ? schema.props[name] : void 0;
|
|
30
|
+
return prop !== void 0 && acceptsValue(prop, value, imageHosts);
|
|
31
|
+
}
|
|
32
|
+
//#endregion
|
|
33
|
+
export { acceptsProp };
|