wirejs-resources 0.1.58 → 0.1.60-realtime
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/dist/client/index.js +44 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/overrides.d.ts +2 -0
- package/dist/resources/distributed-table.js +0 -45
- package/dist/services/realtime.d.ts +20 -0
- package/dist/services/realtime.js +150 -0
- package/package.json +4 -2
package/dist/client/index.js
CHANGED
|
@@ -52,6 +52,50 @@ async function callApi(INTERNAL_API_URL, method, ...args) {
|
|
|
52
52
|
throw new Error(error);
|
|
53
53
|
}
|
|
54
54
|
const value = body[0].data;
|
|
55
|
+
if (typeof value === 'object' && value.__wjstype === 'realtime') {
|
|
56
|
+
const subscribers = [];
|
|
57
|
+
const ws = new WebSocket(value.url, value.protocol);
|
|
58
|
+
ws.onmessage = (event) => {
|
|
59
|
+
const data = JSON.parse(event.data);
|
|
60
|
+
if (data.data) {
|
|
61
|
+
for (const subscriber of subscribers) {
|
|
62
|
+
try {
|
|
63
|
+
subscriber.onmessage(data.data);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
console.error('Error in subscriber onmessage:', error);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
ws.onclose = event => {
|
|
72
|
+
for (const subscriber of subscribers) {
|
|
73
|
+
if (subscriber.onclose) {
|
|
74
|
+
try {
|
|
75
|
+
subscriber.onclose();
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
console.error('Error in subscriber onclose:', error);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
console.log('closed', event);
|
|
83
|
+
};
|
|
84
|
+
return {
|
|
85
|
+
subscribe(subscriber) {
|
|
86
|
+
subscribers.push(subscriber);
|
|
87
|
+
return () => {
|
|
88
|
+
const index = subscribers.indexOf(subscriber);
|
|
89
|
+
if (index !== -1) {
|
|
90
|
+
subscribers.splice(index, 1);
|
|
91
|
+
}
|
|
92
|
+
if (subscribers.length === 0) {
|
|
93
|
+
ws.close();
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
55
99
|
return value;
|
|
56
100
|
}
|
|
57
101
|
;
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -9,3 +9,4 @@ export { Secret } from './resources/secret.js';
|
|
|
9
9
|
export { DistributedTable, PassThruParser, matchesFilter, indexName } from './resources/distributed-table.js';
|
|
10
10
|
export * from './types/index.js';
|
|
11
11
|
export * from './derived-types.js';
|
|
12
|
+
export * from './services/realtime.js';
|
package/dist/overrides.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { FileService } from "./services/file";
|
|
2
2
|
import type { AuthenticationService } from "./services/authentication";
|
|
3
3
|
import type { Secret } from "./resources/secret";
|
|
4
|
+
import type { RealtimeService } from "./services/realtime";
|
|
4
5
|
/**
|
|
5
6
|
* Used by hosting providers to provide service overrides.
|
|
6
7
|
*/
|
|
@@ -9,4 +10,5 @@ export declare const overrides: {
|
|
|
9
10
|
DistributedTable?: typeof AuthenticationService;
|
|
10
11
|
FileService?: typeof FileService;
|
|
11
12
|
Secret?: typeof Secret;
|
|
13
|
+
RealtimeService?: typeof RealtimeService;
|
|
12
14
|
};
|
|
@@ -158,48 +158,3 @@ export class DistributedTable extends Resource {
|
|
|
158
158
|
});
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
|
-
// export type Todo = {
|
|
162
|
-
// id: string;
|
|
163
|
-
// text: string;
|
|
164
|
-
// order: number;
|
|
165
|
-
// };
|
|
166
|
-
// const userTodos = new DistributedTable('app', 'userTodos', {
|
|
167
|
-
// parse: PassThruParser<Todo & { userId: string }>,
|
|
168
|
-
// key: {
|
|
169
|
-
// partition: { field: 'userId', type: 'string' },
|
|
170
|
-
// sort: { field: 'order', type: 'number' }
|
|
171
|
-
// // sort: { field: 'id', type: 'string' }
|
|
172
|
-
// // sort: { }
|
|
173
|
-
// },
|
|
174
|
-
// indexes: [
|
|
175
|
-
// {
|
|
176
|
-
// partition: { field: 'text', type: 'string' },
|
|
177
|
-
// sort: { field: 'userId', type: 'string' }
|
|
178
|
-
// // sort: { field: 'order', type: 'number' }
|
|
179
|
-
// }
|
|
180
|
-
// ]
|
|
181
|
-
// });
|
|
182
|
-
// type I = typeof userTodos['key'];
|
|
183
|
-
// type T1 = typeof userTodos['key']['partition']['field'];
|
|
184
|
-
// type T2 = typeof userTodos['key']['sort']['field'];
|
|
185
|
-
// type T3 = typeof userTodos['indexes'];
|
|
186
|
-
// type TTT1 = AllIndexes<typeof userTodos>;
|
|
187
|
-
// type TTT2 = AllIndexesByName<typeof userTodos>;
|
|
188
|
-
// type AnyIndex = Index<any>;
|
|
189
|
-
// type TEST1 = I extends Index<any> ? 'yes' : 'no';
|
|
190
|
-
// userTodos.query({
|
|
191
|
-
// by: 'userId-order',
|
|
192
|
-
// where: {
|
|
193
|
-
// // maybe the key here can be the actual name of the field.
|
|
194
|
-
// // maybe even follow the filter pattern, but only expose the `eq` operation:
|
|
195
|
-
// // { [field]: { eq: T } }
|
|
196
|
-
// userId: { eq: 'something' },
|
|
197
|
-
// // id: {
|
|
198
|
-
// // eq: 'something'
|
|
199
|
-
// // }
|
|
200
|
-
// // same here. name of the actual sort key field.
|
|
201
|
-
// // id: {
|
|
202
|
-
// // beginsWith: 'something'
|
|
203
|
-
// // },
|
|
204
|
-
// },
|
|
205
|
-
// })
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Resource } from '../resource.js';
|
|
2
|
+
export type MessageStream<T = any> = {
|
|
3
|
+
/**
|
|
4
|
+
* Returns a function to close the subscription.
|
|
5
|
+
*/
|
|
6
|
+
subscribe(subscriber: {
|
|
7
|
+
onmessage: (data: T) => void;
|
|
8
|
+
onclose?: () => void;
|
|
9
|
+
}): () => void;
|
|
10
|
+
};
|
|
11
|
+
export declare class RealtimeService<T = any> extends Resource {
|
|
12
|
+
#private;
|
|
13
|
+
constructor(scope: Resource | string, id: string);
|
|
14
|
+
/**
|
|
15
|
+
* The address the client will need to connect to.
|
|
16
|
+
*/
|
|
17
|
+
get address(): string;
|
|
18
|
+
publish(channel: string, data: T): Promise<void>;
|
|
19
|
+
getStream(channel: string): Promise<MessageStream<T>>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import * as jose from 'jose';
|
|
2
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
3
|
+
import { Resource } from '../resource.js';
|
|
4
|
+
import { Secret } from '../resources/secret.js';
|
|
5
|
+
import { overrides } from '../overrides.js';
|
|
6
|
+
const servers = new Map();
|
|
7
|
+
const channels = new Map();
|
|
8
|
+
export class RealtimeService extends Resource {
|
|
9
|
+
#channels;
|
|
10
|
+
#secret;
|
|
11
|
+
#server;
|
|
12
|
+
constructor(scope, id) {
|
|
13
|
+
super(scope, id);
|
|
14
|
+
this.#secret = new (overrides.Secret || Secret)(this, 'auth-secret');
|
|
15
|
+
if (servers.has(this.absoluteId)) {
|
|
16
|
+
console.log('existing realtime service found');
|
|
17
|
+
this.#server = servers.get(this.absoluteId);
|
|
18
|
+
}
|
|
19
|
+
if (channels.has(this.absoluteId)) {
|
|
20
|
+
console.log('existing channel entries found');
|
|
21
|
+
this.#channels = channels.get(this.absoluteId);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
this.#channels = new Map();
|
|
25
|
+
channels.set(this.absoluteId, this.#channels);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
#requireServer() {
|
|
29
|
+
if (this.#server)
|
|
30
|
+
return;
|
|
31
|
+
console.log('Starting WebSocket server...');
|
|
32
|
+
this.#server = new WebSocketServer({ port: 0 });
|
|
33
|
+
servers.set(this.absoluteId, this.#server);
|
|
34
|
+
this.#server.on('connection', async (ws, req) => {
|
|
35
|
+
const encodedChannel = req.url?.split('/')[1];
|
|
36
|
+
let channel;
|
|
37
|
+
const protocol = ws.protocol;
|
|
38
|
+
console.log('ws connection', { encodedChannel, protocol });
|
|
39
|
+
if (!encodedChannel) {
|
|
40
|
+
console.log('No channel specified');
|
|
41
|
+
ws.close(4000, 'Channel not specified');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
channel = Buffer.from(encodedChannel, 'base64').toString('utf-8');
|
|
46
|
+
console.log('Decoded channel:', channel);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
console.log('Invalid channel encoding');
|
|
50
|
+
ws.close(4004, 'Invalid channel encoding');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (!protocol) {
|
|
54
|
+
console.log('No protocol specified');
|
|
55
|
+
ws.close(4001, 'Protocol not specified');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const { payload } = await jose.jwtVerify(protocol, new TextEncoder().encode(await this.#secret.read()));
|
|
60
|
+
if (payload.channel !== channel) {
|
|
61
|
+
console.log('Channel mismatch');
|
|
62
|
+
ws.close(4003, 'Channel mismatch');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
console.log('Invalid token', err);
|
|
68
|
+
ws.close(4002, 'Invalid token');
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
this.#channels.set(channel, this.#channels.get(channel) || []);
|
|
72
|
+
this.#channels.get(channel).push(ws);
|
|
73
|
+
ws.onclose = () => {
|
|
74
|
+
this.#channels.set(channel, this.#channels.get(channel).filter((w) => w !== ws));
|
|
75
|
+
};
|
|
76
|
+
console.log('WebSocket connection established:', channel);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Ensures channel name will be supported by known, major cloud provers using
|
|
81
|
+
* "serverless" solutions.
|
|
82
|
+
*
|
|
83
|
+
* Least common denominator on channel naming appears to be AWS AppSync:
|
|
84
|
+
* Ref: https://docs.aws.amazon.com/appsync/latest/eventapi/event-api-websocket-protocol.html#realtime-websocket-operations
|
|
85
|
+
*
|
|
86
|
+
* AFAIK, other providers allow 255 and beyond with a similar limitations. GCP PubSub
|
|
87
|
+
* doesn't support slashes in the topic name. But, on GCP, we can probably replaces
|
|
88
|
+
* slashes with underscores, and actual underscores with double-underscores...
|
|
89
|
+
*
|
|
90
|
+
* @param name
|
|
91
|
+
*/
|
|
92
|
+
#validateChannelName(name) {
|
|
93
|
+
if (!name.match(/^\/?[A-Za-z0-9](?:[A-Za-z0-9-]{0,48}[A-Za-z0-9])?(?:\/[A-Za-z0-9](?:[A-Za-z0-9-]{0,48}[A-Za-z0-9])?){0,4}\/?$/)) {
|
|
94
|
+
throw new Error("Channel name must be no more than 5 segments of 1-50 alphanumeric characters and dashes each.");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The address the client will need to connect to.
|
|
99
|
+
*/
|
|
100
|
+
get address() {
|
|
101
|
+
this.#requireServer();
|
|
102
|
+
const address = this.#server.address();
|
|
103
|
+
if (typeof address === 'string') {
|
|
104
|
+
return address;
|
|
105
|
+
}
|
|
106
|
+
else if (typeof address === 'object' && address !== null) {
|
|
107
|
+
const { port } = address;
|
|
108
|
+
return `ws://localhost:${port}`;
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
throw new Error('Server address is not available');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async publish(channel, data) {
|
|
115
|
+
this.#requireServer();
|
|
116
|
+
this.#validateChannelName(channel);
|
|
117
|
+
console.log('Publishing to channel:', channel, data);
|
|
118
|
+
(this.#channels.get(channel) || []).forEach((ws) => {
|
|
119
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
120
|
+
ws.send(JSON.stringify({ data }));
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
async getStream(channel) {
|
|
125
|
+
this.#requireServer();
|
|
126
|
+
this.#validateChannelName(channel);
|
|
127
|
+
const channelString = Buffer.from(channel).toString('base64');
|
|
128
|
+
const payload = { channel };
|
|
129
|
+
const jwt = await new jose.SignJWT(payload)
|
|
130
|
+
.setProtectedHeader({ alg: 'HS256' })
|
|
131
|
+
.setIssuedAt()
|
|
132
|
+
.setExpirationTime(`10s`)
|
|
133
|
+
.sign(new TextEncoder().encode(await this.#secret.read()));
|
|
134
|
+
// The type we return is not at all what we actually return.
|
|
135
|
+
// It's the metadata needed to satisfy the contract client-side.
|
|
136
|
+
return {
|
|
137
|
+
__wjstype: 'realtime',
|
|
138
|
+
url: `${this.address}/${channelString}`,
|
|
139
|
+
protocol: jwt
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// TEMP. playing with API interface.
|
|
144
|
+
// TODO: figure out when/where a `parse` option would be used.
|
|
145
|
+
// probably just server-side when sending messages to clients. when
|
|
146
|
+
// clients send messages to the server, the server could also parse
|
|
147
|
+
// the message to validate it.
|
|
148
|
+
//
|
|
149
|
+
// const rt = new RealtimeService<{ message: string }>('app', 'rt');
|
|
150
|
+
// rt.publish('test', { message: 'test' });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wirejs-resources",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.60-realtime",
|
|
4
4
|
"description": "Basic services and server-side resources for wirejs apps",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -34,9 +34,11 @@
|
|
|
34
34
|
},
|
|
35
35
|
"homepage": "https://github.com/svidgen/create-wirejs-app#readme",
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"jose": "^5.9.6"
|
|
37
|
+
"jose": "^5.9.6",
|
|
38
|
+
"ws": "^8.18.2"
|
|
38
39
|
},
|
|
39
40
|
"devDependencies": {
|
|
41
|
+
"@types/ws": "^8.18.1",
|
|
40
42
|
"typescript": "^5.7.3"
|
|
41
43
|
},
|
|
42
44
|
"files": [
|