sliftutils 1.1.41 → 1.1.43

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,65 @@
1
+ import { isNode } from "typesafecss";
2
+
3
+ // SharedWorker that synchronizes BulkDatabase2 writes between tabs of the same origin. It does NOT
4
+ // persist anything (each tab still writes to disk itself); it just relays writes to other tabs and
5
+ // buffers the recent ones so a freshly-opened tab can catch writes that haven't been flushed to disk
6
+ // yet. Per collection it keeps the latest write per key, pruned to a short recency window (older
7
+ // writes are already on disk, so a new tab gets them from there).
8
+
9
+ type RemoteWrite = { key: string; time: number; deleted?: boolean; value?: unknown };
10
+ type Collection = { ports: Set<MessagePort>; recent: Map<string, RemoteWrite> };
11
+
12
+ const RECENT_WINDOW_MS = 60_000;
13
+ const collections = new Map<string, Collection>();
14
+
15
+ function getCollection(name: string): Collection {
16
+ let c = collections.get(name);
17
+ if (!c) {
18
+ c = { ports: new Set(), recent: new Map() };
19
+ collections.set(name, c);
20
+ }
21
+ return c;
22
+ }
23
+
24
+ function prune(c: Collection) {
25
+ let cutoff = Date.now() - RECENT_WINDOW_MS;
26
+ for (let [key, write] of c.recent) {
27
+ if (write.time < cutoff) c.recent.delete(key);
28
+ }
29
+ }
30
+
31
+ function post(port: MessagePort, message: unknown) {
32
+ try {
33
+ port.postMessage(message);
34
+ } catch {
35
+ // Port closed (tab gone); it'll be dropped on the next failed send.
36
+ }
37
+ }
38
+
39
+ function main() {
40
+ if (isNode()) return;
41
+ (self as unknown as { onconnect: (e: MessageEvent) => void }).onconnect = (event: MessageEvent) => {
42
+ let port = (event as unknown as { ports: MessagePort[] }).ports[0];
43
+ port.onmessage = (ev: MessageEvent) => {
44
+ let msg = ev.data as { type: string; collection: string } & RemoteWrite;
45
+ let c = getCollection(msg.collection);
46
+ if (msg.type === "hello") {
47
+ c.ports.add(port);
48
+ prune(c);
49
+ port.postMessage({ type: "recent", collection: msg.collection, writes: [...c.recent.values()] });
50
+ } else if (msg.type === "write") {
51
+ prune(c);
52
+ let existing = c.recent.get(msg.key);
53
+ if (!existing || msg.time > existing.time) {
54
+ c.recent.set(msg.key, { key: msg.key, time: msg.time, deleted: msg.deleted, value: msg.value });
55
+ }
56
+ for (let other of c.ports) {
57
+ if (other !== port) post(other, msg);
58
+ }
59
+ }
60
+ };
61
+ port.start();
62
+ };
63
+ }
64
+
65
+ main();
@@ -70,6 +70,52 @@ type DirectoryWrapper = {
70
70
  ]>;
71
71
  };
72
72
  export declare function setFileAPIKey(key: string): void;
