mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
more endpoints
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||||
|
|
||||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
import { logger, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createAccount,
|
createAccount,
|
||||||
@@ -85,6 +85,46 @@ function toAccountDto(account: Account) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project a stored account into the private self DTO (the /account/me shape) —
|
||||||
|
* the public DTO plus owner-only fields. `juniorState`/`parentAccountId` are
|
||||||
|
* OMITTED when null (emitting `null` makes the client's enum parser throw);
|
||||||
|
* `email`/`birthday` are kept as null (not enums, so null is fine).
|
||||||
|
*/
|
||||||
|
function toSelfAccountDto(account: Account) {
|
||||||
|
return {
|
||||||
|
...toAccountDto(account),
|
||||||
|
email: account.email ?? null,
|
||||||
|
birthday: null,
|
||||||
|
availableUsernameChanges: account.availableUsernameChanges ?? DEFAULT_USERNAME_CHANGES,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||||
|
const HUB_INSTANCE = 'global'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push the notifications that follow an account mutation, mirroring the C#/Go
|
||||||
|
* hub behavior: the owner receives `SelfAccountUpdate` and `AccountUpdate`, and
|
||||||
|
* every connected client receives an `AccountUpdate` broadcast. Hub failures are
|
||||||
|
* logged and swallowed — the account write has already committed, so a hub
|
||||||
|
* hiccup must not fail the request.
|
||||||
|
*/
|
||||||
|
async function pushAccountUpdate(c: Context<App>, account: Account): Promise<void> {
|
||||||
|
try {
|
||||||
|
const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
|
||||||
|
const publicDto = toAccountDto(account)
|
||||||
|
await hub.notifyPlayer(account.accountId, 'SelfAccountUpdate', toSelfAccountDto(account))
|
||||||
|
await hub.notifyPlayer(account.accountId, 'AccountUpdate', publicDto)
|
||||||
|
await hub.broadcast('AccountUpdate', publicDto)
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('failed to push account update notifications', {
|
||||||
|
accountId: account.accountId,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const app = new Hono<App>()
|
const app = new Hono<App>()
|
||||||
.use(
|
.use(
|
||||||
'*',
|
'*',
|
||||||
@@ -108,16 +148,7 @@ const app = new Hono<App>()
|
|||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
// Load the stored account, falling back to a synthesized default.
|
// Load the stored account, falling back to a synthesized default.
|
||||||
const account = (await getAccount(c.env.DB, id)) ?? defaultAccount(id)
|
const account = (await getAccount(c.env.DB, id)) ?? defaultAccount(id)
|
||||||
// `juniorState` (an enum) and `parentAccountId` are OMITTED when null —
|
return c.json(toSelfAccountDto(account))
|
||||||
// emitting `"juniorState":null` makes the client's enum parser throw
|
|
||||||
// ("Can't parse JSON to Enum format"). `email`/`birthday` are kept as null
|
|
||||||
// (they aren't enums, so null is fine).
|
|
||||||
return c.json({
|
|
||||||
...toAccountDto(account),
|
|
||||||
email: account.email ?? null,
|
|
||||||
birthday: null,
|
|
||||||
availableUsernameChanges: account.availableUsernameChanges ?? DEFAULT_USERNAME_CHANGES,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- Bulk / single lookup ------------------------------------------------
|
// ---- Bulk / single lookup ------------------------------------------------
|
||||||
@@ -176,6 +207,10 @@ const app = new Hono<App>()
|
|||||||
return c.json({ accountId: id, disallowInAppPurchases: false })
|
return c.json({ accountId: id, disallowInAppPurchases: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Privacy settings for an account. The client deserializes this into an
|
||||||
|
// object, so it must return `{}` (not `[]`).
|
||||||
|
.get('/accountprivacysettings/:id', (c) => c.json({}))
|
||||||
|
|
||||||
// ---- Profile mutations ---------------------------------------------------
|
// ---- Profile mutations ---------------------------------------------------
|
||||||
// Set the player's display name (persisted on the account row).
|
// Set the player's display name (persisted on the account row).
|
||||||
.put('/account/me/displayname', async (c) => {
|
.put('/account/me/displayname', async (c) => {
|
||||||
@@ -183,7 +218,8 @@ const app = new Hono<App>()
|
|||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
const displayName = (await formField(c, 'displayName')).trim()
|
const displayName = (await formField(c, 'displayName')).trim()
|
||||||
if (displayName === '') return c.body(null, 400)
|
if (displayName === '') return c.body(null, 400)
|
||||||
await updateAccount(c.env.DB, id, { displayName })
|
const account = await updateAccount(c.env.DB, id, { displayName })
|
||||||
|
await pushAccountUpdate(c, account)
|
||||||
return c.json({ success: true })
|
return c.json({ success: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -214,6 +250,7 @@ const app = new Hono<App>()
|
|||||||
username,
|
username,
|
||||||
availableUsernameChanges: remaining - 1,
|
availableUsernameChanges: remaining - 1,
|
||||||
})
|
})
|
||||||
|
await pushAccountUpdate(c, updated)
|
||||||
return usernameResult(c, '', toAccountDto(updated))
|
return usernameResult(c, '', toAccountDto(updated))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -261,7 +298,8 @@ const app = new Hono<App>()
|
|||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
const bio = await formField(c, 'bio')
|
const bio = await formField(c, 'bio')
|
||||||
await updateAccount(c.env.DB, id, { bio })
|
const account = await updateAccount(c.env.DB, id, { bio })
|
||||||
|
await pushAccountUpdate(c, account)
|
||||||
return c.json({ success: true })
|
return c.json({ success: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -270,9 +308,10 @@ const app = new Hono<App>()
|
|||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
const imageName = await formField(c, 'imageName')
|
const imageName = await formField(c, 'imageName')
|
||||||
if (!imageName) return c.body(null, 400)
|
if (!imageName) return c.body(null, 400)
|
||||||
// Persist the new avatar key on the account row (the C# also fires an
|
// Persist the new avatar key on the account row and fire the AccountUpdate
|
||||||
// AccountUpdate websocket — no notify binding here, so it's omitted).
|
// websocket (the new profileImage rides along in the DTO payload).
|
||||||
await updateAccount(c.env.DB, id, { profileImage: imageName })
|
const account = await updateAccount(c.env.DB, id, { profileImage: imageName })
|
||||||
|
await pushAccountUpdate(c, account)
|
||||||
return c.json({ success: true })
|
return c.json({ success: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import type { HonoApp } from '@repo/hono-helpers'
|
import type { HonoApp } from '@repo/hono-helpers'
|
||||||
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
|
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
|
||||||
|
// Type-only import (erased at build) of the DO class owned by the `notify`
|
||||||
|
// worker, so the cross-worker RPC stub is fully typed.
|
||||||
|
import type { NotificationsHub } from '../../notify/src/notifications-hub'
|
||||||
|
|
||||||
export type Env = SharedHonoEnv & {
|
export type Env = SharedHonoEnv & {
|
||||||
// Shared rooms/accounts D1 database (schema owned by the `auth` worker). Used
|
// Shared rooms/accounts D1 database (schema owned by the `auth` worker). Used
|
||||||
// to look up accounts in bulk/by id and to create new accounts.
|
// to look up accounts in bulk/by id and to create new accounts.
|
||||||
DB: D1Database
|
DB: D1Database
|
||||||
|
// SignalR notifications hub (DO owned by the `notify` worker). Bound here to
|
||||||
|
// push AccountUpdate/SelfAccountUpdate notifications on profile mutations.
|
||||||
|
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Variables can be extended */
|
/** Variables can be extended */
|
||||||
|
|||||||
@@ -9,6 +9,28 @@ export default defineConfig({
|
|||||||
bindings: {
|
bindings: {
|
||||||
ENVIRONMENT: 'VITEST',
|
ENVIRONMENT: 'VITEST',
|
||||||
},
|
},
|
||||||
|
// The worker's RECFLARE_NOTIFICATIONS_HUB binding points at the `notify`
|
||||||
|
// worker's DO (script_name: "notify"). That worker isn't part of this
|
||||||
|
// isolated test, so provide a minimal stub service exposing the same
|
||||||
|
// NotificationsHub RPC surface — enough for the runtime to start and for
|
||||||
|
// notification sends to no-op.
|
||||||
|
workers: [
|
||||||
|
{
|
||||||
|
name: 'notify',
|
||||||
|
modules: true,
|
||||||
|
compatibilityDate: '2025-09-20',
|
||||||
|
compatibilityFlags: ['nodejs_compat'],
|
||||||
|
durableObjects: { RECFLARE_NOTIFICATIONS_HUB: 'NotificationsHub' },
|
||||||
|
script: `
|
||||||
|
import { DurableObject } from 'cloudflare:workers'
|
||||||
|
export class NotificationsHub extends DurableObject {
|
||||||
|
async notifyPlayer() { return { delivered: 0, queued: true } }
|
||||||
|
async broadcast() { return { delivered: 0 } }
|
||||||
|
}
|
||||||
|
export default { fetch() { return new Response('ok') } }
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -15,6 +15,17 @@
|
|||||||
"database_id": "local"
|
"database_id": "local"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
// Cross-worker binding to the SignalR notifications hub DO (owned/migrated by
|
||||||
|
// the `notify` worker). We only invoke its RPC methods; no migration here.
|
||||||
|
"durable_objects": {
|
||||||
|
"bindings": [
|
||||||
|
{
|
||||||
|
"name": "RECFLARE_NOTIFICATIONS_HUB",
|
||||||
|
"class_name": "NotificationsHub",
|
||||||
|
"script_name": "notify"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"logpush": false,
|
"logpush": false,
|
||||||
"upload_source_maps": true,
|
"upload_source_maps": true,
|
||||||
"observability": {
|
"observability": {
|
||||||
|
|||||||
+12
-1
@@ -197,6 +197,7 @@ const app = new Hono<App>({ strict: false })
|
|||||||
// ---- Social ---------------------------------------------------------------
|
// ---- Social ---------------------------------------------------------------
|
||||||
.get('/api/relationships/v2/get', (c) => c.json([]))
|
.get('/api/relationships/v2/get', (c) => c.json([]))
|
||||||
.get('/api/messages/v2/get', (c) => c.json([]))
|
.get('/api/messages/v2/get', (c) => c.json([]))
|
||||||
|
.get('/api/messages/v1/favoriteFriendOnlineStatus', (c) => c.json([]))
|
||||||
|
|
||||||
// ---- Reputation / progression --------------------------------------------
|
// ---- Reputation / progression --------------------------------------------
|
||||||
.get('/api/playerReputation/v1/:id', (c) =>
|
.get('/api/playerReputation/v1/:id', (c) =>
|
||||||
@@ -506,7 +507,17 @@ const app = new Hono<App>({ strict: false })
|
|||||||
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||||
return c.json(await getImagesByPlayer(c.env.DB, playerId, skip, take))
|
return c.json(await getImagesByPlayer(c.env.DB, playerId, 0, skip, take))
|
||||||
|
})
|
||||||
|
|
||||||
|
// A player's photos with a sort option. `sort` orders the list (1 = most
|
||||||
|
// cheered, else newest). Paginated via skip/take (take defaults to 100). Bare array.
|
||||||
|
.get('/api/images/v5/player/:playerId{[0-9]+}', async (c) => {
|
||||||
|
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||||
|
const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0
|
||||||
|
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||||
|
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||||
|
return c.json(await getImagesByPlayer(c.env.DB, playerId, sort, skip, take))
|
||||||
})
|
})
|
||||||
|
|
||||||
// A player's photo feed — the public images they took plus ones they're tagged
|
// A player's photo feed — the public images they took plus ones they're tagged
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ const newestFirst = (a: SavedImage, b: SavedImage) =>
|
|||||||
export async function getImagesByPlayer(
|
export async function getImagesByPlayer(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
playerId: number,
|
playerId: number,
|
||||||
|
sort: number,
|
||||||
skip: number,
|
skip: number,
|
||||||
take: number
|
take: number
|
||||||
): Promise<SavedImage[]> {
|
): Promise<SavedImage[]> {
|
||||||
@@ -148,7 +149,7 @@ export async function getImagesByPlayer(
|
|||||||
return results
|
return results
|
||||||
.map((r) => JSON.parse(r.data) as SavedImage)
|
.map((r) => JSON.parse(r.data) as SavedImage)
|
||||||
.filter((img) => img.Accessibility === 1)
|
.filter((img) => img.Accessibility === 1)
|
||||||
.sort(newestFirst)
|
.sort(sort === 1 ? (a, b) => b.CheerCount - a.CheerCount || newestFirst(a, b) : newestFirst)
|
||||||
.slice(skip, skip + take)
|
.slice(skip, skip + take)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -205,12 +205,11 @@ const app = new Hono<App>()
|
|||||||
return c.json({ success: true })
|
return c.json({ success: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
// Developer role lookup. Not implemented yet.
|
// Developer role lookup. No developer role granted by default.
|
||||||
.get('/role/developer/:id', (c) => {
|
.get('/role/developer/:id', (c) => {
|
||||||
const { id } = c.req.param()
|
const { id } = c.req.param()
|
||||||
logger.info('developer role lookup', { id })
|
logger.info('developer role lookup', { id })
|
||||||
// TODO: implement
|
return c.json({ success: false })
|
||||||
return c.json({ success: true })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export default app
|
export default app
|
||||||
|
|||||||
@@ -212,10 +212,10 @@ describe('auth worker routes', () => {
|
|||||||
expect(await rotate.json()).toEqual({ success: true })
|
expect(await rotate.json()).toEqual({ success: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('GET /role/developer/:id returns ok', async () => {
|
test('GET /role/developer/:id does not grant developer', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/role/developer/42`)
|
const res = await exports.default.fetch(`${ORIGIN}/role/developer/42`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(await res.json()).toEqual({ success: true })
|
expect(await res.json()).toEqual({ success: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('unknown path returns 404', async () => {
|
test('unknown path returns 404', async () => {
|
||||||
|
|||||||
@@ -80,4 +80,12 @@ const app = new Hono<App>()
|
|||||||
// The clubs the player is a member of (GetMyMembershipClubs). No DB → empty.
|
// The clubs the player is a member of (GetMyMembershipClubs). No DB → empty.
|
||||||
.get('/club/mine/member', (c) => c.json([]))
|
.get('/club/mine/member', (c) => c.json([]))
|
||||||
|
|
||||||
|
// The clubs the player created (GetMyCreatedClubs). No DB → empty.
|
||||||
|
.get('/club/mine/created', (c) => c.json([]))
|
||||||
|
|
||||||
|
// The set of club category tags a club can be filed under — a fixed list.
|
||||||
|
.get('/club/categoryTags', (c) =>
|
||||||
|
c.json(['Social', 'Creative', 'Competitive', 'Casual', 'Entertainment'])
|
||||||
|
)
|
||||||
|
|
||||||
export default app
|
export default app
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import weeklyChallenge from '../static/weekly-challenge.json'
|
|||||||
import { getAvatar, setAvatar } from './avatar-db'
|
import { getAvatar, setAvatar } from './avatar-db'
|
||||||
import { validateAndGetAccountId } from './jwt'
|
import { validateAndGetAccountId } from './jwt'
|
||||||
|
|
||||||
|
import type { Avatar } from './avatar-db'
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { App } from './context'
|
import type { App } from './context'
|
||||||
|
|
||||||
@@ -43,6 +44,21 @@ function unauthorized(c: Context<App>) {
|
|||||||
return c.body(null, 401)
|
return c.body(null, 401)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project a stored avatar into the public render subset returned by
|
||||||
|
* `GET /api/avatar/v2/:id` — the fields needed to draw another player's avatar
|
||||||
|
* (the full blob also holds `OutfitSelectionsV2`/`CustomAvatarItems`, which this
|
||||||
|
* view omits).
|
||||||
|
*/
|
||||||
|
function toAvatarV2Dto(avatar: Avatar) {
|
||||||
|
return {
|
||||||
|
OutfitSelections: avatar.OutfitSelections,
|
||||||
|
FaceFeatures: avatar.FaceFeatures,
|
||||||
|
SkinColor: avatar.SkinColor,
|
||||||
|
HairColor: avatar.HairColor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** RecNet currency types (the `CurrencyType` enum the client uses). */
|
/** RecNet currency types (the `CurrencyType` enum the client uses). */
|
||||||
const CurrencyType = {
|
const CurrencyType = {
|
||||||
Invalid: 0,
|
Invalid: 0,
|
||||||
@@ -153,6 +169,16 @@ const app = new Hono<App>()
|
|||||||
return c.json([])
|
return c.json([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A player's avatar by account id, projected to the public render subset (used
|
||||||
|
// to draw other players' avatars). No auth — like the accounts `/account/:id`
|
||||||
|
// lookup. Falls back to the default outfit when the player hasn't saved one.
|
||||||
|
// Registered after the static `/api/avatar/v2/*` routes so `:id` can't shadow them.
|
||||||
|
.get('/api/avatar/v2/:id', async (c) => {
|
||||||
|
const accountId = Number.parseInt(c.req.param('id'), 10)
|
||||||
|
if (Number.isNaN(accountId)) return c.body(null, 400)
|
||||||
|
return c.json(toAvatarV2Dto((await getAvatar(c.env.DB, accountId)) ?? defaultAvatar))
|
||||||
|
})
|
||||||
|
|
||||||
// Unlocked equipment. Returns "[]" with no auth.
|
// Unlocked equipment. Returns "[]" with no auth.
|
||||||
.get('/api/equipment/v2/getUnlocked', (c) => c.json([]))
|
.get('/api/equipment/v2/getUnlocked', (c) => c.json([]))
|
||||||
|
|
||||||
|
|||||||
@@ -456,4 +456,11 @@ const app = new Hono<App>()
|
|||||||
// ---- Room instance -------------------------------------------------------
|
// ---- Room instance -------------------------------------------------------
|
||||||
.post('/roominstance/:id/reportjoinresult', (c) => c.body(null, 200))
|
.post('/roominstance/:id/reportjoinresult', (c) => c.body(null, 200))
|
||||||
|
|
||||||
|
// Rooms flagged as needing a developer/moderator to spawn in. No such queue
|
||||||
|
// yet → empty list.
|
||||||
|
.get('/rooms/requiring/developer', (c) => c.json([]))
|
||||||
|
|
||||||
|
// Rooms flagged as requiring an RR+ subscription. No such queue yet → empty list.
|
||||||
|
.get('/rooms/requiring/rrplus', (c) => c.json([]))
|
||||||
|
|
||||||
export default app
|
export default app
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ export class NotificationsHub extends DurableObject<Env> {
|
|||||||
*/
|
*/
|
||||||
async notifyPlayer(
|
async notifyPlayer(
|
||||||
playerId: number,
|
playerId: number,
|
||||||
notificationType: number,
|
notificationType: string | number,
|
||||||
data?: Record<string, unknown>
|
data?: Record<string, unknown>
|
||||||
): Promise<{ delivered: number; queued: boolean }> {
|
): Promise<{ delivered: number; queued: boolean }> {
|
||||||
const payload = this.buildNotificationPayload(notificationType, data)
|
const payload = this.buildNotificationPayload(notificationType, data)
|
||||||
@@ -257,7 +257,7 @@ export class NotificationsHub extends DurableObject<Env> {
|
|||||||
|
|
||||||
/** Broadcast a notification to every connected (handshaken) client. */
|
/** Broadcast a notification to every connected (handshaken) client. */
|
||||||
async broadcast(
|
async broadcast(
|
||||||
notificationType: number,
|
notificationType: string | number,
|
||||||
data?: Record<string, unknown>
|
data?: Record<string, unknown>
|
||||||
): Promise<{ delivered: number }> {
|
): Promise<{ delivered: number }> {
|
||||||
const payload = this.buildNotificationPayload(notificationType, data)
|
const payload = this.buildNotificationPayload(notificationType, data)
|
||||||
@@ -275,10 +275,11 @@ export class NotificationsHub extends DurableObject<Env> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the `Notification` argument: a JSON string `{ Id, Msg }`
|
* Build the `Notification` argument: a JSON string `{ Id, Msg }`
|
||||||
* (null values are dropped from `Msg`).
|
* (null values are dropped from `Msg`). `Id` is a client-defined tag — a
|
||||||
|
* string name (e.g. "AccountUpdate") or a numeric code.
|
||||||
*/
|
*/
|
||||||
private buildNotificationPayload(
|
private buildNotificationPayload(
|
||||||
notificationType: number,
|
notificationType: string | number,
|
||||||
data?: Record<string, unknown>
|
data?: Record<string, unknown>
|
||||||
): string {
|
): string {
|
||||||
const msg: Record<string, unknown> = {}
|
const msg: Record<string, unknown> = {}
|
||||||
@@ -288,7 +289,7 @@ export class NotificationsHub extends DurableObject<Env> {
|
|||||||
msg[key] = value
|
msg[key] = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return JSON.stringify({ Id: notificationType ?? 0, Msg: msg })
|
return JSON.stringify({ Id: notificationType ?? '', Msg: msg })
|
||||||
}
|
}
|
||||||
|
|
||||||
private invocation(target: string, args: unknown[]): string {
|
private invocation(target: string, args: unknown[]): string {
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ import type { App } from './context'
|
|||||||
/** The hub state is global → one DO instance. */
|
/** The hub state is global → one DO instance. */
|
||||||
const HUB_INSTANCE = 'global'
|
const HUB_INSTANCE = 'global'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A valid notification `Id` — a client-defined string tag (e.g. "AccountUpdate")
|
||||||
|
* or a numeric code. An empty string is treated as missing.
|
||||||
|
*/
|
||||||
|
function isNotificationType(value: unknown): value is string | number {
|
||||||
|
return (typeof value === 'string' && value !== '') || typeof value === 'number'
|
||||||
|
}
|
||||||
|
|
||||||
const app = new Hono<App>()
|
const app = new Hono<App>()
|
||||||
.use(
|
.use(
|
||||||
'*',
|
'*',
|
||||||
@@ -48,7 +56,7 @@ const app = new Hono<App>()
|
|||||||
})
|
})
|
||||||
|
|
||||||
// The hub WebSocket. Upgrade requests are forwarded to the Durable Object.
|
// The hub WebSocket. Upgrade requests are forwarded to the Durable Object.
|
||||||
.get('/hub/v1', (c) => {
|
.get('/hub/v1', async (c) => {
|
||||||
if ((c.req.header('upgrade') ?? '').toLowerCase() !== 'websocket') {
|
if ((c.req.header('upgrade') ?? '').toLowerCase() !== 'websocket') {
|
||||||
return c.json({ error: 'Expected a WebSocket upgrade request' }, 426)
|
return c.json({ error: 'Expected a WebSocket upgrade request' }, 426)
|
||||||
}
|
}
|
||||||
@@ -60,9 +68,13 @@ const app = new Hono<App>()
|
|||||||
// TODO: protect these before production.
|
// TODO: protect these before production.
|
||||||
.post('/internal/notify', async (c) => {
|
.post('/internal/notify', async (c) => {
|
||||||
const body = await c.req
|
const body = await c.req
|
||||||
.json<{ playerId?: number; notificationType?: number; data?: Record<string, unknown> }>()
|
.json<{
|
||||||
|
playerId?: number
|
||||||
|
notificationType?: string | number
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
}>()
|
||||||
.catch(() => null)
|
.catch(() => null)
|
||||||
if (!body || typeof body.playerId !== 'number' || typeof body.notificationType !== 'number') {
|
if (!body || typeof body.playerId !== 'number' || !isNotificationType(body.notificationType)) {
|
||||||
return c.json({ error: 'playerId and notificationType are required' }, 400)
|
return c.json({ error: 'playerId and notificationType are required' }, 400)
|
||||||
}
|
}
|
||||||
const result = await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
const result = await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||||
@@ -75,9 +87,9 @@ const app = new Hono<App>()
|
|||||||
|
|
||||||
.post('/internal/broadcast', async (c) => {
|
.post('/internal/broadcast', async (c) => {
|
||||||
const body = await c.req
|
const body = await c.req
|
||||||
.json<{ notificationType?: number; data?: Record<string, unknown> }>()
|
.json<{ notificationType?: string | number; data?: Record<string, unknown> }>()
|
||||||
.catch(() => null)
|
.catch(() => null)
|
||||||
if (!body || typeof body.notificationType !== 'number') {
|
if (!body || !isNotificationType(body.notificationType)) {
|
||||||
return c.json({ error: 'notificationType is required' }, 400)
|
return c.json({ error: 'notificationType is required' }, 400)
|
||||||
}
|
}
|
||||||
const result = await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).broadcast(
|
const result = await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).broadcast(
|
||||||
|
|||||||
@@ -424,6 +424,9 @@ const app = new Hono<App>()
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The caller's per-room player data. Stub → empty blob (client reads `Data`).
|
||||||
|
.get('/rooms/:roomId{[0-9]+}/playerdata/me', (c) => c.json({ Data: '' }))
|
||||||
|
|
||||||
// Single room by id. 404 when the room isn't in D1. Ignores the
|
// Single room by id. 404 when the room isn't in D1. Ignores the
|
||||||
// include/unityAsset* query params.
|
// include/unityAsset* query params.
|
||||||
.get('/rooms/:roomId{[0-9]+}', async (c) => {
|
.get('/rooms/:roomId{[0-9]+}', async (c) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user