@zintrust/socket 0.4.58

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 ADDED
@@ -0,0 +1,251 @@
1
+ # @zintrust/socket
2
+
3
+ Unified websocket runtime for ZinTrust across Node.js and Cloudflare Workers.
4
+
5
+ This package gives you a Pusher-compatible socket surface without requiring you to hand-wire websocket upgrade routes into your app. On Node.js it handles raw `upgrade` requests directly in the core server. On Cloudflare Workers it uses a Durable Object hub so connected clients and publish requests share one coordination point instead of isolate-local memory.
6
+
7
+ ## What You Get
8
+
9
+ - Automatic socket runtime registration through `@zintrust/socket/register`
10
+ - Websocket upgrade endpoint at `GET {SOCKET_PATH}/:appKey`
11
+ - Auth endpoint at `POST /broadcasting/auth`
12
+ - Publish endpoint at `POST /apps/:appId/events`
13
+ - Pusher-style events such as `pusher:connection_established`, `pusher:pong`, and `pusher_internal:subscription_succeeded`
14
+ - Private and presence-channel auth signing via HMAC SHA-256
15
+ - Node.js in-memory fan-out
16
+ - Cloudflare Durable Object-backed fan-out via `ZT_SOCKET_HUB`
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ npm i @zintrust/socket
22
+ ```
23
+
24
+ If you are using official ZinTrust package auto-imports, installing the package is enough for runtime registration because core will attempt to import `@zintrust/socket/register` automatically.
25
+
26
+ If you prefer an explicit local entrypoint in an app repository, you can still add one:
27
+
28
+ ```ts
29
+ // src/socket-runtime.ts
30
+ import '@zintrust/socket/register';
31
+ ```
32
+
33
+ ## Runtime Model
34
+
35
+ ### Node.js
36
+
37
+ - ZinTrust core listens for HTTP `upgrade` events.
38
+ - `@zintrust/socket` validates the app key and completes the websocket handshake.
39
+ - Connected peers and channel memberships are stored in process memory.
40
+
41
+ ### Cloudflare Workers
42
+
43
+ - ZinTrust core intercepts websocket upgrade requests before the normal HTTP adapter path.
44
+ - The request is forwarded to the `ZT_SOCKET_HUB` Durable Object.
45
+ - The Durable Object owns peer membership and publish fan-out for that app key.
46
+ - Normal HTTP publish requests to `/apps/:appId/events` also forward into the same Durable Object, so websocket traffic and server-side publishes stay coordinated.
47
+
48
+ ## Minimum Env Setup
49
+
50
+ ```env
51
+ SOCKET_ENABLED=true
52
+ SOCKET_PATH=/app
53
+ PUSHER_APP_ID=local-app
54
+ PUSHER_APP_KEY=local-key
55
+ PUSHER_APP_SECRET=local-secret
56
+ ```
57
+
58
+ With that configuration your upgrade endpoint becomes:
59
+
60
+ ```text
61
+ /app/local-key
62
+ ```
63
+
64
+ ## Supported Environment Variables
65
+
66
+ The package supports multiple env aliases so you can keep existing Pusher/broadcast style naming.
67
+
68
+ ### Core toggles
69
+
70
+ - `SOCKET_ENABLED`
71
+ Enables the unified socket runtime.
72
+ - `SOCKET_TRANSPORT`
73
+ Allowed values: `auto`, `node`, `cloudflare`.
74
+ `auto` is the default.
75
+ - `SOCKET_PATH`
76
+ Websocket upgrade base path. Default: `/app`.
77
+
78
+ ### App identity
79
+
80
+ - `PUSHER_APP_ID`
81
+ Primary app identifier used by `/apps/:appId/events`.
82
+ - `BROADCAST_APP_ID`
83
+ Fallback alias for app id.
84
+
85
+ ### Public auth key
86
+
87
+ - `PUSHER_APP_KEY`
88
+ Primary public websocket/auth key.
89
+ - `BROADCAST_AUTH_KEY`
90
+ Fallback alias for the public auth key.
91
+ - `BROADCAST_APP_KEY`
92
+ Additional fallback alias for the public auth key.
93
+
94
+ ### Publish/auth secret
95
+
96
+ - `PUSHER_APP_SECRET`
97
+ Primary signing secret for private/presence auth and publish authorization.
98
+ - `BROADCAST_SECRET`
99
+ Fallback alias for the signing secret.
100
+ - `BROADCAST_APP_SECRET`
101
+ Additional fallback alias for the signing secret.
102
+
103
+ ### Connection timing
104
+
105
+ - `BROADCAST_ACTIVITY_TIMEOUT`
106
+ Activity timeout advertised to clients. Default: `120` seconds.
107
+
108
+ ### Cloudflare binding
109
+
110
+ - `ZT_SOCKET_HUB`
111
+ Durable Object binding required for Cloudflare websocket coordination.
112
+
113
+ ## Cloudflare Worker Configuration
114
+
115
+ Cloudflare support requires exporting the Durable Object class from the worker module and binding it in Wrangler.
116
+
117
+ If your worker entry is `@zintrust/core/start` or ZinTrust's stock `src/functions/cloudflare.ts`, the `ZintrustSocketHub` export is already available.
118
+
119
+ Add a binding like this to your Wrangler config:
120
+
121
+ ```jsonc
122
+ {
123
+ "durable_objects": {
124
+ "bindings": [
125
+ {
126
+ "name": "ZT_SOCKET_HUB",
127
+ "class_name": "ZintrustSocketHub",
128
+ },
129
+ ],
130
+ },
131
+ "migrations": [
132
+ {
133
+ "tag": "v1-zintrust-socket-hub",
134
+ "new_sqlite_classes": ["ZintrustSocketHub"],
135
+ },
136
+ ],
137
+ }
138
+ ```
139
+
140
+ ## Example: Laravel Echo / Pusher-Style Client
141
+
142
+ ```ts
143
+ import Echo from 'laravel-echo';
144
+ import Pusher from 'pusher-js';
145
+
146
+ const echo = new Echo({
147
+ broadcaster: 'pusher',
148
+ client: new Pusher('local-key', {
149
+ wsHost: '127.0.0.1',
150
+ wsPort: 7777,
151
+ wssPort: 443,
152
+ forceTLS: false,
153
+ enabledTransports: ['ws', 'wss'],
154
+ wsPath: '/app',
155
+ authEndpoint: '/broadcasting/auth',
156
+ }),
157
+ });
158
+
159
+ echo.private('orders').listen('.updated', (payload: unknown) => {
160
+ console.log(payload);
161
+ });
162
+ ```
163
+
164
+ For Cloudflare, keep the same client-side contract and only change the host/TLS settings for your deployed Worker domain.
165
+
166
+ ## Example: Publish From Server Code
167
+
168
+ The package exposes an HTTP-compatible publish endpoint:
169
+
170
+ ```http
171
+ POST /apps/local-app/events
172
+ Authorization: Bearer local-secret
173
+ Content-Type: application/json
174
+
175
+ {
176
+ "event": "orders.updated",
177
+ "channel": "private-orders",
178
+ "data": {
179
+ "orderId": 42,
180
+ "status": "paid"
181
+ }
182
+ }
183
+ ```
184
+
185
+ Accepted publish authorization headers:
186
+
187
+ - `Authorization: Bearer <secret>`
188
+ - `x-zintrust-socket-secret: <secret>`
189
+
190
+ You can also provide `channels` instead of `channel`, and `name` instead of `event`.
191
+
192
+ ## Example: Auth Request
193
+
194
+ ```http
195
+ POST /broadcasting/auth
196
+ Content-Type: application/json
197
+
198
+ {
199
+ "socket_id": "123.456",
200
+ "channel_name": "private-orders",
201
+ "channel_data": "{\"user_id\":\"7\"}"
202
+ }
203
+ ```
204
+
205
+ Response shape:
206
+
207
+ ```json
208
+ {
209
+ "auth": "local-key:<signature>",
210
+ "channel_data": "{\"user_id\":\"7\"}"
211
+ }
212
+ ```
213
+
214
+ ## Endpoints Summary
215
+
216
+ - `GET {SOCKET_PATH}/:appKey`
217
+ Returns `426 Upgrade Required` over HTTP and upgrades over websocket.
218
+ - `POST /broadcasting/auth`
219
+ Signs private/presence subscriptions.
220
+ - `POST /apps/:appId/events`
221
+ Publishes server-originated events to one or many channels.
222
+
223
+ ## Behavior Notes
224
+
225
+ - Node.js fan-out is process-local. If you run multiple Node instances, use your own cross-node broadcast layer in front of this package.
226
+ - Cloudflare fan-out is app-scoped through one Durable Object instance per socket app key.
227
+ - If `SOCKET_TRANSPORT=node` is set, Cloudflare Durable Object forwarding is disabled intentionally.
228
+ - If `SOCKET_ENABLED=true` on Cloudflare but `ZT_SOCKET_HUB` is missing, upgrade and publish requests return a `503` response explaining the missing binding.
229
+
230
+ ## Good Defaults For Local Development
231
+
232
+ ```env
233
+ SOCKET_ENABLED=true
234
+ SOCKET_TRANSPORT=auto
235
+ SOCKET_PATH=/app
236
+ PUSHER_APP_ID=local-app
237
+ PUSHER_APP_KEY=local-key
238
+ PUSHER_APP_SECRET=local-secret
239
+ BROADCAST_ACTIVITY_TIMEOUT=45
240
+ ```
241
+
242
+ ## Troubleshooting
243
+
244
+ - `404 Socket app key not found`:
245
+ Your client is connecting with a key that does not match the resolved app key env.
246
+ - `403 Socket publish secret is invalid`:
247
+ The publish request secret does not match the resolved signing secret.
248
+ - `503 socket_durable_object_missing`:
249
+ Cloudflare transport is active but Wrangler is missing the `ZT_SOCKET_HUB` binding.
250
+ - `426 Upgrade Required` over HTTP:
251
+ You hit the websocket route with a normal HTTP request, which is expected for health/debug checks.
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@zintrust/socket",
3
+ "version": "0.4.58",
4
+ "buildDate": "2026-04-04T19:59:22.567Z",
5
+ "buildEnvironment": {
6
+ "node": "v22.22.1",
7
+ "platform": "darwin",
8
+ "arch": "arm64"
9
+ },
10
+ "git": {
11
+ "commit": "99e4d331",
12
+ "branch": "release"
13
+ },
14
+ "package": {
15
+ "engines": {
16
+ "node": ">=20.0.0"
17
+ },
18
+ "dependencies": [],
19
+ "peerDependencies": [
20
+ "@zintrust/core"
21
+ ]
22
+ },
23
+ "files": {
24
+ "index.d.ts": {
25
+ "size": 932,
26
+ "sha256": "6432952783fd7eacfc46813fcbd6e96672ff94c73fb0bad8e2f20fc278c64377"
27
+ },
28
+ "index.js": {
29
+ "size": 28400,
30
+ "sha256": "972ccf9128c9915e3bbcf6f91f3332ab5d31e7aaa8ca828be838d231ef47eeb5"
31
+ },
32
+ "register.d.ts": {
33
+ "size": 16,
34
+ "sha256": "71d366165dd36f1675aa253a76262b226fb6c62e5ab632746b8aea61c0c625fc"
35
+ },
36
+ "register.js": {
37
+ "size": 419,
38
+ "sha256": "e5c28fd549e3fd5dbee6a211608acb48fa4a8b4ef11b5823668510cde3d924c2"
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,20 @@
1
+ import { type IRouter, type SocketRouteRegistrar } from '@zintrust/core';
2
+ declare const socketRuntime: any;
3
+ declare const registerSocketRoutes: (router: IRouter) => void;
4
+ declare const socketRouteRegistrar: SocketRouteRegistrar;
5
+ export declare const SocketPackage: Readonly<{
6
+ runtime: any;
7
+ routeRegistrar: any;
8
+ publish: (channels: string[], event: string, data: unknown, excludeSocketId?: string) => number;
9
+ registerRoutes: (router: IRouter) => void;
10
+ }>;
11
+ export declare class ZintrustSocketHub {
12
+ private readonly settings;
13
+ private readonly state;
14
+ constructor(_state: unknown, env: unknown);
15
+ fetch(request: Request): Promise<Response>;
16
+ private handlePublishRequest;
17
+ }
18
+ export declare const publishSocketEvent: (channels: string[], event: string, data: unknown, excludeSocketId?: string) => number;
19
+ export { registerSocketRoutes, socketRouteRegistrar, socketRuntime };
20
+ export default SocketPackage;
package/dist/index.js ADDED
@@ -0,0 +1,746 @@
1
+ import { Cloudflare, isArray, isNonEmptyString, Router, SocketFeature, } from '@zintrust/core';
2
+ const encoder = new TextEncoder();
3
+ const websocketGuid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
4
+ const socketHubBindingName = 'ZT_SOCKET_HUB';
5
+ const socketInternalPublishPath = '/__zintrust/socket/publish';
6
+ const jsonHeaders = Object.freeze({ 'content-type': 'application/json; charset=utf-8' });
7
+ const createSocketState = () => {
8
+ return {
9
+ peers: new Map(),
10
+ channels: new Map(),
11
+ };
12
+ };
13
+ const getNodeSocketState = () => {
14
+ const globalSocketState = globalThis;
15
+ globalSocketState.__zintrustSocketState ??= createSocketState();
16
+ return globalSocketState.__zintrustSocketState;
17
+ };
18
+ const toEnvRecord = (value) => {
19
+ if (typeof value !== 'object' || value === null) {
20
+ return null;
21
+ }
22
+ return value;
23
+ };
24
+ const readEnvString = (source, key, fallback = '') => {
25
+ const value = source?.[key];
26
+ if (value === undefined || value === null) {
27
+ return fallback;
28
+ }
29
+ return String(value);
30
+ };
31
+ const readEnvBool = (source, key, fallback) => {
32
+ const raw = readEnvString(source, key, fallback ? 'true' : 'false')
33
+ .trim()
34
+ .toLowerCase();
35
+ if (raw === '')
36
+ return fallback;
37
+ return raw === 'true' || raw === '1' || raw === 'yes' || raw === 'on';
38
+ };
39
+ const readEnvInt = (source, key, fallback) => {
40
+ const raw = readEnvString(source, key, String(fallback)).trim();
41
+ const parsed = Number.parseInt(raw, 10);
42
+ return Number.isFinite(parsed) ? parsed : fallback;
43
+ };
44
+ const normalizeSocketPath = (value) => {
45
+ const trimmed = value.trim();
46
+ if (trimmed === '' || trimmed === '/')
47
+ return '/app';
48
+ const normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
49
+ return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized;
50
+ };
51
+ const pickFirstNonEmpty = (...values) => {
52
+ for (const value of values) {
53
+ if (value.trim() !== '') {
54
+ return value.trim();
55
+ }
56
+ }
57
+ return '';
58
+ };
59
+ const resolveTransport = (value) => {
60
+ const normalized = value.trim().toLowerCase();
61
+ if (normalized === 'node' || normalized === 'cloudflare') {
62
+ return normalized;
63
+ }
64
+ return 'auto';
65
+ };
66
+ const getSocketRuntimeSettings = (envSource) => {
67
+ const source = toEnvRecord(envSource);
68
+ if (source === null) {
69
+ const settings = SocketFeature.getSettings();
70
+ return Object.freeze({
71
+ ...settings,
72
+ appId: settings.appId === '' ? 'local' : settings.appId,
73
+ });
74
+ }
75
+ return Object.freeze({
76
+ enabled: readEnvBool(source, 'SOCKET_ENABLED', false),
77
+ transport: resolveTransport(readEnvString(source, 'SOCKET_TRANSPORT', 'auto')),
78
+ path: normalizeSocketPath(readEnvString(source, 'SOCKET_PATH', '/app')),
79
+ appId: pickFirstNonEmpty(readEnvString(source, 'PUSHER_APP_ID', ''), readEnvString(source, 'BROADCAST_APP_ID', '')) || 'local',
80
+ appKey: pickFirstNonEmpty(readEnvString(source, 'PUSHER_APP_KEY', ''), readEnvString(source, 'BROADCAST_AUTH_KEY', ''), readEnvString(source, 'BROADCAST_APP_KEY', '')),
81
+ secret: pickFirstNonEmpty(readEnvString(source, 'PUSHER_APP_SECRET', ''), readEnvString(source, 'BROADCAST_SECRET', ''), readEnvString(source, 'BROADCAST_APP_SECRET', '')),
82
+ activityTimeout: readEnvInt(source, 'BROADCAST_ACTIVITY_TIMEOUT', 120),
83
+ });
84
+ };
85
+ const toJsonString = (value) => {
86
+ if (typeof value === 'string')
87
+ return value;
88
+ return JSON.stringify(value ?? {});
89
+ };
90
+ const createJsonResponse = (payload, status) => {
91
+ return new Response(JSON.stringify(payload), {
92
+ status,
93
+ headers: jsonHeaders,
94
+ });
95
+ };
96
+ const decodeText = (value) => {
97
+ if (typeof value === 'string')
98
+ return value;
99
+ if (value instanceof ArrayBuffer) {
100
+ return new TextDecoder().decode(value);
101
+ }
102
+ if (ArrayBuffer.isView(value)) {
103
+ return new TextDecoder().decode(new Uint8Array(value.buffer, value.byteOffset, value.byteLength));
104
+ }
105
+ return '';
106
+ };
107
+ const getUpgradeHeader = (value) => {
108
+ if (Array.isArray(value))
109
+ return value[0] ?? '';
110
+ return value ?? '';
111
+ };
112
+ const getBearerToken = (value) => {
113
+ const normalized = value.trim();
114
+ if (!normalized.toLowerCase().startsWith('bearer '))
115
+ return '';
116
+ return normalized.slice('bearer '.length).trim();
117
+ };
118
+ const parseSocketPath = (pathname, settings) => {
119
+ const prefix = `${settings.path}/`;
120
+ if (!pathname.startsWith(prefix)) {
121
+ return null;
122
+ }
123
+ const remainder = pathname.slice(prefix.length);
124
+ if (remainder === '' || remainder.includes('/')) {
125
+ return null;
126
+ }
127
+ return decodeURIComponent(remainder);
128
+ };
129
+ const isWorkerUpgradeRequest = (request) => {
130
+ return request.headers.get('upgrade')?.trim().toLowerCase() === 'websocket';
131
+ };
132
+ const isNodeUpgradeRequest = (input, settings) => {
133
+ const pathname = new URL(input.request.url ?? '/', 'http://localhost').pathname;
134
+ return parseSocketPath(pathname, settings) !== null;
135
+ };
136
+ const toBase64 = (value) => {
137
+ const bytes = new Uint8Array(value);
138
+ let binary = '';
139
+ for (const byte of bytes) {
140
+ binary += String.fromCodePoint(byte);
141
+ }
142
+ return btoa(binary);
143
+ };
144
+ const toHex = (value) => {
145
+ return Array.from(new Uint8Array(value))
146
+ .map((byte) => byte.toString(16).padStart(2, '0'))
147
+ .join('');
148
+ };
149
+ const sha1Base64 = async (value) => {
150
+ return toBase64(await globalThis.crypto.subtle.digest('SHA-1', encoder.encode(value)));
151
+ };
152
+ const hmacSha256Hex = async (secret, value) => {
153
+ const key = await globalThis.crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
154
+ const signature = await globalThis.crypto.subtle.sign('HMAC', key, encoder.encode(value));
155
+ return toHex(signature);
156
+ };
157
+ const createSocketId = () => {
158
+ const token = globalThis.crypto.randomUUID().replaceAll('-', '').slice(0, 16);
159
+ const left = Number.parseInt(token.slice(0, 8), 16).toString();
160
+ const right = Number.parseInt(token.slice(8, 16), 16).toString();
161
+ return `${left}.${right}`;
162
+ };
163
+ const removeFromChannel = (state, channel, peerId) => {
164
+ const members = state.channels.get(channel);
165
+ if (members === undefined)
166
+ return;
167
+ members.delete(peerId);
168
+ if (members.size === 0) {
169
+ state.channels.delete(channel);
170
+ }
171
+ };
172
+ const detachPeer = (state, peer) => {
173
+ state.peers.delete(peer.id);
174
+ for (const channel of peer.subscriptions) {
175
+ removeFromChannel(state, channel, peer.id);
176
+ }
177
+ peer.subscriptions.clear();
178
+ };
179
+ const addPeerToChannel = (state, channel, peer) => {
180
+ const existing = state.channels.get(channel);
181
+ if (existing === undefined) {
182
+ state.channels.set(channel, new Set([peer.id]));
183
+ }
184
+ else {
185
+ existing.add(peer.id);
186
+ }
187
+ peer.subscriptions.add(channel);
188
+ };
189
+ const createEnvelope = (event, data, channel) => {
190
+ const payload = {
191
+ event,
192
+ data: toJsonString(data),
193
+ };
194
+ if (channel !== undefined) {
195
+ payload['channel'] = channel;
196
+ }
197
+ return JSON.stringify(payload);
198
+ };
199
+ const emitConnectionEstablished = (peer, settings) => {
200
+ peer.sendText(createEnvelope('pusher:connection_established', {
201
+ socket_id: peer.id,
202
+ activity_timeout: settings.activityTimeout,
203
+ }));
204
+ };
205
+ const emitSubscriptionSucceeded = (peer, channel) => {
206
+ peer.sendText(createEnvelope('pusher_internal:subscription_succeeded', {}, channel));
207
+ };
208
+ const emitPong = (peer) => {
209
+ peer.sendText(createEnvelope('pusher:pong', {}));
210
+ };
211
+ const getPublishSecret = (request) => {
212
+ const directHeader = request.getHeader('x-zintrust-socket-secret');
213
+ const authHeader = request.getHeader('authorization');
214
+ const fromDirect = getUpgradeHeader(typeof directHeader === 'string' ? directHeader : null).trim();
215
+ if (fromDirect !== '')
216
+ return fromDirect;
217
+ return getBearerToken(getUpgradeHeader(typeof authHeader === 'string' ? authHeader : null));
218
+ };
219
+ const validatePrivateChannelAuth = async (peer, channel, authValue, channelData, settings) => {
220
+ if (settings.secret.trim() === '' || settings.appKey.trim() === '') {
221
+ return false;
222
+ }
223
+ const [authKey, signature] = authValue.split(':');
224
+ if (authKey !== settings.appKey || !isNonEmptyString(signature)) {
225
+ return false;
226
+ }
227
+ const payload = channelData === undefined ? `${peer.id}:${channel}` : `${peer.id}:${channel}:${channelData}`;
228
+ return (await hmacSha256Hex(settings.secret, payload)) === signature;
229
+ };
230
+ const isPrivateChannel = (channel) => {
231
+ return channel.startsWith('private-') || channel.startsWith('presence-');
232
+ };
233
+ const publishToChannels = (state, channels, event, data, excludeSocketId) => {
234
+ const delivered = new Set();
235
+ for (const channel of channels) {
236
+ const members = state.channels.get(channel);
237
+ if (members === undefined)
238
+ continue;
239
+ for (const peerId of members) {
240
+ if (peerId === excludeSocketId)
241
+ continue;
242
+ const peer = state.peers.get(peerId);
243
+ if (peer === undefined)
244
+ continue;
245
+ peer.sendText(createEnvelope(event, data, channel));
246
+ delivered.add(peerId);
247
+ }
248
+ }
249
+ return delivered.size;
250
+ };
251
+ const parseJsonObject = (value) => {
252
+ try {
253
+ const parsed = JSON.parse(value);
254
+ if (parsed === null || Array.isArray(parsed) || typeof parsed !== 'object') {
255
+ return null;
256
+ }
257
+ return parsed;
258
+ }
259
+ catch {
260
+ return null;
261
+ }
262
+ };
263
+ const handleSubscribe = async (state, peer, payload, settings) => {
264
+ const channel = isNonEmptyString(payload.channel) ? payload.channel.trim() : '';
265
+ if (channel === '') {
266
+ return;
267
+ }
268
+ if (isPrivateChannel(channel)) {
269
+ const auth = isNonEmptyString(payload.auth) ? payload.auth.trim() : '';
270
+ const channelData = isNonEmptyString(payload.channel_data) ? payload.channel_data : undefined;
271
+ if (!(await validatePrivateChannelAuth(peer, channel, auth, channelData, settings))) {
272
+ peer.sendText(createEnvelope('pusher:error', { message: 'Subscription auth failed.' }));
273
+ return;
274
+ }
275
+ }
276
+ addPeerToChannel(state, channel, peer);
277
+ emitSubscriptionSucceeded(peer, channel);
278
+ };
279
+ const handleClientMessage = async (state, peer, text, settings) => {
280
+ const payload = parseJsonObject(text);
281
+ if (payload === null) {
282
+ return;
283
+ }
284
+ const eventName = isNonEmptyString(payload['event']) ? payload['event'].trim() : '';
285
+ const dataRaw = payload['data'];
286
+ const data = typeof dataRaw === 'string' ? (parseJsonObject(dataRaw) ?? dataRaw) : dataRaw;
287
+ if (eventName === 'pusher:ping') {
288
+ emitPong(peer);
289
+ return;
290
+ }
291
+ if (eventName === 'pusher:subscribe' && data !== null && typeof data === 'object') {
292
+ await handleSubscribe(state, peer, data, settings);
293
+ return;
294
+ }
295
+ if (eventName === 'pusher:unsubscribe' && data !== null && typeof data === 'object') {
296
+ const candidate = data.channel;
297
+ const channel = isNonEmptyString(candidate) ? candidate.trim() : '';
298
+ if (channel !== '') {
299
+ peer.subscriptions.delete(channel);
300
+ removeFromChannel(state, channel, peer.id);
301
+ }
302
+ }
303
+ };
304
+ const encodeFrame = (opcode, payload) => {
305
+ const header = [0x80 | (opcode & 0x0f)];
306
+ if (payload.length < 126) {
307
+ header.push(payload.length);
308
+ }
309
+ else if (payload.length <= 0xffff) {
310
+ header.push(126, (payload.length >> 8) & 0xff, payload.length & 0xff);
311
+ }
312
+ else {
313
+ const high = Math.floor(payload.length / 2 ** 32);
314
+ const low = payload.length >>> 0;
315
+ header.push(127, (high >> 24) & 0xff, (high >> 16) & 0xff, (high >> 8) & 0xff, high & 0xff, (low >> 24) & 0xff, (low >> 16) & 0xff, (low >> 8) & 0xff, low & 0xff);
316
+ }
317
+ return Buffer.concat([Buffer.from(header), Buffer.from(payload)]);
318
+ };
319
+ const parseFrame = (buffer) => {
320
+ if (buffer.length < 2)
321
+ return null;
322
+ const second = buffer[1];
323
+ const opcode = buffer[0] & 0x0f;
324
+ const masked = (second & 0x80) !== 0;
325
+ let length = second & 0x7f;
326
+ let offset = 2;
327
+ if (length === 126) {
328
+ if (buffer.length < 4)
329
+ return null;
330
+ length = buffer.readUInt16BE(2);
331
+ offset = 4;
332
+ }
333
+ else if (length === 127) {
334
+ if (buffer.length < 10)
335
+ return null;
336
+ const high = buffer.readUInt32BE(2);
337
+ const low = buffer.readUInt32BE(6);
338
+ length = high * 2 ** 32 + low;
339
+ offset = 10;
340
+ }
341
+ const maskOffset = masked ? 4 : 0;
342
+ if (buffer.length < offset + maskOffset + length) {
343
+ return null;
344
+ }
345
+ const payload = buffer.subarray(offset + maskOffset, offset + maskOffset + length);
346
+ if (masked) {
347
+ const mask = buffer.subarray(offset, offset + 4);
348
+ const decoded = Buffer.alloc(length);
349
+ for (let index = 0; index < length; index += 1) {
350
+ decoded[index] = payload[index] ^ mask[index % 4];
351
+ }
352
+ return { opcode, payload: decoded, bytesConsumed: offset + 4 + length };
353
+ }
354
+ return { opcode, payload, bytesConsumed: offset + length };
355
+ };
356
+ const createNodePeer = (state, socket) => {
357
+ const peer = {
358
+ id: createSocketId(),
359
+ subscriptions: new Set(),
360
+ sendText(text) {
361
+ socket.write(encodeFrame(0x1, encoder.encode(text)));
362
+ },
363
+ close(code = 1000, reason = '') {
364
+ const body = Buffer.alloc(2 + Buffer.byteLength(reason));
365
+ body.writeUInt16BE(code, 0);
366
+ body.write(reason, 2);
367
+ socket.write(encodeFrame(0x8, body));
368
+ socket.end();
369
+ },
370
+ };
371
+ state.peers.set(peer.id, peer);
372
+ socket.on('close', () => detachPeer(state, peer));
373
+ socket.on('error', () => detachPeer(state, peer));
374
+ return peer;
375
+ };
376
+ const createWorkerPeer = (state, socket) => {
377
+ const peer = {
378
+ id: createSocketId(),
379
+ subscriptions: new Set(),
380
+ sendText(text) {
381
+ socket.send(text);
382
+ },
383
+ close(code = 1000, reason = '') {
384
+ socket.close(code, reason);
385
+ },
386
+ };
387
+ state.peers.set(peer.id, peer);
388
+ return peer;
389
+ };
390
+ const attachNodePeer = async (state, input, settings) => {
391
+ const key = input.request.headers['sec-websocket-key'];
392
+ const secKey = Array.isArray(key) ? key[0] : key;
393
+ if (!isNonEmptyString(secKey)) {
394
+ return false;
395
+ }
396
+ const acceptKey = await sha1Base64(`${secKey.trim()}${websocketGuid}`);
397
+ input.socket.write([
398
+ 'HTTP/1.1 101 Switching Protocols',
399
+ 'Upgrade: websocket',
400
+ 'Connection: Upgrade',
401
+ `Sec-WebSocket-Accept: ${acceptKey}`,
402
+ '\r\n',
403
+ ].join('\r\n'));
404
+ const peer = createNodePeer(state, input.socket);
405
+ emitConnectionEstablished(peer, settings);
406
+ let frameBuffer = input.head.length > 0 ? Buffer.from(input.head) : Buffer.alloc(0);
407
+ const consumeFrames = async () => {
408
+ while (true) {
409
+ const frame = parseFrame(frameBuffer);
410
+ if (frame === null)
411
+ return;
412
+ frameBuffer = frameBuffer.subarray(frame.bytesConsumed);
413
+ if (frame.opcode === 0x8) {
414
+ peer.close();
415
+ return;
416
+ }
417
+ if (frame.opcode === 0x9) {
418
+ input.socket.write(encodeFrame(0xa, frame.payload));
419
+ continue;
420
+ }
421
+ if (frame.opcode === 0x1) {
422
+ // eslint-disable-next-line no-await-in-loop
423
+ await handleClientMessage(state, peer, frame.payload.toString('utf-8'), settings);
424
+ }
425
+ }
426
+ };
427
+ input.socket.on('data', (chunk) => {
428
+ frameBuffer = Buffer.concat([frameBuffer, chunk]);
429
+ void consumeFrames();
430
+ });
431
+ if (frameBuffer.length > 0) {
432
+ await consumeFrames();
433
+ }
434
+ return true;
435
+ };
436
+ const attachWorkerPeer = (state, socket, settings) => {
437
+ const peer = createWorkerPeer(state, socket);
438
+ emitConnectionEstablished(peer, settings);
439
+ socket.addEventListener('message', (event) => {
440
+ void handleClientMessage(state, peer, decodeText(event.data), settings);
441
+ });
442
+ socket.addEventListener('close', () => detachPeer(state, peer));
443
+ socket.addEventListener('error', () => detachPeer(state, peer));
444
+ };
445
+ const getSocketAppKey = (requestPath, settings) => {
446
+ return parseSocketPath(requestPath, settings);
447
+ };
448
+ const getSocketHubNamespace = (envSource) => {
449
+ const source = toEnvRecord(envSource) ?? Cloudflare.getWorkersEnv();
450
+ if (source === null) {
451
+ return null;
452
+ }
453
+ const candidate = source[socketHubBindingName];
454
+ if (typeof candidate !== 'object' || candidate === null) {
455
+ return null;
456
+ }
457
+ const namespace = candidate;
458
+ if (typeof namespace.getByName === 'function') {
459
+ return namespace;
460
+ }
461
+ if (typeof namespace.idFromName === 'function' && typeof namespace.get === 'function') {
462
+ return namespace;
463
+ }
464
+ return null;
465
+ };
466
+ const getSocketHubStub = (settings, envSource) => {
467
+ const namespace = getSocketHubNamespace(envSource);
468
+ if (namespace === null) {
469
+ return null;
470
+ }
471
+ const objectName = `socket-app:${settings.appId}:${settings.appKey}`;
472
+ if (typeof namespace.getByName === 'function') {
473
+ return namespace.getByName(objectName);
474
+ }
475
+ if (typeof namespace.idFromName === 'function' && typeof namespace.get === 'function') {
476
+ return namespace.get(namespace.idFromName(objectName));
477
+ }
478
+ return null;
479
+ };
480
+ const createMissingHubResponse = () => {
481
+ return createJsonResponse({
482
+ error: 'socket_durable_object_missing',
483
+ message: 'Cloudflare socket transport requires a Durable Object binding named ZT_SOCKET_HUB.',
484
+ }, 503);
485
+ };
486
+ const shouldUseCloudflareHub = (settings) => {
487
+ if (settings.transport === 'node') {
488
+ return false;
489
+ }
490
+ return Cloudflare.getWorkersEnv() !== null;
491
+ };
492
+ const parseJsonResponse = async (response) => {
493
+ try {
494
+ return (await response.clone().json());
495
+ }
496
+ catch {
497
+ try {
498
+ return await response.text();
499
+ }
500
+ catch {
501
+ return null;
502
+ }
503
+ }
504
+ };
505
+ const parsePublishPayload = (payload) => {
506
+ let event = '';
507
+ if (isNonEmptyString(payload.name)) {
508
+ event = payload.name.trim();
509
+ }
510
+ else if (isNonEmptyString(payload.event)) {
511
+ event = payload.event.trim();
512
+ }
513
+ let channels = [];
514
+ if (isArray(payload.channels)) {
515
+ channels = payload.channels.filter(isNonEmptyString).map((item) => item.trim());
516
+ }
517
+ else if (isNonEmptyString(payload.channel)) {
518
+ channels = [payload.channel.trim()];
519
+ }
520
+ if (event === '' || channels.length === 0) {
521
+ return null;
522
+ }
523
+ return {
524
+ channels,
525
+ event,
526
+ data: payload.data ?? {},
527
+ ...(isNonEmptyString(payload.socket_id) ? { socket_id: payload.socket_id.trim() } : {}),
528
+ };
529
+ };
530
+ const forwardPublishToHub = async (settings, payload, envSource) => {
531
+ const stub = getSocketHubStub(settings, envSource);
532
+ if (stub === null) {
533
+ return createMissingHubResponse();
534
+ }
535
+ return stub.fetch(new Request(`https://zintrust-socket.internal${socketInternalPublishPath}`, {
536
+ method: 'POST',
537
+ headers: jsonHeaders,
538
+ body: JSON.stringify(payload),
539
+ }));
540
+ };
541
+ const getWebSocketPairCtor = () => {
542
+ return globalThis.WebSocketPair;
543
+ };
544
+ const createSocketRuntime = () => {
545
+ const shouldEnable = () => {
546
+ const settings = getSocketRuntimeSettings();
547
+ return settings.enabled && settings.appKey.trim() !== '';
548
+ };
549
+ return Object.freeze({
550
+ name: '@zintrust/socket',
551
+ isEnabled: shouldEnable,
552
+ describe() {
553
+ const settings = getSocketRuntimeSettings();
554
+ const transport = typeof getWebSocketPairCtor() === 'function' ? 'cloudflare' : 'node';
555
+ return {
556
+ enabled: shouldEnable(),
557
+ transport,
558
+ path: settings.path,
559
+ appKeyConfigured: settings.appKey.trim() !== '',
560
+ };
561
+ },
562
+ canHandleNodeUpgrade(input) {
563
+ const settings = getSocketRuntimeSettings();
564
+ if (settings.transport === 'cloudflare')
565
+ return false;
566
+ return isNodeUpgradeRequest(input, settings);
567
+ },
568
+ async handleNodeUpgrade(input) {
569
+ const settings = getSocketRuntimeSettings();
570
+ const pathname = new URL(input.request.url ?? '/', 'http://localhost').pathname;
571
+ const appKey = getSocketAppKey(pathname, settings);
572
+ if (appKey === null || appKey !== settings.appKey) {
573
+ return false;
574
+ }
575
+ return attachNodePeer(getNodeSocketState(), input, settings);
576
+ },
577
+ canHandleWorkerRequest(request) {
578
+ const settings = getSocketRuntimeSettings();
579
+ if (settings.transport === 'node')
580
+ return false;
581
+ if (!isWorkerUpgradeRequest(request))
582
+ return false;
583
+ return getSocketAppKey(new URL(request.url).pathname, settings) !== null;
584
+ },
585
+ async handleWorkerRequest(request, context) {
586
+ const settings = getSocketRuntimeSettings(context.env);
587
+ const appKey = getSocketAppKey(new URL(request.url).pathname, settings);
588
+ if (appKey === null || appKey !== settings.appKey) {
589
+ return null;
590
+ }
591
+ const stub = getSocketHubStub(settings, context.env);
592
+ if (stub === null) {
593
+ return createMissingHubResponse();
594
+ }
595
+ return stub.fetch(request);
596
+ },
597
+ });
598
+ };
599
+ const socketRuntime = createSocketRuntime();
600
+ const respondUpgradeRequired = (req, res) => {
601
+ const settings = getSocketRuntimeSettings();
602
+ const appKey = req.getParam('appKey');
603
+ if (!isNonEmptyString(appKey) || appKey.trim() !== settings.appKey) {
604
+ res.setStatus(404).json({ error: 'Socket app key not found.' });
605
+ return;
606
+ }
607
+ res.setStatus(426).json({
608
+ error: 'Upgrade Required',
609
+ message: 'Open this endpoint with a WebSocket upgrade request.',
610
+ path: `${settings.path}/${settings.appKey}`,
611
+ });
612
+ };
613
+ const authenticateSubscription = async (req, res) => {
614
+ const settings = getSocketRuntimeSettings();
615
+ if (settings.secret.trim() === '' || settings.appKey.trim() === '') {
616
+ res.setStatus(503).json({ error: 'Socket auth is not configured.' });
617
+ return;
618
+ }
619
+ const body = req.getBody();
620
+ const payload = body !== null && typeof body === 'object' ? body : {};
621
+ const socketId = isNonEmptyString(payload['socket_id']) ? payload['socket_id'].trim() : '';
622
+ const channelName = isNonEmptyString(payload['channel_name'])
623
+ ? payload['channel_name'].trim()
624
+ : '';
625
+ const channelData = isNonEmptyString(payload['channel_data'])
626
+ ? payload['channel_data'].trim()
627
+ : undefined;
628
+ if (socketId === '' || channelName === '') {
629
+ res.setStatus(400).json({ error: 'socket_id and channel_name are required.' });
630
+ return;
631
+ }
632
+ const signature = await hmacSha256Hex(settings.secret, channelData === undefined
633
+ ? `${socketId}:${channelName}`
634
+ : `${socketId}:${channelName}:${channelData}`);
635
+ res.json({
636
+ auth: `${settings.appKey}:${signature}`,
637
+ ...(channelData === undefined ? {} : { channel_data: channelData }),
638
+ });
639
+ };
640
+ const publishEvent = async (req, res) => {
641
+ const settings = getSocketRuntimeSettings();
642
+ const appId = req.getParam('appId');
643
+ if (!isNonEmptyString(appId) || appId.trim() !== settings.appId) {
644
+ res.setStatus(404).json({ error: 'Socket app id not found.' });
645
+ return;
646
+ }
647
+ if (settings.secret.trim() !== '') {
648
+ const providedSecret = getPublishSecret(req);
649
+ if (providedSecret !== settings.secret) {
650
+ res.setStatus(403).json({ error: 'Socket publish secret is invalid.' });
651
+ return;
652
+ }
653
+ }
654
+ const payload = req.getBody();
655
+ const body = payload !== null && typeof payload === 'object' ? payload : {};
656
+ const normalizedPayload = parsePublishPayload(body);
657
+ if (normalizedPayload === null) {
658
+ res.setStatus(400).json({ error: 'event/name and channel/channels are required.' });
659
+ return;
660
+ }
661
+ if (shouldUseCloudflareHub(settings)) {
662
+ const response = await forwardPublishToHub(settings, normalizedPayload, Cloudflare.getWorkersEnv());
663
+ const responseBody = await parseJsonResponse(response);
664
+ res.setStatus(response.status).json(responseBody);
665
+ return;
666
+ }
667
+ const deliveries = publishToChannels(getNodeSocketState(), normalizedPayload.channels, normalizedPayload.event, normalizedPayload.data, normalizedPayload.socket_id);
668
+ res.setStatus(202).json({
669
+ ok: true,
670
+ channels: normalizedPayload.channels,
671
+ event: normalizedPayload.event,
672
+ deliveries,
673
+ });
674
+ };
675
+ const registerSocketRoutes = (router) => {
676
+ const settings = getSocketRuntimeSettings();
677
+ Router.get(router, `${settings.path}/:appKey`, respondUpgradeRequired);
678
+ Router.post(router, '/broadcasting/auth', authenticateSubscription);
679
+ Router.post(router, '/apps/:appId/events', publishEvent);
680
+ };
681
+ const socketRouteRegistrar = Object.freeze({
682
+ registerRoutes: registerSocketRoutes,
683
+ });
684
+ export const SocketPackage = Object.freeze({
685
+ runtime: socketRuntime,
686
+ routeRegistrar: socketRouteRegistrar,
687
+ publish: (channels, event, data, excludeSocketId) => publishToChannels(getNodeSocketState(), channels, event, data, excludeSocketId),
688
+ registerRoutes: registerSocketRoutes,
689
+ });
690
+ // eslint-disable-next-line no-restricted-syntax -- Cloudflare Durable Objects require class exports.
691
+ export class ZintrustSocketHub {
692
+ settings;
693
+ state;
694
+ constructor(_state, env) {
695
+ this.settings = getSocketRuntimeSettings(env);
696
+ this.state = createSocketState();
697
+ }
698
+ async fetch(request) {
699
+ const url = new URL(request.url);
700
+ if (request.method === 'POST' && url.pathname === socketInternalPublishPath) {
701
+ return this.handlePublishRequest(request);
702
+ }
703
+ if (!isWorkerUpgradeRequest(request)) {
704
+ return createJsonResponse({
705
+ error: 'not_found',
706
+ message: 'Unknown socket Durable Object route.',
707
+ }, 404);
708
+ }
709
+ const appKey = getSocketAppKey(url.pathname, this.settings);
710
+ if (appKey === null || appKey !== this.settings.appKey) {
711
+ return createJsonResponse({ error: 'Socket app key not found.' }, 404);
712
+ }
713
+ const WebSocketPairRef = getWebSocketPairCtor();
714
+ if (typeof WebSocketPairRef !== 'function') {
715
+ return createJsonResponse({ error: 'WebSocketPair is unavailable in this runtime.' }, 501);
716
+ }
717
+ const pair = new WebSocketPairRef();
718
+ const client = pair[0];
719
+ const server = pair[1];
720
+ server.accept();
721
+ attachWorkerPeer(this.state, server, this.settings);
722
+ return new Response(null, { status: 101, webSocket: client });
723
+ }
724
+ async handlePublishRequest(request) {
725
+ const body = parseJsonObject(await request.text());
726
+ if (body === null) {
727
+ return createJsonResponse({ error: 'Invalid socket publish payload.' }, 400);
728
+ }
729
+ const payload = parsePublishPayload(body);
730
+ if (payload === null) {
731
+ return createJsonResponse({ error: 'event/name and channel/channels are required.' }, 400);
732
+ }
733
+ const deliveries = publishToChannels(this.state, payload.channels, payload.event, payload.data, payload.socket_id);
734
+ return createJsonResponse({
735
+ ok: true,
736
+ channels: payload.channels,
737
+ event: payload.event,
738
+ deliveries,
739
+ }, 202);
740
+ }
741
+ }
742
+ export const publishSocketEvent = (channels, event, data, excludeSocketId) => {
743
+ return publishToChannels(getNodeSocketState(), channels, event, data, excludeSocketId);
744
+ };
745
+ export { registerSocketRoutes, socketRouteRegistrar, socketRuntime };
746
+ export default SocketPackage;
@@ -0,0 +1 @@
1
+ export type {};
@@ -0,0 +1,14 @@
1
+ import { socketRouteRegistrar, socketRuntime } from './index.js';
2
+ const importCore = async () => {
3
+ try {
4
+ return (await import('@zintrust/core'));
5
+ }
6
+ catch {
7
+ return {};
8
+ }
9
+ };
10
+ const core = await importCore();
11
+ if (core.SocketRuntimeRegistry !== undefined) {
12
+ core.SocketRuntimeRegistry.registerRuntime(socketRuntime);
13
+ core.SocketRuntimeRegistry.registerRoutes(socketRouteRegistrar);
14
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@zintrust/socket",
3
+ "version": "0.4.58",
4
+ "description": "Unified socket runtime for ZinTrust.",
5
+ "private": false,
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ },
17
+ "./register": {
18
+ "types": "./dist/register.d.ts",
19
+ "default": "./dist/register.js"
20
+ }
21
+ },
22
+ "engines": {
23
+ "node": ">=20.0.0"
24
+ },
25
+ "peerDependencies": {
26
+ "@zintrust/core": "^0.4.58"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "keywords": [
32
+ "zintrust",
33
+ "socket",
34
+ "websocket",
35
+ "broadcast"
36
+ ],
37
+ "scripts": {
38
+ "build": "tsc -p tsconfig.json",
39
+ "prepublishOnly": "npm run build"
40
+ }
41
+ }