@enbox/browser 0.3.70 → 0.3.71

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,55 @@
1
+ import type { ObjectUrlLease } from './object-url-reference.js';
2
+
3
+ import { ObjectUrlReference } from './object-url-reference.js';
4
+
5
+ /**
6
+ * A reference-counted lease for a browser object URL.
7
+ *
8
+ * When replacing a URL already rendered by the DOM, `releaseAfter()` provides
9
+ * a grace period without passing the lease through the component tree.
10
+ */
11
+ export type BlobUrlLease = ObjectUrlLease;
12
+
13
+ /** Owns object URLs for Blob identities until their last lease is released. */
14
+ export interface BlobUrlPool {
15
+ acquire(blob: Blob): BlobUrlLease;
16
+ dispose(): void;
17
+ }
18
+
19
+ class DefaultBlobUrlPool implements BlobUrlPool {
20
+ private readonly _references = new Map<Blob, ObjectUrlReference>();
21
+ private _isDisposed = false;
22
+
23
+ public acquire(blob: Blob): BlobUrlLease {
24
+ if (this._isDisposed) {
25
+ throw new Error('BlobUrlPool is disposed.');
26
+ }
27
+
28
+ let reference = this._references.get(blob);
29
+ if (reference === undefined) {
30
+ reference = new ObjectUrlReference(blob, (released): void => {
31
+ this._references.delete(blob);
32
+ released.revoke();
33
+ });
34
+ this._references.set(blob, reference);
35
+ }
36
+ return reference.acquire();
37
+ }
38
+
39
+ public dispose(): void {
40
+ if (this._isDisposed) {
41
+ return;
42
+ }
43
+
44
+ this._isDisposed = true;
45
+ for (const reference of this._references.values()) {
46
+ reference.revoke();
47
+ }
48
+ this._references.clear();
49
+ }
50
+ }
51
+
52
+ /** Create a terminally disposable, reference-counted object URL pool. */
53
+ export function createBlobUrlPool(): BlobUrlPool {
54
+ return new DefaultBlobUrlPool();
55
+ }
@@ -0,0 +1,260 @@
1
+ import type { FileEnvelopeData } from '@enbox/api';
2
+
3
+ import type { BlobUrlLease } from './blob-url-pool.js';
4
+
5
+ import { ObjectUrlReference } from './object-url-reference.js';
6
+
7
+ const DEFAULT_MAX_BYTES = 128 * 1024 * 1024;
8
+ const DEFAULT_MAX_ENTRIES = 50;
9
+
10
+ type CacheEntry = {
11
+ bytes: number;
12
+ filename: string;
13
+ key: string;
14
+ mimeType: string;
15
+ url: ObjectUrlReference;
16
+ };
17
+
18
+ type PendingRead = {
19
+ controller: AbortController;
20
+ generation: number;
21
+ promise: Promise<CacheEntry | undefined>;
22
+ };
23
+
24
+ /** A cached file envelope represented by a leased browser object URL. */
25
+ export type FileEnvelopeUrlLease = BlobUrlLease & Readonly<{
26
+ filename: string;
27
+ mimeType: string;
28
+ }>;
29
+
30
+ export type FileEnvelopeUrlStoreOptions<Request> = Readonly<{
31
+ /** Select the stable cache key for a read request. */
32
+ key(request: Request): string;
33
+ /** Read and decode the file. The signal aborts invalidated work. */
34
+ read(request: Request, signal: AbortSignal): Promise<FileEnvelopeData | undefined>;
35
+ /** Choose the Blob exposed by the URL, retaining MIME/render policy in the caller. */
36
+ blobForUrl(value: FileEnvelopeData): Blob;
37
+ /** Maximum cached entries (default 50). Soft while every eviction candidate is leased. */
38
+ maxEntries?: number;
39
+ /** Maximum cached Blob bytes (default 128 MiB). Soft while every eviction candidate is leased. */
40
+ maxBytes?: number;
41
+ }>;
42
+
43
+ /** A keyed, bounded object URL cache for decoded file envelopes. */
44
+ export interface FileEnvelopeUrlStore<Request> {
45
+ acquire(request: Request): Promise<FileEnvelopeUrlLease | undefined>;
46
+ acquireCached(request: Request): FileEnvelopeUrlLease | undefined;
47
+ clear(): void;
48
+ dispose(): void;
49
+ invalidate(request: Request): void;
50
+ }
51
+
52
+ function assertLimit(value: number, name: string): void {
53
+ if (value !== Infinity && (!Number.isSafeInteger(value) || value < 0)) {
54
+ throw new TypeError(`${name} must be a non-negative safe integer or Infinity.`);
55
+ }
56
+ }
57
+
58
+ function throwAborted(controller: AbortController): never {
59
+ controller.abort();
60
+ throw controller.signal.reason;
61
+ }
62
+
63
+ class DefaultFileEnvelopeUrlStore<Request> implements FileEnvelopeUrlStore<Request> {
64
+ private readonly _cache = new Map<string, CacheEntry>();
65
+ private readonly _entries = new Set<CacheEntry>();
66
+ private readonly _maxBytes: number;
67
+ private readonly _maxEntries: number;
68
+ private readonly _pending = new Map<string, PendingRead>();
69
+ private _bytes = 0;
70
+ private _generation = 0;
71
+ private _isDisposed = false;
72
+
73
+ public constructor(private readonly _options: FileEnvelopeUrlStoreOptions<Request>) {
74
+ this._maxBytes = _options.maxBytes ?? DEFAULT_MAX_BYTES;
75
+ this._maxEntries = _options.maxEntries ?? DEFAULT_MAX_ENTRIES;
76
+ assertLimit(this._maxBytes, 'maxBytes');
77
+ assertLimit(this._maxEntries, 'maxEntries');
78
+ }
79
+
80
+ public async acquire(request: Request): Promise<FileEnvelopeUrlLease | undefined> {
81
+ this._assertActive();
82
+ const key = this._options.key(request);
83
+ const cached = this._cache.get(key);
84
+ if (cached !== undefined) {
85
+ return this._acquire(cached);
86
+ }
87
+
88
+ const pending = this._pending.get(key) ?? this._startRead(request, key);
89
+ const entry = await pending.promise;
90
+ if (this._isDisposed || pending.generation !== this._generation) {
91
+ return throwAborted(pending.controller);
92
+ }
93
+ if (entry === undefined) {
94
+ return undefined;
95
+ }
96
+ if (this._cache.get(key) !== entry) {
97
+ return throwAborted(pending.controller);
98
+ }
99
+ return this._acquire(entry);
100
+ }
101
+
102
+ public acquireCached(request: Request): FileEnvelopeUrlLease | undefined {
103
+ this._assertActive();
104
+ const entry = this._cache.get(this._options.key(request));
105
+ return entry === undefined ? undefined : this._acquire(entry);
106
+ }
107
+
108
+ public invalidate(request: Request): void {
109
+ this._assertActive();
110
+ const key = this._options.key(request);
111
+ const pending = this._pending.get(key);
112
+ pending?.controller.abort();
113
+ this._pending.delete(key);
114
+ const entry = this._cache.get(key);
115
+ if (entry !== undefined) {
116
+ this._remove(entry);
117
+ }
118
+ }
119
+
120
+ public clear(): void {
121
+ this._assertActive();
122
+ this._reset(false);
123
+ }
124
+
125
+ public dispose(): void {
126
+ if (this._isDisposed) {
127
+ return;
128
+ }
129
+
130
+ this._isDisposed = true;
131
+ this._reset(true);
132
+ }
133
+
134
+ private _abortReads(): void {
135
+ for (const pending of this._pending.values()) {
136
+ pending.controller.abort();
137
+ }
138
+ this._pending.clear();
139
+ }
140
+
141
+ private _acquire(entry: CacheEntry): FileEnvelopeUrlLease {
142
+ this._cache.delete(entry.key);
143
+ this._cache.set(entry.key, entry);
144
+ const lease = entry.url.acquire();
145
+ this._evict();
146
+ return Object.freeze({
147
+ filename : entry.filename,
148
+ mimeType : entry.mimeType,
149
+ release : lease.release,
150
+ releaseAfter : lease.releaseAfter,
151
+ url : lease.url,
152
+ });
153
+ }
154
+
155
+ private _assertActive(): void {
156
+ if (this._isDisposed) {
157
+ throw new Error('FileEnvelopeUrlStore is disposed.');
158
+ }
159
+ }
160
+
161
+ private _assertCurrent(key: string, controller: AbortController, generation: number): void {
162
+ if (
163
+ this._isDisposed
164
+ || controller.signal.aborted
165
+ || this._generation !== generation
166
+ || this._pending.get(key)?.controller !== controller
167
+ ) {
168
+ throwAborted(controller);
169
+ }
170
+ }
171
+
172
+ private _evict(): void {
173
+ while (this._cache.size > this._maxEntries || this._bytes > this._maxBytes) {
174
+ const entry = [...this._cache.values()].find((candidate): boolean => candidate.url.references === 0);
175
+ if (entry === undefined) {
176
+ return;
177
+ }
178
+ this._remove(entry);
179
+ }
180
+ }
181
+
182
+ private async _load(request: Request, key: string, controller: AbortController, generation: number): Promise<CacheEntry | undefined> {
183
+ const value = await this._options.read(request, controller.signal);
184
+ this._assertCurrent(key, controller, generation);
185
+ if (value === undefined) {
186
+ return undefined;
187
+ }
188
+
189
+ const blob = this._options.blobForUrl(value);
190
+ this._assertCurrent(key, controller, generation);
191
+ const url = new ObjectUrlReference(blob, (): void => this._released(entry));
192
+ const entry: CacheEntry = {
193
+ bytes : blob.size,
194
+ filename : value.filename,
195
+ key,
196
+ mimeType : value.mimeType,
197
+ url,
198
+ };
199
+ this._cache.set(key, entry);
200
+ this._bytes += entry.bytes;
201
+ this._entries.add(entry);
202
+ return entry;
203
+ }
204
+
205
+ private _released(entry: CacheEntry): void {
206
+ if (this._cache.get(entry.key) !== entry) {
207
+ this._revoke(entry);
208
+ } else {
209
+ this._evict();
210
+ }
211
+ }
212
+
213
+ private _remove(entry: CacheEntry): void {
214
+ if (this._cache.get(entry.key) !== entry) {
215
+ return;
216
+ }
217
+ this._cache.delete(entry.key);
218
+ this._bytes -= entry.bytes;
219
+ if (entry.url.references === 0) {
220
+ this._revoke(entry);
221
+ }
222
+ }
223
+
224
+ private _reset(revokeActive: boolean): void {
225
+ this._generation += 1;
226
+ this._abortReads();
227
+ this._cache.clear();
228
+ this._bytes = 0;
229
+ for (const entry of this._entries) {
230
+ if (revokeActive || entry.url.references === 0) {
231
+ this._revoke(entry);
232
+ }
233
+ }
234
+ }
235
+
236
+ private _revoke(entry: CacheEntry): void {
237
+ entry.url.revoke();
238
+ this._entries.delete(entry);
239
+ }
240
+
241
+ private _startRead(request: Request, key: string): PendingRead {
242
+ const controller = new AbortController();
243
+ const generation = this._generation;
244
+ const promise = this._load(request, key, controller, generation).finally((): void => {
245
+ if (this._pending.get(key)?.controller === controller) {
246
+ this._pending.delete(key);
247
+ }
248
+ });
249
+ const pending = { controller, generation, promise };
250
+ this._pending.set(key, pending);
251
+ return pending;
252
+ }
253
+ }
254
+
255
+ /** Create a bounded file-envelope URL store with deduplicated reads. */
256
+ export function createFileEnvelopeUrlStore<Request>(
257
+ options: FileEnvelopeUrlStoreOptions<Request>,
258
+ ): FileEnvelopeUrlStore<Request> {
259
+ return new DefaultFileEnvelopeUrlStore(options);
260
+ }
package/src/index.ts CHANGED
@@ -8,14 +8,17 @@
8
8
  * @example Single-import dapp setup
