@banou/ponyfill 0.0.1
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 +70 -0
- package/build/index.cjs +8 -0
- package/build/index.d.ts +26 -0
- package/build/index.js +2 -0
- package/build/storage.cjs +164 -0
- package/build/storage.d.ts +163 -0
- package/build/storage.js +158 -0
- package/package.json +55 -0
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# @banou/ponyfill
|
|
2
|
+
|
|
3
|
+
Ponyfills for the places browsers disagree, with the measurement behind each one.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { storage } from '@banou/ponyfill'
|
|
7
|
+
|
|
8
|
+
const { usage, quota } = await storage.estimate()
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Why this is a package and not a file in an app
|
|
12
|
+
|
|
13
|
+
**It is a ponyfill, never a polyfill.** Nothing here writes to a global. You import the wrapped API
|
|
14
|
+
and call the same method names the platform uses, so a call site reads the same as the native one and
|
|
15
|
+
`grep` can still find every place that needed the workaround.
|
|
16
|
+
|
|
17
|
+
Patching `navigator.storage` would change what every other script on the page sees, including code
|
|
18
|
+
that was correct against the real behaviour, and it would hide the difference at exactly the call
|
|
19
|
+
sites that most need to show it.
|
|
20
|
+
|
|
21
|
+
**Every workaround carries its measurement.** What was measured, on what, and when. A workaround with
|
|
22
|
+
no measurement behind it is a guess that outlives the bug it was written for, and there is no way to
|
|
23
|
+
tell the two apart later.
|
|
24
|
+
|
|
25
|
+
## What is in it
|
|
26
|
+
|
|
27
|
+
### `storage`
|
|
28
|
+
|
|
29
|
+
The same method names as `navigator.storage`, fixing two things.
|
|
30
|
+
|
|
31
|
+
**`usage` can be six orders of magnitude short.** Measured on Chrome 151: an origin holding a
|
|
32
|
+
verified 1,783,407,077 bytes reported `usage: 1,813,502` with `usageDetails.fileSystem: 752`. 752
|
|
33
|
+
bytes against 1.78 GB. Anything sizing a write from that decides everything fits, then fails with
|
|
34
|
+
`QuotaExceededError` at the real limit. `storage.estimate()` walks the origin and reports the larger
|
|
35
|
+
of the two, keeping the parts the browser counts correctly (IndexedDB, caches) rather than replacing
|
|
36
|
+
them.
|
|
37
|
+
|
|
38
|
+
**`quota` means a different thing on each engine.** Measured 2026-09-03, one machine with 2.7 TiB
|
|
39
|
+
free, one origin, three 512 MiB sparse writes per engine, same page and same code:
|
|
40
|
+
|
|
41
|
+
| engine | quota at rest | after 1.615 GB written | `quota - usage` |
|
|
42
|
+
| --- | --- | --- | --- |
|
|
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 |
|
|
45
|
+
|
|
46
|
+
Both cap at 10 GiB; they cap **different quantities**. Chromium's quota is a floating ceiling
|
|
47
|
+
(`usage + headroom`), so the headroom is a constant and can never shrink. Firefox's is a fixed
|
|
48
|
+
ceiling, so writing consumes it.
|
|
49
|
+
|
|
50
|
+
Neither is a bug: the Storage Standard calls quota "a conservative estimate" and never says how to
|
|
51
|
+
compute it. But it means this very common line is dead on one engine and live on the other:
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
if (quota - usage < someFloor) { /* ... */ } // can never be true on Chromium
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
That is not hypothetical. It is why four of ripple's storage eviction tests sat failing for months:
|
|
58
|
+
they filled the origin to provoke that condition, and 3.5 GB of padding left the free figure
|
|
59
|
+
identical to the byte.
|
|
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.
|
|
65
|
+
|
|
66
|
+
## Adding to it
|
|
67
|
+
|
|
68
|
+
If you hit something that behaves differently between engines, or differently from its own
|
|
69
|
+
specification, it goes here rather than into the app that found it. State what you measured, on what,
|
|
70
|
+
and when, and pin it with a test that fails when the workaround is removed.
|
package/build/index.cjs
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
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
|
+
exports.storage = require_storage.storage;
|
package/build/index.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ponyfills for the places browsers disagree, so an app never carries the difference itself.
|
|
3
|
+
*
|
|
4
|
+
* A PONYFILL and not a polyfill: nothing here is written to a global, ever. You import the wrapped
|
|
5
|
+
* API and call it exactly as you would the platform's, with the same names:
|
|
6
|
+
*
|
|
7
|
+
* import { storage } from '@banou/ponyfill'
|
|
8
|
+
* const { usage, quota } = await storage.estimate()
|
|
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.
|
|
14
|
+
*
|
|
15
|
+
* ## What belongs in here
|
|
16
|
+
*
|
|
17
|
+
* Anything that behaves differently between engines, or differently from its own specification, and
|
|
18
|
+
* that an app would otherwise work around in place. The rule is not "hard to implement", it is
|
|
19
|
+
* "surprising": if finding it out cost a measurement, the measurement belongs beside the fix so the
|
|
20
|
+
* next person is not made to repeat it.
|
|
21
|
+
*
|
|
22
|
+
* Every module here states what was measured, on what, and when. A workaround with no measurement
|
|
23
|
+
* behind it is a guess that outlives the bug it was written for.
|
|
24
|
+
*/
|
|
25
|
+
export { QUOTA_CEILING, correctedUsage, isUsageUnderReported, measureDirectoryBytes, measureQuotaCeiling, storage, } from './storage';
|
|
26
|
+
export type { QuotaCeiling, StorageEstimate } from './storage';
|
package/build/index.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region src/storage.ts
|
|
3
|
+
/**
|
|
4
|
+
* What each engine was measured doing, so a caller can reason without re-running the experiment.
|
|
5
|
+
*
|
|
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.
|
|
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
|
+
};
|
|
22
|
+
var isDirectory = (handle) => typeof handle.values === "function";
|
|
23
|
+
/**
|
|
24
|
+
* Every byte under a directory, measured rather than asked for.
|
|
25
|
+
*
|
|
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.
|
|
30
|
+
*
|
|
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.
|
|
34
|
+
*/
|
|
35
|
+
var measureDirectoryBytes = async (directory) => {
|
|
36
|
+
let total = 0;
|
|
37
|
+
const visit = async (handle) => {
|
|
38
|
+
for await (const child of handle.values()) {
|
|
39
|
+
if (isDirectory(child)) {
|
|
40
|
+
await visit(child);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const file = await child.getFile().catch(() => null);
|
|
44
|
+
if (file) total += file.size;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
await visit(directory);
|
|
48
|
+
return total;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* The usage figure to believe, given what the browser said and what a walk found.
|
|
52
|
+
*
|
|
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.
|
|
64
|
+
*/
|
|
65
|
+
var correctedUsage = (estimate, walkedBytes) => {
|
|
66
|
+
const reported = estimate.usage;
|
|
67
|
+
if (walkedBytes === null || !Number.isFinite(walkedBytes) || walkedBytes < 0) return reported ?? null;
|
|
68
|
+
if (reported === void 0) return walkedBytes;
|
|
69
|
+
const fileSystem = estimate.usageDetails?.fileSystem;
|
|
70
|
+
if (typeof fileSystem === "number") {
|
|
71
|
+
const other = Math.max(0, reported - fileSystem);
|
|
72
|
+
return Math.max(reported, walkedBytes + other);
|
|
73
|
+
}
|
|
74
|
+
return Math.max(reported, walkedBytes);
|
|
75
|
+
};
|
|
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
|
+
var storage = {
|
|
87
|
+
/**
|
|
88
|
+
* Same name and same shape as the platform's, with `usage` MEASURED rather than reported.
|
|
89
|
+
*
|
|
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.
|
|
94
|
+
*/
|
|
95
|
+
estimate: async () => {
|
|
96
|
+
const native = globalThis.navigator?.storage;
|
|
97
|
+
if (!native?.estimate) return {
|
|
98
|
+
usage: 0,
|
|
99
|
+
quota: 0
|
|
100
|
+
};
|
|
101
|
+
const estimate = await native.estimate();
|
|
102
|
+
const walked = native.getDirectory ? await native.getDirectory().then((directory) => measureDirectoryBytes(directory)).catch(() => null) : null;
|
|
103
|
+
return {
|
|
104
|
+
...estimate,
|
|
105
|
+
usage: correctedUsage(estimate, walked) ?? estimate.usage ?? 0
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
persist: () => globalThis.navigator?.storage?.persist?.() ?? Promise.resolve(false),
|
|
109
|
+
persisted: () => globalThis.navigator?.storage?.persisted?.() ?? Promise.resolve(false),
|
|
110
|
+
getDirectory: () => {
|
|
111
|
+
const native = globalThis.navigator?.storage;
|
|
112
|
+
if (!native?.getDirectory) return Promise.reject(/* @__PURE__ */ new Error("this browser has no origin private file system"));
|
|
113
|
+
return native.getDirectory();
|
|
114
|
+
}
|
|
115
|
+
};
|
|
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
|
+
//#endregion
|
|
159
|
+
exports.QUOTA_CEILING = QUOTA_CEILING;
|
|
160
|
+
exports.correctedUsage = correctedUsage;
|
|
161
|
+
exports.isUsageUnderReported = isUsageUnderReported;
|
|
162
|
+
exports.measureDirectoryBytes = measureDirectoryBytes;
|
|
163
|
+
exports.measureQuotaCeiling = measureQuotaCeiling;
|
|
164
|
+
exports.storage = storage;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `navigator.storage`, with the two places browsers disagree about it handled in one file.
|
|
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:
|
|
6
|
+
*
|
|
7
|
+
* import { storage } from '@banou/ponyfill'
|
|
8
|
+
* const { usage, quota } = await storage.estimate()
|
|
9
|
+
*
|
|
10
|
+
* ## What is actually wrong with the native one
|
|
11
|
+
*
|
|
12
|
+
* ### 1. `usage` can be six orders of magnitude short, and nothing says so
|
|
13
|
+
*
|
|
14
|
+
* 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
|
+
* 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.
|
|
24
|
+
*
|
|
25
|
+
* ### 2. `quota` means a different thing on each engine, and the shapes are indistinguishable at rest
|
|
26
|
+
*
|
|
27
|
+
* MEASURED 2026-09-03, one machine with 2.7 TiB free, one origin, three 512 MiB sparse writes per
|
|
28
|
+
* engine, same page and same code:
|
|
29
|
+
*
|
|
30
|
+
* | engine | quota at rest | quota after 1.615 GB written | `quota - usage` |
|
|
31
|
+
* | --- | --- | --- | --- |
|
|
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 |
|
|
34
|
+
*
|
|
35
|
+
* Both cap at 10 GiB. They cap DIFFERENT QUANTITIES. Chromium's quota is a FLOATING ceiling,
|
|
36
|
+
* `usage + headroom`, so the headroom is a constant and can never shrink however much is written.
|
|
37
|
+
* Firefox's is a FIXED ceiling, so writing consumes it.
|
|
38
|
+
*
|
|
39
|
+
* Neither is a bug. The Storage Standard calls quota "a conservative estimate" and never says how to
|
|
40
|
+
* compute it. But it means one very common line is dead on one engine and live on the other:
|
|
41
|
+
*
|
|
42
|
+
* if (quota - usage < someFloor) { ... } // can never be true on Chromium
|
|
43
|
+
*
|
|
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}.
|
|
53
|
+
*/
|
|
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
|
+
* How an engine's `quota` behaves as bytes are written.
|
|
62
|
+
*
|
|
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.
|
|
67
|
+
*/
|
|
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
|
+
}>;
|
|
91
|
+
};
|
|
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
|
+
export declare const storage: {
|
|
132
|
+
/**
|
|
133
|
+
* Same name and same shape as the platform's, with `usage` MEASURED rather than reported.
|
|
134
|
+
*
|
|
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.
|
|
139
|
+
*/
|
|
140
|
+
estimate: () => Promise<StorageEstimate>;
|
|
141
|
+
persist: () => Promise<boolean>;
|
|
142
|
+
persisted: () => Promise<boolean>;
|
|
143
|
+
getDirectory: () => Promise<FileSystemDirectoryHandle>;
|
|
144
|
+
};
|
|
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
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
//#region src/storage.ts
|
|
2
|
+
/**
|
|
3
|
+
* What each engine was measured doing, so a caller can reason without re-running the experiment.
|
|
4
|
+
*
|
|
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.
|
|
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
|
+
};
|
|
21
|
+
var isDirectory = (handle) => typeof handle.values === "function";
|
|
22
|
+
/**
|
|
23
|
+
* Every byte under a directory, measured rather than asked for.
|
|
24
|
+
*
|
|
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.
|
|
29
|
+
*
|
|
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.
|
|
33
|
+
*/
|
|
34
|
+
var measureDirectoryBytes = async (directory) => {
|
|
35
|
+
let total = 0;
|
|
36
|
+
const visit = async (handle) => {
|
|
37
|
+
for await (const child of handle.values()) {
|
|
38
|
+
if (isDirectory(child)) {
|
|
39
|
+
await visit(child);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const file = await child.getFile().catch(() => null);
|
|
43
|
+
if (file) total += file.size;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
await visit(directory);
|
|
47
|
+
return total;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* The usage figure to believe, given what the browser said and what a walk found.
|
|
51
|
+
*
|
|
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.
|
|
63
|
+
*/
|
|
64
|
+
var correctedUsage = (estimate, walkedBytes) => {
|
|
65
|
+
const reported = estimate.usage;
|
|
66
|
+
if (walkedBytes === null || !Number.isFinite(walkedBytes) || walkedBytes < 0) return reported ?? null;
|
|
67
|
+
if (reported === void 0) return walkedBytes;
|
|
68
|
+
const fileSystem = estimate.usageDetails?.fileSystem;
|
|
69
|
+
if (typeof fileSystem === "number") {
|
|
70
|
+
const other = Math.max(0, reported - fileSystem);
|
|
71
|
+
return Math.max(reported, walkedBytes + other);
|
|
72
|
+
}
|
|
73
|
+
return Math.max(reported, walkedBytes);
|
|
74
|
+
};
|
|
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
|
+
var storage = {
|
|
86
|
+
/**
|
|
87
|
+
* Same name and same shape as the platform's, with `usage` MEASURED rather than reported.
|
|
88
|
+
*
|
|
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.
|
|
93
|
+
*/
|
|
94
|
+
estimate: async () => {
|
|
95
|
+
const native = globalThis.navigator?.storage;
|
|
96
|
+
if (!native?.estimate) return {
|
|
97
|
+
usage: 0,
|
|
98
|
+
quota: 0
|
|
99
|
+
};
|
|
100
|
+
const estimate = await native.estimate();
|
|
101
|
+
const walked = native.getDirectory ? await native.getDirectory().then((directory) => measureDirectoryBytes(directory)).catch(() => null) : null;
|
|
102
|
+
return {
|
|
103
|
+
...estimate,
|
|
104
|
+
usage: correctedUsage(estimate, walked) ?? estimate.usage ?? 0
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
persist: () => globalThis.navigator?.storage?.persist?.() ?? Promise.resolve(false),
|
|
108
|
+
persisted: () => globalThis.navigator?.storage?.persisted?.() ?? Promise.resolve(false),
|
|
109
|
+
getDirectory: () => {
|
|
110
|
+
const native = globalThis.navigator?.storage;
|
|
111
|
+
if (!native?.getDirectory) return Promise.reject(/* @__PURE__ */ new Error("this browser has no origin private file system"));
|
|
112
|
+
return native.getDirectory();
|
|
113
|
+
}
|
|
114
|
+
};
|
|
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
|
+
//#endregion
|
|
158
|
+
export { QUOTA_CEILING, correctedUsage, isUsageUnderReported, measureDirectoryBytes, measureQuotaCeiling, storage };
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@banou/ponyfill",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "build/index.cjs",
|
|
6
|
+
"module": "build/index.js",
|
|
7
|
+
"types": "build/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./build/index.d.ts",
|
|
11
|
+
"import": "./build/index.js",
|
|
12
|
+
"require": "./build/index.cjs"
|
|
13
|
+
},
|
|
14
|
+
"./storage": {
|
|
15
|
+
"types": "./build/storage.d.ts",
|
|
16
|
+
"import": "./build/storage.js",
|
|
17
|
+
"require": "./build/storage.cjs"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"dev": "vp build --mode development --watch",
|
|
22
|
+
"build": "vp build && tsc -p tsconfig.json",
|
|
23
|
+
"prepack": "npm run build",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"test": "vp test run"
|
|
26
|
+
},
|
|
27
|
+
"description": "Ponyfills for the places browsers disagree, with the measurement behind each one. Imports only, never a global.",
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^25.9.5",
|
|
30
|
+
"typescript": "^5.9.2",
|
|
31
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@0.3.0",
|
|
32
|
+
"vite-plus": "0.3.0",
|
|
33
|
+
"vitest": "4.1.11"
|
|
34
|
+
},
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/Banou26/ponyfill.git"
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"build",
|
|
41
|
+
"README.md"
|
|
42
|
+
],
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"keywords": [
|
|
47
|
+
"ponyfill",
|
|
48
|
+
"browser",
|
|
49
|
+
"storage",
|
|
50
|
+
"opfs",
|
|
51
|
+
"quota",
|
|
52
|
+
"compatibility"
|
|
53
|
+
],
|
|
54
|
+
"sideEffects": false
|
|
55
|
+
}
|