@superdoc/sdk 2.27.0 → 2.28.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.
@@ -0,0 +1,29 @@
1
+ export declare const LEAD_IN_DELIMITERS: readonly [":", " — ", " – ", " - ", "—", "–"];
2
+ export declare const LEAD_IN_MAX_CHARS = 90;
3
+ export declare const LEAD_IN_BODY_SAMPLE_CHARS = 40;
4
+ export declare const LEAD_IN_QUOTES: readonly ["\"", "“", "‘", "'"];
5
+ export type LabelBeforeDelimiter = {
6
+ kind: 'before';
7
+ delimiter: string | null;
8
+ };
9
+ export type LabelIncludingDelimiter = {
10
+ kind: 'inside';
11
+ delimiter: string;
12
+ occurrence: number;
13
+ };
14
+ export type QuotedDefinedTerm = {
15
+ kind: 'quoted';
16
+ quote: string;
17
+ };
18
+ export type EmphasisedOpeningWord = {
19
+ kind: 'word';
20
+ label: string;
21
+ };
22
+ export type LabelLeadInShape = LabelBeforeDelimiter | LabelIncludingDelimiter | QuotedDefinedTerm | EmphasisedOpeningWord;
23
+ export type LeadInSpan = {
24
+ start: number;
25
+ end: number;
26
+ };
27
+ export declare function leadInEndOf(text: string, siblingDelimiter?: string | null): number | null;
28
+ export declare function leadInShapeOf(blockText: string, labelText: string, quote?: string): LabelLeadInShape | null;
29
+ export declare function labelLeadInSpanOf(text: string, shape: LabelLeadInShape): LeadInSpan | null;
@@ -0,0 +1,90 @@
1
+ const SPACED_DASH_DELIMITERS = [' — ', ' – ', ' - '];
2
+ const BARE_DASH_DELIMITERS = ['—', '–'];
3
+ export const LEAD_IN_DELIMITERS = [':', ...SPACED_DASH_DELIMITERS, ...BARE_DASH_DELIMITERS];
4
+ const DELIMITERS_A_LABEL_RUN_MAY_END_WITH = ['. ', ': ', ...LEAD_IN_DELIMITERS];
5
+ export const LEAD_IN_MAX_CHARS = 90;
6
+ export const LEAD_IN_BODY_SAMPLE_CHARS = 40;
7
+ export const LEAD_IN_QUOTES = ['"', '“', '‘', "'"];
8
+ const QUOTED_TERM_OPENER_WINDOW = 2;
9
+ const SINGLE_WORD_THEN_SPACE = /^\S+\s$/;
10
+ function labelEndAt(text, delimiter) {
11
+ const index = text.indexOf(delimiter);
12
+ return index > 0 && index <= LEAD_IN_MAX_CHARS ? index : null;
13
+ }
14
+ function earliestLeadInEnd(text) {
15
+ const found = LEAD_IN_DELIMITERS.map((delimiter) => labelEndAt(text, delimiter)).filter((index) => index != null);
16
+ return found.length > 0 ? Math.min(...found) : null;
17
+ }
18
+ export function leadInEndOf(text, siblingDelimiter) {
19
+ const siblingEnd = siblingDelimiter ? labelEndAt(text, siblingDelimiter) : null;
20
+ return siblingEnd ?? earliestLeadInEnd(text);
21
+ }
22
+ function labelRunTailShapeOf(labelText) {
23
+ const trimmed = labelText.trimEnd();
24
+ const delimiter = DELIMITERS_A_LABEL_RUN_MAY_END_WITH.find((d) => trimmed.endsWith(d.trimEnd()) && trimmed.length > d.trimEnd().length);
25
+ if (!delimiter)
26
+ return null;
27
+ const occurrence = labelText.split(delimiter).length - 1;
28
+ return occurrence > 0 ? { kind: 'inside', delimiter, occurrence } : null;
29
+ }
30
+ function emphasisedOpeningWordOf(blockText, labelText) {
31
+ const opensWithSingleWord = SINGLE_WORD_THEN_SPACE.test(labelText) && blockText.length > labelText.length && blockText.startsWith(labelText);
32
+ return opensWithSingleWord ? { kind: 'word', label: labelText.trimEnd() } : null;
33
+ }
34
+ export function leadInShapeOf(blockText, labelText, quote) {
35
+ if (quote)
36
+ return { kind: 'quoted', quote };
37
+ const delimiterAfterLabel = LEAD_IN_DELIMITERS.find((d) => blockText.startsWith(d, labelText.length));
38
+ if (delimiterAfterLabel)
39
+ return { kind: 'before', delimiter: delimiterAfterLabel };
40
+ return labelRunTailShapeOf(labelText) ?? emphasisedOpeningWordOf(blockText, labelText);
41
+ }
42
+ function isWordBoundary(character) {
43
+ return character === '' || /\s/.test(character);
44
+ }
45
+ function openingWordSpanOf(text, shape) {
46
+ const opensWithSameWord = text.startsWith(shape.label) && isWordBoundary(text.charAt(shape.label.length));
47
+ return opensWithSameWord ? { start: 0, end: shape.label.length } : null;
48
+ }
49
+ function quotedTermSpanOf(text, shape) {
50
+ const opener = text.slice(0, QUOTED_TERM_OPENER_WINDOW).indexOf(shape.quote);
51
+ if (opener < 0)
52
+ return null;
53
+ const termStart = opener + 1;
54
+ const close = text.indexOf(shape.quote, termStart);
55
+ const closesAfterNonEmptyTerm = close > termStart;
56
+ if (!closesAfterNonEmptyTerm || close > LEAD_IN_MAX_CHARS)
57
+ return null;
58
+ return { start: termStart, end: close };
59
+ }
60
+ function nthIndexOf(text, search, occurrence) {
61
+ let at = -1;
62
+ for (let seen = 0; seen < occurrence; seen += 1) {
63
+ at = text.indexOf(search, at + 1);
64
+ if (at < 0)
65
+ return -1;
66
+ }
67
+ return at;
68
+ }
69
+ function spanThroughSameDelimiterCountOf(text, shape) {
70
+ const at = nthIndexOf(text, shape.delimiter, shape.occurrence);
71
+ if (at <= 0 || at > LEAD_IN_MAX_CHARS)
72
+ return null;
73
+ return { start: 0, end: at + shape.delimiter.length };
74
+ }
75
+ function spanBeforeDelimiterOf(text, shape) {
76
+ const end = leadInEndOf(text, shape.delimiter);
77
+ return end == null ? null : { start: 0, end };
78
+ }
79
+ export function labelLeadInSpanOf(text, shape) {
80
+ switch (shape.kind) {
81
+ case 'word':
82
+ return openingWordSpanOf(text, shape);
83
+ case 'quoted':
84
+ return quotedTermSpanOf(text, shape);
85
+ case 'inside':
86
+ return spanThroughSameDelimiterCountOf(text, shape);
87
+ case 'before':
88
+ return spanBeforeDelimiterOf(text, shape);
89
+ }
90
+ }
@@ -0,0 +1,185 @@
1
+ 'use strict';
2
+
3
+ const WRITE_ADMISSION_PROTOCOL = 'superdoc.write-admission.v1';
4
+ const DEFAULT_LEASE_TIMEOUT_MS = 10_000;
5
+ const SYNC_MESSAGE_TYPE = 0;
6
+ function readVarUint(bytes, start) {
7
+ let value = 0;
8
+ let shift = 0;
9
+ for (let index = start; index < bytes.length && shift < 35; index += 1) {
10
+ const byte = bytes[index] ?? 0;
11
+ value += (byte & 0x7f) * 2 ** shift;
12
+ if (byte < 0x80)
13
+ return { value, next: index + 1 };
14
+ shift += 7;
15
+ }
16
+ return null;
17
+ }
18
+ function isDocumentSyncMessage(update) {
19
+ const nameLength = readVarUint(update, 0);
20
+ if (!nameLength)
21
+ return false;
22
+ return readVarUint(update, nameLength.next + nameLength.value)?.value === SYNC_MESSAGE_TYPE;
23
+ }
24
+ function parseClientRequest(payload) {
25
+ let parsed;
26
+ try {
27
+ parsed = JSON.parse(payload);
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ if (typeof parsed !== 'object' || parsed === null)
33
+ return null;
34
+ const protocol = Reflect.get(parsed, 'protocol');
35
+ const type = Reflect.get(parsed, 'type');
36
+ const leaseId = Reflect.get(parsed, 'leaseId');
37
+ if (protocol !== WRITE_ADMISSION_PROTOCOL || typeof leaseId !== 'string' || leaseId.length === 0)
38
+ return null;
39
+ if (type !== 'begin' && type !== 'release')
40
+ return null;
41
+ return { type, leaseId };
42
+ }
43
+ function reply(connection, type, leaseId, reason) {
44
+ try {
45
+ connection.sendStateless(JSON.stringify({ protocol: WRITE_ADMISSION_PROTOCOL, type, leaseId, ...(reason ? { reason } : {}) }));
46
+ }
47
+ catch {
48
+ return;
49
+ }
50
+ }
51
+ function nextMacrotask() {
52
+ return new Promise((resolve) => setTimeout(resolve, 0));
53
+ }
54
+ function sameRequest(request, leaseId, socketId) {
55
+ return request.leaseId === leaseId && request.connection.socketId === socketId;
56
+ }
57
+ function createWriteAdmissionExtension(options = {}) {
58
+ const leaseTimeoutMs = options.leaseTimeoutMs ?? DEFAULT_LEASE_TIMEOUT_MS;
59
+ if (!Number.isFinite(leaseTimeoutMs) || leaseTimeoutMs <= 0) {
60
+ throw new RangeError('leaseTimeoutMs must be a positive number.');
61
+ }
62
+ const rooms = new Map();
63
+ const roomFor = (documentName) => {
64
+ const existing = rooms.get(documentName);
65
+ if (existing)
66
+ return existing;
67
+ const room = { holder: null, scheduled: null, waiting: [], heldWrites: [] };
68
+ rooms.set(documentName, room);
69
+ return room;
70
+ };
71
+ const forgetIdleRoom = (documentName, room) => {
72
+ if (room.holder || room.scheduled || room.waiting.length > 0 || room.heldWrites.length > 0)
73
+ return;
74
+ rooms.delete(documentName);
75
+ };
76
+ const isBusy = (room) => room.holder !== null || room.scheduled !== null || room.waiting.length > 0;
77
+ const grant = async (documentName, room, request) => {
78
+ const lease = { ...request, timer: null };
79
+ room.holder = lease;
80
+ await nextMacrotask();
81
+ if (room.holder !== lease)
82
+ return;
83
+ request.document.flush?.();
84
+ reply(request.connection, 'granted', request.leaseId);
85
+ lease.timer = setTimeout(() => {
86
+ if (room.holder !== lease)
87
+ return;
88
+ reply(request.connection, 'expired', request.leaseId);
89
+ endLease(documentName, room);
90
+ }, leaseTimeoutMs);
91
+ };
92
+ const scheduleNextGrant = (documentName, room) => {
93
+ const next = room.waiting.shift();
94
+ if (!next) {
95
+ forgetIdleRoom(documentName, room);
96
+ return;
97
+ }
98
+ room.scheduled = next;
99
+ void nextMacrotask().then(async () => {
100
+ const request = room.scheduled;
101
+ room.scheduled = null;
102
+ if (request)
103
+ await grant(documentName, room, request);
104
+ else
105
+ scheduleNextGrant(documentName, room);
106
+ });
107
+ };
108
+ const endLease = (documentName, room) => {
109
+ if (room.holder?.timer)
110
+ clearTimeout(room.holder.timer);
111
+ room.holder = null;
112
+ for (const resume of room.heldWrites.splice(0))
113
+ resume();
114
+ scheduleNextGrant(documentName, room);
115
+ };
116
+ return {
117
+ async beforeHandleMessage({ documentName, socketId, update }) {
118
+ const room = rooms.get(documentName);
119
+ if (!room || !isDocumentSyncMessage(update))
120
+ return;
121
+ while (room.holder && room.holder.connection.socketId !== socketId) {
122
+ await new Promise((resume) => room.heldWrites.push(resume));
123
+ }
124
+ },
125
+ async onStateless({ documentName, connection, document, payload }) {
126
+ const request = parseClientRequest(payload);
127
+ if (!request)
128
+ return;
129
+ const room = roomFor(documentName);
130
+ if (request.type === 'begin') {
131
+ if (connection.readOnly) {
132
+ reply(connection, 'denied', request.leaseId, 'read-only connection');
133
+ forgetIdleRoom(documentName, room);
134
+ return;
135
+ }
136
+ const lease = { leaseId: request.leaseId, connection, document };
137
+ if (isBusy(room)) {
138
+ room.waiting.push(lease);
139
+ reply(connection, 'queued', request.leaseId);
140
+ return;
141
+ }
142
+ await grant(documentName, room, lease);
143
+ return;
144
+ }
145
+ if (room.holder && sameRequest(room.holder, request.leaseId, connection.socketId)) {
146
+ reply(connection, 'released', request.leaseId);
147
+ endLease(documentName, room);
148
+ return;
149
+ }
150
+ if (room.scheduled && sameRequest(room.scheduled, request.leaseId, connection.socketId)) {
151
+ room.scheduled = null;
152
+ reply(connection, 'released', request.leaseId);
153
+ return;
154
+ }
155
+ const waitingIndex = room.waiting.findIndex((entry) => sameRequest(entry, request.leaseId, connection.socketId));
156
+ if (waitingIndex >= 0) {
157
+ room.waiting.splice(waitingIndex, 1);
158
+ reply(connection, 'released', request.leaseId);
159
+ forgetIdleRoom(documentName, room);
160
+ return;
161
+ }
162
+ reply(connection, 'expired', request.leaseId);
163
+ forgetIdleRoom(documentName, room);
164
+ },
165
+ async onDisconnect({ documentName, socketId }) {
166
+ const room = rooms.get(documentName);
167
+ if (!room)
168
+ return;
169
+ for (let index = room.waiting.length - 1; index >= 0; index -= 1) {
170
+ if (room.waiting[index]?.connection.socketId === socketId)
171
+ room.waiting.splice(index, 1);
172
+ }
173
+ if (room.scheduled?.connection.socketId === socketId)
174
+ room.scheduled = null;
175
+ if (room.holder?.connection.socketId === socketId) {
176
+ endLease(documentName, room);
177
+ return;
178
+ }
179
+ forgetIdleRoom(documentName, room);
180
+ },
181
+ };
182
+ }
183
+
184
+ exports.WRITE_ADMISSION_PROTOCOL = WRITE_ADMISSION_PROTOCOL;
185
+ exports.createWriteAdmissionExtension = createWriteAdmissionExtension;
@@ -0,0 +1,33 @@
1
+ export declare const WRITE_ADMISSION_PROTOCOL = "superdoc.write-admission.v1";
2
+ export interface WriteAdmissionConnection {
3
+ readonly socketId: string;
4
+ readonly readOnly?: boolean;
5
+ sendStateless(payload: string): void;
6
+ }
7
+ export interface WriteAdmissionDocument {
8
+ flush?(): void;
9
+ }
10
+ export interface WriteAdmissionMessagePayload {
11
+ readonly documentName: string;
12
+ readonly socketId: string;
13
+ readonly update: Uint8Array;
14
+ }
15
+ export interface WriteAdmissionStatelessPayload {
16
+ readonly documentName: string;
17
+ readonly connection: WriteAdmissionConnection;
18
+ readonly document: WriteAdmissionDocument;
19
+ readonly payload: string;
20
+ }
21
+ export interface WriteAdmissionDisconnectPayload {
22
+ readonly documentName: string;
23
+ readonly socketId: string;
24
+ }
25
+ export interface WriteAdmissionExtension {
26
+ beforeHandleMessage(payload: WriteAdmissionMessagePayload): Promise<void>;
27
+ onStateless(payload: WriteAdmissionStatelessPayload): Promise<void>;
28
+ onDisconnect(payload: WriteAdmissionDisconnectPayload): Promise<void>;
29
+ }
30
+ export interface WriteAdmissionOptions {
31
+ readonly leaseTimeoutMs?: number;
32
+ }
33
+ export declare function createWriteAdmissionExtension(options?: WriteAdmissionOptions): WriteAdmissionExtension;
@@ -0,0 +1,180 @@
1
+ export const WRITE_ADMISSION_PROTOCOL = 'superdoc.write-admission.v1';
2
+ const DEFAULT_LEASE_TIMEOUT_MS = 10_000;
3
+ const SYNC_MESSAGE_TYPE = 0;
4
+ function readVarUint(bytes, start) {
5
+ let value = 0;
6
+ let shift = 0;
7
+ for (let index = start; index < bytes.length && shift < 35; index += 1) {
8
+ const byte = bytes[index] ?? 0;
9
+ value += (byte & 0x7f) * 2 ** shift;
10
+ if (byte < 0x80)
11
+ return { value, next: index + 1 };
12
+ shift += 7;
13
+ }
14
+ return null;
15
+ }
16
+ function isDocumentSyncMessage(update) {
17
+ const nameLength = readVarUint(update, 0);
18
+ if (!nameLength)
19
+ return false;
20
+ return readVarUint(update, nameLength.next + nameLength.value)?.value === SYNC_MESSAGE_TYPE;
21
+ }
22
+ function parseClientRequest(payload) {
23
+ let parsed;
24
+ try {
25
+ parsed = JSON.parse(payload);
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ if (typeof parsed !== 'object' || parsed === null)
31
+ return null;
32
+ const protocol = Reflect.get(parsed, 'protocol');
33
+ const type = Reflect.get(parsed, 'type');
34
+ const leaseId = Reflect.get(parsed, 'leaseId');
35
+ if (protocol !== WRITE_ADMISSION_PROTOCOL || typeof leaseId !== 'string' || leaseId.length === 0)
36
+ return null;
37
+ if (type !== 'begin' && type !== 'release')
38
+ return null;
39
+ return { type, leaseId };
40
+ }
41
+ function reply(connection, type, leaseId, reason) {
42
+ try {
43
+ connection.sendStateless(JSON.stringify({ protocol: WRITE_ADMISSION_PROTOCOL, type, leaseId, ...(reason ? { reason } : {}) }));
44
+ }
45
+ catch {
46
+ return;
47
+ }
48
+ }
49
+ function nextMacrotask() {
50
+ return new Promise((resolve) => setTimeout(resolve, 0));
51
+ }
52
+ function sameRequest(request, leaseId, socketId) {
53
+ return request.leaseId === leaseId && request.connection.socketId === socketId;
54
+ }
55
+ export function createWriteAdmissionExtension(options = {}) {
56
+ const leaseTimeoutMs = options.leaseTimeoutMs ?? DEFAULT_LEASE_TIMEOUT_MS;
57
+ if (!Number.isFinite(leaseTimeoutMs) || leaseTimeoutMs <= 0) {
58
+ throw new RangeError('leaseTimeoutMs must be a positive number.');
59
+ }
60
+ const rooms = new Map();
61
+ const roomFor = (documentName) => {
62
+ const existing = rooms.get(documentName);
63
+ if (existing)
64
+ return existing;
65
+ const room = { holder: null, scheduled: null, waiting: [], heldWrites: [] };
66
+ rooms.set(documentName, room);
67
+ return room;
68
+ };
69
+ const forgetIdleRoom = (documentName, room) => {
70
+ if (room.holder || room.scheduled || room.waiting.length > 0 || room.heldWrites.length > 0)
71
+ return;
72
+ rooms.delete(documentName);
73
+ };
74
+ const isBusy = (room) => room.holder !== null || room.scheduled !== null || room.waiting.length > 0;
75
+ const grant = async (documentName, room, request) => {
76
+ const lease = { ...request, timer: null };
77
+ room.holder = lease;
78
+ await nextMacrotask();
79
+ if (room.holder !== lease)
80
+ return;
81
+ request.document.flush?.();
82
+ reply(request.connection, 'granted', request.leaseId);
83
+ lease.timer = setTimeout(() => {
84
+ if (room.holder !== lease)
85
+ return;
86
+ reply(request.connection, 'expired', request.leaseId);
87
+ endLease(documentName, room);
88
+ }, leaseTimeoutMs);
89
+ };
90
+ const scheduleNextGrant = (documentName, room) => {
91
+ const next = room.waiting.shift();
92
+ if (!next) {
93
+ forgetIdleRoom(documentName, room);
94
+ return;
95
+ }
96
+ room.scheduled = next;
97
+ void nextMacrotask().then(async () => {
98
+ const request = room.scheduled;
99
+ room.scheduled = null;
100
+ if (request)
101
+ await grant(documentName, room, request);
102
+ else
103
+ scheduleNextGrant(documentName, room);
104
+ });
105
+ };
106
+ const endLease = (documentName, room) => {
107
+ if (room.holder?.timer)
108
+ clearTimeout(room.holder.timer);
109
+ room.holder = null;
110
+ for (const resume of room.heldWrites.splice(0))
111
+ resume();
112
+ scheduleNextGrant(documentName, room);
113
+ };
114
+ return {
115
+ async beforeHandleMessage({ documentName, socketId, update }) {
116
+ const room = rooms.get(documentName);
117
+ if (!room || !isDocumentSyncMessage(update))
118
+ return;
119
+ while (room.holder && room.holder.connection.socketId !== socketId) {
120
+ await new Promise((resume) => room.heldWrites.push(resume));
121
+ }
122
+ },
123
+ async onStateless({ documentName, connection, document, payload }) {
124
+ const request = parseClientRequest(payload);
125
+ if (!request)
126
+ return;
127
+ const room = roomFor(documentName);
128
+ if (request.type === 'begin') {
129
+ if (connection.readOnly) {
130
+ reply(connection, 'denied', request.leaseId, 'read-only connection');
131
+ forgetIdleRoom(documentName, room);
132
+ return;
133
+ }
134
+ const lease = { leaseId: request.leaseId, connection, document };
135
+ if (isBusy(room)) {
136
+ room.waiting.push(lease);
137
+ reply(connection, 'queued', request.leaseId);
138
+ return;
139
+ }
140
+ await grant(documentName, room, lease);
141
+ return;
142
+ }
143
+ if (room.holder && sameRequest(room.holder, request.leaseId, connection.socketId)) {
144
+ reply(connection, 'released', request.leaseId);
145
+ endLease(documentName, room);
146
+ return;
147
+ }
148
+ if (room.scheduled && sameRequest(room.scheduled, request.leaseId, connection.socketId)) {
149
+ room.scheduled = null;
150
+ reply(connection, 'released', request.leaseId);
151
+ return;
152
+ }
153
+ const waitingIndex = room.waiting.findIndex((entry) => sameRequest(entry, request.leaseId, connection.socketId));
154
+ if (waitingIndex >= 0) {
155
+ room.waiting.splice(waitingIndex, 1);
156
+ reply(connection, 'released', request.leaseId);
157
+ forgetIdleRoom(documentName, room);
158
+ return;
159
+ }
160
+ reply(connection, 'expired', request.leaseId);
161
+ forgetIdleRoom(documentName, room);
162
+ },
163
+ async onDisconnect({ documentName, socketId }) {
164
+ const room = rooms.get(documentName);
165
+ if (!room)
166
+ return;
167
+ for (let index = room.waiting.length - 1; index >= 0; index -= 1) {
168
+ if (room.waiting[index]?.connection.socketId === socketId)
169
+ room.waiting.splice(index, 1);
170
+ }
171
+ if (room.scheduled?.connection.socketId === socketId)
172
+ room.scheduled = null;
173
+ if (room.holder?.connection.socketId === socketId) {
174
+ endLease(documentName, room);
175
+ return;
176
+ }
177
+ forgetIdleRoom(documentName, room);
178
+ },
179
+ };
180
+ }