@evolu/nodejs 3.0.0-next.0 → 3.0.0-next.2
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 +21 -0
- package/dist/src/Crypto.d.ts +1 -0
- package/dist/src/Crypto.d.ts.map +1 -1
- package/dist/src/Crypto.js +1 -0
- package/dist/src/WebSocket.d.ts +19 -0
- package/dist/src/WebSocket.d.ts.map +1 -0
- package/dist/src/WebSocket.js +46 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/local-first/Relay.d.ts +2 -2
- package/dist/src/local-first/Relay.d.ts.map +1 -1
- package/dist/src/local-first/Relay.js +72 -45
- package/package.json +13 -11
- package/src/Crypto.ts +1 -0
- package/src/WebSocket.ts +69 -0
- package/src/index.ts +1 -0
- package/src/local-first/Relay.ts +104 -54
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Evolu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/src/Crypto.d.ts
CHANGED
package/dist/src/Crypto.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Crypto.d.ts","sourceRoot":"","sources":["../../src/Crypto.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAGrD,eAAO,MAAM,qBAAqB,QAAO,eAAkC,CAAC"}
|
|
1
|
+
{"version":3,"file":"Crypto.d.ts","sourceRoot":"","sources":["../../src/Crypto.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAGrD,qEAAqE;AACrE,eAAO,MAAM,qBAAqB,QAAO,eAAkC,CAAC"}
|
package/dist/src/Crypto.js
CHANGED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type ClientRequest, type IncomingMessage } from "http";
|
|
2
|
+
/**
|
|
3
|
+
* A raw WebSocket upgrade request prepared for tests by
|
|
4
|
+
* {@link testSetupWebSocketUpgradeRequest}.
|
|
5
|
+
*/
|
|
6
|
+
export interface TestSetupWebSocketUpgradeRequest extends AsyncDisposable {
|
|
7
|
+
readonly req: ClientRequest;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Creates a raw WebSocket upgrade request test setup for tests that need manual
|
|
11
|
+
* request control.
|
|
12
|
+
*/
|
|
13
|
+
export declare const testSetupWebSocketUpgradeRequest: (port: number, path: string) => TestSetupWebSocketUpgradeRequest;
|
|
14
|
+
/**
|
|
15
|
+
* Sends a raw WebSocket upgrade request and resolves with the rejection
|
|
16
|
+
* response.
|
|
17
|
+
*/
|
|
18
|
+
export declare const testSendWebSocketUpgradeRequest: (port: number, path: string) => Promise<IncomingMessage>;
|
|
19
|
+
//# sourceMappingURL=WebSocket.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"WebSocket.d.ts","sourceRoot":"","sources":["../../src/WebSocket.ts"],"names":[],"mappings":"AAAA,OAAO,EAAW,KAAK,aAAa,EAAE,KAAK,eAAe,EAAE,MAAM,MAAM,CAAC;AAEzE;;;GAGG;AACH,MAAM,WAAW,gCAAiC,SAAQ,eAAe;IACvE,QAAQ,CAAC,GAAG,EAAE,aAAa,CAAC;CAC7B;AAED;;;GAGG;AACH,eAAO,MAAM,gCAAgC,GAC3C,MAAM,MAAM,EACZ,MAAM,MAAM,KACX,gCAWF,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,+BAA+B,GAC1C,MAAM,MAAM,EACZ,MAAM,MAAM,KACX,OAAO,CAAC,eAAe,CAezB,CAAC"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { request } from "http";
|
|
2
|
+
/**
|
|
3
|
+
* Creates a raw WebSocket upgrade request test setup for tests that need manual
|
|
4
|
+
* request control.
|
|
5
|
+
*/
|
|
6
|
+
export const testSetupWebSocketUpgradeRequest = (port, path) => {
|
|
7
|
+
const req = createWebSocketUpgradeRequest(port, path);
|
|
8
|
+
req.on("error", () => undefined);
|
|
9
|
+
return {
|
|
10
|
+
req,
|
|
11
|
+
[Symbol.asyncDispose]: () => {
|
|
12
|
+
req.destroy();
|
|
13
|
+
return Promise.resolve();
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Sends a raw WebSocket upgrade request and resolves with the rejection
|
|
19
|
+
* response.
|
|
20
|
+
*/
|
|
21
|
+
export const testSendWebSocketUpgradeRequest = async (port, path) => {
|
|
22
|
+
const req = createWebSocketUpgradeRequest(port, path);
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
req.once("response", (response) => {
|
|
25
|
+
response.resume();
|
|
26
|
+
resolve(response);
|
|
27
|
+
});
|
|
28
|
+
req.once("upgrade", (_response, socket) => {
|
|
29
|
+
socket.destroy();
|
|
30
|
+
reject(new Error("Expected HTTP upgrade rejection"));
|
|
31
|
+
});
|
|
32
|
+
req.once("error", reject);
|
|
33
|
+
req.end();
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
const createWebSocketUpgradeRequest = (port, path) => request({
|
|
37
|
+
host: "127.0.0.1",
|
|
38
|
+
port,
|
|
39
|
+
path,
|
|
40
|
+
headers: {
|
|
41
|
+
Connection: "Upgrade",
|
|
42
|
+
Upgrade: "websocket",
|
|
43
|
+
"Sec-WebSocket-Version": "13",
|
|
44
|
+
"Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==",
|
|
45
|
+
},
|
|
46
|
+
});
|
package/dist/src/index.d.ts
CHANGED
package/dist/src/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,wBAAwB,CAAC;AACvC,cAAc,WAAW,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,wBAAwB,CAAC;AACvC,cAAc,WAAW,CAAC;AAC1B,cAAc,gBAAgB,CAAC"}
|
package/dist/src/index.js
CHANGED
|
@@ -37,8 +37,8 @@ export declare const createRelayDeps: () => RelayDeps;
|
|
|
37
37
|
* startRelay({
|
|
38
38
|
* port: 4000,
|
|
39
39
|
*
|
|
40
|
-
* // Note: Relay requires URL in format ws://host:port
|
|
41
|
-
* // isOwnerAllowed: (_ownerId) => true,
|
|
40
|
+
* // Note: Relay requires URL in format ws://host:port?ownerId=<ownerId>
|
|
41
|
+
* // isOwnerAllowed: (_ownerId, { signal: _signal }) => true,
|
|
42
42
|
*
|
|
43
43
|
* isOwnerWithinQuota: (_ownerId, requiredBytes) => {
|
|
44
44
|
* const maxBytes = 1024 * 1024; // 1MB
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Relay.d.ts","sourceRoot":"","sources":["../../../src/local-first/Relay.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"Relay.d.ts","sourceRoot":"","sources":["../../../src/local-first/Relay.ts"],"names":[],"mappings":"AAAA,OAAO,EAOL,KAAK,qBAAqB,EAI1B,KAAK,SAAS,EACd,KAAK,IAAI,EACT,KAAK,kBAAkB,EAExB,MAAM,eAAe,CAAC;AACvB,OAAO,EAQL,KAAK,KAAK,EACV,KAAK,WAAW,EACjB,MAAM,2BAA2B,CAAC;AAQnC,MAAM,WAAW,iBAAkB,SAAQ,WAAW;IACpD,2CAA2C;IAC3C,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,MAAM,SAAS,GAAG,qBAAqB,GAAG,SAAS,GAAG,kBAAkB,CAAC;AAE/E,gEAAgE;AAChE,eAAO,MAAM,eAAe,QAAO,SAIjC,CAAC;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,eAAO,MAAM,UAAU,GACpB,qDAKE,iBAAiB,KAAG,IAAI,CAAC,KAAK,EAAE,KAAK,EAAE,SAAS,CA6MlD,CAAC"}
|
|
@@ -50,8 +50,9 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
|
|
|
50
50
|
var e = new Error(message);
|
|
51
51
|
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
52
52
|
});
|
|
53
|
-
import { createRandom, createRelation, createSqlite,
|
|
53
|
+
import { AbortError, assert, callback, createRandom, createRelation, createSqlite, Name, ok, OwnerId, Uint8Array, } from "@evolu/common";
|
|
54
54
|
import { applyProtocolMessageAsRelay, createBaseSqliteStorageTables, createRelaySqliteStorage, createRelayStorageTables, defaultProtocolMessageMaxSize, parseOwnerIdFromOwnerWebSocketTransportUrl, } from "@evolu/common/local-first";
|
|
55
|
+
import { once } from "events";
|
|
55
56
|
import { existsSync } from "fs";
|
|
56
57
|
import { createServer } from "http";
|
|
57
58
|
import { WebSocket, WebSocketServer } from "ws";
|
|
@@ -93,8 +94,8 @@ export const createRelayDeps = () => ({
|
|
|
93
94
|
* startRelay({
|
|
94
95
|
* port: 4000,
|
|
95
96
|
*
|
|
96
|
-
* // Note: Relay requires URL in format ws://host:port
|
|
97
|
-
* // isOwnerAllowed: (_ownerId) => true,
|
|
97
|
+
* // Note: Relay requires URL in format ws://host:port?ownerId=<ownerId>
|
|
98
|
+
* // isOwnerAllowed: (_ownerId, { signal: _signal }) => true,
|
|
98
99
|
*
|
|
99
100
|
* isOwnerWithinQuota: (_ownerId, requiredBytes) => {
|
|
100
101
|
* const maxBytes = 1024 * 1024; // 1MB
|
|
@@ -112,27 +113,35 @@ export const startRelay = ({ port = 443, name = Name.orThrow("evolu-relay"), isO
|
|
|
112
113
|
try {
|
|
113
114
|
const stack = __addDisposableResource(env_1, new AsyncDisposableStack(), true);
|
|
114
115
|
const console = run.deps.console.child("relay");
|
|
116
|
+
stack.defer(() => {
|
|
117
|
+
console.info("Shutdown complete");
|
|
118
|
+
});
|
|
115
119
|
const dbFileExists = existsSync(`${name}.db`);
|
|
116
|
-
const
|
|
117
|
-
if (!sqliteResult.ok)
|
|
118
|
-
return sqliteResult;
|
|
119
|
-
const sqlite = stack.use(sqliteResult.value);
|
|
120
|
+
const sqlite = stack.use(await run.orThrow(createSqlite(name)));
|
|
120
121
|
const deps = { ...run.deps, sqlite };
|
|
121
122
|
if (!dbFileExists) {
|
|
122
123
|
createBaseSqliteStorageTables(deps);
|
|
123
124
|
createRelayStorageTables(deps);
|
|
124
125
|
}
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}),
|
|
126
|
+
const server = stack.use(createServer());
|
|
127
|
+
server.once("close", () => {
|
|
128
|
+
console.info("HTTP server closed");
|
|
129
129
|
});
|
|
130
|
-
const
|
|
131
|
-
const wss = new WebSocketServer({
|
|
130
|
+
const wss = stack.adopt(new WebSocketServer({
|
|
132
131
|
maxPayload: defaultProtocolMessageMaxSize,
|
|
133
132
|
noServer: true,
|
|
134
|
-
})
|
|
133
|
+
}), (wss) => new Promise((resolve) => {
|
|
134
|
+
wss.close(() => {
|
|
135
|
+
console.info("WebSocketServer closed");
|
|
136
|
+
resolve();
|
|
137
|
+
});
|
|
138
|
+
}));
|
|
135
139
|
const ownerSocketRelation = createRelation();
|
|
140
|
+
const relayRun = stack.use(run.create().addDeps({
|
|
141
|
+
storage: createRelaySqliteStorage(deps)({
|
|
142
|
+
isOwnerWithinQuota,
|
|
143
|
+
}),
|
|
144
|
+
}));
|
|
136
145
|
server.on("upgrade", (request, socket, head) => {
|
|
137
146
|
socket.on("error", console.debug);
|
|
138
147
|
const completeUpgrade = () => {
|
|
@@ -145,24 +154,48 @@ export const startRelay = ({ port = 443, name = Name.orThrow("evolu-relay"), isO
|
|
|
145
154
|
completeUpgrade();
|
|
146
155
|
return;
|
|
147
156
|
}
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
socket.write(
|
|
157
|
+
const respondAndDestroy = (statusCode) => {
|
|
158
|
+
if (socket.destroyed)
|
|
159
|
+
return;
|
|
160
|
+
socket.write(`HTTP/1.1 ${statusCode} ${HttpStatusTextByCode[statusCode]}\r\n\r\n`);
|
|
152
161
|
socket.destroy();
|
|
162
|
+
};
|
|
163
|
+
const requestUrl = request.url;
|
|
164
|
+
const ownerId = requestUrl
|
|
165
|
+
? parseOwnerIdFromOwnerWebSocketTransportUrl(requestUrl)
|
|
166
|
+
: undefined;
|
|
167
|
+
if (!ownerId) {
|
|
168
|
+
console.debug("invalid or missing ownerId in URL", requestUrl);
|
|
169
|
+
respondAndDestroy(400);
|
|
153
170
|
return;
|
|
154
171
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
172
|
+
const authorizationFiber = relayRun(callback(({ ok, err, signal }) => {
|
|
173
|
+
void Promise.try(() => isOwnerAllowed(ownerId, { signal })).then(ok, err);
|
|
174
|
+
}));
|
|
175
|
+
const abortAuthorization = () => {
|
|
176
|
+
authorizationFiber.abort("WebSocket upgrade request socket closed");
|
|
177
|
+
};
|
|
178
|
+
socket.once("close", abortAuthorization);
|
|
179
|
+
socket.once("error", abortAuthorization);
|
|
180
|
+
void authorizationFiber.then((result) => {
|
|
181
|
+
socket.removeListener("close", abortAuthorization);
|
|
182
|
+
socket.removeListener("error", abortAuthorization);
|
|
183
|
+
if (!result.ok) {
|
|
184
|
+
if (!AbortError.is(result.error)) {
|
|
185
|
+
console.error("isOwnerAllowed failed", ownerId, result.error);
|
|
186
|
+
respondAndDestroy(503);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
161
189
|
socket.destroy();
|
|
162
190
|
return;
|
|
163
191
|
}
|
|
192
|
+
if (!result.value) {
|
|
193
|
+
console.debug("unauthorized owner", ownerId);
|
|
194
|
+
respondAndDestroy(401);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
164
197
|
completeUpgrade();
|
|
165
|
-
})
|
|
198
|
+
});
|
|
166
199
|
});
|
|
167
200
|
wss.on("connection", (ws) => {
|
|
168
201
|
console.debug("on connection", wss.clients.size);
|
|
@@ -201,23 +234,6 @@ export const startRelay = ({ port = 443, name = Name.orThrow("evolu-relay"), isO
|
|
|
201
234
|
console.debug("ws close", wss.clients.size);
|
|
202
235
|
});
|
|
203
236
|
});
|
|
204
|
-
// Cleanup runs in LIFO order: clients → WebSocketServer → HTTP server
|
|
205
|
-
stack.defer(() => {
|
|
206
|
-
console.info("Shutdown complete");
|
|
207
|
-
});
|
|
208
|
-
stack.defer(() => {
|
|
209
|
-
server.close(() => {
|
|
210
|
-
console.info("HTTP server closed");
|
|
211
|
-
});
|
|
212
|
-
});
|
|
213
|
-
stack.defer(() => {
|
|
214
|
-
// wss.close() emits 'close' when all clients have disconnected
|
|
215
|
-
// https://github.com/websockets/ws/blob/master/doc/ws.md#serverclosecallback
|
|
216
|
-
wss.close(() => {
|
|
217
|
-
console.info("WebSocketServer closed");
|
|
218
|
-
ok();
|
|
219
|
-
});
|
|
220
|
-
});
|
|
221
237
|
stack.defer(() => {
|
|
222
238
|
console.info("Shutting down...");
|
|
223
239
|
for (const client of wss.clients) {
|
|
@@ -226,10 +242,16 @@ export const startRelay = ({ port = 443, name = Name.orThrow("evolu-relay"), isO
|
|
|
226
242
|
}
|
|
227
243
|
}
|
|
228
244
|
});
|
|
229
|
-
stack.use(relayRun);
|
|
230
245
|
server.listen(port);
|
|
231
|
-
|
|
232
|
-
|
|
246
|
+
await once(server, "listening");
|
|
247
|
+
const address = server.address();
|
|
248
|
+
assert(address && typeof address !== "string", "Expected TCP address");
|
|
249
|
+
const moved = stack.move();
|
|
250
|
+
console.info(`Started on port ${address.port}`);
|
|
251
|
+
return ok({
|
|
252
|
+
port: address.port,
|
|
253
|
+
[Symbol.asyncDispose]: () => moved.disposeAsync(),
|
|
254
|
+
});
|
|
233
255
|
}
|
|
234
256
|
catch (e_1) {
|
|
235
257
|
env_1.error = e_1;
|
|
@@ -241,3 +263,8 @@ export const startRelay = ({ port = 443, name = Name.orThrow("evolu-relay"), isO
|
|
|
241
263
|
await result_1;
|
|
242
264
|
}
|
|
243
265
|
};
|
|
266
|
+
const HttpStatusTextByCode = {
|
|
267
|
+
400: "Bad Request",
|
|
268
|
+
401: "Unauthorized",
|
|
269
|
+
503: "Service Unavailable",
|
|
270
|
+
};
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evolu/nodejs",
|
|
3
|
-
"version": "3.0.0-next.
|
|
3
|
+
"version": "3.0.0-next.2",
|
|
4
4
|
"description": "Evolu for Node.js",
|
|
5
5
|
"author": "Daniel Steigerwald <daniel@steigerwald.cz>",
|
|
6
6
|
"license": "MIT",
|
|
7
|
-
"repository":
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/evoluhq/evolu.git"
|
|
10
|
+
},
|
|
8
11
|
"bugs": {
|
|
9
12
|
"url": "https://github.com/evoluhq/evolu/issues"
|
|
10
13
|
},
|
|
@@ -23,22 +26,17 @@
|
|
|
23
26
|
"src/**",
|
|
24
27
|
"README.md"
|
|
25
28
|
],
|
|
26
|
-
"scripts": {
|
|
27
|
-
"build": "tsc --build tsconfig.build.json",
|
|
28
|
-
"prepack": "npm run build",
|
|
29
|
-
"format": "prettier --write \"src/*.{ts,tsx,md}\""
|
|
30
|
-
},
|
|
31
29
|
"dependencies": {
|
|
32
30
|
"better-sqlite3": "^12.6.2",
|
|
33
31
|
"ws": "^8.19.0"
|
|
34
32
|
},
|
|
35
33
|
"devDependencies": {
|
|
36
|
-
"@evolu/common": "8.0.0-next.0",
|
|
37
|
-
"@evolu/typescript-config": "*",
|
|
38
34
|
"@types/better-sqlite3": "^7.6.13",
|
|
39
35
|
"@types/node": "^24.10.9",
|
|
40
36
|
"@types/ws": "^8.18.1",
|
|
41
|
-
"typescript": "^
|
|
37
|
+
"typescript": "^6",
|
|
38
|
+
"@evolu/common": "8.0.0-next.2",
|
|
39
|
+
"@evolu/typescript-config": "0.0.2"
|
|
42
40
|
},
|
|
43
41
|
"peerDependencies": {
|
|
44
42
|
"@evolu/common": "^8.0.0-next.0"
|
|
@@ -48,5 +46,9 @@
|
|
|
48
46
|
},
|
|
49
47
|
"publishConfig": {
|
|
50
48
|
"access": "public"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "tsc --build tsconfig.build.json",
|
|
52
|
+
"format": "prettier --write \"src/*.{ts,tsx,md}\""
|
|
51
53
|
}
|
|
52
|
-
}
|
|
54
|
+
}
|
package/src/Crypto.ts
CHANGED
package/src/WebSocket.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { request, type ClientRequest, type IncomingMessage } from "http";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A raw WebSocket upgrade request prepared for tests by
|
|
5
|
+
* {@link testSetupWebSocketUpgradeRequest}.
|
|
6
|
+
*/
|
|
7
|
+
export interface TestSetupWebSocketUpgradeRequest extends AsyncDisposable {
|
|
8
|
+
readonly req: ClientRequest;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Creates a raw WebSocket upgrade request test setup for tests that need manual
|
|
13
|
+
* request control.
|
|
14
|
+
*/
|
|
15
|
+
export const testSetupWebSocketUpgradeRequest = (
|
|
16
|
+
port: number,
|
|
17
|
+
path: string,
|
|
18
|
+
): TestSetupWebSocketUpgradeRequest => {
|
|
19
|
+
const req = createWebSocketUpgradeRequest(port, path);
|
|
20
|
+
req.on("error", () => undefined);
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
req,
|
|
24
|
+
[Symbol.asyncDispose]: () => {
|
|
25
|
+
req.destroy();
|
|
26
|
+
return Promise.resolve();
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Sends a raw WebSocket upgrade request and resolves with the rejection
|
|
33
|
+
* response.
|
|
34
|
+
*/
|
|
35
|
+
export const testSendWebSocketUpgradeRequest = async (
|
|
36
|
+
port: number,
|
|
37
|
+
path: string,
|
|
38
|
+
): Promise<IncomingMessage> => {
|
|
39
|
+
const req = createWebSocketUpgradeRequest(port, path);
|
|
40
|
+
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
req.once("response", (response) => {
|
|
43
|
+
response.resume();
|
|
44
|
+
resolve(response);
|
|
45
|
+
});
|
|
46
|
+
req.once("upgrade", (_response, socket) => {
|
|
47
|
+
socket.destroy();
|
|
48
|
+
reject(new Error("Expected HTTP upgrade rejection"));
|
|
49
|
+
});
|
|
50
|
+
req.once("error", reject);
|
|
51
|
+
req.end();
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const createWebSocketUpgradeRequest = (
|
|
56
|
+
port: number,
|
|
57
|
+
path: string,
|
|
58
|
+
): ClientRequest =>
|
|
59
|
+
request({
|
|
60
|
+
host: "127.0.0.1",
|
|
61
|
+
port,
|
|
62
|
+
path,
|
|
63
|
+
headers: {
|
|
64
|
+
Connection: "Upgrade",
|
|
65
|
+
Upgrade: "websocket",
|
|
66
|
+
"Sec-WebSocket-Version": "13",
|
|
67
|
+
"Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==",
|
|
68
|
+
},
|
|
69
|
+
});
|
package/src/index.ts
CHANGED
package/src/local-first/Relay.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
|
+
AbortError,
|
|
3
|
+
assert,
|
|
4
|
+
callback,
|
|
2
5
|
createRandom,
|
|
3
6
|
createRelation,
|
|
4
7
|
createSqlite,
|
|
5
8
|
type CreateSqliteDriverDep,
|
|
6
|
-
isPromiseLike,
|
|
7
9
|
Name,
|
|
8
10
|
ok,
|
|
9
11
|
OwnerId,
|
|
@@ -23,6 +25,7 @@ import {
|
|
|
23
25
|
type Relay,
|
|
24
26
|
type RelayConfig,
|
|
25
27
|
} from "@evolu/common/local-first";
|
|
28
|
+
import { once } from "events";
|
|
26
29
|
import { existsSync } from "fs";
|
|
27
30
|
import { createServer } from "http";
|
|
28
31
|
import { WebSocket, WebSocketServer } from "ws";
|
|
@@ -73,8 +76,8 @@ export const createRelayDeps = (): RelayDeps => ({
|
|
|
73
76
|
* startRelay({
|
|
74
77
|
* port: 4000,
|
|
75
78
|
*
|
|
76
|
-
* // Note: Relay requires URL in format ws://host:port
|
|
77
|
-
* // isOwnerAllowed: (_ownerId) => true,
|
|
79
|
+
* // Note: Relay requires URL in format ws://host:port?ownerId=<ownerId>
|
|
80
|
+
* // isOwnerAllowed: (_ownerId, { signal: _signal }) => true,
|
|
78
81
|
*
|
|
79
82
|
* isOwnerWithinQuota: (_ownerId, requiredBytes) => {
|
|
80
83
|
* const maxBytes = 1024 * 1024; // 1MB
|
|
@@ -98,12 +101,12 @@ export const startRelay =
|
|
|
98
101
|
await using stack = new AsyncDisposableStack();
|
|
99
102
|
const console = run.deps.console.child("relay");
|
|
100
103
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (!sqliteResult.ok) return sqliteResult;
|
|
105
|
-
const sqlite = stack.use(sqliteResult.value);
|
|
104
|
+
stack.defer(() => {
|
|
105
|
+
console.info("Shutdown complete");
|
|
106
|
+
});
|
|
106
107
|
|
|
108
|
+
const dbFileExists = existsSync(`${name}.db`);
|
|
109
|
+
const sqlite = stack.use(await run.orThrow(createSqlite(name)));
|
|
107
110
|
const deps = { ...run.deps, sqlite };
|
|
108
111
|
|
|
109
112
|
if (!dbFileExists) {
|
|
@@ -111,19 +114,35 @@ export const startRelay =
|
|
|
111
114
|
createRelayStorageTables(deps);
|
|
112
115
|
}
|
|
113
116
|
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}),
|
|
117
|
+
const server = stack.use(createServer());
|
|
118
|
+
server.once("close", () => {
|
|
119
|
+
console.info("HTTP server closed");
|
|
118
120
|
});
|
|
119
121
|
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
122
|
+
const wss = stack.adopt(
|
|
123
|
+
new WebSocketServer({
|
|
124
|
+
maxPayload: defaultProtocolMessageMaxSize,
|
|
125
|
+
noServer: true,
|
|
126
|
+
}),
|
|
127
|
+
(wss) =>
|
|
128
|
+
new Promise<void>((resolve) => {
|
|
129
|
+
wss.close(() => {
|
|
130
|
+
console.info("WebSocketServer closed");
|
|
131
|
+
resolve();
|
|
132
|
+
});
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
|
|
125
136
|
const ownerSocketRelation = createRelation<OwnerId, WebSocket>();
|
|
126
137
|
|
|
138
|
+
const relayRun = stack.use(
|
|
139
|
+
run.create().addDeps({
|
|
140
|
+
storage: createRelaySqliteStorage(deps)({
|
|
141
|
+
isOwnerWithinQuota,
|
|
142
|
+
}),
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
|
|
127
146
|
server.on("upgrade", (request, socket, head) => {
|
|
128
147
|
socket.on("error", console.debug);
|
|
129
148
|
|
|
@@ -140,28 +159,65 @@ export const startRelay =
|
|
|
140
159
|
return;
|
|
141
160
|
}
|
|
142
161
|
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
)
|
|
162
|
+
const respondAndDestroy = (
|
|
163
|
+
statusCode: keyof typeof HttpStatusTextByCode,
|
|
164
|
+
) => {
|
|
165
|
+
if (socket.destroyed) return;
|
|
166
|
+
socket.write(
|
|
167
|
+
`HTTP/1.1 ${statusCode} ${HttpStatusTextByCode[statusCode]}\r\n\r\n`,
|
|
168
|
+
);
|
|
169
|
+
socket.destroy();
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const requestUrl = request.url;
|
|
173
|
+
const ownerId = requestUrl
|
|
174
|
+
? parseOwnerIdFromOwnerWebSocketTransportUrl(requestUrl)
|
|
175
|
+
: undefined;
|
|
146
176
|
|
|
147
177
|
if (!ownerId) {
|
|
148
|
-
console.debug("invalid or missing ownerId in URL",
|
|
149
|
-
|
|
150
|
-
socket.destroy();
|
|
178
|
+
console.debug("invalid or missing ownerId in URL", requestUrl);
|
|
179
|
+
respondAndDestroy(400);
|
|
151
180
|
return;
|
|
152
181
|
}
|
|
153
182
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
183
|
+
const authorizationFiber = relayRun(
|
|
184
|
+
callback<boolean, unknown>(({ ok, err, signal }) => {
|
|
185
|
+
void Promise.try(() => isOwnerAllowed(ownerId, { signal })).then(
|
|
186
|
+
ok,
|
|
187
|
+
err,
|
|
188
|
+
);
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const abortAuthorization = () => {
|
|
193
|
+
authorizationFiber.abort("WebSocket upgrade request socket closed");
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
socket.once("close", abortAuthorization);
|
|
197
|
+
socket.once("error", abortAuthorization);
|
|
198
|
+
|
|
199
|
+
void authorizationFiber.then((result) => {
|
|
200
|
+
socket.removeListener("close", abortAuthorization);
|
|
201
|
+
socket.removeListener("error", abortAuthorization);
|
|
202
|
+
|
|
203
|
+
if (!result.ok) {
|
|
204
|
+
if (!AbortError.is(result.error)) {
|
|
205
|
+
console.error("isOwnerAllowed failed", ownerId, result.error);
|
|
206
|
+
respondAndDestroy(503);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
160
209
|
socket.destroy();
|
|
161
210
|
return;
|
|
162
211
|
}
|
|
212
|
+
|
|
213
|
+
if (!result.value) {
|
|
214
|
+
console.debug("unauthorized owner", ownerId);
|
|
215
|
+
respondAndDestroy(401);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
163
219
|
completeUpgrade();
|
|
164
|
-
})
|
|
220
|
+
});
|
|
165
221
|
});
|
|
166
222
|
|
|
167
223
|
wss.on("connection", (ws) => {
|
|
@@ -222,26 +278,6 @@ export const startRelay =
|
|
|
222
278
|
});
|
|
223
279
|
});
|
|
224
280
|
|
|
225
|
-
// Cleanup runs in LIFO order: clients → WebSocketServer → HTTP server
|
|
226
|
-
stack.defer(() => {
|
|
227
|
-
console.info("Shutdown complete");
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
stack.defer(() => {
|
|
231
|
-
server.close(() => {
|
|
232
|
-
console.info("HTTP server closed");
|
|
233
|
-
});
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
stack.defer(() => {
|
|
237
|
-
// wss.close() emits 'close' when all clients have disconnected
|
|
238
|
-
// https://github.com/websockets/ws/blob/master/doc/ws.md#serverclosecallback
|
|
239
|
-
wss.close(() => {
|
|
240
|
-
console.info("WebSocketServer closed");
|
|
241
|
-
ok();
|
|
242
|
-
});
|
|
243
|
-
});
|
|
244
|
-
|
|
245
281
|
stack.defer(() => {
|
|
246
282
|
console.info("Shutting down...");
|
|
247
283
|
for (const client of wss.clients) {
|
|
@@ -251,10 +287,24 @@ export const startRelay =
|
|
|
251
287
|
}
|
|
252
288
|
});
|
|
253
289
|
|
|
254
|
-
stack.use(relayRun);
|
|
255
|
-
|
|
256
290
|
server.listen(port);
|
|
257
|
-
|
|
291
|
+
await once(server, "listening");
|
|
292
|
+
|
|
293
|
+
const address = server.address();
|
|
294
|
+
assert(address && typeof address !== "string", "Expected TCP address");
|
|
295
|
+
|
|
296
|
+
const moved = stack.move();
|
|
258
297
|
|
|
259
|
-
|
|
298
|
+
console.info(`Started on port ${address.port}`);
|
|
299
|
+
|
|
300
|
+
return ok({
|
|
301
|
+
port: address.port,
|
|
302
|
+
[Symbol.asyncDispose]: () => moved.disposeAsync(),
|
|
303
|
+
});
|
|
260
304
|
};
|
|
305
|
+
|
|
306
|
+
const HttpStatusTextByCode = {
|
|
307
|
+
400: "Bad Request",
|
|
308
|
+
401: "Unauthorized",
|
|
309
|
+
503: "Service Unavailable",
|
|
310
|
+
} as const;
|