@moloodev/editor-sdk 0.0.0-stage → 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/package.json +62 -3
- package/src/channel.ts +287 -0
- package/src/chrome-manifest.ts +111 -0
- package/src/commands/clipboard/router.ts +99 -0
- package/src/commands/clipboard/types.ts +20 -0
- package/src/commands/history/stack.ts +53 -0
- package/src/commands/icons/catalog.ts +172 -0
- package/src/commands/index.ts +100 -0
- package/src/commands/keys/display.ts +16 -0
- package/src/commands/keys/normalize.ts +55 -0
- package/src/commands/keys/reserved.ts +62 -0
- package/src/commands/keys/validate.ts +77 -0
- package/src/commands/manifest/configuration.ts +59 -0
- package/src/commands/manifest/contribution-manifest-schema.ts +56 -0
- package/src/commands/manifest/editor-document.ts +501 -0
- package/src/commands/manifest/ids.ts +2 -0
- package/src/commands/manifest/merge.ts +215 -0
- package/src/commands/manifest/types.ts +113 -0
- package/src/commands/manifest/validate-manifest.ts +45 -0
- package/src/commands/react/context.tsx +249 -0
- package/src/commands/react/hotkey-defaults.ts +11 -0
- package/src/commands/react/scope-context.tsx +31 -0
- package/src/commands/react/scope-provider.tsx +123 -0
- package/src/commands/react/store.ts +182 -0
- package/src/commands/registry/create.ts +55 -0
- package/src/commands/registry/types.ts +12 -0
- package/src/commands/when/evaluate.ts +80 -0
- package/src/commands/when/index.ts +19 -0
- package/src/commands/when/parse.ts +276 -0
- package/src/commands/when/types.ts +24 -0
- package/src/commands/when/validate.ts +20 -0
- package/src/crdt/awareness.ts +29 -0
- package/src/crdt/bytes.ts +26 -0
- package/src/crdt/connect.ts +285 -0
- package/src/crdt/diff.ts +86 -0
- package/src/crdt/doc.ts +67 -0
- package/src/crdt/front-matter.ts +45 -0
- package/src/crdt/index.ts +76 -0
- package/src/crdt/persist.ts +40 -0
- package/src/crdt/protocol.ts +119 -0
- package/src/crdt/shared.ts +104 -0
- package/src/crdt/text-body.ts +125 -0
- package/src/crdt/undo.ts +59 -0
- package/src/crdt/vite-plugin.ts +42 -0
- package/src/crdt/yjs.ts +16 -0
- package/src/editor-common.ts +160 -0
- package/src/editor-id.ts +56 -0
- package/src/frame-attributes.ts +9 -0
- package/src/index.ts +229 -0
- package/src/json-schema.ts +13 -0
- package/src/layout/bind-toolbar.ts +60 -0
- package/src/layout/catalog.ts +82 -0
- package/src/layout/chrome/command-affordances.ts +47 -0
- package/src/layout/chrome/command-layout-state.ts +42 -0
- package/src/layout/chrome/editor-menu.tsx +358 -0
- package/src/layout/chrome/editor-shortcut-keys.tsx +27 -0
- package/src/layout/chrome/menu-styles.ts +3 -0
- package/src/layout/chrome/mid-menu-nodes.ts +27 -0
- package/src/layout/chrome/use-editor-command-affordances.ts +33 -0
- package/src/layout/chrome/visible-menu-leaves.ts +15 -0
- package/src/layout/content-error-notice.tsx +31 -0
- package/src/layout/define-editor-layout.ts +60 -0
- package/src/layout/define-editor-layout.types.ts +57 -0
- package/src/layout/doc-undo-bridge.tsx +22 -0
- package/src/layout/dock-zones.tsx +61 -0
- package/src/layout/drop.ts +39 -0
- package/src/layout/editor-layout.tsx +287 -0
- package/src/layout/editor-loading.tsx +23 -0
- package/src/layout/frame-layout-binding.tsx +74 -0
- package/src/layout/frame.ts +23 -0
- package/src/layout/hide.ts +103 -0
- package/src/layout/index.ts +14 -0
- package/src/layout/layout-binding.tsx +42 -0
- package/src/layout/layout-storage-key.ts +6 -0
- package/src/layout/layout-values.ts +48 -0
- package/src/layout/mutate.ts +486 -0
- package/src/layout/nesting-context.tsx +20 -0
- package/src/layout/panel-header-action.tsx +29 -0
- package/src/layout/panel-header.tsx +168 -0
- package/src/layout/panel-hide-menu.tsx +6 -0
- package/src/layout/panel-menu.tsx +85 -0
- package/src/layout/panel-section.tsx +114 -0
- package/src/layout/panel-size.ts +94 -0
- package/src/layout/panel-title.tsx +5 -0
- package/src/layout/registry.tsx +333 -0
- package/src/layout/restore-panel.ts +26 -0
- package/src/layout/slots-context.tsx +189 -0
- package/src/layout/tab-button.tsx +60 -0
- package/src/layout/toolbar-scroll.ts +22 -0
- package/src/layout/toolbar.tsx +168 -0
- package/src/layout/types.ts +41 -0
- package/src/layout/use-collapsed-sections.ts +50 -0
- package/src/layout/validate-layout-spec.ts +143 -0
- package/src/message-samples.ts +377 -0
- package/src/origin-headers.ts +57 -0
- package/src/print/index.ts +10 -0
- package/src/print/inspect.ts +16 -0
- package/src/print/schema.ts +122 -0
- package/src/protocol-entry.ts +19 -0
- package/src/protocol.ts +471 -0
- package/src/render-result.ts +25 -0
- package/src/view/editor-chrome.ts +28 -0
- package/src/view/hooks.ts +260 -0
- package/src/view/mount-editor-view.ts +36 -0
- package/src/view/port-binding.ts +127 -0
- package/src/view/port-commands.ts +36 -0
- package/src/view/port.ts +35 -0
- package/src/view/theme-class.ts +11 -0
- package/src/view.ts +313 -0
- package/README.md +0 -3
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { WhenParseError, type WhenExpr } from "./types";
|
|
2
|
+
|
|
3
|
+
type Token =
|
|
4
|
+
| { kind: "ident"; value: string; offset: number }
|
|
5
|
+
| { kind: "number"; value: number; offset: number }
|
|
6
|
+
| { kind: "string"; value: string; offset: number }
|
|
7
|
+
| { kind: "true"; offset: number }
|
|
8
|
+
| { kind: "false"; offset: number }
|
|
9
|
+
| { kind: "&&"; offset: number }
|
|
10
|
+
| { kind: "||"; offset: number }
|
|
11
|
+
| { kind: "=="; offset: number }
|
|
12
|
+
| { kind: "!="; offset: number }
|
|
13
|
+
| { kind: "<="; offset: number }
|
|
14
|
+
| { kind: ">="; offset: number }
|
|
15
|
+
| { kind: "<"; offset: number }
|
|
16
|
+
| { kind: ">"; offset: number }
|
|
17
|
+
| { kind: "!"; offset: number }
|
|
18
|
+
| { kind: "("; offset: number }
|
|
19
|
+
| { kind: ")"; offset: number };
|
|
20
|
+
|
|
21
|
+
function isIdentStart(ch: string): boolean {
|
|
22
|
+
return /[A-Za-z_]/.test(ch);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isIdentPart(ch: string): boolean {
|
|
26
|
+
return /[A-Za-z0-9_]/.test(ch);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function tokenize(source: string): Token[] {
|
|
30
|
+
const tokens: Token[] = [];
|
|
31
|
+
let i = 0;
|
|
32
|
+
|
|
33
|
+
while (i < source.length) {
|
|
34
|
+
const ch = source[i]!;
|
|
35
|
+
if (/\s/.test(ch)) {
|
|
36
|
+
i += 1;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (source.startsWith("&&", i)) {
|
|
41
|
+
tokens.push({ kind: "&&", offset: i });
|
|
42
|
+
i += 2;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (source.startsWith("||", i)) {
|
|
46
|
+
tokens.push({ kind: "||", offset: i });
|
|
47
|
+
i += 2;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (source.startsWith("==", i)) {
|
|
51
|
+
tokens.push({ kind: "==", offset: i });
|
|
52
|
+
i += 2;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (source.startsWith("!=", i)) {
|
|
56
|
+
tokens.push({ kind: "!=", offset: i });
|
|
57
|
+
i += 2;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (source.startsWith("<=", i)) {
|
|
61
|
+
tokens.push({ kind: "<=", offset: i });
|
|
62
|
+
i += 2;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (source.startsWith(">=", i)) {
|
|
66
|
+
tokens.push({ kind: ">=", offset: i });
|
|
67
|
+
i += 2;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (ch === "<") {
|
|
71
|
+
tokens.push({ kind: "<", offset: i });
|
|
72
|
+
i += 1;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (ch === ">") {
|
|
76
|
+
tokens.push({ kind: ">", offset: i });
|
|
77
|
+
i += 1;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (ch === "!") {
|
|
81
|
+
tokens.push({ kind: "!", offset: i });
|
|
82
|
+
i += 1;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (ch === "(") {
|
|
86
|
+
tokens.push({ kind: "(", offset: i });
|
|
87
|
+
i += 1;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (ch === ")") {
|
|
91
|
+
tokens.push({ kind: ")", offset: i });
|
|
92
|
+
i += 1;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (ch === "'" || ch === '"') {
|
|
97
|
+
const quote = ch;
|
|
98
|
+
const start = i;
|
|
99
|
+
i += 1;
|
|
100
|
+
let value = "";
|
|
101
|
+
while (i < source.length && source[i] !== quote) {
|
|
102
|
+
if (source[i] === "\\") {
|
|
103
|
+
i += 1;
|
|
104
|
+
if (i >= source.length) {
|
|
105
|
+
throw new WhenParseError("Unterminated string escape", start);
|
|
106
|
+
}
|
|
107
|
+
value += source[i]!;
|
|
108
|
+
i += 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
value += source[i]!;
|
|
112
|
+
i += 1;
|
|
113
|
+
}
|
|
114
|
+
if (i >= source.length) {
|
|
115
|
+
throw new WhenParseError("Unterminated string", start);
|
|
116
|
+
}
|
|
117
|
+
i += 1;
|
|
118
|
+
tokens.push({ kind: "string", value, offset: start });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (/[0-9]/.test(ch) || (ch === "-" && /[0-9]/.test(source[i + 1] ?? ""))) {
|
|
123
|
+
const start = i;
|
|
124
|
+
let raw = "";
|
|
125
|
+
if (ch === "-") {
|
|
126
|
+
raw += ch;
|
|
127
|
+
i += 1;
|
|
128
|
+
}
|
|
129
|
+
while (i < source.length && /[0-9.]/.test(source[i]!)) {
|
|
130
|
+
raw += source[i]!;
|
|
131
|
+
i += 1;
|
|
132
|
+
}
|
|
133
|
+
const value = Number(raw);
|
|
134
|
+
if (!Number.isFinite(value)) {
|
|
135
|
+
throw new WhenParseError(`Invalid number "${raw}"`, start);
|
|
136
|
+
}
|
|
137
|
+
tokens.push({ kind: "number", value, offset: start });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (isIdentStart(ch)) {
|
|
142
|
+
const start = i;
|
|
143
|
+
let value = "";
|
|
144
|
+
while (i < source.length && isIdentPart(source[i]!)) {
|
|
145
|
+
value += source[i]!;
|
|
146
|
+
i += 1;
|
|
147
|
+
}
|
|
148
|
+
if (value === "true") {
|
|
149
|
+
tokens.push({ kind: "true", offset: start });
|
|
150
|
+
} else if (value === "false") {
|
|
151
|
+
tokens.push({ kind: "false", offset: start });
|
|
152
|
+
} else {
|
|
153
|
+
tokens.push({ kind: "ident", value, offset: start });
|
|
154
|
+
}
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
throw new WhenParseError(`Unexpected character "${ch}"`, i);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return tokens;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
class Parser {
|
|
165
|
+
private index = 0;
|
|
166
|
+
|
|
167
|
+
constructor(private readonly tokens: Token[]) {}
|
|
168
|
+
|
|
169
|
+
parse(): WhenExpr {
|
|
170
|
+
const expr = this.parseOr();
|
|
171
|
+
if (this.index < this.tokens.length) {
|
|
172
|
+
const token = this.tokens[this.index]!;
|
|
173
|
+
throw new WhenParseError("Unexpected token after expression", token.offset);
|
|
174
|
+
}
|
|
175
|
+
return expr;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private peek(): Token | undefined {
|
|
179
|
+
return this.tokens[this.index];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private advance(): Token {
|
|
183
|
+
const token = this.tokens[this.index];
|
|
184
|
+
if (!token) {
|
|
185
|
+
throw new WhenParseError("Unexpected end of expression", 0);
|
|
186
|
+
}
|
|
187
|
+
this.index += 1;
|
|
188
|
+
return token;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private parseOr(): WhenExpr {
|
|
192
|
+
let left = this.parseAnd();
|
|
193
|
+
while (this.peek()?.kind === "||") {
|
|
194
|
+
this.advance();
|
|
195
|
+
const right = this.parseAnd();
|
|
196
|
+
left = { kind: "binary", op: "||", left, right };
|
|
197
|
+
}
|
|
198
|
+
return left;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private parseAnd(): WhenExpr {
|
|
202
|
+
let left = this.parseCompare();
|
|
203
|
+
while (this.peek()?.kind === "&&") {
|
|
204
|
+
this.advance();
|
|
205
|
+
const right = this.parseCompare();
|
|
206
|
+
left = { kind: "binary", op: "&&", left, right };
|
|
207
|
+
}
|
|
208
|
+
return left;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private parseCompare(): WhenExpr {
|
|
212
|
+
const left = this.parseUnary();
|
|
213
|
+
const peek = this.peek();
|
|
214
|
+
if (
|
|
215
|
+
peek &&
|
|
216
|
+
(peek.kind === "==" ||
|
|
217
|
+
peek.kind === "!=" ||
|
|
218
|
+
peek.kind === "<" ||
|
|
219
|
+
peek.kind === "<=" ||
|
|
220
|
+
peek.kind === ">" ||
|
|
221
|
+
peek.kind === ">=")
|
|
222
|
+
) {
|
|
223
|
+
const op = this.advance().kind as "==" | "!=" | "<" | "<=" | ">" | ">=";
|
|
224
|
+
const right = this.parseUnary();
|
|
225
|
+
return { kind: "binary", op, left, right };
|
|
226
|
+
}
|
|
227
|
+
return left;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private parseUnary(): WhenExpr {
|
|
231
|
+
if (this.peek()?.kind === "!") {
|
|
232
|
+
this.advance();
|
|
233
|
+
return { kind: "unary", op: "!", arg: this.parseUnary() };
|
|
234
|
+
}
|
|
235
|
+
return this.parsePrimary();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private parsePrimary(): WhenExpr {
|
|
239
|
+
const token = this.advance();
|
|
240
|
+
switch (token.kind) {
|
|
241
|
+
case "true":
|
|
242
|
+
return { kind: "literal", value: true };
|
|
243
|
+
case "false":
|
|
244
|
+
return { kind: "literal", value: false };
|
|
245
|
+
case "number":
|
|
246
|
+
return { kind: "literal", value: token.value };
|
|
247
|
+
case "string":
|
|
248
|
+
return { kind: "literal", value: token.value };
|
|
249
|
+
case "ident":
|
|
250
|
+
return { kind: "key", name: token.value };
|
|
251
|
+
case "(": {
|
|
252
|
+
const expr = this.parseOr();
|
|
253
|
+
const close = this.advance();
|
|
254
|
+
if (close.kind !== ")") {
|
|
255
|
+
throw new WhenParseError("Expected closing parenthesis", close.offset);
|
|
256
|
+
}
|
|
257
|
+
return expr;
|
|
258
|
+
}
|
|
259
|
+
default:
|
|
260
|
+
throw new WhenParseError("Expected expression", token.offset);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Parse a when-clause into an AST. Throws WhenParseError on failure. */
|
|
266
|
+
export function parseWhen(source: string): WhenExpr {
|
|
267
|
+
const trimmed = source.trim();
|
|
268
|
+
if (!trimmed) {
|
|
269
|
+
throw new WhenParseError("Empty when clause", 0);
|
|
270
|
+
}
|
|
271
|
+
const tokens = tokenize(trimmed);
|
|
272
|
+
if (tokens.length === 0) {
|
|
273
|
+
throw new WhenParseError("Empty when clause", 0);
|
|
274
|
+
}
|
|
275
|
+
return new Parser(tokens).parse();
|
|
276
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type ContextValue = string | number | boolean | undefined;
|
|
2
|
+
|
|
3
|
+
export type ContextKeys = Readonly<Record<string, ContextValue>>;
|
|
4
|
+
|
|
5
|
+
export type WhenExpr =
|
|
6
|
+
| { kind: "literal"; value: boolean | number | string }
|
|
7
|
+
| { kind: "key"; name: string }
|
|
8
|
+
| { kind: "unary"; op: "!"; arg: WhenExpr }
|
|
9
|
+
| {
|
|
10
|
+
kind: "binary";
|
|
11
|
+
op: "&&" | "||" | "==" | "!=" | "<" | "<=" | ">" | ">=";
|
|
12
|
+
left: WhenExpr;
|
|
13
|
+
right: WhenExpr;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export class WhenParseError extends Error {
|
|
17
|
+
readonly offset: number;
|
|
18
|
+
|
|
19
|
+
constructor(message: string, offset: number) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "WhenParseError";
|
|
22
|
+
this.offset = offset;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { parseWhen } from "./parse";
|
|
2
|
+
import { WhenParseError } from "./types";
|
|
3
|
+
|
|
4
|
+
export type WhenValidationIssue = {
|
|
5
|
+
message: string;
|
|
6
|
+
offset: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/** Returns parse issues for a when clause, or an empty array if valid. */
|
|
10
|
+
export function validateWhenClause(source: string): WhenValidationIssue[] {
|
|
11
|
+
try {
|
|
12
|
+
parseWhen(source);
|
|
13
|
+
return [];
|
|
14
|
+
} catch (err) {
|
|
15
|
+
if (err instanceof WhenParseError) {
|
|
16
|
+
return [{ message: err.message, offset: err.offset }];
|
|
17
|
+
}
|
|
18
|
+
return [{ message: String(err), offset: 0 }];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Awareness } from "./yjs";
|
|
2
|
+
import type { Doc } from "./doc";
|
|
3
|
+
|
|
4
|
+
export type Presence = Awareness;
|
|
5
|
+
|
|
6
|
+
export function createAwareness(doc: Doc): Presence {
|
|
7
|
+
return new Awareness(doc);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function setLocalPresence(
|
|
11
|
+
presence: Presence,
|
|
12
|
+
state: Record<string, unknown>,
|
|
13
|
+
): void {
|
|
14
|
+
presence.setLocalState(state);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function localPresence(
|
|
18
|
+
presence: Presence,
|
|
19
|
+
): Record<string, unknown> | null {
|
|
20
|
+
return presence.getLocalState();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function states(presence: Presence): Map<number, Record<string, unknown>> {
|
|
24
|
+
return presence.getStates() as Map<number, Record<string, unknown>>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function destroyPresence(presence: Presence): void {
|
|
28
|
+
presence.destroy();
|
|
29
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
function toBinary(bytes: Uint8Array): string {
|
|
2
|
+
let binary = "";
|
|
3
|
+
const chunk = 0x8000;
|
|
4
|
+
for (let i = 0; i < bytes.byteLength; i += chunk) {
|
|
5
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
|
6
|
+
}
|
|
7
|
+
return binary;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Browser-safe. Do not use Node `Buffer` — the workspace opens this in the client. */
|
|
11
|
+
export function toBase64(bytes: Uint8Array): string {
|
|
12
|
+
const view =
|
|
13
|
+
bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength
|
|
14
|
+
? bytes
|
|
15
|
+
: bytes.slice();
|
|
16
|
+
return btoa(toBinary(view));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function fromBase64(value: string): Uint8Array {
|
|
20
|
+
const binary = atob(value);
|
|
21
|
+
const out = new Uint8Array(binary.length);
|
|
22
|
+
for (let i = 0; i < binary.length; i++) {
|
|
23
|
+
out[i] = binary.charCodeAt(i);
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
import {
|
|
3
|
+
applyAwarenessMessage,
|
|
4
|
+
encodeAwarenessUpdate,
|
|
5
|
+
encodeSyncUpdate,
|
|
6
|
+
syncOpenMessages,
|
|
7
|
+
peekType,
|
|
8
|
+
readSync,
|
|
9
|
+
MSG_AWARENESS,
|
|
10
|
+
MSG_SYNC,
|
|
11
|
+
} from "./protocol";
|
|
12
|
+
import { REMOTE_ORIGIN, createDoc, applyUpdate, encodeState, destroyDoc, type Doc } from "./doc";
|
|
13
|
+
import type { Presence } from "./awareness";
|
|
14
|
+
|
|
15
|
+
export type SocketHandle = {
|
|
16
|
+
close: () => void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Minimal constructor so tests can substitute the browser `WebSocket`. */
|
|
20
|
+
export type SocketFactory = (url: string) => WebSocket;
|
|
21
|
+
|
|
22
|
+
const RECONNECT_MIN_MS = 1000;
|
|
23
|
+
const RECONNECT_MAX_MS = 30_000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Application close codes (4xxx: bad query, unauthorized, not found, epoch
|
|
27
|
+
* mismatch), malformed input, and oversized frames end the session. Everything else — the server going away when a
|
|
28
|
+
* Vercel Function hits its duration cap, network drops, 1011 — retries.
|
|
29
|
+
*/
|
|
30
|
+
export function shouldReconnect(code: number): boolean {
|
|
31
|
+
return code < 4000 && ![1002, 1007, 1008, 1009].includes(code);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function nextReconnectDelay(previous: number): number {
|
|
35
|
+
return Math.min(previous * 2, RECONNECT_MAX_MS);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function withJitter(delay: number): number {
|
|
39
|
+
return delay + Math.random() * 250;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type ReconnectingSocket = {
|
|
43
|
+
readonly current: WebSocket | null;
|
|
44
|
+
close(): void;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Keeps one live socket to `url`, reopening with exponential backoff after
|
|
49
|
+
* non-application closes (and immediately when the browser comes back
|
|
50
|
+
* online). `onOpen` runs for every (re)connection so callers resend state.
|
|
51
|
+
*/
|
|
52
|
+
function reconnecting(
|
|
53
|
+
url: string,
|
|
54
|
+
handlers: {
|
|
55
|
+
onOpen: (socket: WebSocket) => void;
|
|
56
|
+
onMessage: (event: MessageEvent) => void;
|
|
57
|
+
onStatus?: (status: SyncStatus) => void;
|
|
58
|
+
createSocket?: SocketFactory;
|
|
59
|
+
},
|
|
60
|
+
): ReconnectingSocket {
|
|
61
|
+
const createSocket = handlers.createSocket ?? ((target) => new WebSocket(target));
|
|
62
|
+
let socket: WebSocket | null = null;
|
|
63
|
+
let closed = false;
|
|
64
|
+
let delay = RECONNECT_MIN_MS;
|
|
65
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
66
|
+
|
|
67
|
+
const online = () => {
|
|
68
|
+
if (closed || socket || !timer) return;
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
timer = null;
|
|
71
|
+
connect();
|
|
72
|
+
};
|
|
73
|
+
if (typeof window !== "undefined") {
|
|
74
|
+
window.addEventListener("online", online);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function connect() {
|
|
78
|
+
const next = createSocket(url);
|
|
79
|
+
next.binaryType = "arraybuffer";
|
|
80
|
+
socket = next;
|
|
81
|
+
next.addEventListener("open", () => {
|
|
82
|
+
delay = RECONNECT_MIN_MS;
|
|
83
|
+
handlers.onStatus?.("live");
|
|
84
|
+
handlers.onOpen(next);
|
|
85
|
+
});
|
|
86
|
+
next.addEventListener("message", handlers.onMessage);
|
|
87
|
+
next.addEventListener("error", () => {
|
|
88
|
+
handlers.onStatus?.("error");
|
|
89
|
+
});
|
|
90
|
+
next.addEventListener("close", (event) => {
|
|
91
|
+
if (socket === next) socket = null;
|
|
92
|
+
handlers.onStatus?.("offline");
|
|
93
|
+
if (closed || !shouldReconnect(event.code)) return;
|
|
94
|
+
timer = setTimeout(() => {
|
|
95
|
+
timer = null;
|
|
96
|
+
connect();
|
|
97
|
+
}, withJitter(delay));
|
|
98
|
+
delay = nextReconnectDelay(delay);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
connect();
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
get current() {
|
|
105
|
+
return socket;
|
|
106
|
+
},
|
|
107
|
+
close() {
|
|
108
|
+
closed = true;
|
|
109
|
+
if (timer) clearTimeout(timer);
|
|
110
|
+
timer = null;
|
|
111
|
+
if (typeof window !== "undefined") {
|
|
112
|
+
window.removeEventListener("online", online);
|
|
113
|
+
}
|
|
114
|
+
socket?.close();
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function sendBytes(socket: WebSocket, data: Uint8Array): void {
|
|
120
|
+
const copy = new Uint8Array(data.byteLength);
|
|
121
|
+
copy.set(data);
|
|
122
|
+
(socket as unknown as { send: (data: Uint8Array) => void }).send(copy);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function toBytes(data: ArrayBuffer | Blob | string): Promise<Uint8Array> {
|
|
126
|
+
if (typeof data === "string") {
|
|
127
|
+
return Promise.resolve(new TextEncoder().encode(data));
|
|
128
|
+
}
|
|
129
|
+
if (data instanceof Blob) {
|
|
130
|
+
return data.arrayBuffer().then((buf) => new Uint8Array(buf));
|
|
131
|
+
}
|
|
132
|
+
return Promise.resolve(new Uint8Array(data));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export type SyncControl = {
|
|
136
|
+
/** `error`: the server could not persist an update; nothing about the lock changed. */
|
|
137
|
+
type: "locked" | "unlocked" | "reset" | "error" | "schema_incompatible";
|
|
138
|
+
epoch: number;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export type SyncStatus = "live" | "offline" | "error";
|
|
142
|
+
|
|
143
|
+
export function connectSync(
|
|
144
|
+
doc: Doc,
|
|
145
|
+
url: string,
|
|
146
|
+
opts?: {
|
|
147
|
+
onControl?: (msg: SyncControl) => void;
|
|
148
|
+
canSync?: (candidate: Doc) => boolean;
|
|
149
|
+
onBlocked?: () => void;
|
|
150
|
+
onStatus?: (status: SyncStatus) => void;
|
|
151
|
+
createSocket?: SocketFactory;
|
|
152
|
+
},
|
|
153
|
+
): SocketHandle {
|
|
154
|
+
let stopped = false;
|
|
155
|
+
const stop = () => {
|
|
156
|
+
if (stopped) return;
|
|
157
|
+
stopped = true;
|
|
158
|
+
doc.off("update", onUpdate);
|
|
159
|
+
link.close();
|
|
160
|
+
};
|
|
161
|
+
const allowed = (candidate: Doc = doc) => {
|
|
162
|
+
if (stopped) return false;
|
|
163
|
+
if (opts?.canSync?.(candidate) === false) {
|
|
164
|
+
stop();
|
|
165
|
+
opts.onBlocked?.();
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
return true;
|
|
169
|
+
};
|
|
170
|
+
const link = reconnecting(url, {
|
|
171
|
+
onStatus: opts?.onStatus,
|
|
172
|
+
createSocket: opts?.createSocket,
|
|
173
|
+
// Every (re)open: state vector + local state, so the server fills gaps
|
|
174
|
+
// from the lost window and takes what was edited offline.
|
|
175
|
+
onOpen(socket) {
|
|
176
|
+
if (!allowed()) return;
|
|
177
|
+
for (const message of syncOpenMessages(doc)) {
|
|
178
|
+
sendBytes(socket, message);
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
onMessage(event) {
|
|
182
|
+
handleSyncMessage(event);
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const onUpdate = (update: Uint8Array, origin: unknown) => {
|
|
187
|
+
if (origin === REMOTE_ORIGIN || !allowed()) return;
|
|
188
|
+
const socket = link.current;
|
|
189
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
|
190
|
+
sendBytes(socket, encodeSyncUpdate(update));
|
|
191
|
+
};
|
|
192
|
+
doc.on("update", onUpdate);
|
|
193
|
+
|
|
194
|
+
function handleSyncMessage(event: MessageEvent) {
|
|
195
|
+
if (stopped) return;
|
|
196
|
+
if (typeof event.data === "string") {
|
|
197
|
+
try {
|
|
198
|
+
const parsed = JSON.parse(event.data) as SyncControl;
|
|
199
|
+
if (
|
|
200
|
+
parsed &&
|
|
201
|
+
(parsed.type === "locked" ||
|
|
202
|
+
parsed.type === "unlocked" ||
|
|
203
|
+
parsed.type === "reset" ||
|
|
204
|
+
parsed.type === "error" ||
|
|
205
|
+
parsed.type === "schema_incompatible")
|
|
206
|
+
) {
|
|
207
|
+
if (parsed.type === "schema_incompatible") stop();
|
|
208
|
+
opts?.onControl?.(parsed);
|
|
209
|
+
}
|
|
210
|
+
} catch {
|
|
211
|
+
/* ignore */
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
void toBytes(event.data).then((bytes) => {
|
|
216
|
+
if (stopped || peekType(bytes) !== MSG_SYNC) return;
|
|
217
|
+
if (opts?.canSync) {
|
|
218
|
+
const candidate = createDoc();
|
|
219
|
+
try {
|
|
220
|
+
applyUpdate(candidate, encodeState(doc));
|
|
221
|
+
readSync(candidate, bytes);
|
|
222
|
+
if (!allowed(candidate)) return;
|
|
223
|
+
} finally {
|
|
224
|
+
destroyDoc(candidate);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const reply = readSync(doc, bytes);
|
|
228
|
+
if (!allowed()) return;
|
|
229
|
+
const socket = link.current;
|
|
230
|
+
if (reply && socket && socket.readyState === WebSocket.OPEN) {
|
|
231
|
+
sendBytes(socket, reply);
|
|
232
|
+
}
|
|
233
|
+
}).catch(() => {
|
|
234
|
+
if (stopped) return;
|
|
235
|
+
stop();
|
|
236
|
+
opts?.onStatus?.("error");
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
close() {
|
|
242
|
+
stop();
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function connectPresence(
|
|
248
|
+
presence: Presence,
|
|
249
|
+
url: string,
|
|
250
|
+
opts?: { createSocket?: SocketFactory },
|
|
251
|
+
): SocketHandle {
|
|
252
|
+
const link = reconnecting(url, {
|
|
253
|
+
createSocket: opts?.createSocket,
|
|
254
|
+
// Re-announce every known state so peers see us after a reconnect.
|
|
255
|
+
onOpen(socket) {
|
|
256
|
+
sendBytes(socket, encodeAwarenessUpdate(presence));
|
|
257
|
+
},
|
|
258
|
+
onMessage(event) {
|
|
259
|
+
void toBytes(event.data).then((bytes) => {
|
|
260
|
+
if (peekType(bytes) !== MSG_AWARENESS) return;
|
|
261
|
+
applyAwarenessMessage(presence, bytes);
|
|
262
|
+
});
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const onChange = (
|
|
267
|
+
{ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
|
|
268
|
+
origin: unknown,
|
|
269
|
+
) => {
|
|
270
|
+
if (origin === REMOTE_ORIGIN) return;
|
|
271
|
+
const socket = link.current;
|
|
272
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
|
273
|
+
const clients = [...added, ...updated, ...removed];
|
|
274
|
+
if (clients.length === 0) return;
|
|
275
|
+
sendBytes(socket, encodeAwarenessUpdate(presence, clients));
|
|
276
|
+
};
|
|
277
|
+
presence.on("update", onChange);
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
close() {
|
|
281
|
+
presence.off("update", onChange);
|
|
282
|
+
link.close();
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
}
|