@openlfcp/shared-objects 0.1.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +91 -0
- package/dist/automerge-bytes.d.ts +37 -0
- package/dist/automerge-bytes.js +95 -0
- package/dist/chunk-limits.d.ts +58 -0
- package/dist/chunk-limits.js +457 -0
- package/dist/data-profile.d.ts +159 -0
- package/dist/data-profile.js +311 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +15 -0
- package/dist/profile-invalid.d.ts +12 -0
- package/dist/profile-invalid.js +15 -0
- package/dist/replica.d.ts +277 -0
- package/dist/replica.js +1039 -0
- package/dist/task.d.ts +159 -0
- package/dist/task.js +192 -0
- package/dist/validate.d.ts +70 -0
- package/dist/validate.js +196 -0
- package/dist/values.d.ts +52 -0
- package/dist/values.js +163 -0
- package/dist/wasm.d.ts +33 -0
- package/dist/wasm.js +58 -0
- package/package.json +59 -0
package/dist/values.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { fromBase64url, ID32_LENGTH, LfcpError, principalId, toBase64url, } from "@openlfcp/core";
|
|
2
|
+
import { sha256 } from "@openlfcp/crypto";
|
|
3
|
+
import { ProfileInvalidError } from "./profile-invalid.js";
|
|
4
|
+
/**
|
|
5
|
+
* Value-level rules of SHARED-OBJECTS-PROFILE-01: Principal references,
|
|
6
|
+
* Local Dates, timestamps, reverse-domain names, namespaced values, the
|
|
7
|
+
* Automerge actor ID and the profile framing of Data Unit and Snapshot
|
|
8
|
+
* plaintexts.
|
|
9
|
+
*/
|
|
10
|
+
/** §6: the profile identifier. */
|
|
11
|
+
export const PROFILE_ID = "org.openlfcp.shared-objects.v1";
|
|
12
|
+
/** §27: the reference of a Principal ID. */
|
|
13
|
+
export function principalRef(id) {
|
|
14
|
+
return `p:${toBase64url(principalId(id))}`;
|
|
15
|
+
}
|
|
16
|
+
/** True when `text` is a canonical Principal reference of exactly 32 bytes. */
|
|
17
|
+
export function isPrincipalRef(text) {
|
|
18
|
+
if (typeof text !== "string" || !text.startsWith("p:"))
|
|
19
|
+
return false;
|
|
20
|
+
try {
|
|
21
|
+
return fromBase64url(text.slice(2)).length === ID32_LENGTH;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** The Principal ID a reference names; throws INVALID_BASE64URL / INVALID_LENGTH otherwise. */
|
|
28
|
+
export function parsePrincipalRef(text) {
|
|
29
|
+
if (!text.startsWith("p:"))
|
|
30
|
+
throw new LfcpError("INVALID_BASE64URL", "a Principal reference starts with p:");
|
|
31
|
+
return principalId(fromBase64url(text.slice(2)));
|
|
32
|
+
}
|
|
33
|
+
/** §35: a real Gregorian date YYYY-MM-DD (no time zone). */
|
|
34
|
+
export function isLocalDate(text) {
|
|
35
|
+
if (typeof text !== "string")
|
|
36
|
+
return false;
|
|
37
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
|
|
38
|
+
if (m === null)
|
|
39
|
+
return false;
|
|
40
|
+
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
41
|
+
const leap = (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
|
|
42
|
+
const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
43
|
+
return mo >= 1 && mo <= 12 && d >= 1 && d <= days[mo - 1];
|
|
44
|
+
}
|
|
45
|
+
/** §28: an RFC 3339 UTC timestamp ending in Z with a real date and time (second 60 for leap seconds). */
|
|
46
|
+
export function isUtcTimestamp(text) {
|
|
47
|
+
if (typeof text !== "string")
|
|
48
|
+
return false;
|
|
49
|
+
const m = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d+)?Z$/.exec(text);
|
|
50
|
+
return (m !== null &&
|
|
51
|
+
isLocalDate(m[1]) &&
|
|
52
|
+
Number(m[2]) <= 23 &&
|
|
53
|
+
Number(m[3]) <= 59 &&
|
|
54
|
+
Number(m[4]) <= 60);
|
|
55
|
+
}
|
|
56
|
+
const LABEL = "[a-z0-9](?:[a-z0-9-]*[a-z0-9])?";
|
|
57
|
+
const REVERSE_DOMAIN = new RegExp(`^${LABEL}(?:\\.${LABEL})+$`);
|
|
58
|
+
const NAMESPACED = new RegExp(`^x/${LABEL}(?:\\.${LABEL})+/[^/]+$`, "u");
|
|
59
|
+
/** §18 ABNF reverse-domain: at least two lowercase LDH labels without leading or trailing hyphen. */
|
|
60
|
+
export const isReverseDomain = (text) => typeof text === "string" && REVERSE_DOMAIN.test(text);
|
|
61
|
+
/** §33 ABNF namespaced-value: x/<reverse-domain>/<value>, value non-empty without "/". */
|
|
62
|
+
export const isNamespacedValue = (text) => typeof text === "string" && NAMESPACED.test(text);
|
|
63
|
+
const ACTOR_DOMAIN = Uint8Array.from("OPENLFCP-SHARED-OBJECTS-ACTOR-v1", (c) => c.charCodeAt(0));
|
|
64
|
+
/**
|
|
65
|
+
* §8: the Automerge actor ID of `principal` editing `resource`:
|
|
66
|
+
* SHA-256(ASCII("OPENLFCP-SHARED-OBJECTS-ACTOR-v1") || resource_id || principal_id),
|
|
67
|
+
* from the raw 32-byte IDs.
|
|
68
|
+
*/
|
|
69
|
+
export function deriveActorId(resource, principal) {
|
|
70
|
+
for (const [what, id] of [
|
|
71
|
+
["the Resource ID", resource],
|
|
72
|
+
["the Principal ID", principal],
|
|
73
|
+
]) {
|
|
74
|
+
if (!(id instanceof Uint8Array) || id.length !== ID32_LENGTH)
|
|
75
|
+
throw new LfcpError("INVALID_LENGTH", `${what} must be the raw 32-byte ID`);
|
|
76
|
+
}
|
|
77
|
+
const input = new Uint8Array(ACTOR_DOMAIN.length + 2 * ID32_LENGTH);
|
|
78
|
+
input.set(ACTOR_DOMAIN, 0);
|
|
79
|
+
input.set(resource, ACTOR_DOMAIN.length);
|
|
80
|
+
input.set(principal, ACTOR_DOMAIN.length + ID32_LENGTH);
|
|
81
|
+
return sha256(input);
|
|
82
|
+
}
|
|
83
|
+
/** §11, §13: the only framing version. */
|
|
84
|
+
export const FRAMING_VERSION = 1;
|
|
85
|
+
/** The deterministic CBOR head of a byte string of `length` bytes (major type 2, shortest form). */
|
|
86
|
+
function bstrHead(length) {
|
|
87
|
+
if (length < 24)
|
|
88
|
+
return [0x40 | length];
|
|
89
|
+
if (length < 0x100)
|
|
90
|
+
return [0x58, length];
|
|
91
|
+
if (length < 0x10000)
|
|
92
|
+
return [0x59, length >> 8, length & 0xff];
|
|
93
|
+
if (length <= 0xffffffff)
|
|
94
|
+
return [
|
|
95
|
+
0x5a,
|
|
96
|
+
(length >>> 24) & 0xff,
|
|
97
|
+
(length >>> 16) & 0xff,
|
|
98
|
+
(length >>> 8) & 0xff,
|
|
99
|
+
length & 0xff,
|
|
100
|
+
];
|
|
101
|
+
throw new LfcpError("OUT_OF_RANGE", "the framed bytes are too long");
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Deterministic CBOR of [1, bstr] (§11 shared-objects-change, §13
|
|
105
|
+
* shared-objects-snapshot): the profile plaintext of a Data Unit (one
|
|
106
|
+
* Automerge change) or a Snapshot (one Automerge full save). The bytes are
|
|
107
|
+
* framed as given; checking that they are a valid Automerge change or save
|
|
108
|
+
* is the Automerge binding (LFCP-031).
|
|
109
|
+
*/
|
|
110
|
+
export function frameProfilePayload(bytes) {
|
|
111
|
+
if (!(bytes instanceof Uint8Array))
|
|
112
|
+
throw new LfcpError("INVALID_LENGTH", "framing takes bytes");
|
|
113
|
+
const head = bstrHead(bytes.length);
|
|
114
|
+
const out = new Uint8Array(2 + head.length + bytes.length);
|
|
115
|
+
out.set([0x82, FRAMING_VERSION, ...head], 0);
|
|
116
|
+
out.set(bytes, 2 + head.length);
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The inner bytes of a framed profile plaintext. §11: a receiver MUST
|
|
121
|
+
* reject plaintext that is not valid CBOR, not a two-element array, or
|
|
122
|
+
* uses an unsupported framing version; this also requires the
|
|
123
|
+
* deterministic encoding and no trailing bytes. Throws PROFILE_INVALID /
|
|
124
|
+
* INVALID_AUTOMERGE_BYTES (§74.1).
|
|
125
|
+
*/
|
|
126
|
+
export function unframeProfilePayload(framed) {
|
|
127
|
+
const fail = (why) => {
|
|
128
|
+
throw new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", `invalid profile framing: ${why}`);
|
|
129
|
+
};
|
|
130
|
+
if (!(framed instanceof Uint8Array) || framed.length < 3)
|
|
131
|
+
return fail("too short");
|
|
132
|
+
if (framed[0] !== 0x82)
|
|
133
|
+
return fail("not a two-element array");
|
|
134
|
+
if (framed[1] !== FRAMING_VERSION)
|
|
135
|
+
return fail("unsupported framing version");
|
|
136
|
+
const first = framed[2];
|
|
137
|
+
if (first >> 5 !== 2)
|
|
138
|
+
return fail("the second element is not a byte string");
|
|
139
|
+
const info = first & 0x1f;
|
|
140
|
+
let length;
|
|
141
|
+
let offset;
|
|
142
|
+
const at = (i) => framed[i];
|
|
143
|
+
if (info < 24)
|
|
144
|
+
[length, offset] = [info, 3];
|
|
145
|
+
else if (info === 24)
|
|
146
|
+
[length, offset] = [at(3), 4];
|
|
147
|
+
else if (info === 25)
|
|
148
|
+
[length, offset] = [(at(3) << 8) | at(4), 5];
|
|
149
|
+
else if (info === 26)
|
|
150
|
+
[length, offset] = [((at(3) << 24) >>> 0) + (at(4) << 16) + (at(5) << 8) + at(6), 7];
|
|
151
|
+
else
|
|
152
|
+
return fail("unsupported byte string length");
|
|
153
|
+
if (offset > framed.length)
|
|
154
|
+
return fail("truncated");
|
|
155
|
+
const bytes = framed.subarray(offset);
|
|
156
|
+
if (bytes.length !== length)
|
|
157
|
+
return fail(bytes.length < length ? "truncated" : "trailing bytes");
|
|
158
|
+
const canonical = bstrHead(length);
|
|
159
|
+
if (canonical.length !== offset - 2)
|
|
160
|
+
return fail("non-shortest byte string length");
|
|
161
|
+
return Uint8Array.from(bytes);
|
|
162
|
+
}
|
|
163
|
+
//# sourceMappingURL=values.js.map
|
package/dist/wasm.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** What initializing Automerge needs (injectable for tests). */
|
|
2
|
+
export interface AutomergeWasmHooks {
|
|
3
|
+
readonly isInitialized: () => boolean;
|
|
4
|
+
readonly loadBase64: () => Promise<string>;
|
|
5
|
+
readonly initialize: (wasm: Uint8Array) => Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Standard base64 to bytes, fast: Uint8Array.fromBase64 where the runtime
|
|
9
|
+
* has it, else atob and a plain loop. Automerge's own initializeBase64Wasm
|
|
10
|
+
* decodes with Uint8Array.from(atob(s), callback), which takes about 200 ms
|
|
11
|
+
* of main-thread time for its 3.5 MB wasm (measured in Node 24, LFCP-059);
|
|
12
|
+
* this takes under 10 ms.
|
|
13
|
+
*/
|
|
14
|
+
export declare function decodeBase64(text: string): Uint8Array;
|
|
15
|
+
/**
|
|
16
|
+
* An idempotent, concurrency-safe initializer: concurrent callers share one
|
|
17
|
+
* in-flight promise; a failure clears it so a later call can retry.
|
|
18
|
+
*/
|
|
19
|
+
export declare function automergeInitializer(hooks: AutomergeWasmHooks): () => Promise<void>;
|
|
20
|
+
/**
|
|
21
|
+
* Makes sure Automerge's WebAssembly is ready. Every build of
|
|
22
|
+
* `@automerge/automerge` except `/slim` initializes it on import, so this is
|
|
23
|
+
* a no-op there. A host that aliases `@automerge/automerge` to its `/slim`
|
|
24
|
+
* entry (e.g. a renderer where compiling 3.5 MB of wasm synchronously on the
|
|
25
|
+
* main thread is refused, as Chromium does above 4 KB) awaits this once
|
|
26
|
+
* before using this package: it loads the base64 wasm through a dynamic
|
|
27
|
+
* import, decodes it quickly and compiles it asynchronously. Idempotent and
|
|
28
|
+
* safe to call concurrently; a failed attempt can be retried.
|
|
29
|
+
*/
|
|
30
|
+
export declare const initializeAutomerge: () => Promise<void>;
|
|
31
|
+
/** Whether Automerge's WebAssembly is ready (always, except on `/slim` before initializeAutomerge). */
|
|
32
|
+
export declare const isAutomergeInitialized: () => boolean;
|
|
33
|
+
//# sourceMappingURL=wasm.d.ts.map
|
package/dist/wasm.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import * as A from "@automerge/automerge";
|
|
2
|
+
/**
|
|
3
|
+
* Standard base64 to bytes, fast: Uint8Array.fromBase64 where the runtime
|
|
4
|
+
* has it, else atob and a plain loop. Automerge's own initializeBase64Wasm
|
|
5
|
+
* decodes with Uint8Array.from(atob(s), callback), which takes about 200 ms
|
|
6
|
+
* of main-thread time for its 3.5 MB wasm (measured in Node 24, LFCP-059);
|
|
7
|
+
* this takes under 10 ms.
|
|
8
|
+
*/
|
|
9
|
+
export function decodeBase64(text) {
|
|
10
|
+
const native = Uint8Array.fromBase64;
|
|
11
|
+
if (native !== undefined)
|
|
12
|
+
return native(text);
|
|
13
|
+
const bin = atob(text);
|
|
14
|
+
const out = new Uint8Array(bin.length);
|
|
15
|
+
for (let i = 0; i < bin.length; i++)
|
|
16
|
+
out[i] = bin.charCodeAt(i);
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* An idempotent, concurrency-safe initializer: concurrent callers share one
|
|
21
|
+
* in-flight promise; a failure clears it so a later call can retry.
|
|
22
|
+
*/
|
|
23
|
+
export function automergeInitializer(hooks) {
|
|
24
|
+
let inFlight = null;
|
|
25
|
+
return () => {
|
|
26
|
+
if (hooks.isInitialized())
|
|
27
|
+
return Promise.resolve();
|
|
28
|
+
inFlight ??= (async () => {
|
|
29
|
+
try {
|
|
30
|
+
await hooks.initialize(decodeBase64(await hooks.loadBase64()));
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
inFlight = null;
|
|
34
|
+
throw e;
|
|
35
|
+
}
|
|
36
|
+
})();
|
|
37
|
+
return inFlight;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Makes sure Automerge's WebAssembly is ready. Every build of
|
|
42
|
+
* `@automerge/automerge` except `/slim` initializes it on import, so this is
|
|
43
|
+
* a no-op there. A host that aliases `@automerge/automerge` to its `/slim`
|
|
44
|
+
* entry (e.g. a renderer where compiling 3.5 MB of wasm synchronously on the
|
|
45
|
+
* main thread is refused, as Chromium does above 4 KB) awaits this once
|
|
46
|
+
* before using this package: it loads the base64 wasm through a dynamic
|
|
47
|
+
* import, decodes it quickly and compiles it asynchronously. Idempotent and
|
|
48
|
+
* safe to call concurrently; a failed attempt can be retried.
|
|
49
|
+
*/
|
|
50
|
+
export const initializeAutomerge = automergeInitializer({
|
|
51
|
+
isInitialized: () => A.isWasmInitialized(),
|
|
52
|
+
loadBase64: async () => (await import("@automerge/automerge/automerge.wasm.base64")).automergeWasmBase64,
|
|
53
|
+
// initializeWasm compiles and instantiates asynchronously (WebAssembly.instantiate).
|
|
54
|
+
initialize: (wasm) => A.initializeWasm(wasm),
|
|
55
|
+
});
|
|
56
|
+
/** Whether Automerge's WebAssembly is ready (always, except on `/slim` before initializeAutomerge). */
|
|
57
|
+
export const isAutomergeInitialized = () => A.isWasmInitialized();
|
|
58
|
+
//# sourceMappingURL=wasm.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openlfcp/shared-objects",
|
|
3
|
+
"version": "0.1.0-rc.1",
|
|
4
|
+
"description": "SHARED-OBJECTS-PROFILE-01 (org.openlfcp.shared-objects.v1) implementation.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"openlfcp",
|
|
7
|
+
"lfcp",
|
|
8
|
+
"local-first",
|
|
9
|
+
"end-to-end-encryption",
|
|
10
|
+
"automerge",
|
|
11
|
+
"crdt",
|
|
12
|
+
"tasks",
|
|
13
|
+
"shared-objects"
|
|
14
|
+
],
|
|
15
|
+
"license": "Apache-2.0",
|
|
16
|
+
"homepage": "https://github.com/openlfcp/sdk-ts/tree/main/packages/shared-objects#readme",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/openlfcp/sdk-ts/issues"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/openlfcp/sdk-ts.git",
|
|
23
|
+
"directory": "packages/shared-objects"
|
|
24
|
+
},
|
|
25
|
+
"type": "module",
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=24"
|
|
29
|
+
},
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"import": "./dist/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"!dist/**/*.map",
|
|
39
|
+
"!dist/**/*.tsbuildinfo"
|
|
40
|
+
],
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public",
|
|
43
|
+
"tag": "next"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@automerge/automerge": "3.5.0",
|
|
47
|
+
"fflate": "0.8.3",
|
|
48
|
+
"@openlfcp/core": "^0.1.0-rc.1",
|
|
49
|
+
"@openlfcp/crypto": "^0.1.0-rc.1"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"fast-check": "4.10.2"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"test": "cd ../.. && vitest run packages/shared-objects/test/",
|
|
56
|
+
"test:property": "cd ../.. && vitest run packages/shared-objects/test/property/",
|
|
57
|
+
"test:property:extended": "cd ../.. && LFCP_PROPERTY_EXTENDED=1 vitest run packages/shared-objects/test/property/"
|
|
58
|
+
}
|
|
59
|
+
}
|