@banou/ponyfill 0.0.5 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/file-system.ts
3
+ /**
4
+ * Whether this document is framed by another origin.
5
+ *
6
+ * Chromium exposes the picker either way and refuses it at call time, so a property probe says
7
+ * nothing. A same origin ancestor answers `location.origin`; a cross origin one throws, and so does
8
+ * an opaque origin, which is the case a sandboxed frame presents.
9
+ */
10
+ var framedByAnotherOrigin = () => {
11
+ if (typeof window === "undefined") return false;
12
+ const top = window.top;
13
+ if (!top || top === window.self) return false;
14
+ try {
15
+ top.location.origin;
16
+ return false;
17
+ } catch {
18
+ return true;
19
+ }
20
+ };
21
+ var showSaveFilePicker = async (options) => {
22
+ const picker = globalThis.showSaveFilePicker;
23
+ if (!picker) throw new DOMException("this browser has no file save picker", "NotAllowedError");
24
+ if (framedByAnotherOrigin()) throw new DOMException("a cross origin frame cannot show a file save picker", "NotAllowedError");
25
+ return picker(options);
26
+ };
27
+ //#endregion
28
+ exports.showSaveFilePicker = showSaveFilePicker;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The File System Access pickers, with the same names and one behaviour picked.
3
+ *
4
+ * ## The divergence
5
+ *
6
+ * `showSaveFilePicker` exists on the window in Chromium whether or not it can actually be used, and
7
+ * refuses at CALL time in two cases that a property probe cannot see:
8
+ *
9
+ * - a cross origin ancestor frame, which fails with "Cross origin sub frames aren't allowed to show
10
+ * a file picker"
11
+ * - no transient activation, which fails with "Must be handling a user gesture to show a file picker"
12
+ *
13
+ * Firefox does not expose it at all, so `'showSaveFilePicker' in window` answers a different question
14
+ * on each engine: on one it means "might work", on the other "will never work".
15
+ *
16
+ * ## The pick
17
+ *
18
+ * REJECT BEFORE THE ACTIVATION IS SPENT, with a rejection a caller can tell apart from a cancel.
19
+ *
20
+ * That ordering is the whole of it. A caller with a fallback chain has one transient activation to
21
+ * spend, and a picker that rejects at call time has already consumed part of it, so the fallback the
22
+ * caller reaches for next can fail too. Rejecting before the call keeps the gesture intact.
23
+ *
24
+ * `NotAllowedError` for both refusals rather than `AbortError`, because `AbortError` is what the
25
+ * platform throws when the PERSON cancels, and a caller that cannot tell those apart shows an error
26
+ * for something the person chose to do. Ripple's `isSaveCancelled` matches on `AbortError`, so this
27
+ * distinction is load bearing rather than tidy.
28
+ *
29
+ * WHAT IS NOT ABSORBED, deliberately. The fallback chain itself stays with the caller: an anchor
30
+ * download, a service worker sink, or holding bytes in memory are not platform names and choosing
31
+ * between them is a product decision about what a page does when it cannot save. A ponyfill that
32
+ * silently downloaded something instead of opening a picker would be lying about which API it is.
33
+ */
34
+ type SaveFilePickerOptions = {
35
+ suggestedName?: string;
36
+ types?: {
37
+ description?: string;
38
+ accept: Record<string, string[]>;
39
+ }[];
40
+ excludeAcceptAllOption?: boolean;
41
+ id?: string;
42
+ startIn?: unknown;
43
+ };
44
+ type SaveFilePicker = (options?: SaveFilePickerOptions) => Promise<FileSystemFileHandle>;
45
+ export declare const showSaveFilePicker: SaveFilePicker;
46
+ export {};
@@ -0,0 +1,27 @@
1
+ //#region src/file-system.ts
2
+ /**
3
+ * Whether this document is framed by another origin.
4
+ *
5
+ * Chromium exposes the picker either way and refuses it at call time, so a property probe says
6
+ * nothing. A same origin ancestor answers `location.origin`; a cross origin one throws, and so does
7
+ * an opaque origin, which is the case a sandboxed frame presents.
8
+ */
9
+ var framedByAnotherOrigin = () => {
10
+ if (typeof window === "undefined") return false;
11
+ const top = window.top;
12
+ if (!top || top === window.self) return false;
13
+ try {
14
+ top.location.origin;
15
+ return false;
16
+ } catch {
17
+ return true;
18
+ }
19
+ };
20
+ var showSaveFilePicker = async (options) => {
21
+ const picker = globalThis.showSaveFilePicker;
22
+ if (!picker) throw new DOMException("this browser has no file save picker", "NotAllowedError");
23
+ if (framedByAnotherOrigin()) throw new DOMException("a cross origin frame cannot show a file save picker", "NotAllowedError");
24
+ return picker(options);
25
+ };
26
+ //#endregion
27
+ export { showSaveFilePicker };
package/build/index.cjs CHANGED
@@ -1,3 +1,7 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_storage = require("./storage.cjs");
3
+ const require_permissions = require("./permissions.cjs");
4
+ const require_file_system = require("./file-system.cjs");
5
+ exports.permissions = require_permissions.permissions;
6
+ exports.showSaveFilePicker = require_file_system.showSaveFilePicker;
3
7
  exports.storage = require_storage.storage;
