Skip to content

Commit 4f33bd0

Browse files
EvanHahngmaclennan
andauthored
feat: adding self-hosted servers (#952)
This adds support for adding self-hosted servers. It adds the following functions: - `project.$member.addServerPeer()` - `project.$sync.connectServers()` - `project.$sync.disconnectServers()` This change doesn't include end-to-end tests for the server. That's deliberate! We can't (easily) add those tests without the server being released, but we can't (easily) release the server without this change. Once this change is released, we can release the server, and then we should be able to add end-to-end tests. See [#886](#886) for more. Co-Authored-By: Gregor MacLennan <gmaclennan@digital-democracy.org>
1 parent 4a7bf54 commit 4f33bd0

16 files changed

Lines changed: 1081 additions & 367 deletions

package-lock.json

Lines changed: 167 additions & 354 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@
125125
"@types/sub-encoder": "^2.1.0",
126126
"@types/throttle-debounce": "^5.0.0",
127127
"@types/varint": "^6.0.1",
128+
"@types/ws": "^8.5.12",
128129
"@types/yauzl-promise": "^4.0.0",
129130
"@types/yazl": "^2.4.5",
130131
"bitfield": "^4.2.0",
@@ -200,6 +201,7 @@
200201
"type-fest": "^4.5.0",
201202
"undici": "^6.13.0",
202203
"varint": "^6.0.0",
204+
"ws": "^8.18.0",
203205
"yauzl-promise": "^4.0.0"
204206
}
205207
}

src/index.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,19 @@ import {
33
COORDINATOR_ROLE_ID,
44
MEMBER_ROLE_ID,
55
} from './roles.js'
6+
import { kProjectReplicate } from './mapeo-project.js'
67
export { plugin as CoMapeoMapsFastifyPlugin } from './fastify-plugins/maps.js'
78
export { FastifyController } from './fastify-controller.js'
89
export { MapeoManager } from './mapeo-manager.js'
10+
/** @import { MapeoProject } from './mapeo-project.js' */
11+
12+
/**
13+
* @param {MapeoProject} project
14+
* @param {Parameters<MapeoProject.prototype[kProjectReplicate]>} args
15+
* @returns {ReturnType<MapeoProject.prototype[kProjectReplicate]>}
16+
*/
17+
export const replicateProject = (project, ...args) =>
18+
project[kProjectReplicate](...args)
919

