@banou/ponyfill 0.0.2 → 0.0.4

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/README.md CHANGED
@@ -58,10 +58,10 @@ That is not hypothetical. It is why four of ripple's storage eviction tests sat
58
58
  they filled the origin to provoke that condition, and 3.5 GB of padding left the free figure
59
59
  identical to the byte.
60
60
 
61
- This package does **not** invent a normalised quota, because there is no honest number to invent: on
62
- Chromium you really can write 10 more GiB, so the native answer is true. It names the two shapes
63
- (`QUOTA_CEILING`) and can measure which one an origin has (`measureQuotaCeiling`), by writing a
64
- sparse probe and watching, because at rest the two are indistinguishable.
61
+ This package does **not** invent a normalised quota, and does not add API for the difference either:
62
+ on Chromium you really can write 10 more GiB, so the native answer is true, and there is no platform
63
+ name for "which shape is this". It is written down here and in the source because the two are
64
+ indistinguishable at rest, which is how the difference went unnoticed for months.
65
65
 
66
66
  ## Adding to it
67
67
 
package/build/index.cjs CHANGED
@@ -1,8 +1,3 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_storage = require("./storage.cjs");
3
- exports.QUOTA_CEILING = require_storage.QUOTA_CEILING;
4
- exports.correctedUsage = require_storage.correctedUsage;
5
- exports.isUsageUnderReported = require_storage.isUsageUnderReported;
6
- exports.measureDirectoryBytes = require_storage.measureDirectoryBytes;
7
- exports.measureQuotaCeiling = require_storage.measureQuotaCeiling;
8
3
  exports.storage = require_storage.storage;
package/build/index.d.ts CHANGED
@@ -7,10 +7,15 @@
7
7
  * import { storage } from '@banou/ponyfill'
8
8
  * const { usage, quota } = await storage.estimate()
9
9
  *
10
- * That constraint is the whole design. A polyfill that patched `navigator.storage` would change what
11
- * every other script on the page sees, including code that was correct against the real behaviour,
12
- * and it would make the difference invisible at the call site. An import is greppable, and a package
13
- * boundary is somewhere the measurement that justifies each workaround can live next to the code.
10
+ * That constraint is the whole design, and it cuts both ways. A polyfill that patched
11
+ * `navigator.storage` would change what every other script on the page sees, including code that was
12
+ * correct against the real behaviour. And an export that is not a platform name is not a ponyfill of
13
+ * anything: it is a utility library wearing the word. So the surface here is exactly the platform's
14
+ * surface, and a helper only becomes public if the platform has one by that name.
15
+ *
16
+ * Everything else lives behind it. The walk that corrects `estimate().usage`, the bounds on that
17
+ * walk, and the reconciliation between the measured and reported figures are all internal, because
18
+ * `navigator.storage` has no such members and neither should this.
14
19
  *
15
20
  * ## What belongs in here
16
21
  *
@@ -22,5 +27,5 @@
22
27
  * Every module here states what was measured, on what, and when. A workaround with no measurement
23
28
  * behind it is a guess that outlives the bug it was written for.
24
29
  */
25
- export { QUOTA_CEILING, correctedUsage, isUsageUnderReported, measureDirectoryBytes, measureQuotaCeiling, storage, } from './storage';
26
- export type { QuotaCeiling, StorageEstimate } from './storage';
30
+ export { storage } from './storage';
31
+ export type { StorageEstimate } from './storage';
package/build/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import { QUOTA_CEILING, correctedUsage, isUsageUnderReported, measureDirectoryBytes, measureQuotaCeiling, storage } from "./storage.js";
2
- export { QUOTA_CEILING, correctedUsage, isUsageUnderReported, measureDirectoryBytes, measureQuotaCeiling, storage };
1
+ import { storage } from "./storage.js";
2
+ export { storage };
package/build/storage.cjs CHANGED
@@ -1,108 +1,87 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  //#region src/storage.ts
3
3
  /**
4
- * What each engine was measured doing, so a caller can reason without re-running the experiment.
4
+ * Never walk forever: a cycle is impossible in the origin private file system, but a pathological
5
+ * tree is not, and neither is a directory with a hundred thousand entries in it.
5
6
  *
6
- * Deliberately NOT keyed by user agent string and never consulted automatically. It is a record of
7
- * measurements, for a human reading this file or writing a comment, and
8
- * {@link measureQuotaCeiling} is what answers the question for the origin actually in front of you.
7
+ * Bounds rather than correctness. Hitting either returns what was counted so far instead of failing,
8
+ * which keeps the answer a floor in the same direction as everything else here.
9
9
  */
