itty-sockets 0.6.0 → 0.7.0
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/.github/FUNDING.yml +12 -0
- package/.github/workflows/coverage.yml +25 -0
- package/.github/workflows/lint.yml +19 -0
- package/.github/workflows/verify.yml +19 -0
- package/CHANGELOG.md +29 -0
- package/README.md +19 -29
- package/bun.lockb +0 -0
- package/package.json +2 -2
- package/src/connect.spec.ts +570 -0
- package/src/connect.ts +133 -0
- package/src/connect.ts.backup +183 -0
- package/tsconfig.json +25 -0
- package/connect.d.ts +0 -61
- package/connect.js +0 -1
- package/connect.mjs +0 -1
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# These are supported funding model platforms
|
|
2
|
+
|
|
3
|
+
github: kwhitley
|
|
4
|
+
open_collective: kevinrwhitley
|
|
5
|
+
# patreon: # Replace with a single Patreon username
|
|
6
|
+
# ko_fi: # Replace with a single Ko-fi username
|
|
7
|
+
# tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
|
8
|
+
# community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
|
9
|
+
# liberapay: # Replace with a single Liberapay username
|
|
10
|
+
# issuehunt: # Replace with a single IssueHunt username
|
|
11
|
+
# otechie: # Replace with a single Otechie username
|
|
12
|
+
# custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
on: [push, pull_request]
|
|
2
|
+
|
|
3
|
+
name: test
|
|
4
|
+
|
|
5
|
+
jobs:
|
|
6
|
+
build:
|
|
7
|
+
name: Build
|
|
8
|
+
runs-on: ubuntu-latest
|
|
9
|
+
steps:
|
|
10
|
+
- uses: actions/checkout@v3
|
|
11
|
+
with:
|
|
12
|
+
fetch-depth: 0
|
|
13
|
+
|
|
14
|
+
- uses: oven-sh/setup-bun@v2.0.1
|
|
15
|
+
|
|
16
|
+
- name: Clean workspace and run tests
|
|
17
|
+
run: |
|
|
18
|
+
git clean -xfd # Remove all untracked files, including stale tests
|
|
19
|
+
bun install
|
|
20
|
+
bun test --coverage --coverage-reporter=text --coverage-reporter=lcov
|
|
21
|
+
|
|
22
|
+
- name: Coveralls
|
|
23
|
+
uses: coverallsapp/github-action@master
|
|
24
|
+
with:
|
|
25
|
+
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
name: lint
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [v0.x]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [v0.x]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
build:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v2
|
|
15
|
+
- uses: oven-sh/setup-bun@v2.0.1
|
|
16
|
+
- name: install dependencies
|
|
17
|
+
run: bun install
|
|
18
|
+
- name: lint
|
|
19
|
+
run: bun run lint
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
name: verify
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [v0.x]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [v0.x]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
build:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v2
|
|
15
|
+
- uses: oven-sh/setup-bun@v2.0.1
|
|
16
|
+
- name: Install dependencies
|
|
17
|
+
run: bun install
|
|
18
|
+
- name: Build
|
|
19
|
+
run: bun run build
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
## Changelog
|
|
2
|
+
CAUTION: Pre v1.0.0, this should be considered an alpha release, with minor updates allowing for breaking changes to the interface.
|
|
3
|
+
#### v0.7.0
|
|
4
|
+
- added: .on('*', listener) to respond to *any* messages (e.g. normal, custom, join, leave, error, etc)
|
|
5
|
+
#### v0.6.0
|
|
6
|
+
- added: .on('custom-type', listener) support, keying off payload.type
|
|
7
|
+
- added: .on(filterFunction, listener) support to route listeners based on custom payload rules
|
|
8
|
+
- added: .on('message', listener) still works, catching *all* user-sent messages
|
|
9
|
+
- added: full TypeScript generics support for .on and .send functions
|
|
10
|
+
- added: for convenience, we now destructure the message payload into the top-level event (before the event props)
|
|
11
|
+
- breaking: removed Date casting of event.date, now left as numeric timestamp
|
|
12
|
+
#### v0.5.0
|
|
13
|
+
- BREAKING: removed base (url) option - instead, simply pass a full wss:// path as the channelId to use an external compatible server.
|
|
14
|
+
#### v0.4.0
|
|
15
|
+
- added: base (url) option
|
|
16
|
+
#### v0.3.1
|
|
17
|
+
- fixes: module export
|
|
18
|
+
- removes extra NPM files
|
|
19
|
+
#### v0.3.0
|
|
20
|
+
- breaking: every event has multiple listeners (previously only on('message') allowed multiple)
|
|
21
|
+
- added: join/leave/error event types
|
|
22
|
+
- added: .remove('event-name', listener) to remove listeners
|
|
23
|
+
#### v0.2.3
|
|
24
|
+
- fix type hinting on listeners
|
|
25
|
+
- improve type hinting on send/push
|
|
26
|
+
#### v0.2.0
|
|
27
|
+
- alpha release 2
|
|
28
|
+
#### v0.1.0
|
|
29
|
+
- alpha release 1
|
package/README.md
CHANGED
|
@@ -18,32 +18,19 @@
|
|
|
18
18
|
|
|
19
19
|
---
|
|
20
20
|
|
|
21
|
-
###
|
|
21
|
+
### Say goodbye to WebSocket boilerplate.
|
|
22
|
+
|
|
23
|
+
Your own wrapper is bigger, I promise.
|
|
24
|
+
|
|
25
|
+
Or [optionally] go a step further and use the integrated [itty.ws](https://itty.ws) connection to send messages (zero-config, zero-tracking, 100% free).
|
|
22
26
|
|
|
23
27
|
## Features ✨
|
|
24
|
-
1. **DX perks** - JSON-in/out, queued messages, easy-reconnect, chainable everything
|
|
25
|
-
1. **Works with any JSON-based WebSocket server**
|
|
26
|
-
1. **Powerful
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
```
|
|
31
|
-
- Only specific types
|
|
32
|
-
```ts
|
|
33
|
-
// matches any data with { type: 'chat' }
|
|
34
|
-
.on('chat', ({ text }) => console.log(text))
|
|
35
|
-
```
|
|
36
|
-
- or fully custom filters
|
|
37
|
-
```ts
|
|
38
|
-
// matches only messages where data.value > 20
|
|
39
|
-
.on(
|
|
40
|
-
({ value }) => value > 20), // filter function
|
|
41
|
-
({ value }) => console.log(value),
|
|
42
|
-
)
|
|
43
|
-
```
|
|
44
|
-
1. **Type-safe message handling**
|
|
45
|
-
1. **Tiny footprint** - 512 bytes, all-in.
|
|
46
|
-
1. **Optional usage with free/public/zero-config [itty.ws](https://itty.ws) service**
|
|
28
|
+
1. **DX perks** - JSON-in/out, queued messages, easy-reconnect, chainable everything
|
|
29
|
+
1. **Works with *any* JSON-based WebSocket server** - it's just a raw WebSocket wrapper, after all
|
|
30
|
+
1. **Powerful routing** - easily handle your own message formats
|
|
31
|
+
1. **Type-safe message handling** - so your app knows what to expect
|
|
32
|
+
1. **No socket server needed** - Use [itty.ws](https://itty.ws) public channels to get started even faster
|
|
33
|
+
1. **Tiny** - under 500 bytes total
|
|
47
34
|
|
|
48
35
|
## Basic Example
|
|
49
36
|
```ts
|
|
@@ -85,7 +72,7 @@ import { connect } from 'itty-sockets'
|
|
|
85
72
|
**Option 2: Just copy this snippet:**
|
|
86
73
|
<!-- BEGIN SNIPPET -->
|
|
87
74
|
```ts
|
|
88
|
-
let connect=(e,s={})=>{let
|
|
75
|
+
let connect=(e,s={})=>{let a,t,n=[],p={},o=()=>(a||(a=new WebSocket((/^wss?:/.test(e)?e:"wss://itty.ws/c/"+e)+"?"+new URLSearchParams(s)),a.onmessage=(e,s=JSON.parse(e.data),a=s?.message,t={...null==a?.[0]&&a,...s})=>[t.type,s.type?0:"message","*"].map(e=>p[e]?.map(e=>e(t))),a.onopen=()=>(n.splice(0).map(e=>a.send(e)),p.open?.map(e=>e(t)),t&&a?.close()),a.onclose=()=>(t=a=null,p.close?.map(e=>e(t)))),l),l={open:o,send:(e,s)=>(e=(s?`${s}`:"")+JSON.stringify(e),1&a?.readyState?a.send(e):n.push(e),o()),on:(e,s)=>((p[e?.[0]?e:"*"]??=[]).push(e?.[0]?s:a=>e?.(a)&&s(a)),o()),remove:(e,s)=>(p[e]=p[e]?.filter(e=>e!=s),l),close:()=>(1&a?.readyState?a.close():t=1,l),push:(e,s)=>(t=1,l.send(e,s))};return l};
|
|
89
76
|
```
|
|
90
77
|
<!-- END SNIPPET -->
|
|
91
78
|
*Note: This will lose TypeScript support.*
|
|
@@ -101,13 +88,14 @@ connect('wss://example.com')
|
|
|
101
88
|
.on('message', console.log)
|
|
102
89
|
|
|
103
90
|
// and just { type: 'chat' }
|
|
104
|
-
.on('chat',
|
|
91
|
+
.on('chat',
|
|
105
92
|
({ user, text }) => console.log(`${user} says: ${text}`)
|
|
106
93
|
)
|
|
107
94
|
```
|
|
108
95
|
|
|
109
96
|
Now let's assume the following 2 messages are sent:
|
|
110
97
|
```json
|
|
98
|
+
// message 1
|
|
111
99
|
{
|
|
112
100
|
"type": "chat",
|
|
113
101
|
"user": "Kevin",
|
|
@@ -116,6 +104,7 @@ Now let's assume the following 2 messages are sent:
|
|
|
116
104
|
```
|
|
117
105
|
|
|
118
106
|
```json
|
|
107
|
+
// message 2
|
|
119
108
|
{
|
|
120
109
|
"date": 1754659171196,
|
|
121
110
|
"items": [1, 2, 3],
|
|
@@ -123,12 +112,13 @@ Now let's assume the following 2 messages are sent:
|
|
|
123
112
|
```
|
|
124
113
|
|
|
125
114
|
This will output the following to the console:
|
|
126
|
-
```
|
|
115
|
+
```js
|
|
116
|
+
// message 1
|
|
127
117
|
{ type: "chat", user: "Kevin", text: "Hey!" }
|
|
118
|
+
"Kevin says: Hey!"
|
|
128
119
|
|
|
120
|
+
// message 2
|
|
129
121
|
{ date: 1754659171196, items: [1, 2, 3] }
|
|
130
|
-
|
|
131
|
-
"Kevin says: Hey!"
|
|
132
122
|
```
|
|
133
123
|
|
|
134
124
|
## Example 3 - Reconnection
|
package/bun.lockb
ADDED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "itty-sockets",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "WebSockets : simplified and minified.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|
|
8
8
|
"import": "./connect.mjs",
|
|
9
9
|
"types": "./connect.d.ts",
|
|
10
|
-
"require": "./connect.
|
|
10
|
+
"require": "./connect.js"
|
|
11
11
|
}
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
import { describe, afterAll, expect, it, mock } from 'bun:test'
|
|
2
|
+
import { connect, type IttySocket, type UseItty } from './connect'
|
|
3
|
+
|
|
4
|
+
type TestLeaf = (args: {
|
|
5
|
+
channel: IttySocket<UseItty>,
|
|
6
|
+
resolve: () => void,
|
|
7
|
+
spy: () => void,
|
|
8
|
+
getChannel: (options?: any) => IttySocket<UseItty>
|
|
9
|
+
}) => void
|
|
10
|
+
|
|
11
|
+
type TestTree = {
|
|
12
|
+
[key: string]: TestTree | TestLeaf
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type ChatMessage = {
|
|
16
|
+
type: 'chat',
|
|
17
|
+
user: string,
|
|
18
|
+
text: string,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const EXPOSED_METHODS = ['send', 'push', 'on', 'remove', 'close', 'open']
|
|
22
|
+
const OPEN_CHANNELS: IttySocket[] = []
|
|
23
|
+
|
|
24
|
+
const tests: TestTree = {
|
|
25
|
+
'NAMED EXPORTS': {
|
|
26
|
+
'import { connect } from "itty-sockets"': {
|
|
27
|
+
'is a function': () => expect(typeof connect).toBe('function'),
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
'connect(id, options?)': {
|
|
31
|
+
'constructs correct WebSocket URL': () => {
|
|
32
|
+
const originalWebSocket = global.WebSocket
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
34
|
+
const mockFn = mock((url: string) => ({}))
|
|
35
|
+
|
|
36
|
+
// @ts-ignore
|
|
37
|
+
global.WebSocket = mockFn
|
|
38
|
+
|
|
39
|
+
connect('my-channel', { a: 'b', c: 'd' } as any).open()
|
|
40
|
+
expect(mockFn.mock.calls[0][0]).toBe('wss://itty.ws/c/my-channel?a=b&c=d')
|
|
41
|
+
|
|
42
|
+
connect('ws://custom.server/path').open()
|
|
43
|
+
expect(mockFn.mock.calls[1][0]).toBe('ws://custom.server/path?')
|
|
44
|
+
|
|
45
|
+
connect('ws://custom.server/path', { echo: true, alias: 'test-user' }).open()
|
|
46
|
+
expect(mockFn.mock.calls[2][0]).toBe('ws://custom.server/path?echo=true&alias=test-user')
|
|
47
|
+
|
|
48
|
+
global.WebSocket = originalWebSocket
|
|
49
|
+
},
|
|
50
|
+
'exposes chainable method': EXPOSED_METHODS.reduce((acc, method) => {
|
|
51
|
+
acc[`.${method}()`] = ({ channel }) => {
|
|
52
|
+
expect(typeof channel[method]).toBe('function')
|
|
53
|
+
expect(channel[method]()).toBe(channel)
|
|
54
|
+
}
|
|
55
|
+
return acc
|
|
56
|
+
}, {} as Record<string, TestLeaf>),
|
|
57
|
+
'OPTIONS': {
|
|
58
|
+
'{ echo: true }': {
|
|
59
|
+
'sends messages back to sender': async ({ getChannel, resolve }) =>
|
|
60
|
+
getChannel({ echo: true })
|
|
61
|
+
.on('message', msg => {
|
|
62
|
+
expect(msg.message).toBe('test')
|
|
63
|
+
resolve()
|
|
64
|
+
})
|
|
65
|
+
.send('test'),
|
|
66
|
+
},
|
|
67
|
+
'{ alias: string }': {
|
|
68
|
+
'sets alias for messages': async ({ getChannel, resolve }) =>
|
|
69
|
+
getChannel({ echo: true, alias: 'test-user' })
|
|
70
|
+
.on('message', msg => {
|
|
71
|
+
expect(msg.alias).toBe('test-user')
|
|
72
|
+
resolve()
|
|
73
|
+
})
|
|
74
|
+
.send('test'),
|
|
75
|
+
'sets alias for join events if { announce: true } is set': async ({ getChannel, resolve }) =>
|
|
76
|
+
getChannel({ echo: true, alias: 'test-user', announce: true })
|
|
77
|
+
.on('join', ({ alias }) => {
|
|
78
|
+
expect(alias).toBe('test-user')
|
|
79
|
+
resolve()
|
|
80
|
+
}),
|
|
81
|
+
},
|
|
82
|
+
'{ as: string }': {
|
|
83
|
+
'sets alias for messages': async ({ getChannel, resolve }) =>
|
|
84
|
+
getChannel({ echo: true, as: 'test-user' })
|
|
85
|
+
.on('message', msg => {
|
|
86
|
+
expect(msg.alias).toBe('test-user')
|
|
87
|
+
resolve()
|
|
88
|
+
})
|
|
89
|
+
.send('test')
|
|
90
|
+
},
|
|
91
|
+
'{ announce: true }': {
|
|
92
|
+
'announces self to channel upon joining/leaving': async ({ getChannel, resolve }) =>
|
|
93
|
+
getChannel({ announce: true, as: 'test-user' })
|
|
94
|
+
.on('join', ({ alias }) => {
|
|
95
|
+
expect(alias).toBe('test-user')
|
|
96
|
+
resolve()
|
|
97
|
+
})
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
'METHODS': {
|
|
101
|
+
'.open()': {
|
|
102
|
+
'opens the socket': async ({ channel, resolve }) =>
|
|
103
|
+
channel
|
|
104
|
+
.on('open', resolve)
|
|
105
|
+
.open(),
|
|
106
|
+
'calling multiple times is fine': async ({ channel, resolve }) =>
|
|
107
|
+
channel
|
|
108
|
+
.on('open', () => {
|
|
109
|
+
channel.open() // one more time for good measure
|
|
110
|
+
resolve()
|
|
111
|
+
})
|
|
112
|
+
.open(),
|
|
113
|
+
},
|
|
114
|
+
'.close()': {
|
|
115
|
+
'closes the socket': async ({ channel, resolve }) =>
|
|
116
|
+
channel
|
|
117
|
+
.on('close', resolve)
|
|
118
|
+
.on('open', channel.close),
|
|
119
|
+
'calling multiple times is fine': async ({ channel, resolve }) =>
|
|
120
|
+
channel
|
|
121
|
+
.on('close',resolve)
|
|
122
|
+
.on('open', () => {
|
|
123
|
+
channel.close()
|
|
124
|
+
channel.close()
|
|
125
|
+
}),
|
|
126
|
+
},
|
|
127
|
+
'.on(\'open\', listener)': {
|
|
128
|
+
'registers an event listener that is called when the socket is opened': async ({ channel, resolve }) =>
|
|
129
|
+
channel
|
|
130
|
+
.on('open', resolve)
|
|
131
|
+
.open(),
|
|
132
|
+
'allows multiple listeners': async ({ channel, resolve, spy }) =>
|
|
133
|
+
channel
|
|
134
|
+
.on('open', spy)
|
|
135
|
+
.on('open', () => {
|
|
136
|
+
expect(spy).toHaveBeenCalled()
|
|
137
|
+
resolve()
|
|
138
|
+
}),
|
|
139
|
+
},
|
|
140
|
+
'.on(\'close\', listener)': {
|
|
141
|
+
'registers an event listener that is called when the socket is closed': async ({ channel, resolve }) =>
|
|
142
|
+
channel
|
|
143
|
+
.on('close', resolve)
|
|
144
|
+
.on('open', channel.close),
|
|
145
|
+
'allows multiple listeners': async ({ channel, resolve, spy }) =>
|
|
146
|
+
channel
|
|
147
|
+
.on('close', spy)
|
|
148
|
+
.on('close', () => {
|
|
149
|
+
expect(spy).toHaveBeenCalled()
|
|
150
|
+
resolve()
|
|
151
|
+
})
|
|
152
|
+
.on('open', channel.close),
|
|
153
|
+
},
|
|
154
|
+
'.on(\'message\', listener)': {
|
|
155
|
+
'registers an event listener that is called when a message is received': async ({ getChannel, resolve }) => {
|
|
156
|
+
getChannel({ echo: true })
|
|
157
|
+
.on('message', e => {
|
|
158
|
+
expect(e.message).toBe('test')
|
|
159
|
+
resolve()
|
|
160
|
+
})
|
|
161
|
+
.send('test')
|
|
162
|
+
},
|
|
163
|
+
'allows multiple message listeners': async ({ getChannel, resolve, spy }) =>
|
|
164
|
+
getChannel({ echo: true })
|
|
165
|
+
.on('message', spy)
|
|
166
|
+
.on('message', (e) => {
|
|
167
|
+
expect(e.message).toBe('test')
|
|
168
|
+
expect(spy).toHaveBeenCalled()
|
|
169
|
+
resolve()
|
|
170
|
+
})
|
|
171
|
+
.send('test'),
|
|
172
|
+
'receives message props on base message object': async ({ getChannel, resolve }) =>
|
|
173
|
+
getChannel({ echo: true })
|
|
174
|
+
.on<{ foo: string }>('message', (e) => {
|
|
175
|
+
expect(e.foo).toBe('bar')
|
|
176
|
+
expect(e.message.foo).toBe('bar')
|
|
177
|
+
resolve()
|
|
178
|
+
})
|
|
179
|
+
.send({ foo: 'bar' }),
|
|
180
|
+
'message props do not override event base props': async ({ getChannel, resolve }, date = new Date()) =>
|
|
181
|
+
getChannel({ echo: true, alias: 'test-user' })
|
|
182
|
+
.on<{ foo: string }>('message', (e) => {
|
|
183
|
+
expect(e.foo).toBe('bar')
|
|
184
|
+
// confirm types are correct
|
|
185
|
+
expect(e.uid).toBeTypeOf('string')
|
|
186
|
+
expect(e.alias).toBeTypeOf('string')
|
|
187
|
+
expect(e.date).toBeTypeOf('number')
|
|
188
|
+
// confirm props are not overridden
|
|
189
|
+
expect(e.uid).not.toBe('foo')
|
|
190
|
+
expect(e.alias).not.toBe('bar')
|
|
191
|
+
expect(e.date).not.toBe(+date)
|
|
192
|
+
resolve()
|
|
193
|
+
})
|
|
194
|
+
.send({ foo: 'bar', uid: 'foo', alias: 'bar', date }),
|
|
195
|
+
'base props not polluted by string messages': async ({ getChannel, resolve, spy }) =>
|
|
196
|
+
getChannel({ echo: true })
|
|
197
|
+
.on('message', spy)
|
|
198
|
+
.on('message', (e) => {
|
|
199
|
+
expect(e[0]).toBeUndefined() // "h" if polluted
|
|
200
|
+
expect(spy).toHaveBeenCalled()
|
|
201
|
+
resolve()
|
|
202
|
+
})
|
|
203
|
+
.send('hello'),
|
|
204
|
+
'base props not polluted by array messages': async ({ getChannel, resolve, spy }) =>
|
|
205
|
+
getChannel({ echo: true })
|
|
206
|
+
.on('message', spy)
|
|
207
|
+
.on('message', (e) => {
|
|
208
|
+
expect(e[0]).toBeUndefined() // "1" if polluted
|
|
209
|
+
expect(spy).toHaveBeenCalled()
|
|
210
|
+
resolve()
|
|
211
|
+
})
|
|
212
|
+
.send([1, 2, 3]),
|
|
213
|
+
'base props not polluted by numeric messages': async ({ getChannel, resolve, spy }) =>
|
|
214
|
+
getChannel({ echo: true })
|
|
215
|
+
.on('message', spy)
|
|
216
|
+
.on('message', (e) => {
|
|
217
|
+
expect(e[0]).toBeUndefined() // "?" if polluted
|
|
218
|
+
expect(spy).toHaveBeenCalled()
|
|
219
|
+
resolve()
|
|
220
|
+
})
|
|
221
|
+
.send(13),
|
|
222
|
+
},
|
|
223
|
+
'.on(\'join\', listener)': {
|
|
224
|
+
'registers an event listener that is called when a user (or self) joins the channel': async ({ channel, resolve }) =>
|
|
225
|
+
channel
|
|
226
|
+
.on('join', e => {
|
|
227
|
+
expect(e.users).toBe(1)
|
|
228
|
+
resolve()
|
|
229
|
+
}),
|
|
230
|
+
'does NOT include user details when { announce: true } is not set': async ({ channel, resolve }) =>
|
|
231
|
+
channel
|
|
232
|
+
.on('join', e => {
|
|
233
|
+
expect(e.uid).toBeUndefined()
|
|
234
|
+
expect(e.alias).toBeUndefined()
|
|
235
|
+
resolve()
|
|
236
|
+
}),
|
|
237
|
+
'DOES include user details when { announce: true } is set': async ({ getChannel, resolve }) =>
|
|
238
|
+
getChannel({ announce: true, alias: 'test-user' })
|
|
239
|
+
.on('join', e => {
|
|
240
|
+
expect(e.uid).not.toBeUndefined()
|
|
241
|
+
expect(e.alias).toBe('test-user')
|
|
242
|
+
resolve()
|
|
243
|
+
})
|
|
244
|
+
},
|
|
245
|
+
'.on(\'error\', listener)': {
|
|
246
|
+
'registers an event listener that is called when an error occurs': async ({ channel, resolve }) =>
|
|
247
|
+
channel
|
|
248
|
+
.on('error', e => {
|
|
249
|
+
expect(e.message).toContain('non-existent-user')
|
|
250
|
+
resolve()
|
|
251
|
+
})
|
|
252
|
+
.send('test', 'non-existent-user')
|
|
253
|
+
},
|
|
254
|
+
'.on(\'leave\', listener)': {
|
|
255
|
+
'registers an event listener that is called when a user leaves the channel': async ({ channel, getChannel,resolve }) => {
|
|
256
|
+
channel
|
|
257
|
+
.on('leave', e => {
|
|
258
|
+
expect(e.users).toBe(1)
|
|
259
|
+
resolve()
|
|
260
|
+
})
|
|
261
|
+
.on('open', () => {
|
|
262
|
+
getChannel().push('test') // trigger a join + leave
|
|
263
|
+
})
|
|
264
|
+
},
|
|
265
|
+
'does NOT include user details when { announce: true } is not set': async ({ channel, getChannel, resolve }) => {
|
|
266
|
+
channel
|
|
267
|
+
.on('leave', e => {
|
|
268
|
+
expect(e.uid).toBeUndefined()
|
|
269
|
+
expect(e.alias).toBeUndefined()
|
|
270
|
+
resolve()
|
|
271
|
+
})
|
|
272
|
+
.on('open', () => {
|
|
273
|
+
getChannel().push('test') // trigger a join + leave
|
|
274
|
+
})
|
|
275
|
+
},
|
|
276
|
+
'DOES include user details when { announce: true } is set': async ({ getChannel, resolve }) => {
|
|
277
|
+
getChannel()
|
|
278
|
+
.on('leave', e => {
|
|
279
|
+
expect(e.uid).not.toBeUndefined()
|
|
280
|
+
expect(e.alias).toBe('test-user')
|
|
281
|
+
resolve()
|
|
282
|
+
})
|
|
283
|
+
.on('open', () => {
|
|
284
|
+
getChannel({ announce: true, alias: 'test-user' }).push('test') // trigger a join + leave
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
'.on(\'{custom-type}\', listener)': {
|
|
289
|
+
'catches when message.type matches the custom type': async ({ getChannel, resolve }) => {
|
|
290
|
+
getChannel()
|
|
291
|
+
.on<{ user: string, text: string }>('chat', (e) => {
|
|
292
|
+
const { user, text } = e
|
|
293
|
+
expect(user).toBe('test-user')
|
|
294
|
+
expect(text).toBe('test')
|
|
295
|
+
expect(e.type).toBe('chat') // currently giving a TS error (incorrect)
|
|
296
|
+
expect(e.uid).toBeTypeOf('string')
|
|
297
|
+
expect(e.date).toBeTypeOf('number')
|
|
298
|
+
expect(e.user).toBe(e.message.user)
|
|
299
|
+
resolve()
|
|
300
|
+
})
|
|
301
|
+
.on('open', () => {
|
|
302
|
+
getChannel().send({ type: 'chat', user: 'test-user', text: 'test' })
|
|
303
|
+
})
|
|
304
|
+
},
|
|
305
|
+
'will still trigger "message" listeners': async ({ getChannel, resolve, spy }) =>
|
|
306
|
+
getChannel({ echo: true })
|
|
307
|
+
.on('message', spy)
|
|
308
|
+
.on('chat', () => {
|
|
309
|
+
setTimeout(() => {
|
|
310
|
+
expect(spy).toHaveBeenCalled()
|
|
311
|
+
resolve()
|
|
312
|
+
}, 5)
|
|
313
|
+
})
|
|
314
|
+
.send({ type: 'chat', user: 'test-user', text: 'test' }),
|
|
315
|
+
'will include custom payloads at top level and under e.message': async ({ getChannel, resolve, spy }) =>
|
|
316
|
+
getChannel({ echo: true })
|
|
317
|
+
.on('message', spy)
|
|
318
|
+
.on<ChatMessage>('chat', (e) => {
|
|
319
|
+
expect(e.type).toBe('chat')
|
|
320
|
+
expect(e.user).toBe('test-user')
|
|
321
|
+
expect(e.text).toBe('test')
|
|
322
|
+
expect(e.message.type).toBe('chat')
|
|
323
|
+
expect(e.message.user).toBe('test-user')
|
|
324
|
+
expect(e.message.text).toBe('test')
|
|
325
|
+
resolve()
|
|
326
|
+
})
|
|
327
|
+
.send({ type: 'chat', user: 'test-user', text: 'test' })
|
|
328
|
+
},
|
|
329
|
+
'.on(\'*\', listener)': {
|
|
330
|
+
'catches both typed and untyped events': async ({ getChannel, resolve }) => {
|
|
331
|
+
const received: any[] = []
|
|
332
|
+
getChannel({ echo: true })
|
|
333
|
+
.on('*', (e) => {
|
|
334
|
+
received.push(e)
|
|
335
|
+
if (received.length === 2) {
|
|
336
|
+
expect(received[0].type).toBe('join')
|
|
337
|
+
expect(received[1].message).toBe('test')
|
|
338
|
+
resolve()
|
|
339
|
+
}
|
|
340
|
+
})
|
|
341
|
+
.send('test')
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
'.on(eventFilter, listener)': {
|
|
345
|
+
'can accept a filter function as type': async ({ getChannel, resolve, spy }) =>
|
|
346
|
+
getChannel({ echo: true })
|
|
347
|
+
.on('message', spy)
|
|
348
|
+
.on(e => e.type === 'chat', () => {
|
|
349
|
+
setTimeout(() => {
|
|
350
|
+
expect(spy).toHaveBeenCalled()
|
|
351
|
+
resolve()
|
|
352
|
+
}, 5)
|
|
353
|
+
})
|
|
354
|
+
.send({ type: 'chat', user: 'test-user', text: 'test' }),
|
|
355
|
+
},
|
|
356
|
+
'.remove(\'open\', listener)': {
|
|
357
|
+
'removes a listener (will not fire)': async ({ channel, resolve, spy }) =>
|
|
358
|
+
channel
|
|
359
|
+
.on('open', spy)
|
|
360
|
+
.on('open', () => {
|
|
361
|
+
expect(spy).not.toHaveBeenCalled()
|
|
362
|
+
resolve()
|
|
363
|
+
})
|
|
364
|
+
.remove('open', spy)
|
|
365
|
+
},
|
|
366
|
+
'.send(message, recipient?)': {
|
|
367
|
+
'delivers a message to the channel': async ({ channel, getChannel, resolve }) => {
|
|
368
|
+
channel
|
|
369
|
+
.on('message', e => {
|
|
370
|
+
expect(e.message).toBe('test')
|
|
371
|
+
resolve()
|
|
372
|
+
})
|
|
373
|
+
.on('open', () => {
|
|
374
|
+
getChannel().send('test')
|
|
375
|
+
})
|
|
376
|
+
},
|
|
377
|
+
'delivers a message to a recipient': async ({ channel, getChannel, resolve }) => {
|
|
378
|
+
channel
|
|
379
|
+
.on('join', ({ uid }) => {
|
|
380
|
+
channel.send('test', uid)
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
getChannel({ announce: true })
|
|
384
|
+
.on('message', (e) => {
|
|
385
|
+
expect(e.message).toBe('test')
|
|
386
|
+
resolve()
|
|
387
|
+
})
|
|
388
|
+
},
|
|
389
|
+
'private messages are ONLY delivered to the recipient': async ({ channel, getChannel, resolve, spy }) =>
|
|
390
|
+
channel
|
|
391
|
+
.on('join', ({ uid, alias }) => {
|
|
392
|
+
if (alias === 'test-user') {
|
|
393
|
+
channel.send('test', uid)
|
|
394
|
+
}
|
|
395
|
+
})
|
|
396
|
+
.on('open', () => {
|
|
397
|
+
getChannel()
|
|
398
|
+
.on('message', spy)
|
|
399
|
+
.on('open', () => {
|
|
400
|
+
getChannel({ announce: true, alias: 'test-user' })
|
|
401
|
+
.on('message', (e) => {
|
|
402
|
+
expect(e.message).toBe('test')
|
|
403
|
+
expect(spy).not.toHaveBeenCalled()
|
|
404
|
+
resolve()
|
|
405
|
+
})
|
|
406
|
+
})
|
|
407
|
+
}),
|
|
408
|
+
'will send an error if the recipient does not exist': async ({ channel, resolve }) =>
|
|
409
|
+
channel
|
|
410
|
+
.on('error', e => {
|
|
411
|
+
expect(e.message).toContain('non-existent-user')
|
|
412
|
+
resolve()
|
|
413
|
+
})
|
|
414
|
+
.send('test', 'non-existent-user'),
|
|
415
|
+
},
|
|
416
|
+
'.push(message, recipient?)': {
|
|
417
|
+
'sends a message to the channel': async ({ channel, getChannel, resolve }) => {
|
|
418
|
+
channel
|
|
419
|
+
.on('message', e => {
|
|
420
|
+
expect(e.message).toBe('test')
|
|
421
|
+
resolve()
|
|
422
|
+
})
|
|
423
|
+
.on('open', () => {
|
|
424
|
+
getChannel().push('test')
|
|
425
|
+
})
|
|
426
|
+
},
|
|
427
|
+
'closes after sending a message': async ({ channel, resolve }) =>
|
|
428
|
+
channel
|
|
429
|
+
.on('close', () => {
|
|
430
|
+
resolve()
|
|
431
|
+
})
|
|
432
|
+
.push('test')
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
'MISC BEHAVIOR': {
|
|
436
|
+
'messages are queued and delivered upon connection': async ({ getChannel, resolve }) => {
|
|
437
|
+
const messages = ['first', 'second', 'third']
|
|
438
|
+
const received: string[] = []
|
|
439
|
+
|
|
440
|
+
getChannel({ echo: true })
|
|
441
|
+
.on('message', e => {
|
|
442
|
+
received.push(e.message)
|
|
443
|
+
|
|
444
|
+
if (received.length === messages.length) {
|
|
445
|
+
expect(received).toEqual(messages)
|
|
446
|
+
resolve()
|
|
447
|
+
}
|
|
448
|
+
})
|
|
449
|
+
.send(messages[0])
|
|
450
|
+
.send(messages[1])
|
|
451
|
+
.send(messages[2])
|
|
452
|
+
},
|
|
453
|
+
'connection is opened when registering a listener': async ({ channel, resolve }) => channel.on('open', resolve),
|
|
454
|
+
},
|
|
455
|
+
'EVENT PAYLOADS': {
|
|
456
|
+
'join': {
|
|
457
|
+
'default': async ({ channel, resolve }) =>
|
|
458
|
+
channel
|
|
459
|
+
.on('join', e => {
|
|
460
|
+
expect(e.users).toBe(1)
|
|
461
|
+
expect(e.uid).toBeUndefined()
|
|
462
|
+
expect(e.alias).toBeUndefined()
|
|
463
|
+
expect(e.date).toBeTypeOf('number')
|
|
464
|
+
resolve()
|
|
465
|
+
}),
|
|
466
|
+
'with { announce: true }': async ({ getChannel, resolve }) =>
|
|
467
|
+
getChannel({ announce: true, as: 'test-user' })
|
|
468
|
+
.on('join', e => {
|
|
469
|
+
expect(e.users).toBe(1)
|
|
470
|
+
expect(e.uid).toBeTypeOf('string')
|
|
471
|
+
expect(e.alias).toBe('test-user')
|
|
472
|
+
expect(e.date).toBeTypeOf('number')
|
|
473
|
+
resolve()
|
|
474
|
+
})
|
|
475
|
+
},
|
|
476
|
+
'leave': {
|
|
477
|
+
'default': async ({ channel, getChannel, resolve }) => {
|
|
478
|
+
channel
|
|
479
|
+
.on('leave', e => {
|
|
480
|
+
expect(e.users).toBe(1)
|
|
481
|
+
expect(e.uid).toBeUndefined()
|
|
482
|
+
expect(e.alias).toBeUndefined()
|
|
483
|
+
expect(e.date).toBeTypeOf('number')
|
|
484
|
+
resolve()
|
|
485
|
+
})
|
|
486
|
+
.on('open', () => {
|
|
487
|
+
getChannel().push('test')
|
|
488
|
+
})
|
|
489
|
+
},
|
|
490
|
+
'with { announce: true }': async ({ getChannel, resolve }) => {
|
|
491
|
+
getChannel()
|
|
492
|
+
.on('leave', e => {
|
|
493
|
+
expect(e.users).toBe(1)
|
|
494
|
+
expect(e.uid).toBeTypeOf('string')
|
|
495
|
+
expect(e.alias).toBe('test-user')
|
|
496
|
+
expect(e.date).toBeTypeOf('number')
|
|
497
|
+
resolve()
|
|
498
|
+
})
|
|
499
|
+
.on('open', () => {
|
|
500
|
+
getChannel({ announce: true, as: 'test-user' }).push('test')
|
|
501
|
+
})
|
|
502
|
+
}
|
|
503
|
+
},
|
|
504
|
+
'message': {
|
|
505
|
+
'default': async ({ getChannel, resolve }) =>
|
|
506
|
+
getChannel({ echo: true })
|
|
507
|
+
.on('message', e => {
|
|
508
|
+
expect(e.message).toBe('test')
|
|
509
|
+
expect(e.uid).toBeTypeOf('string')
|
|
510
|
+
expect(e.alias).toBeUndefined()
|
|
511
|
+
expect(e.date).toBeTypeOf('number')
|
|
512
|
+
resolve()
|
|
513
|
+
})
|
|
514
|
+
.send('test'),
|
|
515
|
+
'with { alias: string } includes users alias in payload': async ({ getChannel, resolve }) =>
|
|
516
|
+
getChannel({ echo: true, alias: 'test-user' })
|
|
517
|
+
.on('message', e => {
|
|
518
|
+
expect(e.message).toBe('test')
|
|
519
|
+
expect(e.uid).toBeTypeOf('string')
|
|
520
|
+
expect(e.alias).toBe('test-user')
|
|
521
|
+
expect(e.date).toBeTypeOf('number')
|
|
522
|
+
resolve()
|
|
523
|
+
})
|
|
524
|
+
.send('test'),
|
|
525
|
+
},
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// setup function for each test
|
|
531
|
+
const setup = () => {
|
|
532
|
+
const id = 'itty:itty-sockets:test-' + Math.random().toString(36).slice(2)
|
|
533
|
+
const getChannel = (options = {}): IttySocket<UseItty> => {
|
|
534
|
+
const channel = connect(id, options)
|
|
535
|
+
OPEN_CHANNELS.push(channel)
|
|
536
|
+
return channel
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return {
|
|
540
|
+
getChannel,
|
|
541
|
+
channel: getChannel(id) as IttySocket<UseItty>,
|
|
542
|
+
spy: mock(() => {}),
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// recursive test runner
|
|
547
|
+
const runTests = (tests: TestTree) => {
|
|
548
|
+
for (const [name, test] of Object.entries(tests)) {
|
|
549
|
+
if (typeof test === 'function') {
|
|
550
|
+
if (test.constructor.name === 'AsyncFunction') {
|
|
551
|
+
// @ts-ignore
|
|
552
|
+
it(name, () => new Promise(resolve => test({ ...setup(), resolve })))
|
|
553
|
+
} else {
|
|
554
|
+
// @ts-ignore
|
|
555
|
+
it(name, () => test({ ...setup() }))
|
|
556
|
+
}
|
|
557
|
+
} else {
|
|
558
|
+
describe(name, () => runTests(test))
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// run the tests!
|
|
564
|
+
runTests(tests)
|
|
565
|
+
|
|
566
|
+
// close any open channels
|
|
567
|
+
afterAll(() => {
|
|
568
|
+
console.log(`closing ${OPEN_CHANNELS.length} channels`)
|
|
569
|
+
OPEN_CHANNELS.forEach(channel => channel.close())
|
|
570
|
+
})
|
package/src/connect.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
type IttySocketEvent<BaseFormat> = BaseFormat extends UseItty
|
|
2
|
+
? 'open' | 'close' | 'message' | 'join' | 'leave'
|
|
3
|
+
: 'open' | 'close' | 'message'
|
|
4
|
+
|
|
5
|
+
type Timestamp = { date: number }
|
|
6
|
+
type UserDetails = { uid: string, alias?: string }
|
|
7
|
+
type OptionalUserDetails = { uid?: string, alias?: string }
|
|
8
|
+
|
|
9
|
+
export type UseItty<MessageType = any> = {
|
|
10
|
+
message: MessageType
|
|
11
|
+
} & UserDetails & Timestamp
|
|
12
|
+
|
|
13
|
+
export type MessageEvent<MessageType = any> = {
|
|
14
|
+
message: MessageType
|
|
15
|
+
} & Timestamp & OptionalUserDetails
|
|
16
|
+
|
|
17
|
+
export type JoinEvent = {
|
|
18
|
+
type: 'join'
|
|
19
|
+
users: number
|
|
20
|
+
} & Timestamp & OptionalUserDetails
|
|
21
|
+
|
|
22
|
+
export type LeaveEvent = {
|
|
23
|
+
type: 'leave'
|
|
24
|
+
users: number
|
|
25
|
+
} & Timestamp & OptionalUserDetails
|
|
26
|
+
|
|
27
|
+
export type ErrorEvent = {
|
|
28
|
+
type: 'error'
|
|
29
|
+
message: string
|
|
30
|
+
} & Timestamp
|
|
31
|
+
|
|
32
|
+
export type IttySocketOptions = {
|
|
33
|
+
as?: string,
|
|
34
|
+
alias?: string,
|
|
35
|
+
echo?: true,
|
|
36
|
+
announce?: true,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface IttySocketConnect {
|
|
40
|
+
<BaseFormat = object>(
|
|
41
|
+
...args: BaseFormat extends UseItty
|
|
42
|
+
? [channelID: string, options?: IttySocketOptions]
|
|
43
|
+
: [url: string, queryParams?: any]
|
|
44
|
+
): IttySocket<BaseFormat>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type UseIttyEvents<BaseFormat> = {
|
|
48
|
+
on(type: 'join', listener: (event: JoinEvent) => any): IttySocket<BaseFormat>
|
|
49
|
+
on(type: 'leave', listener: (event: LeaveEvent) => any): IttySocket<BaseFormat>
|
|
50
|
+
on(type: 'error', listener: (event: ErrorEvent) => any): IttySocket<BaseFormat>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type SendMessage<BaseFormat> = BaseFormat extends UseItty
|
|
54
|
+
? <MessageFormat = any>(message: MessageFormat, uid?: string) => IttySocket<BaseFormat>
|
|
55
|
+
: <MessageFormat = any>(message: MessageFormat) => IttySocket<BaseFormat>
|
|
56
|
+
|
|
57
|
+
export type IttySocket<BaseFormat = object> = {
|
|
58
|
+
open: () => IttySocket<BaseFormat>
|
|
59
|
+
close: () => IttySocket<BaseFormat>
|
|
60
|
+
send: SendMessage<BaseFormat>
|
|
61
|
+
push: SendMessage<BaseFormat>
|
|
62
|
+
remove(type: IttySocketEvent<BaseFormat>, listener: () => any): IttySocket<BaseFormat>
|
|
63
|
+
remove(type: string, listener: () => any): IttySocket<BaseFormat>
|
|
64
|
+
|
|
65
|
+
// EVENTS
|
|
66
|
+
on(type: 'open', listener: () => any): IttySocket<BaseFormat>
|
|
67
|
+
on(type: 'close', listener: () => any): IttySocket<BaseFormat>
|
|
68
|
+
on<MessageFormat = BaseFormat>(type: 'message', listener: (event: BaseFormat & MessageFormat) => any): IttySocket<BaseFormat>
|
|
69
|
+
on<MessageFormat = BaseFormat>(type: string, listener: (event: BaseFormat & MessageFormat & { type: string }) => any): IttySocket<BaseFormat>
|
|
70
|
+
on<MessageFormat = BaseFormat>(type: (event?: any) => any, listener: (event: BaseFormat & MessageFormat & { type: string }) => any): IttySocket<BaseFormat>
|
|
71
|
+
} & (BaseFormat extends UseItty ? UseIttyEvents<BaseFormat> : object)
|
|
72
|
+
|
|
73
|
+
export let connect: IttySocketConnect = (channelId: string, options = {}) => {
|
|
74
|
+
let ws: WebSocket | null,
|
|
75
|
+
closeAfterSend: any,
|
|
76
|
+
queue: string[] = [],
|
|
77
|
+
events: Record<string, Array<(event?: any) => any>> = {}
|
|
78
|
+
|
|
79
|
+
let open = () => (
|
|
80
|
+
ws || (
|
|
81
|
+
// @ts-ignore - options will be cast as string regardless of what is passed
|
|
82
|
+
ws = new WebSocket((/^wss?:/.test(channelId) ? channelId : 'wss://itty.ws/c/' + channelId) + '?' + new URLSearchParams(options)),
|
|
83
|
+
|
|
84
|
+
ws.onmessage = (
|
|
85
|
+
event: any,
|
|
86
|
+
parsed = JSON.parse(event.data),
|
|
87
|
+
payload = parsed?.message,
|
|
88
|
+
eventPayload = {
|
|
89
|
+
...(payload?.[0] == null && payload),
|
|
90
|
+
...parsed,
|
|
91
|
+
},
|
|
92
|
+
) =>
|
|
93
|
+
[eventPayload.type, parsed.type ? 0 : 'message', '*'].map(key =>
|
|
94
|
+
events[key]?.map(listener => listener(eventPayload))
|
|
95
|
+
),
|
|
96
|
+
|
|
97
|
+
ws.onopen = () => (
|
|
98
|
+
queue.splice(0).map(m => ws!.send(m)),
|
|
99
|
+
events.open?.map(listener => listener(closeAfterSend)),
|
|
100
|
+
closeAfterSend && ws?.close()
|
|
101
|
+
),
|
|
102
|
+
|
|
103
|
+
ws.onclose = () => (
|
|
104
|
+
closeAfterSend = ws = null,
|
|
105
|
+
events.close?.map(listener => listener(closeAfterSend))
|
|
106
|
+
)
|
|
107
|
+
),
|
|
108
|
+
socket
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
let socket: any = {
|
|
112
|
+
open,
|
|
113
|
+
send: (message: any, recipient?: string) => (
|
|
114
|
+
message = (recipient ? `\x1F${recipient}\x1F` : '') + JSON.stringify(message),
|
|
115
|
+
// @ts-ignore
|
|
116
|
+
ws?.readyState & 1 ? ws!.send(message) : queue.push(message),
|
|
117
|
+
open()
|
|
118
|
+
),
|
|
119
|
+
on: (type: any, listener: (e?: any) => any) => (
|
|
120
|
+
(events[type?.[0] ? type : '*'] ??= []).push(type?.[0] ? listener : (e: any) => type?.(e) && listener(e)),
|
|
121
|
+
open()
|
|
122
|
+
),
|
|
123
|
+
remove: (type: any, listener: () => any) => (
|
|
124
|
+
events[type] = events[type]?.filter((l: any) => l != listener),
|
|
125
|
+
socket
|
|
126
|
+
),
|
|
127
|
+
// @ts-ignore
|
|
128
|
+
close: () => (ws?.readyState & 1 ? ws!.close() : closeAfterSend = 1, socket),
|
|
129
|
+
push: (message: any, recipient?: string) => (closeAfterSend = 1, socket.send(message, recipient!)),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return socket
|
|
133
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
type IttySocketEvent<BaseFormat> = BaseFormat extends UseItty
|
|
2
|
+
? 'open' | 'close' | 'message' | 'join' | 'leave'
|
|
3
|
+
: 'open' | 'close' | 'message'
|
|
4
|
+
|
|
5
|
+
type Timestamp = { date: number }
|
|
6
|
+
type UserDetails = { uid: string, alias?: string }
|
|
7
|
+
type OptionalUserDetails = { uid?: string, alias?: string }
|
|
8
|
+
|
|
9
|
+
export type UseItty<MessageType = any> = {
|
|
10
|
+
message: MessageType
|
|
11
|
+
} & UserDetails & Timestamp
|
|
12
|
+
|
|
13
|
+
export type MessageEvent<MessageType = any> = {
|
|
14
|
+
message: MessageType
|
|
15
|
+
} & Timestamp & OptionalUserDetails
|
|
16
|
+
|
|
17
|
+
export type JoinEvent = {
|
|
18
|
+
type: 'join'
|
|
19
|
+
users: number
|
|
20
|
+
} & Timestamp & OptionalUserDetails
|
|
21
|
+
|
|
22
|
+
export type LeaveEvent = {
|
|
23
|
+
type: 'leave'
|
|
24
|
+
users: number
|
|
25
|
+
} & Timestamp & OptionalUserDetails
|
|
26
|
+
|
|
27
|
+
export type ErrorEvent = {
|
|
28
|
+
type: 'error'
|
|
29
|
+
message: string
|
|
30
|
+
} & Timestamp
|
|
31
|
+
|
|
32
|
+
export type IttySocketOptions = {
|
|
33
|
+
as?: string,
|
|
34
|
+
alias?: string,
|
|
35
|
+
echo?: true,
|
|
36
|
+
announce?: true,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface IttySocketConnect {
|
|
40
|
+
<BaseFormat = object>(
|
|
41
|
+
...args: BaseFormat extends UseItty
|
|
42
|
+
? [channelID: string, options?: IttySocketOptions]
|
|
43
|
+
: [url: string, queryParams?: any]
|
|
44
|
+
): IttySocket<BaseFormat>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type UseIttyEvents<BaseFormat> = {
|
|
48
|
+
on(type: 'join', listener: (event: JoinEvent) => any): IttySocket<BaseFormat>
|
|
49
|
+
on(type: 'leave', listener: (event: LeaveEvent) => any): IttySocket<BaseFormat>
|
|
50
|
+
on(type: 'error', listener: (event: ErrorEvent) => any): IttySocket<BaseFormat>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type SendMessage<BaseFormat> = BaseFormat extends UseItty
|
|
54
|
+
? <MessageFormat = any>(message: MessageFormat, uid?: string) => IttySocket<BaseFormat>
|
|
55
|
+
: <MessageFormat = any>(message: MessageFormat) => IttySocket<BaseFormat>
|
|
56
|
+
|
|
57
|
+
export type IttySocket<BaseFormat = object> = {
|
|
58
|
+
open: () => IttySocket<BaseFormat>
|
|
59
|
+
close: () => IttySocket<BaseFormat>
|
|
60
|
+
send: SendMessage<BaseFormat>
|
|
61
|
+
push: SendMessage<BaseFormat>
|
|
62
|
+
remove(type: IttySocketEvent<BaseFormat>, listener: () => any): IttySocket<BaseFormat>
|
|
63
|
+
remove(type: string, listener: () => any): IttySocket<BaseFormat>
|
|
64
|
+
|
|
65
|
+
// EVENTS
|
|
66
|
+
on(type: 'open', listener: () => any): IttySocket<BaseFormat>
|
|
67
|
+
on(type: 'close', listener: () => any): IttySocket<BaseFormat>
|
|
68
|
+
on<MessageFormat = BaseFormat>(type: 'message', listener: (event: BaseFormat & MessageFormat) => any): IttySocket<BaseFormat>
|
|
69
|
+
on<MessageFormat = BaseFormat>(type: string, listener: (event: BaseFormat & MessageFormat & { type: string }) => any): IttySocket<BaseFormat>
|
|
70
|
+
on<MessageFormat = BaseFormat>(type: (event?: any) => any, listener: (event: BaseFormat & MessageFormat & { type: string }) => any): IttySocket<BaseFormat>
|
|
71
|
+
} & (BaseFormat extends UseItty ? UseIttyEvents<BaseFormat> : object)
|
|
72
|
+
|
|
73
|
+
export let connect: IttySocketConnect = (channelId: string, options = {}) => {
|
|
74
|
+
let ws: WebSocket | null,
|
|
75
|
+
closeAfterSend: any,
|
|
76
|
+
queue: string[] = [],
|
|
77
|
+
events: Record<string, Array<(event?: any) => any>> = {}
|
|
78
|
+
|
|
79
|
+
let open = () => {
|
|
80
|
+
if (ws) return socket
|
|
81
|
+
|
|
82
|
+
// @ts-ignore - options will be cast as string regardless of what is passed
|
|
83
|
+
ws = new WebSocket((channelId[3] < 'a' ? channelId : 'wss://itty.ws/c/' + channelId) + '?' + new URLSearchParams(options))
|
|
84
|
+
|
|
85
|
+
ws.onmessage = (
|
|
86
|
+
event: any,
|
|
87
|
+
parsed = JSON.parse(event.data),
|
|
88
|
+
payload = parsed?.message,
|
|
89
|
+
eventPayload = {
|
|
90
|
+
...(payload?.[0] == null && payload),
|
|
91
|
+
...parsed,
|
|
92
|
+
},
|
|
93
|
+
) => (
|
|
94
|
+
events[eventPayload.type]?.map(listener => listener(eventPayload)),
|
|
95
|
+
parsed.type || events.message?.map(listener => listener(eventPayload)),
|
|
96
|
+
events['*']?.map(listener => listener(eventPayload))
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
ws.onopen = () => (
|
|
100
|
+
queue.splice(0).map(m => ws!.send(m)),
|
|
101
|
+
events.open?.map(listener => listener(closeAfterSend)),
|
|
102
|
+
closeAfterSend && ws?.close()
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
ws.onclose = () => (
|
|
106
|
+
closeAfterSend = ws = null,
|
|
107
|
+
events.close?.map(listener => listener(closeAfterSend))
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return socket
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let send = (message: any, recipient?: string) => (
|
|
114
|
+
message = (recipient ? `\x1F${recipient}\x1F` : '') + JSON.stringify(message),
|
|
115
|
+
ws?.readyState & 1 ? ws!.send(message) : queue.push(message),
|
|
116
|
+
open()
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
let socket: any = {
|
|
120
|
+
open,
|
|
121
|
+
send,
|
|
122
|
+
on: (type: any, listener: (e?: any) => any) => (
|
|
123
|
+
(events[type?.[0] ? type : '*'] ??= []).push(type?.[0] ? listener : (e: any) => type(e) && listener(e)),
|
|
124
|
+
open()
|
|
125
|
+
),
|
|
126
|
+
remove: (type: any, listener: () => any) => (
|
|
127
|
+
events[type] = events[type]?.filter((l: any) => l != listener),
|
|
128
|
+
socket
|
|
129
|
+
),
|
|
130
|
+
close: () => (ws?.readyState & 1 ? ws!.close() : closeAfterSend = 1, socket),
|
|
131
|
+
push: (message: any, recipient?: string) => (closeAfterSend = 1, send(message, recipient!)),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return socket
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// type Chat = { type: 'chat', user: string, text: string }
|
|
138
|
+
|
|
139
|
+
// connect<UseItty>('doo')
|
|
140
|
+
// .on<Chat>('message', e => {
|
|
141
|
+
// e
|
|
142
|
+
// })
|
|
143
|
+
// .on<Chat>('chat', (e) => {
|
|
144
|
+
// e.text
|
|
145
|
+
// })
|
|
146
|
+
// .on<Chat>(v => v.type === 'chat', e => {
|
|
147
|
+
// e.texts
|
|
148
|
+
// })
|
|
149
|
+
// // .send() // test for (message) vs (message, recipient) based on BaseFormat type
|
|
150
|
+
// .remove('leave',
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
// GENERICS TESTING
|
|
154
|
+
// connect('test')
|
|
155
|
+
// .on('message', (e) => e.message.name)
|
|
156
|
+
// .on('close', () => {})
|
|
157
|
+
// .send(123)
|
|
158
|
+
// .on<{ x: string }>('message', (e) => parseInt(e.message.x))
|
|
159
|
+
// .send<{ foo: string }>({ foo: 'bar' })
|
|
160
|
+
// .on('join', e => e.users + 4)
|
|
161
|
+
// .on('leave', e => e.users - 4)
|
|
162
|
+
// .on('error', e => e.message)
|
|
163
|
+
// .on('message', e => e.message.whatever)
|
|
164
|
+
// .on('message', e => e.whatever)
|
|
165
|
+
// .on<{ foo: string }>('message', e => e.message.foo)
|
|
166
|
+
// .on<{ foo: string }>('message', e => e.foo)
|
|
167
|
+
// .on<{ foo: string }>('chat', e => e.foo)
|
|
168
|
+
// .on<{ foo: string }>('chat', e => e.type)
|
|
169
|
+
// .send({ $type: 'chat', foo: 'bar' })
|
|
170
|
+
// .on('*', e => e.message)
|
|
171
|
+
|
|
172
|
+
// .on<{ age: number }>('message', (e) => e.message.name) // ERROR
|
|
173
|
+
// .on<{ x: number }>('message', (e) => parseInt(e.message.x)) // ERROR
|
|
174
|
+
// .send<string>(123) // ERROR
|
|
175
|
+
// .send<{ foo: string }>(123) // ERROR
|
|
176
|
+
// .send<{ foo: string }>({ foo: 'foo', bar: 123 }) // ERROR
|
|
177
|
+
// .on('join', e => e.message) // ERROR
|
|
178
|
+
// .on<{ foo: string }>('join', e => e.users) // ERROR
|
|
179
|
+
// .on('leave', e => e.message) // ERROR
|
|
180
|
+
// .on('error', e => e.foo) // ERROR
|
|
181
|
+
// .on<{ foo: string }>('message', e => e.message.whatever) // ERROR
|
|
182
|
+
// .on<{ foo: string }>('chat', e => e.bar) // ERROR
|
|
183
|
+
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"allowJs": true,
|
|
4
|
+
"allowSyntheticDefaultImports": true,
|
|
5
|
+
"baseUrl": "src",
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"sourceMap": false,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"inlineSourceMap": false,
|
|
10
|
+
"lib": ["esnext", "es2015", "dom"],
|
|
11
|
+
"listEmittedFiles": false,
|
|
12
|
+
"listFiles": false,
|
|
13
|
+
"noFallthroughCasesInSwitch": true,
|
|
14
|
+
"pretty": true,
|
|
15
|
+
"rootDir": "src",
|
|
16
|
+
"skipLibCheck": true,
|
|
17
|
+
"strict": true,
|
|
18
|
+
"traceResolution": false,
|
|
19
|
+
"outDir": "",
|
|
20
|
+
"target": "esnext",
|
|
21
|
+
"module": "esnext"
|
|
22
|
+
},
|
|
23
|
+
"exclude": ["node_modules", "dist", "**/*.spec.ts"],
|
|
24
|
+
"include": ["src"]
|
|
25
|
+
}
|
package/connect.d.ts
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
type IttySocketEvent<BaseFormat> = BaseFormat extends UseItty ? 'open' | 'close' | 'message' | 'join' | 'leave' : 'open' | 'close' | 'message';
|
|
2
|
-
type Date = {
|
|
3
|
-
date: Date;
|
|
4
|
-
};
|
|
5
|
-
type UserDetails = {
|
|
6
|
-
uid: string;
|
|
7
|
-
alias?: string;
|
|
8
|
-
};
|
|
9
|
-
type OptionalUserDetails = {
|
|
10
|
-
uid?: string;
|
|
11
|
-
alias?: string;
|
|
12
|
-
};
|
|
13
|
-
export type UseItty<MessageType = any> = {
|
|
14
|
-
message: MessageType;
|
|
15
|
-
} & UserDetails & Date;
|
|
16
|
-
export type JoinEvent = {
|
|
17
|
-
type: 'join';
|
|
18
|
-
users: number;
|
|
19
|
-
} & Date & OptionalUserDetails;
|
|
20
|
-
export type LeaveEvent = {
|
|
21
|
-
type: 'leave';
|
|
22
|
-
users: number;
|
|
23
|
-
} & Date & OptionalUserDetails;
|
|
24
|
-
export type ErrorEvent = {
|
|
25
|
-
type: 'error';
|
|
26
|
-
message: string;
|
|
27
|
-
} & Date;
|
|
28
|
-
export type IttySocketOptions = {
|
|
29
|
-
as?: string;
|
|
30
|
-
alias?: string;
|
|
31
|
-
echo?: true;
|
|
32
|
-
announce?: true;
|
|
33
|
-
};
|
|
34
|
-
export interface IttySocketConnect {
|
|
35
|
-
<BaseFormat = object>(...args: BaseFormat extends UseItty ? [channelID: string, options?: IttySocketOptions] : [url: string, queryParams?: any]): IttySocket<BaseFormat>;
|
|
36
|
-
}
|
|
37
|
-
type UseIttyEvents<BaseFormat> = {
|
|
38
|
-
on(type: 'join', listener: (event: JoinEvent) => any): IttySocket<BaseFormat>;
|
|
39
|
-
on(type: 'leave', listener: (event: LeaveEvent) => any): IttySocket<BaseFormat>;
|
|
40
|
-
on(type: 'error', listener: (event: ErrorEvent) => any): IttySocket<BaseFormat>;
|
|
41
|
-
};
|
|
42
|
-
type SendMessage<BaseFormat> = BaseFormat extends UseItty ? <MessageFormat = any>(message: MessageFormat, uid?: string) => IttySocket<BaseFormat> : <MessageFormat = any>(message: MessageFormat) => IttySocket<BaseFormat>;
|
|
43
|
-
export type IttySocket<BaseFormat = object> = {
|
|
44
|
-
open: () => IttySocket<BaseFormat>;
|
|
45
|
-
close: () => IttySocket<BaseFormat>;
|
|
46
|
-
send: SendMessage<BaseFormat>;
|
|
47
|
-
push: SendMessage<BaseFormat>;
|
|
48
|
-
remove(type: IttySocketEvent<BaseFormat>, listener: () => any): IttySocket<BaseFormat>;
|
|
49
|
-
remove(type: string, listener: () => any): IttySocket<BaseFormat>;
|
|
50
|
-
on(type: 'open', listener: () => any): IttySocket<BaseFormat>;
|
|
51
|
-
on(type: 'close', listener: () => any): IttySocket<BaseFormat>;
|
|
52
|
-
on<MessageFormat = BaseFormat>(type: 'message', listener: (event: BaseFormat & MessageFormat) => any): IttySocket<BaseFormat>;
|
|
53
|
-
on<MessageFormat = BaseFormat>(type: string, listener: (event: BaseFormat & MessageFormat & {
|
|
54
|
-
type: string;
|
|
55
|
-
}) => any): IttySocket<BaseFormat>;
|
|
56
|
-
on<MessageFormat = BaseFormat>(type: (event?: any) => any, listener: (event: BaseFormat & MessageFormat & {
|
|
57
|
-
type: string;
|
|
58
|
-
}) => any): IttySocket<BaseFormat>;
|
|
59
|
-
} & (BaseFormat extends UseItty ? UseIttyEvents<BaseFormat> : object);
|
|
60
|
-
export declare let connect: IttySocketConnect;
|
|
61
|
-
export {};
|
package/connect.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"use strict";exports.connect=(e,s={})=>{let n,t=0,p=[],a=[],o={},c=()=>(n||(n=new WebSocket((/^wss?:/.test(e)?e:"wss://itty.ws/c/"+e)+"?"+new URLSearchParams(s)),n.onmessage=(e,s=JSON.parse(e.data),n=s?.message,t={...null==n?.[0]&&n,...s})=>{o[s?.type??n?.type]?.map(e=>e(t)),s?.type||o.message?.map(e=>e(t)),a.map(([e,s])=>e(t)&&s(t))},n.onopen=()=>(p.splice(0).map(e=>n?.send(e)),o.open?.map(e=>e()),t&&n?.close()),n.onclose=()=>(t=0,n=null,o.close?.map(e=>e()))),l),l=new Proxy(c,{get:(e,s)=>({open:c,close:()=>(1==n?.readyState?n.close():t=1,l),push:(e,s)=>(t=1,l.send(e,s)),send:(e,s)=>(e=JSON.stringify(e),e=s?""+s+""+e:e,1==n?.readyState?(n.send(e),l):(p.push(e),c())),on:(e,s)=>(s&&(e?.[0]?(o[e]??=[]).push(s):a.push([e,s])),c()),remove:(e,s,n=o[e],t=n?.indexOf(s)??-1)=>(~t&&n?.splice(t,1),c())}[s])});return l};
|
package/connect.mjs
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
let e=(e,s={})=>{let p,a=0,n=[],t=[],o={},l=()=>(p||(p=new WebSocket((/^wss?:/.test(e)?e:"wss://itty.ws/c/"+e)+"?"+new URLSearchParams(s)),p.onmessage=(e,s=JSON.parse(e.data),p=s?.message,a={...null==p?.[0]&&p,...s})=>{o[s?.type??p?.type]?.map(e=>e(a)),s?.type||o.message?.map(e=>e(a)),t.map(([e,s])=>e(a)&&s(a))},p.onopen=()=>(n.splice(0).map(e=>p?.send(e)),o.open?.map(e=>e()),a&&p?.close()),p.onclose=()=>(a=0,p=null,o.close?.map(e=>e()))),m),m=new Proxy(l,{get:(e,s)=>({open:l,close:()=>(1==p?.readyState?p.close():a=1,m),push:(e,s)=>(a=1,m.send(e,s)),send:(e,s)=>(e=JSON.stringify(e),e=s?""+s+""+e:e,1==p?.readyState?(p.send(e),m):(n.push(e),l())),on:(e,s)=>(s&&(e?.[0]?(o[e]??=[]).push(s):t.push([e,s])),l()),remove:(e,s,p=o[e],a=p?.indexOf(s)??-1)=>(~a&&p?.splice(a,1),l())}[s])});return m};export{e as connect};
|