@jcbuisson/express-x 3.0.2 → 3.0.4
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/settings.local.json +11 -0
- package/CLAUDE.md +109 -0
- package/package.json +21 -3
- package/src/server.mjs +60 -2
- package/test/round-trip.test.mjs +1368 -0
- package/test/sync.test.mjs +127 -0
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Project Overview
|
|
6
|
+
|
|
7
|
+
**express-x** is a full-stack framework that unifies backend and frontend communication through WebSocket-based services. It's published as `@jcbuisson/express-x` on npm.
|
|
8
|
+
|
|
9
|
+
Core concept: ExpressX is a thin wrapper around Express.js and Socket.io that provides a service-oriented, real-time RPC pattern. Clients call server-side service methods via WebSocket and receive results as promises. Services can publish events to channels, enabling pub/sub-style real-time updates.
|
|
10
|
+
|
|
11
|
+
**Repository**: https://github.com/jcbuisson/express-x
|
|
12
|
+
**Documentation**: https://expressx.jcbuisson.dev
|
|
13
|
+
**Version**: 3.0.3 (ES modules)
|
|
14
|
+
|
|
15
|
+
## Architecture
|
|
16
|
+
|
|
17
|
+
### Single Entry Point
|
|
18
|
+
- `/src/server.mjs` (625 lines) is the complete framework implementation. This is the only source file.
|
|
19
|
+
- Exports the main `expressX()` factory function and various utilities/plugins.
|
|
20
|
+
|
|
21
|
+
### Core Concepts
|
|
22
|
+
|
|
23
|
+
1. **Services**: Named groups of callable methods. Created via `app.createService(name, methods)`. Each method receives a `context` object containing metadata about the call (transport, socket, service/method names, args).
|
|
24
|
+
|
|
25
|
+
2. **Context Object**: Contains execution metadata:
|
|
26
|
+
- `app`, `socket`, `serviceName`, `methodName`, `args`, `result`
|
|
27
|
+
- `caller` ('client' or 'server'), `transport` ('ws')
|
|
28
|
+
- Modified by hooks before/after method execution
|
|
29
|
+
|
|
30
|
+
3. **Hooks**: Pre/post-execution functions that can modify context or validate requests. Attached to services via `service.hooks()`. Support method-specific and "all methods" variants:
|
|
31
|
+
```js
|
|
32
|
+
service.hooks({
|
|
33
|
+
before: { methodName: [hook1, hook2], all: [globalHook] },
|
|
34
|
+
after: { methodName: [hook], all: [globalHook] }
|
|
35
|
+
})
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
4. **Channels & Pub/Sub**: Enable real-time broadcasting:
|
|
39
|
+
- Channels are Socket.io rooms with names
|
|
40
|
+
- Services attach a `publishFunction` via `service.publish()` that determines which channels receive events after method execution
|
|
41
|
+
- Clients join/subscribe to channels via `app.joinChannel(name, socket)`
|
|
42
|
+
- Events are published as `service-event` messages containing `{name, action, result}`
|
|
43
|
+
|
|
44
|
+
5. **WebSocket Protocol**: Single `client-request` event from client → `client-response` event from server, with uid-based request/response correlation. Errors are serialized and sent back to client.
|
|
45
|
+
|
|
46
|
+
### Key Plugins
|
|
47
|
+
|
|
48
|
+
- **reloadPlugin**: Manages connection recovery across page reloads/reconnects by caching socket data and rooms.
|
|
49
|
+
- **offlinePlugin**: Implements offline-first CRUD operations with Prisma. Provides `sync.go()` service method that reconciles client cache with database using timestamps (created_at, updated_at, deleted_at).
|
|
50
|
+
|
|
51
|
+
### Common Hook Patterns (Exported Utilities)
|
|
52
|
+
- `addTimestamp(field)`: Adds ISO timestamp to result
|
|
53
|
+
- `hashPassword(field)`: Hashes password using bcryptjs
|
|
54
|
+
- `protect(field)`: Removes sensitive fields from results
|
|
55
|
+
|
|
56
|
+
### Error Handling
|
|
57
|
+
- `EXError(code, message)`: Custom error class with error codes. Method errors are caught, serialized, and sent to client as rejected promises.
|
|
58
|
+
|
|
59
|
+
## Development Commands
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# Install dependencies
|
|
63
|
+
npm install
|
|
64
|
+
|
|
65
|
+
# Prisma (optional, if using database)
|
|
66
|
+
npm run generate # Generate Prisma client
|
|
67
|
+
npm run migrate # Run Prisma migrations
|
|
68
|
+
|
|
69
|
+
# No dedicated test, build, or lint scripts
|
|
70
|
+
# Manual testing via client-server examples in README
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Key Dependencies
|
|
74
|
+
- **express** (^4.18.2): HTTP server framework
|
|
75
|
+
- **socket.io** (^4.6.0): WebSocket server
|
|
76
|
+
- **bcryptjs** (^2.4.3): Password hashing
|
|
77
|
+
- **config** (^3.3.9): Configuration management
|
|
78
|
+
- Peer: Prisma for database CRUD services (optional)
|
|
79
|
+
|
|
80
|
+
## File Structure
|
|
81
|
+
```
|
|
82
|
+
express-x/
|
|
83
|
+
src/server.mjs # Complete framework (625 lines)
|
|
84
|
+
package.json # v3.0.3, ES module type
|
|
85
|
+
README.md # Getting started + examples
|
|
86
|
+
.vscode/launch.json # Debug config
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Implementation Notes
|
|
90
|
+
|
|
91
|
+
1. **Context Execution**: Service methods are wrapped with hook execution:
|
|
92
|
+
- All `before.all` hooks → method-specific `before` hooks → method execution → method-specific `after` hooks → all `after.all` hooks
|
|
93
|
+
- Hooks receive context and can throw errors (which propagate to client)
|
|
94
|
+
|
|
95
|
+
2. **Event Publishing**: After a service method completes, `publishFunction` is called with the context. It returns an array of channel names. The result is broadcast to all sockets in those channels as a `service-event`.
|
|
96
|
+
|
|
97
|
+
3. **Connection State**: Socket.io's built-in `socket.data` object stores per-connection state. Socket.io's connection recovery feature (2-minute default) restores socket.id, rooms, and data on network reconnects.
|
|
98
|
+
|
|
99
|
+
4. **Offline Sync**: The `offlinePlugin` uses transaction-safe Prisma operations and a mutex to ensure consistent synchronization. Clients track operation timestamps and the sync algorithm compares created_at/updated_at to determine add/update/delete operations.
|
|
100
|
+
|
|
101
|
+
5. **Logging**: App-level logging via `app.log(severity, message)`. If no logger is configured, falls back to console.log.
|
|
102
|
+
|
|
103
|
+
## Configuration
|
|
104
|
+
|
|
105
|
+
ExpressX accepts an optional config object (first parameter to `expressX(config)`):
|
|
106
|
+
- `config.WS_PATH` (default: '/socket.io/'): Socket.io path
|
|
107
|
+
- `config.maxDisconnectionDuration` (default: 2 min): Connection recovery window
|
|
108
|
+
- Any config is accessible server-side via `app.get('config')`
|
|
109
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jcbuisson/express-x",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.4",
|
|
4
4
|
"description": "",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/server.mjs",
|
|
@@ -11,9 +11,14 @@
|
|
|
11
11
|
"author": "Jean-Christophe Buisson <buisson@enseeiht.fr> (jcbuisson.dev)",
|
|
12
12
|
"license": "MIT",
|
|
13
13
|
"private": false,
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"imports": {
|
|
18
|
+
"#root/*": "./*"
|
|
19
|
+
},
|
|
14
20
|
"scripts": {
|
|
15
|
-
"
|
|
16
|
-
"migrate": "npx prisma migrate dev --name init"
|
|
21
|
+
"test": "node --import tsx/esm --test --test-force-exit test/round-trip.test.mjs"
|
|
17
22
|
},
|
|
18
23
|
"keywords": [],
|
|
19
24
|
"dependencies": {
|
|
@@ -21,5 +26,18 @@
|
|
|
21
26
|
"config": "^3.3.9",
|
|
22
27
|
"express": "^4.18.2",
|
|
23
28
|
"socket.io": "^4.6.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@electric-sql/pglite": "^0.4.5",
|
|
32
|
+
"@jcbuisson/express-x-client": "^3.0.5",
|
|
33
|
+
"@jcbuisson/express-x-drizzle": "^1.0.5",
|
|
34
|
+
"@vueuse/core": "^14.3.0",
|
|
35
|
+
"dexie": "^4.4.2",
|
|
36
|
+
"drizzle-orm": "^0.45.2",
|
|
37
|
+
"fake-indexeddb": "^6.2.5",
|
|
38
|
+
"rxjs": "^7.8.2",
|
|
39
|
+
"socket.io-client": "^4.8.3",
|
|
40
|
+
"tsx": "^4.22.1",
|
|
41
|
+
"uuid": "^14.0.0"
|
|
24
42
|
}
|
|
25
43
|
}
|
package/src/server.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import bcrypt from 'bcryptjs'
|
|
|
13
13
|
|
|
14
14
|
////////////////////////// UTILITIES //////////////////////////
|
|
15
15
|
|
|
16
|
-
function truncateString(str, maxLength = 300, ellipsis = '...') {
|
|
16
|
+
export function truncateString(str, maxLength = 300, ellipsis = '...') {
|
|
17
17
|
// Check if the string already fits
|
|
18
18
|
if (str.length <= maxLength) return str;
|
|
19
19
|
// Calculate the cut-off point, accounting for the ellipsis length
|
|
@@ -25,7 +25,7 @@ function truncateString(str, maxLength = 300, ellipsis = '...') {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
|
|
28
|
-
class Mutex {
|
|
28
|
+
export class Mutex {
|
|
29
29
|
constructor() {
|
|
30
30
|
this.locked = false;
|
|
31
31
|
this.queue = [];
|
|
@@ -48,6 +48,64 @@ class Mutex {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
////////////////////////// SYNC ALGORITHM (common to all offline plugins) //////////////////////////
|
|
52
|
+
|
|
53
|
+
export function computeSyncResult(databaseValuesDict, clientMetadataDict, databaseMetadataDict) {
|
|
54
|
+
const onlyDatabaseIds = new Set()
|
|
55
|
+
const onlyClientIds = new Set()
|
|
56
|
+
const databaseAndClientIds = new Set()
|
|
57
|
+
|
|
58
|
+
for (const uid in databaseValuesDict) {
|
|
59
|
+
if (uid in clientMetadataDict) databaseAndClientIds.add(uid)
|
|
60
|
+
else onlyDatabaseIds.add(uid)
|
|
61
|
+
}
|
|
62
|
+
for (const uid in clientMetadataDict) {
|
|
63
|
+
if (uid in databaseValuesDict) databaseAndClientIds.add(uid)
|
|
64
|
+
else onlyClientIds.add(uid)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const addDatabase = [], updateDatabase = [], deleteDatabase = []
|
|
68
|
+
const addClient = [], updateClient = [], deleteClient = []
|
|
69
|
+
|
|
70
|
+
for (const uid of onlyDatabaseIds) {
|
|
71
|
+
const databaseMetaData = databaseMetadataDict[uid] || { uid, created_at: null }
|
|
72
|
+
addClient.push([databaseValuesDict[uid], databaseMetaData])
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const uid of onlyClientIds) {
|
|
76
|
+
const clientMetaData = clientMetadataDict[uid]
|
|
77
|
+
if (clientMetaData.deleted_at) {
|
|
78
|
+
deleteClient.push([uid, clientMetaData.deleted_at])
|
|
79
|
+
} else {
|
|
80
|
+
addDatabase.push(clientMetaData)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const uid of databaseAndClientIds) {
|
|
85
|
+
const clientMetaData = clientMetadataDict[uid]
|
|
86
|
+
if (clientMetaData.deleted_at) {
|
|
87
|
+
deleteDatabase.push(uid)
|
|
88
|
+
deleteClient.push([uid, clientMetaData.deleted_at])
|
|
89
|
+
} else {
|
|
90
|
+
const databaseMetaData = databaseMetadataDict[uid] || { uid, created_at: null }
|
|
91
|
+
const clientUpdatedAt = new Date(clientMetaData.updated_at || clientMetaData.created_at)
|
|
92
|
+
const databaseUpdatedAt = new Date(databaseMetaData.updated_at || databaseMetaData.created_at)
|
|
93
|
+
const diff = clientUpdatedAt - databaseUpdatedAt
|
|
94
|
+
if (diff > 0) updateDatabase.push(clientMetaData)
|
|
95
|
+
else if (diff < 0) updateClient.push([databaseValuesDict[uid], databaseMetaData])
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
addClient,
|
|
101
|
+
updateClient,
|
|
102
|
+
deleteClient,
|
|
103
|
+
addDatabase,
|
|
104
|
+
updateDatabase,
|
|
105
|
+
deleteDatabase,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
51
109
|
////////////////////////// EXPRESSX //////////////////////////
|
|
52
110
|
|
|
53
111
|
export function expressX(config) {
|