9
9
  * ```ts
10
10
  * import {
11
- * Enbox, BrowserConnectHandler,
12
- * defineProtocol, recordCodecs,
11
+ * BrowserConnectHandler, createConnectionStore,
12
+ * defineApplicationManifest,
13
13
  * } from '@enbox/browser';
14
+ * import { MyProtocol } from './my-protocol.js';
14
15
  *
15
- * const { enbox, session } = await Enbox.connect({
16
- * connectHandler: BrowserConnectHandler({ appName: 'My App' }),
17
- * protocols: [MyProtocol],
16
+ * const store = createConnectionStore({
17
+ * application : defineApplicationManifest({ protocols: [MyProtocol] }),
18
+ * connectHandler : BrowserConnectHandler({ appName: 'My App' }),
18
19
  * });
20
+ * const snapshot = await store.connect();
21
+ * if (snapshot.phase !== 'connected') throw snapshot.error ?? new Error('Connection failed.');
19
22
  * ```
20
23
  *
21
24
  * For non-browser environments (Node.js, CLI, desktop), import from
@@ -28,150 +31,13 @@
28
31
  //
29
32
  // The high-level Enbox API: DWN records and protocols.
30
33
 
31
- export {
32
- AudienceDecryptError,
33
- createConnectionStore,
34
- ContextNotReadyError,
35
- ContextRetiredError,
36
- defineApplicationManifest,
37
- DwnResponseError,
38
- Enbox,
39
- defineProtocol,
40
- getApplicationProtocolRequests,
41
- ProtocolReadinessError,
42
- RecordConflictError,
43
- RecordParentNotFoundError,
44
- RecordSquashBackstopError,
45
- RecordValidationError,
46
- protocolContextKey,
47
- recordCodecs,
48
- ServiceConfigProtocol,
49
- } from '@enbox/api';
50
-
51
- export type {
52
- ApplicationConnectionStore,
53
- ApplicationConnectionStoreConnectOptions,
54
- ApplicationConnectionStoreOptions,
55
- ApplicationConnectionStoreRefreshOptions,
56
- ApplicationManifest,
57
- ApplicationManifestProtocol,
58
- ApplicationManifestProtocolInput,
59
- AudienceDecryptFailureCause,
60
- ConnectionPhase,
61
- ConnectionSnapshot,
62
- ConnectionSnapshotListener,
63
- ConnectionStore,
64
- ConnectionStoreOptions,
65
- ContextView,
66
- ContextViewListener,
67
- ContextViewState,
68
- ContextMember,
69
- ContextMembersApi,
70
- ContextInvitation,
71
- ContextInvitationPreview,
72
- ContextInvitationsApi,
73
- ContextMaterializedRecord,
74
- ContextRecord,
75
- ContextRecordDeleteParams,
76
- ContextRecordsApi,
77
- ContextRecordUpdateParams,
78
- ContextRoleGroup,
79
- ContextRoleGroups,
80
- ContextRolePath,
81
- ContextsApi,
82
- DataForPath,
83
- DefineApplicationManifestOptions,
84
- DwnEndpointResolution,
85
- EnboxAnonymousOptions,
86
- EnboxConnectOptions,
87
- EnboxConnectResult,
88
- EnboxParams,
89
- EnboxSessionParams,
90
- EncodedRecordData,
91
- EnsureProtocolsReadyOptions,
92
- ExpandableRecordView,
93
- FileEnvelopeCodec,
94
- FileEnvelopeCodecOptions,
95
- FileEnvelopeData,
96
- FollowContextOptions,
97
- JsonRecordCodecOptions,
98
- MaterializedMemberContext,
99
- MaterializedRecord,
100
- MemberContext,
101
- OwnedContext,
102
- ProtocolContext,
103
- ProtocolRolePaths,
104
- SquashProtocolPaths,
105
- RecordCodec,
106
- RecordCodecContext,
107
- RecordCodecValue,
108
- RecordFilter,
109
- RecordPage,
110
- RecordPatch,
111
- RecordPatchInput,
112
- RecordQuery,
113
- RecordSubscription,
114
- RecordSubscriptionEvent,
115
- RecordSubscriptionListener,
116
- RecordView,
117
- RecordViewListener,
118
- RecordViewState,
119
- RecordValidationFailure,
120
- RecordValidator,
121
- ReplicationCurrentness,
122
- RoleDeliveryState,
123
- SyncStatusSnapshot,
124
- TypedMaterializedRecord,
125
- TypedRecord,
126
- } from '@enbox/api';
34
+ export * from '@enbox/api';
127
35
 