73
+ export declare class NodeJSFileHandleWrapper implements FileWrapper {
74
+ private filePath;
75
+ constructor(filePath: string);
76
+ getFile(): Promise<{
77
+ size: number;
78
+ lastModified: number;
79
+ arrayBuffer: () => Promise<ArrayBuffer>;
80
+ slice: (start: number, end: number) => {
81
+ arrayBuffer: () => Promise<ArrayBuffer>;
82
+ };
83
+ }>;
84
+ createWritable(config?: {
85
+ keepExistingData?: boolean;
86
+ }): Promise<{
87
+ seek: (offset: number) => Promise<void>;
88
+ write: (value: Buffer) => Promise<void>;
89
+ close: () => Promise<void>;
90
+ }>;
91
+ }
92
+ export declare class NodeJSDirectoryHandleWrapper implements DirectoryWrapper {
93
+ private rootPath;
94
+ constructor(rootPath: string);
95
+ removeEntry(key: string, options?: {
96
+ recursive?: boolean;
97
+ }): Promise<void>;
98
+ getFileHandle(key: string, options?: {
99
+ create?: boolean;
100
+ }): Promise<FileWrapper>;
101
+ getDirectoryHandle(key: string, options?: {
102
+ create?: boolean;
103
+ }): Promise<DirectoryWrapper>;
104
+ [Symbol.asyncIterator](): AsyncIterableIterator<[
105
+ string,
106
+ {
107
+ kind: "file";
108
+ name: string;
109
+ getFile(): Promise<FileWrapper>;
110
+ } | {
111
+ kind: "directory";
112
+ name: string;
113
+ getDirectoryHandle(key: string, options?: {
114
+ create?: boolean;
115
+ }): Promise<DirectoryWrapper>;
116
+ }
117
+ ]>;
118
+ }
73
119
  export declare const getDirectoryHandle: {
74
120
  (): Promise<DirectoryWrapper>;
75
121
  reset(): void;
@@ -98,4 +144,6 @@ export type NestedFileStorage = {
98
144
  export type FileStorage = IStorageRaw & {
99
145
  folder: NestedFileStorage;
100
146
  };
147
+ export declare function wrapHandle(handle: DirectoryWrapper): FileStorage;
148
+ export declare function tryToLoadPointer(pointer: string): Promise<DirectoryWrapper | undefined>;
101
149
  export {};
@@ -96,7 +96,7 @@ class DirectoryPrompter extends preact.Component {
96
96
  }
97
97
  }
98
98
 
99
- class NodeJSFileHandleWrapper implements FileWrapper {
99
+ export class NodeJSFileHandleWrapper implements FileWrapper {
100
100
  constructor(private filePath: string) {
101
101
  }
102
102
 
@@ -159,7 +159,7 @@ class NodeJSFileHandleWrapper implements FileWrapper {
159
159
  }
160
160
  }
161
161
 
162
- class NodeJSDirectoryHandleWrapper implements DirectoryWrapper {
162
+ export class NodeJSDirectoryHandleWrapper implements DirectoryWrapper {
163
163
  constructor(private rootPath: string) {
164
164
  }
165
165
 
@@ -241,6 +241,7 @@ class NodeJSDirectoryHandleWrapper implements DirectoryWrapper {
241
241
 
242
242
 
243
243
  // NOTE: Blocks until the user provides a directory
244
+ // NOTE: If you want to override the location, you can explicitly call getDirectoryHandle.set with your own directory wrapper. Which if your server side can be the Node.js directory handle wrapper, or if your client side it can just be a pointer from tryToLoadPointer/fileSystemPointer.ts.
244
245
  export const getDirectoryHandle = lazy(async function getDirectoryHandle(): Promise<DirectoryWrapper> {
245
246
  if (isNode()) {
246
247
  return new NodeJSDirectoryHandleWrapper(path.resolve("./data/"));
@@ -434,40 +435,78 @@ async function fixedGetFileHandle(config: {
434
435
  return await config.handle.getFileHandle(config.key, { create: true });
435
436
  }
436
437
 
438
+ // A file that genuinely does not exist. We must NOT retry these, otherwise reading many missing
439
+ // files (a common pattern) gets catastrophically slow.
440
+ function isMissingError(error: unknown): boolean {
441
+ let name = (error as { name?: string })?.name;
442
+ let code = (error as { code?: string })?.code;
443
+ return name === "NotFoundError" || code === "ENOENT";
444
+ }
445
+
446
+ const MAX_READ_RETRIES = 6;
447
+
448
+ // The browser File System Access API can transiently fail reads under heavy load (it appears to
449
+ // throttle when a page issues many reads at once). Those failures surface as errors OTHER than
450
+ // "not found", so we retry them with backoff. A real missing file (NotFoundError / ENOENT) returns
451
+ // undefined immediately with no retry.
452
+ async function readWithRetry<T>(label: string, key: string, read: () => Promise<T>): Promise<T | undefined> {
453
+ let backoff = 25;
454
+ for (let attempt = 0; ; attempt++) {
455
+ try {
456
+ return await read();
457
+ } catch (error) {
458
+ if (isMissingError(error)) return undefined;
459
+ let name = (error as { name?: string })?.name || "Error";
460
+ let message = (error as { message?: string })?.message || String(error);
461
+ if (attempt >= MAX_READ_RETRIES) {
462
+ console.warn(`${label} gave up on ${JSON.stringify(key)} after ${attempt} retries (${name}): ${message.slice(0, 200)}`);
463
+ return undefined;
464
+ }
465
+ console.warn(`${label} retrying ${JSON.stringify(key)} (attempt ${attempt + 1}, ${name}): ${message.slice(0, 200)}`);
466
+ await new Promise(resolve => setTimeout(resolve, backoff));
467
+ backoff = Math.min(backoff * 2, 1000);
468
+ }
469
+ }
470
+ }
471
+
437
472
  function wrapHandleFiles(handle: DirectoryWrapper): IStorageRaw {
438
473
  return {
439
474
  async getInfo(key: string) {
440
- try {
475
+ return readWithRetry("getInfo", key, async () => {
441
476
  const file = await handle.getFileHandle(key);
442
477
  const fileContent = await file.getFile();
443
478
  return {
444
479
  size: fileContent.size,
445
480
  lastModified: fileContent.lastModified,
446
481
  };
447
- } catch (error) {
448
- return undefined;
449
- }
482
+ });
450
483
  },
451
484
  async get(key: string): Promise<Buffer | undefined> {
452
- try {
485
+ return readWithRetry("get", key, async () => {
453
486
  const file = await handle.getFileHandle(key);
454
487
  const fileContent = await file.getFile();
455
488
  const arrayBuffer = await fileContent.arrayBuffer();
489
+ // Under load the FS Access API can resolve with a truncated buffer and no error.
490
+ // Treat a short read as transient so readWithRetry retries it.
491
+ if (arrayBuffer.byteLength !== fileContent.size) {
492
+ throw Object.assign(new Error(`Short read: got ${arrayBuffer.byteLength} of ${fileContent.size} bytes`), { name: "ShortReadError" });
493
+ }
456
494
  return Buffer.from(arrayBuffer);
457
- } catch (error) {
458
- return undefined;
459
- }
495
+ });
460
496
  },
461
497
 
462
498
  async getRange(key: string, config: { start: number; end: number }): Promise<Buffer | undefined> {
463
- try {
499
+ return readWithRetry("getRange", key, async () => {
464
500
  const file = await handle.getFileHandle(key);
465
501
  const fileContent = await file.getFile();
502
+ const clampedStart = Math.min(Math.max(config.start, 0), fileContent.size);
503
+ const clampedEnd = Math.min(Math.max(config.end, clampedStart), fileContent.size);
466
504
  const arrayBuffer = await fileContent.slice(config.start, config.end).arrayBuffer();
505
+ if (arrayBuffer.byteLength !== clampedEnd - clampedStart) {
506
+ throw Object.assign(new Error(`Short range read: got ${arrayBuffer.byteLength} of ${clampedEnd - clampedStart} bytes`), { name: "ShortReadError" });
507
+ }
467
508
  return Buffer.from(arrayBuffer);
468
- } catch (error) {
469
- return undefined;
470
- }
509
+ });
471
510
  },
472
511
 
473
512
  async append(key: string, value: Buffer): Promise<void> {
@@ -496,10 +535,18 @@ function wrapHandleFiles(handle: DirectoryWrapper): IStorageRaw {
496
535
 
497
536
  async getKeys(includeFolders: boolean = false): Promise<string[]> {
498
537
  const keys: string[] = [];
499
- for await (const [name, entry] of handle) {
500
- if (entry.kind === "file" || includeFolders) {
501
- keys.push(entry.name);
538
+ try {
539
+ for await (const [name, entry] of handle) {
540
+ if (entry.kind === "file" || includeFolders) {
541
+ keys.push(entry.name);
542
+ }
502
543
  }
544
+ } catch (error) {
545
+ let name = (error as { name?: string })?.name || "Error";
546
+ let message = (error as { message?: string })?.message || String(error);
547
+ // A failure mid-iteration would silently truncate the listing, so surface it loudly.
548
+ console.error(`getKeys directory iteration failed after ${keys.length} entries (${name}): ${message.slice(0, 300)}`);
549
+ throw error;
503
550
  }
504
551
  return keys;
505
552
  },
@@ -544,14 +591,14 @@ function wrapHandleNested(handle: DirectoryWrapper): NestedFileStorage {
544
591
  };
545
592
  }
546
593
 
547
- function wrapHandle(handle: DirectoryWrapper): FileStorage {
594
+ export function wrapHandle(handle: DirectoryWrapper): FileStorage {
548
595
  return {
549
596
  ...wrapHandleFiles(handle),
550
597
  folder: wrapHandleNested(handle),
551
598
  };
552
599
  }
553
600
 
554
- async function tryToLoadPointer(pointer: string) {
601
+ export async function tryToLoadPointer(pointer: string) {
555
602
  let result = await getFileSystemPointer({ pointer });
556
603
  if (!result) return;
557
604
  let handle = await result?.onUserActivation();
package/yarn.lock CHANGED
@@ -1940,10 +1940,10 @@ slash@^3.0.0:
1940
1940
  resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
1941
1941
  integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
1942
1942
 
1943
- socket-function@^1.1.30:
1944
- version "1.1.30"
1945
- resolved "https://registry.yarnpkg.com/socket-function/-/socket-function-1.1.30.tgz#adeb9614996e727518e2abdc7a1aa90544d669cd"
1946
- integrity sha512-EcqNpM8i0icBr2iU80aovXI05aUBrB4P4e9U7EQyju68TGAMagdA4OQXi+V1MHUipH/3nIdw3UAVYv+kFwZwnQ==
1943
+ socket-function@^1.1.38:
1944
+ version "1.1.38"
1945
+ resolved "https://registry.yarnpkg.com/socket-function/-/socket-function-1.1.38.tgz#8923a1db19480d5828d9bde9975eabac505698ce"
1946
+ integrity sha512-LJFeVUaXT8D8egyKr7fZajq7ctmLUN58Uih6GHx3/pfXV3pDFfJS0+XJRqsk0udn0II1ZqozEz242khs4fLCKQ==
1947
1947
  dependencies:
1948
1948
  "@types/pako" "^2.0.3"
1949
1949
  "@types/ws" "^8.5.3"