package/build/index.d.ts CHANGED
@@ -13,8 +13,12 @@
13
13
  * anything: it is a utility library wearing the word. So the surface here is exactly the platform's
14
14
  * surface, and a helper only becomes public if the platform has one by that name.
15
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
16
+ * WHERE ENGINES DIVERGE, ONE BEHAVIOUR IS PICKED and made consistent, so application code never
17
+ * branches. That is the point of the package rather than a liberty it takes. Each pick is argued
18
+ * where it lives, and each says what it gives up.
19
+ *
20
+ * Everything else stays behind the surface. The walk that corrects `estimate().usage`, the bounds on
21
+ * that walk, and the reconciliation between measured and reported figures are all internal, because
18
22
  * `navigator.storage` has no such members and neither should this.
19
23
  *
20
24
  * ## What belongs in here
@@ -25,7 +29,11 @@
25
29
  * next person is not made to repeat it.
26
30
  *
27
31
  * Every module here states what was measured, on what, and when. A workaround with no measurement
28
- * behind it is a guess that outlives the bug it was written for.
32
+ * behind it is a guess that outlives the bug it was written for, and where a measurement is missing
33
+ * the module says so rather than implying one.
29
34
  */
30
35
  export { storage } from './storage';
36
+ export { permissions } from './permissions';
37
+ export { showSaveFilePicker } from './file-system';
31
38
  export type { StorageEstimate } from './storage';
39
+ export type { PermissionStatus } from './permissions';
package/build/index.js CHANGED
@@ -1,2 +1,4 @@
1
1
  import { storage } from "./storage.js";
