@socketo/cli 0.0.0-alpha.11
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/LICENSE +7 -0
- package/README.md +97 -0
- package/dist/index.js +27 -0
- package/dist/worker.js +1 -0
- package/package.json +44 -0
package/LICENSE
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# @socketo/cli
|
|
2
|
+
|
|
3
|
+
Local Pusher-compatible WebSocket server. Drop-in replacement for Pusher Channels during development — same protocol, zero config, no API key needed.
|
|
4
|
+
|
|
5
|
+
Built on [Miniflare](https://miniflare.dev/) and [Cloudflare Durable Objects](https://developers.cloudflare.com/durable-objects/), using the WebSocket Standard API for persistent in-memory state.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @socketo/cli start
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Server listens at `ws://localhost:8787`.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
npx @socketo/cli start [options]
|
|
17
|
+
|
|
18
|
+
-p, --port <port> Port (default: 8787)
|
|
19
|
+
--webhook-url <url> Webhook URL for server-side callbacks
|
|
20
|
+
--webhook-secret <secret> HMAC secret for webhook signatures
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Client SDK
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
// Client
|
|
27
|
+
import PusherJS from 'pusher-js'
|
|
28
|
+
|
|
29
|
+
const pusher = new Pusher('local', {
|
|
30
|
+
wsHost: 'localhost',
|
|
31
|
+
wsPort: 8787,
|
|
32
|
+
forceTLS: false,
|
|
33
|
+
enabledTransports: ['ws'],
|
|
34
|
+
cluster: 'local',
|
|
35
|
+
})
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Server SDK
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import Pusher from 'pusher'
|
|
42
|
+
|
|
43
|
+
const server = new Pusher({
|
|
44
|
+
appId: 'local',
|
|
45
|
+
key: 'local',
|
|
46
|
+
secret: 'local',
|
|
47
|
+
host: 'localhost:8787',
|
|
48
|
+
useTLS: false,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
server.trigger('my-channel', 'my-event', { hello: 'world' })
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Supported
|
|
55
|
+
|
|
56
|
+
**WebSocket Protocol**
|
|
57
|
+
- `pusher:connection_established` handshake
|
|
58
|
+
- Subscribe / unsubscribe (public, private, presence)
|
|
59
|
+
- Client events (`client-*`)
|
|
60
|
+
- Ping / pong
|
|
61
|
+
|
|
62
|
+
**Presence Channels**
|
|
63
|
+
- `channel_data` with `user_id` and `user_info`
|
|
64
|
+
- `pusher_internal:subscription_succeeded` with member list
|
|
65
|
+
- `member_added` / `member_removed` events
|
|
66
|
+
|
|
67
|
+
**REST API**
|
|
68
|
+
|
|
69
|
+
| Method | Path | Description |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `POST` | `/apps/local/events` | Trigger events |
|
|
72
|
+
| `POST` | `/apps/local/batch_events` | Trigger up to 10 events |
|
|
73
|
+
| `GET` | `/apps/local/channels` | List channels |
|
|
74
|
+
| `GET` | `/apps/local/channels/{name}` | Channel info |
|
|
75
|
+
| `GET` | `/apps/local/channels/{name}/users` | Presence users |
|
|
76
|
+
| `POST` | `/apps/local/users/{id}/terminate_connections` | Disconnect user |
|
|
77
|
+
|
|
78
|
+
Query params: `?filter_by_prefix=` and `?info=user_count,subscription_count`.
|
|
79
|
+
|
|
80
|
+
**Webhooks**
|
|
81
|
+
|
|
82
|
+
`POST` to your URL when channels are occupied/vacated, members join/leave, or client events fire. Payload matches Pusher's webhook format with `X-Pusher-Key` and `X-Pusher-Signature` headers.
|
|
83
|
+
|
|
84
|
+
**State**
|
|
85
|
+
|
|
86
|
+
Connection state survives indefinitely (no hibernation). Channels, members, and user data persist across requests.
|
|
87
|
+
|
|
88
|
+
**CORS**
|
|
89
|
+
|
|
90
|
+
All HTTP endpoints return `Access-Control-Allow-Origin: *`.
|
|
91
|
+
|
|
92
|
+
## Not Supported
|
|
93
|
+
|
|
94
|
+
- Authentication (auth signatures, body_md5)
|
|
95
|
+
- Encrypted channels (E2E)
|
|
96
|
+
- Cache channels
|
|
97
|
+
- TLS / WSS
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import{dirname as l,join as i}from"path";import{fileURLToPath as a}from"url";import{parseArgs as p}from"util";import{Miniflare as h}from"miniflare";var w=l(a(import.meta.url)),n=`
|
|
3
|
+
Socketo CLI - Pusher-compatible local WebSocket server
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
npx @socketo/cli start [options]
|
|
7
|
+
|
|
8
|
+
Options:
|
|
9
|
+
-p, --port <port> Port number (default: 8787)
|
|
10
|
+
--webhook-url <url> Pusher-compatible webhook URL (POST)
|
|
11
|
+
--webhook-secret <secret> HMAC secret for webhook signatures
|
|
12
|
+
-h, --help Show this help message
|
|
13
|
+
`,{positionals:b,values:e}=p({args:process.argv.slice(2),options:{port:{type:"string",short:"p",default:"8787"},"webhook-url":{type:"string"},"webhook-secret":{type:"string"},help:{type:"boolean",short:"h"}},allowPositionals:!0}),t=b[0];(e.help||!t)&&(console.log(n),process.exit(0));t!=="start"&&(console.error(`Error: Unknown command '${t}'`),console.log(n),process.exit(1));var o=parseInt(e.port,10);(!o||o<1||o>65535)&&(console.error(`Error: Invalid port number '${e.port}'`),process.exit(1));var s=e["webhook-url"]||"",c=e["webhook-secret"]||"";s&&!c&&console.log("Warning: --webhook-url set without --webhook-secret, webhooks will not be HMAC-signed");var r=new h({scriptPath:i(w,"worker.js"),durableObjects:{WEBSOCKET_SERVER:"WebSocketServer"},modules:!0,port:o,bindings:{WEBHOOK_URL:s,WEBHOOK_SECRET:c}});async function m(){await r.ready,console.log(`
|
|
14
|
+
Socketo server ready at ws://localhost:${o}
|
|
15
|
+
App key: local
|
|
16
|
+
|
|
17
|
+
Pusher client config:
|
|
18
|
+
|
|
19
|
+
new Pusher('local', {
|
|
20
|
+
wsHost: 'localhost',
|
|
21
|
+
wsPort: ${o},
|
|
22
|
+
forceTLS: false,
|
|
23
|
+
enabledTransports: ['ws'],
|
|
24
|
+
cluster: 'local',
|
|
25
|
+
})${s?`
|
|
26
|
+
|
|
27
|
+
Webhooks: POST to ${s}`:""}`),process.on("SIGINT",async()=>{await r.dispose(),process.exit(0)}),process.on("SIGTERM",async()=>{await r.dispose(),process.exit(0)})}m();
|
package/dist/worker.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var T="local",A=class{constructor(e,n){this.url=e;this.secret=n}notify(e){let n=JSON.stringify({time_ms:Date.now(),events:[e]});this.post(n)}async post(e){try{let n={"Content-Type":"application/json","X-Pusher-Key":"local"};if(this.secret){let t=new TextEncoder,o=await crypto.subtle.importKey("raw",t.encode(this.secret),{name:"HMAC",hash:"SHA-256"},!1,["sign"]);n["X-Pusher-Signature"]=Array.from(new Uint8Array(await crypto.subtle.sign("HMAC",o,t.encode(e)))).map(r=>r.toString(16).padStart(2,"0")).join("")}await fetch(this.url,{method:"POST",headers:n,body:e})}catch{}}},l=null,R=class{sockets=new Map;channels=new Map;users=new Map;addSocket(e,n){this.sockets.set(e,n)}removeSocket(e){this.sockets.delete(e)}addToChannel(e,n){this.channels.has(n)||this.channels.set(n,new Set);let t=this.channels.get(n);t.add(e),t.size===1&&l&&l.notify({name:"channel_occupied",channel:n})}removeFromChannel(e,n){let t=this.channels.get(n);t&&(t.delete(e),t.size===0&&(this.channels.delete(n),l&&l.notify({name:"channel_vacated",channel:n})))}removeSocketAllChannels(e){for(let[n,t]of this.channels)t.delete(e),t.size===0&&this.channels.delete(n)}publish(e){let{channel:n,exceptId:t,event:o,data:r}=e,f=this.channels.get(n);if(f)for(let g of f){if(g===t)continue;let i=this.sockets.get(g);i&&this.sendTo(i,{event:o,channel:n,data:r})}}sendTo(e,n){try{e.send(JSON.stringify(n))}catch{e.close()}}getSocketsCount(){return this.sockets.size}getAllChannels(){let e={};for(let[n,t]of this.channels)e[n]=t.size;return e}getChannelInfo(e){let n=this.channels.get(e);if(n)return{subscription_count:n.size}}getChannelUsers(e){let n=this.channels.get(e);if(!n)return[];let t=[];for(let o of n){let r=this.users.get(o);r&&t.push({id:r.userId,user_info:r.userInfo})}return t}getUserInfo(e){return this.users.get(e)}setUserInfo(e,n){this.users.set(e,n)}removeUserInfo(e){this.users.delete(e)}terminateUserConnections(e){for(let[n,t]of this.users)if(t.userId===e){let o=this.sockets.get(n);o&&o.close()}}},I=class{ctx;connections=new R;constructor(e,n){this.ctx=e}async fetch(e){let n=new URL(e.url),t=n.pathname.match(/^\/app\/([^/]+)$/),o=n.pathname.match(/^\/apps\/([^/]+)(\/(.+))?$/);if(!t&&!o)return new Response("Not Found",{status:404,headers:{"Access-Control-Allow-Origin":"*"}});if((t?t[1]:o[1])!==T)return new Response("Invalid app key",{status:403,headers:{"Access-Control-Allow-Origin":"*"}});if(e.method==="OPTIONS")return new Response(null,{status:204,headers:{"Access-Control-Allow-Origin":"*","Access-Control-Allow-Methods":"GET, POST, OPTIONS","Access-Control-Allow-Headers":"Content-Type","Access-Control-Max-Age":"86400"}});if(o){let c=o[3]||"";if(e.method==="POST"&&c==="events")return this.handleEventTrigger(e);if(e.method==="POST"&&c==="batch_events")return this.handleBatchEvents(e);if(e.method==="GET"&&c==="channels")return this.handleChannelList(e);let h=c.match(/^channels\/([^/]+)$/);if(e.method==="GET"&&h)return this.handleChannelInfo(h[1]);let u=c.match(/^channels\/([^/]+)\/users$/);if(e.method==="GET"&&u)return this.handleChannelUsers(u[1]);let s=c.match(/^users\/(.+)\/terminate_connections$/);return e.method==="POST"&&s?this.handleTerminateConnections(s[1]):new Response("Not Found",{status:404,headers:{"Access-Control-Allow-Origin":"*"}})}let f=e.headers.get("Upgrade");if(!f||f!=="websocket")return new Response("Expected Upgrade: websocket",{status:426,headers:{"Access-Control-Allow-Origin":"*"}});let[g,i]=Object.values(new WebSocketPair),d=crypto.randomUUID();return i.serializeAttachment({id:d,channels:new Set}),i.accept(),this.connections.addSocket(d,i),this.connections.sendTo(i,{event:"pusher:connection_established",data:JSON.stringify({socket_id:d,activity_timeout:30})}),i.addEventListener("message",c=>{try{let h=c.data,u=typeof h=="string"?h:new TextDecoder().decode(h),s=JSON.parse(u);if(s.event==="pusher:ping"){this.connections.sendTo(i,{event:"pusher:pong",data:{}});return}let{id:p,channels:w}=i.deserializeAttachment();if(s.event==="pusher:subscribe"){let a=s.data.channel;if(w.has(a)){this.connections.sendTo(i,{event:"pusher:error",data:{code:4100,message:"Already subscribed to channel"}});return}let y=a.startsWith("presence-"),m,_;if(y){let b=s.data.channel_data;if(b)try{let O=JSON.parse(b);m=O.user_id,_=O.user_info??{},this.connections.setUserInfo(p,{userId:m,userInfo:_})}catch{}}if(w.add(a),i.serializeAttachment({id:p,channels:w}),this.connections.addToChannel(p,a),y){let b=this.connections.getChannelUsers(a),O=b.map(C=>C.id),S={};for(let C of b)S[C.id]=C.user_info??{};let k=JSON.stringify({presence:{ids:O,hash:S,count:O.length}});this.connections.sendTo(i,{event:"pusher_internal:subscription_succeeded",channel:a,data:k}),m&&(this.connections.publish({event:"pusher_internal:member_added",channel:a,data:JSON.stringify({user_id:m,user_info:_}),exceptId:p}),l&&l.notify({name:"member_added",channel:a,user_id:m}))}else this.connections.sendTo(i,{event:"pusher_internal:subscription_succeeded",channel:a,data:"{}"})}else if(s.event==="pusher:unsubscribe"){let a=s.data.channel,y=a.startsWith("presence-"),m=y?this.connections.getUserInfo(p):void 0;w.delete(a),i.serializeAttachment({id:p,channels:w}),this.connections.removeFromChannel(p,a),y&&m&&(this.connections.publish({event:"pusher_internal:member_removed",channel:a,data:JSON.stringify({user_id:m.userId})}),l&&l.notify({name:"member_removed",channel:a,user_id:m.userId}))}else if(s.event.startsWith("client-")){let a=s.data.channel||s.channel||"";if(a&&w.has(a)&&(this.connections.publish({event:s.event,channel:a,data:s.data,exceptId:p}),l)){let y=this.connections.getUserInfo(p);l.notify({name:"client_event",channel:a,event:s.event,data:typeof s.data=="string"?s.data:JSON.stringify(s.data),socket_id:p,...y?{user_id:y.userId}:{}})}}}catch{}}),i.addEventListener("close",()=>{try{let{id:c,channels:h}=i.deserializeAttachment(),u=this.connections.getUserInfo(c);for(let s of h)s.startsWith("presence-")&&u&&(this.connections.publish({event:"pusher_internal:member_removed",channel:s,data:JSON.stringify({user_id:u.userId})}),l&&l.notify({name:"member_removed",channel:s,user_id:u.userId}));this.connections.removeSocket(c),this.connections.removeSocketAllChannels(c),this.connections.removeUserInfo(c)}catch{}}),new Response(null,{status:101,webSocket:g})}async handleEventTrigger(e){try{let n=await e.text(),t=JSON.parse(n),o=t.name,r=t.channels,f=t.channel,g=t.data??"",i=t.socket_id,d=t.info,c=r??(f?[f]:[]);if(!o||c.length===0)return new Response("Invalid payload",{status:400,headers:{"Access-Control-Allow-Origin":"*"}});for(let h of c)this.connections.publish({event:o,channel:h,data:typeof g=="string"?g:JSON.stringify(g),exceptId:i});if(d){let h=d.split(",").map(s=>s.trim()),u={};for(let s of c){u[s]={};let p=this.connections.getChannelInfo(s);if(p&&(h.includes("subscription_count")&&(u[s].subscription_count=p.subscription_count),h.includes("user_count"))){let w=this.connections.getChannelUsers(s);u[s].user_count=w.length}}return new Response(JSON.stringify({channels:u}),{status:200,headers:{"Content-Type":"application/json","Access-Control-Allow-Origin":"*"}})}return new Response("{}",{status:200,headers:{"Content-Type":"application/json","Access-Control-Allow-Origin":"*"}})}catch{return new Response("Invalid payload",{status:400,headers:{"Access-Control-Allow-Origin":"*"}})}}handleChannelList(e){let n=new URL(e.url),t=n.searchParams.get("filter_by_prefix")||"",o=n.searchParams.get("info")||"",r=this.connections.getAllChannels(),f={};for(let[d,c]of Object.entries(r))(!t||d.startsWith(t))&&(f[d]=c);let g=o.split(",").map(d=>d.trim()).filter(Boolean),i={};for(let[d]of Object.entries(f))if(i[d]={},g.includes("subscription_count")&&(i[d].subscription_count=f[d]),g.includes("user_count")&&d.startsWith("presence-")){let c=this.connections.getChannelUsers(d);i[d].user_count=c.length}return new Response(JSON.stringify({channels:i}),{status:200,headers:{"Content-Type":"application/json","Access-Control-Allow-Origin":"*"}})}handleChannelInfo(e){return this.connections.getChannelInfo(e)?new Response(JSON.stringify({occupied:!0}),{status:200,headers:{"Content-Type":"application/json","Access-Control-Allow-Origin":"*"}}):new Response("Not Found",{status:404,headers:{"Access-Control-Allow-Origin":"*"}})}handleChannelUsers(e){let n=this.connections.getChannelUsers(e);return new Response(JSON.stringify({users:n.map(t=>({id:t.id}))}),{status:200,headers:{"Content-Type":"application/json","Access-Control-Allow-Origin":"*"}})}async handleBatchEvents(e){try{let n=await e.text(),o=JSON.parse(n).batch;if(!o||!Array.isArray(o)||o.length===0)return new Response("Invalid payload",{status:400,headers:{"Access-Control-Allow-Origin":"*"}});for(let r of o)!r.name||!r.channel||this.connections.publish({event:r.name,channel:r.channel,data:typeof r.data=="string"?r.data:JSON.stringify(r.data),exceptId:r.socket_id});return new Response("{}",{status:200,headers:{"Content-Type":"application/json","Access-Control-Allow-Origin":"*"}})}catch{return new Response("Invalid payload",{status:400,headers:{"Access-Control-Allow-Origin":"*"}})}}handleTerminateConnections(e){return this.connections.terminateUserConnections(e),new Response("{}",{status:200,headers:{"Content-Type":"application/json","Access-Control-Allow-Origin":"*"}})}},U={async fetch(v,e){!l&&e.WEBHOOK_URL&&(l=new A(e.WEBHOOK_URL,e.WEBHOOK_SECRET||""));let n=new URL(v.url);if(n.pathname.startsWith("/app/")||n.pathname.startsWith("/apps/")){let t=e.WEBSOCKET_SERVER.idFromName("default");return e.WEBSOCKET_SERVER.get(t).fetch(v)}return new Response("Not Found",{status:404,headers:{"Access-Control-Allow-Origin":"*"}})}};export{I as WebSocketServer,U as default};
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@socketo/cli",
|
|
3
|
+
"version": "0.0.0-alpha.11",
|
|
4
|
+
"description": "Local Pusher-compatible WebSocket server for development",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"homepage": "https://socketo.dev",
|
|
8
|
+
"author": "eckoln",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"pusher",
|
|
11
|
+
"websocket",
|
|
12
|
+
"local",
|
|
13
|
+
"dev",
|
|
14
|
+
"miniflare",
|
|
15
|
+
"durable-objects",
|
|
16
|
+
"channels"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=22"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"bin": {
|
|
25
|
+
"socketo": "dist/index.js"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsup",
|
|
32
|
+
"start": "bun run dist/index.js start",
|
|
33
|
+
"test": "bun test",
|
|
34
|
+
"prepublishOnly": "bun run build"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"miniflare": "^4.20260504.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@cloudflare/workers-types": "^4.20260506.1",
|
|
41
|
+
"tsup": "^8.5.1",
|
|
42
|
+
"typescript": "^6.0.3"
|
|
43
|
+
}
|
|
44
|
+
}
|