128
36
  // ─── Re-exports from @enbox/auth ────────────────────────────────
129
37
  //
130
38
  // Authentication lifecycle, session management, connect handlers.
131
39
 
132
- export {
133
- AuthManager,
134
- AuthSession,
135
- ConnectDeniedError,
136
- SESSION_EXPIRED_ERROR_CODE,
137
- SESSION_REVOKED_ERROR_CODE,
138
- computeConnectionStatus,
139
- isConnectDeniedError,
140
- isSessionExpiredError,
141
- isSessionInvalidError,
142
- normalizeProtocolRequests,
143
- WalletConnect,
144
- } from '@enbox/auth/browser';
145
-
146
- export type {
147
- AuthEvent,
148
- AuthEventHandler,
149
- AuthManagerOptions,
150
- AuthState,
151
- ComputeConnectionStatusOptions,
152
- ConnectionMonitorOptions,
153
- ConnectionState,
154
- ConnectionStatus,
155
- ConnectionStatusGrant,
156
- ConnectHandler,
157
- ConnectOptions,
158
- ConnectResult,
159
- DisconnectOptions,
160
- GetConnectionStatusOptions,
161
- HandlerConnectOptions,
162
- ImportFromPortableOptions,
163
- VaultConnectOptions,
164
- Permission,
165
- PortableIdentity,
166
- ProtocolDefinitionCarrier,
167
- ProtocolPermissionRequest,
168
- ProtocolRequest,
169
- RefreshOptions,
170
- RegistrationOptions,
171
- StorageAdapter,
172
- SyncOption,
173
- WalletConnectOptions,
174
- } from '@enbox/auth/browser';
40
+ export * from '@enbox/auth/browser';
175
41
 
