mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
mono updates
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
"check:lint": "run-oxlint",
|
||||
"check:types": "run-tsc",
|
||||
"check:workers-types": "run-wrangler-types --check",
|
||||
"deploy:mono": "run-wrangler-deploy",
|
||||
"dev": "run-wrangler-dev",
|
||||
"fix:workers-types": "run-wrangler-types",
|
||||
"test": "run-vitest"
|
||||
|
||||
@@ -15,6 +15,19 @@ import type { NotificationsHub } from '../../notify/src/notifications-hub'
|
||||
* each app's narrower `Env`, so the sub-apps type-check unchanged.
|
||||
*/
|
||||
export type Env = SharedHonoEnv & {
|
||||
/**
|
||||
* Base domain this worker answers on, e.g. `rec.example.com` — injected from
|
||||
* `RECFLARE_DOMAIN` by both `run-wrangler-dev` and `run-wrangler-deploy`, with a
|
||||
* placeholder default in `wrangler.jsonc` for tests and an unconfigured checkout.
|
||||
*
|
||||
* Read by the mounted `ns` app to build the service-discovery document — the thing a
|
||||
* client is pointed at — so it has to name the host that actually reaches this worker:
|
||||
* the tunnel/LAN hostname when running it locally, and the apex of the domain when
|
||||
* deployed (`RECFLARE_SUBDOMAINS='{"mono":"@"}'`, `just deploy-mono`). Every service
|
||||
* mounted here is served from a PATH on that one host, so the document says
|
||||
* `https://<domain>/rooms` and nothing else would answer there.
|
||||
*/
|
||||
DOMAIN: string
|
||||
// HS256 JWT signing key (shared Secrets Store). Tokens signed by `auth` verify everywhere.
|
||||
JWT_SECRET: SecretsStoreSecret
|
||||
// Meta (Oculus) app secret, from the same store. Read only by `auth`, to validate a
|
||||
|
||||
+39
-13
@@ -3,22 +3,31 @@
|
||||
*
|
||||
* Mounts each RecFlare worker inside a single deployable Worker WITHOUT modifying the
|
||||
* originals: every app is imported by relative path and bundled by esbuild at build
|
||||
* time. Production routing mirrors the split deployment — requests are dispatched on
|
||||
* the request's subdomain (`accounts.<domain>` -> the `accounts` app), so the sub-app
|
||||
* paths (and therefore the client contract) are untouched.
|
||||
* time. A request selects its service two ways, and the sub-app paths (and therefore the
|
||||
* client contract) are untouched either way.
|
||||
*
|
||||
* Local dev has no subdomain, so the first path segment selects the service and is
|
||||
* stripped before the request is forwarded, e.g.
|
||||
* http://localhost:8787/accounts/ -> accounts app sees /
|
||||
* http://localhost:8787/match/player/login -> match app sees /player/login
|
||||
* http://localhost:8787/api/api/config/v2 -> api app sees /api/config/v2
|
||||
* By PATH — how this worker is meant to be deployed, at the apex of `DOMAIN`, and the
|
||||
* only way that works in local dev, which has no subdomain. The first path segment names
|
||||
* the service and is stripped before the request is forwarded, e.g.
|
||||
* https://<domain>/accounts/ -> accounts app sees /
|
||||
* https://<domain>/match/player/login -> match app sees /player/login
|
||||
* https://<domain>/api/api/config/v2 -> api app sees /api/config/v2
|
||||
*
|
||||
* By SUBDOMAIN — `accounts.<domain>` -> the `accounts` app, with the path forwarded
|
||||
* unchanged. That mirrors the split deployment, so a client (or a stray DNS record) still
|
||||
* pointed at the per-service hosts keeps working if they're routed here.
|
||||
*
|
||||
* A request with no path (just `/`) that selects no service serves the `ns` discovery
|
||||
* document, so a bare hit to the facade root returns the service map to bootstrap from.
|
||||
* The document is built in the PATH style (`https://<domain>/rooms`, every service on
|
||||
* this one host) — see ENDPOINT_STYLE below — so deploy this worker at the apex of
|
||||
* `DOMAIN` and point the client at nothing else.
|
||||
*
|
||||
* NOT mounted here: `www`, `img`, `econ`. Each binds a static `assets` directory and
|
||||
* Cloudflare allows only one static-assets binding per Worker. Resolve that (serve
|
||||
* their static trees from R2, or keep those three as their own Workers) before adding.
|
||||
* The discovery document still puts them on this host, since a single-service run is the
|
||||
* whole point of this worker — so until they're mounted, their paths 404 here.
|
||||
*/
|
||||
import accounts from '../../accounts/src/accounts.app'
|
||||
import api from '../../api/src/api.app'
|
||||
@@ -67,16 +76,24 @@ const services = {
|
||||
|
||||
type ServiceName = keyof typeof services
|
||||
|
||||
/**
|
||||
* This worker is one host, so its discovery document has to name one host: every service
|
||||
* is advertised as `https://<domain>/<name>`, never `https://<name>.<domain>`. Handed to
|
||||
* the mounted `ns` app, which defaults to the per-host document the split deployment wants.
|
||||
*/
|
||||
const ENDPOINT_STYLE = 'path'
|
||||
|
||||
function resolve(request: Request): { name: ServiceName; request: Request } | undefined {
|
||||
const url = new URL(request.url)
|
||||
|
||||
// Production: dispatch on the leftmost DNS label — accounts.<domain> -> accounts.
|
||||
// The path is forwarded unchanged so the client contract is identical.
|
||||
// Dispatch on the leftmost DNS label — accounts.<domain> -> accounts. The path is
|
||||
// forwarded unchanged so the client contract is identical to the split deployment.
|
||||
const sub = url.hostname.split('.')[0]
|
||||
if (sub in services) return { name: sub as ServiceName, request }
|
||||
|
||||
// Local dev (no service subdomain): the first path segment selects the service and
|
||||
// is stripped before forwarding — /match/player/login -> match app sees /player/login.
|
||||
// Apex (and local dev): the first path segment selects the service and is stripped
|
||||
// before forwarding — /match/player/login -> match app sees /player/login. This is
|
||||
// what the discovery document advertises; see ENDPOINT_STYLE.
|
||||
const [, first, ...rest] = url.pathname.split('/')
|
||||
if (first !== undefined && first in services) {
|
||||
url.pathname = `/${rest.join('/')}`
|
||||
@@ -103,11 +120,20 @@ export default {
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
// `ns` is the one mounted app whose answer depends on this worker's own shape: the
|
||||
// addresses it hands out have to be paths on this host. Passed as a var — the same
|
||||
// way a deploy would — so the app itself stays free of any knowledge of mono.
|
||||
if (resolved.name === 'ns') return ns.fetch(resolved.request, { ...env, ENDPOINT_STYLE }, ctx)
|
||||
|
||||
return services[resolved.name].fetch(resolved.request, env, ctx)
|
||||
},
|
||||
|
||||
// Only `match` runs a cron in the split deployment; this worker owns its presence sweep.
|
||||
scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> | void {
|
||||
scheduled(
|
||||
controller: ScheduledController,
|
||||
env: Env,
|
||||
ctx: ExecutionContext
|
||||
): Promise<void> | void {
|
||||
return matchScheduled(controller, env, ctx)
|
||||
},
|
||||
} satisfies ExportedHandler<Env>
|
||||
|
||||
@@ -9,6 +9,9 @@ declare module 'cloudflare:test' {
|
||||
|
||||
const ORIGIN = 'https://example.com'
|
||||
|
||||
// Must match the DOMAIN var default in apps/mono/wrangler.jsonc.
|
||||
const TEST_DOMAIN = 'rec.example.com'
|
||||
|
||||
// The facade's job is routing, not business logic, so one request that reaches a
|
||||
// mounted app through the path prefix is enough to prove the wiring. `api` serves a
|
||||
// static game-config with no auth/DB, so it's a clean target. The api worker namespaces
|
||||
@@ -24,8 +27,22 @@ describe('mono routing', () => {
|
||||
test('root path (no service, no prefix) serves the ns discovery document', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/`)
|
||||
expect(res.status).toBe(200)
|
||||
// The ns worker serves the service-discovery document.
|
||||
expect(await res.json()).toHaveProperty('Auth')
|
||||
// The ns worker serves the service-discovery document. This worker is one host, so
|
||||
// every service in it is a path on the base domain (the DOMAIN var default in
|
||||
// wrangler.jsonc) — no per-service subdomains anywhere in the document.
|
||||
const doc = (await res.json()) as Record<string, string>
|
||||
expect(doc).toMatchObject({
|
||||
Auth: `https://${TEST_DOMAIN}/auth`,
|
||||
Rooms: `https://${TEST_DOMAIN}/rooms`,
|
||||
Matchmaking: `https://${TEST_DOMAIN}/match`,
|
||||
})
|
||||
expect(Object.values(doc).every((url) => url.startsWith(`https://${TEST_DOMAIN}/`))).toBe(true)
|
||||
})
|
||||
|
||||
test('the ns service prefix serves that same document', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/ns/`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toMatchObject({ Rooms: `https://${TEST_DOMAIN}/rooms` })
|
||||
})
|
||||
|
||||
test('unknown service prefix returns the facade 404', async () => {
|
||||
|
||||
@@ -76,6 +76,11 @@
|
||||
"vars": {
|
||||
"NAME": "mono", // logging tag; split workers derive this per-app
|
||||
"ENVIRONMENT": "development", // overridden during deployment
|
||||
"SENTRY_RELEASE": "unknown" // overridden during deployment
|
||||
"SENTRY_RELEASE": "unknown", // overridden during deployment
|
||||
// Base domain the discovery document is built from; replaced with RECFLARE_DOMAIN by
|
||||
// both `just dev` and `just deploy-mono`. It must name the host that actually reaches
|
||||
// this worker, which serves every service it mounts from a path on that ONE host — so
|
||||
// deployed, it belongs on the APEX of that domain (see src/context.ts).
|
||||
"DOMAIN": "rec.example.com"
|
||||
}
|
||||
}
|
||||
|
||||
+11
-3
@@ -7,11 +7,19 @@ discover every service host (Accounts, API, Auth, Econ, Matchmaking,
|
||||
Notifications, …).
|
||||
|
||||
Each host is built at runtime from the `DOMAIN` var (the base domain) plus the
|
||||
service → subdomain map in `src/endpoints.ts`. `DOMAIN` is injected at deploy
|
||||
time from `RECFLARE_DOMAIN` (see `run-wrangler-deploy`) and defaults to
|
||||
`rec.example.com` in `wrangler.jsonc` for local dev.
|
||||
service → subdomain map in `src/endpoints.ts`. `DOMAIN` is injected from
|
||||
`RECFLARE_DOMAIN` by both `run-wrangler-dev` and `run-wrangler-deploy`, and
|
||||
defaults to `rec.example.com` in `wrangler.jsonc` when that isn't set.
|
||||
|
||||
## Updating endpoints
|
||||
|
||||
- To change the base domain, set `RECFLARE_DOMAIN` (in `.env`) and redeploy.
|
||||
- To add or rename a service host, edit the map in `src/endpoints.ts`.
|
||||
|
||||
## ENDPOINT_STYLE
|
||||
|
||||
With `ENDPOINT_STYLE=path`, every service is advertised as `https://<domain>/<slug>`
|
||||
instead of `https://<slug>.<domain>`. That's for the combined `mono` worker alone —
|
||||
it's a single Worker that routes on the first path segment, so one host serves the
|
||||
lot. Unset (the split deployment, where each service is its own Worker on its own
|
||||
host) gives the subdomain document.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { HonoApp } from '@repo/hono-helpers'
|
||||
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
|
||||
import type { EndpointStyle } from './endpoints'
|
||||
|
||||
export type Env = SharedHonoEnv & {
|
||||
/**
|
||||
@@ -8,6 +9,13 @@ export type Env = SharedHonoEnv & {
|
||||
* for local dev and tests.
|
||||
*/
|
||||
DOMAIN: string
|
||||
/**
|
||||
* `path` to serve every service from a path on `DOMAIN` (`https://<domain>/rooms`)
|
||||
* instead of from its own subdomain. Set only by the combined `mono` worker, which is
|
||||
* one Worker routing on that first path segment; anything else (the split deployment)
|
||||
* leaves it unset and gets the per-service hosts.
|
||||
*/
|
||||
ENDPOINT_STYLE?: EndpointStyle
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
|
||||
@@ -44,9 +44,25 @@ const SERVICE_SUBDOMAINS = {
|
||||
WWW: 'www',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Where the services live, relative to the base domain:
|
||||
*
|
||||
* `subdomain` — one host each, `https://rooms.<domain>`. The split deployment, and the
|
||||
* default, since that's what every worker in `apps/` is deployed as.
|
||||
* `path` — one host, first path segment names the service: `https://<domain>/rooms`.
|
||||
* Only the combined `mono` worker, which is a single Worker routing on that segment.
|
||||
*/
|
||||
export type EndpointStyle = 'subdomain' | 'path'
|
||||
|
||||
/** Builds the endpoints document for `domain`, e.g. `rec.example.com`. */
|
||||
export function buildEndpoints(domain: string): Record<string, string> {
|
||||
export function buildEndpoints(
|
||||
domain: string,
|
||||
style: EndpointStyle = 'subdomain'
|
||||
): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(SERVICE_SUBDOMAINS).map(([label, sub]) => [label, `https://${sub}.${domain}`])
|
||||
Object.entries(SERVICE_SUBDOMAINS).map(([label, sub]) => [
|
||||
label,
|
||||
style === 'path' ? `https://${domain}/${sub}` : `https://${sub}.${domain}`,
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@ const app = new Hono<App>()
|
||||
.onError(withOnError())
|
||||
.notFound(withNotFound())
|
||||
|
||||
// Endpoints document, derived from the deploy-time base domain.
|
||||
.get('/', (c) => c.json(buildEndpoints(c.env.DOMAIN)))
|
||||
// Endpoints document, derived from the deploy-time base domain. ENDPOINT_STYLE is set
|
||||
// only by the combined `mono` worker, to advertise the services on paths of that one
|
||||
// domain rather than on a host each; unset (the split deployment) means subdomains.
|
||||
.get('/', (c) => c.json(buildEndpoints(c.env.DOMAIN, c.env.ENDPOINT_STYLE)))
|
||||
|
||||
export default app
|
||||
|
||||
@@ -18,6 +18,20 @@ describe('ns endpoints', () => {
|
||||
expect(body).toEqual(buildEndpoints(TEST_DOMAIN))
|
||||
})
|
||||
|
||||
test('the path style puts every service on the base domain', async () => {
|
||||
const doc = buildEndpoints(TEST_DOMAIN, 'path')
|
||||
expect(doc.Rooms).toBe(`https://${TEST_DOMAIN}/rooms`)
|
||||
expect(doc.Matchmaking).toBe(`https://${TEST_DOMAIN}/match`)
|
||||
expect(doc.Images).toBe(`https://${TEST_DOMAIN}/img`)
|
||||
expect(Object.values(doc).every((url) => url.startsWith(`https://${TEST_DOMAIN}/`))).toBe(true)
|
||||
})
|
||||
|
||||
test('the default style gives every service its own host', async () => {
|
||||
const doc = buildEndpoints(TEST_DOMAIN)
|
||||
expect(doc.Rooms).toBe(`https://rooms.${TEST_DOMAIN}`)
|
||||
expect(doc.Images).toBe(`https://img.${TEST_DOMAIN}`)
|
||||
})
|
||||
|
||||
test('unknown path returns 404', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/nope`)
|
||||
expect(res.status).toBe(404)
|
||||
|
||||
Reference in New Issue
Block a user