2356 lines
107 KiB
Markdown
2356 lines
107 KiB
Markdown
# Realtime events, Web Push, and bounded polling
|
|
|
|
> **정본 안내 (non-authoritative for runtime capability decisions)**
|
|
>
|
|
> Runtime Config/boot, Fetch HTTP client, Router, Query/Mutation, realtime 공통 경계, Web Worker,
|
|
> Service Worker, offline command와 Background Sync의 구현 결정은
|
|
> [프론트엔드 런타임 Capability 저장소 정합형 구현 결정 폐쇄 상세 설계](./2026-07-30-frontend-runtime-capability-repository-aligned-implementation-closed-deep-design.md)가 정본이다.
|
|
> 본 문서의 해당 서술이 정본과 충돌하면 정본을 따른다.
|
|
|
|
|
|
이 문서는 SSE, WebSocket, Web Push와 제한된 Polling을 frontend에 도입할 때의
|
|
선택 기준, 포트·어댑터 경계, event 계약, 재연결·복구, 인증, 브라우저 lifecycle,
|
|
관측성과 promotion 조건을 정의한다.
|
|
|
|
이 네 기술은 모두 “새 정보가 도착했음을 frontend에 알린다”는 점만 비슷하다.
|
|
연결 방향, 실행 가능한 browser 상태, 전달 보장과 실패 복구가 다르므로 하나의
|
|
범용 realtime transport로 합치지 않는다.
|
|
|
|
현재 저장소에는 production에서 제외된 RT-01~RT-04 reference runtime과
|
|
deterministic contract/fault test가 있다. transport-independent event authority,
|
|
bounded reconnect owner, single-writer live↔Poll handoff, fetch-stream SSE,
|
|
bounded Polling, closed WebSocket protocol, Web Push window/Service Worker
|
|
adapter까지 `AVAILABLE_NOT_COMPOSED`이지만 제품별 endpoint,
|
|
event/query/notification registry, backend replay/provider protocol과 production
|
|
composition은 선택하지 않았다. 따라서 reference source가 있다는 사실은
|
|
capability가 설치됐거나 production-ready라는 뜻이 아니다.
|
|
|
|
상세 결정은
|
|
[VD-28](./decisions/VD-28-realtime-events-web-push-and-bounded-polling.md)이
|
|
소유한다. 공통 cache와 account scope는
|
|
[Client cache and browser storage platform](./client-cache-and-storage.md),
|
|
선택 capability의 설치·제거 절차는
|
|
[Optional frontend adapter recipes](./optional-adapter-recipes.md), 계층 방향은
|
|
[Frontend ports, adapters, and boundaries](./frontend-ports-adapters-and-boundaries.md)
|
|
를 따른다.
|
|
|
|
## 0. 현재 상태와 목표 delta
|
|
|
|
이 문서에서 설계 승인, reference source 존재, production 조합과 target browser의
|
|
보장은 서로 다른 사실이다. primary current status는 기존 browser capability
|
|
문서와 같은 다섯 literal만 사용한다.
|
|
|
|
| 상태 | 의미 |
|
|
| --- | --- |
|
|
| `COMPOSED` | production bootstrap 또는 설치된 feature 경로가 concrete runtime을 생성하고 소비한다. |
|
|
| `AVAILABLE_NOT_COMPOSED` | reusable runtime과 deterministic/native test가 있으나 production graph에는 없다. |
|
|
| `DESIGNED_NOT_IMPLEMENTED` | 경계와 불변조건은 승인됐지만 필요한 실행 source 또는 orchestration이 없다. |
|
|
| `NOT_SELECTED` | 제품 요구, owner, backend 계약과 운영 정책이 승인되지 않았다. |
|
|
| `PLATFORM_LIMITED` | browser/platform이 요구한 의미를 공통으로 보장할 수 없다. |
|
|
|
|
현재 catalog의 `RECIPE_AVAILABLE`은 위 runtime 상태와 다른 축이다. realtime
|
|
row의 `referenceRuntime.status=AVAILABLE_NOT_COMPOSED`가 reusable source의
|
|
존재를 별도로 기록하며, 제품 선택이나 production 조합을 뜻하지 않는다.
|
|
|
|
| capability | primary current status | 현재 있는 것 | 목표 또는 잔여 |
|
|
| --- | --- | --- | --- |
|
|
| common event/recovery runtime | `AVAILABLE_NOT_COMPOSED` | scope/generation fence, effect-after-checkpoint, exact recovery barrier와 deterministic fault test | 제품 stream/event/effect/recovery registry |
|
|
| reconnect와 live↔Poll ownership | `AVAILABLE_NOT_COMPOSED` | finite full-jitter reconnect owner, authoritative close classification, single-writer handoff와 bounded quiescence/checkpoint | 제품 lifecycle/fallback policy와 transport composition |
|
|
| SSE reference runtime | `AVAILABLE_NOT_COMPOSED` | bounded fetch-stream adapter/parser, fixed same-origin endpoint, resume/heartbeat/reconnect와 deterministic fault test | actual local streaming server와 target-browser evidence |
|
|
| WebSocket reference runtime | `AVAILABLE_NOT_COMPOSED` | exact `realtime.v1` protocol, bounded queue/rate/buffer, resume/recovery와 deterministic facade test | actual local server load와 target-browser evidence |
|
|
| Web Push subscription/window runtime | `AVAILABLE_NOT_COMPOSED` | permission/subscription control, backend registration/revoke facade와 durable association fence | actual provider, permission UX와 target-browser evidence |
|
|
| Service Worker push inbound runtime | `AVAILABLE_NOT_COMPOSED` | strict hint/click codec, notification/route registry와 uncomposed worker runtime factory | selected worker composition과 provider/browser evidence |
|
|
| bounded polling coordinator | `AVAILABLE_NOT_COMPOSED` | single-flight, visible/online finite lease, elapsed/attempt budget와 terminal stop test | 제품 operation registry와 freshness policy |
|
|
| 제품 realtime delivery | `NOT_SELECTED` | owner, endpoint, event registry, SLO가 없음 | 측정된 freshness 요구와 backend/provider 계약 승인 후 선택 |
|
|
| 제품 Web Push | `NOT_SELECTED` | permission copy, subscription owner, notification policy가 없음 | user-visible background notification 요구가 승인될 때 별도 선택 |
|
|
| exactly-once delivery | `PLATFORM_LIMITED` | 없음 | 공통 목표로 선언하지 않음; duplicate-tolerant 처리와 authoritative resync 사용 |
|
|
| global event ordering | `PLATFORM_LIMITED` | 없음 | V1은 logical stream-local ordering만 계약 |
|
|
| always-on background SSE/WebSocket/Polling | `PLATFORM_LIMITED` | 없음 | hidden/frozen/terminated browser 상태에서 공통 보장하지 않음 |
|
|
| timely cross-browser Web Push | `PLATFORM_LIMITED` | 없음 | best-effort notification과 foreground authoritative refresh만 제공 |
|
|
|
|
별도로 realtime catalog row 자체는 `RECIPE_AVAILABLE`이다. 이것은 primary
|
|
runtime status가 아니다. 기존 copyable `RealtimePort`를 production wire
|
|
authority나 generic mega port로 확장하지 않았고, reference runtime의 공개 계약은
|
|
`RealtimeEventAuthority`, `WebPushControlPort`와 transport-specific factory로
|
|
나뉜다. 이 source는 production graph 밖에 있다.
|
|
|
|
설계 승인 직후 canonical readiness 기본값은 다음과 같다.
|
|
|
|
```text
|
|
Selection = NOT_SELECTED
|
|
TrafficAdmission = DISABLED
|
|
RuntimeHealth = UNKNOWN
|
|
PromotionEvidence = MISSING
|
|
```
|
|
|
|
`PromotionEvidence`는 문서가 존재한다는 이유로 `PARTIAL`이나 `COMPLETE`가 되지
|
|
않는다. 실제 frontend contract, backend/provider conformance, target-browser
|
|
evidence와 operations drill이 있어야 별도로 승격한다.
|
|
|
|
현재 recipe 계약의 `channel: string`, `sequence: number`, 단일 `resumeToken`,
|
|
callback과 application-facing `heartbeat()`는 production wire authority가 아니다.
|
|
scope/epoch, closed event type, gap, byte limit, 진행되는 cursor, connection
|
|
generation과 effect certainty가 없기 때문이다. 선택 시 recipe를 그대로 import하지
|
|
않고 이 문서와 실제 backend protocol에 맞게 더 좁은 계약으로 이동한다.
|
|
|
|
이 문서의 최상위 불변조건은 다음과 같다.
|
|
|
|
> 연결이 열려 있음, event 수신, Web Push 수신 또는 Polling 성공은 최신 상태,
|
|
> authorization, server commit이나 정확히 한 번의 처리를 각각 보장하지 않는다.
|
|
> 일반 server state의 source of truth는 계속 서버이며 gap과 불확실성은
|
|
> authoritative snapshot으로 복구한다.
|
|
|
|
## 1. 변경할 수 없는 설계 결정
|
|
|
|
### 1.1 하나의 범용 `RealtimePort`로 합치지 않는다
|
|
|
|
```text
|
|
foreground one-way notification
|
|
-> SSE connection owner
|
|
-> validated event inbound adapter
|
|
|
|
foreground duplex interaction
|
|
-> WebSocket protocol owner
|
|
-> bounded send/receive coordinators
|
|
-> validated event/ack inbound adapters
|
|
|
|
inactive-browser notification
|
|
-> backend Web Push provider
|
|
-> browser push service
|
|
-> Service Worker inbound adapter
|
|
-> notification or bounded foreground handoff
|
|
|
|
relaxed freshness or explicit fallback
|
|
-> bounded polling scheduling policy
|
|
-> existing HTTP/query gateway
|
|
```
|
|
|
|
`transport: "SSE" | "WEBSOCKET" | "PUSH" | "POLLING"` 하나를 받는 facade는
|
|
다음 차이를 숨긴다.
|
|
|
|
- SSE는 active document의 server-to-client UTF-8 stream이다.
|
|
- WebSocket은 application message를 양방향으로 교환하지만 browser API가
|
|
incoming backpressure를 제공하지 않는다.
|
|
- Web Push는 push service와 Service Worker, 사용자 permission, server-side
|
|
subscription registry가 필요한 background notification capability다.
|
|
- Polling은 새 network transport가 아니라 기존 HTTP operation을 실행하는
|
|
제한된 scheduling policy다.
|
|
- SSE/WS의 cursor와 Web Push subscription endpoint는 수명·민감도·authority가
|
|
다르다.
|
|
- `unsubscribe`, socket close, push subscription revoke와 poll lease 종료는
|
|
같은 effect가 아니다.
|
|
|
|
공통으로 재사용할 수 있는 것은 closed event envelope 검증, scope generation
|
|
fence, dedupe/gap detection, backoff 계산, redacted observation과 authoritative
|
|
resync orchestration이다. native constructor와 lifecycle owner는 분리한다.
|
|
|
|
### 1.2 source of truth와 delivery 의미
|
|
|
|
- 일반 server entity/collection의 source of truth는 서버다.
|
|
- SSE/WebSocket event의 기본 역할은 “어떤 namespace가 바뀌었을 수 있다”는
|
|
versioned hint다.
|
|
- event payload를 TanStack Query cache나 domain entity에 자동으로 덮어쓰지
|
|
않는다. 기본 효과는 validated event → registered invalidation topic →
|
|
authorization을 다시 통과한 HTTP refetch다.
|
|
- 제품이 authoritative delta 적용을 선택하려면 server revision, base revision,
|
|
atomic commit point, gap recovery와 conflict semantics를 event type별로
|
|
증명해야 한다.
|
|
- Web Push payload는 사용자에게 알릴 작은 opaque hint다. live state delta,
|
|
ordered stream, read receipt 또는 background synchronization authority가 아니다.
|
|
- Polling의 `200`/`304`는 해당 HTTP representation의 결과다. 다른 stream의
|
|
event 처리나 background notification 성공을 증명하지 않는다.
|
|
- `CURSOR` profile의 resume cursor는 replay 위치다. credential, authorization proof, entity revision,
|
|
event ID 또는 global ordering과 동일시하지 않는다.
|
|
- transport가 확인한 delivery는 application effect commit을 뜻하지 않는다.
|
|
|
|
browser 종료, replay retention 만료, `SNAPSHOT_ONLY` stream과 Push
|
|
expiry/permission/provider 상태까지 포함한 공통 delivery 보장은 없다. 다만
|
|
retention 안의 `CURSOR` foreground profile에서 server가 전달·replay한 event의
|
|
처리 모델은 duplicate-tolerant at-least-once다. application 효과는
|
|
idempotent하거나 authoritative refetch로 수렴해야 한다. 그 밖의 profile은
|
|
best-effort이며 snapshot만 최신 상태의 authority다. exactly-once는 공통 목표가
|
|
아니다.
|
|
|
|
### 1.3 inbound와 outbound 경계를 분리한다
|
|
|
|
연결을 만들고 닫는 책임은 outbound infrastructure다.
|
|
|
|
- fixed endpoint 선택
|
|
- session credential 협력
|
|
- connect, subscribe, resume와 reconnect
|
|
- WebSocket control frame과 선택된 typed command 송신
|
|
- push subscription 생성·backend 등록·해제
|
|
- poll request scheduling과 cancellation
|
|
|
|
외부 event를 application 의도로 바꾸는 책임은 inbound adapter다.
|
|
|
|
- raw bytes/frame의 hard cap
|
|
- UTF-8/JSON/wire schema/version 검증
|
|
- scope, stream epoch, event type와 generation 확인
|
|
- duplicate/out-of-order/gap 처리
|
|
- feature event input 또는 query invalidation effect로 mapping
|
|
- 처리 결과 뒤 cursor/ack commit
|
|
|
|
물리적으로 하나의 runtime factory가 두 역할을 조립할 수는 있지만 public
|
|
interface와 module dependency는 분리한다. application/domain에는 `EventSource`,
|
|
`WebSocket`, `MessageEvent`, `PushSubscription`, `ServiceWorkerRegistration`,
|
|
native `Notification`, URL/header, TanStack 또는 vendor SDK type을 노출하지
|
|
않는다.
|
|
|
|
WebSocket send가 필요해도 `send(payload: unknown)`을 application port로 만들지
|
|
않는다. `PublishPresence`, `AcknowledgeAssignment`처럼 실제 업무 capability와
|
|
closed payload를 feature가 소유한다. durable mutation은 별도 선택이 없으면
|
|
기존 HTTP/idempotency 경로를 유지한다.
|
|
|
|
### 1.4 공통 mechanism과 제품 정책을 분리한다
|
|
|
|
공통 runtime이 소유한다.
|
|
|
|
- strict protocol/envelope parser와 byte/depth/count ceiling
|
|
- immutable config snapshot과 fixed endpoint lookup
|
|
- scope/generation fence
|
|
- sequential apply queue, bounded dedupe와 gap detection
|
|
- cursor commit ordering과 reset orchestration
|
|
- backoff/jitter, retry budget와 timer cleanup
|
|
- online/visibility/page lifecycle adapter
|
|
- native exception과 close reason redaction
|
|
- terminal/idempotent `close()`/`dispose()`
|
|
|
|
composition 또는 feature owner가 결정한다.
|
|
|
|
- freshness SLO와 transport 선택
|
|
- event stream/topic/type registry
|
|
- event payload codec와 application effect
|
|
- account/tenant/session scope projection
|
|
- replay retention, snapshot endpoint와 cursor reset UX
|
|
- event rate/size, connection/subscription와 polling budget
|
|
- hidden 상태의 grace/close 정책
|
|
- push permission copy, notification content와 retention
|
|
- backend/provider, rollout과 kill switch
|
|
|
|
backend/provider가 소유한다.
|
|
|
|
- connection, subscription과 각 command의 authorization
|
|
- commit 이후 event publication과 replay ledger
|
|
- stream epoch, sequence, cursor retention과 reset response
|
|
- SSE proxy flush/idle contract
|
|
- WebSocket upgrade, connection/rate/backpressure limit
|
|
- push subscription registry, encryption, VAPID private key와 delivery cleanup
|
|
- polling snapshot/ETag/rate-limit semantics
|
|
|
|
제품 owner와 protocol authority가 없는 skeleton은 임의 topic, endpoint,
|
|
notification text나 poll interval을 자동 등록하지 않는다.
|
|
|
|
## 2. Transport 선택
|
|
|
|
### 2.1 선택표
|
|
|
|
| 요구 | 우선 후보 | 선택 조건 | 선택하지 않는 조건 |
|
|
| --- | --- | --- | --- |
|
|
| active document에서 server → client event | SSE | one-way text event, replay cursor, HTTP streaming/hosting owner | client message, binary, arbitrary header/handshake가 핵심 |
|
|
| active document의 진짜 duplex interaction | WebSocket | presence/collaboration/interactive command, subprotocol·resume·queue owner | server notification만 필요하거나 HTTP command가 충분 |
|
|
| inactive/terminated document에 user-visible notification | Web Push | 명시적 permission UX, Service Worker, backend subscription/provider owner | live UI, ordered delta, 보장된 즉시성 또는 silent sync가 목적 |
|
|
| relaxed freshness 또는 stream fallback | bounded polling | snapshot/conditional endpoint, finite lease와 request budget | 짧은 주기로 push를 흉내 내거나 hidden 상태에서 계속 실행 |
|
|
| focus/reconnect 시 최신화면 충분 | 기존 Query refetch | freshness SLO를 만족 | 추가 transport를 설치할 이유가 없음 |
|
|
| 사용자가 직접 갱신해도 충분 | manual refresh | stale 상태를 명확히 표시 가능 | 자동 background work가 불필요 |
|
|
|
|
SSE를 지원하지 않는다는 이유만으로 WebSocket으로 자동 전환하지 않는다.
|
|
WebSocket duplex protocol을 Polling으로 조용히 축소하지도 않는다. fallback은
|
|
같은 사용자 의미와 server authority를 보존할 때 registry에 명시한 한 경로만
|
|
선택한다.
|
|
|
|
Web Push는 foreground transport의 fallback이 아니다. active document가 없을 때
|
|
사용자에게 알리는 보완 채널이며, document가 열리면 snapshot/refetch 후 SSE 또는
|
|
WebSocket이 독립적으로 연결된다.
|
|
|
|
### 2.2 선택 절차
|
|
|
|
```text
|
|
measured freshness/interaction requirement
|
|
-> focus/refetch/manual refresh로 충족 가능한지 확인
|
|
-> direction/background/delivery 의미 분류
|
|
-> server replay/snapshot/auth/retention owner 확인
|
|
-> 하나의 primary foreground transport 선택
|
|
-> 의미가 같은 bounded polling fallback 선택 여부
|
|
-> Web Push 보완 요구를 별도 선택
|
|
-> failure UX, resource budget와 kill switch 승인
|
|
-> deterministic/provider/browser/operations evidence
|
|
-> canary traffic admission
|
|
```
|
|
|
|
다음 중 하나라도 없으면 product selection은 `NOT_SELECTED`다.
|
|
|
|
- 측정 가능한 freshness 또는 interaction SLO
|
|
- event/snapshot protocol owner
|
|
- authorization와 account transition 계약
|
|
- replay retention 또는 명시적인 snapshot-only 복구
|
|
- connection/request/push 비용 owner
|
|
- stale/degraded/unsupported UX
|
|
- 실제 backend/provider test environment
|
|
|
|
### 2.3 인접 server-stream protocol과의 경계
|
|
|
|
[API contract, Schema, Mapper와 Server State](./api-contract-schema-mapper-and-server-state.md)의
|
|
Connect/gRPC-Web server stream과 향후 GraphQL subscription은 이 문서의 SSE/WebSocket과
|
|
자동으로 같은 adapter가 아니다.
|
|
|
|
- Connect/gRPC-Web server stream은 registry-owned operation 하나의 request/response
|
|
lifecycle, protocol-specific envelope/trailer와 total deadline을 API platform이 소유한다.
|
|
application orchestrator가 mapped `AsyncIterable<Result<Event>>`를 소비하는
|
|
operation-bound outbound result일 수 있으며, 항상 unsolicited inbound
|
|
notification으로 재분류하지 않는다.
|
|
- GraphQL subscription은 현재 `NOT_SELECTED`이며 persisted query adapter에
|
|
암묵적으로 추가하지 않는다. GraphQL `@defer`/`@stream`은 finite incremental
|
|
HTTP response profile이지 subscription이나 realtime delivery가 아니다.
|
|
- generated protobuf message를 `REALTIME_EVENT_V1` JSON envelope로 다시 감싸지
|
|
않는다. API adapter가 frame decode, semantic schema와 pure boundary mapping을
|
|
끝낸 뒤, 제품이 runtime-wide notification projection을 명시적으로 선택한
|
|
branch에서만 mapped event를 common scope/generation/dedupe/gap/resync
|
|
coordinator 또는 `FeatureEventInput`에 전달한다.
|
|
- transport frame decode, media/subprotocol, status/trailer와 reconnect는 각 API
|
|
adapter가 계속 소유한다. 특히 Connect/gRPC-Web은 첫 event 전 transport
|
|
replay만 허용하고 이후 reconnect에는 등록된 resume protocol이 필요하다는
|
|
VD-29/VD-27 규칙을 우선한다.
|
|
- API operation registry의 현재 `SERVER_STREAM`은
|
|
`CONNECT_HTTP | GRPC_WEB + rpcKind=SERVER_STREAM`만 허용한다. SSE, WebSocket과 Web Push는
|
|
realtime registry가 소유하고, bounded polling은 terminal·replay-safe REST
|
|
`QUERY` operation에 적용하는 scheduling policy다.
|
|
- bounded polling의 한 logical attempt는 REST operation registry의 credential,
|
|
conditional request, schema/mapper와 deadline을 재사용한다. Poll 전용
|
|
operation은 transport retry를 끄며 이 문서는 완료된 attempt 사이 cadence와
|
|
failure backoff만 소유한다.
|
|
|
|
같은 stream을 Connect/gRPC-Web과 SSE로 자동 failover하지 않는다. wire와 resume 의미가
|
|
같다는 backend contract와 별도 rollout evidence가 있을 때만 명시된 fallback으로
|
|
선택한다.
|
|
|
|
## 3. Topology와 clean architecture 배치
|
|
|
|
### 3.1 Window runtime
|
|
|
|
```text
|
|
bootstrap composition root
|
|
-> immutable RealtimePolicyRegistry
|
|
-> session/account scope authority
|
|
-> lifecycle/online clock adapters
|
|
-> selected connection owner
|
|
-> SSE adapter
|
|
-> or WebSocket adapter
|
|
-> or no live connection
|
|
-> event protocol decoder
|
|
-> stream coordinator
|
|
-> dedupe/order/gap/resume
|
|
-> feature event input
|
|
-> query invalidation bridge
|
|
-> authoritative resync gateway
|
|
-> optional bounded polling coordinator
|
|
-> redacted diagnostics
|
|
```
|
|
|
|
page나 feature hook이 native connection을 직접 생성하지 않는다. route는
|
|
registry-owned logical subscription lease를 획득하고 release할 뿐이다.
|
|
WebSocket runtime은 같은 endpoint/protocol의 logical subscription을
|
|
multiplex할 수 있지만 서로 다른 account scope나 protocol version을 같은 physical
|
|
connection에 섞지 않는다. SSE baseline은 §6의 단일 session feed를 local
|
|
dispatch할 뿐 server-side logical subscription을 multiplex하지 않는다.
|
|
|
|
권장 application 의미 경계의 예시는 다음과 같다.
|
|
|
|
```ts
|
|
type ExternalEventEffect =
|
|
| Readonly<{ kind: "IGNORE" }>
|
|
| Readonly<{ kind: "INVALIDATE"; topic: QueryInvalidationTopic }>
|
|
| Readonly<{ kind: "RESYNC"; stream: StreamRegistrationId }>
|
|
| Readonly<{ kind: "APPLY_EPHEMERAL"; viewEvent: ViewEvent }>;
|
|
|
|
interface FeatureEventInput {
|
|
handleExternalEvent(
|
|
event: FeatureExternalEvent,
|
|
context: ExternalEventContext,
|
|
): Promise<Result<ExternalEventEffect>>;
|
|
}
|
|
```
|
|
|
|
이 예시는 target 의미를 설명하며 현재 production type이 아니다.
|
|
`QueryInvalidationTopic`, event union과 failure는 실제 feature registry가 소유한다.
|
|
application use case가 `QueryClient`를 호출하지 않고, presentation query bridge가
|
|
`INVALIDATE`를 canonical query key 동작으로 투영한다.
|
|
|
|
### 3.2 Service Worker runtime
|
|
|
|
Web Push는 window bootstrap과 다른 실행 환경이다.
|
|
|
|
```text
|
|
service worker composition root
|
|
-> exact worker/release protocol
|
|
-> strict push envelope codec
|
|
-> bounded push event handler
|
|
-> notification policy registry
|
|
-> safe notification renderer
|
|
-> notificationclick route mapper
|
|
-> optional client-window handoff
|
|
```
|
|
|
|
Service Worker를 realtime 때문에 선택해도 offline shell, fetch interception,
|
|
private response cache와 background sync가 자동으로 승인되지 않는다. 기존 Cache
|
|
Storage reference runtime과 worker registration은 인접하지만 별도 capability다.
|
|
|
|
하나의 origin과 scope에는 worker registration/update owner가 하나여야 한다.
|
|
realtime용 worker를 별도 파일로 겹쳐 등록하지 않고, 선택된 worker capability가
|
|
하나의 build/composition root에서 `push`, `notificationclick`, install/activate
|
|
handler를 조립한다.
|
|
|
|
worker는 React presentation이 아니지만 외부 event가 application 의도를 구동한다는
|
|
inbound 원칙을 동일하게 따른다. worker 전용 codec과 use-case facade는 DOM이나
|
|
window runtime을 import하지 않는다.
|
|
|
|
### 3.3 Polling의 소유 위치
|
|
|
|
일반 server-state freshness polling은 별도 `PollingPort`가 아니라 query bridge의
|
|
scheduling policy다. 기존 query key, AbortSignal, focus/reconnect refetch와 같은
|
|
owner가 중복 실행을 막는다.
|
|
|
|
업무상 long-running job의 terminal 상태를 기다리는 polling은 application
|
|
orchestrator가 기존 feature gateway와 `ClockPort`를 사용한다. 이 경우 poll
|
|
lease의 max attempts, max elapsed, terminal states와 cancellation이 use case
|
|
계약에 포함된다.
|
|
|
|
어느 경우에도 page component의 `setInterval`이나 각 HTTP/Query/SDK의 독립 retry
|
|
loop로 구현하지 않는다.
|
|
|
|
## 4. Registry와 공통 event protocol
|
|
|
|
### 4.1 Immutable registration
|
|
|
|
선택된 stream은 closed registry row를 가져야 한다.
|
|
|
|
```ts
|
|
type RealtimeStreamRegistration = Readonly<{
|
|
id: StreamRegistrationId;
|
|
protocol: "REALTIME_EVENT_V1";
|
|
owner: FeatureId;
|
|
scope: "ORIGIN_SHARED" | "ACCOUNT_BOUND" | "SESSION_BOUND";
|
|
primaryTransport: "SSE" | "WEBSOCKET" | "NONE";
|
|
endpointId: RealtimeEndpointId;
|
|
eventTypeIds: readonly EventTypeId[];
|
|
delivery: "INVALIDATION_HINT" | "AUTHORITATIVE_DELTA" | "EPHEMERAL";
|
|
recovery: RealtimeRecoveryProfile;
|
|
fallback: "BOUNDED_POLLING" | "EXPLICITLY_STALE";
|
|
hiddenPolicy: "CLOSE" | "BOUNDED_GRACE";
|
|
limits: RealtimeLimits;
|
|
killSwitchId: KillSwitchId;
|
|
}>;
|
|
|
|
type RealtimeEventTypeRegistration = Readonly<{
|
|
id: EventTypeId;
|
|
owner: FeatureId;
|
|
payloadSchemaId: RuntimeSchemaId;
|
|
mapperId: BoundaryMapperId;
|
|
effectProfileId: ExternalEventEffectProfileId;
|
|
stateBearing: boolean;
|
|
}>;
|
|
|
|
type RealtimeRecoveryProfile =
|
|
| Readonly<{
|
|
mode: "CURSOR";
|
|
snapshotOperationId: ApiOperationId;
|
|
checkpointCodecId: RuntimeSchemaId;
|
|
barrier: "REPLAY";
|
|
}>
|
|
| Readonly<{
|
|
mode: "SNAPSHOT_ONLY";
|
|
snapshotOperationId: ApiOperationId;
|
|
checkpointCodecId: RuntimeSchemaId;
|
|
barrier: "CONNECT_BUFFER" | "SERVER_HOLD" | "NONE";
|
|
}>
|
|
| Readonly<{
|
|
mode: "SESSION_REBUILD";
|
|
rebuildInputId: ApplicationInputId;
|
|
}>;
|
|
```
|
|
|
|
실제 config schema는 exact key, duplicate ID, unknown reference, contradictory
|
|
transport/fallback과 implementation ceiling 초과를 startup에서 거절한다.
|
|
registry는 construction 시 copy/freeze하며 request마다 endpoint, topic, scope,
|
|
payload limit, retry와 fallback을 override하지 못한다.
|
|
|
|
`channel: string`이나 arbitrary URL을 application caller가 제공하지 않는다.
|
|
V1 logical subscription input은 registry ID만 허용한다. server-side subset
|
|
filter는 contiguous stream-wide sequence를 깨뜨릴 수 있으므로 `NOT_SELECTED`다.
|
|
filter가 필요하면 별도 `StreamRegistrationId`, stream epoch, contiguous sequence와
|
|
checkpoint를 가진 stream으로 등록한다. common sequence/checkpoint 처리 뒤의
|
|
feature-local dispatch predicate는 server subscription/cursor 의미를 바꾸지
|
|
않는다.
|
|
|
|
state-bearing event가 하나라도 있는 stream은 `CURSOR | SNAPSHOT_ONLY`와 registered
|
|
snapshot/checkpoint를 가져야 한다. `SESSION_REBUILD`는 모든 event가 `EPHEMERAL`인
|
|
stream에만 허용한다. contradictory recovery/delivery/barrier는 startup에서
|
|
거절한다.
|
|
|
|
### 4.2 Target event envelope
|
|
|
|
공통 transport가 전달하는 target envelope의 의미는 다음과 같다.
|
|
|
|
```ts
|
|
type RealtimeEventBase<TPayload> = Readonly<{
|
|
protocol: "REALTIME_EVENT_V1";
|
|
streamId: StreamRegistrationId;
|
|
streamEpoch: string;
|
|
eventType: EventTypeId;
|
|
eventId: string;
|
|
sequence: string;
|
|
occurredAt: string;
|
|
scopeBinding: string;
|
|
payload: TPayload;
|
|
}>;
|
|
|
|
type RealtimeEventEnvelope<TPayload> = Readonly<
|
|
RealtimeEventBase<TPayload> &
|
|
(
|
|
| Readonly<{ recoveryMode: "CURSOR"; resumeCursor: string }>
|
|
| Readonly<{
|
|
recoveryMode: "SNAPSHOT_ONLY" | "SESSION_REBUILD";
|
|
resumeCursor: null;
|
|
}>
|
|
)
|
|
>;
|
|
```
|
|
|
|
- `protocol`은 exact literal이고 unknown/newer version은 fail-closed한다.
|
|
- `streamId`와 `eventType`은 registry에 존재해야 한다.
|
|
- `streamEpoch`은 server stream reset, partition rebuild 또는 호환 불가능한
|
|
replay change 때 바뀌는 opaque identifier다.
|
|
- `eventId`는 bounded dedupe용 opaque ID다.
|
|
- `sequence`는 stream + epoch 안에서만 단조 증가하는 canonical unsigned decimal
|
|
string이다. JSON `number`의 safe-integer 한계를 피하고 parse 뒤 bounded
|
|
integer representation으로 비교한다.
|
|
- `CURSOR`의 non-empty `resumeCursor`만 server-issued opaque replay position이다.
|
|
event ID나 sequence와 같은 문자열일 수 있어도 의미는 별도다.
|
|
- `SNAPSHOT_ONLY | SESSION_REBUILD`는 cursor를 발급·전송하지 않고 exact
|
|
`resumeCursor: null`을 사용한다. 빈 문자열이나 synthetic cursor를 만들지 않는다.
|
|
- `occurredAt`은 strict RFC 3339 timestamp지만 ordering authority가 아니다.
|
|
- `scopeBinding`은 raw account/session ID나 cache-key fingerprint가 아닌
|
|
session/BFF-issued opaque exact-match token이다. authenticated session snapshot과
|
|
server subscription에 같은 값을 bind하고 scope switch마다 rotate한다. client
|
|
nonce profile을 선택하면 authenticated fetch/WebSocket subscribe에서 server가
|
|
exact echo해야 하며 native EventSource에는 사용하지 않는다.
|
|
- `payload`는 event type별 closed codec와 더 작은 byte/count/depth limit를 가진다.
|
|
|
|
credential, access token, email, account/tenant/user ID, signed URL,
|
|
PushSubscription endpoint/key와 notification 자유 문구는 envelope에서 금지한다.
|
|
제품에 식별자가 필요하면 backend가 authorization한 opaque resource reference를
|
|
사용한다.
|
|
|
|
`scopeBinding`은 authorization proof가 아니다. server는 connect/resume와 각
|
|
logical subscription을 current credential로 다시 인가한다. `CURSOR`는
|
|
`protocol + stream/feed + streamEpoch + registered subscription set +
|
|
authorization scope`에 server-side로 bind한다. client는 cursor를 해석하거나
|
|
수정하지 않고 header-safe character/byte ceiling만 검사해 전달한다. 다른
|
|
stream/account/subscription의 cursor 재사용이나 tamper는 `RESET_REQUIRED` 또는
|
|
`FORBIDDEN`으로 닫는다.
|
|
|
|
기존 recipe의 `RealtimeEvent<T>`는 이 target envelope로 구현됐다는 증거가
|
|
아니다. `sequence: number`와 generic payload를 가진 copyable example일 뿐이며,
|
|
선택 branch에서 breaking recipe amendment 또는 feature-local contract로
|
|
교체한다.
|
|
|
|
### 4.3 Decode와 적용 순서
|
|
|
|
모든 foreground transport는 다음 순서를 지킨다.
|
|
|
|
```text
|
|
raw bytes/frame
|
|
-> transport byte ceiling
|
|
-> UTF-8 / exact media or frame type
|
|
-> JSON syntax + object depth/count ceiling
|
|
-> protocol/version/exact-key schema
|
|
-> registry stream/event lookup
|
|
-> current scope + generation fence
|
|
-> stream epoch + sequence + dedupe check
|
|
-> ValidatedRealtimeEventDto (adapter-private)
|
|
-> registered pure boundary mapper
|
|
-> FeatureExternalEvent
|
|
-> sequential feature effect
|
|
-> local effect commit
|
|
-> last-applied sequence + conditional CURSOR advance
|
|
-> optional selected WebSocket/event-protocol ACK
|
|
```
|
|
|
|
CURSOR를 effect보다 먼저 저장하면 crash 시 event를 잃을 수 있다. effect 뒤에
|
|
저장하면 crash window에서 duplicate가 다시 올 수 있으므로 effect가 idempotent
|
|
해야 한다. non-CURSOR profile도 sequence/checkpoint를 effect 뒤에 갱신한다.
|
|
memory-only invalidation hint는 duplicate invalidate를 허용한다.
|
|
authoritative delta나 durable command effect는 별도 idempotency ledger 없이는
|
|
선택하지 않는다.
|
|
|
|
schema/mapper가 실패하면 effect, cursor/ACK와 cache write는 모두 0이다.
|
|
`MAPPING_CONTRACT_VIOLATION`으로 닫고 state-bearing event는 snapshot resync한다.
|
|
ACK가 있는 protocol에서도 ACK는 idempotent하고 bounded해야 하며 business
|
|
commit, 사용자 확인 또는 read proof가 아니다. SSE에는 application ACK 경로가
|
|
없다.
|
|
|
|
callback 하나의 실패가 connection event loop를 깨거나 다음 event와 병렬로
|
|
뒤섞이지 않게 stream별 sequential queue를 사용한다. V1 sequence는 stream-wide
|
|
하나뿐이다. partition ordering이 필요하면 각 partition을 별도 logical stream으로
|
|
등록한다. 한 envelope 안의 partition별 ordering/concurrency는 V2에서 closed
|
|
`partitionId`와 partition별 checkpoint/ceiling을 추가하기 전에는 허용하지 않는다.
|
|
|
|
### 4.4 Duplicate, out-of-order와 gap
|
|
|
|
stream + epoch별 `lastAppliedSequence`를 기준으로 처리한다.
|
|
|
|
| 입력 | 동작 |
|
|
| --- | --- |
|
|
| 이미 처리한 `eventId` | payload를 다시 적용하지 않고 duplicate 관측만 남김 |
|
|
| 같은 `eventId`인데 sequence/payload가 다름 | protocol conflict; cursor 미진행, close + resync |
|
|
| 같은 `sequence`인데 다른 `eventId` | protocol conflict; cursor 미진행, close + resync |
|
|
| `sequence <= lastApplied` | stale/out-of-order로 drop |
|
|
| `sequence == lastApplied + 1` | 순차 적용 |
|
|
| `sequence > lastApplied + 1` | gap; delta 적용 중지, `RESYNC_REQUIRED` |
|
|
| unknown `streamEpoch` | old/new epoch를 섞지 않고 registered snapshot 또는 session rebuild |
|
|
| dedupe/queue ceiling 초과 | 임의 eviction 후 계속 적용하지 않고 registered recovery |
|
|
| captured old runtime generation | late callback safe drop + aggregate diagnostic |
|
|
| current connection의 `scopeBinding` mismatch | effect/sequence/cursor 0, freshness `UNKNOWN`, close + session revalidation + registered recovery |
|
|
|
|
작은 out-of-order reorder buffer를 선택할 수 있지만 count, bytes와 wait deadline을
|
|
registry가 제한해야 한다. 기본은 reorder하지 않고 gap으로 처리하는 것이다.
|
|
|
|
gap 뒤에는 뒤 이벤트를 “최신처럼 보이므로” 적용하지 않는다. 다음 흐름은
|
|
state-bearing stream에 적용한다.
|
|
|
|
```text
|
|
gap detected
|
|
-> pause stream apply
|
|
-> mark freshness UNKNOWN/RESYNCING
|
|
-> authoritative snapshot request
|
|
-> validate snapshot + checkpoint
|
|
-> current generation의 required projection 적용 완료
|
|
-> reset dedupe/order state
|
|
-> checkpoint nextExpectedSequence부터 resume
|
|
```
|
|
|
|
snapshot request가 실패하면 stale UI와 manual retry를 표시한다. reconnect만
|
|
반복해 gap을 복구했다고 주장하지 않는다.
|
|
|
|
```ts
|
|
type SnapshotCheckpoint = Readonly<{
|
|
streamEpoch: string;
|
|
lastAppliedSequence: string;
|
|
snapshotRevision: string;
|
|
} & (
|
|
| Readonly<{ recoveryMode: "CURSOR"; resumeCursor: string }>
|
|
| Readonly<{ recoveryMode: "SNAPSHOT_ONLY"; resumeCursor: null }>
|
|
)>;
|
|
```
|
|
|
|
snapshot과 checkpoint는 같은 server commit point의 의미로 발급한다. subscribe
|
|
ack도 recovery mode와 일치하는 `acceptedCursor: string | null` 및
|
|
`nextExpectedSequence`를 반환한다. frontend는 TanStack 여러 projection 사이의
|
|
transaction을 과장하지 않고, current generation에서 필요한 projection 적용이
|
|
모두 성공하기 전에는 cursor/sequence state를 바꾸거나 stream effect를 재개하지
|
|
않는다.
|
|
|
|
`EPHEMERAL + SESSION_REBUILD` gap/epoch/overflow는 snapshot을 가장하지 않는다.
|
|
connection을 닫고 presence/collaboration session을 registered rebuild input으로
|
|
다시 연다. rebuild가 실패하면 interaction을 disabled/degraded로 표시한다.
|
|
|
|
### 4.5 Backend commit, outbox와 replay authority
|
|
|
|
frontend 정확성은 backend event publication 계약에 의존한다.
|
|
|
|
- 업무 transaction commit 전에 “완료” event를 publish하지 않는다.
|
|
- database state와 event ledger의 이중 쓰기는 transactional outbox, 동일
|
|
commit log 또는 동등한 server-owned mechanism으로 해결한다.
|
|
- broker offset이나 Kafka partition을 frontend wire에 직접 노출하지 않는다.
|
|
- server가 stream/subject authorization을 매 connect/subscribe/resume마다
|
|
다시 확인한다.
|
|
- replay retention과 cursor expiry 시간을 문서화한다.
|
|
- 오래된 cursor는 빈 성공 stream이 아니라 explicit `RESET_REQUIRED` 의미를
|
|
반환한다.
|
|
- snapshot 응답은 함께 사용할 `SnapshotCheckpoint`를 같은 commit point의
|
|
의미로 발급한다.
|
|
- server scale-out instance가 바뀌어도 같은 logical stream의 ordering 의미가
|
|
보존돼야 한다.
|
|
|
|
backend가 replay를 제공하지 않으면 state-bearing registration은
|
|
`SNAPSHOT_ONLY`다. snapshot과
|
|
connect 사이 event를 보존하는 server barrier가 없다면 “snapshot 먼저, 그 뒤
|
|
connect”만으로 `CURRENT`를 보장하지 못한다. `CURRENT`가 필요하면
|
|
connect-and-bounded-buffer → snapshot/checkpoint → checkpoint 이후 event 적용,
|
|
또는 server가 checkpoint 이후 event를 hold하는 동등한 barrier를 제공해야 한다.
|
|
그 barrier가 없는 profile은 reconnect/restore마다 finite revalidation을
|
|
수행하되 freshness를 `STALE`/`UNKNOWN`으로 표시하고 manual refresh를 제공한다.
|
|
|
|
## 5. 공통 lifecycle
|
|
|
|
### 5.1 서로 다른 상태 축
|
|
|
|
상태를 `connected: boolean` 하나로 표현하지 않는다.
|
|
|
|
| 축 | canonical 상태 |
|
|
| --- | --- |
|
|
| connection | `IDLE`, `CONNECTING`, `OPEN`, `BACKING_OFF`, `PAUSED`, `DRAINING`, `CLOSED` |
|
|
| freshness | `UNKNOWN`, `CURRENT`, `STALE`, `RESYNCING` |
|
|
| authorization | `UNKNOWN`, `VALID`, `REFRESHING`, `REQUIRED`, `FORBIDDEN` |
|
|
| availability | `UNKNOWN`, `AVAILABLE`, `DEGRADED`, `UNSUPPORTED`, `UNAVAILABLE` |
|
|
| admission | `DISABLED`, `SHADOW`, `CANARY`, `ENABLED` |
|
|
|
|
UI에는 필요한 projection만 노출한다.
|
|
|
|
```text
|
|
LIVE
|
|
RECONNECTING
|
|
STALE
|
|
AUTH_REQUIRED
|
|
UNSUPPORTED
|
|
```
|
|
|
|
`OPEN + UNKNOWN`, `PAUSED + CURRENT`, `BACKING_OFF + STALE`처럼 조합될 수 있다.
|
|
socket open만으로 “최신” badge를 표시하지 않는다.
|
|
|
|
### 5.2 Runtime 생성과 lease
|
|
|
|
```text
|
|
runtime config/release verified
|
|
-> registry validated
|
|
-> traffic admission checked
|
|
-> 새 factory가 DISABLED: network side effect 없이 종료
|
|
-> local static capability probe
|
|
-> session recovery settled
|
|
-> immutable scope/generation acquired
|
|
-> selected adapter and coordinator created
|
|
-> SHADOW: 승인된 synthetic hosting probe만 허용
|
|
-> CANARY/ENABLED: route/session subscription lease 허용
|
|
```
|
|
|
|
route-scoped subscription은 마지막 consumer가 사라지면 logical unsubscribe한다.
|
|
session-wide notification badge처럼 명시적으로 등록된 subscription만 route
|
|
unmount 뒤 남을 수 있다.
|
|
|
|
React StrictMode mount → cleanup → mount에서도 physical listener, timer와
|
|
subscription이 중복되지 않아야 한다. lease release와 runtime `close()`는
|
|
terminal/idempotent다.
|
|
|
|
`TrafficAdmission=DISABLED`는 새 data-plane runtime/lease/send/subscribe/poll을
|
|
막는다. 이미 소유한 resource를 안전하게 없애는 fixed, idempotent
|
|
close/unsubscribe/revoke는 connection `DRAINING`의 bounded cleanup plane에서만
|
|
허용한다. cleanup caller는 captured resource/association ID만 사용할 수 있고 새
|
|
endpoint/topic을 만들거나 retry loop를 시작하지 않는다. `CLOSED` 뒤에는 cleanup을
|
|
포함한 network side effect가 0이다.
|
|
|
|
### 5.3 Reconnect와 retry owner
|
|
|
|
reconnect는 full-jitter exponential backoff를 사용한다.
|
|
|
|
```text
|
|
localDelay = random(0, min(maxDelay, baseDelay * 2^attempt))
|
|
effectiveDelay = max(localDelay, validServerNotBefore)
|
|
```
|
|
|
|
- base/max delay, max attempts와 max elapsed를 registry ceiling 안에서 고정한다.
|
|
- HTTP `Retry-After`, SSE `retry`와 protocol retry hint는 서로 다른 wire
|
|
의미지만 모두 server가 요구한 시각보다 먼저 재시도하지 않는 not-before
|
|
bound로 취급한다. `effectiveDelay`가 implementation max delay나 남은 elapsed
|
|
budget을 넘으면 낮춰 clamp하지 않고 reconnect/poll을 종료해
|
|
`DEGRADED`/`STALE`로 전환한다.
|
|
- connection이 잠깐 열렸다는 이유로 attempt를 0으로 만들지 않는다. stable-open
|
|
window 또는 valid heartbeat/event 뒤에만 reset한다.
|
|
- stream reconnect는 realtime recovery coordinator, credential recovery는
|
|
session owner, Poll attempt 사이 cadence는 poll coordinator가 각각 소유한다.
|
|
Poll-bound REST transport retry와 Query retry는 끈다. 같은 logical recovery의
|
|
effect writer나 timer가 둘 이상 활성화되지 않게 generation으로 배타화한다.
|
|
- `offline` 상태에서는 timer로 재시도하지 않고 online hint를 기다린다.
|
|
`navigator.onLine`은 실제 backend reachability authority가 아니다.
|
|
- auth expiry는 session owner의 single-flight recovery를 한 번 요청한다.
|
|
실패하면 `AUTH_REQUIRED`로 멈춘다.
|
|
- protocol/schema/forbidden/policy failure는 retry하지 않는다.
|
|
- 외부 close/result의 rate limit/provider unavailable은 유효한 bounded server
|
|
not-before hint가 있을 때만 재시도한다. hint가 없거나 ceiling 밖이면
|
|
terminal이다. adapter 내부 provider throw처럼 server 응답이 아닌 실패만
|
|
남은 retry budget 안에서 local full-jitter를 사용할 수 있다.
|
|
- budget을 소진하면 infinite reconnect가 아니라 `DEGRADED`와 명시된 fallback
|
|
또는 stale UI로 전환한다.
|
|
|
|
authoritative recovery와 새 transport 사이에는 값 동등성이 아닌 한 generation의
|
|
exact proof barrier를 둔다.
|
|
|
|
```text
|
|
stream recovery commits exact branded RECOVERY_COMMITTED checkpoint
|
|
-> reconnect owner retains that exact object
|
|
-> next attempt receives initialRecovery=RECOVERY_RECONNECT
|
|
-> SSE onOpen / WebSocket onSubscribed proves the same checkpoint
|
|
-> attempt returns the same recovery proof
|
|
-> common coordinator confirms the transport barrier
|
|
-> readiness gate settles
|
|
-> only then may the new transport admit events
|
|
```
|
|
|
|
checkpoint clone, 누락된 proof, 다른 stream/generation의 proof와 readiness deadline
|
|
초과는 fail-closed다. gate의 성공·실패·abort 모든 경로는 반드시 settle하며,
|
|
attempt당 상한은 30초다.
|
|
|
|
### 5.4 Visibility, page lifecycle와 network
|
|
|
|
| signal | 기본 동작 |
|
|
| --- | --- |
|
|
| `visibilitychange -> hidden` | 새 Polling 중지, configured grace 뒤 live connection pause/close |
|
|
| `visibilitychange -> visible` | scope 확인, snapshot freshness gate, 그 뒤 resume/connect |
|
|
| `offline` | request/connection attempt abort, backoff timer 정지 |
|
|
| `online` | 즉시 flood하지 않고 jitter 후 authoritative reachability check |
|
|
| `pagehide` | document-owned SSE/WS/Poll 모두 close; unload-only write 금지 |
|
|
| `pageshow`/bfcache restore | old callback generation 폐기, snapshot/resume gate |
|
|
| discarded/reloaded document | memory cursor를 신뢰하지 않고 snapshot 또는 scope-bound resume |
|
|
| Service Worker termination | in-memory queue/cursor가 보존된다고 가정하지 않음 |
|
|
|
|
hidden document의 timer는 throttling되므로 heartbeat deadline만으로 즉시 connection
|
|
failure를 선언하지 않는다. hidden grace를 선택한 경우에도 implementation hard
|
|
ceiling 뒤에는 close한다.
|
|
|
|
`beforeunload`/`unload`에서 unsubscribe, cursor write, push revoke나 server logout이
|
|
완료된다고 가정하지 않는다. local session notification과 server-side TTL/revoke가
|
|
authority다.
|
|
|
|
`pagehide`/freeze 뒤 session-wide logical subscription metadata가 남더라도 physical
|
|
connection은 document owner와 함께 닫는다. 다음 document/runtime은 current
|
|
session과 checkpoint를 다시 검증한 뒤 새 connection을 만든다.
|
|
|
|
### 5.5 Logout, account/tenant switch와 release
|
|
|
|
```text
|
|
session authority announces local transition
|
|
-> admission DISABLED
|
|
-> connection DRAINING
|
|
-> old generation FENCED
|
|
-> reject new lease/send/poll
|
|
-> abort connect/read/backoff/snapshot
|
|
-> close SSE/WebSocket
|
|
-> clear memory cursor/dedupe/queue
|
|
-> revoke old push account association through bounded request
|
|
-> dispose listeners/timers
|
|
-> CLOSED
|
|
-> construct new scope independently
|
|
```
|
|
|
|
late socket event, poll response, auth recovery, snapshot, notification handoff와
|
|
Service Worker message는 old generation/scope면 적용하지 않는다.
|
|
|
|
long-lived connection은 connect-time authorization만 신뢰하지 않는다. backend는
|
|
active subscription의 permission/session revoke를 control event 또는 close로
|
|
전파하고, registry-owned `maxConnectionAge`/reauth deadline 안에 current
|
|
credential로 재연결하게 한다. SSE처럼 별도 subscribe command가 없는 transport는
|
|
권한 변경 시 server가 stream을 종료해야 한다. server hint는 client hard
|
|
connection-age ceiling을 늘릴 수 없다.
|
|
|
|
Web Push browser subscription 자체와 account association은 구분한다. logout의
|
|
정상 security commit은 local `REVOKED` fence와 backend old-account association
|
|
revoke 둘 다다. 둘 중 하나가 ambiguous하면 완전한 push revoke를 주장하지 않고
|
|
§8.3의 unavailable/reconciliation 경로로 내린다. browser `unsubscribe()`는 제품
|
|
정책에 따른 best-effort cleanup이며, 그 실패 때문에 old account payload가
|
|
노출되지 않도록 push envelope와 notification copy가 scope-safe해야 한다.
|
|
|
|
release가 event codec, worker protocol이나 registry compatibility를 깨면
|
|
release epoch를 바꾸고 old stream/worker message를 거절한다. worker update
|
|
중 old/new page가 공존할 수 있으므로 지원하는 N/N-1 wire window 또는
|
|
fail-closed reload policy를 명시한다.
|
|
|
|
### 5.6 여러 tab
|
|
|
|
server stream과 `BroadcastChannel`을 하나의 bus로 합치지 않는다. server stream은
|
|
backend authorization/replay authority이고 cross-tab channel은 같은 origin 안의
|
|
best-effort hint다.
|
|
|
|
기본 reference 설계는 tab별 runtime이되 다음을 제한한다.
|
|
|
|
- tab 하나당 selected primary transport physical connection 1개
|
|
- WebSocket logical subscription multiplexing 또는 SSE single-feed local dispatch
|
|
- hidden tab close/pause 정책
|
|
- visible 복귀 시 authoritative snapshot
|
|
|
|
SharedWorker나 leader election으로 origin 전체 connection을 하나로 만드는 기능은
|
|
기본 목표가 아니다. leader crash, storage partition, worker 지원과 exactly-once
|
|
handoff를 별도로 설계해야 한다. 여러 visible window의 connection 수가 provider
|
|
budget을 넘는 제품은 별도 multi-context coordinator를 선택한다.
|
|
|
|
다른 tab이 수신한 event를 BroadcastChannel로 전달해도 그것은 invalidation
|
|
optimization일 뿐이다. 수신하지 못한 tab은 focus/snapshot으로 수렴해야 한다.
|
|
|
|
## 6. SSE
|
|
|
|
### 6.1 선택 의미
|
|
|
|
SSE는 active document에서 server-to-client text event만 필요할 때 우선한다.
|
|
일반 업무 command는 기존 HTTP gateway를 사용한다.
|
|
|
|
장점:
|
|
|
|
- HTTP semantics와 proxy/observability 인프라를 재사용
|
|
- event ID와 reconnect model이 표준에 있음
|
|
- one-way 요구를 duplex protocol로 과장하지 않음
|
|
|
|
제약:
|
|
|
|
- native `EventSource` constructor는 URL과 `withCredentials`만 받고 arbitrary
|
|
request header를 받지 않는다.
|
|
- native reconnect의 세부 status/header와 bounded retry를 application이 충분히
|
|
제어하기 어렵다.
|
|
- active document가 없거나 frozen/terminated되면 background delivery를
|
|
보장하지 않는다.
|
|
- HTTP/1.x, proxy buffering/idle timeout과 tab 수에 영향을 받는다.
|
|
|
|
### 6.2 Baseline adapter 선택
|
|
|
|
공통 reference target은 same-origin BFF의 fetch-stream SSE adapter다.
|
|
|
|
```text
|
|
fixed same-origin HTTPS endpoint
|
|
-> credential attacher / HttpOnly session cookie
|
|
-> fetch with AbortSignal
|
|
-> exact status/content type
|
|
-> bounded UTF-8 SSE parser
|
|
-> common event envelope decoder
|
|
```
|
|
|
|
이 선택은 다음을 제어하기 위함이다.
|
|
|
|
- fixed headers와 opaque session integration
|
|
- `200/204/401/403/409/410/429/503`의 closed mapping
|
|
- redirect 금지
|
|
- read/idle deadline와 total event buffer
|
|
- bounded reconnect owner
|
|
- lifecycle close와 current cursor 명시
|
|
|
|
native `EventSource`는 다음 조건을 모두 만족할 때 별도 adapter profile로 허용할
|
|
수 있다.
|
|
|
|
- same-origin HttpOnly cookie 인증
|
|
- `INVALIDATION_HINT + SNAPSHOT_ONLY` recovery profile
|
|
- status별 상세 UX가 control event와 `204`만으로 닫힘
|
|
- URL에 credential/cursor secret이 없음
|
|
- adapter close/recreate 뒤 snapshot/checkpoint recovery가 있음
|
|
- target browser/hosting evidence가 있음
|
|
|
|
native `EventSource`는 UA가 관리하는 last-event-ID를 application effect commit과
|
|
묶을 수 없다. 따라서 이 profile은 `INVALIDATION_HINT` 전용이며
|
|
`AUTHORITATIVE_DELTA`에는 금지한다. `error`/자동 reconnect/visibility·page restore
|
|
때마다 generation을 fence하고 authoritative snapshot freshness gate를 수행한다.
|
|
gate 동안 도착한 validated hint는 effect를 바로 쓰지 않고 bounded one-bit
|
|
`pendingInvalidation`으로 coalesce하며, checkpoint 뒤 pending refetch까지 끝내기
|
|
전에는 `CURRENT`를 표시하지 않는다. 이 post-checkpoint drain 또는 동등한 server
|
|
hold/replay barrier를 제공하지 못하면 profile은 계속 `STALE`/`UNKNOWN`이다.
|
|
cursor-after-effect 또는 exact delta replay가 필요한 stream은 fetch-stream
|
|
profile만 허용한다.
|
|
|
|
SSE baseline은 registry-owned `SESSION_FEED` 하나를 사용한다. `CURSOR` profile만
|
|
feed-wide replay cursor 하나를 가지며 `SNAPSHOT_ONLY`는 cursor를 전송하지
|
|
않는다. route lease는 수신 event의 local dispatch만 제어하며 server
|
|
subscription set을 동적으로 바꾸지 않는다. SSE에는 client→server
|
|
`SUBSCRIBE`/`UNSUBSCRIBE` frame이 없고 `Last-Event-ID`도 physical request당
|
|
하나이므로, arbitrary multi-stream multiplex와 per-subscription cursor는
|
|
`NOT_SELECTED`다. 필요하면 stream별 physical SSE와 더 작은 connection ceiling,
|
|
또는 별도 typed HTTP control plane/composite cursor protocol을 새 ADR로 선택한다.
|
|
|
|
### 6.3 HTTP와 parser 계약
|
|
|
|
fetch-stream baseline:
|
|
|
|
```text
|
|
method = GET
|
|
credentials = same-origin
|
|
redirect = error
|
|
cache = no-store
|
|
referrerPolicy = no-referrer
|
|
Accept = text/event-stream
|
|
Last-Event-ID = approved current cursor, CURSOR profile only
|
|
```
|
|
|
|
- endpoint는 composition-owned fixed absolute HTTPS URL이다.
|
|
- cross-origin은 explicit origin, exact CORS, CSP `connect-src`, credential와
|
|
preflight 계약을 별도 승인해야 한다.
|
|
- token, account ID나 unrestricted topic을 URL/query에 넣지 않는다.
|
|
- 성공은 exact `200`과 normalized `text/event-stream`만 허용한다.
|
|
- stream은 UTF-8로만 decode한다.
|
|
- BOM, CR/LF/CRLF, comments, multi-line `data`, `event`, `id`, `retry`를 표준
|
|
semantics로 처리한다.
|
|
- NUL/CR/LF가 포함된 invalid event ID를 cursor로 저장하지 않는다.
|
|
- fetch parser는 parsed candidate ID와 application-committed reconnect cursor를
|
|
분리한다. `CURSOR`의 각 application event block은 non-empty `id`를 직접
|
|
포함하고 decoded envelope의 `resumeCursor`와 exact match해야 한다. 이전
|
|
block의 inherited SSE ID만으로 통과시키지 않는다. `SNAPSHOT_ONLY |
|
|
SESSION_REBUILD`는 `id`를 발급하지 않고 envelope cursor도 exact `null`이어야
|
|
한다.
|
|
- event blank line 전 EOF는 incomplete event로 폐기한다.
|
|
- comments/heartbeat도 line과 idle budget을 소비하지만 application event를
|
|
만들지 않는다.
|
|
- `retry` 값은 숫자 syntax와 registry ceiling을 통과한 경우 다음 delay hint로만
|
|
사용한다.
|
|
- compressed/decompressed bytes 중 더 큰 보수적 측정을 ceiling에 적용한다.
|
|
|
|
상태 mapping:
|
|
|
|
| 결과 | 동작 |
|
|
| --- | --- |
|
|
| `200 text/event-stream` | parser 시작 |
|
|
| `204` | server terminal close; 자동 reconnect 금지 |
|
|
| `401` | session recovery 한 번, 실패 시 `AUTH_REQUIRED` |
|
|
| `403` | `FORBIDDEN`, retry 금지 |
|
|
| `409`/`410` with exact reset contract | cursor expired, snapshot resync |
|
|
| `429` | bounded `Retry-After`, retry budget 소비 |
|
|
| `502`/`503`/`504` | bounded provider backoff |
|
|
| redirect, wrong type, malformed stream | protocol/policy failure, fail-closed |
|
|
|
|
generic HTTP client의 전체-body decoder와 retry를 우회하되 credential, diagnostics,
|
|
fixed endpoint와 failure vocabulary는 같은 platform policy를 재사용한다.
|
|
|
|
### 6.4 Heartbeat, resume와 hosting
|
|
|
|
server는 application event가 없어도 policy 범위의 comment heartbeat를 보낸다.
|
|
client watchdog은 last received byte/comment/event 시간을 관측한다. hidden/frozen
|
|
상태에서는 watchdog을 pause하거나 connection을 닫아 false timeout을 만들지
|
|
않는다.
|
|
|
|
`CURSOR`에서만 SSE event `id`를 transport resume cursor로 사용하고 JSON
|
|
envelope의 `resumeCursor`와 exact equality를 검사한다. 다르면 protocol
|
|
failure다. application event ID와 cursor가 우연히 같아도 두 의미를 합치지
|
|
않는다. 이 cursor는 위의 feed/subscription-set, scope와 epoch binding을
|
|
만족해야 하며 authorization proof로 사용하지 않는다.
|
|
|
|
hosting/provider evidence는 다음을 포함한다.
|
|
|
|
- proxy/CDN response buffering 비활성 또는 streaming flush 증거
|
|
- `Cache-Control: no-store`와 transform/cache 금지
|
|
- idle/request duration과 heartbeat 호환
|
|
- HTTP/2 또는 승인 connection budget
|
|
- deploy/drain 중 reconnect storm 제한
|
|
- load balancer와 backend의 replay cursor 일관성
|
|
- client disconnect 후 server resource cleanup
|
|
|
|
local parser test만으로 hosting readiness를 주장하지 않는다.
|
|
|
|
## 7. WebSocket
|
|
|
|
### 7.1 선택 의미
|
|
|
|
WebSocket은 client와 server가 낮은 지연으로 지속적인 application message를
|
|
교환해야 할 때만 선택한다.
|
|
|
|
적합한 예:
|
|
|
|
- presence와 ephemeral collaboration signal
|
|
- server가 조정하는 interactive session
|
|
- HTTP 요청/응답으로 표현하기 어려운 duplex protocol
|
|
|
|
부적합한 예:
|
|
|
|
- 단순 server invalidation notification
|
|
- durable mutation을 idempotency ledger 없이 socket send로 바꾸는 것
|
|
- background notification
|
|
- 높은 event rate의 근거가 없는데 “실시간 같아 보이기” 위한 선택
|
|
|
|
### 7.2 Handshake와 인증
|
|
|
|
browser `WebSocket` constructor는 URL과 subprotocol만 제공한다. arbitrary
|
|
`Authorization` header를 기대하지 않는다.
|
|
|
|
baseline:
|
|
|
|
- fixed same-origin `wss:` endpoint
|
|
- Secure/HttpOnly/SameSite session cookie 또는 same-origin BFF가 소유한
|
|
짧은 handshake mechanism
|
|
- exact `Origin` 검증
|
|
- fixed versioned subprotocol 예: `realtime.v1`
|
|
- CSP `connect-src`
|
|
- redirect 없음
|
|
|
|
access token, session token과 push capability를 URL/query 또는
|
|
`Sec-WebSocket-Protocol`에 넣지 않는다. subprotocol은 protocol negotiation
|
|
전용이다. cross-origin cookie를 선택하면 exact origin allowlist, CSWSH 방어,
|
|
SameSite 정책과 handshake authorization을 별도 threat model로 승인한다.
|
|
|
|
server는 handshake 성공만으로 이후 모든 logical subscription과 command를
|
|
허용하지 않는다. subscribe/resume/command마다 current subject, resource와
|
|
scope를 재인가한다.
|
|
|
|
### 7.3 Closed message protocol
|
|
|
|
기본 text JSON protocol은 다음 control message만 허용한다.
|
|
|
|
```text
|
|
server -> WELCOME(protocol, connectionId, heartbeat, limits)
|
|
client -> SUBSCRIBE(subscriptionId, streamId, cursor|null, scopeBinding)
|
|
server -> SUBSCRIBED(
|
|
subscriptionId,
|
|
streamEpoch,
|
|
acceptedCursor|null,
|
|
nextExpectedSequence
|
|
)
|
|
server -> EVENT(subscriptionId, envelope)
|
|
server -> RESET_REQUIRED(subscriptionId, reason)
|
|
client -> UNSUBSCRIBE(subscriptionId)
|
|
server -> UNSUBSCRIBED(subscriptionId)
|
|
client -> HEARTBEAT(nonce)
|
|
server -> HEARTBEAT_ACK(nonce)
|
|
client/server -> CLOSE(category)
|
|
```
|
|
|
|
- exact top-level type/version와 key set을 검증한다.
|
|
- `connectionId`, subscription ID와 nonce는 diagnostics에 raw로 남기지 않는다.
|
|
- accepted cursor는 recovery mode/stream/scope binding을 다시 검증하고
|
|
`nextExpectedSequence`로 local checkpoint를 초기화한다.
|
|
- same-epoch `CURSOR` resume이면 `nextExpectedSequence`는 반드시 local
|
|
`lastAppliedSequence + 1`이다. mismatch는 effect/cursor 0,
|
|
`RESET_REQUIRED`와 snapshot recovery로 닫는다. server가 opaque accepted cursor를
|
|
rotate할 수는 있지만 requested cursor와 같은 logical position임을 conformance로
|
|
증명해야 하며 silent advance는 금지한다.
|
|
- state-bearing initial/no-cursor subscribe는 snapshot/checkpoint와 selected
|
|
buffer/hold barrier를 통과하기 전 `CURRENT`나 event effect를 허용하지 않는다.
|
|
- `UNSUBSCRIBE`를 보낸 subscription은 `UNSUBSCRIBING` tombstone으로 남긴다.
|
|
matching `UNSUBSCRIBED` ACK 전에는 ID를 재사용하거나 quota를 반환하지 않고,
|
|
그 사이 늦게 온 `EVENT`/`RESET_REQUIRED`/`SUBSCRIBED`는 effect 없이 버린다.
|
|
unknown 또는 다른 state의 ACK는 protocol failure다. ACK가 bounded apply
|
|
deadline 안에 오지 않으면 connection을 닫아 reconnect owner로 넘긴다.
|
|
- server-advertised limit은 client implementation ceiling을 높일 수 없다.
|
|
- binary frame은 baseline에서 거절한다. 제품이 binary protocol을 선택하면
|
|
별도 codec/version, decompressed byte cap과 browser evidence가 필요하다.
|
|
- per-message compression은 decompression bomb와 memory ceiling을 검증한
|
|
provider profile에서만 허용한다.
|
|
|
|
업무 command를 추가하면 별도 closed operation registry, command ID,
|
|
idempotency, expected revision, authorization, ack/commit certainty와 retry
|
|
규칙을 정의한다. `send()` 반환 또는 `bufferedAmount` 감소는 server acceptance나
|
|
business commit이 아니다.
|
|
|
|
### 7.4 Heartbeat와 close
|
|
|
|
browser API는 protocol ping/pong을 application에 노출하지 않으므로 backend
|
|
transport ping만으로 application freshness를 판정하지 않는다. 필요하면 위의
|
|
bounded application heartbeat/ack를 사용한다.
|
|
|
|
- server `WELCOME`이 허용된 heartbeat range를 고정한다.
|
|
- 한 번에 하나의 outstanding nonce만 둔다.
|
|
- ack deadline은 visibility와 network state를 고려한다.
|
|
- timeout은 connection을 close하고 common reconnect owner로 넘긴다.
|
|
- heartbeat timer를 route component마다 만들지 않는다.
|
|
|
|
close code/reason은 raw 문자열을 application이나 telemetry에 노출하지 않고
|
|
closed category로 mapping한다.
|
|
|
|
| category | 예시 의미 | reconnect |
|
|
| --- | --- | --- |
|
|
| `NORMAL` | intentional close/drain | 아니오 |
|
|
| `RESTART` | server deploy/restart | bounded |
|
|
| `OVERLOADED` | server capacity/rate | exact server hint가 있을 때만 bounded; 없으면 terminal |
|
|
| `AUTH_REQUIRED` | session expired | single-flight recovery 뒤 한 번 |
|
|
| `FORBIDDEN` | policy/authorization | 아니오 |
|
|
| `PROTOCOL_MISMATCH` | version/schema/subprotocol | 아니오 |
|
|
| `CURSOR_RESET` | replay 불가 | snapshot 뒤 새 connection |
|
|
| `NETWORK_LOST` | abnormal/network | bounded |
|
|
|
|
server private reason, stack, resource ID나 credential text를 UI/log에 복사하지
|
|
않는다.
|
|
|
|
### 7.5 Backpressure
|
|
|
|
classic browser WebSocket은 incoming stream backpressure를 제공하지 않는다.
|
|
따라서 다음 상한이 필수다.
|
|
|
|
- raw frame/decompressed frame bytes
|
|
- sequential inbound queue count와 bytes
|
|
- per-subscription event rate
|
|
- parsing/apply deadline
|
|
- outbound `bufferedAmount`
|
|
- outbound queued message count/bytes
|
|
|
|
inbound queue overflow에서 오래된 delta를 임의로 버리고 계속하지 않는다.
|
|
classic browser WebSocket baseline은 receive pause가 없으므로 connection을
|
|
close하고 freshness를 `UNKNOWN`으로 바꾼 뒤 snapshot resync한다. server가
|
|
bounded `PAUSE`/`PAUSED`/`RESUME` control ACK와 발신 중단을 별도 protocol로
|
|
증명한 profile에서만 subscription pause를 허용한다.
|
|
|
|
outbound ceiling을 넘으면 새 ephemeral message를 typed `DROPPED_BACKPRESSURE`로
|
|
거절하거나 connection을 drain한다. durable command를 memory queue에 무제한
|
|
쌓지 않는다. offline command queue는 별도 `NOT_SELECTED` capability다.
|
|
reference runtime의 `SUBSCRIBE`/`UNSUBSCRIBE`/heartbeat/`CLOSE`는 하나의 FIFO
|
|
outbound queue를 통과하며, negotiated message count/queued bytes와 native
|
|
`bufferedAmount`를 함께 검사한다. 하나라도 넘으면 `QUEUE_OVERFLOW`,
|
|
`retryable=false`, `OVERLOADED`로 connection generation 전체를 닫고 snapshot
|
|
recovery를 요청한다.
|
|
|
|
## 8. Web Push와 persistent notification
|
|
|
|
### 8.1 다른 transport와의 관계
|
|
|
|
Web Push의 목적은 active document가 없을 수 있는 상태에서 Service Worker를 통해
|
|
작은 notification hint를 받는 것이다.
|
|
|
|
```text
|
|
application server
|
|
-> authorized subscription registry
|
|
-> Web Push provider / push service
|
|
-> browser PushSubscription
|
|
-> Service Worker push event
|
|
-> strict hint validation
|
|
-> persistent notification
|
|
-> notificationclick
|
|
-> safe route + authoritative foreground refresh
|
|
```
|
|
|
|
Web Push는 다음을 보장하지 않는다.
|
|
|
|
- 즉시 delivery
|
|
- ordered delivery
|
|
- exactly-once
|
|
- 사용자 확인/read receipt
|
|
- silent background synchronization
|
|
- SSE/WebSocket과 같은 live connection
|
|
- push payload만으로 최신 server state
|
|
|
|
push가 늦거나 유실돼도 app을 열었을 때 HTTP snapshot/focus refetch가 올바른
|
|
상태로 수렴해야 한다.
|
|
|
|
W3C Push API의 declarative push message는 Service Worker handler가 실행되지
|
|
않아도 user agent가 notification을 표시하거나 handler 실패 시 fallback 표시를
|
|
할 수 있다. V1에서는 이 경로를 `NOT_SELECTED`로 둔다. outbound payload의
|
|
top-level `web_push: 8030` declarative shape를 금지하고 오직 encrypted
|
|
`WEB_PUSH_HINT_V1`만 보낸다. declarative profile은 local association fence,
|
|
closed copy/click route와 logout cleanup을 동등하게 보장하는 별도 ADR 전에는
|
|
선택하지 않는다.
|
|
|
|
### 8.2 Permission UX
|
|
|
|
- secure context와 browser support를 먼저 확인한다.
|
|
- page boot, route enter, sign-in 직후 자동 prompt를 금지한다.
|
|
- 사용자가 notification 가치와 빈도를 이해한 뒤 명시적 action으로 요청한다.
|
|
- `default`, `granted`, `denied`, `unsupported`, `dismissed`를 다른 결과로
|
|
표현한다.
|
|
- denied를 반복 prompt로 우회하지 않는다.
|
|
- 기능 핵심 경로는 push permission 없이도 foreground inbox/status로 접근할 수
|
|
있어야 한다.
|
|
- consent copy, notification category, quiet hours와 해제 경로를 제품 owner가
|
|
승인한다.
|
|
|
|
notification permission은 application authorization이 아니다. push를 켰다는
|
|
이유로 account/resource 접근 권한을 부여하지 않는다.
|
|
|
|
### 8.3 Subscription lifecycle
|
|
|
|
```text
|
|
user opt-in
|
|
-> active service worker registration verify
|
|
-> permission request
|
|
-> VAPID public applicationServerKey verify
|
|
-> PushManager.subscribe(userVisibleOnly = true)
|
|
-> capture endpoint + p256dh + auth inside adapter
|
|
-> authenticated fixed BFF registration
|
|
-> server returns opaque registration ID + associationEpoch + sessionBindingEpoch
|
|
-> durable local ACTIVE fence transaction completes
|
|
-> native material leaves application memory
|
|
```
|
|
|
|
- VAPID private key는 server/provider에만 존재한다. public key만 runtime config에
|
|
둘 수 있다.
|
|
- endpoint, `p256dh`, `auth`는 message delivery capability material이다.
|
|
application state, URL, local/session storage, BroadcastChannel, diagnostics와
|
|
analytics에 넣지 않는다.
|
|
- backend는 endpoint의 raw value 대신 제한된 운영 영역에서 encrypted storage와
|
|
keyed fingerprint를 사용한다.
|
|
- subscription은 account association, device/browser installation, worker scope,
|
|
permission revision과 VAPID key generation을 server record에 묶는다.
|
|
- backend register/revoke는 VD-23 registry의 fixed `COMMAND` operation이다.
|
|
cookie session이면 exact CSRF profile이 필수다. register는 keyed idempotency
|
|
또는 server atomic installation upsert + terminal receipt를 제공하고,
|
|
revoke는 duplicate/`ALREADY_GONE`을 성공으로 닫는 idempotent 의미를 가진다.
|
|
`associationEpoch`은 server commit 뒤에만 반환한다.
|
|
- browser 또는 push service가 subscription을 rotate/deactivate할 수 있으므로
|
|
`pushsubscriptionchange` 하나에 의존하지 않는다. boot/foreground의
|
|
`PushManager.getSubscription()`, authenticated server reconciliation,
|
|
expiration, registration response와 provider `404/410` cleanup을 함께 처리한다.
|
|
- `pushsubscriptionchange` worker handoff 자체도 lifecycle AbortSignal과 10초
|
|
deadline 안에서만 controlled window에 reconciliation hint를 보낸다.
|
|
`matchAll()`이 signal을 따르지 않거나 `waitUntil()`이 동기 예외를 내도 늦은
|
|
`postMessage`를 허용하지 않고 degraded observation으로 닫는다.
|
|
- re-subscribe가 실패하면 notification을 끄고 foreground fallback을 유지한다.
|
|
|
|
`associationEpoch`은 backend registration authority가 발급한다.
|
|
`releaseEpoch`은 signed/verified release manifest에서 온다. backend registration
|
|
뒤 local `ACTIVE` commit 전에 crash할 수 있으므로 boot에서 native subscription,
|
|
local fence와 server association을 모두 대조하기 전에는 push를 `ACTIVE`로
|
|
표시하지 않는다. orphan server record는 revoke하고, native/local만 남은 상태는
|
|
재인가·재등록하거나 fail-closed unsubscribe한다.
|
|
|
|
logout/account switch:
|
|
|
|
1. durable `fenceGeneration`을 먼저 rotate하고 association을 `REVOKED`로 같은
|
|
transaction에 commit한다.
|
|
2. window의 old runtime generation을 fence한다.
|
|
3. authenticated backend association revoke를 bounded request로 실행한다.
|
|
4. 제품 정책에 따라 native `unsubscribe()`를 best effort로 실행한다.
|
|
5. old association tag로 표시한 notification을 bounded `getNotifications()`로
|
|
찾아 best-effort close한다.
|
|
6. 새 account opt-in은 기존 consent와 association 정책을 다시 평가한다.
|
|
|
|
backend revoke가 ambiguous하면 server subscription은 짧은 association TTL,
|
|
session/account epoch 확인과 send-time authorization으로 추가 방어한다.
|
|
local `REVOKED` transaction이 실패하면 “late push를 반드시 drop했다”고 주장하지
|
|
않는다. push result를 `PUSH_UNAVAILABLE(reason=LOCAL_FENCE_UNSAFE)`로 내리고
|
|
captured old association ID의 backend revoke만 bounded하게 시도할 수 있다.
|
|
현재 native subscription을 다시 조회해 unsubscribe하거나 association wildcard로
|
|
notification을 닫지 않는다. 정상 cleanup도 작업 시작 때 capture한 native
|
|
subscription, exact non-null association tag와 변경되지 않은 durable fence를
|
|
다시 확인한 경우에만 수행하며, 그 사이 새 association이 commit되면 모두
|
|
건너뛴다.
|
|
logout/account switch 자체는
|
|
account-neutral notification copy, click-time session 재인가와 짧은 server TTL에
|
|
의존하며, 복구 전 새 account에 기존 association을 재사용하지 않는다.
|
|
|
|
`CONTROL_PURGE`는 정상 revoke 뒤 자동 실행하지 않는다. 별도 retention/maintenance
|
|
owner가 exact `REVOKED` association을 정리할 때만 captured revision, authority와
|
|
association epoch를 모두 제시하고 repository revision CAS로 삭제한다. purge와
|
|
새 owner가 경합하면 `STALE_REVISION`으로 끝나며 새 control record를 지우지 않는다.
|
|
|
|
worker는 window의 in-memory session을 authority로 사용할 수 없다. Web Push를
|
|
선택하면 다음과 같은 최소 control record를 adapter-owned IndexedDB store에
|
|
보관한다.
|
|
|
|
```ts
|
|
type PushControlV1 = Readonly<{
|
|
protocol: "PUSH_CONTROL_V1";
|
|
fenceGeneration: string;
|
|
sessionBindingEpoch: string;
|
|
releaseEpoch: string;
|
|
association:
|
|
| Readonly<{ state: "UNASSOCIATED" }>
|
|
| Readonly<{
|
|
state: "ACTIVE" | "REVOKED";
|
|
associationEpoch: string;
|
|
}>;
|
|
updatedAt: string;
|
|
}>;
|
|
```
|
|
|
|
이 record에는 account/user ID, endpoint, `p256dh`, `auth`, notification content와
|
|
credential을 넣지 않는다. `fenceGeneration`은 session authority가 먼저 rotate하는
|
|
local opaque token이고 `sessionBindingEpoch`은 authenticated BFF가 발급한 opaque
|
|
exact-match 값이다. 둘 다 authorization proof나 readable subject ID가 아니다.
|
|
worker는 push hint의 association/release epoch와 exact match하고 association이
|
|
`ACTIVE`일 때만 처리한다. record가 missing/corrupt/future version이면
|
|
notification을 표시하지 않는다. 이 control store는 query persistence나 offline
|
|
data repository가 아니며 Web Push composition과 함께 설치·제거한다. open,
|
|
upgrade, transaction-complete, `blocked`/`versionchange`, corruption/eviction과
|
|
exact store purge는 VD-11/VD-15의 IndexedDB lifecycle을 재사용한다.
|
|
|
|
`UNASSOCIATED`는 첫 backend register 전에도 durable generation을 만들기 위한
|
|
명시적 상태다. register request는 이 record의 revision과 authority 세 필드를
|
|
capture한다. logout/account switch는 같은 record transaction에서 generation을
|
|
먼저 rotate하므로, 아직 `associationEpoch`을 받지 못한 old register callback도
|
|
CAS에 실패한다. backend가 발급하지 않은 sentinel association epoch을 만들거나
|
|
서로 다른 두 record의 비원자 갱신으로 이 race를 닫지 않는다.
|
|
|
|
같은 `associationEpoch`의 `REVOKED`는 terminal tombstone이며 `ACTIVE`로
|
|
overwrite할 수 없다. 새 `ACTIVE`는 backend가 commit 뒤 발급한 distinct epoch이고
|
|
current durable `fenceGeneration`/`sessionBindingEpoch`와 register request가
|
|
captured한 값이 exact match할 때만 허용한다. writer는 한 IndexedDB read-write
|
|
transaction 안에서 current state, expected record revision/association epoch,
|
|
durable fence generation, server session binding과 release epoch를 CAS 검증한다.
|
|
logout이 먼저 fence generation을 rotate했으면 old callback의 distinct
|
|
association epoch도 거절한다. `updatedAt`과 client wall clock은 ordering
|
|
authority가 아니다.
|
|
|
|
### 8.4 Push envelope와 worker 처리
|
|
|
|
Web Push payload는 foreground event envelope 전체를 옮기지 않는다.
|
|
|
|
```ts
|
|
type WebPushHintV1 = Readonly<{
|
|
protocol: "WEB_PUSH_HINT_V1";
|
|
notificationType: NotificationTypeId;
|
|
notificationId: string;
|
|
associationEpoch: string;
|
|
releaseEpoch: string;
|
|
issuedAt: string;
|
|
expiresAt: string;
|
|
routeIntent: NotificationRouteIntentId;
|
|
}>;
|
|
```
|
|
|
|
- application-owned hard byte cap을 push service/provider limit보다 작게 둔다.
|
|
- 개인 내용, message body, sender name, order amount 같은 민감한 본문을 기본
|
|
payload에 넣지 않는다.
|
|
- `notificationId`는 foreground BFF가 현재 authorization으로 내용을 조회할
|
|
opaque reference다.
|
|
- 최상위 JSON member name이 중복되면 JSON의 last-wins 해석을 허용하지 않고
|
|
payload를 거절한다.
|
|
- `issuedAt`은 client clock보다 최대 5분 앞설 수 있고,
|
|
`expiresAt - issuedAt`은 최대 24시간이다. 이 범위를 넘는 issue/expiry,
|
|
association과 release mismatch는 drop한다.
|
|
- unknown type/route/version과 malformed payload는 generic notification으로
|
|
downgrade하지 않고 fail-closed한다.
|
|
- payload가 없는 push를 사용할지 여부도 explicit registry policy다.
|
|
|
|
worker handler:
|
|
|
|
```text
|
|
push event
|
|
-> event.waitUntil(bounded handler)
|
|
-> byte/schema/association/release/expiry validation
|
|
-> duplicate/collapse policy
|
|
-> registry-owned localized safe copy
|
|
-> registration.showNotification
|
|
```
|
|
|
|
Service Worker는 언제든 종료될 수 있다. in-memory cursor, queue, auth session이나
|
|
timer를 authority로 사용하지 않는다. `waitUntil`도 무제한 lifetime을 보장하지
|
|
않으므로 large fetch, retry loop, data migration을 넣지 않는다.
|
|
|
|
### 8.5 Notification content와 click
|
|
|
|
- title/body/icon/action은 closed notification registry가 만든다.
|
|
- backend raw text를 그대로 OS notification에 렌더링하지 않는다.
|
|
- 잠금 화면 노출을 고려해 default copy는 민감하지 않아야 한다.
|
|
- `tag`/collapse는 notification type과 non-reversible association tag token으로
|
|
bounded하게 만든다. raw account/resource ID나 cache scope fingerprint를 쓰지
|
|
않는다.
|
|
- action 수와 지원은 capability probe에 따라 degrade한다.
|
|
- `requireInteraction`, sound/vibration과 높은 urgency를 기본값으로 사용하지
|
|
않는다.
|
|
|
|
`notificationclick`은 arbitrary URL을 열지 않는다.
|
|
|
|
```ts
|
|
type NotificationClickDataV1 = Readonly<{
|
|
protocol: "NOTIFICATION_CLICK_DATA_V1";
|
|
notificationId: string;
|
|
routeIntent: NotificationRouteIntentId;
|
|
associationEpoch: string;
|
|
releaseEpoch: string;
|
|
expiresAt: string;
|
|
}>;
|
|
```
|
|
|
|
worker가 종료·재시작된 뒤에도 click을 처리할 수 있도록 이 bounded, non-sensitive
|
|
envelope만 `NotificationOptions.data`에 넣는다. click handler는 codec/size/expiry,
|
|
current local `ACTIVE` fence와 release epoch를 다시 검증한다. unknown/mismatch는
|
|
route를 열지 않고 notification을 닫는다. 이미 표시된 notification은 logout 뒤
|
|
OS notification center에 남을 수 있으므로 copy는 항상 account-neutral하고
|
|
민감하지 않아야 하며, `getNotifications()` cleanup 성공을 privacy 보장으로
|
|
과장하지 않는다.
|
|
|
|
```text
|
|
NotificationClickDataV1
|
|
-> codec/size/expiry + ACTIVE fence
|
|
-> closed route intent
|
|
-> route registry lookup
|
|
-> same-origin safe path encode
|
|
-> existing controlled client focus or openWindow
|
|
-> page boot/session recovery
|
|
-> authoritative notification/resource fetch
|
|
```
|
|
|
|
route intent가 stale/unknown이면 안전한 notification inbox/home으로 이동한다.
|
|
notification action 자체에서 destructive 업무 mutation을 실행하려면 별도의
|
|
confirmed UI 또는 idempotent authorized server command 설계가 필요하다.
|
|
|
|
### 8.6 Provider와 browser evidence
|
|
|
|
- server-side subscription registration/revoke authorization
|
|
- VAPID signing key rotation과 private-key custody
|
|
- RFC 8291 payload encryption과 provider request conformance
|
|
- TTL, urgency, topic/collapse와 retry policy
|
|
- provider `404/410/429/5xx` cleanup/retry
|
|
- endpoint/key redaction과 data retention/deletion
|
|
- target browser 설치/permission/subscription/push/notification/click
|
|
- OS/browser notification setting change와 subscription rotation
|
|
- Service Worker update/rollback 중 handler compatibility
|
|
|
|
synthetic `PushEvent` unit test만으로 실제 provider delivery를 증명하지 않는다.
|
|
permission prompt와 OS notification은 자동화 가능 범위와 수동 evidence를
|
|
분리한다.
|
|
|
|
## 9. 제한된 Polling
|
|
|
|
### 9.1 허용하는 두 형태
|
|
|
|
1. **freshness polling**: visible 화면의 server-state query를 낮은 빈도로
|
|
conditional refetch한다.
|
|
2. **convergence polling**: 사용자가 시작한 async job이 terminal state에
|
|
도달할 때까지만 application orchestrator가 조회한다.
|
|
|
|
무한 `setInterval`, hidden tab polling, 여러 component의 중복 loop와 짧은
|
|
주기의 push emulation은 금지한다.
|
|
|
|
### 9.2 Poll lease
|
|
|
|
모든 polling은 immutable lease를 가진다.
|
|
|
|
```ts
|
|
type PollLeasePolicy = Readonly<{
|
|
operationId: ApiOperationId;
|
|
owner: QueryInvalidationTopic | UseCaseId;
|
|
minimumIntervalMs: number;
|
|
successIntervalMs: number;
|
|
maxIntervalMs: number;
|
|
maxAttempts: number;
|
|
maxElapsedMs: number;
|
|
maxResponseBytes: number;
|
|
visibility: "VISIBLE_ONLY";
|
|
fallbackReason: PollFallbackReason;
|
|
terminalStates: readonly PollTerminalState[];
|
|
}>;
|
|
```
|
|
|
|
implementation absolute ceiling보다 느슨한 값, zero interval, unlimited attempt,
|
|
unknown terminal state와 arbitrary operation ID는 startup에서 거절한다.
|
|
`operationId`는 existing API registry의 terminal `QUERY`, `REST`,
|
|
`SAFE | IDEMPOTENT`, non-`SERVER_STREAM` operation이어야 한다. command/mutation,
|
|
GraphQL subscription, Connect/gRPC-Web server stream과 arbitrary status URL은 poll하지
|
|
않는다. Poll-bound operation의 transport retry profile과 TanStack Query retry는
|
|
`NONE`/`false`여야 한다. 구체적으로 VD-23 `LogicalExecutionBudget`은
|
|
`maxAttempts=1`, `authRecoveryCount=0`, `maxCumulativeSleepMs=0`으로 고정해
|
|
executor 내부 status retry나 401 replay가 일어나지 않게 한다.
|
|
|
|
한 lease는 다음 조건 중 먼저 발생하는 시점에 종료한다.
|
|
|
|
- terminal state
|
|
- max attempts
|
|
- max elapsed
|
|
- request/response budget
|
|
- route unmount 또는 consumer 0
|
|
- hidden/pagehide
|
|
- offline
|
|
- session/account/release generation change
|
|
- authorization required/forbidden
|
|
- user cancel
|
|
- primary stream recovery로 fallback이 더 이상 필요 없음
|
|
|
|
종료 뒤 자동으로 새 lease를 만들지 않는다. visible 복귀, user retry 또는
|
|
coordinator의 명시된 state transition이 새 lease를 발급한다.
|
|
|
|
### 9.3 Scheduling
|
|
|
|
```text
|
|
lease admitted
|
|
-> wait jittered cadence
|
|
-> verify visible/online/scope/generation
|
|
-> start exactly one request with AbortSignal
|
|
-> validate bounded response
|
|
-> terminal? close lease
|
|
-> unchanged? success cadence
|
|
-> retryable failure? capped backoff/Retry-After
|
|
-> budget remaining? next attempt
|
|
-> otherwise stale/degraded
|
|
```
|
|
|
|
- `setTimeout` completion chaining으로 single-flight를 보장한다.
|
|
- interval tick이 이전 request와 겹치지 않는다.
|
|
- success cadence와 failure backoff를 구분한다.
|
|
- Poll `maxAttempts`의 한 attempt는 registered REST operation의 한 logical
|
|
completion이자 한 physical request다. credential recovery, decode와 mapping
|
|
시간도 lease `maxElapsed`에 포함하며 transport 내부 sleep/replay로 lease
|
|
budget을 우회하지 않는다.
|
|
- `401`은 해당 attempt를 끝낸다. session owner가 recovery한 뒤 요청을 다시
|
|
보내면 새 Poll attempt/physical request로 계산한다.
|
|
- `Retry-After`는 local backoff보다 빠르게 만들지 않으며 maximum wait/lease
|
|
budget을 넘으면 낮춰서 더 일찍 요청하지 않고 stale UI로 종료한다.
|
|
- immediate manual refresh가 실행되면 pending poll을 취소하거나 같은 Query
|
|
execution을 공유한다.
|
|
- SSE/WebSocket reconnect와 fallback polling이 동시에 authoritative refetch를
|
|
소유하지 않도록 generation 하나만 active owner가 된다.
|
|
|
|
### 9.4 HTTP contract
|
|
|
|
freshness polling은 가능하면 conditional request를 사용한다.
|
|
|
|
- server-issued ETag + `If-None-Match`
|
|
- `304`는 representation unchanged이며 response body가 없어야 한다.
|
|
- weak/strong validator 의미는 endpoint owner가 정한다.
|
|
- `Cache-Control`과 Query stale semantics를 혼동하지 않는다.
|
|
- cursor/since endpoint를 사용하면 cursor expiry와 full snapshot reset을
|
|
명시한다.
|
|
- `401/403/404/409/410/429/5xx`를 closed failure로 mapping한다.
|
|
- `429/503`의 bounded `Retry-After`를 존중한다.
|
|
- response byte와 decode deadline을 기존 HTTP operation registry가 제한한다.
|
|
|
|
`304`라도 current account scope와 lease generation이 바뀌었으면 결과를
|
|
적용하지 않는다.
|
|
|
|
### 9.5 Polling을 fallback으로 사용할 때
|
|
|
|
SSE/WS 장애에서 Polling으로 전환하려면 다음이 모두 참이어야 한다.
|
|
|
|
- 같은 logical resource/freshness 의미를 snapshot endpoint가 제공한다.
|
|
- duplex-only interaction은 명시적으로 disabled UI가 된다.
|
|
- fallback transition이 새로운 generation을 발급한다.
|
|
- common `RecoveryCoordinator`가 아래 handoff state를 단독 소유한다.
|
|
- `POLL_ACTIVE`에서는 poll만 projection effect writer이고 live candidate는
|
|
bounded health/checkpoint probe와 post-checkpoint buffer만 수행한다.
|
|
- active writer의 sequential effect queue도 in-flight 항목을 포함해 256건/4MiB
|
|
중 먼저 도달하는 고정 상한을 예약한다. 비협조적인 첫 effect가 tail을 막아도
|
|
초과 요청은 즉시 `QUEUE_OVERFLOW`로 전체 generation을 fence/abort한다.
|
|
- candidate의 current authorization/checkpoint를 검증한 뒤 handoff mutex를 잡고,
|
|
poll generation을 fence/abort하며 in-flight poll quiescence를 기다린다.
|
|
- 그 뒤에만 current-generation snapshot/checkpoint를 projection에 적용하고
|
|
buffered post-checkpoint event를 drain한 뒤 live effect를 활성화한다.
|
|
- 어느 단계든 실패하면 candidate를 폐기하고 새 poll generation을 발급하거나
|
|
stale/manual UX로 종료한다.
|
|
- UI와 diagnostics가 `LIVE`와 `POLLING/STALE`를 구분한다.
|
|
|
|
```text
|
|
LIVE_ACTIVE
|
|
-> LIVE_DEGRADED
|
|
-> POLL_ACTIVE
|
|
-> LIVE_PROBING
|
|
-> handoff mutex
|
|
-> fence/abort poll + await quiescence
|
|
-> current-generation snapshot/checkpoint apply
|
|
-> buffered post-checkpoint event drain
|
|
-> LIVE_ACTIVE
|
|
```
|
|
|
|
fallback을 사용해 provider 장애를 production 정상으로 숨기지 않는다.
|
|
freshness SLO를 충족하지 못하면 `DEGRADED`다.
|
|
|
|
## 10. Query cache와 application effect
|
|
|
|
### 10.1 기본 invalidation 흐름
|
|
|
|
```text
|
|
validated external event
|
|
-> feature event input
|
|
-> INVALIDATE(topic)
|
|
-> presentation query bridge
|
|
-> current scope의 active query invalidate/refetch
|
|
-> server authorization + schema + mapper
|
|
-> cache update
|
|
```
|
|
|
|
event handler가 raw `QueryClient`, query key array나 native transport를 직접
|
|
받지 않는다. query namespace와 key codec은 client-cache registry가 소유한다.
|
|
|
|
event burst에서 같은 namespace invalidation은 bounded window 안에서 coalesce할
|
|
수 있다. 이 최적화는 last cursor 적용 순서와 gap detection을 바꾸지 않는다.
|
|
|
|
### 10.2 Authoritative delta를 허용하는 조건
|
|
|
|
다음이 모두 있을 때만 event payload로 cache projection을 직접 갱신한다.
|
|
|
|
- event type별 exact payload codec
|
|
- server commit 뒤 발행 증거
|
|
- entity/collection의 base revision과 resulting revision
|
|
- 동일 revision의 idempotent reducer
|
|
- gap/out-of-order/epoch reset 처리
|
|
- 현재 query filter/sort/pagination에 미치는 의미
|
|
- account scope/generation fence
|
|
- snapshot reconciliation test
|
|
|
|
하나라도 없으면 invalidation/refetch를 사용한다. partial patch를 모든 list/detail
|
|
cache에 추측해서 적용하지 않는다.
|
|
|
|
### 10.3 External event와 business command
|
|
|
|
SSE/WebSocket/Push handler는 application input을 호출할 수 있지만, 외부 event를
|
|
사용자 command와 같은 authorization으로 취급하지 않는다. server가 이미 commit한
|
|
사실을 projection에 반영하거나 user-visible notification을 만드는 입력이다.
|
|
|
|
WebSocket client command를 선택해도 optimistic state, ack, server commit,
|
|
event echo와 HTTP refetch의 순서를 별도 protocol로 정의한다. ack만으로
|
|
authoritative query data를 덮지 않는다.
|
|
|
|
## 11. Resource budget와 backpressure
|
|
|
|
다음 값은 target reference runtime의 절대 상한이다. 제품 registry는 더 작게
|
|
설정할 수 있지만 높일 수 없다. 실제 구현 branch는 bundle/browser 측정으로 값을
|
|
검토하고 변경 시 ADR amendment를 남긴다.
|
|
|
|
| 자원 | target implementation ceiling |
|
|
| --- | --- |
|
|
| physical foreground connection | runtime당 primary 1, drain handoff 포함 일시적 2 |
|
|
| WebSocket logical subscriptions | runtime당 32; SSE baseline은 feed 1 |
|
|
| decoded foreground event/frame | 64 KiB |
|
|
| SSE incomplete parser buffer | 128 KiB |
|
|
| inbound apply queue | 256 event 또는 4 MiB 중 먼저 도달 |
|
|
| live↔Poll active effect/probe queue | 각각 256 event 또는 4 MiB 중 먼저 도달 |
|
|
| dedupe window | stream당 2,048 ID 또는 10분 중 먼저 만료 |
|
|
| reorder buffer | 기본 0; 선택 시 64 event, 2초 |
|
|
| WebSocket outbound queued bytes | 256 KiB |
|
|
| WebSocket outbound queued messages | 128 |
|
|
| reconnect attempts | 연속 10회 또는 5분 |
|
|
| reconnect max delay | 60초 |
|
|
| aborted reconnect task drain | 2초 고정; 구현 절대 최대 30초 |
|
|
| transport recovery readiness/barrier gate | attempt당 30초 |
|
|
| live↔Poll handoff quiescence/checkpoint | 단계당 30초 |
|
|
| poll minimum interval | 5초 |
|
|
| poll lease | 120회 또는 30분 |
|
|
| Web Push decoded application hint | 3 KiB |
|
|
| Web Push hint future clock skew | 5분 |
|
|
| Web Push hint maximum lifetime | 24시간 |
|
|
| worker push/click/subscription-change handler application deadline | 10초 |
|
|
| window native permission/subscription operation deadline | operation당 30초 |
|
|
| backend register/reconcile/revoke operation deadline | operation당 15초 |
|
|
| Push association fence transaction | operation당 2초, open/transaction 한 번 |
|
|
| notification cleanup scan | association당 64개 또는 2초 |
|
|
|
|
이 표의 reference ceiling은 deterministic runtime의 fail-closed 경계로
|
|
구현됐다. 실제 backend/provider/target-browser evidence와 제품별 ceiling 승인은
|
|
아직 pending이며, 그 전에는 `AVAILABLE_NOT_COMPOSED`를 넘지 않는다.
|
|
|
|
reconnect drain 상한은 offline/abort로 phase가 이미 중단된 뒤의
|
|
`sleep`/connect attempt/closed-receipt cleanup에만 적용한다. 정상 active
|
|
session의 `waitClosed`에는 deadline을 두지 않는다. 2초 안에 정리되지 않은
|
|
task는 run을 fail-closed로 끝내되 underlying task가 settle할 때까지 lifecycle을
|
|
`DRAINING`으로 유지하여 다음 physical connection을 허용하지 않는다.
|
|
|
|
미선택 reference source 전체를 tree-shaking 없이 합성하는 optional-recipe
|
|
측정의 gzip 상한은 40,000 bytes다. 이 값은 production bundle 허용량이 아니라
|
|
RT-01~RT-04 reference runtime의 회귀 예산이며, production asset에는 계속
|
|
미선택 realtime module이 0이어야 한다.
|
|
|
|
ceiling 도달은 다음처럼 fail-closed한다.
|
|
|
|
- oversized event/frame/push: protocol rejection, apply 금지
|
|
- inbound queue: freshness `UNKNOWN`, connection close와 snapshot resync
|
|
- outbound queue: typed backpressure rejection, durable command enqueue 금지
|
|
- reconnect/poll budget: degraded/stale UI, timer 종료
|
|
- subscription count: 새 lease 거절
|
|
- worker deadline: bounded handler 종료, 무제한 retry 금지
|
|
|
|
event rate가 지속적으로 ceiling 근처라면 limit을 자동으로 높이지 않고 snapshot,
|
|
aggregation, server coalescing 또는 다른 protocol을 재설계한다.
|
|
|
|
## 12. 인증, 보안과 privacy
|
|
|
|
### 12.1 인증
|
|
|
|
- HTTPS/WSS만 허용한다.
|
|
- endpoint는 composition registry의 fixed URL이다.
|
|
- credential은 external session owner와 transport adapter 안에 남는다.
|
|
- URL/query, WebSocket subprotocol, event/push payload, cursor, storage와
|
|
telemetry에 credential을 넣지 않는다.
|
|
- connect 성공 뒤에도 logical subscribe/command와 snapshot을 server가 각각
|
|
authorization한다.
|
|
- logout/account switch는 local generation fence와 server association/session
|
|
revoke를 모두 수행한다.
|
|
- route guard, event scope binding과 push permission은 authorization proof가
|
|
아니다.
|
|
|
|
native EventSource/WebSocket의 custom-header 제약 때문에 bearer token을 query로
|
|
옮기지 않는다. same-origin BFF/cookie 또는 보안 검토를 통과한 별도 handshake
|
|
mechanism을 선택한다.
|
|
|
|
### 12.2 Input validation과 abuse control
|
|
|
|
- raw bytes → strict schema → mapper 순서를 지킨다.
|
|
- unknown field/version/event/message/close category는 fail-closed한다.
|
|
- object depth, array/property/string count, decoded bytes와 event rate를 제한한다.
|
|
- WebSocket decompressed size와 outbound `bufferedAmount`를 제한한다.
|
|
- SSE/WS endpoint는 origin, connection count, subscription count와 per-subject
|
|
rate limit을 server에서 강제한다.
|
|
- push provider는 VAPID, encrypted payload, TTL과 send-time authorization을
|
|
강제한다.
|
|
- notification click route는 same-origin closed registry만 허용한다.
|
|
- CSP `connect-src`, worker/source 정책과 hosting security header를 실제
|
|
deployment에서 검증한다.
|
|
|
|
### 12.3 금지하는 관측·저장 값
|
|
|
|
- raw endpoint URL/query
|
|
- credential/cookie/token
|
|
- account/tenant/user ID와 email
|
|
- event ID, resume cursor, stream epoch와 scope binding 원문
|
|
- raw event/frame/push payload
|
|
- WebSocket raw close reason
|
|
- PushSubscription endpoint, `p256dh`, `auth`
|
|
- notification private content
|
|
- server/backend exception text
|
|
|
|
허용 속성은 registry ID, transport kind, closed failure/category, attempt/count,
|
|
queue/lag/duration bucket과 low-cardinality environment ID다.
|
|
|
|
`NotificationOptions.data`에는 §8.5의 bounded
|
|
`NotificationClickDataV1`만 예외적으로 저장할 수 있다. 이것은 opaque,
|
|
non-sensitive click envelope이며 credential, account/resource ID와 자유 문구를
|
|
추가하는 예외가 아니다.
|
|
|
|
`fenceGeneration`과 `sessionBindingEpoch`은 §8.3의 adapter-owned IndexedDB
|
|
control record 안에서만 저장할 수 있다. generic storage, URL,
|
|
BroadcastChannel/telemetry로 복사하지 않는다. foreground cursor/scope binding은
|
|
bounded adapter memory에만 존재하고 document restore에서는 authoritative
|
|
checkpoint를 다시 얻는다.
|
|
|
|
client-side encryption만으로 같은 runtime의 XSS에서 push endpoint, cached payload나
|
|
credential을 보호할 수 있다고 주장하지 않는다.
|
|
|
|
## 13. Closed failure와 recovery
|
|
|
|
### 13.1 Failure vocabulary
|
|
|
|
| failure | 의미 | 기본 recovery |
|
|
| --- | --- | --- |
|
|
| `ABORTED` | lifecycle/user cancellation | retry 없음 |
|
|
| `UNSUPPORTED` | browser capability 없음 | declared fallback |
|
|
| `OFFLINE` | network hint/attempt failure | online hint 뒤 bounded check |
|
|
| `CONNECT_TIMEOUT` | open deadline 초과 | bounded reconnect |
|
|
| `IDLE_TIMEOUT` | heartbeat/byte deadline 초과 | close + bounded reconnect |
|
|
| `AUTH_REQUIRED` | session expired/recovery 실패 | sign-in UX |
|
|
| `FORBIDDEN` | stream/resource 거절 | retry 없음 |
|
|
| `RATE_LIMITED` | connection/request/provider limit | bounded server hint |
|
|
| `PROVIDER_UNAVAILABLE` | backend/push/hosting unavailable | bounded retry 또는 degraded |
|
|
| `PROTOCOL_MISMATCH` | version/subprotocol/content type | terminal/rollout rollback |
|
|
| `MALFORMED_EVENT` | state-bearing syntax/schema/exact-key 실패 | cursor 미진행, freshness `UNKNOWN`, close + registered recovery |
|
|
| `MAPPING_CONTRACT_VIOLATION` | validated DTO의 registered mapper 실패 | effect/cache/cursor write 0, close + registered recovery |
|
|
| `EVENT_CONFLICT` | 같은 ID/sequence가 다른 event를 가리킴 | terminal protocol conflict + resync/rollback |
|
|
| `EVENT_TOO_LARGE` | byte ceiling 초과 | close/resync, retry storm 금지 |
|
|
| `DUPLICATE_EVENT` | 이미 처리 | safe ignore |
|
|
| `STALE_EVENT` | old/out-of-order | safe ignore + 관측 |
|
|
| `SEQUENCE_GAP` | missing event | snapshot resync |
|
|
| `CURSOR_EXPIRED` | replay retention 밖 | snapshot reset |
|
|
| `QUEUE_OVERFLOW` | backpressure ceiling | close + registered snapshot/session rebuild |
|
|
| `APPLY_FAILED` | feature effect 실패 | cursor 미commit, bounded registered recovery |
|
|
| `POLL_BUDGET_EXHAUSTED` | lease 한도 도달 | stale/manual retry |
|
|
| `PUSH_PERMISSION_DENIED` | 사용자 거절 | foreground fallback |
|
|
| `PUSH_SUBSCRIPTION_STALE` | rotation/expiry/provider gone | bounded re-registration |
|
|
| `NOTIFICATION_REJECTED` | invalid/expired/scope mismatch | safe drop |
|
|
| `SCOPE_FENCED` | old account/release generation | safe drop |
|
|
| `SCOPE_PROTOCOL_VIOLATION` | current connection event의 scope binding mismatch | effect/cursor 0, close + session revalidation/snapshot + security diagnostic |
|
|
| `CLOSED` | terminal runtime | 새 generation만 생성 가능 |
|
|
|
|
raw native error, close reason이나 backend message를 이 union에 추가하지 않는다.
|
|
safe user copy는 i18n message catalog가 failure kind를 mapping한다.
|
|
drop-only malformed 처리는 명시적으로 non-state-bearing heartbeat/control frame에만
|
|
허용하며, threshold를 넘으면 protocol close한다. state-bearing event나 unknown
|
|
event type을 버리고 freshness를 `CURRENT`로 유지하지 않는다.
|
|
|
|
### 13.2 Effect certainty
|
|
|
|
| effect | certainty |
|
|
| --- | --- |
|
|
| socket `send()` returned | local API accepted bytes; server receipt 미확인 |
|
|
| WebSocket command ack | protocol-defined acceptance; business commit은 별도 |
|
|
| SSE/WS event decoded | wire valid; application effect 미commit |
|
|
| CURSOR advanced | local effect 이후 resume position commit |
|
|
| query invalidated | local cache action accepted; refetch/server commit 미확인 |
|
|
| Poll `304` | selected representation unchanged |
|
|
| push provider accepted | provider queue accepted; browser/user delivery 미확인 |
|
|
| Service Worker push fired | browser handler invoked; notification shown/clicked 미확인 |
|
|
| `showNotification()` resolved | platform show step accepted; user 확인 미확인 |
|
|
| backend subscription revoke success | 해당 association의 server revoke commit |
|
|
|
|
diagnostics와 UI는 이 의미보다 강한 “전달됨”, “읽음”, “동기화 완료”를 표시하지
|
|
않는다.
|
|
|
|
## 14. Observability
|
|
|
|
### 14.1 Closed diagnostic events
|
|
|
|
foreground:
|
|
|
|
- `realtime_runtime_state_changed`
|
|
- `realtime_connect_attempted`
|
|
- `realtime_connect_finished`
|
|
- `realtime_connection_closed`
|
|
- `realtime_heartbeat_observed`
|
|
- `realtime_event_received`
|
|
- `realtime_event_rejected`
|
|
- `realtime_event_applied`
|
|
- `realtime_gap_detected`
|
|
- `realtime_resync_finished`
|
|
- `realtime_queue_pressure`
|
|
- `realtime_fallback_changed`
|
|
|
|
polling:
|
|
|
|
- `bounded_poll_lease_started`
|
|
- `bounded_poll_attempt_finished`
|
|
- `bounded_poll_unchanged`
|
|
- `bounded_poll_rate_limited`
|
|
- `bounded_poll_lease_stopped`
|
|
|
|
push:
|
|
|
|
- `web_push_permission_finished`
|
|
- `web_push_registration_finished`
|
|
- `web_push_subscription_rotated`
|
|
- `web_push_hint_processed`
|
|
- `web_push_notification_finished`
|
|
- `web_push_click_dispatched`
|
|
- `web_push_association_revoked`
|
|
|
|
모든 event는 semantic registry에 등록하고 arbitrary payload를 받지 않는다.
|
|
diagnostics sink failure는 connection, worker handler, poll lease와 cleanup을
|
|
실패시키지 않으며 재귀 telemetry를 만들지 않는다.
|
|
|
|
### 14.2 Metrics와 SLO
|
|
|
|
- connect success/latency와 visible open uptime
|
|
- reconnect attempts, delay와 exhausted ratio
|
|
- heartbeat age/timeout
|
|
- accepted/rejected/duplicate/stale/gap event count
|
|
- event receive-to-apply lag bucket
|
|
- resync count, latency와 failure
|
|
- active physical connection/logical subscription
|
|
- queue high-water count/bytes와 overflow
|
|
- poll request, `304`, error, stop reason과 requests per lease
|
|
- push permission outcome
|
|
- active/revoked/stale subscription aggregate
|
|
- provider accepted/gone/rate-limited aggregate
|
|
- worker handler/notification/click aggregate
|
|
- fallback/degraded/stale duration
|
|
|
|
event timestamp와 client clock 차이를 정확한 network latency로 해석하지 않는다.
|
|
server/client clock skew가 있으므로 protocol ingest/apply monotonic duration과
|
|
server-side metric을 함께 사용한다.
|
|
|
|
## 15. Test와 promotion evidence
|
|
|
|
### 15.1 Pure unit/property
|
|
|
|
- transport selection matrix와 forbidden combination
|
|
- exact registry/config freeze와 ceiling
|
|
- decimal sequence boundary와 unsafe input
|
|
- duplicate/stale/gap/epoch reset
|
|
- CURSOR/non-CURSOR discriminated codec와 conditional cursor commit
|
|
- effect failure 뒤 cursor 미commit
|
|
- deterministic fake clock/random의 full-jitter backoff
|
|
- stable-open 이후에만 retry attempt reset
|
|
- queue/dedupe/reorder ceiling
|
|
- old-generation late callback drop와 current scope-binding protocol violation 분리
|
|
- effect/recovery callback lease의 `isCurrent()` commit 직전 확인과 callback
|
|
종료 뒤 영구 만료
|
|
- 외부 result/recovery commit의 own data descriptor 단일 snapshot, accessor,
|
|
Proxy 재조회, extra/inherited/symbol field 거절
|
|
- state-axis와 UI projection
|
|
- Poll lease terminal/budget/visibility
|
|
- notification route/copy registry
|
|
|
|
### 15.2 Deterministic protocol/fault contract
|
|
|
|
SSE:
|
|
|
|
- BOM, CR/LF/CRLF, comment, multi-line data와 incomplete EOF
|
|
- wrong status/content type, redirect, oversized line/event
|
|
- CURSOR direct `id`/Last-Event-ID equality와 non-CURSOR id 부재/null cursor
|
|
- disconnect before/after effect commit
|
|
- `204`, auth, rate limit, cursor reset
|
|
- native EventSource snapshot gate 중 pending invalidation drain
|
|
- exact recovery proof, readiness confirmation과 clone/missing/deadline failure
|
|
|
|
WebSocket:
|
|
|
|
- wrong/missing subprotocol
|
|
- text/binary/unknown frame
|
|
- welcome/subscribe/event/reset/heartbeat/close order
|
|
- frame/decompressed/queue/outbound buffer ceiling
|
|
- duplicate/out-of-order/gap and reconnect resume
|
|
- accepted cursor/next sequence silent advance와 initial snapshot barrier rejection
|
|
- unsubscribe tombstone, matching `UNSUBSCRIBED`, late frame drop, ACK deadline과
|
|
32회 slot 회수
|
|
- negotiated outbound count/bytes/`bufferedAmount` FIFO overflow의 terminal close
|
|
- close category와 auth recovery
|
|
|
|
Polling:
|
|
|
|
- `200/304/401/403/409/410/429/503`
|
|
- ETag/cursor, `Retry-After`, single-flight와 no-overlap
|
|
- transport max attempt 1/auth recovery 0과 401 새 Poll attempt
|
|
- max attempts/elapsed/response bytes
|
|
- hidden/offline/unmount/cancel/terminal stop
|
|
- handoff mutex, poll quiescence, snapshot/buffer ordering과 failure rollback
|
|
- non-cooperative active writer와 256건/4MiB effect queue overflow fail-close
|
|
|
|
Web Push:
|
|
|
|
- permission states
|
|
- subscription register/revoke/rotation
|
|
- endpoint/key redaction
|
|
- association fence IDB open/transaction/blocked/versionchange/missing/corrupt/eviction
|
|
- old fence generation의 distinct-epoch stale ACTIVE와 same-epoch `REVOKED`
|
|
tombstone resurrection 거절
|
|
- exact revoked association purge와 concurrent newer-owner revision CAS
|
|
- malformed/oversized/expired/wrong-scope hint
|
|
- worker `waitUntil`, handler deadline, `pushsubscriptionchange` lifecycle abort와
|
|
non-cooperative client enumeration
|
|
- safe localized notification, persisted typed click route와 old-tag cleanup
|
|
- declarative `web_push: 8030` payload rejection
|
|
- provider `404/410/429/5xx` mapping
|
|
|
|
### 15.3 Local integration server
|
|
|
|
MSW/jsdom만으로 streaming handshake, proxy flush, socket close와 Service Worker
|
|
lifecycle를 증명하지 않는다. test 전용 실제 local server를 사용해 다음을
|
|
검증한다.
|
|
|
|
- chunked SSE flush, heartbeat, abort, reconnect와 cursor replay
|
|
- WebSocket upgrade/subprotocol, server restart, heartbeat, burst와 close
|
|
- auth expiry/recovery와 scope switch
|
|
- replay retention/gap/snapshot reset
|
|
- conditional Polling과 rate limit
|
|
- provider adapter는 별도 conformance harness로 동일 scenario를 실행
|
|
|
|
### 15.4 실제 browser
|
|
|
|
승인된 Chromium, Firefox, WebKit matrix에서 built production asset으로 확인한다.
|
|
|
|
- route mount/unmount와 React StrictMode 후 connection/listener/timer leak 0
|
|
- offline/online, hidden/visible, pagehide/pageshow와 bfcache
|
|
- logout/account switch/release transition의 late event fence
|
|
- SSE stream parsing/abort와 hosting-equivalent proxy
|
|
- WebSocket buffered queue/close/reconnect
|
|
- Poll single-flight와 hidden stop
|
|
- Service Worker registration/update, push event, notification/click
|
|
- permission denied/default/unsupported UX
|
|
- CSP와 cross-origin rejection
|
|
|
|
Web Push actual delivery는 browser automation, OS/manual evidence와 provider
|
|
receipt를 분리한다. 한 engine의 synthetic event를 세 browser/provider 증거로
|
|
재사용하지 않는다.
|
|
|
|
### 15.5 Load, chaos와 security negative gate
|
|
|
|
- event burst와 sustained rate에서 memory/queue ceiling
|
|
- disconnect/restart/redeploy/replay-store unavailable
|
|
- effect commit 직전/직후 crash
|
|
- reconnect herd와 server hint
|
|
- multi-tab connection budget
|
|
- provider push burst/expired endpoint
|
|
- polling rate-limit과 backend slowdown
|
|
|
|
static/security gate는 다음을 거절해야 한다.
|
|
|
|
- adapter/composition 밖의 `new EventSource`, `new WebSocket`,
|
|
`pushManager.subscribe`
|
|
- application/domain의 native/vendor type import
|
|
- arbitrary endpoint/channel/topic/send payload
|
|
- token/cursor/PushSubscription material의 URL/storage/telemetry/BroadcastChannel
|
|
- raw event/push/close reason logging
|
|
- declarative push payload/handler bypass before a separate selection ADR
|
|
- page component `setInterval` polling
|
|
- 선택 전 production bootstrap/Service Worker composition
|
|
|
|
### 15.6 Promotion evidence
|
|
|
|
`COMPOSED`와 production traffic approval은 별도다. 최소 component evidence:
|
|
|
|
| component | 필요 증거 |
|
|
| --- | --- |
|
|
| contract | frontend/backend exact wire, cursor/reset/auth/failure agreement |
|
|
| provider | SSE hosting 또는 WS gateway 또는 Push provider conformance |
|
|
| browser | target matrix의 lifecycle/permission/stream evidence |
|
|
| operations | dashboards, alert, kill switch, drain/recovery/rollback drill |
|
|
| security/privacy | threat model, endpoint/credential/payload redaction, retention approval |
|
|
| performance | connection/request/event/push rate와 memory/battery budget |
|
|
|
|
필수 외부 증거가 없으면 `PromotionEvidence`는 `MISSING | PARTIAL`에 머물고
|
|
promotion gate result는 `FAIL_UNVERIFIED`다. fake 또는 local server로 provider
|
|
성공을 대신하지 않는다.
|
|
|
|
## 16. Optional composition, rollout과 rollback
|
|
|
|
### 16.1 Runtime composition result
|
|
|
|
선택된 runtime factory는 partial object를 외부에 반환하지 않는다.
|
|
|
|
```text
|
|
READY {
|
|
generation,
|
|
selected transport,
|
|
logical subscription facade,
|
|
freshness snapshot,
|
|
close()
|
|
}
|
|
|
|
UNAVAILABLE {
|
|
safe reason,
|
|
supported fallback,
|
|
cleanup outcome
|
|
}
|
|
```
|
|
|
|
생성 중 실패하면 factory가 `finally`에서 열린 stream/socket, readers, timers,
|
|
lifecycle listeners와 subscription draft를 역순으로 정리한 뒤 결과를 반환한다.
|
|
caller에게 partial/native handle이나 cleanup callback을 넘기지 않는다.
|
|
|
|
Web Push는 별도 result다.
|
|
|
|
```text
|
|
PUSH_READY | PUSH_PERMISSION_REQUIRED | PUSH_DENIED |
|
|
PUSH_UNSUPPORTED | PUSH_UNAVAILABLE
|
|
```
|
|
|
|
foreground realtime이 준비됐다는 이유로 push도 준비됐다고 표시하지 않는다.
|
|
|
|
### 16.2 Traffic admission
|
|
|
|
```text
|
|
DISABLED
|
|
-> SHADOW
|
|
-> CANARY
|
|
-> ENABLED
|
|
|
|
SHADOW | CANARY | ENABLED
|
|
-> DISABLED
|
|
```
|
|
|
|
- `SHADOW`는 production event가 아닌 승인된 probe/synthetic stream만 사용한다.
|
|
- `CANARY`는 subject/session hash가 아닌 server-side allowlist로 제한한다.
|
|
- transport, event stream, Poll fallback과 Web Push category는 독립 kill switch를
|
|
가진다.
|
|
- kill switch는 config fetch 실패 시 safe default `DISABLED`다.
|
|
- admission이 `DISABLED`가 되면 connection lifecycle을 `DRAINING`으로 옮겨 새
|
|
lease/send/subscription을 막고 active operation을 bounded close한 뒤
|
|
`CLOSED`로 전환한다.
|
|
|
|
rollback:
|
|
|
|
1. 새 traffic admission을 닫는다.
|
|
2. active connection/poll/push registration 작업을 drain한다.
|
|
3. stale/focus/manual refresh fallback을 노출한다.
|
|
4. backend publisher/replay/subscription compatibility window를 유지한다.
|
|
5. frontend composition과 dependency를 제거한다.
|
|
6. production bundle/worker에서 source 부재를 검증한다.
|
|
|
|
SSE에서 WebSocket으로, 또는 그 반대로 즉석 rollback하지 않는다. protocol
|
|
semantics가 다른 경우 기존 HTTP/stale fallback으로 먼저 안전하게 내린다.
|
|
|
|
## 17. 제거 가능성
|
|
|
|
### 17.1 제거 순서
|
|
|
|
foreground:
|
|
|
|
1. traffic admission `DISABLED`, connection lifecycle `DRAINING`
|
|
2. logical subscription revoke
|
|
3. Poll lease와 reconnect/backoff timer abort
|
|
4. SSE reader/EventSource 또는 WebSocket close
|
|
5. lifecycle/online listener 제거
|
|
6. cursor/dedupe/queue memory 폐기
|
|
7. composition/registry 제거
|
|
8. adapter, port, test와 dependency 제거
|
|
|
|
Web Push:
|
|
|
|
1. 새 opt-in과 server send admission 중지
|
|
2. durable fence generation rotate + local `REVOKED`를 같은 transaction에 commit
|
|
3. backend account association revoke
|
|
4. owned notification bounded close와 native unsubscribe policy 실행
|
|
5. association fence store exact purge
|
|
6. push/notification handler를 worker에서 제거
|
|
7. worker update/drain과 old client 호환 확인
|
|
8. notification registry, provider adapter와 key config 제거
|
|
9. retained subscription data를 server retention 정책대로 삭제
|
|
|
|
### 17.2 Removal gate
|
|
|
|
미선택/제거 상태에서 다음이 0이어야 한다.
|
|
|
|
- production bundle의 optional realtime/vendor sentinel
|
|
- runtime connect/subscribe/poll timer side effect
|
|
- production Service Worker push listener와 subscription request
|
|
- runtime config의 orphan endpoint/topic/key
|
|
- CSP의 불필요한 realtime/provider origin
|
|
- dependency/SBOM의 제거 대상 package
|
|
- docs/catalog의 `INSTALLED` 주장
|
|
|
|
base typecheck, architecture, unit/integration, production build, optional recipe
|
|
gate와 module inventory를 다시 실행한다. stale server subscription/replay topic과
|
|
provider key는 frontend source 삭제만으로 제거됐다고 주장하지 않는다.
|
|
|
|
## 18. Target source 배치
|
|
|
|
실제 선택 branch의 예시다. 제품 protocol에 따라 더 좁혀야 하며 지금 이 source가
|
|
있다는 뜻은 아니다.
|
|
|
|
```text
|
|
src/
|
|
application/
|
|
ports/
|
|
in/
|
|
<feature>-external-event-input.ts
|
|
out/
|
|
live-subscription-control.ts
|
|
<feature>-presence-gateway.ts
|
|
policies/
|
|
external-event-effect.ts
|
|
bounded-polling.ts
|
|
contracts/
|
|
realtime-streams.ts
|
|
realtime-events.ts
|
|
notification-types.ts
|
|
adapters/
|
|
realtime/
|
|
event-codec.ts
|
|
stream-coordinator.ts
|
|
sse/
|
|
fetch-sse-connection.ts
|
|
sse-parser.ts
|
|
websocket/
|
|
websocket-connection.ts
|
|
websocket-protocol.ts
|
|
polling/
|
|
bounded-poll-coordinator.ts
|
|
web-push/
|
|
push-subscription-adapter.ts
|
|
push-registration-gateway.ts
|
|
push-association-fence-store.ts
|
|
inbound/
|
|
push-event-adapter.ts
|
|
notification-click-adapter.ts
|
|
bootstrap/
|
|
realtime-runtime-composition.ts
|
|
service-worker/
|
|
service-worker-composition.ts
|
|
presentation/
|
|
adapters/
|
|
query/
|
|
external-event-query-bridge.ts
|
|
tests/
|
|
unit/realtime/
|
|
integration/realtime/
|
|
browser-capabilities/realtime/
|
|
```
|
|
|
|
generic protocol mechanism은 `src/adapters/realtime`에 둘 수 있지만 feature event
|
|
codec/input과 query namespace는 feature owner가 소유한다. Service Worker entry는
|
|
window bootstrap을 import하지 않으며 둘이 공유하는 contract는 browser-neutral
|
|
module이어야 한다. `bootstrap/service-worker`는 factory/handler registration만
|
|
조립하고 push/click decode와 fence storage 구현은 inbound/infrastructure
|
|
adapter에 둔다.
|
|
|
|
## 19. 구현 work package
|
|
|
|
### RT-00 — 계약과 상태
|
|
|
|
- VD-28과 상세 설계 승인
|
|
- current status와 readiness 축 고정
|
|
- 기존 recipe의 한계 기록
|
|
- backend/hosting/provider owner와 protocol draft 지정
|
|
|
|
initial exit: 구현 없음, 모든 runtime은 `DESIGNED_NOT_IMPLEMENTED`. 현재는
|
|
RT-01~RT-04가 `AVAILABLE_NOT_COMPOSED`이고 제품 protocol/owner 선택은 RT-05에
|
|
남아 있다.
|
|
|
|
### RT-01 — 공통 event authority
|
|
|
|
- closed stream/event registry
|
|
- target envelope codec
|
|
- scope/generation fence
|
|
- sequential queue, dedupe/order/gap/cursor commit
|
|
- authoritative snapshot reset facade
|
|
- bounded reconnect owner와 authoritative close classification
|
|
- deterministic failure/observation contract
|
|
|
|
exit: transport-independent contract/fault suite 통과.
|
|
|
|
### RT-02 — SSE와 bounded polling
|
|
|
|
- bounded fetch-stream SSE parser/adapter
|
|
- same-origin auth와 fixed endpoint
|
|
- reconnect/heartbeat/status/resume
|
|
- single-flight visible-only Poll lease
|
|
- single-writer live↔Poll handoff와 bounded checkpoint/quiescence
|
|
- local streaming server와 target browser evidence
|
|
- removal/bundle gate
|
|
|
|
exit: reference runtime `AVAILABLE_NOT_COMPOSED`. 제품 선택은 계속
|
|
`NOT_SELECTED`.
|
|
|
|
### RT-03 — WebSocket
|
|
|
|
- fixed handshake/subprotocol
|
|
- closed control frames
|
|
- app heartbeat, close mapping과 resume
|
|
- bounded inbound/outbound queue
|
|
- local WS server/load/browser evidence
|
|
- removal/bundle gate
|
|
|
|
exit: reference runtime `AVAILABLE_NOT_COMPOSED`. duplex product requirement가
|
|
없으면 composition하지 않는다.
|
|
|
|
### RT-04 — Web Push
|
|
|
|
- permission/subscription facade
|
|
- fixed backend registration/revoke adapter
|
|
- Service Worker composition과 strict hint codec
|
|
- safe notification/click registry
|
|
- provider/browser/manual evidence
|
|
- subscription rotation/logout/removal
|
|
|
|
exit: reference runtime과 worker가 `AVAILABLE_NOT_COMPOSED`; actual provider
|
|
evidence 없이 product promotion 금지.
|
|
|
|
### RT-05 — 제품 composition과 운영
|
|
|
|
- 제품 event/query/notification registry
|
|
- backend replay/outbox/snapshot/provider conformance
|
|
- immutable runtime config와 kill switch
|
|
- canary SLO/load/security/privacy review
|
|
- operations recovery/rollback drill
|
|
|
|
exit: 선택 capability만 `COMPOSED`, 별도 promotion evidence가 `COMPLETE`일 때만
|
|
production traffic 승인.
|
|
|
|
## 20. 완료 기준
|
|
|
|
### 설계 완료
|
|
|
|
- [x] SSE, WebSocket, Web Push와 Polling의 전달 의미를 분리했다.
|
|
- [x] 현재 recipe와 concrete runtime 상태를 구분했다.
|
|
- [x] outbound connection과 inbound event adapter 경계를 정했다.
|
|
- [x] source of truth, duplicate-tolerant CURSOR 처리, best-effort profile과
|
|
snapshot resync를 정했다.
|
|
- [x] target envelope, scope/generation/cursor 의미를 정했다.
|
|
- [x] lifecycle, retry owner, resource ceiling과 closed failure를 정했다.
|
|
- [x] permission/subscription/worker/notification 경계를 정했다.
|
|
- [x] test, promotion, rollout, rollback과 제거 조건을 정했다.
|
|
|
|
### Reference runtime 완료
|
|
|
|
- [x] RT-01~RT-04 source와 deterministic test가 있다.
|
|
- [ ] actual SSE/WS local server와 target browser evidence가 있다.
|
|
- [ ] Service Worker/Push provider evidence가 자동/수동 범위별로 있다.
|
|
- [x] static boundary/security fixture, synthetic bundle budget와
|
|
runtime-removal 검증이 blocking gate다.
|
|
- [ ] provider/browser promotion evidence와 operations drill이 release
|
|
blocking으로 등록됐다.
|
|
- [x] static source gate 기준 production bootstrap과 worker에서 미선택
|
|
capability side effect가 0이다.
|
|
|
|
### 제품 composition 완료
|
|
|
|
- [ ] 제품 freshness/interaction/notification SLO와 owner가 승인됐다.
|
|
- [ ] event/topic/query/notification registry가 닫혀 있다.
|
|
- [ ] backend commit/replay/snapshot/auth/provider 계약이 conformant다.
|
|
- [ ] session/account/release transition과 late callback fence가 browser에서 검증됐다.
|
|
- [ ] dashboards, alert, kill switch와 recovery/rollback drill이 있다.
|
|
- [ ] privacy/retention, permission UX와 security review가 승인됐다.
|
|
- [ ] `Selection=SELECTED`, 선택 transport의 runtime만 `COMPOSED`다.
|
|
- [ ] `PromotionEvidence=COMPLETE` 전에는 production-ready를 주장하지 않는다.
|
|
|
|
## 21. 관련 자료
|
|
|
|
저장소 내부:
|
|
|
|
- [VD-28](./decisions/VD-28-realtime-events-web-push-and-bounded-polling.md)
|
|
- [VD-10 optional capability recipes](./decisions/VD-10-optional-capability-recipes.md)
|
|
- [Frontend ports, adapters, and boundaries](./frontend-ports-adapters-and-boundaries.md)
|
|
- [Optional frontend adapter recipes](./optional-adapter-recipes.md)
|
|
- [Client cache and browser storage](./client-cache-and-storage.md)
|
|
- [API contract, Schema, Mapper와 Server State](./api-contract-schema-mapper-and-server-state.md)
|
|
- [Browser file and origin-storage platform](./browser-file-and-origin-storage.md)
|
|
- [TypeScript, state, and data flow](./typescript-state-and-data-flow.md)
|
|
- [`RealtimePort` copyable recipe](../../recipes/frontend-capabilities/contracts.ts)
|
|
- [Deterministic realtime fake](../../recipes/frontend-capabilities/fake-adapters.ts)
|
|
- [Optional capability contract test](../../tests/recipes/optional-capability-contracts.test.ts)
|
|
- [Machine-readable optional recipe catalog](../../config/recipes/frontend-capability-recipes.json)
|
|
|
|
공식 기준:
|
|
|
|
- [WHATWG Server-sent events](https://html.spec.whatwg.org/multipage/server-sent-events.html)
|
|
- [WHATWG WebSockets](https://websockets.spec.whatwg.org/)
|
|
- [RFC 6455 — The WebSocket Protocol](https://www.rfc-editor.org/rfc/rfc6455)
|
|
- [W3C Push API](https://www.w3.org/TR/push-api/)
|
|
- [RFC 8030 — Generic Event Delivery Using HTTP Push](https://www.rfc-editor.org/rfc/rfc8030)
|
|
- [RFC 8291 — Message Encryption for Web Push](https://www.rfc-editor.org/rfc/rfc8291)
|
|
- [RFC 8292 — VAPID for Web Push](https://www.rfc-editor.org/rfc/rfc8292)
|
|
- [WHATWG Notifications API](https://notifications.spec.whatwg.org/)
|
|
- [W3C Service Workers](https://www.w3.org/TR/service-workers/)
|
|
- [RFC 9110 — HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110)
|
|
- [RFC 6585 — Additional HTTP Status Codes](https://www.rfc-editor.org/rfc/rfc6585)
|