WebSocket冗長化 

フリーのWebSocketサービスは、意図せずサーバーが落ちていたり、いつのまにか仕様が変わっていたり、ぱっと使いたいときに動かないケースが多々あります。
気楽にWebSocketが使えるように、また有料サーバーの通信を強化する意味で、複数のソケット接続を束ねてメッセージ送受信できるものを作ってみた。

## 使用サーバー
|サービス| タイプ |強さ  |
|---|---|---|
|Achex　  |無料  |たまに落ちている  |
|Scaledrone　  |無料  |そこそこ安定  |
|PieSocket|有料  |安定 ※絶対落ちないわけではない  |

## 使い方
使用するパスでメッセージのチャンネルを切り替えています。
同じディレクトリにいるページ同士でメッセージ送受信
```js
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:'メッセージなど'})//送信
}
```

## コード
```js: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>
```
```js: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
	}
}
```
```php: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,'='),'+/','-_');
}
```