176
42
  // ─── Re-exports from @enbox/connect ─────────────────────────────
177
43
  //
@@ -179,7 +45,7 @@ export type {
179
45
  // transports: opening/sealing envelopes and the deny token.
180
46
 
181
47
  export { CONNECT_DENIED_TOKEN, ConnectProvider } from '@enbox/connect';
182
- export type { ConnectApproval, ConnectPermissionRequest, ConnectRequest, ConnectRequestType } from '@enbox/connect';
48
+ export type { ConnectApproval, ConnectRequest } from '@enbox/connect';
183
49
 
184
50
  // ─── Browser-specific exports ───────────────────────────────────
185
51
  //
@@ -213,6 +79,14 @@ export { MAX_PIN_ATTEMPTS, RelayConnectCancelledError, runRelayConnect } from '.
213
79
  export type { RelayConnectOptions } from './relay-connect-runner.js';
214
80
  export { encodeQr, qrToSvg } from './ui/qr.js';
215
81
  export type { QrCode, QrSvgOptions } from './ui/qr.js';
82
+ export { createBlobUrlPool } from './blob-url-pool.js';
83
+ export type { BlobUrlLease, BlobUrlPool } from './blob-url-pool.js';
84
+ export { createFileEnvelopeUrlStore } from './file-envelope-url-store.js';
85
+ export type {
86
+ FileEnvelopeUrlLease,
87
+ FileEnvelopeUrlStore,
88
+ FileEnvelopeUrlStoreOptions,
89
+ } from './file-envelope-url-store.js';
216
90
 
217
91
  // ─── Convenience re-exports for dapps ───────────────────────────
218
92
  //
@@ -222,14 +96,6 @@ export type { QrCode, QrSvgOptions } from './ui/qr.js';
222
96
  // more specialized is still available by importing the underlying package
223
97
  // directly.
224
98
 
225
- // Canonical typed record (from @enbox/api).
226
- export { Record } from '@enbox/api';
227
-
228
- // Custom storage adapter + DWN registration provider-auth callback shapes
229
- // (from @enbox/auth).
230
- export { BrowserStorage } from '@enbox/auth/browser';
231
- export type { ProviderAuthParams, ProviderAuthResult } from '@enbox/auth/browser';
232
-
233
99
  // Public-profile read/cache layer (from @enbox/protocols): fetch other
234
100
  // users' published profiles (displayName + avatar/hero) from their DWNs
235
101
  // with caching, retries, negative caching, and refcounted subscriptions.
@@ -255,12 +121,9 @@ export type {
255
121
  // DWN interface enum for typing agent requests (from @enbox/agent).
256
122
  export { DwnInterface } from '@enbox/agent';
257
123
 
258
- // Sync-status observability for dapp "remotes" panels (from @enbox/agent).
259
- // Subscribe with `agent.sync.on(event => …)` and snapshot per-remote health
260
- // (healthy / quota-blocked / degraded / offline), including when a
261
- // quota-blocked remote will next be re-probed, via
262
- // `agent.sync.getRemoteSyncStatus()`; resume immediately with
263
- // `agent.sync.retryRemoteNow(tenantDid, remoteEndpoint)`.
124
+ // Low-level sync-status types for advanced agent consumers. Dapps using a
125
+ // ConnectionStore receive current advertised rows through `snapshot.sync.remotes`
126
+ // and can call `store.retryRemote(remoteEndpoint)` without wiring agent events.
264
127
  export type {
265
128
  DeadLetterEntry,
266
129
  RemoteSyncState,
@@ -270,6 +133,7 @@ export type {
270
133
  SyncEvent,
271
134
  SyncEventListener,
272
135
  SyncHealthSummary,
136
+ SyncIdentityStatus,
273
137
  } from '@enbox/agent';
274
138
 
275
139
  // Common DWN primitives for record queries, permission scopes, and protocol
@@ -0,0 +1,82 @@
1
+ export type ObjectUrlLease = Readonly<{
2
+ url: string;
3
+ /** Release immediately, cancelling any deferred release. Idempotent. */
4
+ release(): void;
5
+ /** Release after a grace period. The first deferred release wins. */
6
+ releaseAfter(delayMs: number): void;
7
+ }>;
8
+
9
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
10
+
11
+ function assertReleaseDelay(delayMs: number): void {
12
+ if (!Number.isSafeInteger(delayMs) || delayMs < 0 || delayMs > MAX_TIMER_DELAY_MS) {
13
+ throw new TypeError(`delayMs must be an integer between 0 and ${MAX_TIMER_DELAY_MS}.`);
14
+ }
15
+ }
16
+
17
+ /** Private reference-counted ownership for one object URL. */
18
+ export class ObjectUrlReference {
19
+ public readonly url: string;
20
+ private _isRevoked = false;
21
+ private _references = 0;
22
+ private readonly _releaseTimers = new Set<ReturnType<typeof setTimeout>>();
23
+
24
+ public constructor(blob: Blob, private readonly _onEmpty: (reference: ObjectUrlReference) => void) {
25
+ this.url = globalThis.URL.createObjectURL(blob);
26
+ }
27
+
28
+ public get references(): number {
29
+ return this._references;
30
+ }
31
+
32
+ public acquire(): ObjectUrlLease {
33
+ if (this._isRevoked) {
34
+ throw new Error('Object URL has been revoked.');
35
+ }
36
+
37
+ this._references += 1;
38
+ let released = false;
39
+ let releaseTimer: ReturnType<typeof setTimeout> | undefined;
40
+ const release = (): void => {
41
+ if (released) {
42
+ return;
43
+ }
44
+ released = true;
45
+ if (releaseTimer !== undefined) {
46
+ clearTimeout(releaseTimer);
47
+ this._releaseTimers.delete(releaseTimer);
48
+ }
49
+ if (this._isRevoked) {
50
+ return;
51
+ }
52
+ this._references -= 1;
53
+ if (this._references === 0) {
54
+ this._onEmpty(this);
55
+ }
56
+ };
57
+ return Object.freeze({
58
+ url : this.url,
59
+ release,
60
+ releaseAfter : (delayMs: number): void => {
61
+ assertReleaseDelay(delayMs);
62
+ if (released || this._isRevoked || releaseTimer !== undefined) {
63
+ return;
64
+ }
65
+ releaseTimer = setTimeout(release, delayMs);
66
+ this._releaseTimers.add(releaseTimer);
67
+ },
68
+ });
69
+ }
70
+
71
+ public revoke(): void {
72
+ if (!this._isRevoked) {
73
+ this._isRevoked = true;
74
+ for (const timer of this._releaseTimers) {
75
+ clearTimeout(timer);
76
+ }
77
+ this._releaseTimers.clear();
78
+ this._references = 0;
79
+ globalThis.URL.revokeObjectURL(this.url);
80
+ }
81
+ }
82
+ }