10
- var QUOTA_CEILING = {
11
- chromium: {
12
- ceiling: "elastic",
13
- measured: "2026-09-03, Chrome 152.0.7977.64, 2.7 TiB free",
14
- note: "quota rose 10.737 GB to 12.353 GB across 1.615 GB written, leaving quota - usage at 10,737,418,240 bytes after every write, unmoved to the byte"
15
- },
16
- firefox: {
17
- ceiling: "fixed",
18
- measured: "2026-09-03, Playwright firefox 1532, same machine and origin",
19
- note: "quota held at 10,737,418,240 while the headroom fell by the 1,613,063,025 bytes written, byte for byte"
20
- }
21
- };
10
+ var MAX_DEPTH = 8;
11
+ var MAX_ENTRIES = 2e4;
22
12
  var isDirectory = (handle) => typeof handle.values === "function";
23
13
  /**
24
- * Every byte under a directory, measured rather than asked for.
14
+ * Every byte under a directory, or `null` when the walk could not be done at all.
15
+ *
16
+ * The null is a distinct third answer and the correction below depends on it: "the origin holds
17
+ * nothing" and "the file system was unreachable" lead to opposite decisions, and collapsing them to
18
+ * 0 would report an empty origin for one that simply could not be read.
25
19
  *
26
- * Recursive, and it counts a file's REPORTED SIZE, which for OPFS is the file's extent rather than
27
- * how much of it has been written. That is the same accounting the quota system uses, which is the
28
- * whole point: a one byte write a gigabyte into a file is charged a gigabyte, and a measurement that
29
- * disagreed with the charge would be no more useful than the figure it replaces.
20
+ * Counts a file's REPORTED SIZE, which for this file system is its extent rather than how much of it
21
+ * has been written. That is the same accounting the quota system uses, which is the point: a one byte
22
+ * write a gigabyte into a file is charged a gigabyte, and a measurement that disagreed with the
23
+ * charge would be no more useful than the figure it replaces.
30
24
  *
31
- * An unreadable entry is SKIPPED rather than fatal. A file the engine currently holds an exclusive
32
- * sync access handle for cannot be opened by anyone else, and a walk that threw on the first of
33
- * those would return nothing for exactly the origins that hold the most.
25
+ * An unreadable ENTRY is skipped rather than fatal. A file something else currently holds an
26
+ * exclusive sync access handle for cannot be opened, and `getFile()` on it throws rather than
27
+ * waiting, so a walk that gave up on the first of those would return nothing for exactly the origins
28
+ * holding the most. The answer is therefore a floor: short by the files open right now, never long.
34
29
  */
