svelte-firekit 0.2.4 → 0.2.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.
- package/README.md +101 -12
- package/dist/components/NetworkStatus.svelte +37 -0
- package/dist/components/NetworkStatus.svelte.d.ts +12 -0
- package/dist/components/UploadTask.svelte +64 -21
- package/dist/components/UploadTask.svelte.d.ts +6 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/services/ai.svelte.js +13 -1
- package/dist/services/analytics.js +5 -1
- package/dist/services/auth.d.ts +1 -1
- package/dist/services/auth.js +29 -25
- package/dist/services/messaging.svelte.d.ts +1 -0
- package/dist/services/messaging.svelte.js +5 -0
- package/dist/services/mutations.js +1 -1
- package/dist/services/network.svelte.d.ts +71 -0
- package/dist/services/network.svelte.js +167 -0
- package/dist/services/presence.svelte.d.ts +1 -0
- package/dist/services/presence.svelte.js +12 -4
- package/dist/services/remote-config.svelte.js +3 -0
- package/dist/services/storage.svelte.js +1 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +2 -0
- package/dist/types/network.d.ts +10 -0
- package/dist/types/network.js +16 -0
- package/dist/types/storage.d.ts +34 -0
- package/dist/types/storage.js +11 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/validation.d.ts +20 -0
- package/dist/utils/validation.js +147 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,6 +7,8 @@ A complete Firebase integration library for **Svelte 5** and **SvelteKit**. Buil
|
|
|
7
7
|
- SSR-safe — all services degrade gracefully on the server
|
|
8
8
|
- Works without SvelteKit (`$app/*`, `$env/*` are never imported)
|
|
9
9
|
|
|
10
|
+
**Documentation:** [sveltefirekit.com](https://sveltefirekit.com/)
|
|
11
|
+
|
|
10
12
|
---
|
|
11
13
|
|
|
12
14
|
## Installation
|
|
@@ -162,28 +164,41 @@ try {
|
|
|
162
164
|
|
|
163
165
|
```ts
|
|
164
166
|
// Wait for auth to initialize (useful for SSR / load functions)
|
|
165
|
-
const user = await firekitUser.waitForAuth();
|
|
167
|
+
const user = await firekitUser.waitForAuth(); // 10s timeout (default)
|
|
168
|
+
const user = await firekitUser.waitForAuth(5_000); // custom timeout in ms
|
|
166
169
|
```
|
|
167
170
|
|
|
168
171
|
### Auth components
|
|
169
172
|
|
|
173
|
+
All auth components wait for Firebase Auth to initialize before rendering. Use the optional `fallback` snippet to show a loading state.
|
|
174
|
+
|
|
170
175
|
```svelte
|
|
171
|
-
<SignedIn>
|
|
172
|
-
|
|
176
|
+
<SignedIn>
|
|
177
|
+
{#snippet children(user)}<p>Welcome, {user.displayName}</p>{/snippet}
|
|
178
|
+
{#snippet fallback()}<p>Loading...</p>{/snippet}
|
|
179
|
+
</SignedIn>
|
|
180
|
+
|
|
181
|
+
<SignedOut>
|
|
182
|
+
{#snippet children(signIn)}<button onclick={signIn}>Sign in</button>{/snippet}
|
|
183
|
+
{#snippet fallback()}<p>Loading...</p>{/snippet}
|
|
184
|
+
</SignedOut>
|
|
173
185
|
|
|
174
186
|
<!-- Route guard with redirect callback -->
|
|
175
187
|
<AuthGuard requireAuth={true} onUnauthorized={() => goto('/login')}>
|
|
176
|
-
<p>Protected content</p>
|
|
188
|
+
{#snippet children(user, signOut)}<p>Protected content</p>{/snippet}
|
|
189
|
+
{#snippet fallback()}<p>Loading...</p>{/snippet}
|
|
177
190
|
</AuthGuard>
|
|
178
191
|
|
|
179
192
|
<!-- Custom guard with async checks (e.g. role verification) -->
|
|
180
193
|
<CustomGuard
|
|
181
194
|
verificationChecks={[
|
|
182
|
-
async () =>
|
|
195
|
+
async (user) => user.emailVerified,
|
|
196
|
+
async (user) => { const doc = await getDoc(...); return doc.data()?.role === 'admin'; }
|
|
183
197
|
]}
|
|
184
198
|
onUnauthorized={() => goto('/403')}
|
|
185
199
|
>
|
|
186
|
-
<p>Admin only</p>
|
|
200
|
+
{#snippet children(user, signOut)}<p>Admin only</p>{/snippet}
|
|
201
|
+
{#snippet fallback()}<p>Checking permissions...</p>{/snippet}
|
|
187
202
|
</CustomGuard>
|
|
188
203
|
```
|
|
189
204
|
|
|
@@ -415,16 +430,44 @@ List files:
|
|
|
415
430
|
{#each dir.items as item}<p>{item.name}</p>{/each}
|
|
416
431
|
```
|
|
417
432
|
|
|
418
|
-
|
|
433
|
+
### File upload validation
|
|
434
|
+
|
|
435
|
+
Validate files before uploading — checks size, MIME type, and image dimensions.
|
|
436
|
+
|
|
437
|
+
```ts
|
|
438
|
+
import { validateFile } from 'svelte-firekit';
|
|
439
|
+
|
|
440
|
+
const result = await validateFile(file, {
|
|
441
|
+
maxSize: 5 * 1024 * 1024, // 5 MB
|
|
442
|
+
accept: ['image/png', 'image/jpeg', '.webp'],
|
|
443
|
+
maxWidth: 2048,
|
|
444
|
+
maxHeight: 2048,
|
|
445
|
+
minWidth: 100
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
if (!result.valid) {
|
|
449
|
+
result.errors.forEach((e) => console.log(e.code, e.message));
|
|
450
|
+
}
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
The `<UploadTask>` component supports an optional `validate` prop and `invalid` snippet:
|
|
454
|
+
|
|
455
|
+
```svelte
|
|
456
|
+
<UploadTask path="uploads/{file.name}" {file} validate={{ maxSize: 5_000_000, accept: ['image/*'] }}>
|
|
457
|
+
{#snippet uploading(task)}<progress value={task.progress} max={100} />{/snippet}
|
|
458
|
+
{#snippet complete(url)}<img src={url} alt="uploaded" />{/snippet}
|
|
459
|
+
{#snippet invalid(result)}
|
|
460
|
+
{#each result.errors as err}<p class="error">{err.message}</p>{/each}
|
|
461
|
+
{/snippet}
|
|
462
|
+
</UploadTask>
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
Using the `<DownloadURL>` component:
|
|
419
466
|
|
|
420
467
|
```svelte
|
|
421
468
|
<DownloadURL path="images/avatar.jpg">
|
|
422
469
|
{#snippet data(url)}<img src={url} />{/snippet}
|
|
423
470
|
</DownloadURL>
|
|
424
|
-
|
|
425
|
-
<UploadTask path="uploads/file.jpg" {file}>
|
|
426
|
-
{#snippet data({ progress, downloadURL })}<progress value={progress} max={100} />{/snippet}
|
|
427
|
-
</UploadTask>
|
|
428
471
|
```
|
|
429
472
|
|
|
430
473
|
---
|
|
@@ -566,6 +609,44 @@ firekitInAppMessaging.unsuppress();
|
|
|
566
609
|
|
|
567
610
|
---
|
|
568
611
|
|
|
612
|
+
## Network / Offline Status
|
|
613
|
+
|
|
614
|
+
Track browser connectivity and Firestore sync state reactively.
|
|
615
|
+
|
|
616
|
+
```svelte
|
|
617
|
+
<script lang="ts">
|
|
618
|
+
import { firekitNetwork } from 'svelte-firekit';
|
|
619
|
+
</script>
|
|
620
|
+
|
|
621
|
+
{#if !firekitNetwork.online}
|
|
622
|
+
<p>You're offline. Changes will sync when reconnected.</p>
|
|
623
|
+
{:else if firekitNetwork.hasPendingWrites}
|
|
624
|
+
<p>Saving...</p>
|
|
625
|
+
{:else}
|
|
626
|
+
<p>All changes saved</p>
|
|
627
|
+
{/if}
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
Using the `<NetworkStatus>` component:
|
|
631
|
+
|
|
632
|
+
```svelte
|
|
633
|
+
<NetworkStatus>
|
|
634
|
+
{#snippet online()}<span class="green">Connected</span>{/snippet}
|
|
635
|
+
{#snippet offline()}<span class="red">Offline</span>{/snippet}
|
|
636
|
+
{#snippet pending()}<span>Saving...</span>{/snippet}
|
|
637
|
+
</NetworkStatus>
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
Manual control:
|
|
641
|
+
|
|
642
|
+
```ts
|
|
643
|
+
await firekitNetwork.goOffline(); // force offline mode
|
|
644
|
+
await firekitNetwork.goOnline(); // reconnect
|
|
645
|
+
firekitNetwork.trackWrite(); // mark a pending write
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
---
|
|
649
|
+
|
|
569
650
|
## Presence
|
|
570
651
|
|
|
571
652
|
```ts
|
|
@@ -656,6 +737,7 @@ const data = await posts.waitForReady();
|
|
|
656
737
|
| `firekitAnalytics` | Analytics event logging |
|
|
657
738
|
| `firekitMessaging` | Firebase Cloud Messaging |
|
|
658
739
|
| `firekitInAppMessaging` | In-App Messaging suppression control |
|
|
740
|
+
| `firekitNetwork` | Reactive network/offline status |
|
|
659
741
|
| `firekitAppCheck` | App Check initialization |
|
|
660
742
|
|
|
661
743
|
### Reactive classes
|
|
@@ -676,6 +758,12 @@ const data = await posts.waitForReady();
|
|
|
676
758
|
| `FirekitCallable` / `firekitCallable()` | Typed Cloud Function caller |
|
|
677
759
|
| `FirekitCallableFromURL` / `firekitCallableFromURL()` | Cloud Function by URL |
|
|
678
760
|
|
|
761
|
+
### Utilities
|
|
762
|
+
|
|
763
|
+
| Import | Description |
|
|
764
|
+
|---|---|
|
|
765
|
+
| `validateFile()` | Pre-upload file validation (size, type, dimensions) |
|
|
766
|
+
|
|
679
767
|
### Components
|
|
680
768
|
|
|
681
769
|
| Component | Description |
|
|
@@ -689,7 +777,8 @@ const data = await posts.waitForReady();
|
|
|
689
777
|
| `<Collection>` | Reactive Firestore collection |
|
|
690
778
|
| `<Node>` | Reactive RTDB node |
|
|
691
779
|
| `<DownloadURL>` | Storage download URL |
|
|
692
|
-
| `<UploadTask>` | Resumable file upload |
|
|
780
|
+
| `<UploadTask>` | Resumable file upload with optional validation |
|
|
781
|
+
| `<NetworkStatus>` | Network/sync status display |
|
|
693
782
|
|
|
694
783
|
---
|
|
695
784
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { firekitNetwork } from '../services/network.svelte.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Renders snippets based on network/sync status.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```svelte
|
|
10
|
+
* <NetworkStatus>
|
|
11
|
+
* {#snippet online()}<span class="dot green" />{/snippet}
|
|
12
|
+
* {#snippet offline()}<span class="dot red">Offline</span>{/snippet}
|
|
13
|
+
* {#snippet pending()}<span>Saving...</span>{/snippet}
|
|
14
|
+
* </NetworkStatus>
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
let {
|
|
18
|
+
online: onlineSnippet,
|
|
19
|
+
offline: offlineSnippet,
|
|
20
|
+
pending: pendingSnippet
|
|
21
|
+
}: {
|
|
22
|
+
/** Rendered when online and all writes are synced. */
|
|
23
|
+
online?: Snippet;
|
|
24
|
+
/** Rendered when the browser is offline. */
|
|
25
|
+
offline?: Snippet;
|
|
26
|
+
/** Rendered when there are pending Firestore writes. */
|
|
27
|
+
pending?: Snippet;
|
|
28
|
+
} = $props();
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
{#if !firekitNetwork.online && offlineSnippet}
|
|
32
|
+
{@render offlineSnippet()}
|
|
33
|
+
{:else if firekitNetwork.hasPendingWrites && pendingSnippet}
|
|
34
|
+
{@render pendingSnippet()}
|
|
35
|
+
{:else if firekitNetwork.online && onlineSnippet}
|
|
36
|
+
{@render onlineSnippet()}
|
|
37
|
+
{/if}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
/** Rendered when online and all writes are synced. */
|
|
4
|
+
online?: Snippet;
|
|
5
|
+
/** Rendered when the browser is offline. */
|
|
6
|
+
offline?: Snippet;
|
|
7
|
+
/** Rendered when there are pending Firestore writes. */
|
|
8
|
+
pending?: Snippet;
|
|
9
|
+
};
|
|
10
|
+
declare const NetworkStatus: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
11
|
+
type NetworkStatus = ReturnType<typeof NetworkStatus>;
|
|
12
|
+
export default NetworkStatus;
|
|
@@ -2,16 +2,20 @@
|
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
3
|
import type { UploadMetadata } from 'firebase/storage';
|
|
4
4
|
import { FirekitUploadTask, type UploadState } from '../services/storage.svelte.js';
|
|
5
|
+
import { validateFile } from '../utils/validation.js';
|
|
6
|
+
import type { FileValidationOptions, FileValidationResult } from '../types/storage.js';
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* Manages a Firebase Storage upload with reactive progress.
|
|
8
|
-
*
|
|
9
|
-
* Exposes upload controls (pause, resume, cancel) and progress state
|
|
10
|
-
* to the `uploading` snippet.
|
|
10
|
+
* Optionally validates the file before uploading.
|
|
11
11
|
*
|
|
12
12
|
* @example
|
|
13
13
|
* ```svelte
|
|
14
|
-
* <UploadTask
|
|
14
|
+
* <UploadTask
|
|
15
|
+
* path="uploads/{file.name}"
|
|
16
|
+
* {file}
|
|
17
|
+
* validate={{ maxSize: 5 * 1024 * 1024, accept: ['image/*'] }}
|
|
18
|
+
* >
|
|
15
19
|
* {#snippet uploading(task)}
|
|
16
20
|
* <progress value={task.progress} max={100} />
|
|
17
21
|
* <button onclick={task.pause}>Pause</button>
|
|
@@ -19,6 +23,11 @@
|
|
|
19
23
|
* {#snippet complete(url)}
|
|
20
24
|
* <img src={url} alt="uploaded" />
|
|
21
25
|
* {/snippet}
|
|
26
|
+
* {#snippet invalid(result)}
|
|
27
|
+
* {#each result.errors as err}
|
|
28
|
+
* <p class="error">{err.message}</p>
|
|
29
|
+
* {/each}
|
|
30
|
+
* {/snippet}
|
|
22
31
|
* </UploadTask>
|
|
23
32
|
* ```
|
|
24
33
|
*/
|
|
@@ -26,9 +35,11 @@
|
|
|
26
35
|
path,
|
|
27
36
|
file,
|
|
28
37
|
metadata,
|
|
38
|
+
validate,
|
|
29
39
|
uploading: uploadingSnippet,
|
|
30
40
|
complete: completeSnippet,
|
|
31
|
-
error: errorSnippet
|
|
41
|
+
error: errorSnippet,
|
|
42
|
+
invalid: invalidSnippet
|
|
32
43
|
}: {
|
|
33
44
|
/** Storage destination path. */
|
|
34
45
|
path: string;
|
|
@@ -36,6 +47,8 @@
|
|
|
36
47
|
file: Blob | Uint8Array | ArrayBuffer;
|
|
37
48
|
/** Optional upload metadata (content type, custom metadata, etc.). */
|
|
38
49
|
metadata?: UploadMetadata;
|
|
50
|
+
/** Optional validation rules. Only applied when `file` is a File instance. */
|
|
51
|
+
validate?: FileValidationOptions;
|
|
39
52
|
/**
|
|
40
53
|
* Rendered while the upload is active.
|
|
41
54
|
* Receives an object with `{ progress, state, bytesTransferred, totalBytes, pause, resume, cancel }`.
|
|
@@ -51,25 +64,55 @@
|
|
|
51
64
|
}]>;
|
|
52
65
|
/** Rendered after a successful upload. Receives the download URL. */
|
|
53
66
|
complete?: Snippet<[string]>;
|
|
54
|
-
/** Rendered on error. */
|
|
67
|
+
/** Rendered on upload error. */
|
|
55
68
|
error?: Snippet<[Error]>;
|
|
69
|
+
/** Rendered when validation fails. Receives the validation result with errors. */
|
|
70
|
+
invalid?: Snippet<[FileValidationResult]>;
|
|
56
71
|
} = $props();
|
|
57
72
|
|
|
58
|
-
|
|
73
|
+
let validationResult = $state<FileValidationResult | null>(null);
|
|
74
|
+
let task = $state<FirekitUploadTask | null>(null);
|
|
75
|
+
let started = false;
|
|
76
|
+
|
|
77
|
+
// Run validation (if configured) then start upload.
|
|
78
|
+
// Uses $effect so Svelte properly tracks the prop references.
|
|
79
|
+
$effect(() => {
|
|
80
|
+
if (started) return;
|
|
81
|
+
started = true;
|
|
82
|
+
|
|
83
|
+
if (validate && file instanceof File) {
|
|
84
|
+
validateFile(file, validate).then((result) => {
|
|
85
|
+
validationResult = result;
|
|
86
|
+
if (result.valid) {
|
|
87
|
+
task = new FirekitUploadTask(path, file as Blob, metadata);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
} else {
|
|
91
|
+
task = new FirekitUploadTask(path, file, metadata);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
59
94
|
</script>
|
|
60
95
|
|
|
61
|
-
{#if
|
|
62
|
-
{
|
|
63
|
-
{
|
|
64
|
-
{
|
|
65
|
-
{
|
|
66
|
-
{
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
96
|
+
{#if validationResult && !validationResult.valid}
|
|
97
|
+
{#if invalidSnippet}
|
|
98
|
+
{@render invalidSnippet(validationResult)}
|
|
99
|
+
{:else if errorSnippet}
|
|
100
|
+
{@render errorSnippet(new Error(validationResult.errors.map((e) => e.message).join('; ')))}
|
|
101
|
+
{/if}
|
|
102
|
+
{:else if task}
|
|
103
|
+
{#if task.error && errorSnippet}
|
|
104
|
+
{@render errorSnippet(task.error)}
|
|
105
|
+
{:else if task.completed && completeSnippet && task.downloadURL}
|
|
106
|
+
{@render completeSnippet(task.downloadURL)}
|
|
107
|
+
{:else if task.active && uploadingSnippet}
|
|
108
|
+
{@render uploadingSnippet({
|
|
109
|
+
progress: task.progress,
|
|
110
|
+
state: task.state,
|
|
111
|
+
bytesTransferred: task.bytesTransferred,
|
|
112
|
+
totalBytes: task.totalBytes,
|
|
113
|
+
pause: () => task!.pause(),
|
|
114
|
+
resume: () => task!.resume(),
|
|
115
|
+
cancel: () => task!.cancel()
|
|
116
|
+
})}
|
|
117
|
+
{/if}
|
|
75
118
|
{/if}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
2
|
import type { UploadMetadata } from 'firebase/storage';
|
|
3
3
|
import { type UploadState } from '../services/storage.svelte.js';
|
|
4
|
+
import type { FileValidationOptions, FileValidationResult } from '../types/storage.js';
|
|
4
5
|
type $$ComponentProps = {
|
|
5
6
|
/** Storage destination path. */
|
|
6
7
|
path: string;
|
|
@@ -8,6 +9,8 @@ type $$ComponentProps = {
|
|
|
8
9
|
file: Blob | Uint8Array | ArrayBuffer;
|
|
9
10
|
/** Optional upload metadata (content type, custom metadata, etc.). */
|
|
10
11
|
metadata?: UploadMetadata;
|
|
12
|
+
/** Optional validation rules. Only applied when `file` is a File instance. */
|
|
13
|
+
validate?: FileValidationOptions;
|
|
11
14
|
/**
|
|
12
15
|
* Rendered while the upload is active.
|
|
13
16
|
* Receives an object with `{ progress, state, bytesTransferred, totalBytes, pause, resume, cancel }`.
|
|
@@ -25,8 +28,10 @@ type $$ComponentProps = {
|
|
|
25
28
|
]>;
|
|
26
29
|
/** Rendered after a successful upload. Receives the download URL. */
|
|
27
30
|
complete?: Snippet<[string]>;
|
|
28
|
-
/** Rendered on error. */
|
|
31
|
+
/** Rendered on upload error. */
|
|
29
32
|
error?: Snippet<[Error]>;
|
|
33
|
+
/** Rendered when validation fails. Receives the validation result with errors. */
|
|
34
|
+
invalid?: Snippet<[FileValidationResult]>;
|
|
30
35
|
};
|
|
31
36
|
declare const UploadTask: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
32
37
|
type UploadTask = ReturnType<typeof UploadTask>;
|
package/dist/index.d.ts
CHANGED
|
@@ -20,6 +20,7 @@ export { FirekitRemoteConfig, firekitRemoteConfig, type FirekitRemoteConfigOptio
|
|
|
20
20
|
export { firekitPerformance, type PerformanceTrace } from './services/performance.js';
|
|
21
21
|
export { firekitMessaging, type MessagePayload } from './services/messaging.svelte.js';
|
|
22
22
|
export { firekitAnalytics } from './services/analytics.js';
|
|
23
|
+
export { firekitNetwork } from './services/network.svelte.js';
|
|
23
24
|
export { loadFirestoreBundle, getNamedQuery } from './services/bundles.js';
|
|
24
25
|
export { default as FirebaseApp } from './components/FirebaseApp.svelte';
|
|
25
26
|
export { default as SignedIn } from './components/SignedIn.svelte';
|
|
@@ -31,5 +32,6 @@ export { default as Collection } from './components/Collection.svelte';
|
|
|
31
32
|
export { default as Node } from './components/Node.svelte';
|
|
32
33
|
export { default as DownloadURL } from './components/DownloadURL.svelte';
|
|
33
34
|
export { default as UploadTask } from './components/UploadTask.svelte';
|
|
35
|
+
export { default as NetworkStatus } from './components/NetworkStatus.svelte';
|
|
34
36
|
export * from './types/index.js';
|
|
35
37
|
export * from './utils/index.js';
|
package/dist/index.js
CHANGED
|
@@ -32,6 +32,8 @@ export { firekitPerformance } from './services/performance.js';
|
|
|
32
32
|
export { firekitMessaging } from './services/messaging.svelte.js';
|
|
33
33
|
// ─── Analytics ────────────────────────────────────────────────────────────────
|
|
34
34
|
export { firekitAnalytics } from './services/analytics.js';
|
|
35
|
+
// ─── Network / Offline ───────────────────────────────────────────────────────
|
|
36
|
+
export { firekitNetwork } from './services/network.svelte.js';
|
|
35
37
|
// ─── Firestore bundles ────────────────────────────────────────────────────────
|
|
36
38
|
export { loadFirestoreBundle, getNamedQuery } from './services/bundles.js';
|
|
37
39
|
// ─── Components ──────────────────────────────────────────────────────────────
|
|
@@ -45,6 +47,7 @@ export { default as Collection } from './components/Collection.svelte';
|
|
|
45
47
|
export { default as Node } from './components/Node.svelte';
|
|
46
48
|
export { default as DownloadURL } from './components/DownloadURL.svelte';
|
|
47
49
|
export { default as UploadTask } from './components/UploadTask.svelte';
|
|
50
|
+
export { default as NetworkStatus } from './components/NetworkStatus.svelte';
|
|
48
51
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
49
52
|
export * from './types/index.js';
|
|
50
53
|
// ─── Utilities ───────────────────────────────────────────────────────────────
|
|
@@ -210,7 +210,19 @@ export class FirekitChat {
|
|
|
210
210
|
// Seed history from initialHistory
|
|
211
211
|
for (const turn of initialHistory) {
|
|
212
212
|
const text = turn.parts
|
|
213
|
-
.map((p) =>
|
|
213
|
+
.map((p) => {
|
|
214
|
+
if ('text' in p)
|
|
215
|
+
return p.text;
|
|
216
|
+
if ('inlineData' in p)
|
|
217
|
+
return '[image]';
|
|
218
|
+
if ('fileData' in p)
|
|
219
|
+
return '[file]';
|
|
220
|
+
if ('functionCall' in p)
|
|
221
|
+
return `[function: ${p.functionCall.name}]`;
|
|
222
|
+
if ('functionResponse' in p)
|
|
223
|
+
return `[function response: ${p.functionResponse.name}]`;
|
|
224
|
+
return '[unknown part]';
|
|
225
|
+
})
|
|
214
226
|
.filter(Boolean)
|
|
215
227
|
.join('');
|
|
216
228
|
this._history.push({
|
|
@@ -41,7 +41,11 @@ class FirekitAnalytics {
|
|
|
41
41
|
if (!supported)
|
|
42
42
|
return;
|
|
43
43
|
this._analytics = getAnalytics(getApp());
|
|
44
|
-
})()
|
|
44
|
+
})().catch((err) => {
|
|
45
|
+
// Reset so next call retries instead of returning a rejected promise forever
|
|
46
|
+
this._initPromise = null;
|
|
47
|
+
throw err;
|
|
48
|
+
});
|
|
45
49
|
return this._initPromise;
|
|
46
50
|
}
|
|
47
51
|
async _get() {
|
package/dist/services/auth.d.ts
CHANGED
|
@@ -18,8 +18,8 @@ declare class FirekitAuth {
|
|
|
18
18
|
private recaptchaVerifiers;
|
|
19
19
|
private constructor();
|
|
20
20
|
static getInstance(): FirekitAuth;
|
|
21
|
-
private bootstrap;
|
|
22
21
|
private getAuth;
|
|
22
|
+
private getFirestore;
|
|
23
23
|
private syncToFirestore;
|
|
24
24
|
private profile;
|
|
25
25
|
signInWithEmail(email: string, password: string): Promise<SignInResult>;
|
package/dist/services/auth.js
CHANGED
|
@@ -20,9 +20,8 @@ class FirekitAuth {
|
|
|
20
20
|
firestore = null;
|
|
21
21
|
recaptchaVerifiers = new Map();
|
|
22
22
|
constructor() {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
23
|
+
// Do NOT bootstrap here — Firebase config may not be set yet.
|
|
24
|
+
// Auth and Firestore instances are resolved lazily via getAuth() / getFirestore().
|
|
26
25
|
}
|
|
27
26
|
static getInstance() {
|
|
28
27
|
if (!FirekitAuth.instance) {
|
|
@@ -30,20 +29,6 @@ class FirekitAuth {
|
|
|
30
29
|
}
|
|
31
30
|
return FirekitAuth.instance;
|
|
32
31
|
}
|
|
33
|
-
bootstrap() {
|
|
34
|
-
try {
|
|
35
|
-
this.auth = firebaseService.getAuthInstance();
|
|
36
|
-
try {
|
|
37
|
-
this.firestore = firebaseService.getDbInstance();
|
|
38
|
-
}
|
|
39
|
-
catch {
|
|
40
|
-
this.firestore = null;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
catch {
|
|
44
|
-
// Firebase not yet configured — services will be accessed lazily
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
32
|
getAuth() {
|
|
48
33
|
if (!this.auth) {
|
|
49
34
|
this.auth = firebaseService.getAuthInstance();
|
|
@@ -53,10 +38,22 @@ class FirekitAuth {
|
|
|
53
38
|
}
|
|
54
39
|
return this.auth;
|
|
55
40
|
}
|
|
41
|
+
getFirestore() {
|
|
42
|
+
if (!this.firestore) {
|
|
43
|
+
try {
|
|
44
|
+
this.firestore = firebaseService.getDbInstance();
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
this.firestore = null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return this.firestore;
|
|
51
|
+
}
|
|
56
52
|
async syncToFirestore(user) {
|
|
57
|
-
|
|
53
|
+
const fs = this.getFirestore();
|
|
54
|
+
if (!fs)
|
|
58
55
|
return;
|
|
59
|
-
await updateUserInFirestore(
|
|
56
|
+
await updateUserInFirestore(fs, user);
|
|
60
57
|
}
|
|
61
58
|
profile(user) {
|
|
62
59
|
return mapFirebaseUserToProfile(user);
|
|
@@ -327,9 +324,10 @@ class FirekitAuth {
|
|
|
327
324
|
if (currentPassword)
|
|
328
325
|
await this.reauthenticate(currentPassword);
|
|
329
326
|
// Soft-delete record in Firestore before removing auth
|
|
330
|
-
|
|
327
|
+
const fs = this.getFirestore();
|
|
328
|
+
if (fs) {
|
|
331
329
|
try {
|
|
332
|
-
await setDoc(doc(
|
|
330
|
+
await setDoc(doc(fs, 'users', user.uid), { deleted: true, deletedAt: serverTimestamp() }, { merge: true });
|
|
333
331
|
}
|
|
334
332
|
catch {
|
|
335
333
|
// Non-blocking
|
|
@@ -560,16 +558,22 @@ class FirekitAuth {
|
|
|
560
558
|
}
|
|
561
559
|
// ─── Utility getters ─────────────────────────────────────────────────────────
|
|
562
560
|
getCurrentUser() {
|
|
563
|
-
|
|
561
|
+
try {
|
|
562
|
+
return this.getAuth().currentUser;
|
|
563
|
+
}
|
|
564
|
+
catch {
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
564
567
|
}
|
|
565
568
|
isAuthenticated() {
|
|
566
|
-
|
|
569
|
+
const user = this.getCurrentUser();
|
|
570
|
+
return user !== null && !user.isAnonymous;
|
|
567
571
|
}
|
|
568
572
|
isAnonymous() {
|
|
569
|
-
return this.
|
|
573
|
+
return this.getCurrentUser()?.isAnonymous ?? false;
|
|
570
574
|
}
|
|
571
575
|
isEmailVerified() {
|
|
572
|
-
return this.
|
|
576
|
+
return this.getCurrentUser()?.emailVerified ?? false;
|
|
573
577
|
}
|
|
574
578
|
async cleanup() {
|
|
575
579
|
this.recaptchaVerifiers.forEach((v) => v.clear());
|
|
@@ -40,6 +40,7 @@ class FirekitMessaging {
|
|
|
40
40
|
_messaging = null;
|
|
41
41
|
_unsubscribeMessage = null;
|
|
42
42
|
_requesting = false;
|
|
43
|
+
_listening = false;
|
|
43
44
|
constructor() {
|
|
44
45
|
this._initPermissionState();
|
|
45
46
|
}
|
|
@@ -161,7 +162,10 @@ class FirekitMessaging {
|
|
|
161
162
|
}
|
|
162
163
|
// ── Foreground messages ───────────────────────────────────────────────────
|
|
163
164
|
_listenForMessages(msg) {
|
|
165
|
+
if (this._listening)
|
|
166
|
+
return;
|
|
164
167
|
this._unsubscribeMessage?.();
|
|
168
|
+
this._listening = true;
|
|
165
169
|
this._unsubscribeMessage = onMessage(msg, (payload) => {
|
|
166
170
|
this._lastMessage = payload;
|
|
167
171
|
this._messages = [...this._messages, payload];
|
|
@@ -185,6 +189,7 @@ class FirekitMessaging {
|
|
|
185
189
|
dispose() {
|
|
186
190
|
this._unsubscribeMessage?.();
|
|
187
191
|
this._unsubscribeMessage = null;
|
|
192
|
+
this._listening = false;
|
|
188
193
|
}
|
|
189
194
|
}
|
|
190
195
|
export const firekitMessaging = FirekitMessaging.getInstance();
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { NetworkError } from '../types/network.js';
|
|
2
|
+
/**
|
|
3
|
+
* Reactive network/offline status store.
|
|
4
|
+
*
|
|
5
|
+
* Tracks browser connectivity, Firestore sync state, and pending writes.
|
|
6
|
+
* Provides manual control to go offline/online.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```svelte
|
|
10
|
+
* <script>
|
|
11
|
+
* import { firekitNetwork } from 'svelte-firekit';
|
|
12
|
+
* </script>
|
|
13
|
+
*
|
|
14
|
+
* {#if !firekitNetwork.online}
|
|
15
|
+
* <p>You're offline. Changes will sync when reconnected.</p>
|
|
16
|
+
* {/if}
|
|
17
|
+
*
|
|
18
|
+
* {#if firekitNetwork.hasPendingWrites}
|
|
19
|
+
* <p>Saving...</p>
|
|
20
|
+
* {:else}
|
|
21
|
+
* <p>All changes saved</p>
|
|
22
|
+
* {/if}
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
declare class FirekitNetwork {
|
|
26
|
+
private static instance;
|
|
27
|
+
private _online;
|
|
28
|
+
private _synced;
|
|
29
|
+
private _hasPendingWrites;
|
|
30
|
+
private _initialized;
|
|
31
|
+
private _error;
|
|
32
|
+
private _firestoreEnabled;
|
|
33
|
+
private _listening;
|
|
34
|
+
private _db;
|
|
35
|
+
private _syncUnsub;
|
|
36
|
+
private _onlineHandler;
|
|
37
|
+
private _offlineHandler;
|
|
38
|
+
private constructor();
|
|
39
|
+
static getInstance(): FirekitNetwork;
|
|
40
|
+
private ensureListening;
|
|
41
|
+
/**
|
|
42
|
+
* Called by FirebaseApp or lazily on first getter access.
|
|
43
|
+
* Safe to call multiple times.
|
|
44
|
+
*/
|
|
45
|
+
initialize(): void;
|
|
46
|
+
/** Whether the browser has network connectivity. */
|
|
47
|
+
get online(): boolean;
|
|
48
|
+
/** Whether all Firestore snapshots are in sync with the server. */
|
|
49
|
+
get synced(): boolean;
|
|
50
|
+
/** Whether there are unconfirmed Firestore writes. */
|
|
51
|
+
get hasPendingWrites(): boolean;
|
|
52
|
+
/** Whether the network store has been initialized. */
|
|
53
|
+
get initialized(): boolean;
|
|
54
|
+
/** Last error from network operations. */
|
|
55
|
+
get error(): NetworkError | null;
|
|
56
|
+
/** Whether Firestore network is currently enabled. */
|
|
57
|
+
get firestoreEnabled(): boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Call this when you perform a Firestore write to track pending state.
|
|
60
|
+
* Uses `waitForPendingWrites` to automatically clear when the write is acknowledged.
|
|
61
|
+
*/
|
|
62
|
+
trackWrite(): void;
|
|
63
|
+
/** Enables Firestore network access (reconnects to server). */
|
|
64
|
+
goOnline(): Promise<void>;
|
|
65
|
+
/** Disables Firestore network access (forces offline mode). */
|
|
66
|
+
goOffline(): Promise<void>;
|
|
67
|
+
clearError(): void;
|
|
68
|
+
dispose(): void;
|
|
69
|
+
}
|
|
70
|
+
export declare const firekitNetwork: FirekitNetwork;
|
|
71
|
+
export {};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { onSnapshotsInSync, enableNetwork, disableNetwork, waitForPendingWrites } from 'firebase/firestore';
|
|
2
|
+
import { firebaseService } from '../firebase.js';
|
|
3
|
+
import { NetworkError, NetworkErrorCode } from '../types/network.js';
|
|
4
|
+
/**
|
|
5
|
+
* Reactive network/offline status store.
|
|
6
|
+
*
|
|
7
|
+
* Tracks browser connectivity, Firestore sync state, and pending writes.
|
|
8
|
+
* Provides manual control to go offline/online.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```svelte
|
|
12
|
+
* <script>
|
|
13
|
+
* import { firekitNetwork } from 'svelte-firekit';
|
|
14
|
+
* </script>
|
|
15
|
+
*
|
|
16
|
+
* {#if !firekitNetwork.online}
|
|
17
|
+
* <p>You're offline. Changes will sync when reconnected.</p>
|
|
18
|
+
* {/if}
|
|
19
|
+
*
|
|
20
|
+
* {#if firekitNetwork.hasPendingWrites}
|
|
21
|
+
* <p>Saving...</p>
|
|
22
|
+
* {:else}
|
|
23
|
+
* <p>All changes saved</p>
|
|
24
|
+
* {/if}
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
class FirekitNetwork {
|
|
28
|
+
static instance;
|
|
29
|
+
// ── Reactive state ──────────────────────────────────────────────────────
|
|
30
|
+
_online = $state(typeof navigator !== 'undefined' ? navigator.onLine : true);
|
|
31
|
+
_synced = $state(true);
|
|
32
|
+
_hasPendingWrites = $state(false);
|
|
33
|
+
_initialized = $state(false);
|
|
34
|
+
_error = $state(null);
|
|
35
|
+
_firestoreEnabled = $state(true);
|
|
36
|
+
// ── Internal ────────────────────────────────────────────────────────────
|
|
37
|
+
_listening = false;
|
|
38
|
+
_db = null;
|
|
39
|
+
_syncUnsub = null;
|
|
40
|
+
_onlineHandler = null;
|
|
41
|
+
_offlineHandler = null;
|
|
42
|
+
constructor() { }
|
|
43
|
+
static getInstance() {
|
|
44
|
+
if (!FirekitNetwork.instance) {
|
|
45
|
+
FirekitNetwork.instance = new FirekitNetwork();
|
|
46
|
+
}
|
|
47
|
+
return FirekitNetwork.instance;
|
|
48
|
+
}
|
|
49
|
+
ensureListening() {
|
|
50
|
+
if (this._listening || typeof window === 'undefined')
|
|
51
|
+
return;
|
|
52
|
+
try {
|
|
53
|
+
const db = firebaseService.getDbInstance();
|
|
54
|
+
if (!db)
|
|
55
|
+
return;
|
|
56
|
+
this._listening = true;
|
|
57
|
+
// Browser online/offline events
|
|
58
|
+
this._onlineHandler = () => { this._online = true; };
|
|
59
|
+
this._offlineHandler = () => { this._online = false; };
|
|
60
|
+
window.addEventListener('online', this._onlineHandler);
|
|
61
|
+
window.addEventListener('offline', this._offlineHandler);
|
|
62
|
+
// Firestore sync state — fires when all active listeners are caught up
|
|
63
|
+
this._syncUnsub = onSnapshotsInSync(db, () => {
|
|
64
|
+
this._synced = true;
|
|
65
|
+
this._hasPendingWrites = false;
|
|
66
|
+
});
|
|
67
|
+
this._db = db;
|
|
68
|
+
this._initialized = true;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Firestore not configured yet — will retry on next getter access
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Called by FirebaseApp or lazily on first getter access.
|
|
76
|
+
* Safe to call multiple times.
|
|
77
|
+
*/
|
|
78
|
+
initialize() {
|
|
79
|
+
this.ensureListening();
|
|
80
|
+
}
|
|
81
|
+
// ── Public getters (reactive) ───────────────────────────────────────────
|
|
82
|
+
/** Whether the browser has network connectivity. */
|
|
83
|
+
get online() { this.ensureListening(); return this._online; }
|
|
84
|
+
/** Whether all Firestore snapshots are in sync with the server. */
|
|
85
|
+
get synced() { this.ensureListening(); return this._synced; }
|
|
86
|
+
/** Whether there are unconfirmed Firestore writes. */
|
|
87
|
+
get hasPendingWrites() { this.ensureListening(); return this._hasPendingWrites; }
|
|
88
|
+
/** Whether the network store has been initialized. */
|
|
89
|
+
get initialized() { return this._initialized; }
|
|
90
|
+
/** Last error from network operations. */
|
|
91
|
+
get error() { return this._error; }
|
|
92
|
+
/** Whether Firestore network is currently enabled. */
|
|
93
|
+
get firestoreEnabled() { return this._firestoreEnabled; }
|
|
94
|
+
// ── Methods ─────────────────────────────────────────────────────────────
|
|
95
|
+
/**
|
|
96
|
+
* Call this when you perform a Firestore write to track pending state.
|
|
97
|
+
* Uses `waitForPendingWrites` to automatically clear when the write is acknowledged.
|
|
98
|
+
*/
|
|
99
|
+
trackWrite() {
|
|
100
|
+
this._hasPendingWrites = true;
|
|
101
|
+
this._synced = false;
|
|
102
|
+
if (this._db) {
|
|
103
|
+
waitForPendingWrites(this._db)
|
|
104
|
+
.then(() => {
|
|
105
|
+
this._hasPendingWrites = false;
|
|
106
|
+
this._synced = true;
|
|
107
|
+
})
|
|
108
|
+
.catch(() => {
|
|
109
|
+
// Firestore may have been disabled
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Enables Firestore network access (reconnects to server). */
|
|
114
|
+
async goOnline() {
|
|
115
|
+
try {
|
|
116
|
+
const db = firebaseService.getDbInstance();
|
|
117
|
+
if (!db)
|
|
118
|
+
throw new NetworkError(NetworkErrorCode.FIRESTORE_UNAVAILABLE, 'Firestore is not available.');
|
|
119
|
+
await enableNetwork(db);
|
|
120
|
+
this._firestoreEnabled = true;
|
|
121
|
+
this._error = null;
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
if (err instanceof NetworkError)
|
|
125
|
+
throw err;
|
|
126
|
+
const error = new NetworkError(NetworkErrorCode.ENABLE_NETWORK_FAILED, 'Failed to enable network.', err);
|
|
127
|
+
this._error = error;
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** Disables Firestore network access (forces offline mode). */
|
|
132
|
+
async goOffline() {
|
|
133
|
+
try {
|
|
134
|
+
const db = firebaseService.getDbInstance();
|
|
135
|
+
if (!db)
|
|
136
|
+
throw new NetworkError(NetworkErrorCode.FIRESTORE_UNAVAILABLE, 'Firestore is not available.');
|
|
137
|
+
await disableNetwork(db);
|
|
138
|
+
this._firestoreEnabled = false;
|
|
139
|
+
this._error = null;
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
if (err instanceof NetworkError)
|
|
143
|
+
throw err;
|
|
144
|
+
const error = new NetworkError(NetworkErrorCode.DISABLE_NETWORK_FAILED, 'Failed to disable network.', err);
|
|
145
|
+
this._error = error;
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
clearError() {
|
|
150
|
+
this._error = null;
|
|
151
|
+
}
|
|
152
|
+
dispose() {
|
|
153
|
+
this._listening = false;
|
|
154
|
+
this._syncUnsub?.();
|
|
155
|
+
this._syncUnsub = null;
|
|
156
|
+
if (this._onlineHandler) {
|
|
157
|
+
window.removeEventListener('online', this._onlineHandler);
|
|
158
|
+
this._onlineHandler = null;
|
|
159
|
+
}
|
|
160
|
+
if (this._offlineHandler) {
|
|
161
|
+
window.removeEventListener('offline', this._offlineHandler);
|
|
162
|
+
this._offlineHandler = null;
|
|
163
|
+
}
|
|
164
|
+
this._initialized = false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
export const firekitNetwork = FirekitNetwork.getInstance();
|
|
@@ -174,9 +174,11 @@ class FirekitPresence {
|
|
|
174
174
|
_geo = null;
|
|
175
175
|
_connectedUnsub = null;
|
|
176
176
|
_currentUser = null;
|
|
177
|
+
_visibilityListenerAdded = false;
|
|
177
178
|
constructor() {
|
|
178
179
|
if (typeof window !== 'undefined') {
|
|
179
180
|
document.addEventListener('visibilitychange', this._onVisibilityChange);
|
|
181
|
+
this._visibilityListenerAdded = true;
|
|
180
182
|
}
|
|
181
183
|
}
|
|
182
184
|
static getInstance() {
|
|
@@ -236,10 +238,15 @@ class FirekitPresence {
|
|
|
236
238
|
if (!db)
|
|
237
239
|
throw new PresenceError(PresenceErrorCode.DATABASE_ERROR, 'Realtime Database is not initialized.');
|
|
238
240
|
const connectedRef = ref(db, '.info/connected');
|
|
239
|
-
this._connectedUnsub = onValue(connectedRef,
|
|
241
|
+
this._connectedUnsub = onValue(connectedRef, (snap) => {
|
|
240
242
|
if (snap.val() === true) {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
+
this.setPresence('online')
|
|
244
|
+
.then(() => this._setupDisconnectHandler())
|
|
245
|
+
.catch((err) => {
|
|
246
|
+
this._error = err instanceof PresenceError
|
|
247
|
+
? err
|
|
248
|
+
: new PresenceError(PresenceErrorCode.DATABASE_ERROR, err.message, err);
|
|
249
|
+
});
|
|
243
250
|
}
|
|
244
251
|
else {
|
|
245
252
|
this._status = 'offline';
|
|
@@ -377,8 +384,9 @@ class FirekitPresence {
|
|
|
377
384
|
this._geo?.dispose();
|
|
378
385
|
this._connectedUnsub?.();
|
|
379
386
|
this._connectedUnsub = null;
|
|
380
|
-
if (
|
|
387
|
+
if (this._visibilityListenerAdded) {
|
|
381
388
|
document.removeEventListener('visibilitychange', this._onVisibilityChange);
|
|
389
|
+
this._visibilityListenerAdded = false;
|
|
382
390
|
}
|
|
383
391
|
this._initialized = false;
|
|
384
392
|
this._status = 'offline';
|
|
@@ -92,6 +92,9 @@ export class FirekitRemoteConfig {
|
|
|
92
92
|
},
|
|
93
93
|
error: (err) => {
|
|
94
94
|
this._error = err instanceof Error ? err : new Error(String(err));
|
|
95
|
+
// Clean up the broken subscription so it doesn't keep firing errors
|
|
96
|
+
this._unsubscribe?.();
|
|
97
|
+
this._unsubscribe = null;
|
|
95
98
|
},
|
|
96
99
|
complete: () => { }
|
|
97
100
|
});
|
package/dist/types/index.d.ts
CHANGED
package/dist/types/index.js
CHANGED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare enum NetworkErrorCode {
|
|
2
|
+
FIRESTORE_UNAVAILABLE = "network/firestore-unavailable",
|
|
3
|
+
ENABLE_NETWORK_FAILED = "network/enable-network-failed",
|
|
4
|
+
DISABLE_NETWORK_FAILED = "network/disable-network-failed"
|
|
5
|
+
}
|
|
6
|
+
export declare class NetworkError extends Error {
|
|
7
|
+
code: NetworkErrorCode;
|
|
8
|
+
originalError?: unknown | undefined;
|
|
9
|
+
constructor(code: NetworkErrorCode, message: string, originalError?: unknown | undefined);
|
|
10
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export var NetworkErrorCode;
|
|
2
|
+
(function (NetworkErrorCode) {
|
|
3
|
+
NetworkErrorCode["FIRESTORE_UNAVAILABLE"] = "network/firestore-unavailable";
|
|
4
|
+
NetworkErrorCode["ENABLE_NETWORK_FAILED"] = "network/enable-network-failed";
|
|
5
|
+
NetworkErrorCode["DISABLE_NETWORK_FAILED"] = "network/disable-network-failed";
|
|
6
|
+
})(NetworkErrorCode || (NetworkErrorCode = {}));
|
|
7
|
+
export class NetworkError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
originalError;
|
|
10
|
+
constructor(code, message, originalError) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.originalError = originalError;
|
|
14
|
+
this.name = 'NetworkError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface FileValidationOptions {
|
|
2
|
+
/** Maximum file size in bytes. */
|
|
3
|
+
maxSize?: number;
|
|
4
|
+
/** Minimum file size in bytes. */
|
|
5
|
+
minSize?: number;
|
|
6
|
+
/** Allowed MIME types or extensions. Supports wildcards like `'image/*'` and extensions like `'.jpg'`. */
|
|
7
|
+
accept?: string[];
|
|
8
|
+
/** Maximum image width in pixels. Only checked for image files. */
|
|
9
|
+
maxWidth?: number;
|
|
10
|
+
/** Maximum image height in pixels. Only checked for image files. */
|
|
11
|
+
maxHeight?: number;
|
|
12
|
+
/** Minimum image width in pixels. Only checked for image files. */
|
|
13
|
+
minWidth?: number;
|
|
14
|
+
/** Minimum image height in pixels. Only checked for image files. */
|
|
15
|
+
minHeight?: number;
|
|
16
|
+
}
|
|
17
|
+
export declare enum FileValidationErrorCode {
|
|
18
|
+
FILE_TOO_LARGE = "validation/file-too-large",
|
|
19
|
+
FILE_TOO_SMALL = "validation/file-too-small",
|
|
20
|
+
INVALID_MIME_TYPE = "validation/invalid-mime-type",
|
|
21
|
+
IMAGE_TOO_WIDE = "validation/image-too-wide",
|
|
22
|
+
IMAGE_TOO_TALL = "validation/image-too-tall",
|
|
23
|
+
IMAGE_TOO_NARROW = "validation/image-too-narrow",
|
|
24
|
+
IMAGE_TOO_SHORT = "validation/image-too-short",
|
|
25
|
+
DIMENSION_CHECK_FAILED = "validation/dimension-check-failed"
|
|
26
|
+
}
|
|
27
|
+
export interface FileValidationError {
|
|
28
|
+
code: FileValidationErrorCode;
|
|
29
|
+
message: string;
|
|
30
|
+
}
|
|
31
|
+
export interface FileValidationResult {
|
|
32
|
+
valid: boolean;
|
|
33
|
+
errors: FileValidationError[];
|
|
34
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export var FileValidationErrorCode;
|
|
2
|
+
(function (FileValidationErrorCode) {
|
|
3
|
+
FileValidationErrorCode["FILE_TOO_LARGE"] = "validation/file-too-large";
|
|
4
|
+
FileValidationErrorCode["FILE_TOO_SMALL"] = "validation/file-too-small";
|
|
5
|
+
FileValidationErrorCode["INVALID_MIME_TYPE"] = "validation/invalid-mime-type";
|
|
6
|
+
FileValidationErrorCode["IMAGE_TOO_WIDE"] = "validation/image-too-wide";
|
|
7
|
+
FileValidationErrorCode["IMAGE_TOO_TALL"] = "validation/image-too-tall";
|
|
8
|
+
FileValidationErrorCode["IMAGE_TOO_NARROW"] = "validation/image-too-narrow";
|
|
9
|
+
FileValidationErrorCode["IMAGE_TOO_SHORT"] = "validation/image-too-short";
|
|
10
|
+
FileValidationErrorCode["DIMENSION_CHECK_FAILED"] = "validation/dimension-check-failed";
|
|
11
|
+
})(FileValidationErrorCode || (FileValidationErrorCode = {}));
|
package/dist/utils/index.d.ts
CHANGED
package/dist/utils/index.js
CHANGED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type FileValidationOptions, type FileValidationResult } from '../types/storage.js';
|
|
2
|
+
/**
|
|
3
|
+
* Validates a File against the given options.
|
|
4
|
+
* Returns a result with `valid: true` if all checks pass, or `valid: false` with an array of errors.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```ts
|
|
8
|
+
* const result = await validateFile(file, {
|
|
9
|
+
* maxSize: 5 * 1024 * 1024,
|
|
10
|
+
* accept: ['image/png', 'image/jpeg', '.webp'],
|
|
11
|
+
* maxWidth: 2048,
|
|
12
|
+
* maxHeight: 2048
|
|
13
|
+
* });
|
|
14
|
+
*
|
|
15
|
+
* if (!result.valid) {
|
|
16
|
+
* console.log(result.errors);
|
|
17
|
+
* }
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export declare function validateFile(file: File, options: FileValidationOptions): Promise<FileValidationResult>;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { FileValidationErrorCode } from '../types/storage.js';
|
|
2
|
+
/** Common extension-to-MIME mappings for the `accept` option. */
|
|
3
|
+
const EXT_TO_MIME = {
|
|
4
|
+
'.jpg': 'image/jpeg',
|
|
5
|
+
'.jpeg': 'image/jpeg',
|
|
6
|
+
'.png': 'image/png',
|
|
7
|
+
'.gif': 'image/gif',
|
|
8
|
+
'.webp': 'image/webp',
|
|
9
|
+
'.svg': 'image/svg+xml',
|
|
10
|
+
'.bmp': 'image/bmp',
|
|
11
|
+
'.ico': 'image/x-icon',
|
|
12
|
+
'.avif': 'image/avif',
|
|
13
|
+
'.pdf': 'application/pdf',
|
|
14
|
+
'.mp4': 'video/mp4',
|
|
15
|
+
'.webm': 'video/webm',
|
|
16
|
+
'.mp3': 'audio/mpeg',
|
|
17
|
+
'.wav': 'audio/wav',
|
|
18
|
+
'.ogg': 'audio/ogg',
|
|
19
|
+
'.json': 'application/json',
|
|
20
|
+
'.csv': 'text/csv',
|
|
21
|
+
'.txt': 'text/plain',
|
|
22
|
+
'.zip': 'application/zip'
|
|
23
|
+
};
|
|
24
|
+
function formatBytes(bytes) {
|
|
25
|
+
if (bytes < 1024)
|
|
26
|
+
return `${bytes} B`;
|
|
27
|
+
if (bytes < 1024 * 1024)
|
|
28
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
29
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
30
|
+
}
|
|
31
|
+
function matchesMime(fileType, fileName, pattern) {
|
|
32
|
+
// Extension pattern: '.jpg'
|
|
33
|
+
if (pattern.startsWith('.')) {
|
|
34
|
+
const expectedMime = EXT_TO_MIME[pattern.toLowerCase()];
|
|
35
|
+
if (expectedMime)
|
|
36
|
+
return fileType === expectedMime;
|
|
37
|
+
// Fallback: check the file name extension
|
|
38
|
+
return fileName.toLowerCase().endsWith(pattern.toLowerCase());
|
|
39
|
+
}
|
|
40
|
+
// Wildcard MIME: 'image/*'
|
|
41
|
+
if (pattern.endsWith('/*')) {
|
|
42
|
+
const prefix = pattern.slice(0, -1); // 'image/'
|
|
43
|
+
return fileType.startsWith(prefix);
|
|
44
|
+
}
|
|
45
|
+
// Exact MIME: 'image/png'
|
|
46
|
+
return fileType === pattern;
|
|
47
|
+
}
|
|
48
|
+
function getImageDimensions(file) {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const url = URL.createObjectURL(file);
|
|
51
|
+
const img = new Image();
|
|
52
|
+
img.onload = () => {
|
|
53
|
+
URL.revokeObjectURL(url);
|
|
54
|
+
resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
|
55
|
+
};
|
|
56
|
+
img.onerror = () => {
|
|
57
|
+
URL.revokeObjectURL(url);
|
|
58
|
+
reject(new Error('Failed to load image for dimension check.'));
|
|
59
|
+
};
|
|
60
|
+
img.src = url;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Validates a File against the given options.
|
|
65
|
+
* Returns a result with `valid: true` if all checks pass, or `valid: false` with an array of errors.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```ts
|
|
69
|
+
* const result = await validateFile(file, {
|
|
70
|
+
* maxSize: 5 * 1024 * 1024,
|
|
71
|
+
* accept: ['image/png', 'image/jpeg', '.webp'],
|
|
72
|
+
* maxWidth: 2048,
|
|
73
|
+
* maxHeight: 2048
|
|
74
|
+
* });
|
|
75
|
+
*
|
|
76
|
+
* if (!result.valid) {
|
|
77
|
+
* console.log(result.errors);
|
|
78
|
+
* }
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
export async function validateFile(file, options) {
|
|
82
|
+
const errors = [];
|
|
83
|
+
// ── Size checks ──────────────────────────────────────────────────────────
|
|
84
|
+
if (options.maxSize !== undefined && file.size > options.maxSize) {
|
|
85
|
+
errors.push({
|
|
86
|
+
code: FileValidationErrorCode.FILE_TOO_LARGE,
|
|
87
|
+
message: `File size ${formatBytes(file.size)} exceeds maximum ${formatBytes(options.maxSize)}.`
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (options.minSize !== undefined && file.size < options.minSize) {
|
|
91
|
+
errors.push({
|
|
92
|
+
code: FileValidationErrorCode.FILE_TOO_SMALL,
|
|
93
|
+
message: `File size ${formatBytes(file.size)} is below minimum ${formatBytes(options.minSize)}.`
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
// ── MIME type check ──────────────────────────────────────────────────────
|
|
97
|
+
if (options.accept && options.accept.length > 0) {
|
|
98
|
+
const accepted = options.accept.some((pattern) => matchesMime(file.type, file.name, pattern));
|
|
99
|
+
if (!accepted) {
|
|
100
|
+
errors.push({
|
|
101
|
+
code: FileValidationErrorCode.INVALID_MIME_TYPE,
|
|
102
|
+
message: `File type "${file.type || 'unknown'}" is not accepted. Allowed: ${options.accept.join(', ')}.`
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// ── Image dimension checks ───────────────────────────────────────────────
|
|
107
|
+
const needsDimensions = options.maxWidth !== undefined ||
|
|
108
|
+
options.maxHeight !== undefined ||
|
|
109
|
+
options.minWidth !== undefined ||
|
|
110
|
+
options.minHeight !== undefined;
|
|
111
|
+
if (needsDimensions && file.type.startsWith('image/')) {
|
|
112
|
+
try {
|
|
113
|
+
const { width, height } = await getImageDimensions(file);
|
|
114
|
+
if (options.maxWidth !== undefined && width > options.maxWidth) {
|
|
115
|
+
errors.push({
|
|
116
|
+
code: FileValidationErrorCode.IMAGE_TOO_WIDE,
|
|
117
|
+
message: `Image width ${width}px exceeds maximum ${options.maxWidth}px.`
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
if (options.maxHeight !== undefined && height > options.maxHeight) {
|
|
121
|
+
errors.push({
|
|
122
|
+
code: FileValidationErrorCode.IMAGE_TOO_TALL,
|
|
123
|
+
message: `Image height ${height}px exceeds maximum ${options.maxHeight}px.`
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
if (options.minWidth !== undefined && width < options.minWidth) {
|
|
127
|
+
errors.push({
|
|
128
|
+
code: FileValidationErrorCode.IMAGE_TOO_NARROW,
|
|
129
|
+
message: `Image width ${width}px is below minimum ${options.minWidth}px.`
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
if (options.minHeight !== undefined && height < options.minHeight) {
|
|
133
|
+
errors.push({
|
|
134
|
+
code: FileValidationErrorCode.IMAGE_TOO_SHORT,
|
|
135
|
+
message: `Image height ${height}px is below minimum ${options.minHeight}px.`
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
errors.push({
|
|
141
|
+
code: FileValidationErrorCode.DIMENSION_CHECK_FAILED,
|
|
142
|
+
message: 'Could not read image dimensions for validation.'
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { valid: errors.length === 0, errors };
|
|
147
|
+
}
|