2
- export { storage };
2
+ import { permissions } from "./permissions.js";
3
+ import { showSaveFilePicker } from "./file-system.js";
4
+ export { permissions, showSaveFilePicker, storage };
@@ -0,0 +1,23 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/permissions.ts
3
+ var isState = (value) => value === "granted" || value === "denied" || value === "prompt";
4
+ var permissions = {
5
+ /**
6
+ * Same name and same signature, answering `'prompt'` wherever the platform would not answer.
7
+ *
8
+ * The `await` is INSIDE the `try` on purpose, and it is the whole reason this is a wrapper rather
9
+ * than a one-line `.catch`. `query` can throw synchronously for a name the engine does not know
10
+ * and reject asynchronously for one it does, and a `.catch` on the returned promise sees only the
11
+ * second. Getting that wrong turns a missing permission name into an uncaught exception at
12
+ * whatever moment the feature is first used.
13
+ */
14
+ query: async (descriptor) => {
15
+ try {
16
+ const status = await (globalThis.navigator?.permissions)?.query(descriptor);
17
+ return { state: isState(status?.state) ? status.state : "prompt" };
18
+ } catch {
19
+ return { state: "prompt" };
20
+ }
21
+ } };
22
+ //#endregion
23
+ exports.permissions = permissions;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `navigator.permissions`, with the same member name and one behaviour picked.
3
+ *
4
+ * ## The divergence
5
+ *
6
+ * `query()` has FOUR ways of not answering, and an app that wants a permission state has to handle
7
+ * all of them separately even though every one means the same thing to it:
8
+ *
9
+ * 1. the engine has no Permissions API at all, so `navigator.permissions` is undefined
10
+ * 2. the engine has it but rejects the name, because `PermissionName` is a per-engine list
11
+ * 3. the engine has it and THROWS SYNCHRONOUSLY for a name it does not know, which a `.catch` on
12
+ * the returned promise does not see at all
13
+ * 4. it answers, with a real `PermissionState`
14
+ *
15
+ * Only the fourth is the platform's documented behaviour. The other three are the same question
16
+ * coming back unanswered in three different shapes, and the shapes are not interchangeable: case 3
17
+ * needs a `try` around the CALL rather than a `.catch` on its result, which is the kind of detail
18
+ * that is correct in the one file someone thought about it in and wrong everywhere else.
19
+ *
20
+ * ## The pick
21
+ *
22
+ * All three collapse to `'prompt'`.
23
+ *
24
+ * `'prompt'` rather than `'denied'` because an engine that cannot be asked has not refused anything,
25
+ * and treating silence as refusal costs the person a control that might have worked. `'prompt'` is
26
+ * also the honest description of what happens next: press the button and the browser will decide.
27
+ *
28
+ * WHAT THAT LOSES, stated rather than buried: after the collapse, `'prompt'` means either "the
29
+ * browser will ask" or "the browser cannot be asked at all", and nothing distinguishes them any
30
+ * more. That is deliberate. An app that genuinely needs to tell those apart is asking a question the
31
+ * Permissions API does not answer either, since case 2 and case 4 are indistinguishable from a
32
+ * rejected promise.
33
+ *
34
+ * NOT MEASURED, and this is the one entry in the package without a dated number behind it. What is
35
+ * recorded upstream is the SHAPE of the failure rather than an engine and a version: a query for a
36
+ * name an engine does not implement rejects, or throws outright. Both are handled here, and the
37
+ * absence of a measurement is why this file says so rather than inventing one.
38
+ */
39
+ /** The platform's own `PermissionStatus`, narrowed to the member anything actually reads. */
40
+ export type PermissionStatus = {
41
+ state: PermissionState;
42
+ };
43
+ export declare const permissions: {
44
+ /**
45
+ * Same name and same signature, answering `'prompt'` wherever the platform would not answer.
46
+ *
47
+ * The `await` is INSIDE the `try` on purpose, and it is the whole reason this is a wrapper rather
48
+ * than a one-line `.catch`. `query` can throw synchronously for a name the engine does not know
49
+ * and reject asynchronously for one it does, and a `.catch` on the returned promise sees only the
50
+ * second. Getting that wrong turns a missing permission name into an uncaught exception at
51
+ * whatever moment the feature is first used.
52
+ */
53
+ query: (descriptor: PermissionDescriptor) => Promise<PermissionStatus>;
54
+ };
@@ -0,0 +1,22 @@
1
+ //#region src/permissions.ts
2
+ var isState = (value) => value === "granted" || value === "denied" || value === "prompt";
3
+ var permissions = {
4
+ /**
5
+ * Same name and same signature, answering `'prompt'` wherever the platform would not answer.
6
+ *
7
+ * The `await` is INSIDE the `try` on purpose, and it is the whole reason this is a wrapper rather
8
+ * than a one-line `.catch`. `query` can throw synchronously for a name the engine does not know
9
+ * and reject asynchronously for one it does, and a `.catch` on the returned promise sees only the
10
+ * second. Getting that wrong turns a missing permission name into an uncaught exception at
11
+ * whatever moment the feature is first used.
12
+ */
13
+ query: async (descriptor) => {
14
+ try {
15
+ const status = await (globalThis.navigator?.permissions)?.query(descriptor);
16
+ return { state: isState(status?.state) ? status.state : "prompt" };
17
+ } catch {
18
+ return { state: "prompt" };
19
+ }
20
+ } };
21
+ //#endregion
22
+ export { permissions };
package/build/storage.cjs CHANGED
@@ -119,7 +119,37 @@ var storage = {
119
119
  quota: ceiling(estimate.quota, usage)
120
120
  };
