@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.
@@ -0,0 +1,11 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(git -C /Users/chris/workspaces/NPM/express-x log --oneline --all)",
5
+ "Bash(git *)",
6
+ "Bash(npm scripts *)",
7
+ "Bash(npm test *)",
8
+ "Bash(npm install *)"
9
+ ]
10
+ }
11
+ }
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.2",
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
- "generate": "npx prisma generate",
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) {