mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[match] tachyon sandbox switch
This commit is contained in:
@@ -134,6 +134,11 @@ RECFLARE_DOMAIN=rec.example.com
|
|||||||
# mock pool (RFC 5737 documentation addresses, which answer nothing): tachyon-1/-2 share
|
# mock pool (RFC 5737 documentation addresses, which answer nothing): tachyon-1/-2 share
|
||||||
# a host, as do tachyon-4/-5.
|
# a host, as do tachyon-4/-5.
|
||||||
# RECFLARE_TACHYON_HOST_PORT=198.51.100.10:7777,198.51.100.10:7778,198.51.100.11:7777,203.0.113.20:7777,203.0.113.20:7778
|
# RECFLARE_TACHYON_HOST_PORT=198.51.100.10:7777,198.51.100.10:7778,198.51.100.11:7777,203.0.113.20:7777,203.0.113.20:7778
|
||||||
|
#
|
||||||
|
# The pool SANDBOX accounts (the account's `isSandbox` flag) are served from instead,
|
||||||
|
# same format. Its ids are generated as `dev-1`, `dev-2`, …. Unset, sandbox accounts
|
||||||
|
# use the pool above.
|
||||||
|
# RECFLARE_TACHYON_HOST_PORT_SANDBOX=198.51.100.12:7777,198.51.100.12:7778
|
||||||
|
|
||||||
# The Photon region every session is pinned to (`match`). Unlike the app ids above this
|
# The Photon region every session is pinned to (`match`). Unlike the app ids above this
|
||||||
# does default, to `us` (us-east1) — an instance stamped with an empty region is one the
|
# does default, to `us` (us-east1) — an instance stamped with an empty region is one the
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ export type Env = SharedHonoEnv & {
|
|||||||
* listed twice to model two server slots on one box.
|
* listed twice to model two server slots on one box.
|
||||||
*/
|
*/
|
||||||
TACHYON_HOST_PORT?: string
|
TACHYON_HOST_PORT?: string
|
||||||
|
/**
|
||||||
|
* The Tachyon pool SANDBOX accounts are served from in place of `TACHYON_HOST_PORT`
|
||||||
|
* (`RECFLARE_TACHYON_HOST_PORT_SANDBOX` in `.env`) — the same comma-separated
|
||||||
|
* `host:port` list, with ids generated as `dev-1`, `dev-2`, …. Unset means sandbox
|
||||||
|
* accounts use the live pool. See `tachyonPoolFor` in match.app.ts.
|
||||||
|
*/
|
||||||
|
TACHYON_HOST_PORT_SANDBOX?: string
|
||||||
/**
|
/**
|
||||||
* The Photon region every session is pinned to — both the region named in the connection
|
* The Photon region every session is pinned to — both the region named in the connection
|
||||||
* info and the one stamped on every room instance, which must agree. Optional; unlike the
|
* info and the one stamped on every room instance, which must agree. Optional; unlike the
|
||||||
|
|||||||
+28
-41
@@ -191,12 +191,12 @@ interface TachyonServer {
|
|||||||
* never sends the id anywhere), but it is positional, so inserting an entry renames
|
* never sends the id anywhere), but it is positional, so inserting an entry renames
|
||||||
* every server after it.
|
* every server after it.
|
||||||
*/
|
*/
|
||||||
function tachyonPool(env: Env): TachyonServer[] {
|
function tachyonPool(hostPorts: string, idPrefix = 'tachyon-'): TachyonServer[] {
|
||||||
return varOr(env.TACHYON_HOST_PORT, '')
|
return hostPorts
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((entry) => entry.trim())
|
.map((entry) => entry.trim())
|
||||||
.filter((entry) => entry !== '')
|
.filter((entry) => entry !== '')
|
||||||
.map((hostPort, i) => ({ hostPort, serverId: `tachyon-${i + 1}` }))
|
.map((hostPort, i) => ({ hostPort, serverId: `${idPrefix}${i + 1}` }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the connection info carries when there is no Tachyon server to name. */
|
/** What the connection info carries when there is no Tachyon server to name. */
|
||||||
@@ -223,44 +223,31 @@ const NO_TACHYON_SERVER: TachyonServer = { hostPort: '', serverId: '' }
|
|||||||
* `roomInstanceId` 0 means the caller resolved to no instance at all (they're in no
|
* `roomInstanceId` 0 means the caller resolved to no instance at all (they're in no
|
||||||
* room, or named one that doesn't exist); they get no server rather than server one.
|
* room, or named one that doesn't exist); they get no server rather than server one.
|
||||||
*/
|
*/
|
||||||
function tachyonServerFor(env: Env, roomInstanceId: number): TachyonServer {
|
function tachyonServerFor(pool: TachyonServer[], roomInstanceId: number): TachyonServer {
|
||||||
const pool = tachyonPool(env)
|
|
||||||
if (pool.length === 0 || roomInstanceId <= 0) return NO_TACHYON_SERVER
|
if (pool.length === 0 || roomInstanceId <= 0) return NO_TACHYON_SERVER
|
||||||
return pool[roomInstanceId % pool.length] ?? NO_TACHYON_SERVER
|
return pool[roomInstanceId % pool.length] ?? NO_TACHYON_SERVER
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Tachyon port a DEVELOPER is handed, in place of the port on the pool entry their
|
* The prefix of a sandbox server's generated id (see {@link tachyonPoolFor}) — `dev-1`,
|
||||||
* instance resolved to. Developers connect to a separate Tachyon build running beside the
|
* `dev-2` rather than the live pool's `tachyon-N`, so the (cosmetic) id the client
|
||||||
* live one on the same box, so only the PORT moves: the HOST stays whichever one
|
* displays says which pool it is on.
|
||||||
* {@link tachyonServerFor} picked, rather than scattering developers off the pool.
|
|
||||||
*/
|
*/
|
||||||
const DEVELOPER_TACHYON_PORT = '7778'
|
const SANDBOX_TACHYON_SERVER_ID_PREFIX = 'dev-'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The id displayed for that dev server. Not `tachyon-N` — the generated ids are positional
|
* The pool the caller's instance is picked out of. A SANDBOX account runs against its own
|
||||||
* names for entries in the pool ({@link tachyonPool}), and the dev build is not one of
|
* Tachyon servers, apart from the live pool, so the whole list is swapped for
|
||||||
* them: it is a different server that happens to share a host. The id is cosmetic (the
|
* `TACHYON_HOST_PORT_SANDBOX` rather than any one entry: selection within it is the same
|
||||||
* client displays it and never sends it anywhere), so it only has to say which server the
|
* instance-id round-robin {@link tachyonServerFor} applies to the live pool. When that var
|
||||||
* player is on, and "dev" is that.
|
* is unset a sandbox account falls back to the live pool, so an operator who runs no
|
||||||
|
* sandbox servers doesn't leave those accounts without voice.
|
||||||
*/
|
*/
|
||||||
const DEVELOPER_TACHYON_SERVER_ID = 'dev'
|
function tachyonPoolFor(env: Env, isSandbox: boolean): TachyonServer[] {
|
||||||
|
const sandbox = varOr(env.TACHYON_HOST_PORT_SANDBOX, '')
|
||||||
/**
|
return isSandbox && sandbox.trim() !== ''
|
||||||
* `server` with its port replaced by {@link DEVELOPER_TACHYON_PORT} and its id by
|
? tachyonPool(sandbox, SANDBOX_TACHYON_SERVER_ID_PREFIX)
|
||||||
* {@link DEVELOPER_TACHYON_SERVER_ID}. An empty address is left alone — the pool is unset or
|
: tachyonPool(varOr(env.TACHYON_HOST_PORT, ''))
|
||||||
* the caller is in no instance, so there is no server to point at another port, and writing
|
|
||||||
* a bare `:7778` would hand the client an address that answers nothing (and name a server
|
|
||||||
* that isn't there). Splits on the LAST colon so a bracketed IPv6 host survives.
|
|
||||||
*/
|
|
||||||
function developerTachyonServer(server: TachyonServer): TachyonServer {
|
|
||||||
if (server.hostPort === '') return server
|
|
||||||
const colon = server.hostPort.lastIndexOf(':')
|
|
||||||
const host = colon === -1 ? server.hostPort : server.hostPort.slice(0, colon)
|
|
||||||
return {
|
|
||||||
hostPort: `${host}:${DEVELOPER_TACHYON_PORT}`,
|
|
||||||
serverId: DEVELOPER_TACHYON_SERVER_ID,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2907,8 +2894,8 @@ const app = new Hono<App>()
|
|||||||
'`TACHYON_HOST_PORT` pool, chosen by instance id so every player in a session is',
|
'`TACHYON_HOST_PORT` pool, chosen by instance id so every player in a session is',
|
||||||
'handed the same one, with a generated `voiceServerId` (`tachyon-1`, `tachyon-2`,',
|
'handed the same one, with a generated `voiceServerId` (`tachyon-1`, `tachyon-2`,',
|
||||||
'…). Both are empty when the pool is unset or the caller is in no instance. A',
|
'…). Both are empty when the pool is unset or the caller is in no instance. A',
|
||||||
'caller holding the DEVELOPER role gets that same host on port 7778, the dev',
|
'sandbox account is picked out of the `TACHYON_HOST_PORT_SANDBOX` pool instead, the same way,',
|
||||||
'Tachyon build, with a `voiceServerId` of `dev`.',
|
'with ids `dev-1`, `dev-2`, ….',
|
||||||
'`experiments` carries the client’s networking flags.',
|
'`experiments` carries the client’s networking flags.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
@@ -2949,11 +2936,11 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
const account = await getAccount(c.env.DB, id)
|
const account = await getAccount(c.env.DB, id)
|
||||||
// The instance's server, the same one every other player in it is handed — except
|
// The instance's server, the same one every other player in it is handed — except
|
||||||
// for a DEVELOPER, who is sent to the same host on the dev port, under the id
|
// that a SANDBOX account is picked out of the sandbox pool instead of the live one.
|
||||||
// `dev` rather than the pool entry's positional name.
|
const tachyon = tachyonServerFor(
|
||||||
const assigned = tachyonServerFor(c.env, roomInstanceId)
|
tachyonPoolFor(c.env, account?.isSandbox === true),
|
||||||
const tachyon =
|
roomInstanceId
|
||||||
account?.isDeveloper === true ? developerTachyonServer(assigned) : assigned
|
)
|
||||||
|
|
||||||
// Identifies the player to Photon. Signed with the shared JWT secret; the token's
|
// Identifies the player to Photon. Signed with the shared JWT secret; the token's
|
||||||
// `aud` is the realtime app it's for. Nothing verifies it while Photon is
|
// `aud` is the realtime app it's for. Nothing verifies it while Photon is
|
||||||
@@ -2977,8 +2964,8 @@ const app = new Hono<App>()
|
|||||||
photonRoomId,
|
photonRoomId,
|
||||||
// The Tachyon server this instance runs on, picked out of the operator's
|
// The Tachyon server this instance runs on, picked out of the operator's
|
||||||
// pool by {@link tachyonServerFor} — empty strings when the pool is empty
|
// pool by {@link tachyonServerFor} — empty strings when the pool is empty
|
||||||
// or the caller is in no instance, and on the dev port (id `dev`) for a
|
// or the caller is in no instance, and out of the sandbox pool for a
|
||||||
// developer. Empty rather than null: the client's
|
// sandbox account. Empty rather than null: the client's
|
||||||
// decoder is likelier to accept a missing-value string than a null on a
|
// decoder is likelier to accept a missing-value string than a null on a
|
||||||
// string field. The presence payload's NULL_CONNECTION_INFO keeps its
|
// string field. The presence payload's NULL_CONNECTION_INFO keeps its
|
||||||
// nulls — that one never carries credentials.
|
// nulls — that one never carries credentials.
|
||||||
|
|||||||
@@ -216,9 +216,9 @@ export const ConnectionExperiments = z.object({
|
|||||||
* and `photonRoomId`, the Photon room of the instance their presence says they're in
|
* and `photonRoomId`, the Photon room of the instance their presence says they're in
|
||||||
* — the same name every other player in that instance is handed. The voice fields name
|
* — the same name every other player in that instance is handed. The voice fields name
|
||||||
* the Tachyon server that instance was assigned out of the `TACHYON_HOST_PORT` pool —
|
* the Tachyon server that instance was assigned out of the `TACHYON_HOST_PORT` pool —
|
||||||
* likewise the same for everyone in the session, except that a caller holding the
|
* likewise the same for everyone in the session, except that a SANDBOX account is
|
||||||
* DEVELOPER role is sent to that host's port 7778 as `dev` — and are empty when the pool
|
* picked out of the sandbox pool instead — and are empty when the pool is unset or the
|
||||||
* is unset or the caller is in no instance. `photonRegion` matches the one stamped
|
* caller is in no instance. `photonRegion` matches the one stamped
|
||||||
* on every room instance, so the two can't disagree.
|
* on every room instance, so the two can't disagree.
|
||||||
*/
|
*/
|
||||||
export const ConnectionInfo = z.object({
|
export const ConnectionInfo = z.object({
|
||||||
@@ -231,12 +231,12 @@ export const ConnectionInfo = z.object({
|
|||||||
voiceConnectionInfo: z
|
voiceConnectionInfo: z
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.describe(
|
||||||
'The instance’s Tachyon server, `host:port` (port 7778 for a developer); empty when none is configured'
|
'The instance’s Tachyon server, `host:port` (out of the sandbox pool for a sandbox account); empty when none is configured'
|
||||||
),
|
),
|
||||||
voiceServerId: z
|
voiceServerId: z
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.describe(
|
||||||
'That server’s generated id (`tachyon-1`, …; `dev` for a developer); cosmetic, empty when there is none'
|
'That server’s generated id (`tachyon-1`, …; `dev-1`, … for a sandbox account); cosmetic, empty when there is none'
|
||||||
),
|
),
|
||||||
experiments: ConnectionExperiments,
|
experiments: ConnectionExperiments,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1491,22 +1491,23 @@ describe('auth-gated endpoints', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('a developer is sent to the same Tachyon host on the dev port', async () => {
|
test('a sandbox account is served out of the sandbox Tachyon pool', async () => {
|
||||||
// Developers run against a Tachyon build listening on 7778 beside the live one, so
|
// Sandbox accounts run against their own Tachyon servers, so the whole pool is swapped
|
||||||
// only the host carries over from the instance's assignment — the dev build is not an
|
// for them; selection inside it is the same instance-id round-robin as the live pool.
|
||||||
// entry in the pool, so it is named `dev` rather than borrowing that entry's
|
|
||||||
// positional `tachyon-N`.
|
|
||||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||||
.bind(JSON.stringify({ accountId: 974, username: 'Devver', isDeveloper: true }))
|
.bind(JSON.stringify({ accountId: 974, username: 'Sandboxer', isSandbox: true }))
|
||||||
.run()
|
.run()
|
||||||
const original = env.TACHYON_HOST_PORT
|
const original = env.TACHYON_HOST_PORT
|
||||||
|
const originalSandbox = env.TACHYON_HOST_PORT_SANDBOX
|
||||||
try {
|
try {
|
||||||
// One entry, so the assignment can't happen to land on 7778 by itself.
|
|
||||||
env.TACHYON_HOST_PORT = '198.51.100.12:7777'
|
env.TACHYON_HOST_PORT = '198.51.100.12:7777'
|
||||||
|
env.TACHYON_HOST_PORT_SANDBOX = '203.0.113.30:7777, 203.0.113.30:7778'
|
||||||
|
|
||||||
const voiceFor = async (player: string, roomInstanceId: number) => {
|
const voiceFor = async (player: string, roomInstanceId?: number) => {
|
||||||
const res = await exports.default.fetch(
|
const res = await exports.default.fetch(
|
||||||
`${ORIGIN}/player/connection-info?roomInstanceId=${roomInstanceId}`,
|
roomInstanceId === undefined
|
||||||
|
? `${ORIGIN}/player/connection-info`
|
||||||
|
: `${ORIGIN}/player/connection-info?roomInstanceId=${roomInstanceId}`,
|
||||||
{ headers: await bearer(player) }
|
{ headers: await bearer(player) }
|
||||||
)
|
)
|
||||||
const body = (await res.json()) as {
|
const body = (await res.json()) as {
|
||||||
@@ -1518,34 +1519,45 @@ describe('auth-gated endpoints', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sandboxPool = [
|
||||||
|
{ voiceConnectionInfo: '203.0.113.30:7777', voiceServerId: 'dev-1' },
|
||||||
|
{ voiceConnectionInfo: '203.0.113.30:7778', voiceServerId: 'dev-2' },
|
||||||
|
]
|
||||||
|
for (const photonRoomId of ['tachyon-sandbox-a', 'tachyon-sandbox-b']) {
|
||||||
|
const instance = await createRoomInstance(env.DB, {
|
||||||
|
ownerAccountId: 974,
|
||||||
|
roomId: 2,
|
||||||
|
photonRoomId,
|
||||||
|
maxCapacity: 12,
|
||||||
|
})
|
||||||
|
expect(await voiceFor('974', instance.roomInstanceId)).toEqual(
|
||||||
|
sandboxPool[instance.roomInstanceId % sandboxPool.length]
|
||||||
|
)
|
||||||
|
// Everyone else in that instance keeps the live pool.
|
||||||
|
expect(await voiceFor('975', instance.roomInstanceId)).toEqual({
|
||||||
|
voiceConnectionInfo: '198.51.100.12:7777',
|
||||||
|
voiceServerId: 'tachyon-1',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// In no instance there is no server, sandbox or not.
|
||||||
|
expect(await voiceFor('974')).toEqual({ voiceConnectionInfo: '', voiceServerId: '' })
|
||||||
|
|
||||||
|
// With no sandbox pool configured, a sandbox account falls back to the live one.
|
||||||
|
env.TACHYON_HOST_PORT_SANDBOX = undefined
|
||||||
const instance = await createRoomInstance(env.DB, {
|
const instance = await createRoomInstance(env.DB, {
|
||||||
ownerAccountId: 974,
|
ownerAccountId: 974,
|
||||||
roomId: 2,
|
roomId: 2,
|
||||||
photonRoomId: 'tachyon-instance-dev',
|
photonRoomId: 'tachyon-sandbox-unset',
|
||||||
maxCapacity: 12,
|
maxCapacity: 12,
|
||||||
})
|
})
|
||||||
expect(await voiceFor('974', instance.roomInstanceId)).toEqual({
|
expect(await voiceFor('974', instance.roomInstanceId)).toEqual({
|
||||||
voiceConnectionInfo: '198.51.100.12:7778',
|
|
||||||
voiceServerId: 'dev',
|
|
||||||
})
|
|
||||||
// Everyone else in that instance keeps the pool entry's own port and id.
|
|
||||||
expect(await voiceFor('975', instance.roomInstanceId)).toEqual({
|
|
||||||
voiceConnectionInfo: '198.51.100.12:7777',
|
voiceConnectionInfo: '198.51.100.12:7777',
|
||||||
voiceServerId: 'tachyon-1',
|
voiceServerId: 'tachyon-1',
|
||||||
})
|
})
|
||||||
|
|
||||||
// In no instance there is no server to move the port of: a bare ':7778' would be
|
|
||||||
// an address that answers nothing, named `dev`.
|
|
||||||
const none = await exports.default.fetch(`${ORIGIN}/player/connection-info`, {
|
|
||||||
headers: await bearer('974'),
|
|
||||||
})
|
|
||||||
const body = (await none.json()) as {
|
|
||||||
value: { voiceConnectionInfo: string; voiceServerId: string }
|
|
||||||
}
|
|
||||||
expect(body.value.voiceConnectionInfo).toBe('')
|
|
||||||
expect(body.value.voiceServerId).toBe('')
|
|
||||||
} finally {
|
} finally {
|
||||||
env.TACHYON_HOST_PORT = original
|
env.TACHYON_HOST_PORT = original
|
||||||
|
env.TACHYON_HOST_PORT_SANDBOX = originalSandbox
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,8 @@
|
|||||||
},
|
},
|
||||||
// The operator's knobs — the room substitutions (ROOM_REDIRECTS), the Photon app ids
|
// The operator's knobs — the room substitutions (ROOM_REDIRECTS), the Photon app ids
|
||||||
// and region (PHOTON_REALTIME_APP_ID, PHOTON_VOICE_APP_ID, PHOTON_CHAT_APP_ID,
|
// and region (PHOTON_REALTIME_APP_ID, PHOTON_VOICE_APP_ID, PHOTON_CHAT_APP_ID,
|
||||||
// PHOTON_REGION), and the Tachyon server pool (TACHYON_HOST_PORT) —
|
// PHOTON_REGION), and the Tachyon server pools (TACHYON_HOST_PORT,
|
||||||
|
// TACHYON_HOST_PORT_SANDBOX) —
|
||||||
// are deliberately NOT set here. They're injected at deploy time from
|
// are deliberately NOT set here. They're injected at deploy time from
|
||||||
// the gitignored .env (RECFLARE_<VAR>, see .env.example), so swapping a room out or
|
// the gitignored .env (RECFLARE_<VAR>, see .env.example), so swapping a room out or
|
||||||
// pointing at your own Photon apps never means editing a versioned file. Unset — the
|
// pointing at your own Photon apps never means editing a versioned file. Unset — the
|
||||||
|
|||||||
@@ -123,6 +123,12 @@ export interface Account {
|
|||||||
* `runx admin grant-moderator`. Absent/false means no role.
|
* `runx admin grant-moderator`. Absent/false means no role.
|
||||||
*/
|
*/
|
||||||
isModerator?: boolean
|
isModerator?: boolean
|
||||||
|
/**
|
||||||
|
* Whether this account is a SANDBOX account — served out of the sandbox Tachyon pool
|
||||||
|
* rather than the live one (`match` `GET /player/connection-info`). Operator-set only;
|
||||||
|
* absent/false means the live pool.
|
||||||
|
*/
|
||||||
|
isSandbox?: boolean
|
||||||
/**
|
/**
|
||||||
* Whether this account has Rec Room Plus — the paid tier the client's API calls a
|
* Whether this account has Rec Room Plus — the paid tier the client's API calls a
|
||||||
* `CampusCard`. Nothing SELLS one here. Absent/false means no Plus.
|
* `CampusCard`. Nothing SELLS one here. Absent/false means no Plus.
|
||||||
|
|||||||
Reference in New Issue
Block a user