1020
export const roles = /** @type {const} */ ({
1121
CREATOR_ROLE_ID,

src/lib/error.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Create an `Error` with a `code` property.
3+
*
4+
* @example
5+
* const err = new ErrorWithCode('INVALID_DATA', 'data was invalid')
6+
* err.message
7+
* // => 'data was invalid'
8+
* err.code
9+
* // => 'INVALID_DATA'
10+
*/
11+
export class ErrorWithCode extends Error {
12+
/**
13+
* @param {string} code
14+
* @param {string} message
15+
* @param {object} [options]
16+
* @param {unknown} [options.cause]
17+
*/
18+
constructor(code, message, options) {
19+
super(message, options)
20+
/** @readonly */ this.code = code
21+
}
22+
}
23+
24+
/**
25+
* Get the error message from an object if possible.
26+
* Otherwise, stringify the argument.
27+
*
28+
* @param {unknown} maybeError
29+
* @returns {string}
30+
* @example
31+
* try {
32+
* // do something
33+
* } catch (err) {
34+
* console.error(getErrorMessage(err))
35+
* }
36+
*/
37+
export function getErrorMessage(maybeError) {
38+
if (maybeError && typeof maybeError === 'object' && 'message' in maybeError) {
39+
try {
40+
const { message } = maybeError
41+
if (typeof message === 'string') return message
42+
} catch (_err) {
43+
// Ignored
44+
}
45+
}
46+
return 'unknown error'
47+
}

src/lib/get-own.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* @template {object} T
3+
* @template {keyof T} K
4+
* @param {T} obj
5+
* @param {K} key
6+
* @returns {undefined | T[K]}
7+
*/
8+
export function getOwn(obj, key) {
9+
return Object.hasOwn(obj, key) ? obj[key] : undefined
10+
}

src/lib/is-hostname-ip-address.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { isIPv4, isIPv6 } from 'node:net'
2+
3+
/**
4+
* Is this hostname an IP address?
5+
*
6+
* @param {string} hostname
7+
* @returns {boolean}
8+
* @example
9+
* isHostnameIpAddress('100.64.0.42')
10+
* // => false
11+
*
12+
* isHostnameIpAddress('[2001:0db8:85a3:0000:0000:8a2e:0370:7334]')
13+
* // => true
14+
*
15+
* isHostnameIpAddress('example.com')
16+
* // => false
17+
*/
18+
export function isHostnameIpAddress(hostname) {
19+
if (isIPv4(hostname)) return true
20+
21+
if (hostname.startsWith('[') && hostname.endsWith(']')) {
22+
return isIPv6(hostname.slice(1, -1))
23+
}
24+
25+
return false
26+
}

src/lib/ws-core-replicator.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { pipeline } from 'node:stream/promises'
2+
import { Transform } from 'node:stream'
3+
import { createWebSocketStream } from 'ws'
4+
/** @import { WebSocket } from 'ws' */
5+
/** @import { ReplicationStream } from '../types.js' */
6+
7+
/**
8+
* @param {WebSocket} ws
9+
* @param {ReplicationStream} replicationStream
10+
* @returns {Promise<void>}
11+
*/
12+
export function wsCoreReplicator(ws, replicationStream) {
13+
// This is purely to satisfy typescript at its worst. `pipeline` expects a
14+
// NodeJS ReadWriteStream, but our replicationStream is a streamx Duplex
15+
// stream. The difference is that streamx does not implement the
16+
// `setEncoding`, `unpipe`, `wrap` or `isPaused` methods. The `pipeline`
17+
// function does not depend on any of these methods (I have read through the
18+
// NodeJS source code at cebf21d (v22.9.0) to confirm this), so we can safely
19+
// cast the stream to a NodeJS ReadWriteStream.
20+
const _replicationStream = /** @type {NodeJS.ReadWriteStream} */ (
21+
/** @type {unknown} */ (replicationStream)
22+
)
23+
return pipeline(
24+
_replicationStream,
25+
wsSafetyTransform(ws),
26+
createWebSocketStream(ws),
27+
_replicationStream
28+
)
29+
}
30+
31+
/**
32+
* Avoid writing data to a closing or closed websocket, which would result in an
33+
* error. Instead we drop the data and wait for the stream close/end events to
34+
* propagate and close the streams cleanly.
35+
*
36+
* @param {WebSocket} ws
37+
*/
38+
function wsSafetyTransform(ws) {
39+
return new Transform({
40+
transform(chunk, encoding, callback) {
41+
if (ws.readyState === ws.CLOSED || ws.readyState === ws.CLOSING) {
42+
return callback()
43+
}
44+
callback(null, chunk)
45+
},
46+
})
47+
}

src/mapeo-manager.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -556,7 +556,7 @@ export class MapeoManager extends TypedEmitter {
556556
* downloaded their proof of project membership and the project config.
557557
*
558558
* @param {Pick<import('./generated/rpc.js').ProjectJoinDetails, 'projectKey' | 'encryptionKeys'> & { projectName: string }} projectJoinDetails
559-
* @param {{ waitForSync?: boolean }} [opts] For internal use in tests, set opts.waitForSync = false to not wait for sync during addProject()
559+
* @param {{ waitForSync?: boolean }} [opts] Set opts.waitForSync = false to not wait for sync during addProject()
560560
* @returns {Promise<string>}
561561
*/
562562
addProject = async (

src/mapeo-project.js

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,11 @@ import {
4747
import { migrate } from './lib/drizzle-helpers.js'
4848
import { omit } from './lib/omit.js'
4949
import { MemberApi } from './member-api.js'
50-
import { SyncApi, kHandleDiscoveryKey } from './sync/sync-api.js'
50+
import {
51+
SyncApi,
52+
kHandleDiscoveryKey,
53+
kWaitForInitialSyncWithPeer,
54+
} from './sync/sync-api.js'
5155
import { Logger } from './logger.js'
5256
import { IconApi } from './icon-api.js'
5357
import { readConfig } from './config-import.js'
@@ -77,8 +81,9 @@ const EMPTY_PROJECT_SETTINGS = Object.freeze({})
7781
* @extends {TypedEmitter<{ close: () => void }>}
7882
*/
7983
export class MapeoProject extends TypedEmitter {
80-
#projectId
84+
#projectKey
8185
#deviceId
86+
#identityKeypair
8287
#coreManager
8388
#indexWriter
8489
#dataStores
@@ -135,10 +140,12 @@ export class MapeoProject extends TypedEmitter {
135140

136141
this.#l = Logger.create('project', logger)
137142
this.#deviceId = getDeviceId(keyManager)
138-
this.#projectId = projectKeyToId(projectKey)
143+
this.#projectKey = projectKey
139144
this.#loadingConfig = false
140145
this.#isArchiveDevice = isArchiveDevice
141146

147+
const getReplicationStream = this[kProjectReplicate].bind(this, true)
148+
142149
///////// 1. Setup database
143150

144151
this.#sqlite = new Database(dbPath)
@@ -317,7 +324,7 @@ export class MapeoProject extends TypedEmitter {
317324
},
318325
}),
319326
}
320-
const identityKeypair = keyManager.getIdentityKeypair()
327+
this.#identityKeypair = keyManager.getIdentityKeypair()
321328
const coreKeypairs = getCoreKeypairs({
322329
projectKey,
323330
projectSecretKey,
@@ -326,31 +333,33 @@ export class MapeoProject extends TypedEmitter {
326333
this.#coreOwnership = new CoreOwnership({
327334
dataType: this.#dataTypes.coreOwnership,
328335
coreKeypairs,
329-
identityKeypair,
336+
identityKeypair: this.#identityKeypair,
330337
})
331338
this.#roles = new Roles({
332339
dataType: this.#dataTypes.role,
333340
coreOwnership: this.#coreOwnership,
334341
coreManager: this.#coreManager,
335342
projectKey: projectKey,
336-
deviceKey: keyManager.getIdentityKeypair().publicKey,
343+
deviceKey: this.#identityKeypair.publicKey,
337344
})
338345

339346
this.#memberApi = new MemberApi({
340347
deviceId: this.#deviceId,
341348
roles: this.#roles,
342349
coreOwnership: this.#coreOwnership,
343350
encryptionKeys,
351+
getProjectName: this.#getProjectName.bind(this),
344352
projectKey,
345353
rpc: localPeers,
354+
getReplicationStream,
355+
waitForInitialSyncWithPeer: (deviceId, abortSignal) =>
356+
this.$sync[kWaitForInitialSyncWithPeer](deviceId, abortSignal),
346357
dataTypes: {
347358
deviceInfo: this.#dataTypes.deviceInfo,
348359
project: this.#dataTypes.projectSettings,
349360
},
350361
})
351362

352-
const projectPublicId = projectKeyToPublicId(projectKey)
353-
354363
this.#blobStore = new BlobStore({
355364
coreManager: this.#coreManager,
356365
})
@@ -362,7 +371,7 @@ export class MapeoProject extends TypedEmitter {
362371
if (!base.endsWith('/')) {
363372
base += '/'
364373
}
365-
return base + projectPublicId
374+
return base + this.#projectPublicId
366375
},
367376
})
368377

