@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 +251 -0
- package/dist/build-manifest.json +41 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +746 -0
- package/dist/register.d.ts +1 -0
- package/dist/register.js +14 -0
- package/package.json +41 -0
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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 {};
|
package/dist/register.js
ADDED
|
@@ -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
|
+
}
|