@jcbuisson/express-x-client 3.0.5 → 3.1.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/CLAUDE.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`@jcbuisson/express-x-client` is the browser-side client library for the ExpressX framework. It wraps a socket.io socket and provides service proxies, pub/sub, and optional offline-first sync. The entire library lives in a single file: `src/client.mts`.
|
|
8
|
+
|
|
9
|
+
The package is ESM-only (`"type": "module"`). The `main` field in `package.json` points directly to `src/client.mts` — there is no compilation or build step. No build, lint, or test scripts are defined.
|
|
10
|
+
|
|
11
|
+
The file uses TypeScript syntax (type annotations) despite the absence of a `tsconfig.json`.
|
|
12
|
+
|
|
13
|
+
## Architecture
|
|
14
|
+
|
|
15
|
+
The library exports three main factory functions and one utility class that follow a plugin composition pattern:
|
|
16
|
+
|
|
17
|
+
### `createClient(socket, options)`
|
|
18
|
+
Core factory. Wraps a socket.io `socket` and returns an `app` object. Communication uses two custom socket events:
|
|
19
|
+
- `client-request` / `client-response` — request/response using socket.io acknowledgments to correlate responses to waiting promises
|
|
20
|
+
- `service-event` — server-to-client pub/sub notifications
|
|
21
|
+
- `app-event` — application-wide broadcast events (outside any service)
|
|
22
|
+
|
|
23
|
+
`app.configure(callback)` is the standard plugin composition hook — it calls `callback(app)` and is how plugins extend the app.
|
|
24
|
+
|
|
25
|
+
The `service(name, serviceOptions)` method returns a `Proxy` that intercepts any property access and turns it into a `serviceMethodRequest` call, so callers can write `app.service('user').findMany(...)` without pre-declaring methods. `serviceOptions` supports:
|
|
26
|
+
- `timeout` (default 20000 ms) — socket acknowledgment timeout
|
|
27
|
+
- `volatile: true` — uses `socket.volatile` (fire-and-forget, drops if disconnected)
|
|
28
|
+
|
|
29
|
+
### `reloadPlugin(app)`
|
|
30
|
+
Enriches `app` with page-reload session continuity. On reconnect it emits `cnx-transfer` carrying the previous socket ID (persisted in `sessionStorage` via `@vueuse/core`'s `useSessionStorage`) so the server can migrate state.
|
|
31
|
+
|
|
32
|
+
### `offlinePlugin(app)`
|
|
33
|
+
Enriches `app` with offline-first IndexedDB CRUD via Dexie. Call `app.createOfflineModel(modelName, fields)` to get a model object.
|
|
34
|
+
|
|
35
|
+
This plugin also adds three dynamic attributes to `app`:
|
|
36
|
+
- `app.isConnected` — boolean, updated on socket connect/disconnect
|
|
37
|
+
- `app.connectedDate` — `Date` of the last connection, or `null`
|
|
38
|
+
- `app.disconnectedDate` — `Date` of the last disconnection, or `null` (used as `cutoffDate` for sync)
|
|
39
|
+
|
|
40
|
+
Each model maintains three Dexie stores under the same DB name:
|
|
41
|
+
- `values` — the actual records (indexed on `uid`, `__deleted__`)
|
|
42
|
+
- `metadata` — per-record `created_at / updated_at / deleted_at`
|
|
43
|
+
- `whereList` — set of active `where` filters to scope synchronization
|
|
44
|
+
|
|
45
|
+
`createOfflineModel` auto-registers pub/sub handlers for the service named `modelName`:
|
|
46
|
+
- `createWithMeta` / `updateWithMeta` / `deleteWithMeta` — keep the local cache in sync with server-pushed events
|
|
47
|
+
|
|
48
|
+
**Optimistic writes**: `create`, `update`, `remove` write to IndexedDB immediately, then call the server service method. On server error they roll back the local change.
|
|
49
|
+
|
|
50
|
+
**Sync on reconnect**: When the socket reconnects, every registered model calls `synchronizeAll`, which iterates its `whereList` and calls the server's `sync.go(modelName, where, cutoffDate, clientMetadataDict)` service. The response contains five buckets (`addClient`, `updateClient`, `deleteClient`, `addDatabase`, `updateDatabase`) that are applied in order.
|
|
51
|
+
|
|
52
|
+
**Real-time observables**: `getObservable(where)` returns an RxJS `Observable` backed by Dexie's `liveQuery`. It also registers the `where` in `whereList` and triggers a sync if it is a new, unregistered filter. Vue component lifecycle cleanup is handled by `tryOnScopeDispose`.
|
|
53
|
+
|
|
54
|
+
A shared `Mutex` serializes all sync and `whereList` mutations to avoid concurrent IndexedDB race conditions.
|
|
55
|
+
|
|
56
|
+
### `where` filter syntax
|
|
57
|
+
Used throughout for querying local cache and scoping server sync:
|
|
58
|
+
- Equality: `{ field: value }`
|
|
59
|
+
- Range: `{ field: { lt, lte, gt, gte } }` — `null`/`undefined` fields never satisfy range clauses (matches SQL NULL semantics)
|
|
60
|
+
|
|
61
|
+
### Utilities
|
|
62
|
+
- `Mutex` — exported; simple async mutex backed by a promise queue
|
|
63
|
+
- `wherePredicate(where)` — (module-private) turns a `where` object into a filter function
|
|
64
|
+
- `isSubset` / `isSubsetAmong` — (module-private) checks if a `where` is covered by an existing entry in `whereList` (avoids redundant syncs)
|
|
65
|
+
- `stringifyWithSortedKeys` — (module-private) deterministic JSON stringify used as canonical `whereList` keys
|
|
66
|
+
- `generateUID(length)` — (module-private) alphanumeric random string used to correlate request/response pairs
|
|
67
|
+
|
|
68
|
+
## Notes
|
|
69
|
+
- `uuidv7` is used for client-side record IDs (monotonically increasing, good for B-tree indexes).
|
|
70
|
+
- The `prisma/` directory with a SQLite schema is an unrelated artifact and not part of the library.
|
|
71
|
+
- All imported packages (`dexie`, `rxjs`, `uuid`, `@vueuse/core`) are consumed by the library but are not listed as `dependencies` — they are expected to be provided by the consuming application.
|
package/package.json
CHANGED
package/src/client.mts
CHANGED
|
@@ -13,7 +13,6 @@ import { useSessionStorage } from '@vueuse/core'
|
|
|
13
13
|
export function createClient(socket, options={}) {
|
|
14
14
|
if (options.debug === undefined) options.debug = false
|
|
15
15
|
|
|
16
|
-
const waitingPromisesByUid = {}
|
|
17
16
|
const action2service2handlers = {}
|
|
18
17
|
const type2appHandler = {}
|
|
19
18
|
let connectListeners = []
|
|
@@ -66,19 +65,6 @@ export function createClient(socket, options={}) {
|
|
|
66
65
|
errorListeners = errorListeners.filter(f => f !== func)
|
|
67
66
|
}
|
|
68
67
|
|
|
69
|
-
// on receiving response from service request
|
|
70
|
-
socket.on('client-response', ({ uid, error, result }) => {
|
|
71
|
-
if (options.debug) console.log('client-response', uid, error, result)
|
|
72
|
-
if (!waitingPromisesByUid[uid]) return // may not exist because a timeout removed it
|
|
73
|
-
const [resolve, reject] = waitingPromisesByUid[uid]
|
|
74
|
-
if (error) {
|
|
75
|
-
reject(error)
|
|
76
|
-
} else {
|
|
77
|
-
resolve(result)
|
|
78
|
-
}
|
|
79
|
-
delete waitingPromisesByUid[uid]
|
|
80
|
-
})
|
|
81
|
-
|
|
82
68
|
// on receiving service events from pub/sub
|
|
83
69
|
socket.on('service-event', ({ name, action, result }) => {
|
|
84
70
|
if (options.debug) console.log('service-event', name, action, result)
|
|
@@ -89,29 +75,14 @@ export function createClient(socket, options={}) {
|
|
|
89
75
|
})
|
|
90
76
|
|
|
91
77
|
async function serviceMethodRequest(name, action, serviceOptions, ...args) {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
reject(`Error: timeout on service '${name}', action '${action}', args: ${JSON.stringify(args)}`)
|
|
101
|
-
}, serviceOptions.timeout)
|
|
102
|
-
if (timer.unref) timer.unref(); // so it doesn't prevent process exit for tests (Node.js only — no-op in browsers)
|
|
103
|
-
}
|
|
104
|
-
})
|
|
105
|
-
// send request to server through websocket
|
|
106
|
-
if (options.debug) console.log('client-request', uid, name, action, args)
|
|
107
|
-
if (serviceOptions.volatile) {
|
|
108
|
-
// event is not sent if connection is not active
|
|
109
|
-
socket.volatile.emit('client-request', { uid, name, action, args, })
|
|
110
|
-
} else {
|
|
111
|
-
// event is buffered if connection is not active (default)
|
|
112
|
-
socket.emit('client-request', { uid, name, action, args, })
|
|
113
|
-
}
|
|
114
|
-
return promise
|
|
78
|
+
if (options.debug) console.log('client-request', name, action, args)
|
|
79
|
+
// use socket.io acknowledgment for request/response correlation
|
|
80
|
+
const emitter = serviceOptions.volatile
|
|
81
|
+
? socket.volatile
|
|
82
|
+
: socket.timeout(serviceOptions.timeout || 20000)
|
|
83
|
+
const { error, result } = await emitter.emitWithAck('client-request', { name, action, args })
|
|
84
|
+
if (error) throw error
|
|
85
|
+
return result
|
|
115
86
|
}
|
|
116
87
|
|
|
117
88
|
function service(name, serviceOptions={}) {
|
|
@@ -569,16 +540,6 @@ export function offlinePlugin(app) {
|
|
|
569
540
|
|
|
570
541
|
////////////////////////// UTILITIES //////////////////////////
|
|
571
542
|
|
|
572
|
-
function generateUID(length) {
|
|
573
|
-
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
|
574
|
-
let uid = ''
|
|
575
|
-
|
|
576
|
-
for (let i = 0; i < length; i++) {
|
|
577
|
-
const randomIndex = Math.floor(Math.random() * characters.length)
|
|
578
|
-
uid += characters.charAt(randomIndex)
|
|
579
|
-
}
|
|
580
|
-
return uid
|
|
581
|
-
}
|
|
582
543
|
|
|
583
544
|
function stringifyWithSortedKeys(obj, space = null) {
|
|
584
545
|
return JSON.stringify(obj, (key, value) => {
|
package/prisma/dev.db
DELETED
|
Binary file
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
-- CreateTable
|
|
2
|
-
CREATE TABLE "User" (
|
|
3
|
-
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
|
4
|
-
"name" TEXT NOT NULL,
|
|
5
|
-
"email" TEXT NOT NULL
|
|
6
|
-
);
|
|
7
|
-
|
|
8
|
-
-- CreateTable
|
|
9
|
-
CREATE TABLE "Post" (
|
|
10
|
-
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
|
11
|
-
"text" TEXT NOT NULL,
|
|
12
|
-
"authorId" INTEGER NOT NULL,
|
|
13
|
-
CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
|
14
|
-
);
|
|
15
|
-
|
|
16
|
-
-- CreateIndex
|
|
17
|
-
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
package/prisma/schema.prisma
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
generator client {
|
|
2
|
-
provider = "prisma-client-js"
|
|
3
|
-
}
|
|
4
|
-
|
|
5
|
-
model User {
|
|
6
|
-
id Int @default(autoincrement()) @id
|
|
7
|
-
name String
|
|
8
|
-
email String @unique
|
|
9
|
-
posts Post[]
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
model Post {
|
|
13
|
-
id Int @default(autoincrement()) @id
|
|
14
|
-
text String
|
|
15
|
-
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
|
16
|
-
authorId Int
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
datasource db {
|
|
20
|
-
provider = "sqlite"
|
|
21
|
-
url = "file:./dev.db"
|
|
22
|
-
}
|