@luckystack/core 0.9.2 → 0.10.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/CHANGELOG.md +18 -1
- package/CLAUDE.md +6 -4
- package/dist/client.js.map +1 -1
- package/dist/eslint/index.js +63 -14
- package/dist/eslint/index.js.map +1 -1
- package/dist/index.d.ts +68 -6
- package/dist/index.js +156 -73
- package/dist/index.js.map +1 -1
- package/docs/redis-adapter.md +4 -1
- package/docs/socket-bootstrap.md +25 -2
- package/package.json +1 -1
package/docs/redis-adapter.md
CHANGED
|
@@ -78,11 +78,14 @@ export const attachSocketRedisAdapter = (io: SocketIOServer): void
|
|
|
78
78
|
**Behavior (in order):**
|
|
79
79
|
1. Calls `redis.duplicate()` twice to obtain dedicated pub + sub clients (ioredis blocks non-pub/sub commands on a connection in subscribe mode, so duplicating is required).
|
|
80
80
|
2. Installs `error` listeners on both clients that log to `console.error` with the `[socket-redis-adapter]` prefix.
|
|
81
|
-
3. Calls `io.adapter(createAdapter(pubClient, subClient))` to wire `@socket.io/redis-adapter
|
|
81
|
+
3. Calls `io.adapter(createAdapter(pubClient, subClient, { key }))` to wire `@socket.io/redis-adapter`, with `key` from `resolveSocketAdapterKey()` unless `adapterOptions.key` is given.
|
|
82
|
+
|
|
83
|
+
**The adapter key is the cluster boundary.** The adapter's `key` names its Redis pub/sub channels, so every instance on the same key *and* the same Redis server is one Socket.io cluster: broadcasts reach all of them, and `fetchSockets()` waits for an answer from each of them (or times out after `requestsTimeout`). Upstream's default is the fixed string `socket.io`, which turns two deployments that merely share a Redis server — a dev laptop tunnelled into the staging Redis, staging and production on one managed instance — into one cluster: staging's broadcasts reach the developer's sockets, and staging's `fetchSockets()` hangs on a laptop that went to sleep. The framework therefore derives the key from the project name plus the deploy-topology key (`resolveEnvKey()`: `LUCKYSTACK_ENV` → `NODE_ENV`), which keeps all instances of one environment together and separates environments without any setting. Pass `adapterOptions.key` to override. Note that sessions, rate-limit counters and every other Redis key are namespaced by project name only (see `redisKeyFormatter.ts`), so two environments that share a Redis server *and* a `PROJECT_NAME` still share their session store — the adapter key isolates the socket cluster, not the data.
|
|
82
84
|
|
|
83
85
|
**Edge cases:**
|
|
84
86
|
- Calling this after `io` has already accepted connections is allowed but races with in-flight broadcasts — wire it before `io.listen` / connection handlers in the boot sequence.
|
|
85
87
|
- The duplicated clients inherit the active registered client (or the default).
|
|
88
|
+
- Rolling out a version that changed the derived key: instances on the old key and the new key do not see each other until the rollout completes. On a single-replica environment nothing is observable; on a multi-replica one, cross-instance fan-out is split for the duration of the rollout.
|
|
86
89
|
|
|
87
90
|
**Example:**
|
|
88
91
|
```typescript
|
package/docs/socket-bootstrap.md
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
This topic covers the wiring around the LuckyStack socket transport:
|
|
8
8
|
|
|
9
|
-
- **Server-side socket slot** — `setIoInstance` / `getIoInstance` so framework code can broadcast.
|
|
9
|
+
- **Server-side socket slot** — `setIoInstance` / `getIoInstance` so framework code can broadcast, plus `getRoomSockets` for cross-instance room enumeration.
|
|
10
|
+
- **Room-name formatter** — `registerRoomNameFormatter` / `formatRoomName`, and the contract a formatter must honour.
|
|
10
11
|
- **Client-side socket singleton** — live binding `socket`, `setSocket`, `waitForSocket`, `incrementResponseIndex`.
|
|
11
12
|
- **Token extraction** — pull the session token from a Socket.io handshake (`extractTokenFromSocket`) or a Node `IncomingMessage` (`extractTokenFromRequest`).
|
|
12
13
|
- **Origin check** — `allowedOrigin` for CORS-style allow-listing, with `corsRejected` hook fan-out.
|
|
@@ -30,10 +31,32 @@ export const setIoInstance = (io: SocketIOServer | null): void
|
|
|
30
31
|
|
|
31
32
|
**Behavior:** Stores the running Socket.io server in a module-level slot. Framework code (`@luckystack/sync`, presence package, etc.) reads via `getIoInstance()` for broadcast paths.
|
|
32
33
|
|
|
33
|
-
### `getIoInstance(
|
|
34
|
+
### `getIoInstance(options?: { raw?: boolean })`
|
|
34
35
|
|
|
35
36
|
Returns the registered instance or `null` when no server has been set. Callers must null-check.
|
|
36
37
|
|
|
38
|
+
**Outside production the returned server is a guarded view.** Reading `sockets.adapter.rooms`, `sockets.adapter.sids`, or enumerating `sockets.sockets` (`values` / `keys` / `entries` / `forEach` / `size` / iteration) **throws**, with the same message the ESLint rule `luckystack/no-local-socket-enumeration` prints. `sockets.sockets.get(id)` stays allowed. Why: under the Redis adapter those maps only know the sockets of *this* process, so a helper built on them looks complete on one instance and silently misses the rest of the cluster on two — the failure surfaces only in production, where it is hardest to attribute. The guard moves that discovery to the first dev run. In production the raw instance is always returned; the guard costs nothing on the hot path.
|
|
39
|
+
|
|
40
|
+
Pass `{ raw: true }` for deliberate per-instance work — a sweep cleaning up this process's own connections, sampling this process's backpressure. The option is the audit trail: a reviewer can grep for it. Framework-internal code and the Redis adapter always hold the raw instance.
|
|
41
|
+
|
|
42
|
+
For "who is in this room" use `getRoomSockets` below, or `io.in(room).fetchSockets()` when you already hold the physical room name.
|
|
43
|
+
|
|
44
|
+
### `getRoomSockets(room, { userId? })`
|
|
45
|
+
|
|
46
|
+
Cross-instance room enumeration. Routes `room` through the room-name formatter under the canonical `'broadcast'` purpose (`userId` defaults to `null`, and is context only — see the formatter contract below), then asks the adapter for every member on every instance and returns the `RemoteSocket[]`. `'all'` returns every socket everywhere.
|
|
47
|
+
|
|
48
|
+
It **throws** when no Socket.io server is registered. A silent `[]` in that situation is indistinguishable from "the room is empty", which is exactly the class of failure this helper exists to prevent.
|
|
49
|
+
|
|
50
|
+
## API Reference — Room-Name Formatter
|
|
51
|
+
|
|
52
|
+
### `registerRoomNameFormatter(fn)` / `getRoomNameFormatter()` / `formatRoomName(raw, ctx)` / `defaultRoomNameFormatter`
|
|
53
|
+
|
|
54
|
+
Every room name the framework joins, leaves, broadcasts to or uses for presence passes through the registered formatter (default: identity) with a `RoomNameFormatterContext` — a `purpose` (`'join'`, `'leave'`, `'broadcast'`, `'presence'`) and the `userId` of whoever performs the operation. The intended use is a stable, user-independent transform such as per-tenant prefixing.
|
|
55
|
+
|
|
56
|
+
**The contract: `userId` is context, never an input to the physical name of a content room.** For `purpose: 'broadcast'` the framework passes whichever user performs the operation — the joiner on join and rejoin, the sender on fan-out, the originator on `broadcastStream` — so for a shared room it is a *different* value on each side. A formatter that folds `userId` into the name makes join and fan-out land in different physical rooms, and every broadcast then reaches nobody: no error, `sync.noReceiversFound` at best, silence at worst. The framework cannot detect this for you, because from its side both names are just strings.
|
|
57
|
+
|
|
58
|
+
Per-user isolation belongs in the raw room code the consumer chooses (put the user in the *code*, not in the formatter), or in the separate `'presence'` family, which is per-user by design.
|
|
59
|
+
|
|
37
60
|
## API Reference — Client Socket Singleton
|
|
38
61
|
|
|
39
62
|
### `socket: Socket | null`
|