@civitai/sdk 0.6.0 → 0.7.0
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 +85 -0
- package/dist/index.js +11 -0
- package/dist/safe-storage/index.d.ts +106 -0
- package/dist/safe-storage/index.js +374 -0
- package/package.json +9 -2
package/README.md
CHANGED
|
@@ -374,6 +374,91 @@ not exported, because four of the five fleet apps that store per-viewer state
|
|
|
374
374
|
need knobs it does not have (injected latency, prefix-targeted refusals, quota
|
|
375
375
|
overrides, a cursor it ignores, a read that never settles). Copy it if it helps.
|
|
376
376
|
|
|
377
|
+
## Web storage in a block
|
|
378
|
+
|
|
379
|
+
A block is framed at an **opaque origin**: civitai's `intersectSandbox` adds
|
|
380
|
+
`allow-same-origin` only for the `internal` and `verified` trust tiers, and in
|
|
381
|
+
v1 every approved block is `unverified`. There, `localStorage` and
|
|
382
|
+
`sessionStorage` do not merely come back empty — *reading the property throws*:
|
|
383
|
+
|
|
384
|
+
```
|
|
385
|
+
SecurityError: Failed to read the 'localStorage' property from 'Window':
|
|
386
|
+
The document is sandboxed and lacks the 'allow-same-origin' flag.
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
Guarding your own call sites is not enough, because a dependency that touches
|
|
390
|
+
storage while its module body evaluates takes the app down before any of your
|
|
391
|
+
code runs — and libraries routinely mislabel the failure as something else.
|
|
392
|
+
|
|
393
|
+
So the package ships a repair behind its own subpath. Importing
|
|
394
|
+
`@civitai/sdk/safe-storage` installs a spec-shaped in-memory `Storage` over any
|
|
395
|
+
web-storage global that is present but unusable. It is inert where storage works
|
|
396
|
+
and where there is none at all (Node/SSR/workers), and it never replaces a
|
|
397
|
+
healthy store.
|
|
398
|
+
|
|
399
|
+
🔴 **This is not automatic. Add the import yourself, as the first import in your
|
|
400
|
+
entry module** — `import '@civitai/sdk'` on its own installs nothing:
|
|
401
|
+
|
|
402
|
+
```
|
|
403
|
+
import '@civitai/sdk/safe-storage'; // side-effect import; keep it FIRST
|
|
404
|
+
import 'some-library-that-reads-localStorage';
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
The import *is* the install, so for most blocks that line is the whole of it.
|
|
408
|
+
There is nothing to configure. The subpath does export two functions, for the
|
|
409
|
+
cases the import alone cannot cover:
|
|
410
|
+
|
|
411
|
+
- `installSafeStorage(scope?)` — run the repair at a *specific moment* rather
|
|
412
|
+
than at module-evaluation time. The one that matters: immediately before an
|
|
413
|
+
`await import('some-lib')` that reads storage while evaluating. A hoisted
|
|
414
|
+
static import cannot be placed "after" anything, so a dynamic import is the
|
|
415
|
+
case the side-effect line does not solve. Returns which globals it replaced.
|
|
416
|
+
- `createMemoryStorage()` — the spec-shaped in-memory `Storage` itself, if you
|
|
417
|
+
want one without touching any global.
|
|
418
|
+
|
|
419
|
+
Two things about that line:
|
|
420
|
+
|
|
421
|
+
- **First is the whole point.** ES imports are hoisted and evaluated in order,
|
|
422
|
+
so a storage-touching dependency imported *above* this one still evaluates
|
|
423
|
+
first and nothing can reach it in time. Put it above every other import,
|
|
424
|
+
including your framework's.
|
|
425
|
+
- **It is a side-effect-only import, so bundlers must be told to keep it.** The
|
|
426
|
+
package declares a `sideEffects` allowlist naming
|
|
427
|
+
`./dist/safe-storage/index.js` for exactly that reason; `sideEffects: false`
|
|
428
|
+
would license every bundler to drop it. The package root
|
|
429
|
+
(`./dist/index.js`) is deliberately *not* in that allowlist — it is pure
|
|
430
|
+
re-exports and stays fully tree-shakeable.
|
|
431
|
+
|
|
432
|
+
### Two things "inert where storage works" hides
|
|
433
|
+
|
|
434
|
+
Both only ever happen at a **real** origin — neither can occur in the block
|
|
435
|
+
sandbox this subpath exists for.
|
|
436
|
+
|
|
437
|
+
- **It writes a probe key.** Classifying a store means a real round trip, so at
|
|
438
|
+
import each of `localStorage` and `sessionStorage` gets one
|
|
439
|
+
`setItem('__civitai_app_sdk_storage_probe__', '1')` followed immediately by
|
|
440
|
+
`removeItem`. Net contents are unchanged and neither store object is replaced
|
|
441
|
+
— but per the Web Storage spec each write queues a `storage` event in the
|
|
442
|
+
other documents sharing the store, so a cross-tab listener that does not
|
|
443
|
+
filter by key will see spurious events.
|
|
444
|
+
- **A FULL store counts as broken.** The write probe cannot tell "quota
|
|
445
|
+
exhausted" from "storage disabled" — a `QuotaExceededError` classifies the
|
|
446
|
+
store as unusable, and the repair swaps in the in-memory fallback, seeded
|
|
447
|
+
first from every entry it could still read. Nothing readable is lost, but the
|
|
448
|
+
store stops persisting: writes after that point are session-scoped and gone
|
|
449
|
+
on reload. In a block that is the honest semantic regardless. In an app at a
|
|
450
|
+
real origin with a full `localStorage`, it is a downgrade from persistent to
|
|
451
|
+
session-scoped that you opted into by importing this subpath.
|
|
452
|
+
|
|
453
|
+
A block built on `@civitai/app-sdk/blocks` already gets this: that entry imports
|
|
454
|
+
its own copy (`@civitai/app-sdk/safe-storage`). A block that has finished porting
|
|
455
|
+
to `@civitai/sdk` imports neither, which is why this line exists.
|
|
456
|
+
|
|
457
|
+
The fallback is session-scoped — nothing survives a reload, which is the honest
|
|
458
|
+
semantic at an opaque origin, since there is no origin to persist against. Treat
|
|
459
|
+
web storage in a block as a cache, never a source of truth. The durable
|
|
460
|
+
per-viewer store is [app storage](#app-storage).
|
|
461
|
+
|
|
377
462
|
## Checks
|
|
378
463
|
|
|
379
464
|
- `npm run api` regenerates [`api/public-api.md`](./api/public-api.md) from the
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
* `const app = await initialize()`, then `app.site`, `app.orchestration` and,
|
|
3
3
|
* inside a civitai.com page, `app.host`. Outside one, `createSignIn()` gets the token.
|
|
4
4
|
*/
|
|
5
|
+
// 🔴 NOTHING IS IMPORTED FOR SIDE EFFECTS HERE, on purpose. This entry is pure
|
|
6
|
+
// re-exports, so `./dist/index.js` stays droppable and `import '@civitai/sdk'`
|
|
7
|
+
// stays inert in Node/SSR.
|
|
8
|
+
//
|
|
9
|
+
// The opaque-origin web-storage repair lives behind its own subpath,
|
|
10
|
+
// `@civitai/sdk/safe-storage` (`./safe-storage/index.ts`), and a block opts in
|
|
11
|
+
// with one line at the top of its entry module. Installing it from here would
|
|
12
|
+
// make every consumer of this package — server runtimes included — pay a side
|
|
13
|
+
// effect it never asked for, and would still not fix the ordering problem: a
|
|
14
|
+
// storage-touching dependency imported ABOVE `@civitai/sdk` evaluates first
|
|
15
|
+
// either way. See the README's "Web storage in a block".
|
|
5
16
|
export { initialize } from './app/index.js';
|
|
6
17
|
export { createSignIn, SignInError } from './sign-in/index.js';
|
|
7
18
|
export { SCOPES } from './session/index.js';
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Survive an opaque-origin sandbox. Published as the side-effect subpath
|
|
3
|
+
* `@civitai/sdk/safe-storage`; a block opts in with `import
|
|
4
|
+
* '@civitai/sdk/safe-storage';` as the first line of its entry module. The
|
|
5
|
+
* package root does NOT import it — see `src/index.ts`.
|
|
6
|
+
*
|
|
7
|
+
* 🔴 **This is a deliberate independent COPY of `@civitai/app-sdk`'s
|
|
8
|
+
* `src/safe-storage/index.ts`, not a shared module.** The two packages are
|
|
9
|
+
* separate codebases (`@civitai/sdk` succeeds `@civitai/app-sdk`) and must not
|
|
10
|
+
* depend on each other — a successor that imports its predecessor would drag
|
|
11
|
+
* the whole 0.x surface back in. The duplication is the price of that
|
|
12
|
+
* independence. The two bodies are held byte-identical modulo comments by
|
|
13
|
+
* `tests/guards/safe-storage-copy-parity.test.mjs`, so a fix to one goes red
|
|
14
|
+
* until it is applied to the other — do not rely on remembering.
|
|
15
|
+
*
|
|
16
|
+
* Why this has to exist here at all: a block that has finished porting to
|
|
17
|
+
* `@civitai/sdk` no longer imports `@civitai/app-sdk/blocks`, which is what
|
|
18
|
+
* used to install the shim. `civitai-app-requests` is exactly that shape today
|
|
19
|
+
* — its only remaining `@civitai/app-sdk/blocks` import is in a test file — so
|
|
20
|
+
* without this module its shipped bundle has no repair at all.
|
|
21
|
+
*
|
|
22
|
+
* Civitai Apps run in an iframe sandboxed as `allow-scripts allow-forms`,
|
|
23
|
+
* deliberately WITHOUT `allow-same-origin` (see civitai's
|
|
24
|
+
* `src/components/AppBlocks/sandbox.ts`: `intersectSandbox` adds
|
|
25
|
+
* `allow-same-origin` only for the `internal`/`verified` trust tiers, and in v1
|
|
26
|
+
* every approved block is `unverified`). The document therefore has an
|
|
27
|
+
* **opaque origin**, and there is no origin to key web storage against, so the
|
|
28
|
+
* platform does not merely return an empty store — merely *reading* the
|
|
29
|
+
* property throws:
|
|
30
|
+
*
|
|
31
|
+
* SecurityError: Failed to read the 'localStorage' property from 'Window':
|
|
32
|
+
* The document is sandboxed and lacks the 'allow-same-origin' flag.
|
|
33
|
+
*
|
|
34
|
+
* Guarding your own call sites is not enough: any third-party dependency that
|
|
35
|
+
* touches storage unguarded takes the whole app down, and libraries routinely
|
|
36
|
+
* *mislabel* the failure. A real production example — Photo Sphere Viewer's
|
|
37
|
+
* `SYSTEM.load()` runs an unguarded ``TOUCH_KEY in localStorage`` touch probe,
|
|
38
|
+
* its `Viewer` constructor catches the resulting SecurityError, reports the
|
|
39
|
+
* generic "Your browser does not seem to support WebGL", and returns without
|
|
40
|
+
* rethrowing. The app's own fallback never ran and users with perfectly good
|
|
41
|
+
* GPUs saw a WebGL error. (The sandbox does not block WebGL — only storage.)
|
|
42
|
+
*
|
|
43
|
+
* So this module installs a spec-shaped in-memory `Storage` over any
|
|
44
|
+
* `localStorage`/`sessionStorage` that is present but unusable. Importing it
|
|
45
|
+
* runs the install (see the bottom of the file).
|
|
46
|
+
*
|
|
47
|
+
* The fallback is session-scoped — nothing survives a reload — which is the
|
|
48
|
+
* honest semantic at an opaque origin: there is no origin to persist against.
|
|
49
|
+
* Treat storage in a block as a cache, never a source of truth; the durable
|
|
50
|
+
* per-user store is the platform's app-storage API.
|
|
51
|
+
*/
|
|
52
|
+
/** Which globals a call to {@link installSafeStorage} actually replaced. */
|
|
53
|
+
export interface SafeStorageInstallResult {
|
|
54
|
+
/** `true` when `localStorage` was replaced by the in-memory fallback. */
|
|
55
|
+
localStorage: boolean;
|
|
56
|
+
/** `true` when `sessionStorage` was replaced by the in-memory fallback. */
|
|
57
|
+
sessionStorage: boolean;
|
|
58
|
+
}
|
|
59
|
+
declare const STORAGE_NAMES: readonly ['localStorage', 'sessionStorage'];
|
|
60
|
+
/** The web-storage globals this module can repair. */
|
|
61
|
+
export type SafeStorageName = (typeof STORAGE_NAMES)[number];
|
|
62
|
+
/**
|
|
63
|
+
* A `Storage` work-alike backed by a `Map`.
|
|
64
|
+
*
|
|
65
|
+
* Implemented as a **Proxy, not a class**, because real `Storage` is an exotic
|
|
66
|
+
* object: `s.foo = 1`, `'foo' in s`, `s.foo` and `delete s.foo` are aliases for
|
|
67
|
+
* `setItem`/`getItem`/`removeItem`. Libraries use exactly that form (PSV's
|
|
68
|
+
* touch probe is literally ``KEY in localStorage`` then
|
|
69
|
+
* `localStorage[KEY] === 'true'`), so a plain class would still break them.
|
|
70
|
+
*/
|
|
71
|
+
export declare function createMemoryStorage(): Storage;
|
|
72
|
+
/**
|
|
73
|
+
* Replace any present-but-unusable `localStorage`/`sessionStorage` on `scope`
|
|
74
|
+
* with an in-memory `Storage`, so third-party code that touches it unguarded
|
|
75
|
+
* cannot throw.
|
|
76
|
+
*
|
|
77
|
+
* - **No-op where storage works — but not a no-TOUCH.** A healthy `Storage` is
|
|
78
|
+
* never replaced and its contents are unchanged, but it *is* written to:
|
|
79
|
+
* classifying it means a real round trip, so each of `localStorage` and
|
|
80
|
+
* `sessionStorage` gets `setItem('__civitai_app_sdk_storage_probe__', '1')`
|
|
81
|
+
* immediately followed by `removeItem`. MEASURED against the built artifact
|
|
82
|
+
* with instrumented healthy stores: exactly those four operations, the store
|
|
83
|
+
* objects not replaced, contents identical before and after. Per the Web
|
|
84
|
+
* Storage spec every one of those writes queues a `storage` event in the
|
|
85
|
+
* *other* documents that share the store, so at a REAL origin a cross-tab
|
|
86
|
+
* listener that does not filter by key sees spurious events (the two
|
|
87
|
+
* `localStorage` writes are the ones another tab receives). At the opaque
|
|
88
|
+
* origin this module exists for there is no other same-origin document, so
|
|
89
|
+
* there is nothing to receive them.
|
|
90
|
+
* - **No-op where storage is absent** (Node/SSR/workers). Nothing is invented.
|
|
91
|
+
* - **Never loses readable data.** When the old store reads but refuses writes
|
|
92
|
+
* (full / disabled), its entries are copied into the fallback first, so the
|
|
93
|
+
* shim cannot shadow a live session.
|
|
94
|
+
* - **Idempotent.** Once the fallback is installed the round-trip probe
|
|
95
|
+
* succeeds, so a second call is a no-op and values already written survive.
|
|
96
|
+
* - **Never throws.** Every step is guarded; a shim that could fail at import
|
|
97
|
+
* would be a worse outage than the bug it fixes.
|
|
98
|
+
*
|
|
99
|
+
* This module installs on import, so most apps never need to call it. Call it
|
|
100
|
+
* explicitly when you want the guarantee at a specific moment — e.g. right
|
|
101
|
+
* before `await import('some-lib')` that reads storage while evaluating.
|
|
102
|
+
*
|
|
103
|
+
* @param scope Object carrying the storage globals. Defaults to `globalThis`.
|
|
104
|
+
*/
|
|
105
|
+
export declare function installSafeStorage(scope?: object): SafeStorageInstallResult;
|
|
106
|
+
export {};
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Survive an opaque-origin sandbox. Published as the side-effect subpath
|
|
3
|
+
* `@civitai/sdk/safe-storage`; a block opts in with `import
|
|
4
|
+
* '@civitai/sdk/safe-storage';` as the first line of its entry module. The
|
|
5
|
+
* package root does NOT import it — see `src/index.ts`.
|
|
6
|
+
*
|
|
7
|
+
* 🔴 **This is a deliberate independent COPY of `@civitai/app-sdk`'s
|
|
8
|
+
* `src/safe-storage/index.ts`, not a shared module.** The two packages are
|
|
9
|
+
* separate codebases (`@civitai/sdk` succeeds `@civitai/app-sdk`) and must not
|
|
10
|
+
* depend on each other — a successor that imports its predecessor would drag
|
|
11
|
+
* the whole 0.x surface back in. The duplication is the price of that
|
|
12
|
+
* independence. The two bodies are held byte-identical modulo comments by
|
|
13
|
+
* `tests/guards/safe-storage-copy-parity.test.mjs`, so a fix to one goes red
|
|
14
|
+
* until it is applied to the other — do not rely on remembering.
|
|
15
|
+
*
|
|
16
|
+
* Why this has to exist here at all: a block that has finished porting to
|
|
17
|
+
* `@civitai/sdk` no longer imports `@civitai/app-sdk/blocks`, which is what
|
|
18
|
+
* used to install the shim. `civitai-app-requests` is exactly that shape today
|
|
19
|
+
* — its only remaining `@civitai/app-sdk/blocks` import is in a test file — so
|
|
20
|
+
* without this module its shipped bundle has no repair at all.
|
|
21
|
+
*
|
|
22
|
+
* Civitai Apps run in an iframe sandboxed as `allow-scripts allow-forms`,
|
|
23
|
+
* deliberately WITHOUT `allow-same-origin` (see civitai's
|
|
24
|
+
* `src/components/AppBlocks/sandbox.ts`: `intersectSandbox` adds
|
|
25
|
+
* `allow-same-origin` only for the `internal`/`verified` trust tiers, and in v1
|
|
26
|
+
* every approved block is `unverified`). The document therefore has an
|
|
27
|
+
* **opaque origin**, and there is no origin to key web storage against, so the
|
|
28
|
+
* platform does not merely return an empty store — merely *reading* the
|
|
29
|
+
* property throws:
|
|
30
|
+
*
|
|
31
|
+
* SecurityError: Failed to read the 'localStorage' property from 'Window':
|
|
32
|
+
* The document is sandboxed and lacks the 'allow-same-origin' flag.
|
|
33
|
+
*
|
|
34
|
+
* Guarding your own call sites is not enough: any third-party dependency that
|
|
35
|
+
* touches storage unguarded takes the whole app down, and libraries routinely
|
|
36
|
+
* *mislabel* the failure. A real production example — Photo Sphere Viewer's
|
|
37
|
+
* `SYSTEM.load()` runs an unguarded ``TOUCH_KEY in localStorage`` touch probe,
|
|
38
|
+
* its `Viewer` constructor catches the resulting SecurityError, reports the
|
|
39
|
+
* generic "Your browser does not seem to support WebGL", and returns without
|
|
40
|
+
* rethrowing. The app's own fallback never ran and users with perfectly good
|
|
41
|
+
* GPUs saw a WebGL error. (The sandbox does not block WebGL — only storage.)
|
|
42
|
+
*
|
|
43
|
+
* So this module installs a spec-shaped in-memory `Storage` over any
|
|
44
|
+
* `localStorage`/`sessionStorage` that is present but unusable. Importing it
|
|
45
|
+
* runs the install (see the bottom of the file).
|
|
46
|
+
*
|
|
47
|
+
* The fallback is session-scoped — nothing survives a reload — which is the
|
|
48
|
+
* honest semantic at an opaque origin: there is no origin to persist against.
|
|
49
|
+
* Treat storage in a block as a cache, never a source of truth; the durable
|
|
50
|
+
* per-user store is the platform's app-storage API.
|
|
51
|
+
*/
|
|
52
|
+
const STORAGE_NAMES = ['localStorage', 'sessionStorage'];
|
|
53
|
+
const PROBE_KEY = '__civitai_app_sdk_storage_probe__';
|
|
54
|
+
/**
|
|
55
|
+
* A `Storage` work-alike backed by a `Map`.
|
|
56
|
+
*
|
|
57
|
+
* Implemented as a **Proxy, not a class**, because real `Storage` is an exotic
|
|
58
|
+
* object: `s.foo = 1`, `'foo' in s`, `s.foo` and `delete s.foo` are aliases for
|
|
59
|
+
* `setItem`/`getItem`/`removeItem`. Libraries use exactly that form (PSV's
|
|
60
|
+
* touch probe is literally ``KEY in localStorage`` then
|
|
61
|
+
* `localStorage[KEY] === 'true'`), so a plain class would still break them.
|
|
62
|
+
*/
|
|
63
|
+
export function createMemoryStorage() {
|
|
64
|
+
const map = new Map();
|
|
65
|
+
const api = {
|
|
66
|
+
getItem(key) {
|
|
67
|
+
const k = String(key);
|
|
68
|
+
return map.has(k) ? map.get(k) : null;
|
|
69
|
+
},
|
|
70
|
+
setItem(key, value) {
|
|
71
|
+
map.set(String(key), String(value));
|
|
72
|
+
},
|
|
73
|
+
removeItem(key) {
|
|
74
|
+
map.delete(String(key));
|
|
75
|
+
},
|
|
76
|
+
clear() {
|
|
77
|
+
map.clear();
|
|
78
|
+
},
|
|
79
|
+
key(index) {
|
|
80
|
+
return Array.from(map.keys())[index] ?? null;
|
|
81
|
+
},
|
|
82
|
+
get length() {
|
|
83
|
+
return map.size;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
// `Reflect.has` walks the prototype chain, so `'toString' in storage` stays
|
|
87
|
+
// true exactly as it is for a real Storage instance. Only keys that are NOT
|
|
88
|
+
// part of the Storage API fall through to the backing map.
|
|
89
|
+
const isApiKey = (prop) => typeof prop === 'symbol' || Reflect.has(api, prop);
|
|
90
|
+
return new Proxy(api, {
|
|
91
|
+
get(target, prop, receiver) {
|
|
92
|
+
if (isApiKey(prop))
|
|
93
|
+
return Reflect.get(target, prop, receiver);
|
|
94
|
+
return map.get(prop);
|
|
95
|
+
},
|
|
96
|
+
set(target, prop, value, receiver) {
|
|
97
|
+
if (isApiKey(prop))
|
|
98
|
+
return Reflect.set(target, prop, value, receiver);
|
|
99
|
+
map.set(prop, String(value));
|
|
100
|
+
return true;
|
|
101
|
+
},
|
|
102
|
+
has(_target, prop) {
|
|
103
|
+
return isApiKey(prop) || map.has(prop);
|
|
104
|
+
},
|
|
105
|
+
deleteProperty(_target, prop) {
|
|
106
|
+
// `delete storage.getItem` must NOT destroy the method, and
|
|
107
|
+
// `delete storage.length` must NOT leave `length` undefined — a
|
|
108
|
+
// `for (i < storage.length)` loop would then silently iterate zero
|
|
109
|
+
// times. Real `Storage` is unbothered: its API lives on
|
|
110
|
+
// `Storage.prototype`, so deleting it from the instance finds no own
|
|
111
|
+
// property, reports success and changes nothing. Mirror that exactly.
|
|
112
|
+
if (isApiKey(prop))
|
|
113
|
+
return true;
|
|
114
|
+
map.delete(prop);
|
|
115
|
+
return true;
|
|
116
|
+
},
|
|
117
|
+
// Enumeration sees only stored keys — matching real Storage, where
|
|
118
|
+
// `Object.keys(localStorage)` lists the entries, not the methods.
|
|
119
|
+
ownKeys() {
|
|
120
|
+
return Array.from(map.keys());
|
|
121
|
+
},
|
|
122
|
+
// A non-extensible target would make the `ownKeys` trap above violate a
|
|
123
|
+
// proxy invariant (it must then report every own key of the target), so a
|
|
124
|
+
// single `Object.freeze(localStorage)` — from a SES/`harden()` lockdown
|
|
125
|
+
// sweep, say — would make `Object.keys` / spread / `JSON.stringify` /
|
|
126
|
+
// `for...in` throw on this object FOREVER. Refusing keeps enumeration
|
|
127
|
+
// alive and matches the platform: `Storage` is a legacy platform object
|
|
128
|
+
// with a named-property handler, whose `[[PreventExtensions]]` also
|
|
129
|
+
// returns false.
|
|
130
|
+
preventExtensions: () => false,
|
|
131
|
+
getOwnPropertyDescriptor(target, prop) {
|
|
132
|
+
if (!isApiKey(prop) && map.has(prop)) {
|
|
133
|
+
return {
|
|
134
|
+
value: map.get(prop),
|
|
135
|
+
writable: true,
|
|
136
|
+
enumerable: true,
|
|
137
|
+
configurable: true,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return Reflect.getOwnPropertyDescriptor(target, prop);
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Is `scope[name]` Node's *disabled* web-storage stub?
|
|
146
|
+
*
|
|
147
|
+
* Node >= 22 defines `globalThis.localStorage` / `sessionStorage` as lazy
|
|
148
|
+
* accessors. Without `--localstorage-file` they return `undefined` **and emit
|
|
149
|
+
* `ExperimentalWarning: localStorage is not available because
|
|
150
|
+
* --localstorage-file was not provided.` on every read** — so importing this
|
|
151
|
+
* module would print that on every server boot and in CI. `in` does not trip
|
|
152
|
+
* the warning, but it does report `true` there, so existence alone is not
|
|
153
|
+
* enough to skip the read.
|
|
154
|
+
*
|
|
155
|
+
* Both signals must agree, and anything ambiguous falls through to a real
|
|
156
|
+
* probe:
|
|
157
|
+
*
|
|
158
|
+
* 1. we are running on Node;
|
|
159
|
+
* 2. the property is an **own, non-enumerable accessor with a setter** —
|
|
160
|
+
* Node's replaceable global. The web platform can never match this:
|
|
161
|
+
* `Window.localStorage` is a WebIDL *readonly* attribute, so it is
|
|
162
|
+
* `enumerable: true` with `set: undefined`, in a sandbox exactly as much as
|
|
163
|
+
* anywhere else. That is a spec guarantee, not a guess, and it is why the
|
|
164
|
+
* check does not also test for `window` — happy-dom-style test
|
|
165
|
+
* environments expose a `window` while `globalThis.localStorage` is still
|
|
166
|
+
* Node's stub underneath.
|
|
167
|
+
*
|
|
168
|
+
* Skipping costs nothing either way. Without `--localstorage-file` the read
|
|
169
|
+
* would have returned `undefined` → `absent`, which is what we return; *with*
|
|
170
|
+
* it the storage works → `usable`, which is also a no-op.
|
|
171
|
+
*/
|
|
172
|
+
function isNodeWebStorageStub(scope, name) {
|
|
173
|
+
const runtime = globalThis;
|
|
174
|
+
if (typeof runtime.process?.versions?.node !== 'string')
|
|
175
|
+
return false;
|
|
176
|
+
const descriptor = Object.getOwnPropertyDescriptor(scope, name);
|
|
177
|
+
return (descriptor !== undefined &&
|
|
178
|
+
typeof descriptor.get === 'function' &&
|
|
179
|
+
typeof descriptor.set === 'function' &&
|
|
180
|
+
descriptor.enumerable === false);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Classify `scope[name]`.
|
|
184
|
+
*
|
|
185
|
+
* Reading the property can succeed while writing still throws (storage
|
|
186
|
+
* disabled, private-browsing quirks, quota exhausted), so probe a real
|
|
187
|
+
* round-trip rather than trusting the getter.
|
|
188
|
+
*
|
|
189
|
+
* `absent` is deliberately distinct from `broken`: a runtime with no web
|
|
190
|
+
* storage at all (Node/SSR, a worker) must be left alone. Inventing a
|
|
191
|
+
* `localStorage` there would flip isomorphic libraries onto their browser code
|
|
192
|
+
* path and cause a *different* class of bug than the one we are fixing.
|
|
193
|
+
*
|
|
194
|
+
* **Never throws.** `installSafeStorage()` runs at module scope, so an escaping
|
|
195
|
+
* error would reject `import '@civitai/sdk'` outright and take down every app
|
|
196
|
+
* and block that imports it — a worse failure than the one being fixed.
|
|
197
|
+
*/
|
|
198
|
+
function probe(scope, name) {
|
|
199
|
+
// `in` is the only safe existence check, and it has to come first.
|
|
200
|
+
//
|
|
201
|
+
// It runs [[HasProperty]], which does NOT invoke the getter — on an ordinary
|
|
202
|
+
// object (`globalThis` included) it runs no user code at all, so it cannot
|
|
203
|
+
// throw and cannot trip Node's stub (above).
|
|
204
|
+
//
|
|
205
|
+
// The conventional `typeof localStorage === 'undefined'` guard is NOT a
|
|
206
|
+
// substitute: inside a real opaque-origin sandbox `typeof localStorage`
|
|
207
|
+
// ALSO throws, because `typeof` on a member/global reference still resolves
|
|
208
|
+
// the property and runs the throwing getter — only `typeof` of an
|
|
209
|
+
// *undeclared identifier* is safe, and this identifier is declared.
|
|
210
|
+
// `'localStorage' in window` is the probe that survives there, and it
|
|
211
|
+
// correctly answers `true`: in the sandbox the global exists, it is merely
|
|
212
|
+
// unreadable. So the sandbox is classified `broken` below, never `absent`.
|
|
213
|
+
if (!(name in scope))
|
|
214
|
+
return { health: 'absent' };
|
|
215
|
+
if (isNodeWebStorageStub(scope, name))
|
|
216
|
+
return { health: 'absent' };
|
|
217
|
+
let storage;
|
|
218
|
+
try {
|
|
219
|
+
storage = scope[name];
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
// The opaque-origin case: the getter itself throws.
|
|
223
|
+
return { health: 'broken' };
|
|
224
|
+
}
|
|
225
|
+
if (storage === undefined || storage === null)
|
|
226
|
+
return { health: 'absent' };
|
|
227
|
+
// Everything below TOUCHES the object we were handed, and every one of those
|
|
228
|
+
// touches can throw — a revoked `Proxy` answers even a plain property read
|
|
229
|
+
// with a TypeError, and any exotic object can have a throwing getter. So the
|
|
230
|
+
// shape check lives inside the `try` alongside the round-trip probe rather
|
|
231
|
+
// than in front of it.
|
|
232
|
+
//
|
|
233
|
+
// A property read that throws is classified `broken`, not `usable`: every
|
|
234
|
+
// consumer's `localStorage.getItem(...)` would throw exactly the same way, so
|
|
235
|
+
// the global is unusable by definition — the same conclusion the throwing
|
|
236
|
+
// *getter* above already reaches. Leaving it in place would keep a provably
|
|
237
|
+
// lethal global on the page; replacing it cannot produce a false positive,
|
|
238
|
+
// because a healthy `Storage` never throws on method access.
|
|
239
|
+
try {
|
|
240
|
+
if (typeof storage.getItem !== 'function' || typeof storage.setItem !== 'function') {
|
|
241
|
+
return { health: 'broken', storage };
|
|
242
|
+
}
|
|
243
|
+
storage.setItem(PROBE_KEY, '1');
|
|
244
|
+
storage.removeItem(PROBE_KEY);
|
|
245
|
+
return { health: 'usable', storage };
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return { health: 'broken', storage };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Copy whatever `existing` still holds into `into`, best-effort.
|
|
253
|
+
*
|
|
254
|
+
* The write probe fails for two very different reasons. At an opaque origin
|
|
255
|
+
* there is nothing to read and nothing to preserve. But a *full* (or
|
|
256
|
+
* write-disabled) storage on an ordinary origin — historically the
|
|
257
|
+
* Safari-private-mode shape, and the likeliest real-world trigger — reads
|
|
258
|
+
* perfectly well: swapping a bare in-memory `Storage` over it would shadow
|
|
259
|
+
* live data, turning "can't save" into "the session is gone". So the fallback
|
|
260
|
+
* inherits the current entries first.
|
|
261
|
+
*
|
|
262
|
+
* Values written *after* the swap are session-scoped and lost on reload, the
|
|
263
|
+
* same as every other install path — the fallback never claims to persist.
|
|
264
|
+
*
|
|
265
|
+
* Reading the old store is itself unguarded territory (revoked proxies,
|
|
266
|
+
* throwing getters, an object that is not `Storage`-shaped at all), so the
|
|
267
|
+
* whole copy is best-effort: an empty fallback still beats a global that
|
|
268
|
+
* throws.
|
|
269
|
+
*/
|
|
270
|
+
function seedFrom(existing, into) {
|
|
271
|
+
try {
|
|
272
|
+
const size = existing.length;
|
|
273
|
+
if (typeof size !== 'number' || typeof existing.key !== 'function')
|
|
274
|
+
return;
|
|
275
|
+
if (typeof existing.getItem !== 'function')
|
|
276
|
+
return;
|
|
277
|
+
for (let index = 0; index < size; index += 1) {
|
|
278
|
+
const key = existing.key(index);
|
|
279
|
+
if (key === null || key === undefined || key === PROBE_KEY)
|
|
280
|
+
continue;
|
|
281
|
+
const value = existing.getItem(key);
|
|
282
|
+
if (value !== null && value !== undefined)
|
|
283
|
+
into.setItem(key, value);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
// Unreadable after all. Nothing to inherit; carry on with an empty store.
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Replace any present-but-unusable `localStorage`/`sessionStorage` on `scope`
|
|
292
|
+
* with an in-memory `Storage`, so third-party code that touches it unguarded
|
|
293
|
+
* cannot throw.
|
|
294
|
+
*
|
|
295
|
+
* - **No-op where storage works — but not a no-TOUCH.** A healthy `Storage` is
|
|
296
|
+
* never replaced and its contents are unchanged, but it *is* written to:
|
|
297
|
+
* classifying it means a real round trip, so each of `localStorage` and
|
|
298
|
+
* `sessionStorage` gets `setItem('__civitai_app_sdk_storage_probe__', '1')`
|
|
299
|
+
* immediately followed by `removeItem`. MEASURED against the built artifact
|
|
300
|
+
* with instrumented healthy stores: exactly those four operations, the store
|
|
301
|
+
* objects not replaced, contents identical before and after. Per the Web
|
|
302
|
+
* Storage spec every one of those writes queues a `storage` event in the
|
|
303
|
+
* *other* documents that share the store, so at a REAL origin a cross-tab
|
|
304
|
+
* listener that does not filter by key sees spurious events (the two
|
|
305
|
+
* `localStorage` writes are the ones another tab receives). At the opaque
|
|
306
|
+
* origin this module exists for there is no other same-origin document, so
|
|
307
|
+
* there is nothing to receive them.
|
|
308
|
+
* - **No-op where storage is absent** (Node/SSR/workers). Nothing is invented.
|
|
309
|
+
* - **Never loses readable data.** When the old store reads but refuses writes
|
|
310
|
+
* (full / disabled), its entries are copied into the fallback first, so the
|
|
311
|
+
* shim cannot shadow a live session.
|
|
312
|
+
* - **Idempotent.** Once the fallback is installed the round-trip probe
|
|
313
|
+
* succeeds, so a second call is a no-op and values already written survive.
|
|
314
|
+
* - **Never throws.** Every step is guarded; a shim that could fail at import
|
|
315
|
+
* would be a worse outage than the bug it fixes.
|
|
316
|
+
*
|
|
317
|
+
* This module installs on import, so most apps never need to call it. Call it
|
|
318
|
+
* explicitly when you want the guarantee at a specific moment — e.g. right
|
|
319
|
+
* before `await import('some-lib')` that reads storage while evaluating.
|
|
320
|
+
*
|
|
321
|
+
* @param scope Object carrying the storage globals. Defaults to `globalThis`.
|
|
322
|
+
*/
|
|
323
|
+
export function installSafeStorage(scope = globalThis) {
|
|
324
|
+
const result = { localStorage: false, sessionStorage: false };
|
|
325
|
+
for (const name of STORAGE_NAMES) {
|
|
326
|
+
const { health, storage } = probe(scope, name);
|
|
327
|
+
if (health !== 'broken')
|
|
328
|
+
continue;
|
|
329
|
+
// Inherit anything the old store still holds, so a storage that reads
|
|
330
|
+
// fine but refuses writes (full / disabled) does not lose its data behind
|
|
331
|
+
// the shim. Skipped at an opaque origin, where the read itself threw and
|
|
332
|
+
// there is nothing to inherit.
|
|
333
|
+
const fallback = createMemoryStorage();
|
|
334
|
+
if (storage)
|
|
335
|
+
seedFrom(storage, fallback);
|
|
336
|
+
try {
|
|
337
|
+
Object.defineProperty(scope, name, {
|
|
338
|
+
value: fallback,
|
|
339
|
+
// `configurable: true` on purpose — a consumer (or a second call) can
|
|
340
|
+
// still swap in its own store via `Object.defineProperty`.
|
|
341
|
+
configurable: true,
|
|
342
|
+
// `writable: false` matches the browser: `window.localStorage = x` is
|
|
343
|
+
// a no-op in sloppy mode and a TypeError in strict mode on the real
|
|
344
|
+
// thing, sandboxed or not. A writable shim would quietly accept that
|
|
345
|
+
// assignment and hand blocks a *different* behaviour from production.
|
|
346
|
+
writable: false,
|
|
347
|
+
enumerable: false,
|
|
348
|
+
});
|
|
349
|
+
result[name] = true;
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
// Non-configurable in this engine — nothing more we can do. Swallowing
|
|
353
|
+
// keeps a best-effort shim from becoming its own boot failure.
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return result;
|
|
357
|
+
}
|
|
358
|
+
// Install on import.
|
|
359
|
+
//
|
|
360
|
+
// Import order is the whole game: ES module imports are hoisted, so a
|
|
361
|
+
// *statement* can never run before a sibling `import` of a dependency that
|
|
362
|
+
// reads storage while evaluating. Only an import side effect can, which is why
|
|
363
|
+
// this module installs on import rather than exposing a call to make — a block
|
|
364
|
+
// writes `import '@civitai/sdk/safe-storage';` as the FIRST import in its
|
|
365
|
+
// entry module and is then ahead of every dependency it imports afterwards.
|
|
366
|
+
//
|
|
367
|
+
// 🔴 That ordering is the app's job and nothing here can enforce it: a
|
|
368
|
+
// storage-touching dependency imported ABOVE this specifier still evaluates
|
|
369
|
+
// first. Keep it first.
|
|
370
|
+
//
|
|
371
|
+
// The blast radius is bounded by the rules above: it only ever replaces a
|
|
372
|
+
// global that is already *provably* unusable, so in every healthy runtime this
|
|
373
|
+
// is a couple of `setItem`/`removeItem` calls and nothing else.
|
|
374
|
+
installSafeStorage();
|
package/package.json
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@civitai/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "The Civitai SDK: an app holds a token and calls the Civitai API and the orchestrator; a block asks its host only to show host UI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./dist/index.js",
|
|
8
8
|
"types": "./dist/index.d.ts",
|
|
9
|
-
"sideEffects": false,
|
|
9
|
+
"comment-sideEffects": "An ALLOWLIST, not `false`. `./dist/safe-storage/index.js` installs the opaque-origin storage repair when imported, and a bare `false` licenses every bundler to drop it. `./dist/index.js` is deliberately NOT listed: the package root is pure re-exports and must stay droppable. Pinned by test/safe-storage.test.ts.",
|
|
10
|
+
"sideEffects": [
|
|
11
|
+
"./dist/safe-storage/index.js"
|
|
12
|
+
],
|
|
10
13
|
"exports": {
|
|
11
14
|
".": {
|
|
12
15
|
"types": "./dist/index.d.ts",
|
|
13
16
|
"import": "./dist/index.js"
|
|
14
17
|
},
|
|
18
|
+
"./safe-storage": {
|
|
19
|
+
"types": "./dist/safe-storage/index.d.ts",
|
|
20
|
+
"import": "./dist/safe-storage/index.js"
|
|
21
|
+
},
|
|
15
22
|
"./testing": {
|
|
16
23
|
"types": "./dist/testing.d.ts",
|
|
17
24
|
"import": "./dist/testing.js"
|