121
121
  },
122
- persist: () => globalThis.navigator?.storage?.persist?.() ?? Promise.resolve(false),
122
+ /**
123
+ * Same name and signature, resolving what the origin IS rather than what the call said it did.
124
+ *
125
+ * TWO PICKS, both inside one call.
126
+ *
127
+ * FIRST, the answer. `persist()` resolves its own claim, and `persisted()` resolves the state the
128
+ * app then lives in. Those are different questions and only the second decides anything: whether
129
+ * the origin can still be evicted. A call can resolve false on an engine where the origin is
130
+ * already persistent, and an app reading the first answer then offers a button that has nothing
131
+ * left to do. So this resolves `persisted()`, falling back to the call's own answer only where the
132
+ * platform will not state the state.
133
+ *
134
+ * SECOND, the ceiling. A granted persist can move the quota by orders of magnitude: measured
135
+ * 2026-09-01 on Firefox, granting the "Store data in persistent storage" doorhanger moved the
136
+ * reported quota from 12 GB to 3.97 TB on an 8.03 TB device, about 330 times. `estimate()` latches
137
+ * the narrowest quota it has seen, which is right while nothing changes the ceiling and wrong the
138
+ * moment something does, so a successful grant forgets it. The next `estimate()` re-anchors on
139
+ * whatever the platform now says.
140
+ *
141
+ * Chromium, measured 2026-08-30 on Chrome 151, refuses this on every attempt with no prompt shown
142
+ * at any point, and the quota stays flat. That is not a failure to handle: it is the engine
143
+ * answering, and `persisted()` reports the same false afterwards.
144
+ */
145
+ persist: async () => {
146
+ const native = globalThis.navigator?.storage;
147
+ if (!native?.persist) return false;
148
+ const answered = await native.persist().catch(() => false);
149
+ const persisted = await native.persisted?.().catch(() => null) ?? null ?? answered;
150
+ if (persisted) narrowest = void 0;
151
+ return persisted;
152
+ },
123
153
  persisted: () => globalThis.navigator?.storage?.persisted?.() ?? Promise.resolve(false),
