@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 +4 -4
- package/build/index.cjs +0 -5
- package/build/index.d.ts +11 -6
- package/build/index.js +2 -2
- package/build/storage.cjs +45 -113
- package/build/storage.d.ts +47 -123
- package/build/storage.js +46 -109
- package/package.json +1 -1
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,
|
|
62
|
-
Chromium you really can write 10 more GiB, so the native answer is true
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
11
|
-
* every other script on the page sees, including code that was
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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 {
|
|
26
|
-
export type {
|
|
30
|
+
export { storage } from './storage';
|
|
31
|
+
export type { StorageEstimate } from './storage';
|
package/build/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export {
|
|
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
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
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
|
|
11
|
-
|
|
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,
|
|
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
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
32
|
-
* sync access handle for cannot be opened
|
|
33
|
-
* those would return nothing for exactly the origins
|
|
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
|
|
30
|
+
var walkBytes = async (directory) => {
|
|
36
31
|
let total = 0;
|
|
37
|
-
|
|
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
|
-
|
|
48
|
-
|
|
45
|
+
try {
|
|
46
|
+
await visit(directory, 0);
|
|
47
|
+
return total;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
49
51
|
};
|
|
50
52
|
/**
|
|
51
|
-
* The usage
|
|
53
|
+
* The usage to report, given what the platform said and what the walk found.
|
|
52
54
|
*
|
|
53
|
-
* The
|
|
54
|
-
*
|
|
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
|
|
58
|
+
var reconcile = (estimate, walked) => {
|
|
66
59
|
const reported = estimate.usage;
|
|
67
|
-
if (
|
|
68
|
-
if (reported === void 0) return
|
|
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,
|
|
65
|
+
return Math.max(reported, walked + other);
|
|
73
66
|
}
|
|
74
|
-
return Math.max(reported,
|
|
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
|
|
91
|
-
* system, a directory that will not enumerate, or
|
|
92
|
-
*
|
|
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) =>
|
|
81
|
+
const walked = native.getDirectory ? await native.getDirectory().then((directory) => walkBytes(directory)).catch(() => null) : null;
|
|
103
82
|
return {
|
|
104
83
|
...estimate,
|
|
105
|
-
usage:
|
|
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;
|
package/build/storage.d.ts
CHANGED
|
@@ -1,163 +1,87 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `navigator.storage`, with the
|
|
2
|
+
* `navigator.storage`, with the same method names and no extra ones.
|
|
3
3
|
*
|
|
4
|
-
* A PONYFILL, so nothing here touches a global
|
|
5
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
* ###
|
|
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 |
|
|
41
|
+
* | engine | quota at rest | after 1.615 GB written | `quota - usage` |
|
|
31
42
|
* | --- | --- | --- | --- |
|
|
32
|
-
* | Chromium 152 | 10,737,491,968 |
|
|
33
|
-
* | Firefox | 10,737,418,240 |
|
|
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
|
|
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
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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
|
-
*
|
|
62
|
+
* The platform's own `StorageEstimate`, named the same.
|
|
62
63
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
136
|
-
* system, a directory that will not enumerate, or
|
|
137
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
6
|
-
*
|
|
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
|
|
10
|
-
|
|
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,
|
|
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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
31
|
-
* sync access handle for cannot be opened
|
|
32
|
-
* those would return nothing for exactly the origins
|
|
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
|
|
29
|
+
var walkBytes = async (directory) => {
|
|
35
30
|
let total = 0;
|
|
36
|
-
|
|
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
|
-
|
|
47
|
-
|
|
44
|
+
try {
|
|
45
|
+
await visit(directory, 0);
|
|
46
|
+
return total;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
48
50
|
};
|
|
49
51
|
/**
|
|
50
|
-
* The usage
|
|
52
|
+
* The usage to report, given what the platform said and what the walk found.
|
|
51
53
|
*
|
|
52
|
-
* The
|
|
53
|
-
*
|
|
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
|
|
57
|
+
var reconcile = (estimate, walked) => {
|
|
65
58
|
const reported = estimate.usage;
|
|
66
|
-
if (
|
|
67
|
-
if (reported === void 0) return
|
|
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,
|
|
64
|
+
return Math.max(reported, walked + other);
|
|
72
65
|
}
|
|
73
|
-
return Math.max(reported,
|
|
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
|
|
90
|
-
* system, a directory that will not enumerate, or
|
|
91
|
-
*
|
|
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) =>
|
|
80
|
+
const walked = native.getDirectory ? await native.getDirectory().then((directory) => walkBytes(directory)).catch(() => null) : null;
|
|
102
81
|
return {
|
|
103
82
|
...estimate,
|
|
104
|
-
usage:
|
|
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 {
|
|
95
|
+
export { storage };
|