@lp-labs/sync-protocol-kit 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/LICENSE +21 -0
- package/README.md +72 -2
- package/dist/cjs/index.d.ts +60 -0
- package/dist/cjs/index.js +333 -0
- package/dist/cjs/package.json +1 -0
- package/dist/esm/index.d.ts +60 -0
- package/dist/esm/index.js +325 -0
- package/package.json +61 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Libin Prasanth
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,73 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @lp-labs/sync-protocol-kit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Validate versioned synchronization envelopes and exchange acknowledgements, cursors, and
|
|
4
|
+
capability descriptions. This is a local data contract only: it does not provide transport,
|
|
5
|
+
storage, authentication, ordering, retry, conflict resolution, or delivery semantics.
|
|
6
|
+
|
|
7
|
+
## Install and quick start
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install @lp-labs/sync-protocol-kit
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
import { validateSyncEnvelope, negotiateCapabilities } from '@lp-labs/sync-protocol-kit';
|
|
15
|
+
|
|
16
|
+
const local = {
|
|
17
|
+
versions: [1],
|
|
18
|
+
features: ['batch', 'cursor-resume'],
|
|
19
|
+
maxBatchSize: 100,
|
|
20
|
+
acknowledgements: true,
|
|
21
|
+
cursors: true,
|
|
22
|
+
};
|
|
23
|
+
const peerMessage = validateSyncEnvelope({
|
|
24
|
+
protocol: 'lp-sync',
|
|
25
|
+
version: 1,
|
|
26
|
+
id: 'hello-1',
|
|
27
|
+
kind: 'hello',
|
|
28
|
+
payload: { capabilities: local },
|
|
29
|
+
});
|
|
30
|
+
console.log(peerMessage.kind, negotiateCapabilities(local, local));
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## API and types
|
|
34
|
+
|
|
35
|
+
- `validateSyncEnvelope(value, limits?)` validates and detaches one `hello`, `changes`, `ack`, or
|
|
36
|
+
`cursor` envelope.
|
|
37
|
+
- `parseSyncEnvelope(text, limits?)` parses bounded JSON text and validates it.
|
|
38
|
+
- `serializeSyncEnvelope(envelope, limits?)` validates before serializing.
|
|
39
|
+
- `negotiateCapabilities(local, remote)` selects a common advertised version, feature intersection,
|
|
40
|
+
minimum batch size, and mutually supported acknowledgement/cursor flags.
|
|
41
|
+
- `SyncProtocolError` carries codes including `INVALID_ENVELOPE`, `INVALID_CAPABILITIES`,
|
|
42
|
+
`INVALID_PAYLOAD`, `UNSUPPORTED_VERSION`, `NO_COMMON_VERSION`, and `LIMIT_EXCEEDED`.
|
|
43
|
+
- Types: `JsonValue`, `SyncCapabilities`, `SyncPayload`, `SyncPayloadByKind`, `SyncEnvelope`,
|
|
44
|
+
`SyncLimits`, and `NegotiatedCapabilities`.
|
|
45
|
+
|
|
46
|
+
Envelope version 1 has a fixed protocol tag, bounded ID, kind, and kind-specific payload:
|
|
47
|
+
capabilities for `hello`, a cursor and JSON change list for `changes`, an accepted/rejected
|
|
48
|
+
message reference for `ack`, or a stream token (possibly null) for `cursor`. Rejected acknowledgments
|
|
49
|
+
may include a reason; accepted ones may not. Capability lists reject duplicates.
|
|
50
|
+
Advertised capability versions describe the peers' application-level sync contract; this kit itself
|
|
51
|
+
validates only the version-1 envelope wrapper and does not implement other envelope versions.
|
|
52
|
+
|
|
53
|
+
Default bounds are 1 MiB UTF-8 envelope text, JSON depth 32, 10,000 nodes, 100,000 characters per
|
|
54
|
+
string, 1,000 changes per batch, and 64 capability entries. Override per call with `SyncLimits`.
|
|
55
|
+
The conformance fixtures under `tests/fixtures` document accepted and rejected examples; they are
|
|
56
|
+
not an external standard or a claim of interoperability. No runtime or peer dependencies are used.
|
|
57
|
+
Validation and serialization are linear in the bounded envelope JSON size; capability negotiation
|
|
58
|
+
is linear in the peer capability list sizes.
|
|
59
|
+
|
|
60
|
+
## Module formats and customization
|
|
61
|
+
|
|
62
|
+
ESM, CommonJS, and TypeScript declarations are included. CommonJS:
|
|
63
|
+
`const { parseSyncEnvelope } = require('@lp-labs/sync-protocol-kit')`. Keep application message
|
|
64
|
+
schemas and transport policy outside this package, and version your payload contracts independently.
|
|
65
|
+
There are no runtime or peer dependencies; TypeScript and Prettier are development-only.
|
|
66
|
+
|
|
67
|
+
## Support
|
|
68
|
+
|
|
69
|
+
If this library is useful to you, consider supporting its maintenance with a [donation via PayPal](https://www.paypal.me/LibinPrasanth).
|
|
70
|
+
|
|
71
|
+
## Author
|
|
72
|
+
|
|
73
|
+
Created by [Libin Prasanth](https://www.linkedin.com/in/libinprasanth/). Connect on LinkedIn for updates and discussion.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export type JsonValue = null | boolean | number | string | JsonValue[] | {
|
|
2
|
+
[key: string]: JsonValue;
|
|
3
|
+
};
|
|
4
|
+
export interface SyncCapabilities {
|
|
5
|
+
readonly versions: readonly number[];
|
|
6
|
+
readonly features: readonly string[];
|
|
7
|
+
readonly maxBatchSize: number;
|
|
8
|
+
readonly acknowledgements: boolean;
|
|
9
|
+
readonly cursors: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface SyncPayloadByKind {
|
|
12
|
+
readonly hello: {
|
|
13
|
+
readonly capabilities: SyncCapabilities;
|
|
14
|
+
};
|
|
15
|
+
readonly changes: {
|
|
16
|
+
readonly cursor: string | null;
|
|
17
|
+
readonly changes: readonly JsonValue[];
|
|
18
|
+
};
|
|
19
|
+
readonly ack: {
|
|
20
|
+
readonly messageId: string;
|
|
21
|
+
readonly accepted: boolean;
|
|
22
|
+
readonly cursor?: string;
|
|
23
|
+
readonly reason?: string;
|
|
24
|
+
};
|
|
25
|
+
readonly cursor: {
|
|
26
|
+
readonly stream: string;
|
|
27
|
+
readonly token: string | null;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export type SyncPayload = SyncPayloadByKind[keyof SyncPayloadByKind];
|
|
31
|
+
export type SyncEnvelope<K extends keyof SyncPayloadByKind = keyof SyncPayloadByKind> = K extends keyof SyncPayloadByKind ? {
|
|
32
|
+
readonly protocol: 'lp-sync';
|
|
33
|
+
readonly version: 1;
|
|
34
|
+
readonly id: string;
|
|
35
|
+
readonly kind: K;
|
|
36
|
+
readonly payload: SyncPayloadByKind[K];
|
|
37
|
+
} : never;
|
|
38
|
+
export interface SyncLimits {
|
|
39
|
+
readonly maxBytes?: number;
|
|
40
|
+
readonly maxDepth?: number;
|
|
41
|
+
readonly maxNodes?: number;
|
|
42
|
+
readonly maxStringLength?: number;
|
|
43
|
+
readonly maxBatchSize?: number;
|
|
44
|
+
readonly maxCapabilities?: number;
|
|
45
|
+
}
|
|
46
|
+
export interface NegotiatedCapabilities {
|
|
47
|
+
readonly version: number;
|
|
48
|
+
readonly features: readonly string[];
|
|
49
|
+
readonly maxBatchSize: number;
|
|
50
|
+
readonly acknowledgements: boolean;
|
|
51
|
+
readonly cursors: boolean;
|
|
52
|
+
}
|
|
53
|
+
export declare class SyncProtocolError extends Error {
|
|
54
|
+
readonly code: 'INVALID_ENVELOPE' | 'INVALID_CAPABILITIES' | 'INVALID_PAYLOAD' | 'UNSUPPORTED_VERSION' | 'NO_COMMON_VERSION' | 'LIMIT_EXCEEDED';
|
|
55
|
+
constructor(code: SyncProtocolError['code'], message: string);
|
|
56
|
+
}
|
|
57
|
+
export declare function validateSyncEnvelope(input: unknown, inputLimits?: SyncLimits): SyncEnvelope;
|
|
58
|
+
export declare function parseSyncEnvelope(text: string, limits?: SyncLimits): SyncEnvelope;
|
|
59
|
+
export declare function serializeSyncEnvelope(envelope: SyncEnvelope, limits?: SyncLimits): string;
|
|
60
|
+
export declare function negotiateCapabilities(local: SyncCapabilities, remote: SyncCapabilities): NegotiatedCapabilities;
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SyncProtocolError = void 0;
|
|
4
|
+
exports.validateSyncEnvelope = validateSyncEnvelope;
|
|
5
|
+
exports.parseSyncEnvelope = parseSyncEnvelope;
|
|
6
|
+
exports.serializeSyncEnvelope = serializeSyncEnvelope;
|
|
7
|
+
exports.negotiateCapabilities = negotiateCapabilities;
|
|
8
|
+
class SyncProtocolError extends Error {
|
|
9
|
+
code;
|
|
10
|
+
constructor(code, message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'SyncProtocolError';
|
|
13
|
+
this.code = code;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
exports.SyncProtocolError = SyncProtocolError;
|
|
17
|
+
const envelopeFields = new Set(['protocol', 'version', 'id', 'kind', 'payload']);
|
|
18
|
+
const defaults = {
|
|
19
|
+
maxBytes: 1_048_576,
|
|
20
|
+
maxDepth: 32,
|
|
21
|
+
maxNodes: 10_000,
|
|
22
|
+
maxStringLength: 100_000,
|
|
23
|
+
maxBatchSize: 1_000,
|
|
24
|
+
maxCapabilities: 64,
|
|
25
|
+
};
|
|
26
|
+
function isRecord(value) {
|
|
27
|
+
return (value !== null &&
|
|
28
|
+
typeof value === 'object' &&
|
|
29
|
+
!Array.isArray(value) &&
|
|
30
|
+
(Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null));
|
|
31
|
+
}
|
|
32
|
+
function limitsOf(input = {}) {
|
|
33
|
+
if (input === null ||
|
|
34
|
+
typeof input !== 'object' ||
|
|
35
|
+
Array.isArray(input) ||
|
|
36
|
+
(Object.getPrototypeOf(input) !== Object.prototype && Object.getPrototypeOf(input) !== null)) {
|
|
37
|
+
throw new SyncProtocolError('LIMIT_EXCEEDED', 'Sync limits must be a plain record.');
|
|
38
|
+
}
|
|
39
|
+
const allowed = new Set([
|
|
40
|
+
'maxBytes',
|
|
41
|
+
'maxDepth',
|
|
42
|
+
'maxNodes',
|
|
43
|
+
'maxStringLength',
|
|
44
|
+
'maxBatchSize',
|
|
45
|
+
'maxCapabilities',
|
|
46
|
+
]);
|
|
47
|
+
for (const key of Reflect.ownKeys(input)) {
|
|
48
|
+
const descriptor = typeof key === 'string' ? Object.getOwnPropertyDescriptor(input, key) : undefined;
|
|
49
|
+
if (!descriptor ||
|
|
50
|
+
!allowed.has(key) ||
|
|
51
|
+
!descriptor.enumerable ||
|
|
52
|
+
!('value' in descriptor)) {
|
|
53
|
+
throw new SyncProtocolError('LIMIT_EXCEEDED', 'Sync limits contain unknown fields.');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const limits = {
|
|
57
|
+
maxBytes: input.maxBytes ?? defaults.maxBytes,
|
|
58
|
+
maxDepth: input.maxDepth ?? defaults.maxDepth,
|
|
59
|
+
maxNodes: input.maxNodes ?? defaults.maxNodes,
|
|
60
|
+
maxStringLength: input.maxStringLength ?? defaults.maxStringLength,
|
|
61
|
+
maxBatchSize: input.maxBatchSize ?? defaults.maxBatchSize,
|
|
62
|
+
maxCapabilities: input.maxCapabilities ?? defaults.maxCapabilities,
|
|
63
|
+
};
|
|
64
|
+
for (const [name, value] of Object.entries(limits)) {
|
|
65
|
+
if (!Number.isSafeInteger(value) || value < (name === 'maxDepth' ? 0 : 1)) {
|
|
66
|
+
throw new SyncProtocolError('LIMIT_EXCEEDED', `${name} must be a valid safe integer.`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return limits;
|
|
70
|
+
}
|
|
71
|
+
function record(value, allowed, required, label) {
|
|
72
|
+
if (!isRecord(value))
|
|
73
|
+
throw new SyncProtocolError('INVALID_ENVELOPE', `${label} must be a plain record.`);
|
|
74
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
75
|
+
const descriptor = typeof key === 'string' ? Object.getOwnPropertyDescriptor(value, key) : undefined;
|
|
76
|
+
if (!descriptor ||
|
|
77
|
+
!allowed.has(key) ||
|
|
78
|
+
!descriptor.enumerable ||
|
|
79
|
+
!('value' in descriptor)) {
|
|
80
|
+
throw new SyncProtocolError('INVALID_ENVELOPE', `${label} has unknown or non-data fields.`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (required.some((key) => !Object.hasOwn(value, key))) {
|
|
84
|
+
throw new SyncProtocolError('INVALID_ENVELOPE', `${label} is missing required fields.`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function boundedText(value, label, maximum) {
|
|
88
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > maximum) {
|
|
89
|
+
throw new SyncProtocolError('INVALID_PAYLOAD', `${label} must be a non-empty bounded string.`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function arrayValues(input, label, maximum) {
|
|
93
|
+
if (!Array.isArray(input) || input.length > maximum) {
|
|
94
|
+
throw new SyncProtocolError('LIMIT_EXCEEDED', `${label} must be a bounded array.`);
|
|
95
|
+
}
|
|
96
|
+
const result = [];
|
|
97
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
98
|
+
const descriptor = Object.getOwnPropertyDescriptor(input, String(index));
|
|
99
|
+
if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
|
|
100
|
+
throw new SyncProtocolError('INVALID_PAYLOAD', `${label} must be a dense data array.`);
|
|
101
|
+
}
|
|
102
|
+
result.push(descriptor.value);
|
|
103
|
+
}
|
|
104
|
+
for (const key of Reflect.ownKeys(input)) {
|
|
105
|
+
if (key !== 'length' && (typeof key !== 'string' || !/^(0|[1-9]\d*)$/.test(key))) {
|
|
106
|
+
throw new SyncProtocolError('INVALID_PAYLOAD', `${label} cannot have extra properties.`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
function jsonCopy(input, limits, budget = { nodes: 0, bytes: 0 }) {
|
|
112
|
+
const active = new Set();
|
|
113
|
+
const visit = (value, depth) => {
|
|
114
|
+
budget.nodes += 1;
|
|
115
|
+
if (budget.nodes > limits.maxNodes || depth > limits.maxDepth) {
|
|
116
|
+
throw new SyncProtocolError('LIMIT_EXCEEDED', 'Payload exceeds configured JSON capacity.');
|
|
117
|
+
}
|
|
118
|
+
if (value === null ||
|
|
119
|
+
typeof value === 'boolean' ||
|
|
120
|
+
(typeof value === 'number' && Number.isFinite(value))) {
|
|
121
|
+
budget.bytes += new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
122
|
+
if (budget.bytes > limits.maxBytes) {
|
|
123
|
+
throw new SyncProtocolError('LIMIT_EXCEEDED', 'Payload exceeds byte capacity.');
|
|
124
|
+
}
|
|
125
|
+
return value;
|
|
126
|
+
}
|
|
127
|
+
if (typeof value === 'string') {
|
|
128
|
+
if (value.length > limits.maxStringLength) {
|
|
129
|
+
throw new SyncProtocolError('LIMIT_EXCEEDED', 'Payload string exceeds configured capacity.');
|
|
130
|
+
}
|
|
131
|
+
budget.bytes += new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
132
|
+
if (budget.bytes > limits.maxBytes) {
|
|
133
|
+
throw new SyncProtocolError('LIMIT_EXCEEDED', 'Payload exceeds byte capacity.');
|
|
134
|
+
}
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
if (typeof value !== 'object' || active.has(value)) {
|
|
138
|
+
throw new SyncProtocolError('INVALID_PAYLOAD', 'Payload must be finite JSON without cycles.');
|
|
139
|
+
}
|
|
140
|
+
active.add(value);
|
|
141
|
+
if (Array.isArray(value)) {
|
|
142
|
+
budget.bytes += 2;
|
|
143
|
+
const result = [];
|
|
144
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
145
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
146
|
+
if (!descriptor || !('value' in descriptor) || !descriptor.enumerable) {
|
|
147
|
+
throw new SyncProtocolError('INVALID_PAYLOAD', 'Payload arrays must be dense data arrays.');
|
|
148
|
+
}
|
|
149
|
+
if (index > 0)
|
|
150
|
+
budget.bytes += 1;
|
|
151
|
+
result.push(visit(descriptor.value, depth + 1));
|
|
152
|
+
}
|
|
153
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
154
|
+
if (key !== 'length' && (typeof key !== 'string' || !/^(0|[1-9]\d*)$/.test(key))) {
|
|
155
|
+
throw new SyncProtocolError('INVALID_PAYLOAD', 'Payload arrays cannot have extra properties.');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
active.delete(value);
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
if (!isRecord(value)) {
|
|
162
|
+
throw new SyncProtocolError('INVALID_PAYLOAD', 'Payload objects must be plain records.');
|
|
163
|
+
}
|
|
164
|
+
const result = {};
|
|
165
|
+
budget.bytes += 2;
|
|
166
|
+
let fieldIndex = 0;
|
|
167
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
168
|
+
if (typeof key !== 'string')
|
|
169
|
+
throw new SyncProtocolError('INVALID_PAYLOAD', 'Symbols are not JSON fields.');
|
|
170
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
171
|
+
if (!descriptor.enumerable || !('value' in descriptor)) {
|
|
172
|
+
throw new SyncProtocolError('INVALID_PAYLOAD', 'Payload objects must contain data fields.');
|
|
173
|
+
}
|
|
174
|
+
if (fieldIndex > 0)
|
|
175
|
+
budget.bytes += 1;
|
|
176
|
+
budget.bytes += new TextEncoder().encode(JSON.stringify(key)).byteLength + 1;
|
|
177
|
+
fieldIndex += 1;
|
|
178
|
+
if (budget.bytes > limits.maxBytes) {
|
|
179
|
+
throw new SyncProtocolError('LIMIT_EXCEEDED', 'Payload exceeds byte capacity.');
|
|
180
|
+
}
|
|
181
|
+
Object.defineProperty(result, key, {
|
|
182
|
+
value: visit(descriptor.value, depth + 1),
|
|
183
|
+
enumerable: true,
|
|
184
|
+
writable: true,
|
|
185
|
+
configurable: true,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
active.delete(value);
|
|
189
|
+
return result;
|
|
190
|
+
};
|
|
191
|
+
return visit(input, 0);
|
|
192
|
+
}
|
|
193
|
+
function validateCapabilities(input, limits) {
|
|
194
|
+
record(input, new Set(['versions', 'features', 'maxBatchSize', 'acknowledgements', 'cursors']), ['versions', 'features', 'maxBatchSize', 'acknowledgements', 'cursors'], 'Capabilities');
|
|
195
|
+
const capabilities = input;
|
|
196
|
+
const versionsInput = arrayValues(capabilities.versions, 'versions', limits.maxCapabilities);
|
|
197
|
+
const featuresInput = arrayValues(capabilities.features, 'features', limits.maxCapabilities);
|
|
198
|
+
if (versionsInput.length === 0 ||
|
|
199
|
+
!Number.isSafeInteger(capabilities.maxBatchSize) ||
|
|
200
|
+
capabilities.maxBatchSize < 1 ||
|
|
201
|
+
capabilities.maxBatchSize > limits.maxBatchSize ||
|
|
202
|
+
typeof capabilities.acknowledgements !== 'boolean' ||
|
|
203
|
+
typeof capabilities.cursors !== 'boolean') {
|
|
204
|
+
throw new SyncProtocolError('INVALID_CAPABILITIES', 'Capability values are invalid or exceed limits.');
|
|
205
|
+
}
|
|
206
|
+
const versions = versionsInput.map((version) => {
|
|
207
|
+
if (!Number.isSafeInteger(version) || version < 1) {
|
|
208
|
+
throw new SyncProtocolError('INVALID_CAPABILITIES', 'Supported versions must be positive integers.');
|
|
209
|
+
}
|
|
210
|
+
return version;
|
|
211
|
+
});
|
|
212
|
+
const features = featuresInput.map((feature) => {
|
|
213
|
+
boundedText(feature, 'feature', 128);
|
|
214
|
+
return feature;
|
|
215
|
+
});
|
|
216
|
+
if (new Set(versions).size !== versions.length || new Set(features).size !== features.length) {
|
|
217
|
+
throw new SyncProtocolError('INVALID_CAPABILITIES', 'Capability lists cannot contain duplicates.');
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
versions,
|
|
221
|
+
features,
|
|
222
|
+
maxBatchSize: capabilities.maxBatchSize,
|
|
223
|
+
acknowledgements: capabilities.acknowledgements,
|
|
224
|
+
cursors: capabilities.cursors,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function validatePayload(kind, input, limits) {
|
|
228
|
+
if (!isRecord(input))
|
|
229
|
+
throw new SyncProtocolError('INVALID_PAYLOAD', 'Envelope payload must be a plain record.');
|
|
230
|
+
if (kind === 'hello') {
|
|
231
|
+
record(input, new Set(['capabilities']), ['capabilities'], 'Hello payload');
|
|
232
|
+
return { capabilities: validateCapabilities(input.capabilities, limits) };
|
|
233
|
+
}
|
|
234
|
+
if (kind === 'changes') {
|
|
235
|
+
record(input, new Set(['cursor', 'changes']), ['cursor', 'changes'], 'Changes payload');
|
|
236
|
+
if (input.cursor !== null)
|
|
237
|
+
boundedText(input.cursor, 'cursor', limits.maxStringLength);
|
|
238
|
+
const changes = arrayValues(input.changes, 'changes', limits.maxBatchSize);
|
|
239
|
+
const budget = { nodes: 0, bytes: 2 + Math.max(0, changes.length - 1) };
|
|
240
|
+
return {
|
|
241
|
+
cursor: input.cursor,
|
|
242
|
+
changes: changes.map((change) => jsonCopy(change, limits, budget)),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
if (kind === 'ack') {
|
|
246
|
+
record(input, new Set(['messageId', 'accepted', 'cursor', 'reason']), ['messageId', 'accepted'], 'Acknowledgement payload');
|
|
247
|
+
boundedText(input.messageId, 'messageId', limits.maxStringLength);
|
|
248
|
+
if (typeof input.accepted !== 'boolean') {
|
|
249
|
+
throw new SyncProtocolError('INVALID_PAYLOAD', 'accepted must be a boolean.');
|
|
250
|
+
}
|
|
251
|
+
if (input.cursor !== undefined)
|
|
252
|
+
boundedText(input.cursor, 'cursor', limits.maxStringLength);
|
|
253
|
+
if (input.reason !== undefined)
|
|
254
|
+
boundedText(input.reason, 'reason', limits.maxStringLength);
|
|
255
|
+
if (input.accepted && input.reason !== undefined) {
|
|
256
|
+
throw new SyncProtocolError('INVALID_PAYLOAD', 'Accepted acknowledgements cannot include a rejection reason.');
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
messageId: input.messageId,
|
|
260
|
+
accepted: input.accepted,
|
|
261
|
+
...(input.cursor === undefined ? {} : { cursor: input.cursor }),
|
|
262
|
+
...(input.reason === undefined ? {} : { reason: input.reason }),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
if (kind === 'cursor') {
|
|
266
|
+
record(input, new Set(['stream', 'token']), ['stream', 'token'], 'Cursor payload');
|
|
267
|
+
boundedText(input.stream, 'stream', limits.maxStringLength);
|
|
268
|
+
if (input.token !== null)
|
|
269
|
+
boundedText(input.token, 'token', limits.maxStringLength);
|
|
270
|
+
return { stream: input.stream, token: input.token };
|
|
271
|
+
}
|
|
272
|
+
throw new SyncProtocolError('INVALID_ENVELOPE', 'Envelope kind is not supported.');
|
|
273
|
+
}
|
|
274
|
+
function validateSyncEnvelope(input, inputLimits = {}) {
|
|
275
|
+
const limits = limitsOf(inputLimits);
|
|
276
|
+
record(input, envelopeFields, ['protocol', 'version', 'id', 'kind', 'payload'], 'Sync envelope');
|
|
277
|
+
if (input.protocol !== 'lp-sync') {
|
|
278
|
+
throw new SyncProtocolError('INVALID_ENVELOPE', 'Unsupported protocol identifier.');
|
|
279
|
+
}
|
|
280
|
+
if (input.version !== 1)
|
|
281
|
+
throw new SyncProtocolError('UNSUPPORTED_VERSION', 'Envelope version is not supported.');
|
|
282
|
+
boundedText(input.id, 'id', 256);
|
|
283
|
+
if (!['hello', 'changes', 'ack', 'cursor'].includes(input.kind)) {
|
|
284
|
+
throw new SyncProtocolError('INVALID_ENVELOPE', 'Envelope kind is not supported.');
|
|
285
|
+
}
|
|
286
|
+
const envelope = {
|
|
287
|
+
protocol: 'lp-sync',
|
|
288
|
+
version: 1,
|
|
289
|
+
id: input.id,
|
|
290
|
+
kind: input.kind,
|
|
291
|
+
payload: validatePayload(input.kind, input.payload, limits),
|
|
292
|
+
};
|
|
293
|
+
if (new TextEncoder().encode(JSON.stringify(envelope)).byteLength > limits.maxBytes) {
|
|
294
|
+
throw new SyncProtocolError('LIMIT_EXCEEDED', 'Envelope exceeds configured byte capacity.');
|
|
295
|
+
}
|
|
296
|
+
return envelope;
|
|
297
|
+
}
|
|
298
|
+
function parseSyncEnvelope(text, limits = {}) {
|
|
299
|
+
const safeLimits = limitsOf(limits);
|
|
300
|
+
if (typeof text !== 'string')
|
|
301
|
+
throw new SyncProtocolError('INVALID_ENVELOPE', 'Input must be JSON text.');
|
|
302
|
+
if (text.length > safeLimits.maxBytes ||
|
|
303
|
+
new TextEncoder().encode(text).byteLength > safeLimits.maxBytes) {
|
|
304
|
+
throw new SyncProtocolError('LIMIT_EXCEEDED', 'Envelope exceeds configured byte capacity.');
|
|
305
|
+
}
|
|
306
|
+
let parsed;
|
|
307
|
+
try {
|
|
308
|
+
parsed = JSON.parse(text);
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
throw new SyncProtocolError('INVALID_ENVELOPE', 'Input is not valid JSON.');
|
|
312
|
+
}
|
|
313
|
+
return validateSyncEnvelope(parsed, limits);
|
|
314
|
+
}
|
|
315
|
+
function serializeSyncEnvelope(envelope, limits = {}) {
|
|
316
|
+
return JSON.stringify(validateSyncEnvelope(envelope, limits));
|
|
317
|
+
}
|
|
318
|
+
function negotiateCapabilities(local, remote) {
|
|
319
|
+
const limits = limitsOf();
|
|
320
|
+
const safeLocal = validateCapabilities(local, limits);
|
|
321
|
+
const safeRemote = validateCapabilities(remote, limits);
|
|
322
|
+
const common = safeLocal.versions.filter((version) => safeRemote.versions.includes(version));
|
|
323
|
+
if (common.length === 0) {
|
|
324
|
+
throw new SyncProtocolError('NO_COMMON_VERSION', 'Peers have no shared protocol version.');
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
version: Math.max(...common),
|
|
328
|
+
features: safeLocal.features.filter((feature) => safeRemote.features.includes(feature)).sort(),
|
|
329
|
+
maxBatchSize: Math.min(safeLocal.maxBatchSize, safeRemote.maxBatchSize),
|
|
330
|
+
acknowledgements: safeLocal.acknowledgements && safeRemote.acknowledgements,
|
|
331
|
+
cursors: safeLocal.cursors && safeRemote.cursors,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"commonjs"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export type JsonValue = null | boolean | number | string | JsonValue[] | {
|
|
2
|
+
[key: string]: JsonValue;
|
|
3
|
+
};
|
|
4
|
+
export interface SyncCapabilities {
|
|
5
|
+
readonly versions: readonly number[];
|
|
6
|
+
readonly features: readonly string[];
|
|
7
|
+
readonly maxBatchSize: number;
|
|
8
|
+
readonly acknowledgements: boolean;
|
|
9
|
+
readonly cursors: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface SyncPayloadByKind {
|
|
12
|
+
readonly hello: {
|
|
13
|
+
readonly capabilities: SyncCapabilities;
|
|
14
|
+
};
|
|
15
|
+
readonly changes: {
|
|
16
|
+
readonly cursor: string | null;
|
|
17
|
+
readonly changes: readonly JsonValue[];
|
|
18
|
+
};
|
|
19
|
+
readonly ack: {
|
|
20
|
+
readonly messageId: string;
|
|
21
|
+
readonly accepted: boolean;
|
|
22
|
+
readonly cursor?: string;
|
|
23
|
+
readonly reason?: string;
|
|
24
|
+
};
|
|
25
|
+
readonly cursor: {
|
|
26
|
+
readonly stream: string;
|
|
27
|
+
readonly token: string | null;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export type SyncPayload = SyncPayloadByKind[keyof SyncPayloadByKind];
|
|
31
|
+
export type SyncEnvelope<K extends keyof SyncPayloadByKind = keyof SyncPayloadByKind> = K extends keyof SyncPayloadByKind ? {
|
|
32
|
+
readonly protocol: 'lp-sync';
|
|
33
|
+
readonly version: 1;
|
|
34
|
+
readonly id: string;
|
|
35
|
+
readonly kind: K;
|
|
36
|
+
readonly payload: SyncPayloadByKind[K];
|
|
37
|
+
} : never;
|
|
38
|
+
export interface SyncLimits {
|
|
39
|
+
readonly maxBytes?: number;
|
|
40
|
+
readonly maxDepth?: number;
|
|
41
|
+
readonly maxNodes?: number;
|
|
42
|
+
readonly maxStringLength?: number;
|
|
43
|
+
readonly maxBatchSize?: number;
|
|
44
|
+
readonly maxCapabilities?: number;
|
|
45
|
+
}
|
|
46
|
+
export interface NegotiatedCapabilities {
|
|
47
|
+
readonly version: number;
|
|
48
|
+
readonly features: readonly string[];
|
|
49
|
+
readonly maxBatchSize: number;
|
|
50
|
+
readonly acknowledgements: boolean;
|
|
51
|
+
readonly cursors: boolean;
|
|
52
|
+
}
|
|
53
|
+
export declare class SyncProtocolError extends Error {
|
|
54
|
+
readonly code: 'INVALID_ENVELOPE' | 'INVALID_CAPABILITIES' | 'INVALID_PAYLOAD' | 'UNSUPPORTED_VERSION' | 'NO_COMMON_VERSION' | 'LIMIT_EXCEEDED';
|
|
55
|
+
constructor(code: SyncProtocolError['code'], message: string);
|
|
56
|
+
}
|
|
57
|
+
export declare function validateSyncEnvelope(input: unknown, inputLimits?: SyncLimits): SyncEnvelope;
|
|
58
|
+
export declare function parseSyncEnvelope(text: string, limits?: SyncLimits): SyncEnvelope;
|
|
59
|
+
export declare function serializeSyncEnvelope(envelope: SyncEnvelope, limits?: SyncLimits): string;
|
|
60
|
+
export declare function negotiateCapabilities(local: SyncCapabilities, remote: SyncCapabilities): NegotiatedCapabilities;
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
export class SyncProtocolError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
constructor(code, message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = 'SyncProtocolError';
|
|
6
|
+
this.code = code;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
const envelopeFields = new Set(['protocol', 'version', 'id', 'kind', 'payload']);
|
|
10
|
+
const defaults = {
|
|
11
|
+
maxBytes: 1_048_576,
|
|
12
|
+
maxDepth: 32,
|
|
13
|
+
maxNodes: 10_000,
|
|
14
|
+
maxStringLength: 100_000,
|
|
15
|
+
maxBatchSize: 1_000,
|
|
16
|
+
maxCapabilities: 64,
|
|
17
|
+
};
|
|
18
|
+
function isRecord(value) {
|
|
19
|
+
return (value !== null &&
|
|
20
|
+
typeof value === 'object' &&
|
|
21
|
+
!Array.isArray(value) &&
|
|
22
|
+
(Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null));
|
|
23
|
+
}
|
|
24
|
+
function limitsOf(input = {}) {
|
|
25
|
+
if (input === null ||
|
|
26
|
+
typeof input !== 'object' ||
|
|
27
|
+
Array.isArray(input) ||
|
|
28
|
+
(Object.getPrototypeOf(input) !== Object.prototype && Object.getPrototypeOf(input) !== null)) {
|
|
29
|
+
throw new SyncProtocolError('LIMIT_EXCEEDED', 'Sync limits must be a plain record.');
|
|
30
|
+
}
|
|
31
|
+
const allowed = new Set([
|
|
32
|
+
'maxBytes',
|
|
33
|
+
'maxDepth',
|
|
34
|
+
'maxNodes',
|
|
35
|
+
'maxStringLength',
|
|
36
|
+
'maxBatchSize',
|
|
37
|
+
'maxCapabilities',
|
|
38
|
+
]);
|
|
39
|
+
for (const key of Reflect.ownKeys(input)) {
|
|
40
|
+
const descriptor = typeof key === 'string' ? Object.getOwnPropertyDescriptor(input, key) : undefined;
|
|
41
|
+
if (!descriptor ||
|
|
42
|
+
!allowed.has(key) ||
|
|
43
|
+
!descriptor.enumerable ||
|
|
44
|
+
!('value' in descriptor)) {
|
|
45
|
+
throw new SyncProtocolError('LIMIT_EXCEEDED', 'Sync limits contain unknown fields.');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const limits = {
|
|
49
|
+
maxBytes: input.maxBytes ?? defaults.maxBytes,
|
|
50
|
+
maxDepth: input.maxDepth ?? defaults.maxDepth,
|
|
51
|
+
maxNodes: input.maxNodes ?? defaults.maxNodes,
|
|
52
|
+
maxStringLength: input.maxStringLength ?? defaults.maxStringLength,
|
|
53
|
+
maxBatchSize: input.maxBatchSize ?? defaults.maxBatchSize,
|
|
54
|
+
maxCapabilities: input.maxCapabilities ?? defaults.maxCapabilities,
|
|
55
|
+
};
|
|
56
|
+
for (const [name, value] of Object.entries(limits)) {
|
|
57
|
+
if (!Number.isSafeInteger(value) || value < (name === 'maxDepth' ? 0 : 1)) {
|
|
58
|
+
throw new SyncProtocolError('LIMIT_EXCEEDED', `${name} must be a valid safe integer.`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return limits;
|
|
62
|
+
}
|
|
63
|
+
function record(value, allowed, required, label) {
|
|
64
|
+
if (!isRecord(value))
|
|
65
|
+
throw new SyncProtocolError('INVALID_ENVELOPE', `${label} must be a plain record.`);
|
|
66
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
67
|
+
const descriptor = typeof key === 'string' ? Object.getOwnPropertyDescriptor(value, key) : undefined;
|
|
68
|
+
if (!descriptor ||
|
|
69
|
+
!allowed.has(key) ||
|
|
70
|
+
!descriptor.enumerable ||
|
|
71
|
+
!('value' in descriptor)) {
|
|
72
|
+
throw new SyncProtocolError('INVALID_ENVELOPE', `${label} has unknown or non-data fields.`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (required.some((key) => !Object.hasOwn(value, key))) {
|
|
76
|
+
throw new SyncProtocolError('INVALID_ENVELOPE', `${label} is missing required fields.`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function boundedText(value, label, maximum) {
|
|
80
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > maximum) {
|
|
81
|
+
throw new SyncProtocolError('INVALID_PAYLOAD', `${label} must be a non-empty bounded string.`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function arrayValues(input, label, maximum) {
|
|
85
|
+
if (!Array.isArray(input) || input.length > maximum) {
|
|
86
|
+
throw new SyncProtocolError('LIMIT_EXCEEDED', `${label} must be a bounded array.`);
|
|
87
|
+
}
|
|
88
|
+
const result = [];
|
|
89
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
90
|
+
const descriptor = Object.getOwnPropertyDescriptor(input, String(index));
|
|
91
|
+
if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
|
|
92
|
+
throw new SyncProtocolError('INVALID_PAYLOAD', `${label} must be a dense data array.`);
|
|
93
|
+
}
|
|
94
|
+
result.push(descriptor.value);
|
|
95
|
+
}
|
|
96
|
+
for (const key of Reflect.ownKeys(input)) {
|
|
97
|
+
if (key !== 'length' && (typeof key !== 'string' || !/^(0|[1-9]\d*)$/.test(key))) {
|
|
98
|
+
throw new SyncProtocolError('INVALID_PAYLOAD', `${label} cannot have extra properties.`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
function jsonCopy(input, limits, budget = { nodes: 0, bytes: 0 }) {
|
|
104
|
+
const active = new Set();
|
|
105
|
+
const visit = (value, depth) => {
|
|
106
|
+
budget.nodes += 1;
|
|
107
|
+
if (budget.nodes > limits.maxNodes || depth > limits.maxDepth) {
|
|
108
|
+
throw new SyncProtocolError('LIMIT_EXCEEDED', 'Payload exceeds configured JSON capacity.');
|
|
109
|
+
}
|
|
110
|
+
if (value === null ||
|
|
111
|
+
typeof value === 'boolean' ||
|
|
112
|
+
(typeof value === 'number' && Number.isFinite(value))) {
|
|
113
|
+
budget.bytes += new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
114
|
+
if (budget.bytes > limits.maxBytes) {
|
|
115
|
+
throw new SyncProtocolError('LIMIT_EXCEEDED', 'Payload exceeds byte capacity.');
|
|
116
|
+
}
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
if (typeof value === 'string') {
|
|
120
|
+
if (value.length > limits.maxStringLength) {
|
|
121
|
+
throw new SyncProtocolError('LIMIT_EXCEEDED', 'Payload string exceeds configured capacity.');
|
|
122
|
+
}
|
|
123
|
+
budget.bytes += new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
124
|
+
if (budget.bytes > limits.maxBytes) {
|
|
125
|
+
throw new SyncProtocolError('LIMIT_EXCEEDED', 'Payload exceeds byte capacity.');
|
|
126
|
+
}
|
|
127
|
+
return value;
|
|
128
|
+
}
|
|
129
|
+
if (typeof value !== 'object' || active.has(value)) {
|
|
130
|
+
throw new SyncProtocolError('INVALID_PAYLOAD', 'Payload must be finite JSON without cycles.');
|
|
131
|
+
}
|
|
132
|
+
active.add(value);
|
|
133
|
+
if (Array.isArray(value)) {
|
|
134
|
+
budget.bytes += 2;
|
|
135
|
+
const result = [];
|
|
136
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
137
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
138
|
+
if (!descriptor || !('value' in descriptor) || !descriptor.enumerable) {
|
|
139
|
+
throw new SyncProtocolError('INVALID_PAYLOAD', 'Payload arrays must be dense data arrays.');
|
|
140
|
+
}
|
|
141
|
+
if (index > 0)
|
|
142
|
+
budget.bytes += 1;
|
|
143
|
+
result.push(visit(descriptor.value, depth + 1));
|
|
144
|
+
}
|
|
145
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
146
|
+
if (key !== 'length' && (typeof key !== 'string' || !/^(0|[1-9]\d*)$/.test(key))) {
|
|
147
|
+
throw new SyncProtocolError('INVALID_PAYLOAD', 'Payload arrays cannot have extra properties.');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
active.delete(value);
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
if (!isRecord(value)) {
|
|
154
|
+
throw new SyncProtocolError('INVALID_PAYLOAD', 'Payload objects must be plain records.');
|
|
155
|
+
}
|
|
156
|
+
const result = {};
|
|
157
|
+
budget.bytes += 2;
|
|
158
|
+
let fieldIndex = 0;
|
|
159
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
160
|
+
if (typeof key !== 'string')
|
|
161
|
+
throw new SyncProtocolError('INVALID_PAYLOAD', 'Symbols are not JSON fields.');
|
|
162
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
163
|
+
if (!descriptor.enumerable || !('value' in descriptor)) {
|
|
164
|
+
throw new SyncProtocolError('INVALID_PAYLOAD', 'Payload objects must contain data fields.');
|
|
165
|
+
}
|
|
166
|
+
if (fieldIndex > 0)
|
|
167
|
+
budget.bytes += 1;
|
|
168
|
+
budget.bytes += new TextEncoder().encode(JSON.stringify(key)).byteLength + 1;
|
|
169
|
+
fieldIndex += 1;
|
|
170
|
+
if (budget.bytes > limits.maxBytes) {
|
|
171
|
+
throw new SyncProtocolError('LIMIT_EXCEEDED', 'Payload exceeds byte capacity.');
|
|
172
|
+
}
|
|
173
|
+
Object.defineProperty(result, key, {
|
|
174
|
+
value: visit(descriptor.value, depth + 1),
|
|
175
|
+
enumerable: true,
|
|
176
|
+
writable: true,
|
|
177
|
+
configurable: true,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
active.delete(value);
|
|
181
|
+
return result;
|
|
182
|
+
};
|
|
183
|
+
return visit(input, 0);
|
|
184
|
+
}
|
|
185
|
+
function validateCapabilities(input, limits) {
|
|
186
|
+
record(input, new Set(['versions', 'features', 'maxBatchSize', 'acknowledgements', 'cursors']), ['versions', 'features', 'maxBatchSize', 'acknowledgements', 'cursors'], 'Capabilities');
|
|
187
|
+
const capabilities = input;
|
|
188
|
+
const versionsInput = arrayValues(capabilities.versions, 'versions', limits.maxCapabilities);
|
|
189
|
+
const featuresInput = arrayValues(capabilities.features, 'features', limits.maxCapabilities);
|
|
190
|
+
if (versionsInput.length === 0 ||
|
|
191
|
+
!Number.isSafeInteger(capabilities.maxBatchSize) ||
|
|
192
|
+
capabilities.maxBatchSize < 1 ||
|
|
193
|
+
capabilities.maxBatchSize > limits.maxBatchSize ||
|
|
194
|
+
typeof capabilities.acknowledgements !== 'boolean' ||
|
|
195
|
+
typeof capabilities.cursors !== 'boolean') {
|
|
196
|
+
throw new SyncProtocolError('INVALID_CAPABILITIES', 'Capability values are invalid or exceed limits.');
|
|
197
|
+
}
|
|
198
|
+
const versions = versionsInput.map((version) => {
|
|
199
|
+
if (!Number.isSafeInteger(version) || version < 1) {
|
|
200
|
+
throw new SyncProtocolError('INVALID_CAPABILITIES', 'Supported versions must be positive integers.');
|
|
201
|
+
}
|
|
202
|
+
return version;
|
|
203
|
+
});
|
|
204
|
+
const features = featuresInput.map((feature) => {
|
|
205
|
+
boundedText(feature, 'feature', 128);
|
|
206
|
+
return feature;
|
|
207
|
+
});
|
|
208
|
+
if (new Set(versions).size !== versions.length || new Set(features).size !== features.length) {
|
|
209
|
+
throw new SyncProtocolError('INVALID_CAPABILITIES', 'Capability lists cannot contain duplicates.');
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
versions,
|
|
213
|
+
features,
|
|
214
|
+
maxBatchSize: capabilities.maxBatchSize,
|
|
215
|
+
acknowledgements: capabilities.acknowledgements,
|
|
216
|
+
cursors: capabilities.cursors,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function validatePayload(kind, input, limits) {
|
|
220
|
+
if (!isRecord(input))
|
|
221
|
+
throw new SyncProtocolError('INVALID_PAYLOAD', 'Envelope payload must be a plain record.');
|
|
222
|
+
if (kind === 'hello') {
|
|
223
|
+
record(input, new Set(['capabilities']), ['capabilities'], 'Hello payload');
|
|
224
|
+
return { capabilities: validateCapabilities(input.capabilities, limits) };
|
|
225
|
+
}
|
|
226
|
+
if (kind === 'changes') {
|
|
227
|
+
record(input, new Set(['cursor', 'changes']), ['cursor', 'changes'], 'Changes payload');
|
|
228
|
+
if (input.cursor !== null)
|
|
229
|
+
boundedText(input.cursor, 'cursor', limits.maxStringLength);
|
|
230
|
+
const changes = arrayValues(input.changes, 'changes', limits.maxBatchSize);
|
|
231
|
+
const budget = { nodes: 0, bytes: 2 + Math.max(0, changes.length - 1) };
|
|
232
|
+
return {
|
|
233
|
+
cursor: input.cursor,
|
|
234
|
+
changes: changes.map((change) => jsonCopy(change, limits, budget)),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
if (kind === 'ack') {
|
|
238
|
+
record(input, new Set(['messageId', 'accepted', 'cursor', 'reason']), ['messageId', 'accepted'], 'Acknowledgement payload');
|
|
239
|
+
boundedText(input.messageId, 'messageId', limits.maxStringLength);
|
|
240
|
+
if (typeof input.accepted !== 'boolean') {
|
|
241
|
+
throw new SyncProtocolError('INVALID_PAYLOAD', 'accepted must be a boolean.');
|
|
242
|
+
}
|
|
243
|
+
if (input.cursor !== undefined)
|
|
244
|
+
boundedText(input.cursor, 'cursor', limits.maxStringLength);
|
|
245
|
+
if (input.reason !== undefined)
|
|
246
|
+
boundedText(input.reason, 'reason', limits.maxStringLength);
|
|
247
|
+
if (input.accepted && input.reason !== undefined) {
|
|
248
|
+
throw new SyncProtocolError('INVALID_PAYLOAD', 'Accepted acknowledgements cannot include a rejection reason.');
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
messageId: input.messageId,
|
|
252
|
+
accepted: input.accepted,
|
|
253
|
+
...(input.cursor === undefined ? {} : { cursor: input.cursor }),
|
|
254
|
+
...(input.reason === undefined ? {} : { reason: input.reason }),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
if (kind === 'cursor') {
|
|
258
|
+
record(input, new Set(['stream', 'token']), ['stream', 'token'], 'Cursor payload');
|
|
259
|
+
boundedText(input.stream, 'stream', limits.maxStringLength);
|
|
260
|
+
if (input.token !== null)
|
|
261
|
+
boundedText(input.token, 'token', limits.maxStringLength);
|
|
262
|
+
return { stream: input.stream, token: input.token };
|
|
263
|
+
}
|
|
264
|
+
throw new SyncProtocolError('INVALID_ENVELOPE', 'Envelope kind is not supported.');
|
|
265
|
+
}
|
|
266
|
+
export function validateSyncEnvelope(input, inputLimits = {}) {
|
|
267
|
+
const limits = limitsOf(inputLimits);
|
|
268
|
+
record(input, envelopeFields, ['protocol', 'version', 'id', 'kind', 'payload'], 'Sync envelope');
|
|
269
|
+
if (input.protocol !== 'lp-sync') {
|
|
270
|
+
throw new SyncProtocolError('INVALID_ENVELOPE', 'Unsupported protocol identifier.');
|
|
271
|
+
}
|
|
272
|
+
if (input.version !== 1)
|
|
273
|
+
throw new SyncProtocolError('UNSUPPORTED_VERSION', 'Envelope version is not supported.');
|
|
274
|
+
boundedText(input.id, 'id', 256);
|
|
275
|
+
if (!['hello', 'changes', 'ack', 'cursor'].includes(input.kind)) {
|
|
276
|
+
throw new SyncProtocolError('INVALID_ENVELOPE', 'Envelope kind is not supported.');
|
|
277
|
+
}
|
|
278
|
+
const envelope = {
|
|
279
|
+
protocol: 'lp-sync',
|
|
280
|
+
version: 1,
|
|
281
|
+
id: input.id,
|
|
282
|
+
kind: input.kind,
|
|
283
|
+
payload: validatePayload(input.kind, input.payload, limits),
|
|
284
|
+
};
|
|
285
|
+
if (new TextEncoder().encode(JSON.stringify(envelope)).byteLength > limits.maxBytes) {
|
|
286
|
+
throw new SyncProtocolError('LIMIT_EXCEEDED', 'Envelope exceeds configured byte capacity.');
|
|
287
|
+
}
|
|
288
|
+
return envelope;
|
|
289
|
+
}
|
|
290
|
+
export function parseSyncEnvelope(text, limits = {}) {
|
|
291
|
+
const safeLimits = limitsOf(limits);
|
|
292
|
+
if (typeof text !== 'string')
|
|
293
|
+
throw new SyncProtocolError('INVALID_ENVELOPE', 'Input must be JSON text.');
|
|
294
|
+
if (text.length > safeLimits.maxBytes ||
|
|
295
|
+
new TextEncoder().encode(text).byteLength > safeLimits.maxBytes) {
|
|
296
|
+
throw new SyncProtocolError('LIMIT_EXCEEDED', 'Envelope exceeds configured byte capacity.');
|
|
297
|
+
}
|
|
298
|
+
let parsed;
|
|
299
|
+
try {
|
|
300
|
+
parsed = JSON.parse(text);
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
throw new SyncProtocolError('INVALID_ENVELOPE', 'Input is not valid JSON.');
|
|
304
|
+
}
|
|
305
|
+
return validateSyncEnvelope(parsed, limits);
|
|
306
|
+
}
|
|
307
|
+
export function serializeSyncEnvelope(envelope, limits = {}) {
|
|
308
|
+
return JSON.stringify(validateSyncEnvelope(envelope, limits));
|
|
309
|
+
}
|
|
310
|
+
export function negotiateCapabilities(local, remote) {
|
|
311
|
+
const limits = limitsOf();
|
|
312
|
+
const safeLocal = validateCapabilities(local, limits);
|
|
313
|
+
const safeRemote = validateCapabilities(remote, limits);
|
|
314
|
+
const common = safeLocal.versions.filter((version) => safeRemote.versions.includes(version));
|
|
315
|
+
if (common.length === 0) {
|
|
316
|
+
throw new SyncProtocolError('NO_COMMON_VERSION', 'Peers have no shared protocol version.');
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
version: Math.max(...common),
|
|
320
|
+
features: safeLocal.features.filter((feature) => safeRemote.features.includes(feature)).sort(),
|
|
321
|
+
maxBatchSize: Math.min(safeLocal.maxBatchSize, safeRemote.maxBatchSize),
|
|
322
|
+
acknowledgements: safeLocal.acknowledgements && safeRemote.acknowledgements,
|
|
323
|
+
cursors: safeLocal.cursors && safeRemote.cursors,
|
|
324
|
+
};
|
|
325
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lp-labs/sync-protocol-kit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Versioned synchronization message contracts, validation, and capability negotiation.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/cjs/index.js",
|
|
8
|
+
"module": "./dist/esm/index.js",
|
|
9
|
+
"types": "./dist/esm/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"import": {
|
|
13
|
+
"types": "./dist/esm/index.d.ts",
|
|
14
|
+
"default": "./dist/esm/index.js"
|
|
15
|
+
},
|
|
16
|
+
"require": {
|
|
17
|
+
"types": "./dist/cjs/index.d.ts",
|
|
18
|
+
"default": "./dist/cjs/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": "\u003e=18"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "node scripts/build.mjs",
|
|
34
|
+
"typecheck": "npm run build \u0026\u0026 node scripts/check-types.mjs",
|
|
35
|
+
"test": "npm run build \u0026\u0026 node --test tests/*.test.mjs",
|
|
36
|
+
"format": "prettier --write src scripts tests *.json README.md",
|
|
37
|
+
"format:check": "prettier --check src scripts tests *.json README.md",
|
|
38
|
+
"prepack": "npm run build"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"typescript": "^5.9.3",
|
|
42
|
+
"prettier": "^3.6.2"
|
|
43
|
+
},
|
|
44
|
+
"prettier": {
|
|
45
|
+
"singleQuote": true,
|
|
46
|
+
"semi": true,
|
|
47
|
+
"trailingComma": "all",
|
|
48
|
+
"printWidth": 100,
|
|
49
|
+
"endOfLine": "lf"
|
|
50
|
+
},
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public",
|
|
53
|
+
"registry": "https://registry.npmjs.org/"
|
|
54
|
+
},
|
|
55
|
+
"author": {
|
|
56
|
+
"name": "Libin Prasanth",
|
|
57
|
+
"url": "https://www.linkedin.com/in/libinprasanth/"
|
|
58
|
+
},
|
|
59
|
+
"funding": {
|
|
60
|
+
"type": "paypal",
|
|
61
|
+
"url": "https://www.paypal.me/LibinPrasanth"
|
|
62
|
+
}
|
|
63
|
+
}
|