35
- var measureDirectoryBytes = async (directory) => {
30
+ var walkBytes = async (directory) => {
36
31
  let total = 0;
37
- const visit = async (handle) => {
32
+ let seen = 0;
33
+ const visit = async (handle, depth) => {
34
+ if (depth > MAX_DEPTH) return;
38
35
  for await (const child of handle.values()) {
36
+ if (++seen > MAX_ENTRIES) return;
39
37
  if (isDirectory(child)) {
40
- await visit(child);
38
+ await visit(child, depth + 1);
41
39
  continue;
42
40
  }
43
41
  const file = await child.getFile().catch(() => null);
44
42
  if (file) total += file.size;
45
43
  }
46
44
  };
47
- await visit(directory);
48
- return total;
45
+ try {
46
+ await visit(directory, 0);
47
+ return total;
48
+ } catch {
49
+ return null;
50
+ }
49
51
  };
50
52
  /**
51
- * The usage figure to believe, given what the browser said and what a walk found.
53
+ * The usage to report, given what the platform said and what the walk found.
52
54
  *
53
- * The larger of the two, and the reason is asymmetric: a browser that OVER-reports has never been
54
- * observed, while one that under-reports by six orders of magnitude has. So the walk can only ever
55
- * raise the answer.
56
- *
57
- * `usageDetails.fileSystem` is subtracted out before the walk is added when the browser volunteers
58
- * it, because everything else the browser counts (IndexedDB, caches, service worker registrations)
59
- * it counts correctly and the walk cannot see any of it. Without that split, an origin holding real
60
- * IndexedDB data would have it silently dropped from the total.
61
- *
62
- * Pure, so the arithmetic is testable without a browser: this is the part that can be wrong in a way
63
- * no integration test would notice.
55
+ * The walk can only ever RAISE the answer: a browser over-reporting has never been observed, while
56
+ * one under-reporting by six orders of magnitude has.
64
57
  */
65
- var correctedUsage = (estimate, walkedBytes) => {
58
+ var reconcile = (estimate, walked) => {
66
59
  const reported = estimate.usage;
67
- if (walkedBytes === null || !Number.isFinite(walkedBytes) || walkedBytes < 0) return reported ?? null;
68
- if (reported === void 0) return walkedBytes;
60
+ if (walked === null || !Number.isFinite(walked) || walked < 0) return reported;
61
+ if (reported === void 0) return walked;
69
62
  const fileSystem = estimate.usageDetails?.fileSystem;
70
63
  if (typeof fileSystem === "number") {
71
64
  const other = Math.max(0, reported - fileSystem);
72
- return Math.max(reported, walkedBytes + other);
65
+ return Math.max(reported, walked + other);
73
66
  }
74
- return Math.max(reported, walkedBytes);
67
+ return Math.max(reported, walked);
75
68
  };
76
- /** True when the browser's own figure is too far below the measured one to be believed. */
77
- var isUsageUnderReported = (estimate, walkedBytes) => walkedBytes !== null && walkedBytes > 0 && (estimate.usage ?? 0) < walkedBytes / 2;
78
- /**
79
- * `navigator.storage`, with the same method names.
80
- *
81
- * Only `estimate` behaves differently from the platform's, and only in the way documented at the top
82
- * of this file. The rest are passed straight through so that a caller can import this once and never
83
- * reach for the global, which is what keeps the difference in one place instead of at every call
84
- * site that happens to remember.
85
- */
86
69
  var storage = {
87
70
  /**
88
71
  * Same name and same shape as the platform's, with `usage` MEASURED rather than reported.
89
72
  *
90
- * Falls back to the browser's own figure whenever the walk cannot be done: no origin private file
91
- * system, a directory that will not enumerate, or a platform with no `estimate` at all. Falling
92
- * back is not a silent downgrade, because the browser's figure is a floor rather than a guess: it
93
- * is never observed to be too HIGH.
73
+ * Falls back to the platform's own figure whenever the walk cannot be done: no origin private file
74
+ * system, a directory that will not enumerate, or no `estimate` at all. That is not a silent
75
+ * downgrade, because the platform's figure is a floor rather than a guess.
94
76
  */
95
77
  estimate: async () => {
96
78
  const native = globalThis.navigator?.storage;
97
- if (!native?.estimate) return {
98
- usage: 0,
99
- quota: 0
100
- };
79
+ if (!native?.estimate) return {};
101
80
  const estimate = await native.estimate();
102
- const walked = native.getDirectory ? await native.getDirectory().then((directory) => measureDirectoryBytes(directory)).catch(() => null) : null;
81
+ const walked = native.getDirectory ? await native.getDirectory().then((directory) => walkBytes(directory)).catch(() => null) : null;
103
82
  return {
104
83
  ...estimate,
105
- usage: correctedUsage(estimate, walked) ?? estimate.usage ?? 0
84
+ usage: reconcile(estimate, walked)
106
85
  };
107
86
  },
108
87
  persist: () => globalThis.navigator?.storage?.persist?.() ?? Promise.resolve(false),
@@ -113,52 +92,5 @@ var storage = {
113
92
  return native.getDirectory();
114
93
  }
115
94
  };
116
- /**
117
- * Which ceiling this origin has, by WRITING and watching, because nothing else can tell.
118
- *
119
- * The two shapes are indistinguishable at rest: at low usage a flat quota and a flat headroom are
120
- * the same pair of numbers, which is exactly how the difference went unnoticed. Only writing
121
- * separates them, so this writes, and it is therefore not something to call casually.
122
- *
123
- * Sparse, so it costs no real disk: the quota system charges a file's EXTENT, and a single byte
124
- * written `probeBytes - 1` into a file is charged the whole extent instantly. The probe file is
125
- * removed again, in a `finally`, whether or not the measurement succeeded.
126
- *
127
- * Answers `unknown` rather than guessing when the probe cannot be written or the origin refuses it,
128
- * because "I could not tell" and "the ceiling is fixed" lead to opposite decisions.
129
- */
130
- var measureQuotaCeiling = async ({ probeBytes = 536870912, name = ".ponyfill-quota-probe" } = {}) => {
131
- const native = globalThis.navigator?.storage;
132
- if (!native?.estimate || !native.getDirectory) return "unknown";
133
- let root;
134
- try {
135
- root = await native.getDirectory();
136
- } catch {
137
- return "unknown";
138
- }
139
- const before = await native.estimate();
140
- try {
141
- const writable = await (await root.getFileHandle(name, { create: true })).createWritable();
142
- await writable.write({
143
- type: "write",
144
- position: probeBytes - 1,
145
- data: /* @__PURE__ */ new Uint8Array(1)
146
- });
147
- await writable.close();
148
- const after = await native.estimate();
149
- const written = (after.usage ?? 0) - (before.usage ?? 0);
150
- if (written < probeBytes / 2) return "unknown";
151
- return (before.quota ?? 0) - (before.usage ?? 0) - ((after.quota ?? 0) - (after.usage ?? 0)) < written / 4 ? "elastic" : "fixed";
152
- } catch {
153
- return "unknown";
154
- } finally {
155
- await root.removeEntry(name).catch(() => {});
156
- }
157
- };
158
95
  //#endregion
159
- exports.QUOTA_CEILING = QUOTA_CEILING;
160
- exports.correctedUsage = correctedUsage;
161
- exports.isUsageUnderReported = isUsageUnderReported;
162
- exports.measureDirectoryBytes = measureDirectoryBytes;
163
- exports.measureQuotaCeiling = measureQuotaCeiling;
164
96
  exports.storage = storage;
@@ -1,163 +1,87 @@
1
1
  /**
2
- * `navigator.storage`, with the two places browsers disagree about it handled in one file.
2
+ * `navigator.storage`, with the same method names and no extra ones.
3
3
  *
4
- * A PONYFILL, so nothing here touches a global. Import `storage` and call the same method names the
5
- * platform uses, and the difference is that these answers can be relied on across engines:
4
+ * A PONYFILL, so nothing here touches a global and nothing here invents API. You import `storage`
5
+ * and call it exactly as you would the platform's:
6
6
  *
7
7
  * import { storage } from '@banou/ponyfill'
8
8
  * const { usage, quota } = await storage.estimate()
9
9
  *
10
- * ## What is actually wrong with the native one
10
+ * Only `estimate` behaves differently from the platform's, and only by being CORRECT. The rest are
11
+ * passed straight through so this can be a drop-in for `navigator.storage`, which is the whole point:
12
+ * a caller that had to mix `storage.estimate()` with `navigator.storage.getDirectory()` would be
13
+ * back to remembering which half is safe.
11
14
  *
12
- * ### 1. `usage` can be six orders of magnitude short, and nothing says so
15
+ * ## Why `estimate` cannot be believed as the platform gives it
16
+ *
17
+ * ### `usage` can be six orders of magnitude short
13
18
  *
14
19
  * MEASURED on Chrome 151: an origin holding a VERIFIED 1,783,407,077 bytes of torrent data reported
15
- * `usage: 1,813,502`, with `usageDetails.fileSystem: 752`. Not a rounding difference, not a stale
16
- * cache: 752 bytes against 1.78 GB. Anything deciding whether a write will fit from that figure
17
- * decides that everything fits, and then the write fails with `QuotaExceededError` at whatever
18
- * moment the real limit is reached.
19
20
  *
20
- * So `estimate()` here WALKS the origin's file system and reports the larger of the two. The walk
21
- * costs a directory traversal, which is why the native figure is kept as a floor rather than
22
- * discarded: everything the browser counts that is not the file system is counted correctly, and
23
- * only the file system part is worth re-measuring.
21
+ * usage: 1813502
22
+ * usageDetails: { fileSystem: 752, indexedDB: 1809581, serviceWorkerRegistrations: 3169 }
23
+ *
24
+ * 752 bytes against 1.78 GB. Not rounding and not lag, and not universal either: another machine
25
+ * reported the same data correctly, so the figure can neither be trusted nor discarded.
26
+ *
27
+ * So `estimate()` WALKS the origin's file system and reports whichever answer is larger. Larger is
28
+ * the safe direction on purpose. Over-reporting makes a caller reclaim cache slightly early, which
29
+ * is what cache is for; under-reporting makes it decide there is room forever, and what happens then
30
+ * is not a full disk, it is a write failing with `QuotaExceededError` at some unrelated moment.
31
+ *
32
+ * Where `usageDetails` is available the correction is surgical: the file system component is the
33
+ * broken one, so it is replaced with the measurement and the components the browser counts correctly
34
+ * are kept. Without it, the larger of the two is the best available answer.
24
35
  *
25
- * ### 2. `quota` means a different thing on each engine, and the shapes are indistinguishable at rest
36
+ * ### `quota` means a different thing on each engine
26
37
  *
27
38
  * MEASURED 2026-09-03, one machine with 2.7 TiB free, one origin, three 512 MiB sparse writes per
28
39
  * engine, same page and same code:
29
40
  *
30
- * | engine | quota at rest | quota after 1.615 GB written | `quota - usage` |
41
+ * | engine | quota at rest | after 1.615 GB written | `quota - usage` |
31
42
  * | --- | --- | --- | --- |
32
- * | Chromium 152 | 10,737,491,968 | 12,353,… , up by exactly what was written | 10,737,418,240 every time, moved 0 bytes |
33
- * | Firefox | 10,737,418,240 | 10,737,418,240, unmoved | fell 536,870,912 per write |
43
+ * | Chromium 152 | 10,737,491,968 | rose by exactly what was written | 10,737,418,240 every time, moved 0 bytes |
44
+ * | Firefox | 10,737,418,240 | unmoved | fell 536,870,912 per write |
34
45
  *
35
46
  * Both cap at 10 GiB. They cap DIFFERENT QUANTITIES. Chromium's quota is a FLOATING ceiling,
36
47
  * `usage + headroom`, so the headroom is a constant and can never shrink however much is written.
37
48
  * Firefox's is a FIXED ceiling, so writing consumes it.
38
49
  *
39
- * Neither is a bug. The Storage Standard calls quota "a conservative estimate" and never says how to
50
+ * Neither is a bug: the Storage Standard calls quota "a conservative estimate" and never says how to
40
51
  * compute it. But it means one very common line is dead on one engine and live on the other:
41
52
  *
42
53
  * if (quota - usage < someFloor) { ... } // can never be true on Chromium
43
54
  *
44
- * That is not a hypothetical. It is why four of ripple's eviction tests sat failing for months: they
45
- * filled the origin to provoke exactly that condition, and on Chromium the target recedes as fast as
46
- * it is approached, so 3.5 GB of padding left the free figure identical to the byte.
47
- *
48
- * This module does NOT paper over that by inventing a normalised quota, because there is no honest
49
- * number to invent: on Chromium you really can write 10 more GiB, so the native answer is true. What
50
- * it does is name the two shapes, say which one an origin has, and refuse to let the difference be
51
- * discovered again by somebody debugging a dead branch. See {@link QUOTA_CEILING} and
52
- * {@link measureQuotaCeiling}.
55
+ * Nothing here papers over that, because there is no honest number to substitute: on Chromium you
56
+ * really can write 10 more GiB, so the platform's answer is true. It is written down here because
57
+ * the two shapes are indistinguishable at rest, which is how the difference went unnoticed long
58
+ * enough to leave four of ripple's storage tests failing for months against a condition that could
59
+ * never occur.
53
60
  */
54
- /** The same shape `navigator.storage.estimate()` resolves to, plus what the browser volunteered. */
55
- export type StorageEstimate = {
56
- usage: number;
57
- quota: number;
58
- usageDetails?: Record<string, number>;
59
- };
60
61
  /**
61
- * How an engine's `quota` behaves as bytes are written.
62
+ * The platform's own `StorageEstimate`, named the same.
62
63
  *
63
- * `fixed` is what most code assumes: a ceiling that stays put, so `quota - usage` falls as you
64
- * write. `elastic` is Chromium's: the ceiling rises by whatever was written, so `quota - usage` is a
65
- * constant and never signals pressure. `unknown` is the honest answer before anything has measured
66
- * it, and it is the default, because the alternative is sniffing the user agent.
64
+ * Every field optional exactly as the specification has them, so this is a drop-in for what
65
+ * `navigator.storage.estimate()` resolves to rather than a stricter thing a caller has to adapt to.
67
66
  */
68
- export type QuotaCeiling = 'fixed' | 'elastic' | 'unknown';
69
- /**
70
- * What each engine was measured doing, so a caller can reason without re-running the experiment.
71
- *
72
- * Deliberately NOT keyed by user agent string and never consulted automatically. It is a record of
73
- * measurements, for a human reading this file or writing a comment, and
74
- * {@link measureQuotaCeiling} is what answers the question for the origin actually in front of you.
75
- */
76
- export declare const QUOTA_CEILING: Record<string, {
77
- ceiling: QuotaCeiling;
78
- measured: string;
79
- note: string;
80
- }>;
81
- /** A directory handle, narrowed to the two members a size walk actually touches. */
82
- type WalkableDirectory = {
83
- values: () => AsyncIterable<WalkableDirectory | WalkableFile>;
84
- kind?: string;
85
- };
86
- type WalkableFile = {
87
- kind?: string;
88
- getFile: () => Promise<{
89
- size: number;
90
- }>;
67
+ export type StorageEstimate = {
68
+ usage?: number;
69
+ quota?: number;
70
+ /** Chrome only, and the whole reason the correction can be surgical rather than merely larger. */
71
+ usageDetails?: {
72
+ fileSystem?: number;
73
+ };
91
74
  };
92
- /**
93
- * Every byte under a directory, measured rather than asked for.
94
- *
95
- * Recursive, and it counts a file's REPORTED SIZE, which for OPFS is the file's extent rather than
96
- * how much of it has been written. That is the same accounting the quota system uses, which is the
97
- * whole point: a one byte write a gigabyte into a file is charged a gigabyte, and a measurement that
98
- * disagreed with the charge would be no more useful than the figure it replaces.
99
- *
100
- * An unreadable entry is SKIPPED rather than fatal. A file the engine currently holds an exclusive
101
- * sync access handle for cannot be opened by anyone else, and a walk that threw on the first of
102
- * those would return nothing for exactly the origins that hold the most.
103
- */
104
- export declare const measureDirectoryBytes: (directory: WalkableDirectory) => Promise<number>;
105
- /**
106
- * The usage figure to believe, given what the browser said and what a walk found.
107
- *
108
- * The larger of the two, and the reason is asymmetric: a browser that OVER-reports has never been
109
- * observed, while one that under-reports by six orders of magnitude has. So the walk can only ever
110
- * raise the answer.
111
- *
112
- * `usageDetails.fileSystem` is subtracted out before the walk is added when the browser volunteers
113
- * it, because everything else the browser counts (IndexedDB, caches, service worker registrations)
114
- * it counts correctly and the walk cannot see any of it. Without that split, an origin holding real
115
- * IndexedDB data would have it silently dropped from the total.
116
- *
117
- * Pure, so the arithmetic is testable without a browser: this is the part that can be wrong in a way
118
- * no integration test would notice.
119
- */
120
- export declare const correctedUsage: (estimate: Partial<StorageEstimate>, walkedBytes: number | null) => number | null;
121
- /** True when the browser's own figure is too far below the measured one to be believed. */
122
- export declare const isUsageUnderReported: (estimate: Partial<StorageEstimate>, walkedBytes: number | null) => boolean;
123
- /**
124
- * `navigator.storage`, with the same method names.
125
- *
126
- * Only `estimate` behaves differently from the platform's, and only in the way documented at the top
127
- * of this file. The rest are passed straight through so that a caller can import this once and never
128
- * reach for the global, which is what keeps the difference in one place instead of at every call
129
- * site that happens to remember.
130
- */
131
75
  export declare const storage: {
132
76
  /**
133
77
  * Same name and same shape as the platform's, with `usage` MEASURED rather than reported.
134
78
  *
135
- * Falls back to the browser's own figure whenever the walk cannot be done: no origin private file
136
- * system, a directory that will not enumerate, or a platform with no `estimate` at all. Falling
137
- * back is not a silent downgrade, because the browser's figure is a floor rather than a guess: it
138
- * is never observed to be too HIGH.
79
+ * Falls back to the platform's own figure whenever the walk cannot be done: no origin private file
80
+ * system, a directory that will not enumerate, or no `estimate` at all. That is not a silent
81
+ * downgrade, because the platform's figure is a floor rather than a guess.
139
82
  */
140
83
  estimate: () => Promise<StorageEstimate>;
141
84
  persist: () => Promise<boolean>;
142
85
  persisted: () => Promise<boolean>;
143
86
  getDirectory: () => Promise<FileSystemDirectoryHandle>;
144
87
  };
145
- /**
146
- * Which ceiling this origin has, by WRITING and watching, because nothing else can tell.
147
- *
148
- * The two shapes are indistinguishable at rest: at low usage a flat quota and a flat headroom are
149
- * the same pair of numbers, which is exactly how the difference went unnoticed. Only writing
150
- * separates them, so this writes, and it is therefore not something to call casually.
151
- *
152
- * Sparse, so it costs no real disk: the quota system charges a file's EXTENT, and a single byte
153
- * written `probeBytes - 1` into a file is charged the whole extent instantly. The probe file is
154
- * removed again, in a `finally`, whether or not the measurement succeeded.
155
- *
156
- * Answers `unknown` rather than guessing when the probe cannot be written or the origin refuses it,
157
- * because "I could not tell" and "the ceiling is fixed" lead to opposite decisions.
158
- */
159
- export declare const measureQuotaCeiling: ({ probeBytes, name }?: {
160
- probeBytes?: number;
161
- name?: string;
162
- }) => Promise<QuotaCeiling>;
163
- export {};
package/build/storage.js CHANGED
@@ -1,107 +1,86 @@
1
1
  //#region src/storage.ts
2
2
  /**
3
- * What each engine was measured doing, so a caller can reason without re-running the experiment.
3
+ * Never walk forever: a cycle is impossible in the origin private file system, but a pathological
4
+ * tree is not, and neither is a directory with a hundred thousand entries in it.
4
5
  *
5
- * Deliberately NOT keyed by user agent string and never consulted automatically. It is a record of
6
- * measurements, for a human reading this file or writing a comment, and
7
- * {@link measureQuotaCeiling} is what answers the question for the origin actually in front of you.
6
+ * Bounds rather than correctness. Hitting either returns what was counted so far instead of failing,
7
+ * which keeps the answer a floor in the same direction as everything else here.
8
8
  */
9
- var QUOTA_CEILING = {
10
- chromium: {
11
- ceiling: "elastic",
12
- measured: "2026-09-03, Chrome 152.0.7977.64, 2.7 TiB free",
13
- note: "quota rose 10.737 GB to 12.353 GB across 1.615 GB written, leaving quota - usage at 10,737,418,240 bytes after every write, unmoved to the byte"
14
- },
15
- firefox: {
16
- ceiling: "fixed",
17
- measured: "2026-09-03, Playwright firefox 1532, same machine and origin",
18
- note: "quota held at 10,737,418,240 while the headroom fell by the 1,613,063,025 bytes written, byte for byte"
19
- }
20
- };
9
+ var MAX_DEPTH = 8;
10
+ var MAX_ENTRIES = 2e4;
21
11
  var isDirectory = (handle) => typeof handle.values === "function";
22
12
  /**
23
- * Every byte under a directory, measured rather than asked for.
13
+ * Every byte under a directory, or `null` when the walk could not be done at all.
14
+ *
15
+ * The null is a distinct third answer and the correction below depends on it: "the origin holds
16
+ * nothing" and "the file system was unreachable" lead to opposite decisions, and collapsing them to
17
+ * 0 would report an empty origin for one that simply could not be read.
24
18
  *
25
- * Recursive, and it counts a file's REPORTED SIZE, which for OPFS is the file's extent rather than
26
- * how much of it has been written. That is the same accounting the quota system uses, which is the
27
- * whole point: a one byte write a gigabyte into a file is charged a gigabyte, and a measurement that
28
- * disagreed with the charge would be no more useful than the figure it replaces.
19
+ * Counts a file's REPORTED SIZE, which for this file system is its extent rather than how much of it
20
+ * has been written. That is the same accounting the quota system uses, which is the point: a one byte
21
+ * write a gigabyte into a file is charged a gigabyte, and a measurement that disagreed with the
22
+ * charge would be no more useful than the figure it replaces.
29
23
  *
30
- * An unreadable entry is SKIPPED rather than fatal. A file the engine currently holds an exclusive
31
- * sync access handle for cannot be opened by anyone else, and a walk that threw on the first of
32
- * those would return nothing for exactly the origins that hold the most.
24
+ * An unreadable ENTRY is skipped rather than fatal. A file something else currently holds an
25
+ * exclusive sync access handle for cannot be opened, and `getFile()` on it throws rather than
26
+ * waiting, so a walk that gave up on the first of those would return nothing for exactly the origins
27
+ * holding the most. The answer is therefore a floor: short by the files open right now, never long.
33
28
  */
34
- var measureDirectoryBytes = async (directory) => {
29
+ var walkBytes = async (directory) => {
35
30
  let total = 0;
36
- const visit = async (handle) => {
31
+ let seen = 0;
32
+ const visit = async (handle, depth) => {
33
+ if (depth > MAX_DEPTH) return;
37
34
  for await (const child of handle.values()) {
35
+ if (++seen > MAX_ENTRIES) return;
38
36
  if (isDirectory(child)) {
39
- await visit(child);
37
+ await visit(child, depth + 1);
40
38
  continue;
41
39
  }
42
40
  const file = await child.getFile().catch(() => null);
43
41
  if (file) total += file.size;
44
42
  }
45
43
  };
46
- await visit(directory);
47
- return total;
44
+ try {
45
+ await visit(directory, 0);
46
+ return total;
47
+ } catch {
48
+ return null;
49
+ }
48
50
  };
49
51
  /**
50
- * The usage figure to believe, given what the browser said and what a walk found.
52
+ * The usage to report, given what the platform said and what the walk found.
51
53
  *
52
- * The larger of the two, and the reason is asymmetric: a browser that OVER-reports has never been
53
- * observed, while one that under-reports by six orders of magnitude has. So the walk can only ever
54
- * raise the answer.
55
- *
56
- * `usageDetails.fileSystem` is subtracted out before the walk is added when the browser volunteers
57
- * it, because everything else the browser counts (IndexedDB, caches, service worker registrations)
58
- * it counts correctly and the walk cannot see any of it. Without that split, an origin holding real
59
- * IndexedDB data would have it silently dropped from the total.
60
- *
61
- * Pure, so the arithmetic is testable without a browser: this is the part that can be wrong in a way
62
- * no integration test would notice.
54
+ * The walk can only ever RAISE the answer: a browser over-reporting has never been observed, while
55
+ * one under-reporting by six orders of magnitude has.
63
56
  */
64
- var correctedUsage = (estimate, walkedBytes) => {
57
+ var reconcile = (estimate, walked) => {
65
58
  const reported = estimate.usage;
66
- if (walkedBytes === null || !Number.isFinite(walkedBytes) || walkedBytes < 0) return reported ?? null;
67
- if (reported === void 0) return walkedBytes;
59
+ if (walked === null || !Number.isFinite(walked) || walked < 0) return reported;
60
+ if (reported === void 0) return walked;
68
61
  const fileSystem = estimate.usageDetails?.fileSystem;
69
62
  if (typeof fileSystem === "number") {
70
63
  const other = Math.max(0, reported - fileSystem);
71
- return Math.max(reported, walkedBytes + other);
64
+ return Math.max(reported, walked + other);
72
65
  }
73
- return Math.max(reported, walkedBytes);
66
+ return Math.max(reported, walked);
74
67
  };
75
- /** True when the browser's own figure is too far below the measured one to be believed. */
76
- var isUsageUnderReported = (estimate, walkedBytes) => walkedBytes !== null && walkedBytes > 0 && (estimate.usage ?? 0) < walkedBytes / 2;
77
- /**
78
- * `navigator.storage`, with the same method names.
79
- *
80
- * Only `estimate` behaves differently from the platform's, and only in the way documented at the top
81
- * of this file. The rest are passed straight through so that a caller can import this once and never
82
- * reach for the global, which is what keeps the difference in one place instead of at every call
83
- * site that happens to remember.
84
- */
85
68
  var storage = {
86
69
  /**
87
70
  * Same name and same shape as the platform's, with `usage` MEASURED rather than reported.
88
71
  *
89
- * Falls back to the browser's own figure whenever the walk cannot be done: no origin private file
90
- * system, a directory that will not enumerate, or a platform with no `estimate` at all. Falling
91
- * back is not a silent downgrade, because the browser's figure is a floor rather than a guess: it
92
- * is never observed to be too HIGH.
72
+ * Falls back to the platform's own figure whenever the walk cannot be done: no origin private file
73
+ * system, a directory that will not enumerate, or no `estimate` at all. That is not a silent
74
+ * downgrade, because the platform's figure is a floor rather than a guess.
93
75
  */
94
76
  estimate: async () => {
95
77
  const native = globalThis.navigator?.storage;
96
- if (!native?.estimate) return {
97
- usage: 0,
98
- quota: 0
99
- };
78
+ if (!native?.estimate) return {};
100
79
  const estimate = await native.estimate();
101
- const walked = native.getDirectory ? await native.getDirectory().then((directory) => measureDirectoryBytes(directory)).catch(() => null) : null;
80
+ const walked = native.getDirectory ? await native.getDirectory().then((directory) => walkBytes(directory)).catch(() => null) : null;
102
81
  return {
103
82
  ...estimate,
104
- usage: correctedUsage(estimate, walked) ?? estimate.usage ?? 0
83
+ usage: reconcile(estimate, walked)
105
84
  };
106
85
  },
107
86
  persist: () => globalThis.navigator?.storage?.persist?.() ?? Promise.resolve(false),
@@ -112,47 +91,5 @@ var storage = {
112
91
  return native.getDirectory();
113
92
  }
114
93
  };
115
- /**
116
- * Which ceiling this origin has, by WRITING and watching, because nothing else can tell.
117
- *
118
- * The two shapes are indistinguishable at rest: at low usage a flat quota and a flat headroom are
119
- * the same pair of numbers, which is exactly how the difference went unnoticed. Only writing
120
- * separates them, so this writes, and it is therefore not something to call casually.
121
- *
122
- * Sparse, so it costs no real disk: the quota system charges a file's EXTENT, and a single byte
123
- * written `probeBytes - 1` into a file is charged the whole extent instantly. The probe file is
124
- * removed again, in a `finally`, whether or not the measurement succeeded.
125
- *
126
- * Answers `unknown` rather than guessing when the probe cannot be written or the origin refuses it,
127
- * because "I could not tell" and "the ceiling is fixed" lead to opposite decisions.
128
- */
129
- var measureQuotaCeiling = async ({ probeBytes = 536870912, name = ".ponyfill-quota-probe" } = {}) => {
130
- const native = globalThis.navigator?.storage;
131
- if (!native?.estimate || !native.getDirectory) return "unknown";
132
- let root;
133
- try {
134
- root = await native.getDirectory();
135
- } catch {
136
- return "unknown";
137
- }
138
- const before = await native.estimate();
139
- try {
140
- const writable = await (await root.getFileHandle(name, { create: true })).createWritable();
141
- await writable.write({
142
- type: "write",
143
- position: probeBytes - 1,
144
- data: /* @__PURE__ */ new Uint8Array(1)
145
- });
146
- await writable.close();
147
- const after = await native.estimate();
148
- const written = (after.usage ?? 0) - (before.usage ?? 0);
149
- if (written < probeBytes / 2) return "unknown";
150
- return (before.quota ?? 0) - (before.usage ?? 0) - ((after.quota ?? 0) - (after.usage ?? 0)) < written / 4 ? "elastic" : "fixed";
151
- } catch {
152
- return "unknown";
153
- } finally {
154
- await root.removeEntry(name).catch(() => {});
155
- }
156
- };
157
94
  //#endregion
158
- export { QUOTA_CEILING, correctedUsage, isUsageUnderReported, measureDirectoryBytes, measureQuotaCeiling, storage };
95
+ export { storage };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banou/ponyfill",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "type": "module",
5
5
  "main": "build/index.cjs",
6
6
  "module": "build/index.js",