@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/src/browser.ts CHANGED
@@ -7,6 +7,8 @@
7
7
  * client barrel.
8
8
  */
9
9
 
10
+ import { effect, type Signal, signal } from "./reactive.js";
11
+
10
12
  /** Navigate to `url` with a full page load. No-op during SSR. */
11
13
  export function redirect(url: string): void {
12
14
  if (typeof window !== "undefined") {
@@ -31,38 +33,445 @@ export function reload(): void {
31
33
  }
32
34
  }
33
35
 
36
+ /** Which Web Storage area backs a {@link WebStorage}. */
37
+ export type StorageArea = "local" | "session";
38
+
39
+ export interface WebStorageOptions {
40
+ /**
41
+ * Key namespace, e.g. `"myapp:"`. Keys are written and read prefixed so
42
+ * independent stores never collide. Default `""` (no namespace).
43
+ */
44
+ prefix?: string;
45
+ /**
46
+ * Backing area — `"local"` (`localStorage`, persists across sessions, the
47
+ * default) or `"session"` (`sessionStorage`, cleared when the tab closes).
48
+ */
49
+ area?: StorageArea;
50
+ }
51
+
34
52
  /**
35
- * Typed, SSR-safe `localStorage` wrapper. Values are JSON-serialised; reads
36
- * return `null` on the server, on a missing key, or on malformed JSON. Writes
37
- * swallow quota / private-mode errors so a full storage never crashes the app.
53
+ * Typed, SSR-safe key/value store over `localStorage` / `sessionStorage`.
54
+ *
55
+ * - Values are JSON-serialised; reads return `null` on the server, on a missing
56
+ * key, or on malformed JSON.
57
+ * - Writes swallow quota / private-mode errors so a full store never crashes
58
+ * the app (best-effort).
59
+ * - `prefix` namespaces keys; {@link keys} and {@link clear} stay scoped to it,
60
+ * so two prefixed stores over the same area never touch each other's data.
38
61
  */
39
- export const storage = {
62
+ export class WebStorage {
63
+ readonly #prefix: string;
64
+ readonly #area: StorageArea;
65
+
66
+ constructor(options: WebStorageOptions = {}) {
67
+ this.#prefix = options.prefix ?? "";
68
+ this.#area = options.area ?? "local";
69
+ }
70
+
71
+ #backend(): Storage | undefined {
72
+ if (typeof window === "undefined") return undefined;
73
+ return this.#area === "session"
74
+ ? window.sessionStorage
75
+ : window.localStorage;
76
+ }
77
+
78
+ /** The on-disk key for `key`, namespaced by the configured prefix. */
79
+ fullKey(key: string): string {
80
+ return this.#prefix + key;
81
+ }
82
+
40
83
  get<T>(key: string): T | null {
41
- if (typeof localStorage === "undefined") return null;
42
- const raw = localStorage.getItem(key);
84
+ const backend = this.#backend();
85
+ if (!backend) return null;
86
+ const raw = backend.getItem(this.fullKey(key));
43
87
  if (raw === null) return null;
44
88
  try {
45
- return JSON.parse(raw);
89
+ return JSON.parse(raw) as T;
46
90
  } catch {
47
91
  return null;
48
92
  }
49
- },
93
+ }
94
+
50
95
  set(key: string, value: unknown): void {
51
- if (typeof localStorage === "undefined") return;
96
+ const backend = this.#backend();
97
+ if (!backend) return;
52
98
  try {
53
- localStorage.setItem(key, JSON.stringify(value));
99
+ backend.setItem(this.fullKey(key), JSON.stringify(value));
54
100
  } catch {
55
101
  // QuotaExceededError / Safari private mode — best-effort write.
56
102
  }
57
- },
103
+ }
104
+
105
+ /** Whether `key` is present (and not the server). */
106
+ has(key: string): boolean {
107
+ const backend = this.#backend();
108
+ if (!backend) return false;
109
+ return backend.getItem(this.fullKey(key)) !== null;
110
+ }
111
+
58
112
  remove(key: string): void {
59
- if (typeof localStorage !== "undefined") {
60
- localStorage.removeItem(key);
113
+ this.#backend()?.removeItem(this.fullKey(key));
114
+ }
115
+
116
+ /** Read `key`, or compute + persist `factory()` on a miss, returning the value. */
117
+ getOrSet<T>(key: string, factory: () => T): T {
118
+ const existing = this.get<T>(key);
119
+ if (existing !== null) return existing;
120
+ const value = factory();
121
+ this.set(key, value);
122
+ return value;
123
+ }
124
+
125
+ /** Keys in this store, prefix stripped. Empty array during SSR. */
126
+ keys(): string[] {
127
+ const backend = this.#backend();
128
+ if (!backend) return [];
129
+ const out: string[] = [];
130
+ for (let i = 0; i < backend.length; i++) {
131
+ const k = backend.key(i);
132
+ if (k === null) continue;
133
+ if (this.#prefix === "" || k.startsWith(this.#prefix)) {
134
+ out.push(k.slice(this.#prefix.length));
135
+ }
61
136
  }
62
- },
137
+ return out;
138
+ }
139
+
140
+ /** Remove this store's keys. With no prefix this clears the whole area. */
63
141
  clear(): void {
64
- if (typeof localStorage !== "undefined") {
65
- localStorage.clear();
142
+ const backend = this.#backend();
143
+ if (!backend) return;
144
+ if (this.#prefix === "") {
145
+ backend.clear();
146
+ return;
147
+ }
148
+ for (const key of this.keys()) backend.removeItem(this.fullKey(key));
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Default `localStorage` store (no namespace). Backward-compatible with the
154
+ * previous `storage.get/set/remove/clear` helper, plus `has`/`keys`/`getOrSet`.
155
+ */
156
+ export const storage = new WebStorage();
157
+
158
+ /** Default `sessionStorage` store (no namespace) — cleared when the tab closes. */
159
+ export const session = new WebStorage({ area: "session" });
160
+
161
+ export interface PersistedSignalOptions extends WebStorageOptions {
162
+ /**
163
+ * Update the signal when ANOTHER tab writes the same key (the browser
164
+ * `storage` event). Default `true`. Only effective for `area: "local"` —
165
+ * the browser never emits storage events for `sessionStorage` across tabs.
166
+ */
167
+ crossTab?: boolean;
168
+ }
169
+
170
+ /**
171
+ * A {@link Signal} whose value is mirrored to web storage.
172
+ *
173
+ * The initial value is read from storage (falling back to `initial` on a miss)
174
+ * and every write is persisted via a reactive `effect`. With `crossTab`
175
+ * (default `true`, local area only) the signal also updates live when another
176
+ * tab writes the same key. SSR-safe: with no `window` it is a plain in-memory
177
+ * signal seeded with `initial`. Because the mirror runs inside `effect`, a
178
+ * `persistedSignal` created in a component's setup is disposed with it.
179
+ */
180
+ export function persistedSignal<T>(
181
+ key: string,
182
+ initial: T,
183
+ options: PersistedSignalOptions = {},
184
+ ): Signal<T> {
185
+ const store = new WebStorage(options);
186
+ const stored = store.get<T>(key);
187
+ const sig = signal<T>(stored !== null ? stored : initial);
188
+
189
+ // Mirror every change back to storage; runs once immediately, then on change.
190
+ effect(() => {
191
+ store.set(key, sig());
192
+ });
193
+
194
+ if (
195
+ (options.crossTab ?? true) &&
196
+ (options.area ?? "local") === "local" &&
197
+ typeof window !== "undefined"
198
+ ) {
199
+ const fullKey = store.fullKey(key);
200
+ window.addEventListener("storage", (event) => {
201
+ if (event.key !== fullKey || event.newValue === null) return;
202
+ try {
203
+ sig(JSON.parse(event.newValue) as T);
204
+ } catch {
205
+ // Ignore a malformed cross-tab write.
206
+ }
207
+ });
208
+ }
209
+
210
+ return sig;
211
+ }
212
+
213
+ // ─── Reactive browser-state signals ──────────────────────────────────
214
+ //
215
+ // Each returns a Signal seeded from the current browser state and refreshed
216
+ // when the relevant event fires. The listener lives for the page lifetime, so
217
+ // create these ONCE at module scope and share the signal rather than calling
218
+ // them per component render. SSR-safe: with no `window`/`document`/`navigator`
219
+ // they return a plain signal seeded with a sensible default and never listen.
220
+
221
+ /**
222
+ * Internal: a signal seeded by `read()` and refreshed when any of `events`
223
+ * fire on `target`. `target` is `undefined` during SSR — then it's a static
224
+ * signal of the default `read()`.
225
+ */
226
+ function eventSignal<T>(
227
+ target: EventTarget | undefined,
228
+ events: string[],
229
+ read: () => T,
230
+ ): Signal<T> {
231
+ const sig = signal<T>(read());
232
+ if (target) {
233
+ const update = (): void => {
234
+ sig(read());
235
+ };
236
+ for (const ev of events) target.addEventListener(ev, update);
237
+ }
238
+ return sig;
239
+ }
240
+
241
+ /** Reactive `window.matchMedia(query).matches`. `false` during SSR. */
242
+ export function mediaQuery(query: string): Signal<boolean> {
243
+ if (
244
+ typeof window === "undefined" ||
245
+ typeof window.matchMedia !== "function"
246
+ ) {
247
+ return signal(false);
248
+ }
249
+ const mql = window.matchMedia(query);
250
+ const sig = signal(mql.matches);
251
+ mql.addEventListener("change", (event) => {
252
+ sig(event.matches);
253
+ });
254
+ return sig;
255
+ }
256
+
257
+ /** Reactive `prefers-color-scheme: dark`. Shortcut over {@link mediaQuery}. */
258
+ export function prefersDark(): Signal<boolean> {
259
+ return mediaQuery("(prefers-color-scheme: dark)");
260
+ }
261
+
262
+ /** Reactive online/offline status (`navigator.onLine`). `true` during SSR. */
263
+ export function online(): Signal<boolean> {
264
+ const read = (): boolean =>
265
+ typeof navigator === "undefined" ? true : navigator.onLine;
266
+ return eventSignal(
267
+ typeof window === "undefined" ? undefined : window,
268
+ ["online", "offline"],
269
+ read,
270
+ );
271
+ }
272
+
273
+ export interface WindowSize {
274
+ width: number;
275
+ height: number;
276
+ }
277
+
278
+ /** Reactive `{ width, height }` of the viewport. `{0,0}` during SSR. */
279
+ export function windowSize(): Signal<WindowSize> {
280
+ const read = (): WindowSize =>
281
+ typeof window === "undefined"
282
+ ? { width: 0, height: 0 }
283
+ : { width: window.innerWidth, height: window.innerHeight };
284
+ return eventSignal(
285
+ typeof window === "undefined" ? undefined : window,
286
+ ["resize"],
287
+ read,
288
+ );
289
+ }
290
+
291
+ /** Reactive tab visibility (`!document.hidden`). `true` during SSR. */
292
+ export function visibility(): Signal<boolean> {
293
+ const read = (): boolean =>
294
+ typeof document === "undefined" ? true : !document.hidden;
295
+ return eventSignal(
296
+ typeof document === "undefined" ? undefined : document,
297
+ ["visibilitychange"],
298
+ read,
299
+ );
300
+ }
301
+
302
+ /** Reactive `window.location.hash`. `""` during SSR. */
303
+ export function hash(): Signal<string> {
304
+ const read = (): string =>
305
+ typeof window === "undefined" ? "" : window.location.hash;
306
+ return eventSignal(
307
+ typeof window === "undefined" ? undefined : window,
308
+ ["hashchange"],
309
+ read,
310
+ );
311
+ }
312
+
313
+ // ─── URL / history (SPA navigation) ──────────────────────────────────
314
+
315
+ /** Go back one history entry. No-op during SSR. */
316
+ export function back(): void {
317
+ if (typeof window !== "undefined") window.history.back();
318
+ }
319
+
320
+ /** Go forward one history entry. No-op during SSR. */
321
+ export function forward(): void {
322
+ if (typeof window !== "undefined") window.history.forward();
323
+ }
324
+
325
+ /**
326
+ * SPA navigation: push `url` onto history WITHOUT a full page reload (contrast
327
+ * {@link redirect}, which reloads). Emits a `popstate` event so reactive URL
328
+ * consumers — e.g. {@link queryParam} or a router — pick up the change. No-op
329
+ * during SSR.
330
+ */
331
+ export function navigate(url: string): void {
332
+ if (typeof window === "undefined") return;
333
+ window.history.pushState({}, "", url);
334
+ window.dispatchEvent(new Event("popstate"));
335
+ }
336
+
337
+ /**
338
+ * A {@link Signal} bound to a single URL query parameter. Reading reflects the
339
+ * current value (`null` when absent); writing updates the URL via `pushState`
340
+ * (no reload). Stays in sync with back/forward and {@link navigate} through the
341
+ * `popstate` event. SSR-safe: a plain `null` signal with no listeners.
342
+ */
343
+ export function queryParam(key: string): Signal<string | null> {
344
+ const read = (): string | null =>
345
+ typeof window === "undefined"
346
+ ? null
347
+ : new URLSearchParams(window.location.search).get(key);
348
+ const sig = signal<string | null>(read());
349
+ if (typeof window !== "undefined") {
350
+ window.addEventListener("popstate", () => {
351
+ sig(read());
352
+ });
353
+ // Mirror writes back to the URL (skips the no-op initial run).
354
+ effect(() => {
355
+ const value = sig();
356
+ const url = new URL(window.location.href);
357
+ if (value === null) url.searchParams.delete(key);
358
+ else url.searchParams.set(key, value);
359
+ const next = url.pathname + url.search + url.hash;
360
+ const current =
361
+ window.location.pathname +
362
+ window.location.search +
363
+ window.location.hash;
364
+ if (next !== current) window.history.pushState({}, "", next);
365
+ });
366
+ }
367
+ return sig;
368
+ }
369
+
370
+ // ─── Cookies ─────────────────────────────────────────────────────────
371
+
372
+ export interface CookieOptions {
373
+ /** Path scope. Default `"/"`. */
374
+ path?: string;
375
+ /** Lifetime in seconds. */
376
+ maxAge?: number;
377
+ /** Absolute expiry. */
378
+ expires?: Date;
379
+ /** Domain scope. */
380
+ domain?: string;
381
+ /** SameSite policy. */
382
+ sameSite?: "strict" | "lax" | "none";
383
+ /** Restrict to HTTPS. */
384
+ secure?: boolean;
385
+ }
386
+
387
+ /**
388
+ * SSR-safe cookie accessor — the one store {@link WebStorage} can't cover, for
389
+ * values the server also reads. Reads return `null` during SSR; writes are a
390
+ * no-op. Names and values are URL-encoded.
391
+ */
392
+ export const cookie = {
393
+ get(name: string): string | null {
394
+ if (typeof document === "undefined") return null;
395
+ const prefix = `${encodeURIComponent(name)}=`;
396
+ for (const part of document.cookie.split("; ")) {
397
+ if (part.startsWith(prefix)) {
398
+ return decodeURIComponent(part.slice(prefix.length));
399
+ }
400
+ }
401
+ return null;
402
+ },
403
+ set(name: string, value: string, options: CookieOptions = {}): void {
404
+ if (typeof document === "undefined") return;
405
+ const parts = [
406
+ `${encodeURIComponent(name)}=${encodeURIComponent(value)}`,
407
+ `path=${options.path ?? "/"}`,
408
+ ];
409
+ if (options.maxAge !== undefined) parts.push(`max-age=${options.maxAge}`);
410
+ if (options.expires) parts.push(`expires=${options.expires.toUTCString()}`);
411
+ if (options.domain) parts.push(`domain=${options.domain}`);
412
+ if (options.sameSite) parts.push(`samesite=${options.sameSite}`);
413
+ if (options.secure) parts.push("secure");
414
+ // 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.
415
+ document.cookie = parts.join("; ");
416
+ },
417
+ remove(
418
+ name: string,
419
+ options: Pick<CookieOptions, "path" | "domain"> = {},
420
+ ): void {
421
+ this.set(name, "", { ...options, maxAge: 0, expires: new Date(0) });
422
+ },
423
+ };
424
+
425
+ // ─── Clipboard & Web Share ───────────────────────────────────────────
426
+
427
+ /** Async clipboard access. Methods return `false`/`null` when unavailable. */
428
+ export const clipboard = {
429
+ /** Copy `text`. Returns whether it succeeded. */
430
+ async copy(text: string): Promise<boolean> {
431
+ if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
432
+ return false;
433
+ }
434
+ try {
435
+ await navigator.clipboard.writeText(text);
436
+ return true;
437
+ } catch {
438
+ return false;
439
+ }
440
+ },
441
+ /** Read clipboard text, or `null` if unavailable / denied. */
442
+ async read(): Promise<string | null> {
443
+ if (typeof navigator === "undefined" || !navigator.clipboard?.readText) {
444
+ return null;
445
+ }
446
+ try {
447
+ return await navigator.clipboard.readText();
448
+ } catch {
449
+ return null;
66
450
  }
67
451
  },
68
452
  };
453
+
454
+ export interface ShareData {
455
+ title?: string;
456
+ text?: string;
457
+ url?: string;
458
+ }
459
+
460
+ /**
461
+ * Invoke the native Web Share sheet. Returns `false` when unsupported or the
462
+ * user cancels — never throws.
463
+ */
464
+ export async function share(data: ShareData): Promise<boolean> {
465
+ if (
466
+ typeof navigator === "undefined" ||
467
+ typeof navigator.share !== "function"
468
+ ) {
469
+ return false;
470
+ }
471
+ try {
472
+ await navigator.share(data);
473
+ return true;
474
+ } catch {
475
+ return false;
476
+ }
477
+ }