merge: 최신 main 변경을 전투 시뮬레이터 payload 작업에 반영한다

This commit is contained in:
2026-08-23 13:25:53 +00:00
43 changed files with 2614 additions and 11 deletions
+2
View File
@@ -33,6 +33,7 @@ export interface GameApiConfig {
gatewayInternalApiUrl: string;
accountIconResetReconcileIntervalMs: number;
flushChannel: string;
webPushOutboxPollMs: number;
}
export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env): GameApiConfig => {
@@ -86,5 +87,6 @@ export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env
gatewayInternalApiUrl: env.GATEWAY_INTERNAL_API_URL ?? 'http://127.0.0.1:13000',
accountIconResetReconcileIntervalMs: parseReconcileInterval(env.ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS),
flushChannel: `${gatewayPrefix}:flush`,
webPushOutboxPollMs: parseNumberWithFallback(env.WEB_PUSH_OUTBOX_POLL_MS, 1_000, 'WEB_PUSH_OUTBOX_POLL_MS'),
};
};
+2
View File
@@ -2,6 +2,7 @@ import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/
import type { DatabaseClient } from '../context.js';
import { loadCurrentGameTime } from '../services/gameClock.js';
import { enqueuePrivateMessageWebPush } from '@sammo-ts/infra';
export interface MessageView {
id: number;
@@ -88,6 +89,7 @@ export const insertMessage = async (db: DatabaseClient, draft: MessageRecordDraf
if (!id) {
throw new Error('Failed to insert message row.');
}
await enqueuePrivateMessageWebPush(db, draft, id);
return id;
};
+16
View File
@@ -46,6 +46,7 @@ import { createBestEffortResourceCloser } from './services/bestEffortResourceClo
import { RemoteContentImageStore } from './services/remoteContentImageStore.js';
import { ReadModelOutboxWorker } from './realtime/outboxWorker.js';
import { DeferredGeneralAccessWorker } from './services/deferredGeneralAccess.js';
import { WebPushOutboxWorker } from './services/webPushOutboxWorker.js';
const extractBearerToken = (value: string | string[] | undefined): string | null => {
if (!value) {
@@ -168,9 +169,23 @@ export const createGameApiServer = async () => {
onError: (error) => app.log.error({ err: error }, 'deferred general access flush failed'),
}
);
const webPushOutboxWorker = new WebPushOutboxWorker(
postgres.prisma,
config.gatewayInternalApiUrl,
config.gameTokenSecret,
config.profileName,
{
intervalMs: config.webPushOutboxPollMs,
onError: (error) => app.log.error({ err: error }, 'web push outbox dispatch failed'),
}
);
let flushSubscriberStarted = false;
let realtimeHubStarted = false;
const closeResources = createBestEffortResourceCloser([
{
name: 'web-push-outbox-worker',
run: () => webPushOutboxWorker.stop(),
},
{
name: 'deferred-general-access-worker',
run: () => deferredGeneralAccessWorker.stop(),
@@ -395,6 +410,7 @@ export const createGameApiServer = async () => {
await flushSubscriber.start();
flushSubscriberStarted = true;
readModelOutboxWorker.start();
webPushOutboxWorker.start();
deferredGeneralAccessWorker.start();
accountIconResetReconciler.start();
} catch (error) {
@@ -0,0 +1,154 @@
import { createHmac, randomUUID } from 'node:crypto';
import type { WebPushEventEnvelopeV1, WebPushEventType } from '@sammo-ts/common';
import { GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
const INTERNAL_TOKEN_CONTEXT = 'sammo:web-push-event-ingest:v1';
const MAX_EVENT_AGE_MS = 24 * 60 * 60 * 1_000;
const deriveInternalToken = (secret: string): string =>
createHmac('sha256', secret).update(INTERNAL_TOKEN_CONTEXT).digest('hex');
export interface WebPushOutboxWorkerOptions {
intervalMs?: number;
onError?: (error: unknown) => void;
}
export class WebPushOutboxWorker {
private readonly owner: string;
private readonly intervalMs: number;
private readonly baseUrl: string;
private readonly token: string;
private readonly onError: (error: unknown) => void;
private timer: NodeJS.Timeout | null = null;
private inFlight: Promise<void> | null = null;
private nextPruneAt = 0;
constructor(
private readonly db: GamePrismaClient,
gatewayInternalApiUrl: string,
secret: string,
private readonly profileName: string,
options: WebPushOutboxWorkerOptions = {}
) {
this.baseUrl = gatewayInternalApiUrl.replace(/\/$/u, '');
this.token = deriveInternalToken(secret);
this.owner = `game-web-push:${profileName}:${process.pid}:${randomUUID()}`;
this.intervalMs = Math.max(250, Math.floor(options.intervalMs ?? 1_000));
this.onError = options.onError ?? (() => undefined);
}
private async dispatchBatch(): Promise<void> {
const claimed = await this.db.$transaction(async (tx) => {
const rows = await tx.$queryRaw<Array<{ id: bigint }>>(GamePrisma.sql`
SELECT "id"
FROM "web_push_outbox"
WHERE "delivered_at" IS NULL
AND "available_at" <= CURRENT_TIMESTAMP
AND ("locked_at" IS NULL OR "locked_at" <= CURRENT_TIMESTAMP - INTERVAL '30 seconds')
ORDER BY "id"
FOR UPDATE SKIP LOCKED
LIMIT 50
`);
if (rows.length === 0) return [];
const ids = rows.map((row) => row.id);
await tx.webPushOutbox.updateMany({
where: { id: { in: ids } },
data: { lockedAt: new Date(), lockOwner: this.owner, attempts: { increment: 1 } },
});
return tx.webPushOutbox.findMany({
where: { id: { in: ids }, lockOwner: this.owner },
orderBy: { id: 'asc' },
});
});
for (const event of claimed) {
if (event.createdAt.getTime() <= Date.now() - MAX_EVENT_AGE_MS) {
await this.db.webPushOutbox.updateMany({
where: { id: event.id, lockOwner: this.owner },
data: { deliveredAt: new Date(), lockedAt: null, lockOwner: null, lastError: null },
});
continue;
}
try {
const envelope: WebPushEventEnvelopeV1 = {
version: 1,
eventId: `game:${this.profileName}:${event.eventId}`,
eventType: event.eventType as WebPushEventType,
profileName: this.profileName,
userIds: event.userIds,
...(event.year == null ? {} : { year: event.year }),
...(event.month == null ? {} : { month: event.month }),
occurredAt: event.createdAt.toISOString(),
};
const response = await fetch(`${this.baseUrl}/internal/web-push-events`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-sammo-internal-token': this.token,
},
body: JSON.stringify(envelope),
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) throw new Error(`Gateway web push ingest failed with HTTP ${response.status}.`);
await this.db.webPushOutbox.updateMany({
where: { id: event.id, lockOwner: this.owner },
data: { deliveredAt: new Date(), lockedAt: null, lockOwner: null, lastError: null },
});
} catch (error) {
const attempts = event.attempts;
const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8));
await this.db.webPushOutbox.updateMany({
where: { id: event.id, lockOwner: this.owner },
data: {
availableAt: new Date(Date.now() + delaySeconds * 1_000),
lockedAt: null,
lockOwner: null,
lastError: (error instanceof Error ? error.message : String(error)).slice(0, 500),
},
});
this.onError(error);
}
}
if (Date.now() >= this.nextPruneAt) {
this.nextPruneAt = Date.now() + 60_000;
await this.db.$executeRaw(GamePrisma.sql`
WITH expired AS (
SELECT "id"
FROM "web_push_outbox"
WHERE "delivered_at" < CURRENT_TIMESTAMP - INTERVAL '1 day'
ORDER BY "id"
LIMIT 500
)
DELETE FROM "web_push_outbox"
WHERE "id" IN (SELECT "id" FROM expired)
`);
}
}
private run(): void {
if (this.inFlight) return;
this.inFlight = this.dispatchBatch()
.catch(this.onError)
.finally(() => {
this.inFlight = null;
});
}
start(): void {
if (this.timer) return;
this.timer = setInterval(() => this.run(), this.intervalMs);
this.timer.unref?.();
this.run();
}
wake(): void {
this.run();
}
async stop(): Promise<void> {
if (this.timer) clearInterval(this.timer);
this.timer = null;
await this.inFlight;
}
}
+18 -3
View File
@@ -126,6 +126,7 @@ const buildContext = (options: {
const logCreate = vi.fn(async () => ({}));
const findMany = vi.fn(async () => (target ? [{ id: target.id, name: target.name }] : []));
const inheritanceLogFindMany = vi.fn(async () => options.inheritanceLogs ?? []);
const webPushOutboxCreateMany = vi.fn(async () => ({ count: 1 }));
const activeWorldState =
options.configConst === undefined
? worldState
@@ -167,9 +168,11 @@ const buildContext = (options: {
general?.userId === where.userId ? general : null
),
findMany,
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
target?.id === where.id ? target : null
),
findUnique: vi.fn(async ({ where }: { where: { id: number } }) => {
if (target?.id === where.id) return target;
if (general?.id === where.id) return general;
return null;
}),
},
nation: {
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
@@ -193,6 +196,9 @@ const buildContext = (options: {
findUnique: vi.fn(async () => null),
upsert: vi.fn(async () => ({})),
},
webPushOutbox: {
createMany: webPushOutboxCreateMany,
},
};
const accessTokenStore = new RedisAccessTokenStore(
{
@@ -225,6 +231,7 @@ const buildContext = (options: {
findMany,
inheritanceLogFindMany,
messageRows,
webPushOutboxCreateMany,
changeJournal,
};
};
@@ -613,6 +620,14 @@ describe('inherit router actor and permission boundaries', () => {
}),
}),
]);
expect(fixture.webPushOutboxCreateMany).toHaveBeenNthCalledWith(1, {
data: [{ eventId: 'message:101', eventType: 'PRIVATE_MESSAGE_RECEIVED', userIds: ['user-1'] }],
skipDuplicates: true,
});
expect(fixture.webPushOutboxCreateMany).toHaveBeenNthCalledWith(2, {
data: [{ eventId: 'message:102', eventType: 'PRIVATE_MESSAGE_RECEIVED', userIds: ['user-2'] }],
skipDuplicates: true,
});
expect(fixture.changeJournal.snapshot()).toEqual([
{ domain: 'messages.mailbox', entityId: 7 },
{ domain: 'messages.mailbox', entityId: 8 },
+14
View File
@@ -3,6 +3,8 @@ import {
createGamePostgresConnector,
GamePrisma,
writeReadModelChangeJournal,
enqueuePrivateMessageWebPush,
enqueueWebPushOutboxEvents,
type InputJsonValue,
type ReadModelJournalWriteResult,
type TurnEngineCityUpdateInput,
@@ -50,6 +52,7 @@ import { buildPersistedRankRows } from './rankData.js';
import { persistUnificationFinalization } from './unificationPersistence.js';
import { buildOldNationArchiveData } from './oldNationArchive.js';
import { persistYearbookSnapshot } from './yearbookPersistence.js';
import { buildTurnWebPushEvents, captureWebPushTurnBaseline } from './webPushEvents.js';
export interface DatabaseTurnHooks {
hooks: TurnDaemonHooks;
@@ -1039,6 +1042,7 @@ export const createDatabaseTurnHooks = async (
const readModelBaseline = createRealtimeReadModelBaseline(world);
let worldReadModelBaseline = createWorldReadModelSignature(world);
let persistedTickSeconds = world.getState().tickSeconds;
let webPushTurnBaseline = captureWebPushTurnBaseline(world, options?.reservedTurns);
const committedReceipts = new Map<bigint, CommittedReadModelChangeReceipt>();
const enqueueCommittedReceipt = (
@@ -1109,6 +1113,13 @@ export const createDatabaseTurnHooks = async (
const persistedReservedTurnChanges = reservedTurnChanges
? excludeDeletedReservedTurnQueues(reservedTurnChanges, deletedGenerals, deletedNations)
: undefined;
const nextWebPushTurnBaseline = captureWebPushTurnBaseline(world, options?.reservedTurns);
const webPushEvents = buildTurnWebPushEvents({
before: webPushTurnBaseline,
after: nextWebPushTurnBaseline,
changes,
...(persistedReservedTurnChanges ? { reservedTurnChanges: persistedReservedTurnChanges } : {}),
});
const worldStateUpdate: TurnEngineWorldStateUpdateInput = {
currentYear: state.currentYear,
@@ -1597,6 +1608,7 @@ export const createDatabaseTurnHooks = async (
if (!id) {
throw new Error('Failed to persist turn message.');
}
await enqueuePrivateMessageWebPush(prisma, draft, id);
persistedMessageMailboxes.push(draft.mailbox);
return id;
},
@@ -1657,6 +1669,7 @@ export const createDatabaseTurnHooks = async (
journal.mark('betting');
}
const journalWrite = await writeReadModelChangeJournal(prisma, journal.snapshot());
await enqueueWebPushOutboxEvents(prisma, webPushEvents);
return { readModelChanges, journalWrite, worldReadModelSignature };
};
const persisted = transaction
@@ -1671,6 +1684,7 @@ export const createDatabaseTurnHooks = async (
applyRealtimeReadModelBaseline(readModelBaseline, changes);
worldReadModelBaseline = persisted.worldReadModelSignature;
persistedTickSeconds = state.tickSeconds;
webPushTurnBaseline = nextWebPushTurnBaseline;
},
readModelChanges: persisted.readModelChanges,
journalWrite: persisted.journalWrite,
@@ -189,6 +189,13 @@ export class InMemoryReservedTurnStore {
return this.captureState();
}
inspectGeneralTurnActivity(): Array<[number, boolean]> {
return Array.from(this.generalTurns, ([generalId, turns]) => [
generalId,
turns.some((turn) => turn.action !== DEFAULT_TURN_ACTION || Object.keys(turn.args).length > 0),
]);
}
async loadAll(): Promise<void> {
const [generalRows, nationRows] = await Promise.all([
this.prisma.generalTurn.findMany(),
@@ -1,5 +1,5 @@
import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common';
import { acquireGameSchemaAdvisoryXactLock } from '@sammo-ts/infra';
import { acquireGameSchemaAdvisoryXactLock, enqueuePrivateMessageWebPush } from '@sammo-ts/infra';
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
import { LogCategory, LogScope, sendMessage, type MessageDraft, type MessageRecordDraft } from '@sammo-ts/logic';
@@ -130,6 +130,7 @@ const insertMessage = async (transaction: GamePrisma.TransactionClient, draft: M
`;
const id = rows[0]?.id;
if (!id) throw new Error('Failed to persist unification auction cancellation message.');
await enqueuePrivateMessageWebPush(transaction, draft, id);
return id;
};
+131
View File
@@ -0,0 +1,131 @@
import { asRecord } from '@sammo-ts/common';
import type { WebPushOutboxEventInput } from '@sammo-ts/infra';
import type { InMemoryTurnWorld, TurnWorldChanges } from './inMemoryWorld.js';
import type { InMemoryReservedTurnStore, ReservedTurnChanges } from './reservedTurnStore.js';
interface GeneralNotificationState {
userId: string | null;
nationId: number;
crew: number;
deathCrew: number;
autorunLimit: number | null;
}
export interface WebPushTurnBaseline {
serverId: string;
year: number;
month: number;
turnTick: string;
generals: Map<number, GeneralNotificationState>;
hasReservedTurns: Map<number, boolean>;
}
const readFiniteNumber = (value: unknown): number | null =>
typeof value === 'number' && Number.isFinite(value) ? value : null;
const readDeathCrew = (meta: Record<string, unknown>): number =>
readFiniteNumber(meta.rank_deathcrew) ?? readFiniteNumber(meta.deathcrew) ?? 0;
const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1;
export const captureWebPushTurnBaseline = (
world: InMemoryTurnWorld,
reservedTurns?: InMemoryReservedTurnStore
): WebPushTurnBaseline => {
const state = world.getState();
const meta = asRecord(state.meta);
return {
serverId: typeof meta.serverId === 'string' && meta.serverId ? meta.serverId : 'active-season',
year: state.currentYear,
month: state.currentMonth,
turnTick: String(state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime)),
generals: new Map(
world.listGenerals().map((general) => {
const generalMeta = asRecord(general.meta);
return [
general.id,
{
userId: general.userId ?? null,
nationId: general.nationId,
crew: general.crew,
deathCrew: readDeathCrew(generalMeta),
autorunLimit: readFiniteNumber(generalMeta.autorun_limit),
},
];
})
),
hasReservedTurns: new Map(reservedTurns?.inspectGeneralTurnActivity() ?? []),
};
};
export const buildTurnWebPushEvents = (input: {
before: WebPushTurnBaseline;
after: WebPushTurnBaseline;
changes: Pick<TurnWorldChanges, 'deletedNationSnapshots'>;
reservedTurnChanges?: Pick<ReservedTurnChanges, 'generalIds'>;
}): WebPushOutboxEventInput[] => {
const events: WebPushOutboxEventInput[] = [];
const { before, after } = input;
for (const [generalId, current] of after.generals) {
const previous = before.generals.get(generalId);
if (!previous?.userId || previous.userId !== current.userId) continue;
if (previous.crew > 0 && current.crew <= 0 && current.deathCrew > previous.deathCrew) {
events.push({
eventId: `${after.serverId}:troop-annihilated:${generalId}:${current.deathCrew}`,
eventType: 'TROOP_ANNIHILATED',
userIds: [current.userId],
});
}
}
const dirtyReservedGeneralIds = new Set(input.reservedTurnChanges?.generalIds ?? []);
for (const generalId of dirtyReservedGeneralIds) {
if (!before.hasReservedTurns.get(generalId) || after.hasReservedTurns.get(generalId)) continue;
const userId = after.generals.get(generalId)?.userId ?? before.generals.get(generalId)?.userId;
if (!userId) continue;
events.push({
eventId: `${after.serverId}:reserved-turns-ended:${generalId}:${after.turnTick}`,
eventType: 'RESERVED_TURNS_ENDED',
userIds: [userId],
});
}
const beforeYearMonth = joinYearMonth(before.year, before.month);
const afterYearMonth = joinYearMonth(after.year, after.month);
if (afterYearMonth > beforeYearMonth) {
events.push({
eventId: `${after.serverId}:calendar:${after.year}:${after.month}`,
eventType: 'TARGET_DATE_REACHED',
year: after.year,
month: after.month,
});
for (const [generalId, current] of after.generals) {
const previous = before.generals.get(generalId);
const limit = current.autorunLimit ?? previous?.autorunLimit;
if (!current.userId || limit == null) continue;
if (beforeYearMonth < limit && afterYearMonth >= limit) {
events.push({
eventId: `${after.serverId}:autorun-ended:${generalId}:${limit}`,
eventType: 'AUTONOMOUS_ACTION_ENDED',
userIds: [current.userId],
});
}
}
}
for (const snapshot of input.changes.deletedNationSnapshots) {
const userIds = snapshot.generalIds
.map((generalId) => before.generals.get(generalId)?.userId)
.filter((userId): userId is string => Boolean(userId));
if (userIds.length === 0) continue;
events.push({
eventId: `${after.serverId}:nation-destroyed:${snapshot.nation.id}:${snapshot.removedAt.toISOString()}`,
eventType: 'NATION_DESTROYED',
userIds,
});
}
return events;
};
+116
View File
@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest';
import { buildTurnWebPushEvents, type WebPushTurnBaseline } from '../src/turn/webPushEvents.js';
const general = (
userId: string | null,
options: { nationId?: number; crew?: number; deathCrew?: number; autorunLimit?: number | null } = {}
) => ({
userId,
nationId: options.nationId ?? 1,
crew: options.crew ?? 100,
deathCrew: options.deathCrew ?? 0,
autorunLimit: options.autorunLimit ?? null,
});
const baseline = (input: Partial<WebPushTurnBaseline> = {}): WebPushTurnBaseline => ({
serverId: 'season-1',
year: 200,
month: 1,
turnTick: '100',
generals: new Map(),
hasReservedTurns: new Map(),
...input,
});
describe('turn web push event projection', () => {
it('emits annihilation only when battle casualty totals also increase', () => {
const before = baseline({ generals: new Map([[1, general('user-1', { crew: 500, deathCrew: 10 })]]) });
const battleAfter = baseline({
generals: new Map([[1, general('user-1', { crew: 0, deathCrew: 510 })]]),
turnTick: '101',
});
const disbandAfter = baseline({
generals: new Map([[1, general('user-1', { crew: 0, deathCrew: 10 })]]),
turnTick: '101',
});
expect(buildTurnWebPushEvents({ before, after: battleAfter, changes: { deletedNationSnapshots: [] } })).toEqual(
[expect.objectContaining({ eventType: 'TROOP_ANNIHILATED', userIds: ['user-1'] })]
);
expect(
buildTurnWebPushEvents({ before, after: disbandAfter, changes: { deletedNationSnapshots: [] } })
).toEqual([]);
});
it('emits reserved-turn completion only for a dirty queue that consumed its last command', () => {
const before = baseline({
generals: new Map([[1, general('user-1')]]),
hasReservedTurns: new Map([[1, true]]),
});
const after = baseline({
generals: new Map([[1, general('user-1')]]),
hasReservedTurns: new Map([[1, false]]),
turnTick: '102',
});
const events = buildTurnWebPushEvents({
before,
after,
changes: { deletedNationSnapshots: [] },
reservedTurnChanges: { generalIds: [1] },
});
expect(events).toEqual([expect.objectContaining({ eventType: 'RESERVED_TURNS_ENDED', userIds: ['user-1'] })]);
});
it('emits calendar and autonomous-expiry events at the exclusive limit month', () => {
const before = baseline({
year: 200,
month: 1,
generals: new Map([[1, general('user-1', { autorunLimit: 2401 })]]),
});
const after = baseline({
year: 200,
month: 2,
generals: new Map([[1, general('user-1', { autorunLimit: 2401 })]]),
turnTick: '103',
});
expect(
buildTurnWebPushEvents({ before, after, changes: { deletedNationSnapshots: [] } }).map(
(event) => event.eventType
)
).toEqual(['TARGET_DATE_REACHED', 'AUTONOMOUS_ACTION_ENDED']);
});
it('targets the users who belonged to a destroyed nation before its removal', () => {
const before = baseline({
generals: new Map([
[1, general('user-1', { nationId: 7 })],
[2, general(null, { nationId: 7 })],
[3, general('user-3', { nationId: 7 })],
]),
});
const after = baseline({
generals: new Map([
[1, general('user-1', { nationId: 0 })],
[3, general('user-3', { nationId: 0 })],
]),
turnTick: '104',
});
const events = buildTurnWebPushEvents({
before,
after,
changes: {
deletedNationSnapshots: [
{
nation: { id: 7 },
generalIds: [1, 2, 3],
removedAt: new Date('0200-01-01T00:00:00.000Z'),
} as never,
],
},
});
expect(events).toEqual([
expect.objectContaining({ eventType: 'NATION_DESTROYED', userIds: ['user-1', 'user-3'] }),
]);
});
});
+2
View File
@@ -25,6 +25,7 @@
},
"devDependencies": {
"@types/sanitize-html": "2.16.1",
"@types/web-push": "3.6.4",
"tsdown": "^0.22.14",
"vitest": "^4.1.10"
},
@@ -44,6 +45,7 @@
"redis": "^5.10.0",
"sanitize-html": "2.17.6",
"sharp": "^0.35.0",
"web-push": "3.6.7",
"zod": "^4.3.5"
}
}
+79
View File
@@ -9,6 +9,7 @@ import { procedure, router } from '../trpc.js';
import type { UserRecord, UserSanctions } from '../auth/userRepository.js';
import { openPassword, zPasswordEnvelope } from '../auth/registrationInput.js';
import { resolveEffectiveAccountIcon } from '../auth/accountIconProjection.js';
import { WEB_PUSH_EVENT_TYPES } from '@sammo-ts/common';
const zSessionToken = z.string().min(1);
const MAX_ICON_BYTES = 50 * 1024;
@@ -117,6 +118,84 @@ const publishIconFlush = async (
};
export const accountRouter = router({
notifications: router({
get: procedure
.input(
z.object({
sessionToken: zSessionToken,
currentEndpoint: z.string().url().max(4096).optional(),
})
)
.query(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
return ctx.webPush.getAccountState(user.id, input.currentEndpoint);
}),
setPreference: procedure
.input(
z.object({
sessionToken: zSessionToken,
profileName: z.string().min(1).max(128),
eventType: z.enum(WEB_PUSH_EVENT_TYPES),
enabled: z.boolean(),
targetYear: z.number().int().min(0).max(9999).nullable().optional(),
targetMonth: z.number().int().min(1).max(12).nullable().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
try {
await ctx.webPush.setPreference(user.id, input);
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: error instanceof Error ? error.message : '알림 설정을 저장하지 못했습니다.',
});
}
return { ok: true };
}),
subscribe: procedure
.input(
z.object({
sessionToken: zSessionToken,
subscription: z
.object({
endpoint: z.string().url().max(4096),
expirationTime: z.number().int().positive().nullable(),
keys: z.object({
p256dh: z.string().min(1).max(1024),
auth: z.string().min(1).max(1024),
}),
})
.strict(),
})
)
.mutation(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
try {
const rawUserAgent = ctx.requestHeaders['user-agent'];
const userAgent = Array.isArray(rawUserAgent) ? rawUserAgent[0] : rawUserAgent;
await ctx.webPush.subscribe(user.id, input.subscription, userAgent);
} catch (error) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: error instanceof Error ? error.message : '이 기기의 알림 구독을 저장하지 못했습니다.',
});
}
return { ok: true };
}),
unsubscribe: procedure
.input(
z.object({
sessionToken: zSessionToken,
endpoint: z.string().url().max(4096),
})
)
.mutation(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
await ctx.webPush.unsubscribe(user.id, input.endpoint);
return { ok: true };
}),
}),
get: procedure.input(z.object({ sessionToken: zSessionToken })).query(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
const icons = await ctx.users.listIcons(user.id);
+32
View File
@@ -1,3 +1,4 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { parseBooleanWithFallback, parseNumberWithFallback } from '@sammo-ts/common';
import { resolveFrontendServeMode, type FrontendServeMode } from './orchestrator/frontendArtifactManager.js';
@@ -41,6 +42,11 @@ export interface GatewayApiConfig {
frontendArtifactRoot: string;
frontendReadinessOrigin: string;
releaseBuilderUrl?: string;
webPushEnabled: boolean;
webPushVapidSubject?: string;
webPushVapidPublicKey?: string;
webPushVapidPrivateKey?: string;
webPushPollIntervalMs: number;
}
export interface GatewayOrchestratorConfig {
@@ -82,6 +88,23 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
const redisKeyPrefix = env.GATEWAY_REDIS_PREFIX ?? 'sammo:gateway';
const port = parseNumberWithFallback(env.GATEWAY_API_PORT, 13000, 'GATEWAY_API_PORT');
const workspaceRootHint = env.GATEWAY_WORKSPACE_ROOT ?? process.cwd();
const webPushEnabled = parseBooleanWithFallback(env.WEB_PUSH_ENABLED, false);
const webPushVapidSubject = env.WEB_PUSH_VAPID_SUBJECT?.trim() || undefined;
const webPushVapidPublicKey = env.WEB_PUSH_VAPID_PUBLIC_KEY?.trim() || undefined;
let webPushVapidPrivateKey = env.WEB_PUSH_VAPID_PRIVATE_KEY?.trim() || undefined;
const webPushVapidPrivateKeyFile = env.WEB_PUSH_VAPID_PRIVATE_KEY_FILE?.trim();
if (webPushEnabled && !webPushVapidPrivateKey && webPushVapidPrivateKeyFile) {
try {
webPushVapidPrivateKey = readFileSync(webPushVapidPrivateKeyFile, 'utf8').trim() || undefined;
} catch (error) {
throw new Error('WEB_PUSH_VAPID_PRIVATE_KEY_FILE could not be read.', { cause: error });
}
}
if (webPushEnabled && (!webPushVapidSubject || !webPushVapidPublicKey || !webPushVapidPrivateKey)) {
throw new Error(
'WEB_PUSH_ENABLED requires WEB_PUSH_VAPID_SUBJECT, WEB_PUSH_VAPID_PUBLIC_KEY, and a VAPID private key.'
);
}
return {
host: env.GATEWAY_API_HOST ?? '0.0.0.0',
port,
@@ -149,6 +172,15 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
frontendArtifactRoot: path.resolve(env.FRONTEND_ARTIFACT_ROOT ?? '/srv/frontend-artifacts'),
frontendReadinessOrigin: env.FRONTEND_READINESS_ORIGIN?.trim() || 'http://caddy',
releaseBuilderUrl: env.RELEASE_BUILDER_URL?.trim() || undefined,
webPushEnabled,
webPushVapidSubject,
webPushVapidPublicKey,
webPushVapidPrivateKey,
webPushPollIntervalMs: parseNumberWithFallback(
env.WEB_PUSH_POLL_INTERVAL_MS,
1_000,
'WEB_PUSH_POLL_INTERVAL_MS'
),
};
};
+4
View File
@@ -17,6 +17,7 @@ import { createAdminAuditStore, type AdminAuditStore } from './adminAudit.js';
import type { UserIconUploadStore } from './account/remoteUserIconStore.js';
import path from 'node:path';
import { RuntimeNavigationConfigStore } from './navigation/runtimeNavigationConfig.js';
import { WebPushCoordinator } from './webPush/coordinator.js';
export interface GatewayApiContext {
users: UserRepository;
@@ -44,6 +45,7 @@ export interface GatewayApiContext {
adminAudit: AdminAuditStore;
adminAuth?: AdminAuthContext;
navigationConfig: RuntimeNavigationConfigStore;
webPush: WebPushCoordinator;
}
export const createGatewayApiContext = (options: {
@@ -71,6 +73,7 @@ export const createGatewayApiContext = (options: {
prisma: GatewayPrismaClient;
adminAudit?: AdminAuditStore;
navigationConfig?: RuntimeNavigationConfigStore;
webPush?: WebPushCoordinator;
}): GatewayApiContext => ({
users: options.users,
sessions: options.sessions,
@@ -98,4 +101,5 @@ export const createGatewayApiContext = (options: {
navigationConfig:
options.navigationConfig ??
new RuntimeNavigationConfigStore(null, path.resolve(process.cwd(), 'resources/navigation.json')),
webPush: options.webPush ?? new WebPushCoordinator(options.prisma, { enabled: false }),
});
+18 -1
View File
@@ -33,6 +33,8 @@ import { RemoteUserIconStore } from './account/remoteUserIconStore.js';
import { gatewayFastifyRouterOptions } from './fastifyOptions.js';
import { RuntimeNavigationConfigStore } from './navigation/runtimeNavigationConfig.js';
import { registerRuntimeNavigationRoute } from './navigation/runtimeNavigationRoute.js';
import { WebPushCoordinator } from './webPush/coordinator.js';
import { registerWebPushInternalRoute } from './webPush/internalRoute.js';
export const createGatewayApiServer = async () => {
const config = resolveGatewayApiConfigFromEnv();
@@ -86,11 +88,21 @@ export const createGatewayApiServer = async () => {
config.navigationConfigFile,
config.defaultNavigationConfigFile
);
const app = fastify({
logger: true,
routerOptions: gatewayFastifyRouterOptions,
});
const webPush = new WebPushCoordinator(
postgres.prisma as GatewayPrismaClient,
{
enabled: config.webPushEnabled,
vapidSubject: config.webPushVapidSubject,
vapidPublicKey: config.webPushVapidPublicKey,
vapidPrivateKey: config.webPushVapidPrivateKey,
pollIntervalMs: config.webPushPollIntervalMs,
},
(error) => app.log.error({ err: error }, 'web push delivery failed')
);
await app.register(cors, {
origin: true,
@@ -110,6 +122,7 @@ export const createGatewayApiServer = async () => {
profiles,
secret: config.gameTokenSecret,
});
registerWebPushInternalRoute(app, { secret: config.gameTokenSecret, webPush });
registerRuntimeNavigationRoute(app, navigationConfig);
await app.register(fastifyTRPCPlugin, {
@@ -142,6 +155,7 @@ export const createGatewayApiServer = async () => {
requestHeaders: req.headers,
prisma: postgres.prisma as GatewayPrismaClient,
navigationConfig,
webPush,
}),
},
});
@@ -152,11 +166,14 @@ export const createGatewayApiServer = async () => {
}));
app.addHook('onClose', async () => {
await webPush.stop();
await orchestrator.stop();
await redis.disconnect();
await postgres.disconnect();
});
webPush.start();
return {
app,
config,
+537
View File
@@ -0,0 +1,537 @@
import { randomUUID } from 'node:crypto';
import {
WEB_PUSH_EVENT_TYPES,
isWebPushEventType,
type WebPushClientSubscription,
type WebPushEventEnvelopeV1,
type WebPushEventType,
} from '@sammo-ts/common';
import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
import webPush from 'web-push';
export interface WebPushCoordinatorConfig {
enabled: boolean;
vapidSubject?: string;
vapidPublicKey?: string;
vapidPrivateKey?: string;
pollIntervalMs?: number;
}
type GatewayTransaction = GatewayPrisma.TransactionClient;
const profileWideEvents = new Set<WebPushEventType>([
'PROFILE_PREOPENED',
'PROFILE_OPEN_SCHEDULED',
'PROFILE_OPENED',
'TARGET_DATE_REACHED',
]);
const uniqueUserIds = (values: readonly string[]): string[] => [...new Set(values.filter(Boolean))].sort();
const copyFor = (
eventType: WebPushEventType,
profileLabel: string,
year?: number,
month?: number
): { title: string; body: string } => {
switch (eventType) {
case 'TROOP_ANNIHILATED':
return { title: '병력 전멸', body: `${profileLabel}에서 내 병력이 전멸했습니다.` };
case 'PRIVATE_MESSAGE_RECEIVED':
return { title: '새 개인 서신', body: `${profileLabel}에 새 개인 서신이 도착했습니다.` };
case 'AUTONOMOUS_ACTION_ENDED':
return { title: '자율행동 종료', body: `${profileLabel}의 자율행동 기간이 끝났습니다.` };
case 'RESERVED_TURNS_ENDED':
return { title: '예턴 종료', body: `${profileLabel}에서 등록한 예턴이 모두 실행되었습니다.` };
case 'PROFILE_PREOPENED':
return { title: '서버 가오픈', body: `${profileLabel} 서버가 가오픈되었습니다.` };
case 'PROFILE_OPEN_SCHEDULED':
return { title: '서버 오픈 예약', body: `${profileLabel} 서버의 오픈 시간이 예약되었습니다.` };
case 'PROFILE_OPENED':
return { title: '서버 오픈', body: `${profileLabel} 서버가 오픈되었습니다.` };
case 'NATION_DESTROYED':
return { title: '국가 멸망', body: `${profileLabel}에서 내 국가가 멸망했습니다.` };
case 'TARGET_DATE_REACHED':
return {
title: '설정 연월 도달',
body:
year !== undefined && month !== undefined
? `${profileLabel}${year}${month}월에 도달했습니다.`
: `${profileLabel}이 설정한 연월에 도달했습니다.`,
};
}
};
const isConfigured = (config: WebPushCoordinatorConfig): boolean =>
Boolean(config.enabled && config.vapidSubject && config.vapidPublicKey && config.vapidPrivateKey);
export class WebPushCoordinator {
private readonly configured: boolean;
private readonly owner = `gateway-web-push:${process.pid}:${randomUUID()}`;
private readonly pollIntervalMs: number;
private timer: NodeJS.Timeout | null = null;
private inFlight: Promise<void> | null = null;
private nextProfileReconcileAt = 0;
private nextPruneAt = 0;
constructor(
private readonly prisma: GatewayPrismaClient,
private readonly config: WebPushCoordinatorConfig,
private readonly onError: (error: unknown) => void = () => undefined
) {
this.configured = isConfigured(config);
this.pollIntervalMs = Math.max(250, Math.floor(config.pollIntervalMs ?? 1_000));
if (this.configured) {
webPush.setVapidDetails(config.vapidSubject!, config.vapidPublicKey!, config.vapidPrivateKey!);
}
}
getCapability(): { enabled: boolean; publicKey: string | null } {
return {
enabled: this.configured,
publicKey: this.configured ? this.config.vapidPublicKey! : null,
};
}
async getAccountState(userId: string, currentEndpoint?: string) {
const now = new Date();
const activeSubscriptionWhere: GatewayPrisma.WebPushSubscriptionWhereInput = {
userId,
disabledAt: null,
OR: [{ expirationTime: null }, { expirationTime: { gt: now } }],
};
const [profiles, preferences, subscriptionCount, currentSubscription] = await Promise.all([
this.prisma.gatewayProfile.findMany({
orderBy: [{ profile: 'asc' }, { instanceKey: 'asc' }],
select: { profileName: true, profile: true, currentScenario: true, status: true },
}),
this.prisma.webPushPreference.findMany({
where: { userId },
select: {
profileName: true,
eventType: true,
enabled: true,
targetYear: true,
targetMonth: true,
},
}),
this.prisma.webPushSubscription.count({ where: activeSubscriptionWhere }),
currentEndpoint
? this.prisma.webPushSubscription.findFirst({
where: { ...activeSubscriptionWhere, endpoint: currentEndpoint },
select: { id: true },
})
: Promise.resolve(null),
]);
return {
capability: this.getCapability(),
eventTypes: WEB_PUSH_EVENT_TYPES,
profiles: profiles.map((profile) => ({
...profile,
status: String(profile.status),
})),
preferences: preferences.filter((preference) => isWebPushEventType(preference.eventType)),
subscriptionCount,
currentDeviceSubscribed: Boolean(currentSubscription),
};
}
async setPreference(
userId: string,
input: {
profileName: string;
eventType: WebPushEventType;
enabled: boolean;
targetYear?: number | null;
targetMonth?: number | null;
}
): Promise<void> {
const profile = await this.prisma.gatewayProfile.findUnique({
where: { profileName: input.profileName },
select: { profileName: true },
});
if (!profile) throw new Error('알림 대상 서버를 찾을 수 없습니다.');
const isTargetDate = input.eventType === 'TARGET_DATE_REACHED';
if (isTargetDate && input.enabled && (input.targetYear == null || input.targetMonth == null)) {
throw new Error('도달 알림의 연도와 월을 입력해 주세요.');
}
await this.prisma.webPushPreference.upsert({
where: {
userId_profileName_eventType: {
userId,
profileName: input.profileName,
eventType: input.eventType,
},
},
create: {
userId,
profileName: input.profileName,
eventType: input.eventType,
enabled: input.enabled,
targetYear: isTargetDate ? (input.targetYear ?? null) : null,
targetMonth: isTargetDate ? (input.targetMonth ?? null) : null,
},
update: {
enabled: input.enabled,
targetYear: isTargetDate ? (input.targetYear ?? null) : null,
targetMonth: isTargetDate ? (input.targetMonth ?? null) : null,
},
});
}
async subscribe(userId: string, subscription: WebPushClientSubscription, userAgent?: string): Promise<void> {
if (!this.configured) throw new Error('웹 알림 전송이 아직 활성화되지 않았습니다.');
const endpointUrl = new URL(subscription.endpoint);
if (endpointUrl.protocol !== 'https:') throw new Error('보안 연결의 Push 구독만 저장할 수 있습니다.');
const expirationTime = subscription.expirationTime ? new Date(subscription.expirationTime) : null;
await this.prisma.webPushSubscription.upsert({
where: { endpoint: subscription.endpoint },
create: {
userId,
endpoint: subscription.endpoint,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
expirationTime,
userAgent: userAgent?.slice(0, 500),
},
update: {
userId,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
expirationTime,
userAgent: userAgent?.slice(0, 500),
disabledAt: null,
lastSeenAt: new Date(),
},
});
}
async unsubscribe(userId: string, endpoint: string): Promise<void> {
await this.prisma.webPushSubscription.updateMany({
where: { userId, endpoint },
data: { disabledAt: new Date() },
});
}
private async enqueueEventTx(tx: GatewayTransaction, event: WebPushEventEnvelopeV1): Promise<boolean> {
if (!this.configured) return false;
const profile = await tx.gatewayProfile.findUnique({
where: { profileName: event.profileName },
select: { profile: true, profileName: true },
});
if (!profile) return false;
const receipt = await tx.webPushEventReceipt.createMany({
data: [{ eventId: event.eventId, profileName: event.profileName, eventType: event.eventType }],
skipDuplicates: true,
});
if (receipt.count === 0) return false;
const preferenceWhere: GatewayPrisma.WebPushPreferenceWhereInput = {
profileName: event.profileName,
eventType: event.eventType,
enabled: true,
...(event.eventType === 'TARGET_DATE_REACHED'
? { targetYear: event.year, targetMonth: event.month }
: profileWideEvents.has(event.eventType)
? {}
: { userId: { in: uniqueUserIds(event.userIds) } }),
};
const preferences = await tx.webPushPreference.findMany({
where: preferenceWhere,
select: { userId: true },
});
const selectedUserIds = uniqueUserIds(preferences.map((preference) => preference.userId));
if (selectedUserIds.length === 0) return true;
const subscriptions = await tx.webPushSubscription.findMany({
where: {
userId: { in: selectedUserIds },
disabledAt: null,
OR: [{ expirationTime: null }, { expirationTime: { gt: new Date() } }],
},
select: { id: true, userId: true },
});
const subscriptionIdsByUser = new Map<string, string[]>();
for (const subscription of subscriptions) {
const ids = subscriptionIdsByUser.get(subscription.userId) ?? [];
ids.push(subscription.id);
subscriptionIdsByUser.set(subscription.userId, ids);
}
const copy = copyFor(event.eventType, profile.profile, event.year, event.month);
for (const userId of selectedUserIds) {
const subscriptionIds = subscriptionIdsByUser.get(userId) ?? [];
if (subscriptionIds.length === 0) continue;
const dedupeKey = `${event.eventId}:${userId}`;
const notification = await tx.webPushNotification.upsert({
where: { dedupeKey },
create: {
dedupeKey,
userId,
profileName: event.profileName,
eventType: event.eventType,
title: copy.title,
body: copy.body,
url: `/${encodeURIComponent(profile.profile)}/`,
tag: `sammo-${event.profileName}-${event.eventType}`,
},
update: {},
select: { id: true },
});
await tx.webPushDelivery.createMany({
data: subscriptionIds.map((subscriptionId) => ({
notificationId: notification.id,
subscriptionId,
})),
skipDuplicates: true,
});
}
return true;
}
async ingest(event: WebPushEventEnvelopeV1): Promise<{ queued: boolean }> {
if (!this.configured) return { queued: false };
const queued = await this.prisma.$transaction((tx) => this.enqueueEventTx(tx, event));
this.wake();
return { queued };
}
async reconcileProfiles(now = new Date()): Promise<void> {
const profiles = await this.prisma.gatewayProfile.findMany({
select: {
profileName: true,
status: true,
preopenAt: true,
openAt: true,
updatedAt: true,
},
});
for (const profile of profiles) {
await this.prisma.$transaction(async (tx) => {
const previous = await tx.webPushProfileCursor.findUnique({
where: { profileName: profile.profileName },
});
await tx.webPushProfileCursor.upsert({
where: { profileName: profile.profileName },
create: {
profileName: profile.profileName,
status: String(profile.status),
preopenAt: profile.preopenAt,
openAt: profile.openAt,
},
update: {
status: String(profile.status),
preopenAt: profile.preopenAt,
openAt: profile.openAt,
},
});
if (!previous || !this.configured) return;
const events: WebPushEventEnvelopeV1[] = [];
const eventBase = `gateway:${profile.profileName}:${profile.updatedAt.toISOString()}`;
if (previous.status !== String(profile.status) && profile.status === 'PREOPEN') {
events.push({
version: 1,
eventId: `${eventBase}:preopen`,
eventType: 'PROFILE_PREOPENED',
profileName: profile.profileName,
userIds: [],
occurredAt: now.toISOString(),
});
}
if (previous.status !== String(profile.status) && profile.status === 'RUNNING') {
events.push({
version: 1,
eventId: `${eventBase}:opened`,
eventType: 'PROFILE_OPENED',
profileName: profile.profileName,
userIds: [],
occurredAt: now.toISOString(),
});
}
if (
profile.openAt &&
profile.openAt.getTime() > now.getTime() &&
previous.openAt?.getTime() !== profile.openAt.getTime()
) {
events.push({
version: 1,
eventId: `${eventBase}:open-scheduled:${profile.openAt.toISOString()}`,
eventType: 'PROFILE_OPEN_SCHEDULED',
profileName: profile.profileName,
userIds: [],
occurredAt: now.toISOString(),
});
}
for (const event of events) await this.enqueueEventTx(tx, event);
});
}
this.wake();
}
private async dispatchBatch(): Promise<void> {
if (!this.configured) return;
const claimed = await this.prisma.$transaction(async (tx) => {
const rows = await tx.$queryRaw<Array<{ id: bigint }>>(GatewayPrisma.sql`
SELECT "id"
FROM "web_push_delivery"
WHERE "status" = 'PENDING'
AND "available_at" <= CURRENT_TIMESTAMP
AND ("locked_at" IS NULL OR "locked_at" <= CURRENT_TIMESTAMP - INTERVAL '30 seconds')
ORDER BY "id"
FOR UPDATE SKIP LOCKED
LIMIT 25
`);
if (rows.length === 0) return [];
const ids = rows.map((row) => row.id);
await tx.webPushDelivery.updateMany({
where: { id: { in: ids } },
data: { lockedAt: new Date(), lockOwner: this.owner, attempts: { increment: 1 } },
});
return tx.webPushDelivery.findMany({
where: { id: { in: ids }, lockOwner: this.owner },
include: { notification: true, subscription: true },
orderBy: { id: 'asc' },
});
});
for (const delivery of claimed) {
if (
delivery.subscription.expirationTime &&
delivery.subscription.expirationTime.getTime() <= Date.now()
) {
await this.prisma.$transaction(async (tx) => {
await tx.webPushDelivery.updateMany({
where: { id: delivery.id, lockOwner: this.owner },
data: {
status: 'FAILED',
lockedAt: null,
lockOwner: null,
lastError: 'Push subscription expired.',
},
});
await tx.webPushSubscription.update({
where: { id: delivery.subscriptionId },
data: { disabledAt: new Date() },
});
});
continue;
}
try {
await webPush.sendNotification(
{
endpoint: delivery.subscription.endpoint,
keys: { p256dh: delivery.subscription.p256dh, auth: delivery.subscription.auth },
},
JSON.stringify({
title: delivery.notification.title,
body: delivery.notification.body,
url: delivery.notification.url,
tag: delivery.notification.tag,
}),
{ TTL: 60 * 60 }
);
await this.prisma.webPushDelivery.updateMany({
where: { id: delivery.id, lockOwner: this.owner },
data: {
status: 'DELIVERED',
deliveredAt: new Date(),
lockedAt: null,
lockOwner: null,
lastError: null,
},
});
} catch (error) {
const statusCode =
typeof error === 'object' && error !== null && 'statusCode' in error
? Number((error as { statusCode?: unknown }).statusCode)
: 0;
const terminal = statusCode === 404 || statusCode === 410 || (statusCode >= 400 && statusCode < 500 && statusCode !== 429);
const attempts = delivery.attempts;
const exhausted = attempts >= 8;
const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8));
const safeError = statusCode > 0 ? `Push service returned HTTP ${statusCode}.` : 'Push service request failed.';
await this.prisma.$transaction(async (tx) => {
await tx.webPushDelivery.updateMany({
where: { id: delivery.id, lockOwner: this.owner },
data: {
status: terminal || exhausted ? 'FAILED' : 'PENDING',
availableAt: new Date(Date.now() + delaySeconds * 1_000),
lockedAt: null,
lockOwner: null,
lastError: safeError,
},
});
if (statusCode === 404 || statusCode === 410) {
await tx.webPushSubscription.update({
where: { id: delivery.subscriptionId },
data: { disabledAt: new Date() },
});
}
});
if (!terminal) this.onError(new Error(safeError));
}
}
if (Date.now() >= this.nextPruneAt) {
this.nextPruneAt = Date.now() + 60_000;
await this.prisma.$transaction(async (tx) => {
await tx.$executeRaw(GatewayPrisma.sql`
WITH expired AS (
SELECT "event_id"
FROM "web_push_event_receipt"
WHERE "created_at" < CURRENT_TIMESTAMP - INTERVAL '30 days'
ORDER BY "created_at"
LIMIT 500
)
DELETE FROM "web_push_event_receipt"
WHERE "event_id" IN (SELECT "event_id" FROM expired)
`);
await tx.$executeRaw(GatewayPrisma.sql`
WITH expired AS (
SELECT notification."id"
FROM "web_push_notification" AS notification
WHERE notification."created_at" < CURRENT_TIMESTAMP - INTERVAL '30 days'
AND NOT EXISTS (
SELECT 1 FROM "web_push_delivery" AS delivery
WHERE delivery."notification_id" = notification."id"
AND delivery."status" = 'PENDING'
)
ORDER BY notification."created_at"
LIMIT 500
)
DELETE FROM "web_push_notification"
WHERE "id" IN (SELECT "id" FROM expired)
`);
});
}
}
private run(): void {
if (!this.configured || this.inFlight) return;
const now = Date.now();
const shouldReconcileProfiles = now >= this.nextProfileReconcileAt;
if (shouldReconcileProfiles) this.nextProfileReconcileAt = now + 5_000;
this.inFlight = (shouldReconcileProfiles ? this.reconcileProfiles() : Promise.resolve())
.then(() => this.dispatchBatch())
.catch(this.onError)
.finally(() => {
this.inFlight = null;
});
}
start(): void {
if (!this.configured || this.timer) return;
this.timer = setInterval(() => this.run(), this.pollIntervalMs);
this.timer.unref?.();
this.run();
}
wake(): void {
this.run();
}
async stop(): Promise<void> {
if (this.timer) clearInterval(this.timer);
this.timer = null;
await this.inFlight;
}
}
@@ -0,0 +1,54 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
import { WEB_PUSH_EVENT_TYPES, type WebPushEventEnvelopeV1 } from '@sammo-ts/common';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { WebPushCoordinator } from './coordinator.js';
const INTERNAL_TOKEN_HEADER = 'x-sammo-internal-token';
const INTERNAL_TOKEN_CONTEXT = 'sammo:web-push-event-ingest:v1';
const zEnvelope = z
.object({
version: z.literal(1),
eventId: z.string().min(1).max(500),
eventType: z.enum(WEB_PUSH_EVENT_TYPES),
profileName: z.string().min(1).max(128),
userIds: z.array(z.string().uuid()).max(5_000),
year: z.number().int().min(0).max(9999).optional(),
month: z.number().int().min(1).max(12).optional(),
occurredAt: z.iso.datetime(),
})
.strict();
export const deriveWebPushIngestToken = (secret: string): string =>
createHmac('sha256', secret).update(INTERNAL_TOKEN_CONTEXT).digest('hex');
const matchesSecret = (provided: string | string[] | undefined, expected: string): boolean => {
const candidate = Array.isArray(provided) ? provided[0] : provided;
if (!candidate) return false;
const candidateBuffer = Buffer.from(candidate);
const expectedBuffer = Buffer.from(expected);
return candidateBuffer.length === expectedBuffer.length && timingSafeEqual(candidateBuffer, expectedBuffer);
};
export const registerWebPushInternalRoute = (
app: FastifyInstance,
options: { secret: string; webPush: WebPushCoordinator }
): void => {
app.post('/internal/web-push-events', async (request, reply) => {
void reply.header('Cache-Control', 'no-store');
if (!matchesSecret(request.headers[INTERNAL_TOKEN_HEADER], deriveWebPushIngestToken(options.secret))) {
await reply.status(401).send({ ok: false, error: 'unauthorized' });
return;
}
const parsed = zEnvelope.safeParse(request.body);
if (!parsed.success) {
await reply.status(400).send({ ok: false, error: 'invalid_event' });
return;
}
const result = await options.webPush.ingest(parsed.data as WebPushEventEnvelopeV1);
await reply.send({ ok: true, queued: result.queued });
});
};
+2 -2
View File
@@ -38,8 +38,8 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20260821173000_gateway_release_instant_timestamps',
gameSchemaHead: '20260820002000_persist_official_game_index',
gatewaySchemaHead: '20260823010000_add_web_push_notifications',
gameSchemaHead: '20260823010000_add_web_push_outbox',
});
});
@@ -0,0 +1,59 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { resolveGatewayApiConfigFromEnv } from '../src/config.js';
const requiredEnv = {
GAME_TOKEN_SECRET: 'test-game-token-secret',
KAKAO_REST_KEY: 'test-kakao-key',
KAKAO_REDIRECT_URI: 'https://gateway.test.invalid/gateway/oauth/callback',
};
const tempDirectories: string[] = [];
afterEach(() => {
for (const directory of tempDirectories.splice(0)) rmSync(directory, { recursive: true, force: true });
});
describe('resolveGatewayApiConfigFromEnv web push', () => {
it('stays disabled without reading a configured private-key file', () => {
const config = resolveGatewayApiConfigFromEnv({
...requiredEnv,
WEB_PUSH_ENABLED: 'false',
WEB_PUSH_VAPID_PRIVATE_KEY_FILE: '/does/not/exist',
});
expect(config.webPushEnabled).toBe(false);
expect(config.webPushVapidPrivateKey).toBeUndefined();
});
it('reads the VAPID private key from a file only when enabled', () => {
const directory = mkdtempSync(path.join(tmpdir(), 'sammo-web-push-config-'));
tempDirectories.push(directory);
const privateKeyFile = path.join(directory, 'vapid-private-key');
writeFileSync(privateKeyFile, 'test-private-key\n', { mode: 0o600 });
const config = resolveGatewayApiConfigFromEnv({
...requiredEnv,
WEB_PUSH_ENABLED: 'true',
WEB_PUSH_VAPID_SUBJECT: 'mailto:admin@test.invalid',
WEB_PUSH_VAPID_PUBLIC_KEY: 'test-public-key',
WEB_PUSH_VAPID_PRIVATE_KEY_FILE: privateKeyFile,
});
expect(config.webPushEnabled).toBe(true);
expect(config.webPushVapidPrivateKey).toBe('test-private-key');
});
it('fails closed when activation is incomplete', () => {
expect(() =>
resolveGatewayApiConfigFromEnv({
...requiredEnv,
WEB_PUSH_ENABLED: 'true',
})
).toThrow(/WEB_PUSH_ENABLED requires/u);
});
});
@@ -0,0 +1,169 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import webPush from 'web-push';
import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra';
import { WebPushCoordinator } from '../src/webPush/coordinator.js';
const databaseUrl = process.env.WEB_PUSH_GATEWAY_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const schema = process.env.WEB_PUSH_GATEWAY_INTEGRATION_SCHEMA;
const userId = '8c770c8d-3515-4f6c-8a54-5f17330d9f66';
const profileName = 'hwe:web-push-integration';
const assertDedicatedSchema = (): void => {
const actual = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null;
if (!schema?.endsWith('_web_push_integration') || actual !== schema) {
throw new Error('Refusing to mutate a Gateway database outside the web-push integration schema.');
}
};
integration('web push Gateway persistence boundary', () => {
let db: GatewayPrismaClient;
let closeDb: (() => Promise<void>) | undefined;
let coordinator: WebPushCoordinator;
beforeAll(async () => {
assertDedicatedSchema();
const connector = createGatewayPostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
await db.webPushEventReceipt.deleteMany({ where: { profileName } });
await db.webPushProfileCursor.deleteMany({ where: { profileName } });
await db.gatewayProfile.deleteMany({ where: { profileName } });
await db.appUser.deleteMany({ where: { id: userId } });
await db.appUser.create({
data: {
id: userId,
loginId: 'web-push-integration',
displayName: '웹 푸시 통합',
passwordHash: 'not-used',
passwordSalt: 'not-used',
roles: ['user'],
sanctions: {},
},
});
await db.gatewayProfile.create({
data: {
profileName,
profile: 'hwe',
instanceKey: 'web-push-integration',
currentScenario: 'default',
scenario: 'default',
apiPort: 15015,
status: 'RESERVED',
},
});
await db.webPushSubscription.create({
data: {
userId,
endpoint: 'https://push.example.invalid/subscription/integration',
p256dh: 'public-key-placeholder',
auth: 'auth-placeholder',
},
});
const vapid = webPush.generateVAPIDKeys();
coordinator = new WebPushCoordinator(db, {
enabled: true,
vapidSubject: 'mailto:web-push-test@example.invalid',
vapidPublicKey: vapid.publicKey,
vapidPrivateKey: vapid.privateKey,
});
});
afterAll(async () => {
if (db) {
await db.webPushEventReceipt.deleteMany({ where: { profileName } });
await db.webPushProfileCursor.deleteMany({ where: { profileName } });
await db.gatewayProfile.deleteMany({ where: { profileName } });
await db.appUser.deleteMany({ where: { id: userId } });
}
await closeDb?.();
});
it('fans out an enabled private-message event once without persisting its content', async () => {
await coordinator.setPreference(userId, {
profileName,
eventType: 'PRIVATE_MESSAGE_RECEIVED',
enabled: true,
});
const event = {
version: 1 as const,
eventId: 'integration:private-message:1',
eventType: 'PRIVATE_MESSAGE_RECEIVED' as const,
profileName,
userIds: [userId],
occurredAt: '2026-08-23T00:00:00.000Z',
};
await expect(coordinator.ingest(event)).resolves.toEqual({ queued: true });
await expect(coordinator.ingest(event)).resolves.toEqual({ queued: false });
const notifications = await db.webPushNotification.findMany({
where: { profileName, eventType: 'PRIVATE_MESSAGE_RECEIVED' },
include: { deliveries: true },
});
expect(notifications).toHaveLength(1);
expect(notifications[0]).toMatchObject({
userId,
title: '새 개인 서신',
url: '/hwe/',
deliveries: [expect.objectContaining({ status: 'PENDING' })],
});
expect(
JSON.stringify(notifications.map(({ title, body, url, tag }) => ({ title, body, url, tag })))
).not.toContain('private message content');
});
it('matches a target-date preference and records profile lifecycle transitions', async () => {
await coordinator.setPreference(userId, {
profileName,
eventType: 'TARGET_DATE_REACHED',
enabled: true,
targetYear: 201,
targetMonth: 3,
});
await coordinator.setPreference(userId, {
profileName,
eventType: 'PROFILE_PREOPENED',
enabled: true,
});
await coordinator.ingest({
version: 1,
eventId: 'integration:calendar:201:3',
eventType: 'TARGET_DATE_REACHED',
profileName,
userIds: [],
year: 201,
month: 3,
occurredAt: '2026-08-23T00:00:00.000Z',
});
await coordinator.reconcileProfiles(new Date('2026-08-23T00:00:00.000Z'));
await db.gatewayProfile.update({ where: { profileName }, data: { status: 'PREOPEN' } });
await coordinator.reconcileProfiles(new Date('2026-08-23T00:01:00.000Z'));
const rows = await db.webPushNotification.findMany({
where: { profileName, eventType: { in: ['TARGET_DATE_REACHED', 'PROFILE_PREOPENED'] } },
orderBy: { eventType: 'asc' },
});
expect(rows.map((row) => row.eventType).sort()).toEqual(['PROFILE_PREOPENED', 'TARGET_DATE_REACHED']);
expect(rows.find((row) => row.eventType === 'TARGET_DATE_REACHED')?.body).toContain('201년 3월');
});
it('drops events while globally disabled instead of creating a future backlog', async () => {
const disabled = new WebPushCoordinator(db, { enabled: false });
await expect(
disabled.ingest({
version: 1,
eventId: 'integration:disabled:1',
eventType: 'PRIVATE_MESSAGE_RECEIVED',
profileName,
userIds: [userId],
occurredAt: '2026-08-23T00:00:00.000Z',
})
).resolves.toEqual({ queued: false });
await expect(
db.webPushEventReceipt.findUnique({ where: { eventId: 'integration:disabled:1' } })
).resolves.toBeNull();
});
});
@@ -0,0 +1,63 @@
import fastify from 'fastify';
import { describe, expect, it, vi } from 'vitest';
import type { WebPushCoordinator } from '../src/webPush/coordinator.js';
import { deriveWebPushIngestToken, registerWebPushInternalRoute } from '../src/webPush/internalRoute.js';
const secret = 'web-push-route-test-secret';
const userId = '11111111-1111-4111-8111-111111111111';
const event = {
version: 1,
eventId: 'game:hwe:default:message:42',
eventType: 'PRIVATE_MESSAGE_RECEIVED',
profileName: 'hwe:default',
userIds: [userId],
occurredAt: '2026-08-23T00:00:00.000Z',
};
describe('web push internal event route', () => {
it('accepts the strict privacy-safe envelope with a purpose-derived token', async () => {
const app = fastify();
const ingest = vi.fn().mockResolvedValue({ queued: true });
registerWebPushInternalRoute(app, {
secret,
webPush: { ingest } as unknown as WebPushCoordinator,
});
const unauthorized = await app.inject({
method: 'POST',
url: '/internal/web-push-events',
headers: { 'x-sammo-internal-token': secret },
payload: event,
});
expect(unauthorized.statusCode).toBe(401);
const response = await app.inject({
method: 'POST',
url: '/internal/web-push-events',
headers: { 'x-sammo-internal-token': deriveWebPushIngestToken(secret) },
payload: event,
});
expect(response.statusCode).toBe(200);
expect(response.headers['cache-control']).toBe('no-store');
expect(response.json()).toEqual({ ok: true, queued: true });
expect(ingest).toHaveBeenCalledWith(event);
});
it('rejects payload extensions such as private message text', async () => {
const app = fastify();
const ingest = vi.fn();
registerWebPushInternalRoute(app, {
secret,
webPush: { ingest } as unknown as WebPushCoordinator,
});
const response = await app.inject({
method: 'POST',
url: '/internal/web-push-events',
headers: { 'x-sammo-internal-token': deriveWebPushIngestToken(secret) },
payload: { ...event, message: 'private message content' },
});
expect(response.statusCode).toBe(400);
expect(ingest).not.toHaveBeenCalled();
});
});
@@ -111,6 +111,26 @@ const installFixture = async (page: Page, options: FixtureOptions = {}) => {
deleteAfter: null,
});
}
if (operation === 'account.notifications.get') {
return response({
capability: { enabled: false, publicKey: null },
eventTypes: [
'TROOP_ANNIHILATED',
'PRIVATE_MESSAGE_RECEIVED',
'AUTONOMOUS_ACTION_ENDED',
'RESERVED_TURNS_ENDED',
'PROFILE_PREOPENED',
'PROFILE_OPEN_SCHEDULED',
'PROFILE_OPENED',
'NATION_DESTROYED',
'TARGET_DATE_REACHED',
],
profiles: [],
preferences: [],
subscriptionCount: 0,
currentDeviceSubscribed: false,
});
}
if (operation === 'account.changeIcon') {
return response({
ok: true,
@@ -15,6 +15,7 @@ export default defineConfig({
'lobby-game-auth.spec.ts',
'logout.spec.ts',
'account-icon-sync.spec.ts',
'web-push-settings.spec.ts',
'legacy-log-html.spec.ts',
'gateway-notice-html.spec.ts',
'kakao-otp.spec.ts',
@@ -40,8 +41,7 @@ export default defineConfig({
screenshot: 'only-on-failure',
},
webServer: {
command:
`export VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc VITE_GAME_WEB_URL_TEMPLATE='/{profile}/' VITE_GAME_API_URL_TEMPLATE='/{profile}/api/trpc'; pnpm --filter @sammo-ts/gateway-frontend build && pnpm --filter @sammo-ts/gateway-frontend preview --host 127.0.0.1 --port ${port}`,
command: `export VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc VITE_GAME_WEB_URL_TEMPLATE='/{profile}/' VITE_GAME_API_URL_TEMPLATE='/{profile}/api/trpc'; pnpm --filter @sammo-ts/gateway-frontend build && pnpm --filter @sammo-ts/gateway-frontend preview --host 127.0.0.1 --port ${port}`,
cwd: repositoryRoot,
url: `http://127.0.0.1:${port}/gateway/`,
reuseExistingServer: false,
@@ -0,0 +1,155 @@
import { expect, test, type Page, type Route } from '@playwright/test';
const response = (data: unknown) => ({ result: { data } });
const operationNames = (route: Route): string[] => {
const url = new URL(route.request().url());
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
};
const inputAt = (route: Route, index: number): Record<string, unknown> => {
const body = JSON.parse(route.request().postData() ?? '{}') as Record<
string,
{ json?: Record<string, unknown> } | Record<string, unknown>
>;
const input = body[String(index)] ?? ({} as Record<string, unknown>);
return ('json' in input && input.json ? input.json : input) as Record<string, unknown>;
};
const installFixture = async (page: Page) => {
const saved: Record<string, unknown>[] = [];
await page.addInitScript(() => {
window.localStorage.setItem('sammo-session-token', 'web-push-session');
});
await page.route('**/gateway/api/trpc/**', async (route) => {
const results = operationNames(route).map((operation, index) => {
if (operation === 'account.get') {
return response({
id: '11111111-1111-4111-8111-111111111111',
username: 'push-user',
displayName: '알림 사용자',
roles: ['user'],
oauthType: 'NONE',
createdAt: '2026-08-23T00:00:00.000Z',
iconUrl: null,
icons: [],
preferredPicture: 'default.jpg',
maxActiveIcons: 5,
nextUploadAt: null,
nextRetireAt: null,
thirdPartyUse: false,
deleteAfter: null,
});
}
if (operation === 'account.notifications.get') {
return response({
capability: { enabled: false, publicKey: null },
eventTypes: [
'TROOP_ANNIHILATED',
'PRIVATE_MESSAGE_RECEIVED',
'AUTONOMOUS_ACTION_ENDED',
'RESERVED_TURNS_ENDED',
'PROFILE_PREOPENED',
'PROFILE_OPEN_SCHEDULED',
'PROFILE_OPENED',
'NATION_DESTROYED',
'TARGET_DATE_REACHED',
],
profiles: [
{
profileName: 'hwe:default',
profile: 'hwe',
currentScenario: 'default',
status: 'RUNNING',
},
],
preferences: [],
subscriptionCount: 0,
currentDeviceSubscribed: false,
});
}
if (operation === 'account.notifications.setPreference') {
saved.push(inputAt(route, index));
return response({ ok: true });
}
throw new Error(`Unhandled gateway tRPC operation: ${operation}`);
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(results),
});
});
return saved;
};
test('web push settings are default-off and remain configurable while delivery is disabled', async ({
page,
}, testInfo) => {
const saved = await installFixture(page);
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/gateway/account');
const table = page.locator('#notification-table');
await expect(table).toBeVisible();
await expect(table).toContainText('준비됨 · 운영 비활성');
await expect(page.getByRole('button', { name: '이 기기 알림 켜기' })).toBeDisabled();
const checkboxes = table.getByRole('checkbox');
await expect(checkboxes).toHaveCount(9);
for (let index = 0; index < 9; index += 1) await expect(checkboxes.nth(index)).not.toBeChecked();
await table.getByRole('checkbox', { name: '알림 받기' }).nth(1).check();
await expect.poll(() => saved.length).toBe(1);
expect(saved[0]).toMatchObject({
profileName: 'hwe:default',
eventType: 'PRIVATE_MESSAGE_RECEIVED',
enabled: true,
});
const profileSelect = table.locator('select');
await profileSelect.focus();
expect(await profileSelect.evaluate((element) => getComputedStyle(element).outlineStyle)).not.toBe('none');
const bounds = await table.boundingBox();
expect(bounds?.width).toBe(550);
const serviceWorkerScope = await page.evaluate(async () => (await navigator.serviceWorker.ready).scope);
expect(serviceWorkerScope).toBe('http://127.0.0.1:15130/gateway/');
const pwaAssets = await page.evaluate(async () => {
const [manifest, worker] = await Promise.all([fetch('/gateway/manifest.webmanifest'), fetch('/gateway/sw.js')]);
return {
manifestStatus: manifest.status,
manifestType: manifest.headers.get('content-type'),
manifestBody: await manifest.json(),
workerStatus: worker.status,
workerType: worker.headers.get('content-type'),
};
});
expect(pwaAssets).toMatchObject({
manifestStatus: 200,
manifestBody: { start_url: './', scope: './', display: 'standalone' },
workerStatus: 200,
});
expect(pwaAssets.manifestType).toContain('application/manifest+json');
expect(pwaAssets.workerType).toContain('javascript');
await page.screenshot({ path: testInfo.outputPath('web-push-settings-desktop.png'), fullPage: true });
});
test('web push settings fit a mobile Chromium viewport and show the iPhone install prerequisite', async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.addInitScript(() => {
Object.defineProperty(navigator, 'userAgent', {
value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148',
configurable: true,
});
});
await installFixture(page);
await page.goto('/gateway/account');
const table = page.locator('#notification-table');
await expect(table).toContainText('홈 화면에 추가');
const bounds = await table.boundingBox();
expect(bounds?.x).toBe(0);
expect(bounds?.width).toBeLessThanOrEqual(390);
await page.screenshot({ path: testInfo.outputPath('web-push-settings-mobile.png'), fullPage: true });
});
+4
View File
@@ -4,6 +4,10 @@
<meta charset="UTF-8" />
<meta name="color-scheme" content="dark" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#172a52" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="삼모" />
<link rel="manifest" href="%BASE_URL%manifest.webmanifest" />
<title>삼국지 모의전투 HiDCHe - Gateway</title>
</head>
<body class="bg-black text-white">
@@ -0,0 +1,20 @@
{
"name": "삼국지 모의전투 HiDCHe",
"short_name": "삼모",
"description": "삼국지 모의전투 Gateway",
"lang": "ko",
"id": "./",
"start_url": "./",
"scope": "./",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#172a52",
"icons": [
{
"src": "web-push-icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}
+50
View File
@@ -0,0 +1,50 @@
const ALLOWED_NOTIFICATION_PATH = /^\/(?:gateway|che|hwe|kwe|pwe|twe|nya|pya)(?:\/|$)/u;
const safeNotificationUrl = (value) => {
try {
const url = new URL(typeof value === 'string' ? value : '/gateway/', self.location.origin);
if (url.origin !== self.location.origin || !ALLOWED_NOTIFICATION_PATH.test(url.pathname)) {
return '/gateway/';
}
return `${url.pathname}${url.search}${url.hash}`;
} catch {
return '/gateway/';
}
};
self.addEventListener('push', (event) => {
let payload;
try {
payload = event.data?.json() ?? {};
} catch {
payload = {};
}
const title = typeof payload.title === 'string' ? payload.title : '삼국지 모의전투';
const body = typeof payload.body === 'string' ? payload.body : '새 알림이 있습니다.';
const tag = typeof payload.tag === 'string' ? payload.tag : 'sammo-notification';
event.waitUntil(
self.registration.showNotification(title, {
body,
tag,
icon: './web-push-icon.svg',
badge: './web-push-icon.svg',
data: { url: safeNotificationUrl(payload.url) },
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const targetPath = safeNotificationUrl(event.notification.data?.url);
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(async (clients) => {
const targetUrl = new URL(targetPath, self.location.origin).href;
for (const client of clients) {
if (new URL(client.url).origin !== self.location.origin) continue;
await client.navigate(targetUrl);
return client.focus();
}
return self.clients.openWindow(targetUrl);
})
);
});
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192" role="img" aria-label="삼모">
<rect width="192" height="192" rx="32" fill="#172a52"/>
<rect x="12" y="12" width="168" height="168" rx="24" fill="none" stroke="#d6b25e" stroke-width="8"/>
<text x="96" y="119" fill="#fff" font-family="serif" font-size="72" font-weight="700" text-anchor="middle">삼모</text>
</svg>

After

Width:  |  Height:  |  Size: 385 B

+8
View File
@@ -13,3 +13,11 @@ app.use(createPinia());
app.use(router);
app.mount('#app');
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
void navigator.serviceWorker.register(`${import.meta.env.BASE_URL}sw.js`, {
scope: import.meta.env.BASE_URL,
});
});
}
@@ -8,10 +8,19 @@ import DefaultLayout from '../layouts/DefaultLayout.vue';
import { createGameTrpc } from '../utils/gameTrpc';
import { trpc } from '../utils/trpc';
import { sealPassword } from '../utils/passwordEnvelope';
import type { WebPushEventType } from '@sammo-ts/common';
type Account = Awaited<ReturnType<typeof trpc.account.get.query>>;
type IconSyncProfile = Awaited<ReturnType<typeof trpc.account.changeIcon.mutate>>['profiles'][number];
type IconSyncState = 'idle' | 'pending' | 'success' | 'error';
type NotificationState = Awaited<ReturnType<typeof trpc.account.notifications.get.query>>;
type LocalNotificationPreference = {
profileName: string;
eventType: WebPushEventType;
enabled: boolean;
targetYear: number | null;
targetMonth: number | null;
};
type IconSyncRow = IconSyncProfile & {
selected: boolean;
state: IconSyncState;
@@ -40,12 +49,204 @@ const iconServerStaticFeedback = ref(false);
const iconServerMessage = ref('');
const iconServerRows = ref<IconSyncRow[]>([]);
const iconServerDialog = ref<HTMLElement | null>(null);
const notificationState = ref<NotificationState | null>(null);
const notificationPreferences = ref<LocalNotificationPreference[]>([]);
const selectedNotificationProfile = ref('');
const notificationBusy = ref(false);
const notificationPermission = ref<NotificationPermission | 'unsupported'>('default');
const currentPushSubscription = ref<PushSubscription | null>(null);
let iconServerReturnFocus: HTMLElement | null = null;
let previousBodyOverflow = '';
let iconServerStaticTimer: ReturnType<typeof setTimeout> | null = null;
const notificationLabels: Record<WebPushEventType, string> = {
TROOP_ANNIHILATED: '내 병력 전멸',
PRIVATE_MESSAGE_RECEIVED: '개인 메시지 수신',
AUTONOMOUS_ACTION_ENDED: '자율행동 종료',
RESERVED_TURNS_ENDED: '예턴 종료',
PROFILE_PREOPENED: '서버 가오픈',
PROFILE_OPEN_SCHEDULED: '서버 오픈 예약',
PROFILE_OPENED: '서버 오픈',
NATION_DESTROYED: '내 국가 멸망',
TARGET_DATE_REACHED: '특정 연월 도달',
};
const supportsWebPush = computed(
() => 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
);
const isIos = computed(() => /iPad|iPhone|iPod/u.test(navigator.userAgent));
const isStandalone = computed(
() =>
window.matchMedia('(display-mode: standalone)').matches ||
Boolean((navigator as Navigator & { standalone?: boolean }).standalone)
);
const currentProfile = computed(() =>
notificationState.value?.profiles.find((profile) => profile.profileName === selectedNotificationProfile.value)
);
const notificationEventTypes = computed(
() => (notificationState.value?.eventTypes ?? []) as readonly WebPushEventType[]
);
const sessionToken = (): string | null => window.localStorage.getItem('sammo-session-token');
const ensureNotificationPreference = (eventType: WebPushEventType): LocalNotificationPreference => {
const profileName = selectedNotificationProfile.value;
let preference = notificationPreferences.value.find(
(candidate) => candidate.profileName === profileName && candidate.eventType === eventType
);
if (!preference) {
preference = {
profileName,
eventType,
enabled: false,
targetYear: null,
targetMonth: null,
};
notificationPreferences.value.push(preference);
}
return preference;
};
const loadNotificationSettings = async (): Promise<void> => {
const token = sessionToken();
if (!token) return;
let endpoint: string | undefined;
if (supportsWebPush.value) {
notificationPermission.value = Notification.permission;
const registration = await navigator.serviceWorker.getRegistration(import.meta.env.BASE_URL);
currentPushSubscription.value = (await registration?.pushManager.getSubscription()) ?? null;
endpoint = currentPushSubscription.value?.endpoint;
} else {
notificationPermission.value = 'unsupported';
}
const state = await trpc.account.notifications.get.query({
sessionToken: token,
...(endpoint ? { currentEndpoint: endpoint } : {}),
});
notificationState.value = state;
notificationPreferences.value = state.preferences.map((preference) => ({
profileName: preference.profileName,
eventType: preference.eventType as WebPushEventType,
enabled: preference.enabled,
targetYear: preference.targetYear,
targetMonth: preference.targetMonth,
}));
if (
!selectedNotificationProfile.value ||
!state.profiles.some((profile) => profile.profileName === selectedNotificationProfile.value)
) {
selectedNotificationProfile.value = state.profiles[0]?.profileName ?? '';
}
};
const base64UrlToUint8Array = (value: string): Uint8Array<ArrayBuffer> => {
const padding = '='.repeat((4 - (value.length % 4)) % 4);
const raw = window.atob((value + padding).replace(/-/gu, '+').replace(/_/gu, '/'));
const result = new Uint8Array(new ArrayBuffer(raw.length));
for (let index = 0; index < raw.length; index += 1) result[index] = raw.charCodeAt(index);
return result;
};
const subscribeCurrentDevice = async (): Promise<void> => {
if (notificationBusy.value || !notificationState.value?.capability.enabled) return;
notificationBusy.value = true;
errorMessage.value = '';
try {
const token = sessionToken();
const publicKey = notificationState.value.capability.publicKey;
if (!token || !publicKey) throw new Error('웹 알림 전송이 아직 활성화되지 않았습니다.');
if (!supportsWebPush.value) throw new Error('이 브라우저는 Web Push를 지원하지 않습니다.');
if (isIos.value && !isStandalone.value) {
throw new Error('iPhone에서는 Safari의 공유 메뉴에서 홈 화면에 추가한 뒤 그 아이콘으로 열어 주세요.');
}
const permission = await Notification.requestPermission();
notificationPermission.value = permission;
if (permission !== 'granted') throw new Error('브라우저 알림 권한이 허용되지 않았습니다.');
const registration = await navigator.serviceWorker.ready;
const subscription =
(await registration.pushManager.getSubscription()) ??
(await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: base64UrlToUint8Array(publicKey),
}));
const json = subscription.toJSON();
if (!json.endpoint || !json.keys?.p256dh || !json.keys.auth) {
throw new Error('브라우저가 올바른 Push 구독 정보를 반환하지 않았습니다.');
}
await trpc.account.notifications.subscribe.mutate({
sessionToken: token,
subscription: {
endpoint: json.endpoint,
expirationTime: subscription.expirationTime,
keys: { p256dh: json.keys.p256dh, auth: json.keys.auth },
},
});
currentPushSubscription.value = subscription;
notificationState.value = {
...notificationState.value,
currentDeviceSubscribed: true,
subscriptionCount: notificationState.value.subscriptionCount + 1,
};
successMessage.value = '이 기기의 웹 알림을 등록했습니다.';
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '이 기기의 웹 알림을 등록하지 못했습니다.';
} finally {
notificationBusy.value = false;
}
};
const unsubscribeCurrentDevice = async (): Promise<void> => {
if (notificationBusy.value || !currentPushSubscription.value) return;
notificationBusy.value = true;
errorMessage.value = '';
try {
const token = sessionToken();
if (!token) throw new Error('로그인이 필요합니다.');
const endpoint = currentPushSubscription.value.endpoint;
await trpc.account.notifications.unsubscribe.mutate({ sessionToken: token, endpoint });
await currentPushSubscription.value.unsubscribe();
currentPushSubscription.value = null;
if (notificationState.value) {
notificationState.value = {
...notificationState.value,
currentDeviceSubscribed: false,
subscriptionCount: Math.max(0, notificationState.value.subscriptionCount - 1),
};
}
successMessage.value = '이 기기의 웹 알림을 해제했습니다.';
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '이 기기의 웹 알림을 해제하지 못했습니다.';
} finally {
notificationBusy.value = false;
}
};
const saveNotificationPreference = async (eventType: WebPushEventType, revertToggle = false): Promise<void> => {
if (notificationBusy.value || !selectedNotificationProfile.value) return;
const preference = ensureNotificationPreference(eventType);
const previousEnabled = revertToggle ? !preference.enabled : preference.enabled;
notificationBusy.value = true;
errorMessage.value = '';
try {
const token = sessionToken();
if (!token) throw new Error('로그인이 필요합니다.');
await trpc.account.notifications.setPreference.mutate({
sessionToken: token,
profileName: selectedNotificationProfile.value,
eventType,
enabled: preference.enabled,
targetYear: preference.targetYear,
targetMonth: preference.targetMonth,
});
successMessage.value = `${notificationLabels[eventType]} 알림 설정을 저장했습니다.`;
} catch (error) {
preference.enabled = previousEnabled;
errorMessage.value = error instanceof Error ? error.message : '알림 설정을 저장하지 못했습니다.';
} finally {
notificationBusy.value = false;
}
};
const gradeLabel = computed(() => {
if (!account.value) return '-';
if (account.value.roles.some((role) => role.includes('admin') || role === 'superuser')) return '관리자';
@@ -79,6 +280,7 @@ const loadAccount = async (): Promise<void> => {
}
try {
account.value = await trpc.account.get.query({ sessionToken: token });
await loadNotificationSettings();
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '계정 정보를 불러오지 못했습니다.';
} finally {
@@ -590,6 +792,117 @@ onBeforeUnmount(() => {
</tr>
</tfoot>
</table>
<table v-if="notificationState" id="notification-table" class="legacy-bg0">
<thead>
<tr>
<th colspan="2" class="legacy-bg1"> 알림 설정</th>
</tr>
</thead>
<tbody>
<tr>
<th class="legacy-bg1 notification-label">전송 상태</th>
<td class="notification-copy">
<strong>{{
notificationState.capability.enabled ? '사용 가능' : '준비됨 · 운영 비활성'
}}</strong>
<p v-if="!notificationState.capability.enabled">
전송 기능은 아직 운영 설정에서 꺼져 있습니다. 개별 설정은 저장되지만 실제 알림은
발송되지 않습니다.
</p>
<p v-else>
권한: {{ notificationPermission }} · 등록 기기
{{ notificationState.subscriptionCount }}
</p>
<p v-if="isIos && !isStandalone">
iPhone은 Safari 공유 메뉴의 화면에 추가 , 설치된 아이콘으로 열어야 알림을
있습니다.
</p>
<button
v-if="currentPushSubscription"
class="skin-button"
type="button"
:disabled="notificationBusy"
@click="unsubscribeCurrentDevice"
>
기기 알림 해제
</button>
<button
v-else
class="skin-button"
type="button"
:disabled="
notificationBusy || !notificationState.capability.enabled || !supportsWebPush
"
@click="subscribeCurrentDevice"
>
기기 알림 켜기
</button>
</td>
</tr>
<tr>
<th class="legacy-bg1 notification-label">서버</th>
<td>
<select
v-model="selectedNotificationProfile"
class="skin-input notification-profile-select"
>
<option
v-for="profile in notificationState.profiles"
:key="profile.profileName"
:value="profile.profileName"
>
{{ profile.profile }} · {{ profile.currentScenario ?? profile.profileName }}
</option>
</select>
<span v-if="currentProfile" class="notification-profile-status">{{
currentProfile.status
}}</span>
</td>
</tr>
<tr v-for="eventType in notificationEventTypes" :key="eventType">
<th class="legacy-bg1 notification-label">{{ notificationLabels[eventType] }}</th>
<td class="notification-preference">
<label>
<input
v-model="ensureNotificationPreference(eventType).enabled"
type="checkbox"
:disabled="notificationBusy || !selectedNotificationProfile"
@change="saveNotificationPreference(eventType, true)"
/>
알림 받기
</label>
<span v-if="eventType === 'TARGET_DATE_REACHED'" class="target-date-fields">
<input
v-model.number="ensureNotificationPreference(eventType).targetYear"
class="skin-input target-year"
type="number"
min="0"
max="9999"
aria-label="목표 연도"
@change="
ensureNotificationPreference(eventType).enabled &&
saveNotificationPreference(eventType)
"
/>
<input
v-model.number="ensureNotificationPreference(eventType).targetMonth"
class="skin-input target-month"
type="number"
min="1"
max="12"
aria-label="목표 "
@change="
ensureNotificationPreference(eventType).enabled &&
saveNotificationPreference(eventType)
"
/>
</span>
</td>
</tr>
</tbody>
</table>
<p v-if="successMessage" class="feedback success" role="status">{{ successMessage }}</p>
<p v-if="errorMessage" class="feedback error" role="alert">{{ errorMessage }}</p>
</div>
@@ -717,6 +1030,65 @@ onBeforeUnmount(() => {
text-align: center;
}
#notification-table {
width: 100%;
margin-top: 16px;
border: 1px solid gray;
border-spacing: 0;
table-layout: fixed;
}
#notification-table th,
#notification-table td {
border: 1px solid;
border-color: gray #000 #000 gray;
padding: 6px;
}
.notification-label {
width: 150px;
text-align: center;
}
.notification-copy {
text-align: left;
}
.notification-copy p {
margin: 4px 0;
color: #ddd;
line-height: 1.4;
}
.notification-profile-select {
width: min(310px, calc(100% - 80px));
min-height: 28px;
}
.notification-profile-status {
margin-left: 8px;
color: #bbb;
}
.notification-preference {
text-align: left;
}
.target-date-fields {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 18px;
}
.target-year {
width: 72px;
}
.target-month {
width: 48px;
}
.legacy-bg0 {
background-color: #302016;
background-image: var(--sammo-texture-walnut, url('https://sam-image.hided.net/game/back_walnut.jpg'));
@@ -1138,6 +1510,20 @@ onBeforeUnmount(() => {
margin-left: 0;
}
#notification-table {
width: 100vw;
max-width: 100vw;
}
.notification-label {
width: 118px;
}
.target-date-fields {
flex-wrap: wrap;
margin: 6px 0 0;
}
.icon-server-backdrop {
padding-right: 8px;
padding-left: 8px;