@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 ADDED
@@ -0,0 +1,7 @@
1
+ Copyright (c) 2026 eckoln
2
+
3
+ All rights reserved.
4
+
5
+ This software is proprietary and not open source. You may use this software
6
+ as published on npm, but you may not modify, redistribute, or create
7
+ derivative works without explicit permission.
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
+ }