pal-explorer-cli 0.4.7 → 0.4.9

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.
@@ -17,12 +17,8 @@ const EXTENSIONS_DIR = path.join(
17
17
 
18
18
  const BUNDLED_DIR = path.join(EXTENSIONS_DIR, '@palexplorer');
19
19
 
20
- const CORE_EXTENSIONS = new Set([
21
- 'analytics',
22
- 'discovery',
23
- 'explorer-integration',
24
- 'vfs',
25
- ]);
20
+ // No "core" extensions — all extensions can be enabled/disabled by the user.
21
+ // Extensions that should be on by default set "config.enabled.default": true in their manifest.
26
22
 
27
23
  // Ed25519 public key for verifying extension signatures (Palexplorer team key)
28
24
  const TEAM_PUBLIC_KEY = 'ba71d876238e24b1b230c567a6b7339abdf55030e98580a5a883b97ee0a3f084';
@@ -517,7 +513,6 @@ function getInstalledExtensions() {
517
513
  const manifest = readManifest(bundledPath);
518
514
  if (manifest) {
519
515
  const fullName = `@palexplorer/${bundled}`;
520
- const isCore = CORE_EXTENSIONS.has(bundled);
521
516
  const explicitEnabled = (config.get('enabledExtensions') || []).includes(fullName);
522
517
  const explicitDisabled = disabled.includes(fullName);
523
518
  const manifestDefault = manifest.config?.enabled?.default === true;
@@ -525,9 +520,7 @@ function getInstalledExtensions() {
525
520
  ...manifest,
526
521
  path: bundledPath,
527
522
  bundled: true,
528
- enabled: isCore
529
- ? !explicitDisabled
530
- : explicitDisabled ? false : (explicitEnabled || manifestDefault),
523
+ enabled: explicitDisabled ? false : (explicitEnabled || manifestDefault),
531
524
  });
532
525
  }
533
526
  }
@@ -896,12 +889,10 @@ function enableExtension(name) {
896
889
  const newDisabled = disabled.filter(d => d !== name && d !== full && d !== short);
897
890
  if (newDisabled.length !== disabled.length) config.set('disabledExtensions', newDisabled);
898
891
 
899
- if (!CORE_EXTENSIONS.has(short)) {
900
- const enabled = config.get('enabledExtensions') || [];
901
- if (!enabled.includes(full)) {
902
- enabled.push(full);
903
- config.set('enabledExtensions', enabled);
904
- }
892
+ const enabled = config.get('enabledExtensions') || [];
893
+ if (!enabled.includes(full)) {
894
+ enabled.push(full);
895
+ config.set('enabledExtensions', enabled);
905
896
  }
906
897
  }
907
898
 
@@ -915,11 +906,9 @@ function disableExtension(name) {
915
906
  config.set('disabledExtensions', disabled);
916
907
  }
917
908
 
918
- if (!CORE_EXTENSIONS.has(short)) {
919
- const enabled = config.get('enabledExtensions') || [];
920
- const newEnabled = enabled.filter(e => e !== name && e !== full && e !== short);
921
- if (newEnabled.length !== enabled.length) config.set('enabledExtensions', newEnabled);
922
- }
909
+ const enabled = config.get('enabledExtensions') || [];
910
+ const newEnabled = enabled.filter(e => e !== name && e !== full && e !== short);
911
+ if (newEnabled.length !== enabled.length) config.set('enabledExtensions', newEnabled);
923
912
  unloadExtension(name);
924
913
  }
925
914
 