@@ -374,7 +383,7 @@ export class MapeoProject extends TypedEmitter {
374383
if (!base.endsWith('/')) {
375384
base += '/'
376385
}
377-
return base + projectPublicId
386+
return base + this.#projectPublicId
378387
},
379388
})
380389

@@ -384,6 +393,24 @@ export class MapeoProject extends TypedEmitter {
384393
roles: this.#roles,
385394
blobDownloadFilter: null,
386395
logger: this.#l,
396+
getServerWebsocketUrls: async () => {
397+
const members = await this.#memberApi.getMany()
398+
/** @type {string[]} */
399+
const serverWebsocketUrls = []
400+
for (const member of members) {
401+
if (
402+
member.deviceType === 'selfHostedServer' &&
403+
member.selfHostedServerDetails
404+
) {
405+
const { baseUrl } = member.selfHostedServerDetails
406+
const wsUrl = new URL(`/sync/${this.#projectPublicId}`, baseUrl)
407+
wsUrl.protocol = wsUrl.protocol === 'http:' ? 'ws:' : 'wss:'
408+
serverWebsocketUrls.push(wsUrl.href)
409+
}
410+
}
411+
return serverWebsocketUrls
412+
},
413+
getReplicationStream,
387414
})
388415

389416
this.#translationApi = new TranslationApi({
@@ -458,6 +485,14 @@ export class MapeoProject extends TypedEmitter {
458485
return this.#deviceId
459486
}
460487

488+
get #projectId() {
489+
return projectKeyToId(this.#projectKey)
490+
}
491+
492+
get #projectPublicId() {
493+
return projectKeyToPublicId(this.#projectKey)
494+
}
495+
461496
/**
462497
* Resolves when hypercores have all loaded
463498
*
@@ -603,6 +638,13 @@ export class MapeoProject extends TypedEmitter {
603638
}
604639
}
605640

641+
/**
642+
* @returns {Promise<undefined | string>}
643+
*/
644+
async #getProjectName() {
645+
return (await this.$getProjectSettings()).name
646+
}
647+
606648
async $getOwnRole() {
607649
return this.#roles.getRole(this.#deviceId)
608650
}
@@ -640,6 +682,7 @@ export class MapeoProject extends TypedEmitter {
640682
* Hypercore types need updating.
641683
* @type {any}
642684
*/ ({
685+
keyPair: this.#identityKeypair,
643686
/** @param {Buffer} discoveryKey */
644687
ondiscoverykey: async (discoveryKey) => {
645688
const protomux =

0 commit comments

Comments
 (0)