124
154
  getDirectory: () => {
125
155
  const native = globalThis.navigator?.storage;
@@ -81,6 +81,29 @@ export declare const storage: {
81
81
  * downgrade, because the platform's figure is a floor rather than a guess.
82
82
  */
83
83
  estimate: () => Promise<StorageEstimate>;
84
+ /**
85
+ * Same name and signature, resolving what the origin IS rather than what the call said it did.
86
+ *
87
+ * TWO PICKS, both inside one call.
88
+ *
89
+ * FIRST, the answer. `persist()` resolves its own claim, and `persisted()` resolves the state the
90
+ * app then lives in. Those are different questions and only the second decides anything: whether
91
+ * the origin can still be evicted. A call can resolve false on an engine where the origin is
92
+ * already persistent, and an app reading the first answer then offers a button that has nothing
93
+ * left to do. So this resolves `persisted()`, falling back to the call's own answer only where the
94
+ * platform will not state the state.
95
+ *
96
+ * SECOND, the ceiling. A granted persist can move the quota by orders of magnitude: measured
97
+ * 2026-09-01 on Firefox, granting the "Store data in persistent storage" doorhanger moved the
98
+ * reported quota from 12 GB to 3.97 TB on an 8.03 TB device, about 330 times. `estimate()` latches
99
+ * the narrowest quota it has seen, which is right while nothing changes the ceiling and wrong the
100
+ * moment something does, so a successful grant forgets it. The next `estimate()` re-anchors on
101
+ * whatever the platform now says.
102
+ *
103
+ * Chromium, measured 2026-08-30 on Chrome 151, refuses this on every attempt with no prompt shown
104
+ * at any point, and the quota stays flat. That is not a failure to handle: it is the engine
105
+ * answering, and `persisted()` reports the same false afterwards.
106
+ */
84
107
  persist: () => Promise<boolean>;
85
108
  persisted: () => Promise<boolean>;
86
109
  getDirectory: () => Promise<FileSystemDirectoryHandle>;
package/build/storage.js CHANGED
@@ -118,7 +118,37 @@ var storage = {
118
118
  quota: ceiling(estimate.quota, usage)
119
119
  };
120
120
  },
121
- persist: () => globalThis.navigator?.storage?.persist?.() ?? Promise.resolve(false),
121
+ /**
122
+ * Same name and signature, resolving what the origin IS rather than what the call said it did.
123
+ *
124
+ * TWO PICKS, both inside one call.
125
+ *
126
+ * FIRST, the answer. `persist()` resolves its own claim, and `persisted()` resolves the state the
127
+ * app then lives in. Those are different questions and only the second decides anything: whether
128
+ * the origin can still be evicted. A call can resolve false on an engine where the origin is
129
+ * already persistent, and an app reading the first answer then offers a button that has nothing
130
+ * left to do. So this resolves `persisted()`, falling back to the call's own answer only where the
131
+ * platform will not state the state.
132
+ *
133
+ * SECOND, the ceiling. A granted persist can move the quota by orders of magnitude: measured
134
+ * 2026-09-01 on Firefox, granting the "Store data in persistent storage" doorhanger moved the
135
+ * reported quota from 12 GB to 3.97 TB on an 8.03 TB device, about 330 times. `estimate()` latches
136
+ * the narrowest quota it has seen, which is right while nothing changes the ceiling and wrong the
137
+ * moment something does, so a successful grant forgets it. The next `estimate()` re-anchors on
138
+ * whatever the platform now says.
139
+ *
140
+ * Chromium, measured 2026-08-30 on Chrome 151, refuses this on every attempt with no prompt shown
141
+ * at any point, and the quota stays flat. That is not a failure to handle: it is the engine
142
+ * answering, and `persisted()` reports the same false afterwards.
143
+ */
144
+ persist: async () => {
145
+ const native = globalThis.navigator?.storage;
146
+ if (!native?.persist) return false;
147
+ const answered = await native.persist().catch(() => false);
148
+ const persisted = await native.persisted?.().catch(() => null) ?? null ?? answered;
149
+ if (persisted) narrowest = void 0;
150
+ return persisted;
151
+ },
122
152
  persisted: () => globalThis.navigator?.storage?.persisted?.() ?? Promise.resolve(false),
123
153
  getDirectory: () => {
124
154
  const native = globalThis.navigator?.storage;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banou/ponyfill",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "type": "module",
5
5
  "main": "build/index.cjs",
6
6
  "module": "build/index.js",
@@ -15,6 +15,16 @@
15
15
  "types": "./build/storage.d.ts",
16
16
  "import": "./build/storage.js",
17
17
  "require": "./build/storage.cjs"
18
+ },
19
+ "./permissions": {
20
+ "types": "./build/permissions.d.ts",
21
+ "import": "./build/permissions.js",
22
+ "require": "./build/permissions.cjs"
23
+ },
24
+ "./file-system": {
25
+ "types": "./build/file-system.d.ts",
26
+ "import": "./build/file-system.js",
27
+ "require": "./build/file-system.cjs"
18
28
  }
19
29
  },
20
30
  "scripts": {