@@ -1012,6 +1001,11 @@ function getContributedPages() {
1012
1001
 
1013
1002
  function ensureDefaultExtensions() {
1014
1003
  if (!fs.existsSync(BUNDLED_DIR)) return;
1004
+ // Ensure extensions dir has ESM package.json so import() works
1005
+ const extPkg = path.join(EXTENSIONS_DIR, 'package.json');
1006
+ if (!fs.existsSync(extPkg)) {
1007
+ fs.writeFileSync(extPkg, '{ "type": "module" }\n');
1008
+ }
1015
1009
  const enabled = config.get('enabledExtensions') || [];
1016
1010
  const disabled = config.get('disabledExtensions') || [];
1017
1011
  let changed = false;
@@ -1019,7 +1013,7 @@ function ensureDefaultExtensions() {
1019
1013
  const manifest = readManifest(path.join(BUNDLED_DIR, bundled));
1020
1014
  if (!manifest) continue;
1021
1015
  const full = `@palexplorer/${bundled}`;
1022
- if (manifest.config?.enabled?.default === true && !CORE_EXTENSIONS.has(bundled)) {
1016
+ if (manifest.config?.enabled?.default === true) {
1023
1017
  if (!enabled.includes(full) && !disabled.includes(full)) {
1024
1018
  enabled.push(full);
1025
1019
  changed = true;
@@ -1,7 +1,11 @@
1
1
  import crypto from 'crypto';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
+ import { EventEmitter } from 'events';
4
5
  import logger from '../utils/logger.js';
6
+ import { buildProtocolManifest, computeDelta } from '../protocol/sync.js';
7
+ import { syncManifest as createSyncManifestMsg, syncRequest as createSyncRequestMsg, syncConflict as createSyncConflictMsg } from '../protocol/messages.js';
8
+ import { registerSession, unregisterSession } from './syncProtocolHandlers.js';
5
9
 
6
10
  // mtime+size hash cache: skip re-hashing files that haven't changed
7
11
  const _hashCache = new Map();
@@ -30,6 +34,25 @@ function shouldIgnore(relativePath) {
30
34
  return IGNORE_PATTERNS.some(p => p.test(relativePath));
31
35
  }
32
36
 
37
+ function matchesAny(filePath, patterns) {
38
+ for (const pattern of patterns) {
39
+ // Simple glob: *.ext, dir/*, **/pattern
40
+ const regex = globToRegex(pattern);
41
+ if (regex.test(filePath)) return true;
42
+ }
43
+ return false;
44
+ }
45
+
46
+ function globToRegex(glob) {
47
+ const escaped = glob
48
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
49
+ .replace(/\*\*/g, '§DOUBLESTAR§')
50
+ .replace(/\*/g, '[^/]*')
51
+ .replace(/§DOUBLESTAR§/g, '.*')
52
+ .replace(/\?/g, '[^/]');
53
+ return new RegExp(`^${escaped}$|/${escaped}$|^${escaped}/`);
54
+ }
55
+
33
56
  function hashFileStream(filePath) {
34
57
  return new Promise((resolve, reject) => {
35
58
  const h = crypto.createHash('sha256');
@@ -40,7 +63,8 @@ function hashFileStream(filePath) {
40
63
  });
41
64
  }
42
65
 
43
- export async function buildManifest(dirPath, cachedManifest) {
66
+ export async function buildManifest(dirPath, cachedManifest, options = {}) {
67
+ const { exclude = [], include = [], checksum = false, sizeOnly = false } = options;
44
68
  const cachedMap = new Map();
45
69
  if (cachedManifest) {
46
70
  const entries = Array.isArray(cachedManifest) ? cachedManifest : Object.values(cachedManifest);
@@ -72,6 +96,10 @@ export async function buildManifest(dirPath, cachedManifest) {
72
96
  const relativePath = relBase ? `${relBase}/${dirent.name}` : dirent.name;
73
97
 
74
98
  if (shouldIgnore(relativePath)) continue;
99
+ // User exclude/include patterns
100
+ if (exclude.length > 0 && matchesAny(relativePath, exclude)) {
101
+ if (include.length === 0 || !matchesAny(relativePath, include)) continue;
102
+ }
75
103
 
76
104
  if (dirent.isDirectory()) {
77
105
  await walk(fullPath, relativePath);
@@ -186,8 +214,8 @@ export function detectConflicts(localManifest, remoteManifest, baseManifest) {
186
214
  }
187
215
 
188
216
  export function applyDiff(diff, sourcePath, destPath, options = {}) {
189
- const { dryRun = false } = options;
190
- const results = { copied: [], deleted: [], conflicted: [], errors: [] };
217
+ const { dryRun = false, backup = false, update = false, ignoreExisting = false } = options;
218
+ const results = { copied: [], deleted: [], conflicted: [], skipped: [], errors: [] };
191
219
 
192
220
  for (const entry of diff.added) {
193
221
  const src = path.join(sourcePath, entry.relativePath);
@@ -208,12 +236,35 @@ export function applyDiff(diff, sourcePath, destPath, options = {}) {
208
236
  for (const entry of diff.modified) {
209
237
  const src = path.join(sourcePath, entry.relativePath);
210
238
  const dst = path.join(destPath, entry.relativePath);
239
+
240
+ // --ignore-existing: skip files that exist on destination
241
+ if (ignoreExisting && fs.existsSync(dst)) {
242
+ results.skipped.push(entry.relativePath);
243
+ continue;
244
+ }
245
+
246
+ // --update: skip files newer on destination
247
+ if (update && fs.existsSync(dst)) {
248
+ try {
249
+ const dstStat = fs.statSync(dst);
250
+ const srcStat = fs.statSync(src);
251
+ if (dstStat.mtimeMs > srcStat.mtimeMs) {
252
+ results.skipped.push(entry.relativePath);
253
+ continue;
254
+ }
255
+ } catch {}
256
+ }
257
+
211
258
  if (dryRun) {
212
259
  results.copied.push(entry.relativePath);
213
260
  continue;
214
261
  }
215
262
  try {
216
263
  fs.mkdirSync(path.dirname(dst), { recursive: true });
264
+ // --backup: rename before overwriting
265
+ if (backup && fs.existsSync(dst)) {
266
+ fs.renameSync(dst, dst + '.bak');
267
+ }
217
268
  fs.copyFileSync(src, dst);
218
269
  results.copied.push(entry.relativePath);
219
270
  } catch (err) {
@@ -261,4 +312,226 @@ export function applyDiff(diff, sourcePath, destPath, options = {}) {
261
312
  return results;
262
313
  }
263
314
 
315
+ // --- SyncSession: P2P sync over PAL/1.0 protocol ---
316
+
317
+ export class SyncSession extends EventEmitter {
318
+ constructor({ localPath, peerPK, transport, keyPair, syncPairId, options = {} }) {
319
+ super();
320
+ this.localPath = path.resolve(localPath);
321
+ this.peerPK = peerPK;
322
+ this.transport = transport;
323
+ this.keyPair = keyPair;
324
+ this.syncPairId = syncPairId || crypto.randomUUID();
325
+ this.options = options;
326
+ this.state = 'idle'; // idle → scanning → exchanging → transferring → done/error
327
+ this.localManifest = null;
328
+ this.remoteManifest = null;
329
+ this.diff = null;
330
+ this.generation = Date.now();
331
+ this._resolveRemoteManifest = null;
332
+ this._resolveFileData = null;
333
+ this._receivedFiles = new Map();
334
+ }
335
+
336
+ async start(cachedManifest) {
337
+ try {
338
+ this.state = 'scanning';
339
+ this.emit('state', 'scanning');
340
+ registerSession(this.syncPairId, this);
341
+
342
+ // Build local manifest (respects exclude/include/checksum options)
343
+ this.localManifest = await buildManifest(this.localPath, cachedManifest, this.options);
344
+
345
+ // Build protocol manifest and send via transport
346
+ this.state = 'exchanging';
347
+ this.emit('state', 'exchanging');
348
+
349
+ const protoManifest = buildProtocolManifest(this.localManifest, this.syncPairId, this.generation);
350
+ const envelope = createSyncManifestMsg(this.keyPair, this.peerPK, protoManifest);
351
+ await this.transport.send(envelope);
352
+ this.emit('manifestSent', { entries: this.localManifest.length });
353
+
354
+ return { localManifest: this.localManifest, syncPairId: this.syncPairId };
355
+ } catch (err) {
356
+ this.state = 'error';
357
+ this.emit('error', err);
358
+ throw err;
359
+ }
360
+ }
361
+
362
+ handleRemoteManifest(data) {
363
+ // Convert protocol manifest entries back to engine format
364
+ const entries = (data.entries || []).map(e => ({
365
+ relativePath: e.path || e.relativePath,
366
+ size: e.size,
367
+ hash: e.hash,
368
+ mtime: e.mtime,
369
+ }));
370
+ this.remoteManifest = entries;
371
+ this.emit('manifestReceived', { entries: entries.length, from: data.from });
372
+
373
+ if (this._resolveRemoteManifest) {
374
+ this._resolveRemoteManifest(entries);
375
+ this._resolveRemoteManifest = null;
376
+ }
377
+ }
378
+
379
+ waitForRemoteManifest(timeoutMs = 30000) {
380
+ if (this.remoteManifest) return Promise.resolve(this.remoteManifest);
381
+ return new Promise((resolve, reject) => {
382
+ this._resolveRemoteManifest = resolve;
383
+ const timer = setTimeout(() => {
384
+ this._resolveRemoteManifest = null;
385
+ reject(new Error('Timed out waiting for remote manifest'));
386
+ }, timeoutMs);
387
+ // Clean up timer if resolved
388
+ const origResolve = this._resolveRemoteManifest;
389
+ this._resolveRemoteManifest = (val) => {
390
+ clearTimeout(timer);
391
+ origResolve(val);
392
+ };
393
+ });
394
+ }
395
+
396
+ computeSync(baseManifest) {
397
+ if (!this.localManifest || !this.remoteManifest) {
398
+ throw new Error('Both local and remote manifests required');
399
+ }
400
+
401
+ const localMap = manifestToMap(this.localManifest);
402
+ const remoteMap = manifestToMap(this.remoteManifest);
403
+ const baseMap = baseManifest ? (Array.isArray(baseManifest) ? manifestToMap(baseManifest) : baseManifest) : {};
404
+
405
+ // Compute what we need from remote (files remote has that we don't or are different)
406
+ const diff = diffManifests(localMap, remoteMap);
407
+ const { conflicts, safeModified } = detectConflicts(localMap, remoteMap, baseMap);
408
+
409
+ // Separate real conflicts from safe modifications in the diff
410
+ if (conflicts.length > 0) {
411
+ diff.conflicts = conflicts;
412
+ diff.modified = diff.modified.filter(
413
+ m => !conflicts.some(c => c.relativePath === m.relativePath)
414
+ );
415
+ }
416
+
417
+ this.diff = diff;
418
+ this.emit('diffComputed', {
419
+ added: diff.added.length,
420
+ modified: diff.modified.length,
421
+ deleted: diff.deleted.length,
422
+ conflicts: (diff.conflicts || []).length,
423
+ });
424
+
425
+ return diff;
426
+ }
427
+
428
+ async requestFiles(filesToRequest) {
429
+ if (!filesToRequest || filesToRequest.length === 0) return;
430
+
431
+ this.state = 'transferring';
432
+ this.emit('state', 'transferring');
433
+
434
+ const requestPayload = filesToRequest.map(f => ({
435
+ path: f.relativePath || f,
436
+ mode: 'full',
437
+ }));
438
+
439
+ const envelope = createSyncRequestMsg(this.keyPair, this.peerPK, {
440
+ syncPairId: this.syncPairId,
441
+ files: requestPayload,
442
+ });
443
+
444
+ await this.transport.send(envelope);
445
+ this.emit('filesRequested', { count: requestPayload.length });
446
+ }
447
+
448
+ handleSyncRequest(data) {
449
+ // Peer is requesting files from us — read and stream them
450
+ const files = data.files || [];
451
+ this.emit('syncRequest', { from: data.from, files: files.length });
452
+
453
+ const fileData = {};
454
+ for (const req of files) {
455
+ const filePath = path.join(this.localPath, req.path);
456
+ try {
457
+ if (fs.existsSync(filePath)) {
458
+ fileData[req.path] = fs.readFileSync(filePath);
459
+ }
460
+ } catch (err) {
461
+ logger.warn(`Cannot read requested file: ${req.path} (${err.message})`);
462
+ }
463
+ }
464
+
465
+ this._receivedFiles = new Map(Object.entries(fileData));
466
+ this.emit('filesReady', { count: Object.keys(fileData).length });
467
+ return fileData;
468
+ }
469
+
470
+ handleSyncDiff(data) {
471
+ this.emit('syncDiff', data);
472
+ }
473
+
474
+ handleConflict(data) {
475
+ const conflicts = data.conflicts || [];
476
+ this.emit('conflict', { count: conflicts.length, conflicts });
477
+ }
478
+
479
+ applyChanges(sourcePath, options = {}) {
480
+ if (!this.diff) throw new Error('No diff computed. Call computeSync() first.');
481
+ const mergedOpts = { ...this.options, ...options };
482
+ return applyDiff(this.diff, sourcePath || this.localPath, this.localPath, mergedOpts);
483
+ }
484
+
485
+ // Delta sync: request only changed blocks of modified files (4KB blocks)
486
+ async requestDelta(modifiedFiles) {
487
+ if (!modifiedFiles || modifiedFiles.length === 0) return [];
488
+
489
+ const deltaRequests = [];
490
+ for (const file of modifiedFiles) {
491
+ const localPath = path.join(this.localPath, file.relativePath);
492
+ if (!fs.existsSync(localPath)) continue;
493
+
494
+ try {
495
+ const { computeBlockHashes } = await import('../protocol/sync.js');
496
+ const localHashes = computeBlockHashes(localPath, 4096);
497
+ deltaRequests.push({
498
+ path: file.relativePath,
499
+ mode: 'delta',
500
+ blockSize: 4096,
501
+ localBlockHashes: localHashes.hashes,
502
+ localSize: localHashes.totalSize,
503
+ });
504
+ } catch {
505
+ // Fall back to full file if delta not available (e.g., not Pro)
506
+ deltaRequests.push({ path: file.relativePath, mode: 'full' });
507
+ }
508
+ }
509
+
510
+ if (deltaRequests.length > 0) {
511
+ const envelope = createSyncRequestMsg(this.keyPair, this.peerPK, {
512
+ syncPairId: this.syncPairId,
513
+ files: deltaRequests,
514
+ });
515
+ await this.transport.send(envelope);
516
+ this.emit('deltaRequested', { count: deltaRequests.length });
517
+ }
518
+
519
+ return deltaRequests;
520
+ }
521
+
522
+ finish() {
523
+ this.state = 'done';
524
+ this.emit('state', 'done');
525
+ unregisterSession(this.syncPairId);
526
+ this.transport.close();
527
+ }
528
+
529
+ abort(reason) {
530
+ this.state = 'error';
531
+ this.emit('error', new Error(reason || 'Session aborted'));
532
+ unregisterSession(this.syncPairId);
533
+ this.transport.close();
534
+ }
535
+ }
536
+
264
537
  export { formatSize } from '../utils/format.js';
@@ -0,0 +1,80 @@
1
+ // Parse and validate sync CLI flags into engine options
2
+
3
+ export const SYNC_FLAGS = {
4
+ dryRun: { flag: '--dry-run', description: 'Show changes without applying', default: false },
5
+ delete: { flag: '--delete', description: 'Delete destination files not in source', default: false },
6
+ exclude: { flag: '--exclude <pattern>', description: 'Glob patterns to skip (repeatable)', default: [] },
7
+ include: { flag: '--include <pattern>', description: 'Override exclude for specific patterns (repeatable)', default: [] },
8
+ progress: { flag: '--progress', description: 'Show transfer progress (bytes, speed, ETA)', default: false },
9
+ verbose: { flag: '-v, --verbose', description: 'Per-file operation output', default: false },
10
+ checksum: { flag: '--checksum', description: 'Force hash comparison (skip mtime shortcut)', default: false },
11
+ sizeOnly: { flag: '--size-only', description: 'Compare by size only', default: false },
12
+ update: { flag: '--update', description: 'Skip files newer on destination', default: false },
13
+ ignoreExisting: { flag: '--ignore-existing', description: 'Skip files that exist on destination', default: false },
14
+ backup: { flag: '--backup', description: 'Rename before overwriting (.bak)', default: false },
15
+ partial: { flag: '--partial', description: 'Keep partial files for resume', default: false },
16
+ bandwidthLimit: { flag: '--bandwidth-limit <KBps>', description: 'Throttle transfer speed', default: 0 },
17
+ compress: { flag: '--compress', description: 'zlib compress chunks before transfer', default: false },
18
+ recursive: { flag: '--no-recursive', description: 'Flat sync (recursive is default)', default: true },
19
+ archive: { flag: '--archive', description: 'Shorthand for --recursive --checksum --delete', default: false },
20
+ };
21
+
22
+ export function parseSyncOptions(opts) {
23
+ const options = {
24
+ dryRun: opts.dryRun || false,
25
+ delete: opts.delete || false,
26
+ exclude: normalizeArray(opts.exclude),
27
+ include: normalizeArray(opts.include),
28
+ progress: opts.progress || false,
29
+ verbose: opts.verbose || false,
30
+ checksum: opts.checksum || false,
31
+ sizeOnly: opts.sizeOnly || false,
32
+ update: opts.update || false,
33
+ ignoreExisting: opts.ignoreExisting || false,
34
+ backup: opts.backup || false,
35
+ partial: opts.partial || false,
36
+ bandwidthLimit: parseInt(opts.bandwidthLimit, 10) || 0,
37
+ compress: opts.compress || false,
38
+ recursive: opts.recursive !== false,
39
+ };
40
+
41
+ // --archive expands to --recursive --checksum --delete
42
+ if (opts.archive) {
43
+ options.recursive = true;
44
+ options.checksum = true;
45
+ options.delete = true;
46
+ }
47
+
48
+ // Validate: --checksum and --size-only are mutually exclusive
49
+ if (options.checksum && options.sizeOnly) {
50
+ throw new Error('Cannot use --checksum and --size-only together');
51
+ }
52
+
53
+ return options;
54
+ }
55
+
56
+ function normalizeArray(val) {
57
+ if (!val) return [];
58
+ if (Array.isArray(val)) return val;
59
+ return [val];
60
+ }
61
+
62
+ export function addSyncFlagsToCommand(cmd) {
63
+ return cmd
64
+ .option('--dry-run', 'Show changes without applying')
65
+ .option('--delete', 'Delete destination files not in source')
66
+ .option('--exclude <pattern...>', 'Glob patterns to skip')
67
+ .option('--include <pattern...>', 'Override exclude for specific patterns')
68
+ .option('--progress', 'Show transfer progress')
69
+ .option('-v, --verbose', 'Per-file operation output')
70
+ .option('--checksum', 'Force hash comparison (skip mtime shortcut)')
71
+ .option('--size-only', 'Compare by size only')
72
+ .option('--update', 'Skip files newer on destination')
73
+ .option('--ignore-existing', 'Skip files that exist on destination')
74
+ .option('--backup', 'Rename before overwriting (.bak)')
75
+ .option('--partial', 'Keep partial files for resume')
76
+ .option('--bandwidth-limit <KBps>', 'Throttle transfer speed (KB/s)')
77
+ .option('--compress', 'Compress chunks before transfer')
78
+ .option('--no-recursive', 'Flat sync (recursive by default)')
79
+ .option('--archive', 'Shorthand for --recursive --checksum --delete');
80
+ }
@@ -0,0 +1,87 @@
1
+ import { on, off } from '../protocol/handler.js';
2
+ import logger from '../utils/logger.js';
3
+
4
+ // Registry of active sync sessions by syncPairId
5
+ const activeSessions = new Map();
6
+
7
+ // Bridge protocol handler events to sync engine sessions
8
+ const handlers = {
9
+ 'sync.manifest': (data) => {
10
+ const session = activeSessions.get(data.syncPairId) || findSessionByPeer(data.from);
11
+ if (session) {
12
+ session.handleRemoteManifest(data);
13
+ } else {
14
+ logger.debug(`[sync] Received manifest from ${data.from?.slice(0, 8)} but no active session`);
15
+ pendingManifests.set(data.from, data);
16
+ }
17
+ },
18
+ 'sync.diff': (data) => {
19
+ const session = activeSessions.get(data.syncPairId) || findSessionByPeer(data.from);
20
+ if (session?.handleSyncDiff) session.handleSyncDiff(data);
21
+ },
22
+ 'sync.request': (data) => {
23
+ const session = activeSessions.get(data.syncPairId) || findSessionByPeer(data.from);
24
+ if (session?.handleSyncRequest) session.handleSyncRequest(data);
25
+ },
26
+ 'sync.conflict': (data) => {
27
+ const session = activeSessions.get(data.syncPairId) || findSessionByPeer(data.from);
28
+ if (session?.handleConflict) session.handleConflict(data);
29
+ },
30
+ };
31
+
32
+ // Store manifests that arrive before a session is created
33
+ const pendingManifests = new Map();
34
+
35
+ function findSessionByPeer(peerPK) {
36
+ for (const session of activeSessions.values()) {
37
+ if (session.peerPK === peerPK) return session;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ let registered = false;
43
+
44
+ export function registerSyncHandlers() {
45
+ if (registered) return;
46
+ for (const [type, handler] of Object.entries(handlers)) {
47
+ on(type, handler);
48
+ }
49
+ registered = true;
50
+ }
51
+
52
+ export function unregisterSyncHandlers() {
53
+ if (!registered) return;
54
+ for (const [type, handler] of Object.entries(handlers)) {
55
+ off(type, handler);
56
+ }
57
+ registered = false;
58
+ activeSessions.clear();
59
+ pendingManifests.clear();
60
+ }
61
+
62
+ export function registerSession(syncPairId, session) {
63
+ activeSessions.set(syncPairId, session);
64
+ // Check for pending manifests from this peer
65
+ const pending = pendingManifests.get(session.peerPK);
66
+ if (pending) {
67
+ pendingManifests.delete(session.peerPK);
68
+ session.handleRemoteManifest(pending);
69
+ }
70
+ }
71
+
72
+ export function unregisterSession(syncPairId) {
73
+ activeSessions.delete(syncPairId);
74
+ }
75
+
76
+ export function getActiveSession(syncPairId) {
77
+ return activeSessions.get(syncPairId) || null;
78
+ }
79
+
80
+ export function getPendingManifest(peerPK) {
81
+ const manifest = pendingManifests.get(peerPK);
82
+ if (manifest) {
83
+ pendingManifests.delete(peerPK);
84
+ return manifest;
85
+ }
86
+ return null;
87
+ }