relationship notifications (maybe)

This commit is contained in:
Devin Zuczek
2026-07-21 01:19:37 -04:00
parent 08cf44991d
commit 0f301e5788
6 changed files with 312 additions and 77 deletions
+4 -4
View File
@@ -37,10 +37,10 @@ export const SCHEMA_DDL: string[] = [
]
/**
* Saved-image categories from the C# `SavedImageType` enum — the value of a stored
* image's `Type` (and the client's `imgMeta.savedImageType` on upload). Lives here in
* the image data layer so both the upload route and the slideshow query share one
* definition.
* Saved-image categories from the reference's `SavedImageType` enum — the value of a
* stored image's `Type` (and the client's `imgMeta.savedImageType` on upload). Lives
* here in the image data layer so both the upload route and the slideshow query share
* one definition.
*/
export const SavedImageType = {
None: 0,
+113 -42
View File
@@ -14,7 +14,7 @@
* workers' migrations that share the database).
*/
/** Relationship state from the perspective of the player asking (mirror of the C# enum). */
/** Relationship state from the perspective of the player asking (mirrors the reference). */
export enum RelationshipType {
None = 0,
FriendRequestSent = 1,
@@ -53,7 +53,7 @@ interface RelationshipRow {
target_muted: number
}
/** The per-player relationship projection returned to the client (the C# RelationshipResponse). */
/** The per-player relationship projection returned to the client (RelationshipResponse). */
export interface RelationshipResponse {
Favorited: number
Ignored: number
@@ -62,6 +62,34 @@ export interface RelationshipResponse {
RelationshipType: RelationshipType
}
/**
* The result of a friend-graph mutation. These changes are visible to BOTH players, and
* each sees a different projection of the same row (the target of a request sees
* `FriendRequestReceived` where the sender sees `Sent`), so callers get both — `self` for
* the HTTP response and the acting player's notification, `other` for the target's.
*
* `changed` is false when the mutation was a no-op: re-sending a request that's already
* outstanding, befriending someone you're already friends with, accepting something that
* isn't pending. Nothing was written, so no RelationshipChanged notification should go out
* (the reference server is likewise silent on its no-change branch).
*/
export interface RelationshipChange {
self: RelationshipResponse
other: RelationshipResponse
changed: boolean
}
/** The projection reported for a pair with no stored relationship. */
function noneResponse(otherId: number): RelationshipResponse {
return {
PlayerID: otherId,
RelationshipType: RelationshipType.None,
Favorited: 0,
Ignored: 0,
Muted: 0,
}
}
/** Flip a pending request to the other side's point of view; Friend/None are symmetric. */
function flipType(type: number): RelationshipType {
if (type === RelationshipType.FriendRequestSent) return RelationshipType.FriendRequestReceived
@@ -85,6 +113,16 @@ function toResponse(row: RelationshipRow, playerId: number): RelationshipRespons
}
}
/** Project a written row for both players in the pair. */
function toChange(
row: RelationshipRow,
playerId: number,
otherId: number,
changed: boolean
): RelationshipChange {
return { self: toResponse(row, playerId), other: toResponse(row, otherId), changed }
}
/** Find the single row for an unordered pair (either direction), or null. */
async function findPair(db: D1Database, a: number, b: number): Promise<RelationshipRow | null> {
return db
@@ -98,7 +136,10 @@ async function findPair(db: D1Database, a: number, b: number): Promise<Relations
/**
* All of a player's relationships, projected from that player's point of view.
* `None` rows are omitted (a removed friend leaves no relationship to report).
*
* `None` rows are included: they are how an unfriending, or an ignore/mute of someone you
* were never friends with, is recorded, and they still carry that player's
* favorited/ignored/muted flags. Dropping them would lose the flags on the client.
*/
export async function getRelationshipsForPlayer(
db: D1Database,
@@ -111,9 +152,7 @@ export async function getRelationshipsForPlayer(
)
.bind(playerId)
.all<RelationshipRow>()
return results
.filter((row) => row.relationship_type !== RelationshipType.None)
.map((row) => toResponse(row, playerId))
return results.map((row) => toResponse(row, playerId))
}
/**
@@ -121,14 +160,14 @@ export async function getRelationshipsForPlayer(
* requester. Inserts a new row or, if one already exists for the pair (either
* direction), rewrites it so the requester is normalized to `requesterId` and
* the flags are preserved for whichever side each player is on. Returns the
* relationship from `requesterId`'s point of view.
* row as written, for the caller to project onto whichever side it needs.
*/
async function upsertPair(
db: D1Database,
requesterId: number,
targetId: number,
type: RelationshipType
): Promise<RelationshipResponse> {
): Promise<RelationshipRow> {
const existing = await findPair(db, requesterId, targetId)
if (!existing) {
await db
@@ -138,7 +177,17 @@ async function upsertPair(
)
.bind(requesterId, targetId, type)
.run()
return { PlayerID: targetId, RelationshipType: type, Favorited: 0, Ignored: 0, Muted: 0 }
return {
requester_id: requesterId,
target_id: targetId,
relationship_type: type,
requester_favorited: 0,
requester_ignored: 0,
requester_muted: 0,
target_favorited: 0,
target_ignored: 0,
target_muted: 0,
}
}
// Keep each player's flags with that player as the row is normalized to
@@ -175,93 +224,117 @@ async function upsertPair(
)
.run()
return {
PlayerID: targetId,
RelationshipType: type,
Favorited: reqFlags.favorited,
Ignored: reqFlags.ignored,
Muted: reqFlags.muted,
requester_id: requesterId,
target_id: targetId,
relationship_type: type,
requester_favorited: reqFlags.favorited,
requester_ignored: reqFlags.ignored,
requester_muted: reqFlags.muted,
target_favorited: tgtFlags.favorited,
target_ignored: tgtFlags.ignored,
target_muted: tgtFlags.muted,
}
}
/**
* Send a friend request from `requesterId` to `targetId`. If the target already
* has a pending request out to the requester, the two become friends instead
* (the request crosses an existing one). Already-friends is left unchanged.
* Returns the relationship from the requester's point of view.
* (the request crosses an existing one). Already-friends, and re-sending a request
* that's already outstanding, are no-ops.
*/
export async function sendFriendRequest(
db: D1Database,
requesterId: number,
targetId: number
): Promise<RelationshipResponse> {
): Promise<RelationshipChange> {
const existing = await findPair(db, requesterId, targetId)
if (existing) {
if (existing.relationship_type === RelationshipType.Friend) {
return toResponse(existing, requesterId)
// Already friends, or we already have a request out to them — nothing to write.
if (
existing.relationship_type === RelationshipType.Friend ||
(existing.requester_id === requesterId &&
existing.relationship_type === RelationshipType.FriendRequestSent)
) {
return toChange(existing, requesterId, targetId, false)
}
// The target already requested us → crossing requests become a friendship.
if (
existing.requester_id === targetId &&
existing.relationship_type === RelationshipType.FriendRequestSent
) {
return upsertPair(db, requesterId, targetId, RelationshipType.Friend)
const row = await upsertPair(db, requesterId, targetId, RelationshipType.Friend)
return toChange(row, requesterId, targetId, true)
}
}
return upsertPair(db, requesterId, targetId, RelationshipType.FriendRequestSent)
const row = await upsertPair(db, requesterId, targetId, RelationshipType.FriendRequestSent)
return toChange(row, requesterId, targetId, true)
}
/**
* `accepterId` accepts a pending friend request from `otherId`. Only upgrades to
* Friend when a request from `otherId` is actually pending; otherwise the
* current state is returned unchanged. Returns the relationship from the
* accepter's point of view.
* Friend when a request from `otherId` is actually pending; otherwise the current
* state is returned as a no-op. (The reference server answers 403 there instead;
* we stay lenient, but either way nothing changed.)
*/
export async function acceptFriendRequest(
db: D1Database,
accepterId: number,
otherId: number
): Promise<RelationshipResponse> {
): Promise<RelationshipChange> {
const existing = await findPair(db, accepterId, otherId)
if (
existing &&
existing.requester_id === otherId &&
existing.relationship_type === RelationshipType.FriendRequestSent
) {
// upsertPair projects for the requester (otherId); the accepter is the target,
// so re-project the written row from the accepter's point of view.
await upsertPair(db, otherId, accepterId, RelationshipType.Friend)
const updated = await findPair(db, accepterId, otherId)
if (updated) return toResponse(updated, accepterId)
const row = await upsertPair(db, otherId, accepterId, RelationshipType.Friend)
return toChange(row, accepterId, otherId, true)
}
return existing
? toResponse(existing, accepterId)
: { PlayerID: otherId, RelationshipType: RelationshipType.None, Favorited: 0, Ignored: 0, Muted: 0 }
? toChange(existing, accepterId, otherId, false)
: { self: noneResponse(otherId), other: noneResponse(accepterId), changed: false }
}
/**
* Directly make `requesterId` and `targetId` friends (no pending request step).
* Returns the relationship from the requester's point of view.
*/
export async function addFriend(
db: D1Database,
requesterId: number,
targetId: number
): Promise<RelationshipResponse> {
return upsertPair(db, requesterId, targetId, RelationshipType.Friend)
): Promise<RelationshipChange> {
const existing = await findPair(db, requesterId, targetId)
if (existing && existing.relationship_type === RelationshipType.Friend) {
return toChange(existing, requesterId, targetId, false)
}
const row = await upsertPair(db, requesterId, targetId, RelationshipType.Friend)
return toChange(row, requesterId, targetId, true)
}
/**
* Remove any relationship between the two players (unfriend / cancel request /
* decline). Deletes the row entirely so neither side reports a relationship.
* decline).
*
* The row is set to `None` rather than deleted, matching the reference server: the
* per-player favorited/ignored/muted flags live on that row and must survive an
* unfriending (someone you ignored stays ignored after you drop them as a friend).
*/
export async function removeFriend(db: D1Database, a: number, b: number): Promise<void> {
export async function removeFriend(
db: D1Database,
playerId: number,
otherId: number
): Promise<RelationshipChange> {
await db
.prepare(
`DELETE FROM relationship
`UPDATE relationship SET relationship_type = ?3
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
)
.bind(a, b)
.bind(playerId, otherId, RelationshipType.None)
.run()
const updated = await findPair(db, playerId, otherId)
return updated
? toChange(updated, playerId, otherId, true)
: { self: noneResponse(otherId), other: noneResponse(playerId), changed: true }
}
/** A per-player relationship flag — each is stored on the player's own side of the row. */
@@ -308,7 +381,5 @@ export async function setRelationshipFlag(
}
const updated = await findPair(db, playerId, otherId)
return updated
? toResponse(updated, playerId)
: { PlayerID: otherId, RelationshipType: RelationshipType.None, Favorited: 0, Ignored: 0, Muted: 0 }
return updated ? toResponse(updated, playerId) : noneResponse(otherId)
}
+1 -1
View File
@@ -39,7 +39,7 @@ export const imageRoutes = new Hono<App>({ strict: false })
if (!(candidate instanceof File)) return c.json({ error: 'No file found in request' }, 400)
const file = candidate
// `imgMeta` is a JSON blob describing the upload (the C# `SavedImageMetaDTO`),
// `imgMeta` is a JSON blob describing the upload (`SavedImageMetaDTO`),
// posted as a multipart field. It carries the metadata we record on the image
// (savedImageType, roomId, accessibility, description, taggedPlayerIds, …).
let meta: Record<string, unknown> = {}
+59 -18
View File
@@ -14,7 +14,7 @@ import {
import type { Context } from 'hono'
import type { App } from '../context'
import type { RelationshipFlag } from '../relationships-db'
import type { RelationshipChange, RelationshipFlag, RelationshipResponse } from '../relationships-db'
/** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global'
@@ -23,20 +23,15 @@ const HUB_INSTANCE = 'global'
const RELATIONSHIP_CHANGED = 1
/**
* Apply a per-player relationship flag toggle (favorited/ignored/muted) and hand the
* result to the client the way the Go server does: the resulting relationship rides a
* `RelationshipChanged` hub notification to the caller, and the HTTP body is just the
* `{ Success, Message }` ack. Hub failures are logged and swallowed — the DB write has
* already committed, so a hub hiccup must not fail the request.
* Push a `RelationshipChanged` notification carrying `rel` to one player. Hub failures are
* logged and swallowed — the DB write has already committed, so a hub hiccup must not fail
* the request.
*/
async function applyFlag(
async function notifyRelationship(
c: Context<App>,
playerId: number,
otherId: number,
flag: RelationshipFlag,
value: boolean
): Promise<Response> {
const rel = await setRelationshipFlag(c.env.DB, playerId, otherId, flag, value)
rel: RelationshipResponse
): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
playerId,
@@ -49,6 +44,39 @@ async function applyFlag(
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* Notify both players of a friend-graph change, each with the relationship projected from
* their own point of view — the target of a request sees `FriendRequestReceived` where the
* sender sees `Sent`, so the two payloads differ. A no-op mutation notifies nobody.
*/
async function notifyBoth(
c: Context<App>,
playerId: number,
otherId: number,
change: RelationshipChange
): Promise<void> {
if (!change.changed) return
await notifyRelationship(c, playerId, change.self)
await notifyRelationship(c, otherId, change.other)
}
/**
* Apply a per-player relationship flag toggle (favorited/ignored/muted). The flags are
* private to the caller's own side of the row, so only the caller is notified. The
* resulting relationship rides the notification and the HTTP body is just the
* `{ Success, Message }` ack.
*/
async function applyFlag(
c: Context<App>,
playerId: number,
otherId: number,
flag: RelationshipFlag,
value: boolean
): Promise<Response> {
const rel = await setRelationshipFlag(c.env.DB, playerId, otherId, flag, value)
await notifyRelationship(c, playerId, rel)
return c.json({ Success: true, Message: '' })
}
@@ -92,12 +120,19 @@ export const socialRoutes = new Hono<App>({ strict: false })
// client calls this as a GET; the mutations accept GET or POST (the Go handlers
// matched any method). Auth-gated. Returns the resulting relationship from the
// caller's point of view.
//
// The four friend-graph mutations below change state both players can see, so each
// notifies BOTH sides with their own projection (see notifyBoth) on top of the HTTP
// response. A no-op — re-sending an outstanding request, accepting nothing pending —
// notifies nobody.
.on(['GET', 'POST'], '/api/relationships/v2/sendfriendrequest', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return c.json(await sendFriendRequest(c.env.DB, id, target))
const change = await sendFriendRequest(c.env.DB, id, target)
await notifyBoth(c, id, target, change)
return c.json(change.self)
})
// Accept a pending friend request from another player (`?id=`). Auth-gated.
@@ -106,17 +141,21 @@ export const socialRoutes = new Hono<App>({ strict: false })
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return c.json(await acceptFriendRequest(c.env.DB, id, target))
const change = await acceptFriendRequest(c.env.DB, id, target)
await notifyBoth(c, id, target, change)
return c.json(change.self)
})
// Remove a friend / cancel a request / decline a request (`?id=`). Auth-gated.
// Remove a friend / cancel a request / decline a request (`?id=`). The row is kept as
// a None relationship so the per-side flags survive (see removeFriend). Auth-gated.
.on(['GET', 'POST'], '/api/relationships/v2/removefriend', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
await removeFriend(c.env.DB, id, target)
return c.json({ success: true })
const change = await removeFriend(c.env.DB, id, target)
await notifyBoth(c, id, target, change)
return c.json(change.self)
})
// Directly add another player as a friend, no pending-request step (`?id=`). Auth-gated.
@@ -125,7 +164,9 @@ export const socialRoutes = new Hono<App>({ strict: false })
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return c.json(await addFriend(c.env.DB, id, target))
const change = await addFriend(c.env.DB, id, target)
await notifyBoth(c, id, target, change)
return c.json(change.self)
})
// Ignore / mute another player, and their inverses unignore / unmute (target
+121 -8
View File
@@ -1517,6 +1517,24 @@ describe('relationships', () => {
// the relationship detail rides a RelationshipChanged hub notification instead.
const ACK = { Success: true, Message: '' }
// The notify DO is stubbed to record every notifyPlayer call (see vitest.config).
type Notification = {
playerId: number
notificationType: number
data: { PlayerID: number; RelationshipType: number; Favorited: number; Ignored: number }
}
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
/** Drop everything the hub stub has recorded so far. */
async function resetNotifications() {
await hub().fetch('http://do/all', { method: 'DELETE' })
}
/** Every notification pushed since the last reset, in order. */
async function sentNotifications(): Promise<Notification[]> {
return (await (await hub().fetch('http://do/all')).json()) as Notification[]
}
// POST a flag mutation the real client way (form body `PlayerId=<id>`), returning
// the parsed ack body.
async function ackFlag(path: string, sub: string, playerId: number) {
@@ -1529,9 +1547,8 @@ describe('relationships', () => {
).json()) as { Success: boolean; Message: string }
}
// A player's own-side flags read straight from the relationship row. The None row a
// flag can create for an otherwise-unrelated pair isn't reported by v2/get, so the
// flag effect is verified here instead of through the (now ack-only) response.
// A player's own-side flags read straight from the relationship row — the flag
// endpoints return only an ack, so the effect is verified against the row itself.
async function ownFlags(playerId: number, otherId: number) {
const row = (await env.DB.prepare(
`SELECT requester_id, requester_favorited, requester_ignored, requester_muted,
@@ -1597,10 +1614,27 @@ describe('relationships', () => {
{ PlayerID: 500, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 },
])
// 500 removes → neither side has a relationship.
// 500 removes → both sides drop to None. The row is kept (that's where the
// per-side flags live), so v2/get still reports the pair, now as None (0).
expect((await mutate('/api/relationships/v2/removefriend', '500', 501)).status).toBe(200)
expect(await relationships('500')).toEqual([])
expect(await relationships('501')).toEqual([])
expect(await relationships('500')).toEqual([
{ PlayerID: 501, RelationshipType: 0, Favorited: 0, Ignored: 0, Muted: 0 },
])
expect(await relationships('501')).toEqual([
{ PlayerID: 500, RelationshipType: 0, Favorited: 0, Ignored: 0, Muted: 0 },
])
})
test('removefriend keeps the callers ignore flag', async () => {
// 760 befriends 761 then ignores them; dropping the friendship must not
// un-ignore them (the flag lives on the row the removal downgrades to None).
await mutate('/api/relationships/v2/addfriend', '760', 761)
await ackFlag('/api/relationships/v1/ignore', '760', 761)
await mutate('/api/relationships/v2/removefriend', '760', 761)
expect(await ownFlags(760, 761)).toMatchObject({ Ignored: 1 })
expect(await relationships('760')).toEqual([
{ PlayerID: 761, RelationshipType: 0, Favorited: 0, Ignored: 1, Muted: 0 },
])
})
test('addfriend makes them friends directly', async () => {
@@ -1689,8 +1723,10 @@ describe('relationships', () => {
// Mirrors ignore/mute: a bare None row is created with the caller's side flagged.
expect(await (await mutate('/api/relationships/v1/favorite', '730', 731)).json()).toEqual(ACK)
expect(await ownFlags(730, 731)).toMatchObject({ Favorited: 1 })
// A None row is not reported as a relationship by v2/get.
expect(await relationships('730')).toEqual([])
// The bare None row is reported by v2/get — it carries the flag.
expect(await relationships('730')).toEqual([
{ PlayerID: 731, RelationshipType: 0, Favorited: 1, Ignored: 0, Muted: 0 },
])
})
test('a self-targeted favorite is rejected', async () => {
@@ -1711,4 +1747,81 @@ describe('relationships', () => {
expect(last.notificationType).toBe(1) // NotificationType.RelationshipChanged
expect(last.data).toMatchObject({ PlayerID: 751, Favorited: 1, RelationshipType: 0 })
})
test('sendfriendrequest notifies both players with their own projection', async () => {
await resetNotifications()
await mutate('/api/relationships/v2/sendfriendrequest', '770', 771)
// Both sides hear about it, each seeing the other player and their own side's
// type: the sender Sent (1), the recipient Received (2).
expect(await sentNotifications()).toEqual([
{
playerId: 770,
notificationType: 1,
data: { PlayerID: 771, RelationshipType: 1, Favorited: 0, Ignored: 0, Muted: 0 },
},
{
playerId: 771,
notificationType: 1,
data: { PlayerID: 770, RelationshipType: 2, Favorited: 0, Ignored: 0, Muted: 0 },
},
])
})
test('accepting notifies both players as Friend', async () => {
await mutate('/api/relationships/v2/sendfriendrequest', '780', 781)
await resetNotifications()
await mutate('/api/relationships/v2/acceptfriendrequest', '781', 780)
const sent = await sentNotifications()
expect(sent).toHaveLength(2)
// Friend (3) is symmetric, so both sides see the same type, each pointing at the other.
expect(sent).toEqual(
expect.arrayContaining([
expect.objectContaining({
playerId: 780,
data: expect.objectContaining({ PlayerID: 781, RelationshipType: 3 }),
}),
expect.objectContaining({
playerId: 781,
data: expect.objectContaining({ PlayerID: 780, RelationshipType: 3 }),
}),
])
)
})
test('removefriend notifies both players with None', async () => {
await mutate('/api/relationships/v2/addfriend', '790', 791)
await resetNotifications()
await mutate('/api/relationships/v2/removefriend', '790', 791)
const sent = await sentNotifications()
expect(sent).toHaveLength(2)
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([790, 791])
for (const n of sent) expect(n.data.RelationshipType).toBe(0)
})
test('a no-op friend request notifies nobody', async () => {
await mutate('/api/relationships/v2/sendfriendrequest', '810', 811)
await resetNotifications()
// Re-sending an already-outstanding request writes nothing, so nothing is pushed.
await mutate('/api/relationships/v2/sendfriendrequest', '810', 811)
expect(await sentNotifications()).toEqual([])
// Likewise accepting something that isn't pending (810 has no request to accept).
await mutate('/api/relationships/v2/acceptfriendrequest', '810', 811)
expect(await sentNotifications()).toEqual([])
})
test('crossing requests notify both players as Friend', async () => {
await mutate('/api/relationships/v2/sendfriendrequest', '820', 821)
await resetNotifications()
// 821's request crosses 820's → an immediate friendship, both sides told.
await mutate('/api/relationships/v2/sendfriendrequest', '821', 820)
const sent = await sentNotifications()
expect(sent).toHaveLength(2)
for (const n of sent) expect(n.data.RelationshipType).toBe(3)
})
})
+14 -4
View File
@@ -21,17 +21,27 @@ export default defineConfig({
compatibilityDate: '2026-06-16',
compatibilityFlags: ['nodejs_compat'],
durableObjects: { RECFLARE_NOTIFICATIONS_HUB: 'NotificationsHub' },
// notifyPlayer records its last call so tests can assert the notification
// the worker pushed (type + payload); GET the DO to read it back.
// notifyPlayer records every call so tests can assert the notifications the
// worker pushed (type + payload). GET the DO for the most recent one,
// GET /all for the whole list (friend-graph changes notify both players),
// DELETE to reset it between assertions.
script: `
import { DurableObject } from 'cloudflare:workers'
export class NotificationsHub extends DurableObject {
sent = []
async notifyPlayer(playerId, notificationType, data) {
this.last = { playerId, notificationType, data }
this.sent.push({ playerId, notificationType, data })
return { delivered: 0, queued: true }
}
async broadcast() { return { delivered: 0 } }
async fetch() { return Response.json(this.last ?? null) }
async fetch(request) {
if (request.method === 'DELETE') {
this.sent = []
return new Response(null, { status: 204 })
}
if (new URL(request.url).pathname === '/all') return Response.json(this.sent)
return Response.json(this.sent.at(-1) ?? null)
}
}
export default { fetch() { return new Response('ok') } }
`,