Skip to content

Commit cfc6201

Browse files
committed
feat: standalone ddp server implementation
for now intended to run at a secondary path off the meteor ddp server
1 parent 6221d14 commit cfc6201

40 files changed

Lines changed: 2224 additions & 112 deletions

meteor/.meteor/packages

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010

1111
meteor@2.3.0
1212
webapp@2.1.2
13-
ddp@1.4.2
1413

1514
mongo@2.3.0 # The database Meteor supports right now
1615

meteor/__mocks__/_setupMocks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jest.mock('meteor/meteor', (...args) => require('./meteor').setup(args), { virtu
1414
jest.mock('meteor/random', (...args) => require('./random').setup(args), { virtual: true })
1515
jest.mock('meteor/check', (...args) => require('./check').setup(args), { virtual: true })
1616
jest.mock('meteor/tracker', (...args) => require('./tracker').setup(args), { virtual: true })
17-
jest.mock('meteor/ejson', (...args) => require('./ejson').setup(args), { virtual: true })
17+
jest.mock('meteor/ejson', (...args) => require('./meteor-ejson').setup(args), { virtual: true })
1818

1919
jest.mock('meteor/mdg:validated-method', (...args) => require('./validated-method').setup(args), { virtual: true })
2020
jest.mock('meteor/julusian:meteor-elastic-apm', (...args) => require('./meteor-elastic-apm').setup(args), {
Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
// @ts-expect-error No types available
21
import EJSON from 'ejson'
32

43
export function setup(): any {

meteor/__mocks__/meteor.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
/* eslint-disable @typescript-eslint/no-unsafe-function-type, @typescript-eslint/only-throw-error */
2-
import { USER_PERMISSIONS_HEADER } from '@sofie-automation/meteor-lib/dist/userPermissions'
32

43
let controllableDefer = false
54

@@ -96,6 +95,8 @@ export namespace MeteorMock {
9695
export const absolutePath = process.cwd()
9796

9897
function getMethodContext() {
98+
const { USER_PERMISSIONS_HEADER } = require('../server/security/auth')
99+
99100
return {
100101
connection: {
101102
clientAddress: '1.1.1.1',
@@ -113,7 +114,8 @@ export namespace MeteorMock {
113114
private _stack?: string
114115
constructor(
115116
public error: number,
116-
public reason?: string
117+
public reason?: string,
118+
public details?: unknown
117119
) {
118120
const e = new $.Error('')
119121
let stack: string = e.stack || ''
@@ -132,9 +134,6 @@ export namespace MeteorMock {
132134
get message(): string {
133135
return this.toString()
134136
}
135-
get details(): any {
136-
return undefined
137-
}
138137
get errorType(): string {
139138
return 'Meteor.Error'
140139
}
@@ -231,6 +230,10 @@ export namespace MeteorMock {
231230
mockStartupFunctions.push(fcn)
232231
}
233232

233+
export function onConnection(_callback: (connection: any) => void): void {
234+
// no-op in tests; the standalone DDP server / Connections.ts register a handler here at import time
235+
}
236+
234237
export function publish(publicationName: string, handler: Function): any {
235238
publications[publicationName] = handler
236239
}

meteor/__mocks__/mongo.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export namespace MongoMock {
9696
}
9797

9898
return {
99+
collectionName: this._name,
99100
_fetchRaw: () => {
100101
return docs
101102
},

meteor/package.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"bcrypt": "^6.0.0",
5151
"deep-extend": "0.6.0",
5252
"deepmerge": "^4.3.1",
53+
"ejson": "^2.2.3",
5354
"elastic-apm-node": "^4.15.0",
5455
"i18next": "^26.3.1",
5556
"indexof": "0.0.1",
@@ -68,18 +69,22 @@
6869
"threadedclass": "^1.4.0",
6970
"type-fest": "^4.41.0",
7071
"underscore": "^1.13.8",
71-
"winston": "^3.19.0"
72+
"winston": "^3.19.0",
73+
"ws": "^8.21.0"
7274
},
7375
"devDependencies": {
7476
"@babel/core": "^7.29.7",
77+
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
7578
"@babel/plugin-transform-modules-commonjs": "^7.29.7",
7679
"@meteorjs/rspack": "2.0.1",
7780
"@rsdoctor/rspack-plugin": "1.5.7",
7881
"@rspack/cli": "1.7.1",
7982
"@rspack/core": "1.7.1",
8083
"@shopify/jest-koa-mocks": "^5.3.1",
8184
"@sofie-automation/code-standard-preset": "^3.2.2",
85+
"@sofie-automation/server-core-integration": "portal:../packages/server-core-integration",
8286
"@types/deep-extend": "^0.6.2",
87+
"@types/ejson": "^2.2.2",
8388
"@types/jest": "^30.0.0",
8489
"@types/koa": "^3.0.3",
8590
"@types/koa-bodyparser": "^4.3.13",
@@ -89,9 +94,9 @@
8994
"@types/node": "^22.19.21",
9095
"@types/semver": "^7.7.1",
9196
"@types/underscore": "^1.13.0",
97+
"@types/ws": "^8.18.1",
9298
"babel-jest": "^30.4.1",
9399
"commit-and-tag-version": "^12.7.3",
94-
"ejson": "^2.2.3",
95100
"eslint": "^9.39.4",
96101
"fast-clone": "^1.5.13",
97102
"glob": "^13.0.6",

meteor/scripts/babel-jest.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
const babelJest = require('babel-jest')
22

33
module.exports = babelJest.default.createTransformer({
4-
plugins: ['@babel/plugin-transform-modules-commonjs'],
4+
plugins: [
5+
'@babel/plugin-transform-modules-commonjs',
6+
// Some workspace deps (e.g. server-core-integration) ship ESM that uses `export * as ns from`;
7+
// transform-modules-commonjs alone can't lower that, so include the namespace-from transform.
8+
'@babel/plugin-transform-export-namespace-from',
9+
],
510
babelrc: false,
611
configFile: false,
712
})

meteor/server/Connections.ts

Lines changed: 47 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -11,55 +11,63 @@ const connectionsGauge = new MetricsGauge({
1111
help: 'Number of open ddp connections',
1212
})
1313

14-
Meteor.onConnection((conn: Meteor.Connection) => {
14+
/**
15+
* Track a newly-opened DDP connection (from either Meteor's built-in server or the standalone DDP
16+
* server). Updates the open-connection metrics.
17+
*/
18+
export function trackConnectionOpen(connectionId: string, clientAddress: string): void {
1519
// This is called whenever a new ddp-connection is opened (ie a web-client or a peripheral-device)
20+
connections.add(connectionId)
21+
logger.debug(`Client connected: "${connectionId}", "${clientAddress}"`)
22+
traceConnections()
23+
}
1624

17-
const connectionId: string = conn.id
18-
// var clientAddress = conn.clientAddress; // ip-adress
19-
20-
connections.add(conn.id)
21-
logger.debug(`Client connected: "${conn.id}", "${conn.clientAddress}"`)
25+
/**
26+
* Track a closed DDP connection. Updates metrics, and marks any PeripheralDevices that were bound to this
27+
* connection (and their children) as offline.
28+
*/
29+
export function trackConnectionClose(connectionId: string, clientAddress: string): void {
30+
connections.delete(connectionId)
31+
logger.debug(`Client disconnected: "${connectionId}", "${clientAddress}"`)
2232
traceConnections()
2333

24-
conn.onClose(() => {
25-
// called when a connection is closed
26-
connections.delete(conn.id)
27-
logger.debug(`Client disconnected: "${conn.id}", "${conn.clientAddress}"`)
28-
traceConnections()
34+
if (!connectionId) return
2935

30-
if (connectionId) {
31-
deferAsync(async () => {
32-
const devices = await PeripheralDevices.findFetchAsync({
33-
connectionId: connectionId,
34-
})
36+
deferAsync(async () => {
37+
const devices = await PeripheralDevices.findFetchAsync({
38+
connectionId: connectionId,
39+
})
3540

36-
for (const device of devices) {
37-
// set the status of the machine to offline:
41+
for (const device of devices) {
42+
// set the status of the machine to offline:
3843

39-
await PeripheralDevices.updateAsync(device._id, {
40-
$set: {
41-
lastSeen: getCurrentTime(),
42-
connected: false,
43-
// connectionId: ''
44-
},
45-
})
46-
await PeripheralDevices.updateAsync(
47-
{
48-
parentDeviceId: device._id,
49-
},
50-
{
51-
$set: {
52-
lastSeen: getCurrentTime(),
53-
connected: false,
54-
// connectionId: ''
55-
},
56-
},
57-
{ multi: true }
58-
)
59-
}
44+
await PeripheralDevices.updateAsync(device._id, {
45+
$set: {
46+
lastSeen: getCurrentTime(),
47+
connected: false,
48+
// connectionId: ''
49+
},
6050
})
51+
await PeripheralDevices.updateAsync(
52+
{
53+
parentDeviceId: device._id,
54+
},
55+
{
56+
$set: {
57+
lastSeen: getCurrentTime(),
58+
connected: false,
59+
// connectionId: ''
60+
},
61+
},
62+
{ multi: true }
63+
)
6164
}
6265
})
66+
}
67+
68+
Meteor.onConnection((conn: Meteor.Connection) => {
69+
trackConnectionOpen(conn.id, conn.clientAddress)
70+
conn.onClose(() => trackConnectionClose(conn.id, conn.clientAddress))
6371
})
6472

6573
let logTimeout: number | undefined = undefined
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
jest.mock('../api/integration/influx')
2+
3+
import { trackConnectionClose } from '../Connections'
4+
import { PeripheralDevices } from '../collections'
5+
import { protectString } from '@sofie-automation/corelib/dist/protectedString'
6+
import { PeripheralDeviceId } from '@sofie-automation/corelib/dist/dataModel/Ids'
7+
import { waitUntil } from '../../__mocks__/helpers/jest'
8+
9+
describe('Connections.trackConnectionClose', () => {
10+
beforeEach(async () => {
11+
await PeripheralDevices.removeAsync({})
12+
})
13+
14+
test('marks the device and its parentDeviceId children offline for the closed connection', async () => {
15+
const parentId = protectString<PeripheralDeviceId>('parent1')
16+
const childId = protectString<PeripheralDeviceId>('child1')
17+
const otherId = protectString<PeripheralDeviceId>('other1')
18+
await PeripheralDevices.insertAsync({
19+
_id: parentId,
20+
connectionId: 'conn1',
21+
connected: true,
22+
lastSeen: 1,
23+
} as any)
24+
await PeripheralDevices.insertAsync({
25+
_id: childId,
26+
parentDeviceId: parentId,
27+
connected: true,
28+
lastSeen: 1,
29+
} as any)
30+
await PeripheralDevices.insertAsync({
31+
_id: otherId,
32+
connectionId: 'conn2',
33+
connected: true,
34+
lastSeen: 1,
35+
} as any)
36+
37+
trackConnectionClose('conn1', '127.0.0.1')
38+
39+
await waitUntil(async () => {
40+
expect((await PeripheralDevices.findOneAsync(parentId))?.connected).toBe(false)
41+
expect((await PeripheralDevices.findOneAsync(childId))?.connected).toBe(false)
42+
}, 1000)
43+
44+
// A device on a different connection is untouched.
45+
expect((await PeripheralDevices.findOneAsync(otherId))?.connected).toBe(true)
46+
})
47+
48+
test('does nothing when no connectionId is given', async () => {
49+
const id = protectString<PeripheralDeviceId>('d1')
50+
await PeripheralDevices.insertAsync({ _id: id, connectionId: 'conn1', connected: true, lastSeen: 1 } as any)
51+
52+
trackConnectionClose('', '127.0.0.1')
53+
await new Promise((r) => setTimeout(r, 50)) // give any (incorrect) deferred work a chance to run
54+
55+
expect((await PeripheralDevices.findOneAsync(id))?.connected).toBe(true)
56+
})
57+
})

meteor/server/api/rest/koa.ts

Lines changed: 9 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import { WebApp } from 'meteor/webapp'
66
import { Meteor } from 'meteor/meteor'
77
import { getRandomString } from '@sofie-automation/corelib/dist/lib'
88
import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError'
9-
import _ from 'underscore'
109
import { getRootSubpath, public_dir } from '../../lib'
10+
import { getClientAddress } from '../../lib/clientAddress'
11+
import { STANDALONE_DDP_SERVER_ENABLED, STANDALONE_DDP_SERVER_PATH } from '../../ddp-server/config'
1112
import staticServe from 'koa-static'
1213
import { logger } from '../../logging'
1314
import { PackageInfo } from '../../coreSystem'
@@ -124,6 +125,12 @@ function getExtendedMeteorRuntimeConfig() {
124125
// @ts-expect-error missing types for internal meteor detail
125126
...__meteor_runtime_config__,
126127
sofieVersionExtended: versionExtended,
128+
// The webui's DDP connection endpoint is defined here — point it at the standalone DDP server when
129+
// enabled, otherwise Meteor's own DDP endpoint. This is a site-root-relative path (no prefix): the
130+
// vendored client resolves it against ROOT_URL_PATH_PREFIX (it no longer appends `/websocket` — see
131+
// packages/webui/src/meteor/socket-stream-client/urls.js), and the server matches the prefixed path
132+
// the same way (`getRootSubpath() + path` in ddp-server/index.ts).
133+
DDP_DEFAULT_CONNECTION_URL: STANDALONE_DDP_SERVER_ENABLED ? STANDALONE_DDP_SERVER_PATH : '/websocket',
127134
})})`
128135
}
129136

@@ -164,37 +171,6 @@ export function bindKoaRouter(koaRouter: KoaRouter, bindPath: string): void {
164171
rootRouter.use(bindPathWithPrefix, koaRouter.routes()).use(bindPathWithPrefix, koaRouter.allowedMethods())
165172
}
166173

167-
const REVERSE_PROXY_COUNT = process.env.HTTP_FORWARDED_COUNT ? parseInt(process.env.HTTP_FORWARDED_COUNT) : 0
168-
169-
// X-Forwarded-For (a de-facto standard) has the following syntax by convention
170-
// X-Forwarded-For: 203.0.113.195, 2001:db8:85a3:8d3:1319:8a2e:370:7348
171-
// X-Forwarded-For: 203.0.113.195,2001:db8:85a3:8d3:1319:8a2e:370:7348,198.51.100.178
172-
function getClientAddrFromXForwarded(headerVal: undefined | string | string[]): string | undefined {
173-
if (headerVal === undefined) return undefined
174-
if (typeof headerVal !== 'string') {
175-
headerVal = _.last(headerVal) as string
176-
}
177-
const remoteAddresses = headerVal.split(',')
178-
return remoteAddresses[remoteAddresses.length - REVERSE_PROXY_COUNT]?.trim() ?? remoteAddresses[0]?.trim()
179-
}
180-
181-
// Forwarded uses the following syntax:
182-
// Forwarded: for=192.0.2.60;proto=http;by=203.0.113.43
183-
// Forwarded: for=192.0.2.43, for="[2001:db8:cafe::17]"
184-
function getClientAddrFromForwarded(forwardedVal: string | undefined): string | undefined {
185-
if (forwardedVal === undefined) return undefined
186-
const allProxies = forwardedVal.split(',')
187-
const proxyInfo = allProxies[allProxies.length - REVERSE_PROXY_COUNT] ?? allProxies[0]
188-
const directives = proxyInfo?.trim().split(';')
189-
for (const directive of directives) {
190-
let match: RegExpMatchArray | null
191-
if ((match = directive.trim().match(/^for=("\[)?([\w.:])+(\]")?/))) {
192-
return match[2]
193-
}
194-
}
195-
return undefined
196-
}
197-
198174
export const makeMeteorConnectionFromKoa = (
199175
ctx: Koa.ParameterizedContext<Koa.DefaultState, Koa.DefaultContext, unknown>
200176
): Meteor.Connection => {
@@ -206,13 +182,7 @@ export const makeMeteorConnectionFromKoa = (
206182
onClose: () => {
207183
/* no-op */
208184
},
209-
clientAddress:
210-
// This replicates Meteor behavior which uses the HTTP_FORWARDED_COUNT to extract the "world-facing"
211-
// IP address of the Client User Agent
212-
getClientAddrFromForwarded(ctx.req.headers.forwarded) ||
213-
getClientAddrFromXForwarded(ctx.req.headers['x-forwarded-for']) ||
214-
ctx.req.socket.remoteAddress ||
215-
'unknown',
185+
clientAddress: getClientAddress(ctx.req.headers, ctx.req.socket.remoteAddress),
216186
httpHeaders: ctx.req.headers as Record<string, string>,
217187
}
218188
}

0 commit comments

Comments
 (0)