Skip to content

Commit 9100f8e

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 9100f8e

43 files changed

Lines changed: 2299 additions & 125 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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/__tests__/userActions/general.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ import { ClientAPI } from '@sofie-automation/meteor-lib/dist/api/client'
77
import { UserActionsLog } from '../../../collections'
88
import { registerAllMethodsForTest } from '../../../../__mocks__/helpers/methods'
99

10+
// registerAllMethodsForTest() pulls in the full API graph, which imports deviceTriggers/observer and
11+
// registers a Meteor.startup() callback. Mock it so that draining startup can never spin up the real
12+
// device-trigger observers/job-queue and interfere with this suite's fake timers.
13+
jest.mock('../../deviceTriggers/observer')
14+
1015
registerAllMethodsForTest()
1116

1217
describe('User Actions - General', () => {

meteor/server/api/__tests__/userActions/system.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ import { StudioPlayoutDevice } from '@sofie-automation/corelib/dist/dataModel/St
2424
import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio'
2525
import { registerAllMethodsForTest } from '../../../../__mocks__/helpers/methods'
2626

27+
// registerAllMethodsForTest() pulls in the full API graph, which imports deviceTriggers/observer and
28+
// registers a Meteor.startup() callback. Mock it so that draining startup can never spin up the real
29+
// device-trigger observers/job-queue and interfere with this suite.
30+
jest.mock('../../deviceTriggers/observer')
31+
2732
registerAllMethodsForTest()
2833

2934
describe('User Actions - Disable Peripheral SubDevice', () => {

0 commit comments

Comments
 (0)