フリーのWebSocketサービスは、意図せずサーバーが落ちていたり、いつのまにか仕様が変わっていたり、ぱっと使いたいときに動かないケースが多々あります。
気楽にWebSocketが使えるように、また有料サーバーの通信を強化する意味で、複数のソケット接続を束ねてメッセージ送受信できるものを作ってみた。
使用サーバー
サービス | タイプ | 強さ |
---|---|---|
Achex | 無料 | たまに落ちている |
Scaledrone | 無料 | そこそこ安定 |
PieSocket | 有料 | 安定 ※絶対落ちないわけではない |
使い方
使用するパスでメッセージのチャンネルを切り替えています。
同じディレクトリにいるページ同士でメッセージ送受信
var outer = new Outer(
{
test :e=>{console.log(e)},//タイプ指定受信
dummy :e=>{console.log(e)},
sample :e=>{console.log(e)},
},
e=>{console.log(e)}//全受信
)
button.onclick = e=>{
outer.on({type:'test',value:'メッセージなど'})//送信
}
コード
test.html
<!DOCTYPE html>
<meta charset="UTF-8">
<script type="module">
//メッセージ送受信のテストは、このページを複数ブラウザで開く
import {Outer} from './Outer.js'
class View{
constructor(s,...a){
this.node = document.createElement(s)
this.add(...a)
}
add(...a){
for(var v of a){
switch(typeof v){
case 'function' :this.assign({onclick:v}) ;break
default :this.append(v)
}
}
return this
}
assign(...a){Object.assign(this.node,...a);return this}
append(...a){a.forEach(v=>this.node.append(v.node||v));return this}
flush(){document.body.append(this.node);return this}
}
var outer = new Outer(e=>{
console.log(e)
new View('div',JSON.stringify(e)).flush()
})
new View('button','send',e=>{
var value = new Date().toLocaleString()
outer.on({type:'test',value})
}).flush()
</script>
Outer.js
class Dispatcher{
constructor(...a){
this.hooks = new Listeners()
this.add(...a)
}
add(...a){
for(var v of a){
switch(typeof v){
case 'object' :this.addObject(v) ;break
case 'function' :this.hooks.add(v) ;break
}
}
return this
}
addObject(o){this.hooks.add(o)}
on(...a){this.hooks.on(...a);return this}
}
export class Outer extends Dispatcher{
constructor(...a){
super()
this.items = []
this.tags = []
this.add(
new PieSocket(),
new Scaledrone(),
new Achex(),
new Inner(),
{RECONNECT:e=>{//再接続コマンド
var offline = this.items.filter(v=>v.connecting!=true)
offline.forEach(v=>v.open())
}},
...a
).open()
console.log(this)
}
addObject(o){
if(o instanceof Socket){
o.add({message:e=>this.onComming(e.value)})
this.items.push(o)
}else{
super.addObject(o)
}
}
open(){
//ルーム入場キーを作るのに叩いてる、phpでなくともよい
var url = import.meta.url.match(/.+\/\w+/)[0]+'.php'
var body = this.getLocation()
fetch(url,{method:'POST',body}).then(r=>r.json()).then(data=>{
console.log(data)
this.items.forEach(v=>v.config(data).open())
})
return this
}
on(...a){return this.onGoing(...a)}
onGoing(...a){//メッセージ送信
var e = Object.assign({},...a,{tag:this.random()})
this.record(e)
this.items.forEach(v=>v.onGoing(e))
return this
}
onComming(e){//メッセージ受信
if(this.includes(e)) return this
this.record(e)
this.hooks.on(e)
return this
}
includes(e){
var entry = this.tags.find(v=>v[0]==e.tag)
if(entry){
var ms = performance.now()-entry[1]
console.log(e.tag,'+'+ms.toFixed(1)+'ms',e.via)//エコー
return true
}else{
return false
}
}
record(e){
this.tags.push([e.tag,performance.now()])
this.tags = this.tags.slice(-30)
}
getLocation(){
var s = location.href.split('?')[0]
var a = s.split('/').slice(0,-1)
return a.join('/')
}
random(){return Math.random().toString(36).slice(2)}
}
class Socket extends Dispatcher{
get connecting(){return this.client?.readyState==WebSocket.OPEN}
get name(){return this.constructor.name}
config(hash){return this}
open(){return this}
close(){
if(this.connecting) this.client.close()
return this
}
onComming(data){
var e = JSON.parse(data)
e.via = this.name//経路、デバッグ用
return this.on({type:'message',value:e})
}
onGoing(e){
if(this.connecting) this.send(e)
return this
}
send(...a){
var event = Object.assign({},...a)
return this.client.send(JSON.stringify(event))
}
}
class Inner extends Socket{
//ローカルストレージのダミーソケット
constructor(){
super()
this.key = null
this.client = this
this.readyState = WebSocket.CLOSED
window.addEventListener('storage',e=>{
if(this.connecting && e.newValue && e.key==this.key){
this.onComming(e.newValue)
}
})
}
config(hash){
this.key = [hash.SHA256S,this.name].join("\n")
return this
}
open(){
this.readyState = WebSocket.OPEN
return this.on({type:'status',value:'open'})
}
close() {
this.readyState = WebSocket.CLOSED
return this.on({type:'status',value:'close'})
}
send(e){
localStorage.removeItem(this.key)
localStorage.setItem(this.key,JSON.stringify(e))
return this
}
}
class PieSocket extends Socket{
//https://piehost.com/
config(hash){
var API_KEY = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'//管理画面から
this.request = {url:`wss://free3.piesocket.com/v3/${hash.MD5S}?api_key=${API_KEY}`}
return this
}
}
class Scaledrone extends Socket{
//https://www.scaledrone.com/
config(hash){
var channel = 'XXXXXXXXXXXXXXXX'//管理画面から
var room = hash.SHA256S
this.request = {
url : 'wss://api.scaledrone.com/v3/websocket',
handshake : {type:'handshake',channel,version:2,callback:1},
subscribe : {type:'subscribe',room,callback:2},
publish : {type:'publish',room,message:{}},
}
return this
}
open(){
if(this.connecting) return this
if(this.request){
this.client = Object.assign(new WebSocket(this.request.url),{
onmessage :message=>{
var data = JSON.parse(message.data)
this.on({type:'status',value:data})
switch(data.callback){
case 1 : this.subscribe(data) ;break
case 2 : this.client.onmessage = e=>this.onComming(e.data) ;break
}
},
onopen :e=>this.on({type:'status',value:e}).send(this.request.handshake),
onclose :e=>this.on({type:'status',value:e}),
onerror :e=>this.on({type:'status',value:e}),
})
}
return this
}
subscribe(e){
if('error' in e) return this.close()//channel入れなくてもエラーは来ないので閉じる
return this.send(this.request.subscribe)
}
onComming(data){
var v = JSON.parse(data)
if(v?.message?.type){
var e = v.message
e.via = this.name
this.on({type:'message',value:e})
}else{
this.on({type:'status',value:v})
}
return this
}
onGoing(e){
if(this.connecting) this.send(this.request.publish,{message:e})
return this
}
}
class Achex extends Socket{
//https://www.achex.ca/
config(hash){
var hub = hash.SHA256S
this.request = {
url : 'wss://cloud.achex.ca/'+hub,
auth : {auth:this.random()},
join : {joinHub:hub},//passwd効いてない
to : {toH:hub},
}
return this
}
open(){
if(this.connecting) return this
if(this.request){
this.client = Object.assign(new WebSocket(this.request.url),{
onmessage :message=>{
var data = JSON.parse(message.data)
this.on({type:'status',value:data})
switch('OK'){
case data.auth : this.send(this.request.join) ;break
case data.joinHub : this.client.onmessage = e=>this.onComming(e.data) ;break
}
},
onopen :e=>this.on({type:'status',value:e}).send(this.request.auth),
onclose :e=>this.on({type:'status',value:e}),
onerror :e=>this.on({type:'status',value:e}),
})
}
return this
}
onComming(data){
var e = JSON.parse(data)
if('type' in e){
e.via = this.name
this.on({type:'message',value:e})
}else{
this.on({type:'status',value:e})
}
return this
}
onGoing(e){
if(this.connecting) this.send(e,this.request.to)
return this
}
random(){return Math.random().toString(36).slice(2)}
}
class Listeners extends Array{
add(...a){
for(var v of a){
switch(typeof v){
case 'object' :this.addObject(v) ;break
case 'function' :this.push([true,v]) ;break
}
}
return this
}
addObject(o){
if(o==null) return
switch(o.constructor){
case Object :this.addEntry(...Object.entries(o));break
case Array :this.addEntry(o) ;break
}
}
addEntry(...entries){
for(var entry of entries){
if(typeof entry[1]=='function') this.push(entry)
}
}
on(...a){
var e = Object.assign({},...a)
if('type' in e) this.dispatch(e)
return this
}
dispatch(e){
this.filter(a=>{
if(a[0]==e.type) return true
return a[0]==true
}).forEach(a=>a[1](e))
return this
}
}
Outer.php
header('Content-Type: text/javascript');
echo json_encode(getKey(file_get_contents('php://input')));
function getKey($k){
$data = array(
'KEY' => $k,
'SHA256' => hash('sha256',$k),
'SHA256S' => '',
'MD5' => hash('md5',$k),
'MD5S' => '',
);
$data['SHA256S'] = short($data['SHA256']);
$data['MD5S'] = short($data['MD5']);
return $data;
}
function short($hash){
$s = base64_encode(pack('H*',$hash));
return strtr(rtrim($s,'='),'+/','-_');
}