@sveltebase/sync 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +480 -0
- package/dist/client/index.d.ts +47 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +326 -0
- package/dist/client/live.svelte.d.ts +18 -0
- package/dist/client/live.svelte.d.ts.map +1 -0
- package/dist/client/live.svelte.js +40 -0
- package/dist/global.d.ts +25 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/protocol.d.ts +41 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +14 -0
- package/dist/server/broker.d.ts +27 -0
- package/dist/server/broker.d.ts.map +1 -0
- package/dist/server/broker.js +230 -0
- package/dist/server/dev-engine.d.ts +10 -0
- package/dist/server/dev-engine.d.ts.map +1 -0
- package/dist/server/dev-engine.js +117 -0
- package/dist/server/engine.d.ts +13 -0
- package/dist/server/engine.d.ts.map +1 -0
- package/dist/server/engine.js +117 -0
- package/dist/server/handler.d.ts +3 -0
- package/dist/server/handler.d.ts.map +1 -0
- package/dist/server/handler.js +51 -0
- package/dist/server/index.d.ts +25 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +10 -0
- package/dist/vite.d.ts +6 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +55 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
# @sveltebase/sync
|
|
2
|
+
|
|
3
|
+
Reactive, local-first database synchronization library built for **Svelte 5** and **Cloudflare Workers / Durable Objects**.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Local Persistence**: Powered by [Dexie.js](https://dexie.org/) (IndexedDB). Zero-WASM, instant load times, persistent across page refreshes.
|
|
8
|
+
- **Optimistic Updates**: Client mutations update the local database instantly (~1ms), sync with the server in the background, and roll back automatically on failures.
|
|
9
|
+
- **Real-Time Sync**: Single multiplexed WebSocket connection fanning out updates to all active subscribers.
|
|
10
|
+
- **Last-Write-Wins (LWW)**: Timestamps prevent out-of-order write conflicts.
|
|
11
|
+
- **Delta Syncing (Incremental Load)**: Automatically pulls only modified records since the last sync time to conserve network bandwidth.
|
|
12
|
+
- **Hibernate Friendly**: Client-initiated heartbeats allow Cloudflare Durable Objects to sleep when idle, cutting active execution costs down to near zero.
|
|
13
|
+
- **Vite Integration**: Custom dev plugin simulating Durable Objects and bindings proxy locally without full worker compilation loops.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Architecture & Forked Adapter
|
|
18
|
+
|
|
19
|
+
To bind custom Cloudflare Workers features (like **Durable Objects**, **Queues**, and **Email Handlers**) directly within a SvelteKit application, you **must use** the forked adapter:
|
|
20
|
+
|
|
21
|
+
👉 **`@joshthomas/sveltekit-adapter-cloudflare`**
|
|
22
|
+
|
|
23
|
+
### Why this adapter?
|
|
24
|
+
The official `@sveltejs/adapter-cloudflare` owns the final worker entrypoint (`_worker.js`) and does not natively allow you to declare custom class exports (like Durable Objects) in the same worker.
|
|
25
|
+
|
|
26
|
+
The **Josh Thomas fork** introduces a platform entrypoint (`src/platform.cloudflare.ts`) which SvelteKit bundles into the worker wrapper, allowing you to export Durable Objects while SvelteKit continues to manage Svelte routing and page rendering.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 1. Cloudflare Configuration (`wrangler.jsonc`)
|
|
31
|
+
|
|
32
|
+
Define D1 database and Durable Object namespace configurations in your `wrangler.jsonc` (or `wrangler.toml`):
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"compatibility_date": "2026-06-07",
|
|
37
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
38
|
+
"main": ".svelte-kit/cloudflare/_worker.js",
|
|
39
|
+
"d1_databases": [
|
|
40
|
+
{
|
|
41
|
+
"binding": "DB",
|
|
42
|
+
"database_name": "sveltebase-sync",
|
|
43
|
+
"database_id": "YOUR_DATABASE_ID",
|
|
44
|
+
"migrations_dir": "drizzle/migrations"
|
|
45
|
+
}
|
|
46
|
+
],
|
|
47
|
+
"durable_objects": {
|
|
48
|
+
"bindings": [
|
|
49
|
+
{
|
|
50
|
+
"name": "SYNC_ENGINE",
|
|
51
|
+
"class_name": "SyncEngine"
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
"migrations": [
|
|
56
|
+
{
|
|
57
|
+
"tag": "v1",
|
|
58
|
+
"new_sqlite_classes": ["SyncEngine"]
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## 2. Setup Guide
|
|
67
|
+
|
|
68
|
+
### Step 1: Client Schema & Client Creation
|
|
69
|
+
|
|
70
|
+
Configure your client-side IndexedDB database using `SyncClient`.
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
// src/lib/sync-client.ts
|
|
74
|
+
import { SyncClient } from "@sveltebase/sync/client";
|
|
75
|
+
import type { Todo } from "$lib/server/db/schema";
|
|
76
|
+
|
|
77
|
+
// Map table name to row type
|
|
78
|
+
type AppDatabaseSchema = {
|
|
79
|
+
todos: Todo;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const sync = new SyncClient<AppDatabaseSchema>({
|
|
83
|
+
name: "sveltebase-sync", // Local IndexedDB name
|
|
84
|
+
url: "/api/sync", // WebSocket endpoint
|
|
85
|
+
tables: {
|
|
86
|
+
todos: {
|
|
87
|
+
indexes: "id, completed, createdAt", // IndexedDB indexes
|
|
88
|
+
channel: "todos", // Sync channel
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Export typed table wrapper
|
|
94
|
+
export const todosTable = sync.table("todos");
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Use it in your Svelte 5 components:
|
|
98
|
+
```svelte
|
|
99
|
+
<script lang="ts">
|
|
100
|
+
import { todosTable } from "$lib/sync-client";
|
|
101
|
+
import { Check, Trash } from "lucide-svelte";
|
|
102
|
+
|
|
103
|
+
// Reactive liveQuery updates instantly on local mutations & remote syncs
|
|
104
|
+
const todos = todosTable.liveQuery((t) => t.orderBy("createdAt").reverse().toArray());
|
|
105
|
+
|
|
106
|
+
let title = "";
|
|
107
|
+
|
|
108
|
+
async function addTodo() {
|
|
109
|
+
if (!title.trim()) return;
|
|
110
|
+
await todosTable.add({
|
|
111
|
+
id: crypto.randomUUID(),
|
|
112
|
+
title,
|
|
113
|
+
completed: false,
|
|
114
|
+
createdAt: new Date().toISOString(),
|
|
115
|
+
updatedAt: new Date().toISOString(),
|
|
116
|
+
});
|
|
117
|
+
title = "";
|
|
118
|
+
}
|
|
119
|
+
</script>
|
|
120
|
+
|
|
121
|
+
<input bind:value={title} onkeydown={(e) => e.key === 'Enter' && addTodo()} />
|
|
122
|
+
|
|
123
|
+
{#if todos.isLoading}
|
|
124
|
+
<div>Loading todos...</div>
|
|
125
|
+
{:else if todos.status === "error"}
|
|
126
|
+
<div>Error loading database: {todos.error?.message || todos.error}</div>
|
|
127
|
+
{:else}
|
|
128
|
+
{#each todos.data as todo (todo.id)}
|
|
129
|
+
<div>
|
|
130
|
+
<button onclick={() => todosTable.put(todo.id, { completed: !todo.completed })}>
|
|
131
|
+
<Check class={todo.completed ? "text-emerald-500" : ""} />
|
|
132
|
+
</button>
|
|
133
|
+
<span>{todo.title}</span>
|
|
134
|
+
<button onclick={() => todosTable.delete(todo.id)}><Trash /></button>
|
|
135
|
+
</div>
|
|
136
|
+
{/each}
|
|
137
|
+
{/if}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
### Step 2: Define Sync Handlers (Server)
|
|
143
|
+
|
|
144
|
+
Define the handlers that translate IndexedDB operations (fetch, create, update, delete) to D1 database queries:
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
// src/lib/server/sync-todos.ts
|
|
148
|
+
import { defineSync } from "@sveltebase/sync";
|
|
149
|
+
import { getDB } from "$lib/server/db/index.js";
|
|
150
|
+
import { todos } from "$lib/server/db/schema";
|
|
151
|
+
import { desc, eq, gt } from "drizzle-orm";
|
|
152
|
+
import type { Todo } from "$lib/server/db/schema";
|
|
153
|
+
|
|
154
|
+
export const todoSync = defineSync<Todo>({
|
|
155
|
+
channel: "todos",
|
|
156
|
+
|
|
157
|
+
fetch: async (ctx, since) => {
|
|
158
|
+
const db = getDB(ctx.platform);
|
|
159
|
+
if (since) {
|
|
160
|
+
return await db
|
|
161
|
+
.select()
|
|
162
|
+
.from(todos)
|
|
163
|
+
.where(gt(todos.updatedAt, since))
|
|
164
|
+
.orderBy(desc(todos.createdAt));
|
|
165
|
+
}
|
|
166
|
+
return await db.select().from(todos).orderBy(desc(todos.createdAt));
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
create: async (ctx, data) => {
|
|
170
|
+
const db = getDB(ctx.platform);
|
|
171
|
+
const [created] = await db
|
|
172
|
+
.insert(todos)
|
|
173
|
+
.values(data)
|
|
174
|
+
.onConflictDoUpdate({
|
|
175
|
+
target: todos.id,
|
|
176
|
+
set: {
|
|
177
|
+
title: data.title,
|
|
178
|
+
completed: data.completed,
|
|
179
|
+
updatedAt: new Date().toISOString(),
|
|
180
|
+
},
|
|
181
|
+
})
|
|
182
|
+
.returning();
|
|
183
|
+
return created;
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
update: async (ctx, key, changes) => {
|
|
187
|
+
const db = getDB(ctx.platform);
|
|
188
|
+
const [updated] = await db
|
|
189
|
+
.update(todos)
|
|
190
|
+
.set({ ...changes, updatedAt: new Date().toISOString() })
|
|
191
|
+
.where(eq(todos.id, key))
|
|
192
|
+
.returning();
|
|
193
|
+
return updated;
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
delete: async (ctx, key) => {
|
|
197
|
+
const db = getDB(ctx.platform);
|
|
198
|
+
await db.delete(todos).where(eq(todos.id, key));
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Export handlers from a single list:
|
|
204
|
+
```typescript
|
|
205
|
+
// src/lib/server/sync-handlers.ts
|
|
206
|
+
import { todoSync } from "./sync-todos.js";
|
|
207
|
+
|
|
208
|
+
export const handlers = [todoSync];
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
### Step 3: SvelteKit WebSocket Server Route
|
|
214
|
+
|
|
215
|
+
Set up the upgrade endpoint to forward SvelteKit HTTP upgrades to Durable Objects.
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
// src/routes/api/sync/+server.ts
|
|
219
|
+
import { handleUpgrade } from "@sveltebase/sync";
|
|
220
|
+
import type { RequestEvent, RequestHandler } from "@sveltejs/kit";
|
|
221
|
+
|
|
222
|
+
export const GET: RequestHandler = (event: RequestEvent) => {
|
|
223
|
+
return handleUpgrade(event.request, event.platform);
|
|
224
|
+
};
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
### Step 4: Svelte Config & Cloudflare Platform Entrypoint
|
|
230
|
+
|
|
231
|
+
Configure `@joshthomas/sveltekit-adapter-cloudflare` in your `svelte.config.js`:
|
|
232
|
+
|
|
233
|
+
```javascript
|
|
234
|
+
// svelte.config.js
|
|
235
|
+
import adapter from "@joshthomas/sveltekit-adapter-cloudflare";
|
|
236
|
+
|
|
237
|
+
export default {
|
|
238
|
+
kit: {
|
|
239
|
+
adapter: adapter({
|
|
240
|
+
platform: "src/platform.cloudflare.ts" // Platform config file
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Create `src/platform.cloudflare.ts` to export your Durable Object `SyncEngine` class:
|
|
247
|
+
|
|
248
|
+
```typescript
|
|
249
|
+
// src/platform.cloudflare.ts
|
|
250
|
+
import { SyncEngineBase } from "@sveltebase/sync/server";
|
|
251
|
+
import { handlers } from "./lib/server/sync-handlers.js";
|
|
252
|
+
|
|
253
|
+
// Export the Durable Object class compiled into the worker
|
|
254
|
+
export class SyncEngine extends SyncEngineBase {
|
|
255
|
+
constructor(ctx: DurableObjectState, env: Env) {
|
|
256
|
+
super(ctx, env, handlers);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
### Step 5: Vite Dev Plugin Setup
|
|
264
|
+
|
|
265
|
+
In Vite development mode, Durable Objects are not natively available. We provide a Vite plugin (`syncDevPlugin`) that intercepts upgrades and emulates the DO synchronization broker locally in Node.js.
|
|
266
|
+
|
|
267
|
+
Configure `vite.config.ts`:
|
|
268
|
+
|
|
269
|
+
```typescript
|
|
270
|
+
// vite.config.ts
|
|
271
|
+
import { sveltekit } from "@sveltejs/kit/vite";
|
|
272
|
+
import { defineConfig } from "vite";
|
|
273
|
+
import { syncDevPlugin } from "@sveltebase/sync/vite";
|
|
274
|
+
|
|
275
|
+
export default defineConfig({
|
|
276
|
+
plugins: [
|
|
277
|
+
syncDevPlugin({
|
|
278
|
+
// Path to your sync handlers. Uses ssrLoadModule so SvelteKit
|
|
279
|
+
// path aliases (like $lib) resolve perfectly at runtime.
|
|
280
|
+
handlersPath: "$lib/server/sync-handlers"
|
|
281
|
+
}),
|
|
282
|
+
sveltekit()
|
|
283
|
+
]
|
|
284
|
+
});
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
## 3. Local Development Features
|
|
290
|
+
|
|
291
|
+
### Automatic Bindings Proxy
|
|
292
|
+
During development (`vite dev`), the dev engine uses Wrangler's programmatic Node API `getPlatformProxy()` under the hood. It caches the proxy on `globalThis` to survive Vite HMR reloads.
|
|
293
|
+
|
|
294
|
+
Both SvelteKit and the dev WebSocket server share the **exact same emulated D1 database instance** automatically.
|
|
295
|
+
|
|
296
|
+
### Message Buffering
|
|
297
|
+
Vite's module loading is asynchronous. When upgrading WebSocket connections, the plugin buffers incoming WebSocket frames during the module import phase. Once modules have fully loaded and handlers are registered, it replays the buffered messages to avoid connection race conditions.
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
## 4. Security, Authorization & Scoping
|
|
302
|
+
|
|
303
|
+
### Handshake HTTP Context (Cookies & Headers)
|
|
304
|
+
When the WebSocket connection is established, the HTTP upgrade request's headers, cookies, and query parameters are captured.
|
|
305
|
+
|
|
306
|
+
This context is preserved and passed to every sync handler execution (`fetch`, `create`, `update`, `delete`, `authorize`, `scope`) via the **`ctx.request`** object. Developers can parse session cookies or credentials inside mutations and queries:
|
|
307
|
+
|
|
308
|
+
```typescript
|
|
309
|
+
// Helper to extract session profile from handshake request
|
|
310
|
+
async function getSession(ctx: SyncContext) {
|
|
311
|
+
const cookie = ctx.request.headers.get("Cookie");
|
|
312
|
+
const db = getDB(ctx.platform);
|
|
313
|
+
// Perform session verification/DB lookup...
|
|
314
|
+
return { userId: "usr_123", role: "admin" };
|
|
315
|
+
}
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
### The `authorize` Hook
|
|
321
|
+
The `authorize` hook acts as a guard. It runs synchronously on the server when a client attempts to **subscribe** to a channel or submit a **mutation** (create, update, delete). If it throws an error, the operation is rejected and rolled back.
|
|
322
|
+
|
|
323
|
+
```typescript
|
|
324
|
+
authorize: async (ctx) => {
|
|
325
|
+
const user = await getSession(ctx);
|
|
326
|
+
if (!user) {
|
|
327
|
+
throw new Error("Unauthorized access to channel");
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
---
|
|
333
|
+
|
|
334
|
+
### Throwing & Filtering in Handlers (CRUD Operations)
|
|
335
|
+
|
|
336
|
+
Beyond the global `authorize` hook, you can enforce security directly inside your query (`fetch`) and mutation (`create`, `update`, `delete`) handlers:
|
|
337
|
+
|
|
338
|
+
#### 1. Filtering on Read (`fetch`)
|
|
339
|
+
Use the handshake HTTP request (`ctx.request`) to dynamically filter the records fetched from the database, preventing users from pulling unauthorized rows.
|
|
340
|
+
|
|
341
|
+
```typescript
|
|
342
|
+
fetch: async (ctx, since) => {
|
|
343
|
+
const db = getDB(ctx.platform);
|
|
344
|
+
const user = await getSession(ctx);
|
|
345
|
+
|
|
346
|
+
let query = db.select().from(todos);
|
|
347
|
+
const conditions = [];
|
|
348
|
+
|
|
349
|
+
// Enforce read boundaries
|
|
350
|
+
if (user.role !== "admin") {
|
|
351
|
+
conditions.push(eq(todos.published, true)); // Non-admins only read published todos
|
|
352
|
+
}
|
|
353
|
+
if (since) {
|
|
354
|
+
conditions.push(gt(todos.updatedAt, since)); // Apply delta sync timestamp
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (conditions.length > 0) {
|
|
358
|
+
query = query.where(and(...conditions));
|
|
359
|
+
}
|
|
360
|
+
return await query;
|
|
361
|
+
}
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
#### 2. Write & Delete Handlers (Optional)
|
|
365
|
+
The `create`, `update`, and `delete` handlers are optional. If you omit any of these handlers, Sveltebase Sync treats the channel as read-only for that operation and will automatically reject any incoming client mutations.
|
|
366
|
+
|
|
367
|
+
If you *do* define them, you can throw regular JavaScript/TypeScript errors inside your mutation handlers. When an error is thrown:
|
|
368
|
+
1. The server catches the error and rejects the mutation.
|
|
369
|
+
2. The server sends a rejection response back to the client.
|
|
370
|
+
3. The client receives the rejection, triggers the `rollback` function, and reverts the optimistic UI change in IndexedDB.
|
|
371
|
+
|
|
372
|
+
```typescript
|
|
373
|
+
create: async (ctx, data) => {
|
|
374
|
+
const user = await getSession(ctx);
|
|
375
|
+
|
|
376
|
+
// Guard write action
|
|
377
|
+
if (user.role !== "editor" && user.role !== "admin") {
|
|
378
|
+
throw new Error("You do not have permission to create items.");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const db = getDB(ctx.platform);
|
|
382
|
+
const [created] = await db.insert(todos).values(data).returning();
|
|
383
|
+
return created;
|
|
384
|
+
},
|
|
385
|
+
|
|
386
|
+
update: async (ctx, key, changes) => {
|
|
387
|
+
const user = await getSession(ctx);
|
|
388
|
+
const db = getDB(ctx.platform);
|
|
389
|
+
|
|
390
|
+
// Fetch target record to verify ownership
|
|
391
|
+
const [record] = await db.select().from(todos).where(eq(todos.id, key));
|
|
392
|
+
if (record.ownerId !== user.userId && user.role !== "admin") {
|
|
393
|
+
throw new Error("You cannot update a record owned by someone else.");
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const [updated] = await db.update(todos).set(changes).where(eq(todos.id, key)).returning();
|
|
397
|
+
return updated;
|
|
398
|
+
},
|
|
399
|
+
|
|
400
|
+
delete: async (ctx, key) => {
|
|
401
|
+
const user = await getSession(ctx);
|
|
402
|
+
|
|
403
|
+
// Guard delete action
|
|
404
|
+
if (user.role !== "admin") {
|
|
405
|
+
throw new Error("Only admins can delete items.");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const db = getDB(ctx.platform);
|
|
409
|
+
await db.delete(todos).where(eq(todos.id, key));
|
|
410
|
+
}
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
---
|
|
414
|
+
|
|
415
|
+
### The `scope` Hook (Row-Level Broadcast Filtering)
|
|
416
|
+
The `scope` hook determines which of the connected and subscribed clients should receive real-time notifications when a database record is modified. It runs asynchronously after a mutation succeeds on the database.
|
|
417
|
+
|
|
418
|
+
> [!CAUTION]
|
|
419
|
+
> **Security Warning:** If you omit the `scope` hook, Sveltebase Sync defaults to broadcasting mutations to `"all"` subscribed connections.
|
|
420
|
+
> If your channel contains user-private data (meaning you filter by user ID inside the `fetch` handler), you **must** also define a `scope` hook that returns the user ID of the owner: `scope: (ctx, action, data) => [data.userId]`. Otherwise, a user's private updates will be broadcast to all connected users in real time.
|
|
421
|
+
|
|
422
|
+
* Return **`"all"`** to broadcast the change to every client subscribed to the channel.
|
|
423
|
+
* Return an **array of user IDs** (`string[]`) to restrict the broadcast. The broker will match these IDs against the connection's registered identity and skip broadcasting to everyone else.
|
|
424
|
+
|
|
425
|
+
```typescript
|
|
426
|
+
export const todoSync = defineSync<Todo>({
|
|
427
|
+
channel: "todos",
|
|
428
|
+
|
|
429
|
+
// Runs when a todo changes. Returns list of user IDs allowed to see this update
|
|
430
|
+
scope: async (ctx, action, data) => {
|
|
431
|
+
const db = getDB(ctx.platform);
|
|
432
|
+
|
|
433
|
+
// 1. Public records are broadcasted to all subscribers
|
|
434
|
+
if (data.published) {
|
|
435
|
+
return "all";
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// 2. Draft/Private records are only broadcasted to admins
|
|
439
|
+
const admins = await db
|
|
440
|
+
.select({ id: users.id })
|
|
441
|
+
.from(users)
|
|
442
|
+
.where(eq(users.role, "admin"));
|
|
443
|
+
|
|
444
|
+
return admins.map((admin) => admin.id);
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
#### How Connection Identities are Registered
|
|
450
|
+
The broker matches the user IDs returned by `scope` to each active client's socket state. The server registers the connection's identity during the handshake using query parameters or the `x-user-id` header:
|
|
451
|
+
```typescript
|
|
452
|
+
const userId = url.searchParams.get("userId") || request.headers.get("x-user-id");
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
##### Authenticating using SvelteKit Sessions & Cookies (Recommended)
|
|
456
|
+
Instead of exposing user IDs in client-side WebSocket URLs, you can resolve the user session on the server inside your SvelteKit route (`+server.ts`) and inject the verified `x-user-id` header before calling `handleUpgrade()`:
|
|
457
|
+
|
|
458
|
+
```typescript
|
|
459
|
+
// src/routes/api/sync/+server.ts
|
|
460
|
+
import { handleUpgrade } from "@sveltebase/sync";
|
|
461
|
+
import type { RequestEvent, RequestHandler } from "@sveltejs/kit";
|
|
462
|
+
|
|
463
|
+
export const GET: RequestHandler = (event: RequestEvent) => {
|
|
464
|
+
// 1. Get user identity from your custom server-side session/cookies
|
|
465
|
+
const user = event.locals.user; // e.g., set by your auth hook middleware
|
|
466
|
+
if (!user) {
|
|
467
|
+
return new Response("Unauthorized", { status: 401 });
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// 2. Clone the request and inject the verified user ID header
|
|
471
|
+
const request = new Request(event.request);
|
|
472
|
+
request.headers.set("x-user-id", user.id);
|
|
473
|
+
|
|
474
|
+
// 3. Hand off to the sync engine
|
|
475
|
+
return handleUpgrade(request, event.platform);
|
|
476
|
+
};
|
|
477
|
+
```
|
|
478
|
+
This approach keeps WebSocket URLs clean of private IDs and ensures all active sockets are automatically authenticated with their verified session roles/IDs.
|
|
479
|
+
|
|
480
|
+
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import Dexie, { type Table } from "dexie";
|
|
2
|
+
import { useLiveQuery, type LiveQueryResult } from "./live.svelte.js";
|
|
3
|
+
export { useLiveQuery, type LiveQueryResult };
|
|
4
|
+
export type TableConfig = {
|
|
5
|
+
indexes: string;
|
|
6
|
+
channel: string;
|
|
7
|
+
};
|
|
8
|
+
export type SyncClientOptions = {
|
|
9
|
+
name: string;
|
|
10
|
+
url: string;
|
|
11
|
+
tables: Record<string, TableConfig>;
|
|
12
|
+
};
|
|
13
|
+
export declare class SyncClient<TSchema extends Record<string, any> = Record<string, any>> {
|
|
14
|
+
db: Dexie;
|
|
15
|
+
private wsUrl;
|
|
16
|
+
private socket;
|
|
17
|
+
private tableConfigs;
|
|
18
|
+
private reconnectTimer;
|
|
19
|
+
private pingInterval;
|
|
20
|
+
private closedByClient;
|
|
21
|
+
private activeChannels;
|
|
22
|
+
private pendingMutations;
|
|
23
|
+
private mutationQueue;
|
|
24
|
+
constructor(options: {
|
|
25
|
+
name: string;
|
|
26
|
+
url: string;
|
|
27
|
+
tables: Record<keyof TSchema & string, TableConfig>;
|
|
28
|
+
});
|
|
29
|
+
private connect;
|
|
30
|
+
private startHeartbeat;
|
|
31
|
+
private stopHeartbeat;
|
|
32
|
+
private subscribeToChannel;
|
|
33
|
+
private flushMutationQueue;
|
|
34
|
+
private safePutRow;
|
|
35
|
+
private safeDeleteRow;
|
|
36
|
+
private handleServerMessage;
|
|
37
|
+
private findTableByChannel;
|
|
38
|
+
table<TKey extends keyof TSchema & string>(tableName: TKey): {
|
|
39
|
+
liveQuery: <TResult = TSchema[TKey][]>(queryFn: (table: Table<TSchema[TKey], string>) => Promise<TResult> | TResult) => LiveQueryResult<TResult>;
|
|
40
|
+
add: (row: TSchema[TKey]) => Promise<TSchema[TKey]>;
|
|
41
|
+
put: (id: string, changes: Partial<TSchema[TKey]>) => Promise<TSchema[TKey]>;
|
|
42
|
+
delete: (id: string) => Promise<void>;
|
|
43
|
+
};
|
|
44
|
+
private enqueueMutation;
|
|
45
|
+
disconnect(): void;
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/client/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAE,KAAK,KAAK,EAAE,MAAM,OAAO,CAAC;AAE1C,OAAO,EAAE,YAAY,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAEtE,OAAO,EAAE,YAAY,EAAE,KAAK,eAAe,EAAE,CAAC;AAE9C,MAAM,MAAM,WAAW,GAAG;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;CACrC,CAAC;AAaF,qBAAa,UAAU,CACrB,OAAO,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC;IAElD,EAAE,EAAE,KAAK,CAAC;IACjB,OAAO,CAAC,KAAK,CAAS;IACtB,OAAO,CAAC,MAAM,CAAwB;IACtC,OAAO,CAAC,YAAY,CAA8B;IAClD,OAAO,CAAC,cAAc,CAA4C;IAClE,OAAO,CAAC,YAAY,CAA6C;IACjE,OAAO,CAAC,cAAc,CAAS;IAC/B,OAAO,CAAC,cAAc,CAAqB;IAG3C,OAAO,CAAC,gBAAgB,CAAsC;IAE9D,OAAO,CAAC,aAAa,CAMb;gBAEI,OAAO,EAAE;QACnB,IAAI,EAAE,MAAM,CAAC;QACb,GAAG,EAAE,MAAM,CAAC;QACZ,MAAM,EAAE,MAAM,CAAC,MAAM,OAAO,GAAG,MAAM,EAAE,WAAW,CAAC,CAAC;KACrD;IAiBD,OAAO,CAAC,OAAO;IA+Df,OAAO,CAAC,cAAc;IAStB,OAAO,CAAC,aAAa;YAOP,kBAAkB;IAoBhC,OAAO,CAAC,kBAAkB;YAkBZ,UAAU;YAgBV,aAAa;YAoBb,mBAAmB;IAkEjC,OAAO,CAAC,kBAAkB;IAOnB,KAAK,CAAC,IAAI,SAAS,MAAM,OAAO,GAAG,MAAM,EAAE,SAAS,EAAE,IAAI;oBASjD,OAAO,6BACR,CAAC,KAAK,EAAE,KAAK,gBAAO,MAAM,CAAC,KAAK,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO;qCAK7C,OAAO,eAAM;kBAsBrB,MAAM,WAAW,OAAO,eAAM,KAAG,OAAO,eAAM;qBAyB3C,MAAM,KAAG,OAAO,CAAC,IAAI,CAAC;;IAuB7C,OAAO,CAAC,eAAe;IAqChB,UAAU;CAYlB"}
|