@c9up/aurora 0.1.6 → 0.1.7
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 +1 -1
- package/dist/browser.d.ts +136 -4
- package/dist/browser.js +324 -15
- package/dist/http.d.ts +79 -0
- package/dist/http.js +199 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +2 -7
- package/package.json +1 -1
- package/src/browser.ts +425 -16
- package/src/http.ts +278 -0
- package/src/index.ts +32 -1
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ providers: [
|
|
|
24
24
|
|
|
25
25
|
## Entry points
|
|
26
26
|
|
|
27
|
-
- `@c9up/aurora` — main API
|
|
27
|
+
- `@c9up/aurora` — main API: reactive primitives (`signal`/`effect`/`html`/`component`/`hydrate`) plus the client toolkit — `WebStorage`/`persistedSignal`, reactive browser signals (`prefersDark`/`online`/`windowSize`/…), SPA navigation (`navigate`/`queryParam`), `cookie`/`clipboard`/`share`, and the `HttpClient` fetch wrapper
|
|
28
28
|
- `@c9up/aurora/provider` — Ream IoC provider
|
|
29
29
|
- `@c9up/aurora/services/main` — container service accessor
|
|
30
30
|
- `@c9up/aurora/relay` — realtime adapter
|
package/dist/browser.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* without `typeof window` guards at every call site. Node-free — part of the
|
|
7
7
|
* client barrel.
|
|
8
8
|
*/
|
|
9
|
+
import { type Signal } from "./reactive.js";
|
|
9
10
|
/** Navigate to `url` with a full page load. No-op during SSR. */
|
|
10
11
|
export declare function redirect(url: string): void;
|
|
11
12
|
/**
|
|
@@ -15,14 +16,145 @@ export declare function redirect(url: string): void;
|
|
|
15
16
|
export declare function replace(url: string): void;
|
|
16
17
|
/** Reload the current page. No-op during SSR. */
|
|
17
18
|
export declare function reload(): void;
|
|
19
|
+
/** Which Web Storage area backs a {@link WebStorage}. */
|
|
20
|
+
export type StorageArea = "local" | "session";
|
|
21
|
+
export interface WebStorageOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Key namespace, e.g. `"myapp:"`. Keys are written and read prefixed so
|
|
24
|
+
* independent stores never collide. Default `""` (no namespace).
|
|
25
|
+
*/
|
|
26
|
+
prefix?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Backing area — `"local"` (`localStorage`, persists across sessions, the
|
|
29
|
+
* default) or `"session"` (`sessionStorage`, cleared when the tab closes).
|
|
30
|
+
*/
|
|
31
|
+
area?: StorageArea;
|
|
32
|
+
}
|
|
18
33
|
/**
|
|
19
|
-
* Typed, SSR-safe `localStorage`
|
|
20
|
-
*
|
|
21
|
-
*
|
|
34
|
+
* Typed, SSR-safe key/value store over `localStorage` / `sessionStorage`.
|
|
35
|
+
*
|
|
36
|
+
* - Values are JSON-serialised; reads return `null` on the server, on a missing
|
|
37
|
+
* key, or on malformed JSON.
|
|
38
|
+
* - Writes swallow quota / private-mode errors so a full store never crashes
|
|
39
|
+
* the app (best-effort).
|
|
40
|
+
* - `prefix` namespaces keys; {@link keys} and {@link clear} stay scoped to it,
|
|
41
|
+
* so two prefixed stores over the same area never touch each other's data.
|
|
22
42
|
*/
|
|
23
|
-
export declare
|
|
43
|
+
export declare class WebStorage {
|
|
44
|
+
#private;
|
|
45
|
+
constructor(options?: WebStorageOptions);
|
|
46
|
+
/** The on-disk key for `key`, namespaced by the configured prefix. */
|
|
47
|
+
fullKey(key: string): string;
|
|
24
48
|
get<T>(key: string): T | null;
|
|
25
49
|
set(key: string, value: unknown): void;
|
|
50
|
+
/** Whether `key` is present (and not the server). */
|
|
51
|
+
has(key: string): boolean;
|
|
26
52
|
remove(key: string): void;
|
|
53
|
+
/** Read `key`, or compute + persist `factory()` on a miss, returning the value. */
|
|
54
|
+
getOrSet<T>(key: string, factory: () => T): T;
|
|
55
|
+
/** Keys in this store, prefix stripped. Empty array during SSR. */
|
|
56
|
+
keys(): string[];
|
|
57
|
+
/** Remove this store's keys. With no prefix this clears the whole area. */
|
|
27
58
|
clear(): void;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Default `localStorage` store (no namespace). Backward-compatible with the
|
|
62
|
+
* previous `storage.get/set/remove/clear` helper, plus `has`/`keys`/`getOrSet`.
|
|
63
|
+
*/
|
|
64
|
+
export declare const storage: WebStorage;
|
|
65
|
+
/** Default `sessionStorage` store (no namespace) — cleared when the tab closes. */
|
|
66
|
+
export declare const session: WebStorage;
|
|
67
|
+
export interface PersistedSignalOptions extends WebStorageOptions {
|
|
68
|
+
/**
|
|
69
|
+
* Update the signal when ANOTHER tab writes the same key (the browser
|
|
70
|
+
* `storage` event). Default `true`. Only effective for `area: "local"` —
|
|
71
|
+
* the browser never emits storage events for `sessionStorage` across tabs.
|
|
72
|
+
*/
|
|
73
|
+
crossTab?: boolean;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A {@link Signal} whose value is mirrored to web storage.
|
|
77
|
+
*
|
|
78
|
+
* The initial value is read from storage (falling back to `initial` on a miss)
|
|
79
|
+
* and every write is persisted via a reactive `effect`. With `crossTab`
|
|
80
|
+
* (default `true`, local area only) the signal also updates live when another
|
|
81
|
+
* tab writes the same key. SSR-safe: with no `window` it is a plain in-memory
|
|
82
|
+
* signal seeded with `initial`. Because the mirror runs inside `effect`, a
|
|
83
|
+
* `persistedSignal` created in a component's setup is disposed with it.
|
|
84
|
+
*/
|
|
85
|
+
export declare function persistedSignal<T>(key: string, initial: T, options?: PersistedSignalOptions): Signal<T>;
|
|
86
|
+
/** Reactive `window.matchMedia(query).matches`. `false` during SSR. */
|
|
87
|
+
export declare function mediaQuery(query: string): Signal<boolean>;
|
|
88
|
+
/** Reactive `prefers-color-scheme: dark`. Shortcut over {@link mediaQuery}. */
|
|
89
|
+
export declare function prefersDark(): Signal<boolean>;
|
|
90
|
+
/** Reactive online/offline status (`navigator.onLine`). `true` during SSR. */
|
|
91
|
+
export declare function online(): Signal<boolean>;
|
|
92
|
+
export interface WindowSize {
|
|
93
|
+
width: number;
|
|
94
|
+
height: number;
|
|
95
|
+
}
|
|
96
|
+
/** Reactive `{ width, height }` of the viewport. `{0,0}` during SSR. */
|
|
97
|
+
export declare function windowSize(): Signal<WindowSize>;
|
|
98
|
+
/** Reactive tab visibility (`!document.hidden`). `true` during SSR. */
|
|
99
|
+
export declare function visibility(): Signal<boolean>;
|
|
100
|
+
/** Reactive `window.location.hash`. `""` during SSR. */
|
|
101
|
+
export declare function hash(): Signal<string>;
|
|
102
|
+
/** Go back one history entry. No-op during SSR. */
|
|
103
|
+
export declare function back(): void;
|
|
104
|
+
/** Go forward one history entry. No-op during SSR. */
|
|
105
|
+
export declare function forward(): void;
|
|
106
|
+
/**
|
|
107
|
+
* SPA navigation: push `url` onto history WITHOUT a full page reload (contrast
|
|
108
|
+
* {@link redirect}, which reloads). Emits a `popstate` event so reactive URL
|
|
109
|
+
* consumers — e.g. {@link queryParam} or a router — pick up the change. No-op
|
|
110
|
+
* during SSR.
|
|
111
|
+
*/
|
|
112
|
+
export declare function navigate(url: string): void;
|
|
113
|
+
/**
|
|
114
|
+
* A {@link Signal} bound to a single URL query parameter. Reading reflects the
|
|
115
|
+
* current value (`null` when absent); writing updates the URL via `pushState`
|
|
116
|
+
* (no reload). Stays in sync with back/forward and {@link navigate} through the
|
|
117
|
+
* `popstate` event. SSR-safe: a plain `null` signal with no listeners.
|
|
118
|
+
*/
|
|
119
|
+
export declare function queryParam(key: string): Signal<string | null>;
|
|
120
|
+
export interface CookieOptions {
|
|
121
|
+
/** Path scope. Default `"/"`. */
|
|
122
|
+
path?: string;
|
|
123
|
+
/** Lifetime in seconds. */
|
|
124
|
+
maxAge?: number;
|
|
125
|
+
/** Absolute expiry. */
|
|
126
|
+
expires?: Date;
|
|
127
|
+
/** Domain scope. */
|
|
128
|
+
domain?: string;
|
|
129
|
+
/** SameSite policy. */
|
|
130
|
+
sameSite?: "strict" | "lax" | "none";
|
|
131
|
+
/** Restrict to HTTPS. */
|
|
132
|
+
secure?: boolean;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* SSR-safe cookie accessor — the one store {@link WebStorage} can't cover, for
|
|
136
|
+
* values the server also reads. Reads return `null` during SSR; writes are a
|
|
137
|
+
* no-op. Names and values are URL-encoded.
|
|
138
|
+
*/
|
|
139
|
+
export declare const cookie: {
|
|
140
|
+
get(name: string): string | null;
|
|
141
|
+
set(name: string, value: string, options?: CookieOptions): void;
|
|
142
|
+
remove(name: string, options?: Pick<CookieOptions, "path" | "domain">): void;
|
|
28
143
|
};
|
|
144
|
+
/** Async clipboard access. Methods return `false`/`null` when unavailable. */
|
|
145
|
+
export declare const clipboard: {
|
|
146
|
+
/** Copy `text`. Returns whether it succeeded. */
|
|
147
|
+
copy(text: string): Promise<boolean>;
|
|
148
|
+
/** Read clipboard text, or `null` if unavailable / denied. */
|
|
149
|
+
read(): Promise<string | null>;
|
|
150
|
+
};
|
|
151
|
+
export interface ShareData {
|
|
152
|
+
title?: string;
|
|
153
|
+
text?: string;
|
|
154
|
+
url?: string;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Invoke the native Web Share sheet. Returns `false` when unsupported or the
|
|
158
|
+
* user cancels — never throws.
|
|
159
|
+
*/
|
|
160
|
+
export declare function share(data: ShareData): Promise<boolean>;
|
package/dist/browser.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* without `typeof window` guards at every call site. Node-free — part of the
|
|
7
7
|
* client barrel.
|
|
8
8
|
*/
|
|
9
|
+
import { effect, signal } from "./reactive.js";
|
|
9
10
|
/** Navigate to `url` with a full page load. No-op during SSR. */
|
|
10
11
|
export function redirect(url) {
|
|
11
12
|
if (typeof window !== "undefined") {
|
|
@@ -28,15 +29,38 @@ export function reload() {
|
|
|
28
29
|
}
|
|
29
30
|
}
|
|
30
31
|
/**
|
|
31
|
-
* Typed, SSR-safe `localStorage`
|
|
32
|
-
*
|
|
33
|
-
*
|
|
32
|
+
* Typed, SSR-safe key/value store over `localStorage` / `sessionStorage`.
|
|
33
|
+
*
|
|
34
|
+
* - Values are JSON-serialised; reads return `null` on the server, on a missing
|
|
35
|
+
* key, or on malformed JSON.
|
|
36
|
+
* - Writes swallow quota / private-mode errors so a full store never crashes
|
|
37
|
+
* the app (best-effort).
|
|
38
|
+
* - `prefix` namespaces keys; {@link keys} and {@link clear} stay scoped to it,
|
|
39
|
+
* so two prefixed stores over the same area never touch each other's data.
|
|
34
40
|
*/
|
|
35
|
-
export
|
|
41
|
+
export class WebStorage {
|
|
42
|
+
#prefix;
|
|
43
|
+
#area;
|
|
44
|
+
constructor(options = {}) {
|
|
45
|
+
this.#prefix = options.prefix ?? "";
|
|
46
|
+
this.#area = options.area ?? "local";
|
|
47
|
+
}
|
|
48
|
+
#backend() {
|
|
49
|
+
if (typeof window === "undefined")
|
|
50
|
+
return undefined;
|
|
51
|
+
return this.#area === "session"
|
|
52
|
+
? window.sessionStorage
|
|
53
|
+
: window.localStorage;
|
|
54
|
+
}
|
|
55
|
+
/** The on-disk key for `key`, namespaced by the configured prefix. */
|
|
56
|
+
fullKey(key) {
|
|
57
|
+
return this.#prefix + key;
|
|
58
|
+
}
|
|
36
59
|
get(key) {
|
|
37
|
-
|
|
60
|
+
const backend = this.#backend();
|
|
61
|
+
if (!backend)
|
|
38
62
|
return null;
|
|
39
|
-
const raw =
|
|
63
|
+
const raw = backend.getItem(this.fullKey(key));
|
|
40
64
|
if (raw === null)
|
|
41
65
|
return null;
|
|
42
66
|
try {
|
|
@@ -45,25 +69,310 @@ export const storage = {
|
|
|
45
69
|
catch {
|
|
46
70
|
return null;
|
|
47
71
|
}
|
|
48
|
-
}
|
|
72
|
+
}
|
|
49
73
|
set(key, value) {
|
|
50
|
-
|
|
74
|
+
const backend = this.#backend();
|
|
75
|
+
if (!backend)
|
|
51
76
|
return;
|
|
52
77
|
try {
|
|
53
|
-
|
|
78
|
+
backend.setItem(this.fullKey(key), JSON.stringify(value));
|
|
54
79
|
}
|
|
55
80
|
catch {
|
|
56
81
|
// QuotaExceededError / Safari private mode — best-effort write.
|
|
57
82
|
}
|
|
58
|
-
}
|
|
83
|
+
}
|
|
84
|
+
/** Whether `key` is present (and not the server). */
|
|
85
|
+
has(key) {
|
|
86
|
+
const backend = this.#backend();
|
|
87
|
+
if (!backend)
|
|
88
|
+
return false;
|
|
89
|
+
return backend.getItem(this.fullKey(key)) !== null;
|
|
90
|
+
}
|
|
59
91
|
remove(key) {
|
|
60
|
-
|
|
61
|
-
|
|
92
|
+
this.#backend()?.removeItem(this.fullKey(key));
|
|
93
|
+
}
|
|
94
|
+
/** Read `key`, or compute + persist `factory()` on a miss, returning the value. */
|
|
95
|
+
getOrSet(key, factory) {
|
|
96
|
+
const existing = this.get(key);
|
|
97
|
+
if (existing !== null)
|
|
98
|
+
return existing;
|
|
99
|
+
const value = factory();
|
|
100
|
+
this.set(key, value);
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
/** Keys in this store, prefix stripped. Empty array during SSR. */
|
|
104
|
+
keys() {
|
|
105
|
+
const backend = this.#backend();
|
|
106
|
+
if (!backend)
|
|
107
|
+
return [];
|
|
108
|
+
const out = [];
|
|
109
|
+
for (let i = 0; i < backend.length; i++) {
|
|
110
|
+
const k = backend.key(i);
|
|
111
|
+
if (k === null)
|
|
112
|
+
continue;
|
|
113
|
+
if (this.#prefix === "" || k.startsWith(this.#prefix)) {
|
|
114
|
+
out.push(k.slice(this.#prefix.length));
|
|
115
|
+
}
|
|
62
116
|
}
|
|
63
|
-
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
/** Remove this store's keys. With no prefix this clears the whole area. */
|
|
64
120
|
clear() {
|
|
65
|
-
|
|
66
|
-
|
|
121
|
+
const backend = this.#backend();
|
|
122
|
+
if (!backend)
|
|
123
|
+
return;
|
|
124
|
+
if (this.#prefix === "") {
|
|
125
|
+
backend.clear();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
for (const key of this.keys())
|
|
129
|
+
backend.removeItem(this.fullKey(key));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Default `localStorage` store (no namespace). Backward-compatible with the
|
|
134
|
+
* previous `storage.get/set/remove/clear` helper, plus `has`/`keys`/`getOrSet`.
|
|
135
|
+
*/
|
|
136
|
+
export const storage = new WebStorage();
|
|
137
|
+
/** Default `sessionStorage` store (no namespace) — cleared when the tab closes. */
|
|
138
|
+
export const session = new WebStorage({ area: "session" });
|
|
139
|
+
/**
|
|
140
|
+
* A {@link Signal} whose value is mirrored to web storage.
|
|
141
|
+
*
|
|
142
|
+
* The initial value is read from storage (falling back to `initial` on a miss)
|
|
143
|
+
* and every write is persisted via a reactive `effect`. With `crossTab`
|
|
144
|
+
* (default `true`, local area only) the signal also updates live when another
|
|
145
|
+
* tab writes the same key. SSR-safe: with no `window` it is a plain in-memory
|
|
146
|
+
* signal seeded with `initial`. Because the mirror runs inside `effect`, a
|
|
147
|
+
* `persistedSignal` created in a component's setup is disposed with it.
|
|
148
|
+
*/
|
|
149
|
+
export function persistedSignal(key, initial, options = {}) {
|
|
150
|
+
const store = new WebStorage(options);
|
|
151
|
+
const stored = store.get(key);
|
|
152
|
+
const sig = signal(stored !== null ? stored : initial);
|
|
153
|
+
// Mirror every change back to storage; runs once immediately, then on change.
|
|
154
|
+
effect(() => {
|
|
155
|
+
store.set(key, sig());
|
|
156
|
+
});
|
|
157
|
+
if ((options.crossTab ?? true) &&
|
|
158
|
+
(options.area ?? "local") === "local" &&
|
|
159
|
+
typeof window !== "undefined") {
|
|
160
|
+
const fullKey = store.fullKey(key);
|
|
161
|
+
window.addEventListener("storage", (event) => {
|
|
162
|
+
if (event.key !== fullKey || event.newValue === null)
|
|
163
|
+
return;
|
|
164
|
+
try {
|
|
165
|
+
sig(JSON.parse(event.newValue));
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// Ignore a malformed cross-tab write.
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
return sig;
|
|
173
|
+
}
|
|
174
|
+
// ─── Reactive browser-state signals ──────────────────────────────────
|
|
175
|
+
//
|
|
176
|
+
// Each returns a Signal seeded from the current browser state and refreshed
|
|
177
|
+
// when the relevant event fires. The listener lives for the page lifetime, so
|
|
178
|
+
// create these ONCE at module scope and share the signal rather than calling
|
|
179
|
+
// them per component render. SSR-safe: with no `window`/`document`/`navigator`
|
|
180
|
+
// they return a plain signal seeded with a sensible default and never listen.
|
|
181
|
+
/**
|
|
182
|
+
* Internal: a signal seeded by `read()` and refreshed when any of `events`
|
|
183
|
+
* fire on `target`. `target` is `undefined` during SSR — then it's a static
|
|
184
|
+
* signal of the default `read()`.
|
|
185
|
+
*/
|
|
186
|
+
function eventSignal(target, events, read) {
|
|
187
|
+
const sig = signal(read());
|
|
188
|
+
if (target) {
|
|
189
|
+
const update = () => {
|
|
190
|
+
sig(read());
|
|
191
|
+
};
|
|
192
|
+
for (const ev of events)
|
|
193
|
+
target.addEventListener(ev, update);
|
|
194
|
+
}
|
|
195
|
+
return sig;
|
|
196
|
+
}
|
|
197
|
+
/** Reactive `window.matchMedia(query).matches`. `false` during SSR. */
|
|
198
|
+
export function mediaQuery(query) {
|
|
199
|
+
if (typeof window === "undefined" ||
|
|
200
|
+
typeof window.matchMedia !== "function") {
|
|
201
|
+
return signal(false);
|
|
202
|
+
}
|
|
203
|
+
const mql = window.matchMedia(query);
|
|
204
|
+
const sig = signal(mql.matches);
|
|
205
|
+
mql.addEventListener("change", (event) => {
|
|
206
|
+
sig(event.matches);
|
|
207
|
+
});
|
|
208
|
+
return sig;
|
|
209
|
+
}
|
|
210
|
+
/** Reactive `prefers-color-scheme: dark`. Shortcut over {@link mediaQuery}. */
|
|
211
|
+
export function prefersDark() {
|
|
212
|
+
return mediaQuery("(prefers-color-scheme: dark)");
|
|
213
|
+
}
|
|
214
|
+
/** Reactive online/offline status (`navigator.onLine`). `true` during SSR. */
|
|
215
|
+
export function online() {
|
|
216
|
+
const read = () => typeof navigator === "undefined" ? true : navigator.onLine;
|
|
217
|
+
return eventSignal(typeof window === "undefined" ? undefined : window, ["online", "offline"], read);
|
|
218
|
+
}
|
|
219
|
+
/** Reactive `{ width, height }` of the viewport. `{0,0}` during SSR. */
|
|
220
|
+
export function windowSize() {
|
|
221
|
+
const read = () => typeof window === "undefined"
|
|
222
|
+
? { width: 0, height: 0 }
|
|
223
|
+
: { width: window.innerWidth, height: window.innerHeight };
|
|
224
|
+
return eventSignal(typeof window === "undefined" ? undefined : window, ["resize"], read);
|
|
225
|
+
}
|
|
226
|
+
/** Reactive tab visibility (`!document.hidden`). `true` during SSR. */
|
|
227
|
+
export function visibility() {
|
|
228
|
+
const read = () => typeof document === "undefined" ? true : !document.hidden;
|
|
229
|
+
return eventSignal(typeof document === "undefined" ? undefined : document, ["visibilitychange"], read);
|
|
230
|
+
}
|
|
231
|
+
/** Reactive `window.location.hash`. `""` during SSR. */
|
|
232
|
+
export function hash() {
|
|
233
|
+
const read = () => typeof window === "undefined" ? "" : window.location.hash;
|
|
234
|
+
return eventSignal(typeof window === "undefined" ? undefined : window, ["hashchange"], read);
|
|
235
|
+
}
|
|
236
|
+
// ─── URL / history (SPA navigation) ──────────────────────────────────
|
|
237
|
+
/** Go back one history entry. No-op during SSR. */
|
|
238
|
+
export function back() {
|
|
239
|
+
if (typeof window !== "undefined")
|
|
240
|
+
window.history.back();
|
|
241
|
+
}
|
|
242
|
+
/** Go forward one history entry. No-op during SSR. */
|
|
243
|
+
export function forward() {
|
|
244
|
+
if (typeof window !== "undefined")
|
|
245
|
+
window.history.forward();
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* SPA navigation: push `url` onto history WITHOUT a full page reload (contrast
|
|
249
|
+
* {@link redirect}, which reloads). Emits a `popstate` event so reactive URL
|
|
250
|
+
* consumers — e.g. {@link queryParam} or a router — pick up the change. No-op
|
|
251
|
+
* during SSR.
|
|
252
|
+
*/
|
|
253
|
+
export function navigate(url) {
|
|
254
|
+
if (typeof window === "undefined")
|
|
255
|
+
return;
|
|
256
|
+
window.history.pushState({}, "", url);
|
|
257
|
+
window.dispatchEvent(new Event("popstate"));
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* A {@link Signal} bound to a single URL query parameter. Reading reflects the
|
|
261
|
+
* current value (`null` when absent); writing updates the URL via `pushState`
|
|
262
|
+
* (no reload). Stays in sync with back/forward and {@link navigate} through the
|
|
263
|
+
* `popstate` event. SSR-safe: a plain `null` signal with no listeners.
|
|
264
|
+
*/
|
|
265
|
+
export function queryParam(key) {
|
|
266
|
+
const read = () => typeof window === "undefined"
|
|
267
|
+
? null
|
|
268
|
+
: new URLSearchParams(window.location.search).get(key);
|
|
269
|
+
const sig = signal(read());
|
|
270
|
+
if (typeof window !== "undefined") {
|
|
271
|
+
window.addEventListener("popstate", () => {
|
|
272
|
+
sig(read());
|
|
273
|
+
});
|
|
274
|
+
// Mirror writes back to the URL (skips the no-op initial run).
|
|
275
|
+
effect(() => {
|
|
276
|
+
const value = sig();
|
|
277
|
+
const url = new URL(window.location.href);
|
|
278
|
+
if (value === null)
|
|
279
|
+
url.searchParams.delete(key);
|
|
280
|
+
else
|
|
281
|
+
url.searchParams.set(key, value);
|
|
282
|
+
const next = url.pathname + url.search + url.hash;
|
|
283
|
+
const current = window.location.pathname +
|
|
284
|
+
window.location.search +
|
|
285
|
+
window.location.hash;
|
|
286
|
+
if (next !== current)
|
|
287
|
+
window.history.pushState({}, "", next);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
return sig;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* SSR-safe cookie accessor — the one store {@link WebStorage} can't cover, for
|
|
294
|
+
* values the server also reads. Reads return `null` during SSR; writes are a
|
|
295
|
+
* no-op. Names and values are URL-encoded.
|
|
296
|
+
*/
|
|
297
|
+
export const cookie = {
|
|
298
|
+
get(name) {
|
|
299
|
+
if (typeof document === "undefined")
|
|
300
|
+
return null;
|
|
301
|
+
const prefix = `${encodeURIComponent(name)}=`;
|
|
302
|
+
for (const part of document.cookie.split("; ")) {
|
|
303
|
+
if (part.startsWith(prefix)) {
|
|
304
|
+
return decodeURIComponent(part.slice(prefix.length));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return null;
|
|
308
|
+
},
|
|
309
|
+
set(name, value, options = {}) {
|
|
310
|
+
if (typeof document === "undefined")
|
|
311
|
+
return;
|
|
312
|
+
const parts = [
|
|
313
|
+
`${encodeURIComponent(name)}=${encodeURIComponent(value)}`,
|
|
314
|
+
`path=${options.path ?? "/"}`,
|
|
315
|
+
];
|
|
316
|
+
if (options.maxAge !== undefined)
|
|
317
|
+
parts.push(`max-age=${options.maxAge}`);
|
|
318
|
+
if (options.expires)
|
|
319
|
+
parts.push(`expires=${options.expires.toUTCString()}`);
|
|
320
|
+
if (options.domain)
|
|
321
|
+
parts.push(`domain=${options.domain}`);
|
|
322
|
+
if (options.sameSite)
|
|
323
|
+
parts.push(`samesite=${options.sameSite}`);
|
|
324
|
+
if (options.secure)
|
|
325
|
+
parts.push("secure");
|
|
326
|
+
// biome-ignore lint/suspicious/noDocumentCookie: the Cookie Store API is async-only and unsupported in Safari/Firefox; `document.cookie` is the sole sync, broadly-supported write path for an SSR-safe helper.
|
|
327
|
+
document.cookie = parts.join("; ");
|
|
328
|
+
},
|
|
329
|
+
remove(name, options = {}) {
|
|
330
|
+
this.set(name, "", { ...options, maxAge: 0, expires: new Date(0) });
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
// ─── Clipboard & Web Share ───────────────────────────────────────────
|
|
334
|
+
/** Async clipboard access. Methods return `false`/`null` when unavailable. */
|
|
335
|
+
export const clipboard = {
|
|
336
|
+
/** Copy `text`. Returns whether it succeeded. */
|
|
337
|
+
async copy(text) {
|
|
338
|
+
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
await navigator.clipboard.writeText(text);
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
/** Read clipboard text, or `null` if unavailable / denied. */
|
|
350
|
+
async read() {
|
|
351
|
+
if (typeof navigator === "undefined" || !navigator.clipboard?.readText) {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
return await navigator.clipboard.readText();
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
return null;
|
|
67
359
|
}
|
|
68
360
|
},
|
|
69
361
|
};
|
|
362
|
+
/**
|
|
363
|
+
* Invoke the native Web Share sheet. Returns `false` when unsupported or the
|
|
364
|
+
* user cancels — never throws.
|
|
365
|
+
*/
|
|
366
|
+
export async function share(data) {
|
|
367
|
+
if (typeof navigator === "undefined" ||
|
|
368
|
+
typeof navigator.share !== "function") {
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
await navigator.share(data);
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `HttpClient` — a small typed wrapper over `fetch` so call sites read
|
|
3
|
+
* `await http.get<User>("/auth/me")` instead of hand-rolling headers,
|
|
4
|
+
* `res.json()`, and status checks.
|
|
5
|
+
*
|
|
6
|
+
* - Auto JSON: a plain-object/array body is `JSON.stringify`-d with a
|
|
7
|
+
* `Content-Type: application/json` header; a JSON response is parsed.
|
|
8
|
+
* `FormData`/`Blob`/`URLSearchParams`/`string`/binary bodies pass through
|
|
9
|
+
* untouched.
|
|
10
|
+
* - Bearer auth: a `token` (string or getter, read fresh per request) is sent
|
|
11
|
+
* as `Authorization: Bearer …` unless the caller set the header themselves.
|
|
12
|
+
* - Errors: a non-2xx response rejects with an {@link HttpError} carrying the
|
|
13
|
+
* status, the `Response`, and the parsed body.
|
|
14
|
+
*
|
|
15
|
+
* Node-free and isomorphic — uses the global `fetch` (browsers, Node 18+,
|
|
16
|
+
* Workers, Bun, Deno). Part of the client barrel.
|
|
17
|
+
*/
|
|
18
|
+
export interface HttpClientOptions {
|
|
19
|
+
/** Prepended to every request URL, unless the URL is already absolute. */
|
|
20
|
+
baseURL?: string;
|
|
21
|
+
/** Headers merged into every request. */
|
|
22
|
+
headers?: Record<string, string>;
|
|
23
|
+
/**
|
|
24
|
+
* Bearer token sent as `Authorization: Bearer <token>`. A getter is read
|
|
25
|
+
* fresh on each request (so a rotated/late-set token is always current);
|
|
26
|
+
* a `null`/`undefined` result omits the header.
|
|
27
|
+
*/
|
|
28
|
+
token?: string | null | (() => string | null | undefined);
|
|
29
|
+
/** Default `credentials` mode (e.g. `"include"` to send cookies). */
|
|
30
|
+
credentials?: RequestCredentials;
|
|
31
|
+
}
|
|
32
|
+
export interface HttpRequestOptions<T = unknown> {
|
|
33
|
+
/** Query params appended to the URL. `null`/`undefined` values are skipped. */
|
|
34
|
+
query?: Record<string, string | number | boolean | null | undefined>;
|
|
35
|
+
/** Extra headers for this request (override the client defaults). */
|
|
36
|
+
headers?: Record<string, string>;
|
|
37
|
+
/** Per-request bearer token override (`null` to force-omit). */
|
|
38
|
+
token?: string | null;
|
|
39
|
+
/** Abort signal. */
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
/** `credentials` mode for this request. */
|
|
42
|
+
credentials?: RequestCredentials;
|
|
43
|
+
/**
|
|
44
|
+
* Runtime validator/mapper for the parsed body. When provided, the return
|
|
45
|
+
* type is whatever it returns — no unchecked cast. When omitted, the parsed
|
|
46
|
+
* body is returned as `T` (an UNCHECKED assertion of the response shape).
|
|
47
|
+
*/
|
|
48
|
+
parse?: (raw: unknown) => T;
|
|
49
|
+
}
|
|
50
|
+
/** Thrown on a non-2xx response. Carries the status, the `Response`, and the parsed body. */
|
|
51
|
+
export declare class HttpError extends Error {
|
|
52
|
+
readonly status: number;
|
|
53
|
+
readonly response: Response;
|
|
54
|
+
readonly data: unknown;
|
|
55
|
+
constructor(response: Response, data: unknown);
|
|
56
|
+
}
|
|
57
|
+
export declare class HttpClient {
|
|
58
|
+
#private;
|
|
59
|
+
constructor(options?: HttpClientOptions);
|
|
60
|
+
/** Set a default header for every subsequent request (case-insensitive replace). Chainable. */
|
|
61
|
+
setHeader(name: string, value: string): this;
|
|
62
|
+
/** Merge several default headers at once. Chainable. */
|
|
63
|
+
setHeaders(headers: Record<string, string>): this;
|
|
64
|
+
/** Remove a default header (case-insensitive). Chainable. */
|
|
65
|
+
removeHeader(name: string): this;
|
|
66
|
+
/** A copy of the current default headers. */
|
|
67
|
+
getHeaders(): Record<string, string>;
|
|
68
|
+
get<T>(url: string, options?: HttpRequestOptions<T>): Promise<T>;
|
|
69
|
+
delete<T>(url: string, options?: HttpRequestOptions<T>): Promise<T>;
|
|
70
|
+
post<T>(url: string, body?: unknown, options?: HttpRequestOptions<T>): Promise<T>;
|
|
71
|
+
put<T>(url: string, body?: unknown, options?: HttpRequestOptions<T>): Promise<T>;
|
|
72
|
+
patch<T>(url: string, body?: unknown, options?: HttpRequestOptions<T>): Promise<T>;
|
|
73
|
+
/** Send a request and return the raw `Response` (no parsing, no throw on non-2xx). */
|
|
74
|
+
raw(method: string, url: string, body?: unknown, options?: HttpRequestOptions): Promise<Response>;
|
|
75
|
+
/** Derive a new client with merged defaults (e.g. a scope that adds a token). */
|
|
76
|
+
extend(options: HttpClientOptions): HttpClient;
|
|
77
|
+
}
|
|
78
|
+
/** Default same-origin client. Configure your own via `new HttpClient({ … })`. */
|
|
79
|
+
export declare const http: HttpClient;
|