Implement replay-safe browser synchronization
This commit is contained in:
parent
06a7ac56b7
commit
459ffa04c6
16 changed files with 2734 additions and 25 deletions
|
|
@ -23,6 +23,7 @@
|
|||
"test:e2e:full": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"oidc-client-ts": "3.5.0",
|
||||
"svelte": "5.56.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
23
web/pnpm-lock.yaml
generated
23
web/pnpm-lock.yaml
generated
|
|
@ -7,6 +7,9 @@ settings:
|
|||
importers:
|
||||
.:
|
||||
dependencies:
|
||||
oidc-client-ts:
|
||||
specifier: 3.5.0
|
||||
version: 3.5.0
|
||||
svelte:
|
||||
specifier: 5.56.5
|
||||
version: 5.56.5(@typescript-eslint/[email protected])
|
||||
|
|
@ -1592,6 +1595,13 @@ packages:
|
|||
}
|
||||
engines: { node: '>=0.10.0' }
|
||||
|
||||
[email protected]:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==,
|
||||
}
|
||||
engines: { node: '>=18' }
|
||||
|
||||
[email protected]:
|
||||
resolution:
|
||||
{
|
||||
|
|
@ -1924,6 +1934,13 @@ packages:
|
|||
}
|
||||
engines: { node: '>=12.20.0' }
|
||||
|
||||
[email protected]:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==,
|
||||
}
|
||||
engines: { node: '>=18' }
|
||||
|
||||
[email protected]:
|
||||
resolution:
|
||||
{
|
||||
|
|
@ -3514,6 +3531,8 @@ snapshots:
|
|||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
|
|
@ -3670,6 +3689,10 @@ snapshots:
|
|||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
jwt-decode: 4.0.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/json-schema': 7.0.15
|
||||
|
|
|
|||
|
|
@ -1,12 +1,182 @@
|
|||
import { expect, test } from 'vitest'
|
||||
import { expect, test, vi } from 'vitest'
|
||||
import { render } from 'vitest-browser-svelte'
|
||||
|
||||
import type {
|
||||
EventConnection,
|
||||
EventSocket,
|
||||
EventSocketListener,
|
||||
PublicApi,
|
||||
} from './api/client'
|
||||
import type {
|
||||
DeletionOperationDto,
|
||||
EventFrameDto,
|
||||
EventTicketDto,
|
||||
JobPageDto,
|
||||
JobResourceDto,
|
||||
} from './api/contracts'
|
||||
import App from './App.svelte'
|
||||
import type { Session, SessionState } from './auth/session'
|
||||
import { WebClientRuntime, type Scheduler } from './sync/runtime'
|
||||
import { heartbeat, job, jobChanged } from './sync/test-fixtures'
|
||||
|
||||
test('renders the application shell', async () => {
|
||||
const screen = await render(App)
|
||||
test('renders deterministic synchronization across replay and gaps', async () => {
|
||||
const fixture = runtimeFixture()
|
||||
const screen = await render(App, { runtime: fixture.runtime })
|
||||
fixture.session.emit(authenticated('token-one'))
|
||||
|
||||
await expect
|
||||
.element(screen.getByRole('heading', { name: 'Remora' }))
|
||||
.toBeVisible()
|
||||
await expect.element(screen.getByText('live', { exact: true })).toBeVisible()
|
||||
await expect.element(screen.getByText('Initial snapshot')).toBeVisible()
|
||||
|
||||
fixture.api.refetched = job(2, { label: 'Invalidation refetch' })
|
||||
fixture.socket.frame(jobChanged(1, 'event_000001', 2, 20))
|
||||
await expect.element(screen.getByText('Invalidation refetch')).toBeVisible()
|
||||
|
||||
fixture.socket.frame(jobChanged(2, 'event_000001', 2, 20))
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.api.jobRequests).toBe(1)
|
||||
})
|
||||
|
||||
fixture.api.snapshot = job(3, { label: 'Gap resnapshot' })
|
||||
fixture.socket.frame(heartbeat(4, 'event_000004'))
|
||||
await expect.element(screen.getByText('Gap resnapshot')).toBeVisible()
|
||||
expect(fixture.api.listRequests).toBe(2)
|
||||
expect(fixture.api.jobRequests).toBe(1)
|
||||
})
|
||||
|
||||
test('clears rendered authority on logout and reloads it on token change', async () => {
|
||||
const fixture = runtimeFixture()
|
||||
const screen = await render(App, { runtime: fixture.runtime })
|
||||
fixture.session.emit(authenticated('token-one'))
|
||||
await expect.element(screen.getByText('Initial snapshot')).toBeVisible()
|
||||
|
||||
fixture.session.emit({ kind: 'signed_out' })
|
||||
await expect
|
||||
.element(screen.getByText('Sign in to view authorized jobs.'))
|
||||
.toBeVisible()
|
||||
await expect
|
||||
.element(screen.getByRole('button', { name: 'Sign in' }))
|
||||
.toBeVisible()
|
||||
|
||||
fixture.api.snapshot = job(4, { label: 'Changed authorization' })
|
||||
fixture.session.emit(authenticated('token-two'))
|
||||
await expect.element(screen.getByText('Changed authorization')).toBeVisible()
|
||||
expect(fixture.api.tokens).toEqual(['token-one', 'token-two'])
|
||||
})
|
||||
|
||||
function runtimeFixture(): {
|
||||
runtime: WebClientRuntime
|
||||
session: FakeSession
|
||||
api: FakeApi
|
||||
socket: FakeSocket
|
||||
} {
|
||||
const session = new FakeSession()
|
||||
const api = new FakeApi()
|
||||
const socket = new FakeSocket()
|
||||
return {
|
||||
runtime: new WebClientRuntime(
|
||||
session,
|
||||
api,
|
||||
socket,
|
||||
new ImmediateScheduler(),
|
||||
),
|
||||
session,
|
||||
api,
|
||||
socket,
|
||||
}
|
||||
}
|
||||
|
||||
function authenticated(accessToken: string): SessionState {
|
||||
return {
|
||||
kind: 'authenticated',
|
||||
session: { accessToken, identity: 'issuer\u0000subject' },
|
||||
}
|
||||
}
|
||||
|
||||
class FakeSession implements Session {
|
||||
state: SessionState = { kind: 'loading' }
|
||||
readonly #listeners = new Set<(state: SessionState) => void>()
|
||||
|
||||
async start(): Promise<void> {}
|
||||
async signIn(): Promise<void> {}
|
||||
async signOut(): Promise<void> {
|
||||
this.emit({ kind: 'signed_out' })
|
||||
}
|
||||
|
||||
subscribe(listener: (state: SessionState) => void): () => void {
|
||||
this.#listeners.add(listener)
|
||||
listener(this.state)
|
||||
return () => {
|
||||
this.#listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
emit(state: SessionState): void {
|
||||
this.state = state
|
||||
for (const listener of this.#listeners) listener(state)
|
||||
}
|
||||
}
|
||||
|
||||
class FakeApi implements PublicApi {
|
||||
snapshot = job(1, { label: 'Initial snapshot' })
|
||||
refetched = job(2)
|
||||
listRequests = 0
|
||||
jobRequests = 0
|
||||
readonly tokens: string[] = []
|
||||
|
||||
async listJobs(accessToken: string): Promise<JobPageDto> {
|
||||
this.listRequests += 1
|
||||
this.tokens.push(accessToken)
|
||||
return {
|
||||
items: [this.snapshot],
|
||||
next_cursor: null,
|
||||
event_cursor: 'cursor-snapshot-0001',
|
||||
}
|
||||
}
|
||||
|
||||
async getJob(
|
||||
_accessToken: string,
|
||||
_jobId: string,
|
||||
): Promise<JobResourceDto | null> {
|
||||
this.jobRequests += 1
|
||||
return this.refetched
|
||||
}
|
||||
|
||||
async getDeletion(
|
||||
_accessToken: string,
|
||||
_operationId: string,
|
||||
): Promise<DeletionOperationDto | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
async createEventTicket(_accessToken: string): Promise<EventTicketDto> {
|
||||
return {
|
||||
ticket: 'ticket_00001',
|
||||
expires_at: '2026-07-17T00:01:00Z',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FakeSocket implements EventSocket {
|
||||
#listener: EventSocketListener | null = null
|
||||
|
||||
connect(
|
||||
_ticket: string,
|
||||
_cursor: string,
|
||||
listener: EventSocketListener,
|
||||
): EventConnection {
|
||||
this.#listener = listener
|
||||
listener.opened()
|
||||
return { close(): void {} }
|
||||
}
|
||||
|
||||
frame(frame: EventFrameDto): void {
|
||||
this.#listener?.frame(frame)
|
||||
}
|
||||
}
|
||||
|
||||
class ImmediateScheduler implements Scheduler {
|
||||
schedule(callback: () => void): () => void {
|
||||
queueMicrotask(callback)
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,23 @@
|
|||
<script lang="ts">
|
||||
import { applicationName } from './lib/application'
|
||||
import { createBrowserRuntime } from './runtime'
|
||||
import type { WebClientRuntime } from './sync/runtime'
|
||||
import type { SynchronizationSnapshot } from './sync/state'
|
||||
|
||||
let { runtime = createBrowserRuntime() }: { runtime?: WebClientRuntime } =
|
||||
$props()
|
||||
let snapshot = $state<SynchronizationSnapshot>()
|
||||
|
||||
$effect(() => {
|
||||
const unsubscribe = runtime.subscribe((next) => {
|
||||
snapshot = next
|
||||
})
|
||||
void runtime.start()
|
||||
return () => {
|
||||
unsubscribe()
|
||||
runtime.stop()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -7,8 +25,59 @@
|
|||
</svelte:head>
|
||||
|
||||
<main>
|
||||
<p>Durable transfer service</p>
|
||||
<h1>{applicationName}</h1>
|
||||
<header>
|
||||
<div>
|
||||
<p>Durable transfer service</p>
|
||||
<h1>{applicationName}</h1>
|
||||
</div>
|
||||
{#if snapshot !== undefined}
|
||||
{#if snapshot.status === 'signed_out'}
|
||||
<button type="button" onclick={() => runtime.signIn()}>Sign in</button>
|
||||
{:else if snapshot.status !== 'loading'}
|
||||
<button type="button" onclick={() => runtime.signOut()}>Sign out</button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if snapshot !== undefined}
|
||||
<section aria-labelledby="synchronization-heading">
|
||||
<div class="section-heading">
|
||||
<h2 id="synchronization-heading">Synchronization</h2>
|
||||
<output data-status={snapshot.status}>{snapshot.status}</output>
|
||||
</div>
|
||||
{#if snapshot.message !== null}
|
||||
<p role="alert">{snapshot.message}</p>
|
||||
{/if}
|
||||
{#if snapshot.status === 'signed_out'}
|
||||
<p>Sign in to view authorized jobs.</p>
|
||||
{:else if snapshot.jobs.length === 0}
|
||||
<p>No authorized jobs in the current snapshot.</p>
|
||||
{:else}
|
||||
<ul aria-label="Authorized jobs">
|
||||
{#each snapshot.jobs as cached (cached.resource.id)}
|
||||
<li>
|
||||
<strong>
|
||||
{cached.resource.resource_kind === 'job'
|
||||
? (cached.resource.label ?? cached.resource.id)
|
||||
: cached.resource.id}
|
||||
</strong>
|
||||
<span>
|
||||
{cached.resource.resource_kind === 'job'
|
||||
? cached.resource.phase
|
||||
: 'Deleted'}
|
||||
</span>
|
||||
{#if cached.provisionalProgress !== null}
|
||||
<span>
|
||||
{cached.provisionalProgress.completed_bytes} bytes observed
|
||||
</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
|
|
@ -32,11 +101,17 @@
|
|||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
place-content: center;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
padding: clamp(1.5rem, 5vw, 4rem);
|
||||
}
|
||||
|
||||
header,
|
||||
.section-heading,
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
|
|
@ -49,7 +124,50 @@
|
|||
|
||||
h1 {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: clamp(3rem, 12vw, 7rem);
|
||||
font-size: clamp(2.5rem, 8vw, 5rem);
|
||||
letter-spacing: -0.06em;
|
||||
}
|
||||
|
||||
section {
|
||||
max-width: 60rem;
|
||||
margin-top: 4rem;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid #d9dee8;
|
||||
border-radius: 1rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
output {
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
background: #e9f1fb;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.7rem 1rem;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
color: white;
|
||||
background: #14213d;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
ul {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin: 1.5rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
li {
|
||||
padding: 0.85rem;
|
||||
border-radius: 0.5rem;
|
||||
background: #f8f7f2;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
180
web/src/api/client.ts
Normal file
180
web/src/api/client.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import {
|
||||
decodeDeletionOperation,
|
||||
decodeEventFrame,
|
||||
decodeEventTicket,
|
||||
decodeJobPage,
|
||||
decodeJobResource,
|
||||
} from './decode'
|
||||
import type {
|
||||
DeletionOperationDto,
|
||||
EventFrameDto,
|
||||
EventTicketDto,
|
||||
JobPageDto,
|
||||
JobResourceDto,
|
||||
} from './contracts'
|
||||
|
||||
export interface PublicApi {
|
||||
listJobs(accessToken: string, cursor?: string): Promise<JobPageDto>
|
||||
getJob(accessToken: string, jobId: string): Promise<JobResourceDto | null>
|
||||
getDeletion(
|
||||
accessToken: string,
|
||||
operationId: string,
|
||||
): Promise<DeletionOperationDto | null>
|
||||
createEventTicket(accessToken: string): Promise<EventTicketDto>
|
||||
}
|
||||
|
||||
export class ApiResponseError extends Error {
|
||||
constructor(readonly status: number) {
|
||||
super(`Remora API returned HTTP ${status}`)
|
||||
}
|
||||
}
|
||||
|
||||
export class FetchPublicApi implements PublicApi {
|
||||
readonly #baseUrl: URL
|
||||
readonly #fetch: typeof fetch
|
||||
|
||||
constructor(
|
||||
baseUrl: string | URL,
|
||||
fetchImplementation: typeof fetch = fetch,
|
||||
) {
|
||||
this.#baseUrl = new URL(baseUrl, 'http://localhost/')
|
||||
this.#fetch = fetchImplementation
|
||||
}
|
||||
|
||||
async listJobs(accessToken: string, cursor?: string): Promise<JobPageDto> {
|
||||
const url = this.#url('jobs')
|
||||
if (cursor !== undefined) url.searchParams.set('cursor', cursor)
|
||||
return decodeJobPage(await this.#request(url, accessToken))
|
||||
}
|
||||
|
||||
async getJob(
|
||||
accessToken: string,
|
||||
jobId: string,
|
||||
): Promise<JobResourceDto | null> {
|
||||
const response = await this.#requestOrMissing(
|
||||
this.#url(`jobs/${encodeURIComponent(jobId)}`),
|
||||
accessToken,
|
||||
)
|
||||
return response === null ? null : decodeJobResource(response)
|
||||
}
|
||||
|
||||
async getDeletion(
|
||||
accessToken: string,
|
||||
operationId: string,
|
||||
): Promise<DeletionOperationDto | null> {
|
||||
const response = await this.#requestOrMissing(
|
||||
this.#url(`deletions/${encodeURIComponent(operationId)}`),
|
||||
accessToken,
|
||||
)
|
||||
return response === null ? null : decodeDeletionOperation(response)
|
||||
}
|
||||
|
||||
async createEventTicket(accessToken: string): Promise<EventTicketDto> {
|
||||
return decodeEventTicket(
|
||||
await this.#request(this.#url('event-tickets'), accessToken, 'POST'),
|
||||
)
|
||||
}
|
||||
|
||||
#url(path: string): URL {
|
||||
return new URL(path, withTrailingSlash(this.#baseUrl))
|
||||
}
|
||||
|
||||
async #request(
|
||||
url: URL,
|
||||
accessToken: string,
|
||||
method = 'GET',
|
||||
): Promise<unknown> {
|
||||
const response = await this.#fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
cache: 'no-store',
|
||||
credentials: 'omit',
|
||||
})
|
||||
if (!response.ok) throw new ApiResponseError(response.status)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async #requestOrMissing(url: URL, accessToken: string): Promise<unknown> {
|
||||
try {
|
||||
return await this.#request(url, accessToken)
|
||||
} catch (error) {
|
||||
if (error instanceof ApiResponseError && error.status === 404) return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface EventConnection {
|
||||
close(): void
|
||||
}
|
||||
|
||||
export interface EventSocket {
|
||||
connect(
|
||||
ticket: string,
|
||||
cursor: string,
|
||||
listener: EventSocketListener,
|
||||
): EventConnection
|
||||
}
|
||||
|
||||
export interface EventSocketListener {
|
||||
opened(): void
|
||||
frame(frame: EventFrameDto): void
|
||||
closed(): void
|
||||
failed(error: Error): void
|
||||
}
|
||||
|
||||
export class BrowserEventSocket implements EventSocket {
|
||||
readonly #eventsUrl: URL
|
||||
|
||||
constructor(eventsUrl: string | URL) {
|
||||
this.#eventsUrl = new URL(eventsUrl, 'http://localhost/')
|
||||
}
|
||||
|
||||
connect(
|
||||
ticket: string,
|
||||
cursor: string,
|
||||
listener: EventSocketListener,
|
||||
): EventConnection {
|
||||
const url = new URL(this.#eventsUrl)
|
||||
url.searchParams.set('ticket', ticket)
|
||||
url.searchParams.set('cursor', cursor)
|
||||
const socket = new WebSocket(url)
|
||||
let closed = false
|
||||
socket.addEventListener('open', () => {
|
||||
listener.opened()
|
||||
})
|
||||
socket.addEventListener('message', (event) => {
|
||||
try {
|
||||
listener.frame(decodeEventFrame(JSON.parse(String(event.data))))
|
||||
} catch (error) {
|
||||
socket.close(1002, 'invalid event frame')
|
||||
listener.failed(asError(error))
|
||||
}
|
||||
})
|
||||
socket.addEventListener('error', () => {
|
||||
listener.failed(new Error('Live event connection failed'))
|
||||
})
|
||||
socket.addEventListener('close', () => {
|
||||
if (!closed) listener.closed()
|
||||
})
|
||||
return {
|
||||
close(): void {
|
||||
closed = true
|
||||
socket.close(1000)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function withTrailingSlash(url: URL): URL {
|
||||
const copy = new URL(url)
|
||||
if (!copy.pathname.endsWith('/')) copy.pathname += '/'
|
||||
return copy
|
||||
}
|
||||
|
||||
function asError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error('Invalid live event frame')
|
||||
}
|
||||
177
web/src/api/client.unit.test.ts
Normal file
177
web/src/api/client.unit.test.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { afterEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { heartbeat, job } from '../sync/test-fixtures'
|
||||
import type { EventFrameDto } from './contracts'
|
||||
import { ApiResponseError, BrowserEventSocket, FetchPublicApi } from './client'
|
||||
import { decodeEventTicket, decodeJobPage } from './decode'
|
||||
|
||||
describe('adapted public API client', () => {
|
||||
test('authenticates, encodes cursors, and decodes snapshots', async () => {
|
||||
const request = vi.fn<typeof fetch>(async () =>
|
||||
Response.json({
|
||||
items: [job(1)],
|
||||
next_cursor: null,
|
||||
event_cursor: 'cursor-snapshot-0001',
|
||||
}),
|
||||
)
|
||||
const api = new FetchPublicApi('https://remora.invalid/v1/', request)
|
||||
|
||||
const page = await api.listJobs('access-token', 'cursor page/one')
|
||||
|
||||
expect(page.items).toEqual([job(1)])
|
||||
const [url, init] = request.mock.calls[0]
|
||||
if (!(url instanceof URL)) throw new Error('expected URL request')
|
||||
expect(url.href).toBe(
|
||||
'https://remora.invalid/v1/jobs?cursor=cursor+page%2Fone',
|
||||
)
|
||||
expect(init).toMatchObject({
|
||||
cache: 'no-store',
|
||||
credentials: 'omit',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: 'Bearer access-token',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('maps an indistinguishable missing resource without swallowing faults', async () => {
|
||||
const missing = new FetchPublicApi(
|
||||
'https://remora.invalid/v1/',
|
||||
async () => new Response(null, { status: 404 }),
|
||||
)
|
||||
await expect(missing.getJob('token', 'job_00000001')).resolves.toBeNull()
|
||||
|
||||
const unavailable = new FetchPublicApi(
|
||||
'https://remora.invalid/v1/',
|
||||
async () => new Response(null, { status: 503 }),
|
||||
)
|
||||
await expect(unavailable.getJob('token', 'job_00000001')).rejects.toEqual(
|
||||
new ApiResponseError(503),
|
||||
)
|
||||
})
|
||||
|
||||
test('creates a one-time event ticket through authenticated HTTP', async () => {
|
||||
const request = vi.fn<typeof fetch>(async () =>
|
||||
Response.json(
|
||||
{
|
||||
ticket: 'ticket_00001',
|
||||
expires_at: '2026-07-17T00:01:00Z',
|
||||
},
|
||||
{ status: 201 },
|
||||
),
|
||||
)
|
||||
const api = new FetchPublicApi('https://remora.invalid/v1/', request)
|
||||
|
||||
await expect(api.createEventTicket('token')).resolves.toEqual({
|
||||
ticket: 'ticket_00001',
|
||||
expires_at: '2026-07-17T00:01:00Z',
|
||||
})
|
||||
expect(request.mock.calls[0]?.[1]?.method).toBe('POST')
|
||||
})
|
||||
})
|
||||
|
||||
describe('snapshot wire decoding', () => {
|
||||
test('rejects unknown and malformed resource fields', () => {
|
||||
expect(() =>
|
||||
decodeJobPage({
|
||||
items: [{ ...job(1), secret: 'must not pass through' }],
|
||||
next_cursor: null,
|
||||
event_cursor: 'cursor-snapshot-0001',
|
||||
}),
|
||||
).toThrow('unknown DTO field')
|
||||
expect(() =>
|
||||
decodeEventTicket({
|
||||
ticket: 'short',
|
||||
expires_at: '2026-07-17T00:01:00Z',
|
||||
}),
|
||||
).toThrow('invalid opaque identifier')
|
||||
})
|
||||
})
|
||||
|
||||
describe('browser event adapter', () => {
|
||||
afterEach(() => {
|
||||
FakeWebSocket.instances.length = 0
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
test('uses a ticket and cursor and decodes server frames', () => {
|
||||
vi.stubGlobal('WebSocket', FakeWebSocket)
|
||||
const opened = vi.fn<() => void>()
|
||||
const frame = vi.fn<(event: EventFrameDto) => void>()
|
||||
const closed = vi.fn<() => void>()
|
||||
const failed = vi.fn<(error: Error) => void>()
|
||||
const socket = new BrowserEventSocket('wss://remora.invalid/api/v1/events')
|
||||
|
||||
socket.connect('ticket_00001', 'cursor-snapshot-0001', {
|
||||
opened,
|
||||
frame,
|
||||
closed,
|
||||
failed,
|
||||
})
|
||||
const browser = FakeWebSocket.instances[0]
|
||||
if (browser === undefined) throw new Error('missing WebSocket')
|
||||
expect(browser.url instanceof URL ? browser.url.href : browser.url).toBe(
|
||||
'wss://remora.invalid/api/v1/events?ticket=ticket_00001&cursor=cursor-snapshot-0001',
|
||||
)
|
||||
|
||||
browser.emit('open', {})
|
||||
browser.emit('message', {
|
||||
data: JSON.stringify(heartbeat(1, 'event_000001')),
|
||||
})
|
||||
|
||||
expect(opened).toHaveBeenCalledOnce()
|
||||
expect(frame).toHaveBeenCalledWith(heartbeat(1, 'event_000001'))
|
||||
expect(closed).not.toHaveBeenCalled()
|
||||
expect(failed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('closes a connection that sends an invalid frame', () => {
|
||||
vi.stubGlobal('WebSocket', FakeWebSocket)
|
||||
const failed = vi.fn<(error: Error) => void>()
|
||||
const socket = new BrowserEventSocket('wss://remora.invalid/api/v1/events')
|
||||
|
||||
socket.connect('ticket_00001', 'cursor-snapshot-0001', {
|
||||
opened: vi.fn<() => void>(),
|
||||
frame: vi.fn<(event: EventFrameDto) => void>(),
|
||||
closed: vi.fn<() => void>(),
|
||||
failed,
|
||||
})
|
||||
const browser = FakeWebSocket.instances[0]
|
||||
if (browser === undefined) throw new Error('missing WebSocket')
|
||||
browser.emit('message', { data: '{}' })
|
||||
|
||||
expect(browser.closeCode).toBe(1002)
|
||||
expect(failed).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
class FakeWebSocket {
|
||||
static readonly instances: FakeWebSocket[] = []
|
||||
readonly #listeners = new Map<string, ((event: FakeSocketEvent) => void)[]>()
|
||||
closeCode: number | null = null
|
||||
|
||||
constructor(readonly url: string | URL) {
|
||||
FakeWebSocket.instances.push(this)
|
||||
}
|
||||
|
||||
addEventListener(
|
||||
type: string,
|
||||
listener: (event: FakeSocketEvent) => void,
|
||||
): void {
|
||||
const listeners = this.#listeners.get(type) ?? []
|
||||
listeners.push(listener)
|
||||
this.#listeners.set(type, listeners)
|
||||
}
|
||||
|
||||
close(code?: number): void {
|
||||
this.closeCode = code ?? 1000
|
||||
}
|
||||
|
||||
emit(type: string, event: FakeSocketEvent): void {
|
||||
for (const listener of this.#listeners.get(type) ?? []) listener(event)
|
||||
}
|
||||
}
|
||||
|
||||
interface FakeSocketEvent {
|
||||
data?: unknown
|
||||
}
|
||||
|
|
@ -177,6 +177,11 @@ export interface HeartbeatDto extends EventBaseDto {
|
|||
kind: 'heartbeat'
|
||||
}
|
||||
|
||||
export interface EventTicketDto {
|
||||
ticket: OpaqueIdDto
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export type SourceCredentialKindDto =
|
||||
| 'basic_auth'
|
||||
| 'bearer_token'
|
||||
|
|
|
|||
|
|
@ -2,14 +2,22 @@ import { runtimeFailureClass, type JobPhase } from '../domain/vocabulary'
|
|||
import { opaqueId } from '../domain/validation'
|
||||
import type {
|
||||
AttemptOutcomeRequestDto,
|
||||
CheckpointProjectionDto,
|
||||
DeletionOperationDto,
|
||||
EventFrameDto,
|
||||
EventProgressDto,
|
||||
EventTicketDto,
|
||||
HuggingFaceSelectionDto,
|
||||
JobFailureDto,
|
||||
JobPageDto,
|
||||
JobResourceDto,
|
||||
JobSubmissionDto,
|
||||
ObjectIdentityDto,
|
||||
ProgressDto,
|
||||
ResultDescriptorDto,
|
||||
SourceProjectionDto,
|
||||
SourceSubmissionDto,
|
||||
TargetDto,
|
||||
TargetOverrideDto,
|
||||
} from './contracts'
|
||||
|
||||
|
|
@ -118,6 +126,132 @@ export function decodeEventFrame(value: unknown): EventFrameDto {
|
|||
}
|
||||
}
|
||||
|
||||
export function decodeEventTicket(value: unknown): EventTicketDto {
|
||||
const object = record(value)
|
||||
exactKeys(object, ['ticket', 'expires_at'])
|
||||
return {
|
||||
ticket: opaqueId(string(object.ticket)),
|
||||
expires_at: dateTime(object.expires_at),
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeJobPage(value: unknown): JobPageDto {
|
||||
const object = record(value)
|
||||
exactKeys(object, ['items', 'next_cursor', 'event_cursor'])
|
||||
if (!Array.isArray(object.items)) throw new Error('expected job array')
|
||||
return {
|
||||
items: object.items.map(decodeJobResource),
|
||||
next_cursor:
|
||||
object.next_cursor === null ? null : cursor(object.next_cursor),
|
||||
event_cursor: cursor(object.event_cursor),
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeJobResource(value: unknown): JobResourceDto {
|
||||
const object = record(value)
|
||||
if (object.resource_kind === 'job') {
|
||||
exactKeys(object, [
|
||||
'resource_kind',
|
||||
'id',
|
||||
'owner',
|
||||
'label',
|
||||
'revision',
|
||||
'phase',
|
||||
'lifecycle',
|
||||
'source',
|
||||
'target',
|
||||
'authoritative_attempt',
|
||||
'progress',
|
||||
'checkpoint',
|
||||
'result',
|
||||
'failure',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'event_cursor',
|
||||
])
|
||||
return {
|
||||
resource_kind: 'job',
|
||||
id: opaqueId(string(object.id)),
|
||||
owner: opaqueId(string(object.owner)),
|
||||
label: object.label === null ? null : string(object.label),
|
||||
revision: positiveInteger(object.revision),
|
||||
phase: jobPhase(object.phase),
|
||||
lifecycle: lifecycle(object.lifecycle),
|
||||
source: decodeSourceProjection(object.source),
|
||||
target: decodeTarget(object.target),
|
||||
authoritative_attempt:
|
||||
object.authoritative_attempt === null
|
||||
? null
|
||||
: decodeAttemptAuthority(object.authoritative_attempt),
|
||||
progress:
|
||||
object.progress === null ? null : decodeProgress(object.progress),
|
||||
checkpoint:
|
||||
object.checkpoint === null ? null : decodeCheckpoint(object.checkpoint),
|
||||
result: object.result === null ? null : decodeResult(object.result),
|
||||
failure:
|
||||
object.failure === null ? null : decodeJobFailure(object.failure),
|
||||
created_at: dateTime(object.created_at),
|
||||
updated_at: dateTime(object.updated_at),
|
||||
event_cursor: cursor(object.event_cursor),
|
||||
}
|
||||
}
|
||||
if (object.resource_kind === 'tombstone') {
|
||||
exactKeys(object, [
|
||||
'resource_kind',
|
||||
'id',
|
||||
'owner',
|
||||
'target',
|
||||
'terminal_phase',
|
||||
'deletion_operation_id',
|
||||
'created_at',
|
||||
'hard_delete_started_at',
|
||||
'updated_at',
|
||||
'event_cursor',
|
||||
])
|
||||
const terminalPhase = jobPhase(object.terminal_phase)
|
||||
if (
|
||||
terminalPhase !== 'Succeeded' &&
|
||||
terminalPhase !== 'Failed' &&
|
||||
terminalPhase !== 'Cancelled'
|
||||
)
|
||||
throw new Error('invalid tombstone terminal phase')
|
||||
return {
|
||||
resource_kind: 'tombstone',
|
||||
id: opaqueId(string(object.id)),
|
||||
owner: opaqueId(string(object.owner)),
|
||||
target: decodeTarget(object.target),
|
||||
terminal_phase: terminalPhase,
|
||||
deletion_operation_id: opaqueId(string(object.deletion_operation_id)),
|
||||
created_at: dateTime(object.created_at),
|
||||
hard_delete_started_at: dateTime(object.hard_delete_started_at),
|
||||
updated_at: dateTime(object.updated_at),
|
||||
event_cursor: cursor(object.event_cursor),
|
||||
}
|
||||
}
|
||||
throw new Error('unknown job resource kind')
|
||||
}
|
||||
|
||||
export function decodeDeletionOperation(value: unknown): DeletionOperationDto {
|
||||
const object = record(value)
|
||||
exactKeys(object, [
|
||||
'id',
|
||||
'job_id',
|
||||
'state',
|
||||
'problem',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])
|
||||
return {
|
||||
id: opaqueId(string(object.id)),
|
||||
job_id: opaqueId(string(object.job_id)),
|
||||
state: deletionState(object.state),
|
||||
problem:
|
||||
object.problem === null ? null : decodeDeletionProblem(object.problem),
|
||||
created_at: dateTime(object.created_at),
|
||||
updated_at: dateTime(object.updated_at),
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeAttemptOutcome(value: unknown): AttemptOutcomeRequestDto {
|
||||
const object = record(value)
|
||||
exactKeys(object, ['operation_id', 'fence', 'worker_incarnation', 'outcome'])
|
||||
|
|
@ -155,6 +289,144 @@ export function decodeAttemptOutcome(value: unknown): AttemptOutcomeRequestDto {
|
|||
throw new Error('unknown attempt outcome kind')
|
||||
}
|
||||
|
||||
function decodeSourceProjection(value: unknown): SourceProjectionDto {
|
||||
const object = record(value)
|
||||
const credential =
|
||||
object.credential_id === undefined
|
||||
? {}
|
||||
: { credential_id: opaqueId(string(object.credential_id)) }
|
||||
switch (object.kind) {
|
||||
case 'http':
|
||||
exactKeys(object, ['kind', 'display_url', 'result_path', 'credential_id'])
|
||||
return {
|
||||
kind: 'http',
|
||||
display_url: string(object.display_url),
|
||||
result_path: string(object.result_path),
|
||||
...credential,
|
||||
}
|
||||
case 'object_store':
|
||||
exactKeys(object, [
|
||||
'kind',
|
||||
'store',
|
||||
'bucket',
|
||||
'key',
|
||||
'version_id',
|
||||
'result_path',
|
||||
'credential_id',
|
||||
])
|
||||
return {
|
||||
kind: 'object_store',
|
||||
store: string(object.store),
|
||||
bucket: string(object.bucket),
|
||||
key: string(object.key),
|
||||
...(object.version_id === undefined
|
||||
? {}
|
||||
: { version_id: string(object.version_id) }),
|
||||
result_path: string(object.result_path),
|
||||
...credential,
|
||||
}
|
||||
case 'hugging_face':
|
||||
exactKeys(object, [
|
||||
'kind',
|
||||
'repository_type',
|
||||
'repository_id',
|
||||
'revision',
|
||||
'selection',
|
||||
'credential_id',
|
||||
])
|
||||
return {
|
||||
kind: 'hugging_face',
|
||||
repository_type: repositoryType(object.repository_type),
|
||||
repository_id: string(object.repository_id),
|
||||
revision: string(object.revision),
|
||||
...(object.selection === undefined
|
||||
? {}
|
||||
: { selection: decodeSelection(object.selection) }),
|
||||
...credential,
|
||||
}
|
||||
default:
|
||||
throw new Error('unknown source projection kind')
|
||||
}
|
||||
}
|
||||
|
||||
function decodeTarget(value: unknown): TargetDto {
|
||||
const object = record(value)
|
||||
exactKeys(object, ['store', 'bucket', 'prefix'])
|
||||
return {
|
||||
store: string(object.store),
|
||||
bucket: string(object.bucket),
|
||||
prefix: string(object.prefix),
|
||||
}
|
||||
}
|
||||
|
||||
function decodeAttemptAuthority(value: unknown): {
|
||||
attempt_id: string
|
||||
fence: number
|
||||
worker_incarnation: string
|
||||
} {
|
||||
const object = record(value)
|
||||
exactKeys(object, ['attempt_id', 'fence', 'worker_incarnation'])
|
||||
return {
|
||||
attempt_id: opaqueId(string(object.attempt_id)),
|
||||
fence: positiveInteger(object.fence),
|
||||
worker_incarnation: opaqueId(string(object.worker_incarnation)),
|
||||
}
|
||||
}
|
||||
|
||||
function decodeProgress(value: unknown): ProgressDto {
|
||||
const object = record(value)
|
||||
exactKeys(object, [
|
||||
'attempt_id',
|
||||
'fence',
|
||||
'completed_bytes',
|
||||
'total_bytes',
|
||||
'completed_members',
|
||||
'total_members',
|
||||
'observed_at',
|
||||
])
|
||||
return {
|
||||
attempt_id: opaqueId(string(object.attempt_id)),
|
||||
fence: positiveInteger(object.fence),
|
||||
completed_bytes: nonnegativeInteger(object.completed_bytes),
|
||||
...(object.total_bytes === undefined
|
||||
? {}
|
||||
: { total_bytes: nullableNonnegativeInteger(object.total_bytes) }),
|
||||
completed_members: nonnegativeInteger(object.completed_members),
|
||||
...(object.total_members === undefined
|
||||
? {}
|
||||
: { total_members: nullableNonnegativeInteger(object.total_members) }),
|
||||
observed_at: dateTime(object.observed_at),
|
||||
}
|
||||
}
|
||||
|
||||
function decodeCheckpoint(value: unknown): CheckpointProjectionDto {
|
||||
const object = record(value)
|
||||
exactKeys(object, ['reusable', 'validated_bytes', 'validated_members'])
|
||||
if (typeof object.reusable !== 'boolean')
|
||||
throw new Error('invalid checkpoint reusable flag')
|
||||
return {
|
||||
reusable: object.reusable,
|
||||
validated_bytes: nonnegativeInteger(object.validated_bytes),
|
||||
validated_members: nonnegativeInteger(object.validated_members),
|
||||
}
|
||||
}
|
||||
|
||||
function decodeDeletionProblem(value: unknown): {
|
||||
class: 'authorization' | 'target_unavailable' | 'configuration' | 'internal'
|
||||
message: string
|
||||
} {
|
||||
const object = record(value)
|
||||
exactKeys(object, ['class', 'message'])
|
||||
if (
|
||||
object.class !== 'authorization' &&
|
||||
object.class !== 'target_unavailable' &&
|
||||
object.class !== 'configuration' &&
|
||||
object.class !== 'internal'
|
||||
)
|
||||
throw new Error('unknown deletion problem class')
|
||||
return { class: object.class, message: string(object.message) }
|
||||
}
|
||||
|
||||
function decodeSourceSubmission(value: unknown): SourceSubmissionDto {
|
||||
const object = record(value)
|
||||
const credential =
|
||||
|
|
|
|||
129
web/src/auth/session.browser.test.ts
Normal file
129
web/src/auth/session.browser.test.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { User, UserManager } from 'oidc-client-ts'
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { BrowserOidcSession, type BrowserOidcConfig } from './session'
|
||||
|
||||
const originalUrl = window.location.href
|
||||
const config: BrowserOidcConfig = {
|
||||
authority: 'https://identity.invalid',
|
||||
clientId: 'remora-web',
|
||||
redirectUri: window.location.origin + '/',
|
||||
postLogoutRedirectUri: window.location.origin + '/',
|
||||
scope: 'openid profile',
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
window.history.replaceState({}, document.title, originalUrl)
|
||||
})
|
||||
|
||||
describe('browser OIDC session', () => {
|
||||
test('processes the authorization-code callback and removes its parameters', async () => {
|
||||
const manager = oidcManager()
|
||||
const authenticated = user('access-token')
|
||||
const callback = vi
|
||||
.spyOn(manager, 'signinCallback')
|
||||
.mockResolvedValue(authenticated)
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
`${window.location.pathname}?code=authorization-code&state=oidc-state`,
|
||||
)
|
||||
const session = new BrowserOidcSession(
|
||||
config,
|
||||
window.sessionStorage,
|
||||
manager,
|
||||
)
|
||||
|
||||
await session.start()
|
||||
|
||||
expect(callback).toHaveBeenCalledOnce()
|
||||
expect(session.state).toEqual({
|
||||
kind: 'authenticated',
|
||||
session: {
|
||||
accessToken: 'access-token',
|
||||
identity: 'https://identity.invalid\u0000subject-1',
|
||||
},
|
||||
})
|
||||
expect(window.location.search).toBe('')
|
||||
})
|
||||
|
||||
test('processes logout callbacks and fails closed on expired users', async () => {
|
||||
const manager = oidcManager()
|
||||
const callback = vi
|
||||
.spyOn(manager, 'signoutCallback')
|
||||
.mockResolvedValue(undefined)
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
`${window.location.pathname}?state=logout-state`,
|
||||
)
|
||||
const logout = new BrowserOidcSession(
|
||||
config,
|
||||
window.sessionStorage,
|
||||
manager,
|
||||
)
|
||||
|
||||
await logout.start()
|
||||
|
||||
expect(callback).toHaveBeenCalledOnce()
|
||||
expect(logout.state).toEqual({ kind: 'signed_out' })
|
||||
|
||||
const expiredManager = oidcManager()
|
||||
vi.spyOn(expiredManager, 'getUser').mockResolvedValue(
|
||||
user('expired-token', 1),
|
||||
)
|
||||
const expired = new BrowserOidcSession(
|
||||
config,
|
||||
window.sessionStorage,
|
||||
expiredManager,
|
||||
)
|
||||
await expired.start()
|
||||
expect(expired.state).toEqual({ kind: 'signed_out' })
|
||||
})
|
||||
|
||||
test('clears the local session when provider logout cannot start', async () => {
|
||||
const manager = oidcManager()
|
||||
vi.spyOn(manager, 'signoutRedirect').mockRejectedValue(
|
||||
new Error('provider unavailable'),
|
||||
)
|
||||
const remove = vi.spyOn(manager, 'removeUser').mockResolvedValue()
|
||||
const session = new BrowserOidcSession(
|
||||
config,
|
||||
window.sessionStorage,
|
||||
manager,
|
||||
)
|
||||
|
||||
await session.signOut()
|
||||
|
||||
expect(remove).toHaveBeenCalledOnce()
|
||||
expect(session.state).toEqual({ kind: 'signed_out' })
|
||||
})
|
||||
})
|
||||
|
||||
function oidcManager(): UserManager {
|
||||
return new UserManager({
|
||||
authority: config.authority,
|
||||
client_id: config.clientId,
|
||||
redirect_uri: config.redirectUri,
|
||||
post_logout_redirect_uri: config.postLogoutRedirectUri,
|
||||
response_type: 'code',
|
||||
scope: config.scope,
|
||||
})
|
||||
}
|
||||
|
||||
function user(accessToken: string, expiresAt?: number): User {
|
||||
return new User({
|
||||
session_state: null,
|
||||
access_token: accessToken,
|
||||
token_type: 'Bearer',
|
||||
scope: 'openid profile',
|
||||
profile: {
|
||||
iss: 'https://identity.invalid',
|
||||
sub: 'subject-1',
|
||||
aud: 'remora-web',
|
||||
exp: expiresAt ?? Math.floor(Date.now() / 1000) + 3600,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
...(expiresAt === undefined ? {} : { expires_at: expiresAt }),
|
||||
})
|
||||
}
|
||||
175
web/src/auth/session.ts
Normal file
175
web/src/auth/session.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import {
|
||||
UserManager,
|
||||
WebStorageStateStore,
|
||||
type User,
|
||||
type UserManagerSettings,
|
||||
} from 'oidc-client-ts'
|
||||
|
||||
export interface AuthenticatedSession {
|
||||
accessToken: string
|
||||
identity: string
|
||||
}
|
||||
|
||||
export type SessionState =
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'signed_out' }
|
||||
| { kind: 'authenticated'; session: AuthenticatedSession }
|
||||
| { kind: 'error'; message: string }
|
||||
|
||||
export interface Session {
|
||||
readonly state: SessionState
|
||||
start(): Promise<void>
|
||||
signIn(): Promise<void>
|
||||
signOut(): Promise<void>
|
||||
subscribe(listener: (state: SessionState) => void): () => void
|
||||
}
|
||||
|
||||
export interface BrowserOidcConfig {
|
||||
authority: string
|
||||
clientId: string
|
||||
redirectUri: string
|
||||
postLogoutRedirectUri: string
|
||||
scope: string
|
||||
}
|
||||
|
||||
export type OidcManager = Pick<
|
||||
UserManager,
|
||||
| 'events'
|
||||
| 'getUser'
|
||||
| 'removeUser'
|
||||
| 'signinCallback'
|
||||
| 'signinRedirect'
|
||||
| 'signoutCallback'
|
||||
| 'signoutRedirect'
|
||||
>
|
||||
|
||||
export class BrowserOidcSession implements Session {
|
||||
#state: SessionState = { kind: 'loading' }
|
||||
readonly #listeners = new Set<(state: SessionState) => void>()
|
||||
readonly #manager: OidcManager
|
||||
|
||||
constructor(
|
||||
config: BrowserOidcConfig,
|
||||
storage: Storage = window.sessionStorage,
|
||||
manager?: OidcManager,
|
||||
) {
|
||||
this.#manager = manager ?? createManager(config, storage)
|
||||
this.#manager.events.addUserLoaded((user) => {
|
||||
this.#setState(authenticated(user))
|
||||
})
|
||||
this.#manager.events.addUserUnloaded(() => {
|
||||
this.#setState({ kind: 'signed_out' })
|
||||
})
|
||||
this.#manager.events.addAccessTokenExpired(() => {
|
||||
this.#setState({ kind: 'signed_out' })
|
||||
})
|
||||
this.#manager.events.addSilentRenewError(() => {
|
||||
this.#setState({ kind: 'error', message: 'Session renewal failed' })
|
||||
})
|
||||
}
|
||||
|
||||
get state(): SessionState {
|
||||
return this.#state
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.#setState({ kind: 'loading' })
|
||||
const parameters = new URLSearchParams(window.location.search)
|
||||
const hasState = parameters.has('state')
|
||||
const signinCallback =
|
||||
hasState && (parameters.has('code') || parameters.has('error'))
|
||||
try {
|
||||
let user: User | null | undefined
|
||||
if (signinCallback) user = await this.#manager.signinCallback()
|
||||
else if (hasState) {
|
||||
await this.#manager.signoutCallback()
|
||||
user = null
|
||||
} else user = await this.#manager.getUser()
|
||||
this.#setState(
|
||||
user === null || user === undefined
|
||||
? { kind: 'signed_out' }
|
||||
: authenticated(user),
|
||||
)
|
||||
} catch {
|
||||
this.#setState({
|
||||
kind: 'error',
|
||||
message: 'Unable to restore the session',
|
||||
})
|
||||
} finally {
|
||||
if (hasState)
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
window.location.pathname + window.location.hash,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async signIn(): Promise<void> {
|
||||
try {
|
||||
await this.#manager.signinRedirect()
|
||||
} catch {
|
||||
this.#setState({ kind: 'error', message: 'Unable to start sign in' })
|
||||
}
|
||||
}
|
||||
|
||||
async signOut(): Promise<void> {
|
||||
try {
|
||||
await this.#manager.signoutRedirect()
|
||||
} catch {
|
||||
await this.#manager.removeUser().catch(() => undefined)
|
||||
this.#setState({ kind: 'signed_out' })
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(listener: (state: SessionState) => void): () => void {
|
||||
this.#listeners.add(listener)
|
||||
listener(this.#state)
|
||||
return () => {
|
||||
this.#listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
#setState(state: SessionState): void {
|
||||
this.#state = state
|
||||
for (const listener of this.#listeners) listener(state)
|
||||
}
|
||||
}
|
||||
|
||||
function authenticated(user: User): SessionState {
|
||||
const issuer = user.profile.iss
|
||||
const subject = user.profile.sub
|
||||
if (
|
||||
issuer === undefined ||
|
||||
subject === undefined ||
|
||||
user.access_token === '' ||
|
||||
user.expired
|
||||
)
|
||||
return { kind: 'signed_out' }
|
||||
return {
|
||||
kind: 'authenticated',
|
||||
session: {
|
||||
accessToken: user.access_token,
|
||||
identity: `${issuer}\u0000${subject}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createManager(
|
||||
config: BrowserOidcConfig,
|
||||
storage: Storage,
|
||||
): UserManager {
|
||||
const settings: UserManagerSettings = {
|
||||
authority: config.authority,
|
||||
client_id: config.clientId,
|
||||
redirect_uri: config.redirectUri,
|
||||
post_logout_redirect_uri: config.postLogoutRedirectUri,
|
||||
response_type: 'code',
|
||||
scope: config.scope,
|
||||
automaticSilentRenew: true,
|
||||
monitorSession: true,
|
||||
userStore: new WebStorageStateStore({ store: storage }),
|
||||
stateStore: new WebStorageStateStore({ store: storage }),
|
||||
}
|
||||
return new UserManager(settings)
|
||||
}
|
||||
57
web/src/runtime.ts
Normal file
57
web/src/runtime.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { BrowserEventSocket, FetchPublicApi } from './api/client'
|
||||
import { BrowserOidcSession } from './auth/session'
|
||||
import { WebClientRuntime } from './sync/runtime'
|
||||
|
||||
interface RemoraRuntimeConfig {
|
||||
apiUrl: string
|
||||
eventsUrl: string
|
||||
oidcAuthority: string
|
||||
oidcClientId: string
|
||||
oidcRedirectUri: string
|
||||
oidcPostLogoutRedirectUri: string
|
||||
oidcScope: string
|
||||
}
|
||||
|
||||
export function createBrowserRuntime(): WebClientRuntime {
|
||||
const config = runtimeConfig()
|
||||
const session = new BrowserOidcSession({
|
||||
authority: config.oidcAuthority,
|
||||
clientId: config.oidcClientId,
|
||||
redirectUri: config.oidcRedirectUri,
|
||||
postLogoutRedirectUri: config.oidcPostLogoutRedirectUri,
|
||||
scope: config.oidcScope,
|
||||
})
|
||||
return new WebClientRuntime(
|
||||
session,
|
||||
new FetchPublicApi(config.apiUrl),
|
||||
new BrowserEventSocket(config.eventsUrl),
|
||||
)
|
||||
}
|
||||
|
||||
function runtimeConfig(): RemoraRuntimeConfig {
|
||||
const apiUrl = import.meta.env.VITE_REMORA_API_URL ?? '/api/v1/'
|
||||
return {
|
||||
apiUrl,
|
||||
eventsUrl:
|
||||
import.meta.env.VITE_REMORA_EVENTS_URL ??
|
||||
new URL('events', websocketBase(apiUrl)).toString(),
|
||||
oidcAuthority:
|
||||
import.meta.env.VITE_REMORA_OIDC_AUTHORITY ??
|
||||
new URL('/oidc', window.location.origin).toString(),
|
||||
oidcClientId: import.meta.env.VITE_REMORA_OIDC_CLIENT_ID ?? 'remora-web',
|
||||
oidcRedirectUri:
|
||||
import.meta.env.VITE_REMORA_OIDC_REDIRECT_URI ??
|
||||
new URL('/', window.location.origin).toString(),
|
||||
oidcPostLogoutRedirectUri:
|
||||
import.meta.env.VITE_REMORA_OIDC_POST_LOGOUT_REDIRECT_URI ??
|
||||
new URL('/', window.location.origin).toString(),
|
||||
oidcScope: import.meta.env.VITE_REMORA_OIDC_SCOPE ?? 'openid profile',
|
||||
}
|
||||
}
|
||||
|
||||
function websocketBase(apiUrl: string): URL {
|
||||
const url = new URL(apiUrl, window.location.href)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
if (!url.pathname.endsWith('/')) url.pathname += '/'
|
||||
return url
|
||||
}
|
||||
316
web/src/sync/runtime.ts
Normal file
316
web/src/sync/runtime.ts
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
import type {
|
||||
EventConnection,
|
||||
EventSocket,
|
||||
EventSocketListener,
|
||||
PublicApi,
|
||||
} from '../api/client'
|
||||
import type { EventFrameDto, JobResourceDto } from '../api/contracts'
|
||||
import type {
|
||||
AuthenticatedSession,
|
||||
Session,
|
||||
SessionState,
|
||||
} from '../auth/session'
|
||||
import {
|
||||
SynchronizationState,
|
||||
type SynchronizationEffect,
|
||||
type SynchronizationSnapshot,
|
||||
} from './state'
|
||||
|
||||
export interface Scheduler {
|
||||
schedule(callback: () => void, delayMilliseconds: number): () => void
|
||||
}
|
||||
|
||||
export class BrowserScheduler implements Scheduler {
|
||||
schedule(callback: () => void, delayMilliseconds: number): () => void {
|
||||
const timeout = window.setTimeout(callback, delayMilliseconds)
|
||||
return () => {
|
||||
window.clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class WebClientRuntime {
|
||||
readonly #state = new SynchronizationState()
|
||||
readonly #listeners = new Set<(state: SynchronizationSnapshot) => void>()
|
||||
readonly #session: Session
|
||||
readonly #api: PublicApi
|
||||
readonly #socket: EventSocket
|
||||
readonly #scheduler: Scheduler
|
||||
#sessionKey: string | null = null
|
||||
#accessToken: string | null = null
|
||||
#generation = 0
|
||||
#connectionEpoch = 0
|
||||
#connection: EventConnection | null = null
|
||||
#cancelRetry: (() => void) | null = null
|
||||
#unsubscribeSession: (() => void) | null = null
|
||||
#frames = Promise.resolve()
|
||||
|
||||
constructor(
|
||||
session: Session,
|
||||
api: PublicApi,
|
||||
socket: EventSocket,
|
||||
scheduler: Scheduler = new BrowserScheduler(),
|
||||
) {
|
||||
this.#session = session
|
||||
this.#api = api
|
||||
this.#socket = socket
|
||||
this.#scheduler = scheduler
|
||||
}
|
||||
|
||||
get snapshot(): SynchronizationSnapshot {
|
||||
return this.#state.snapshot()
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.#unsubscribeSession === null)
|
||||
this.#unsubscribeSession = this.#session.subscribe((state) => {
|
||||
this.#sessionChanged(state)
|
||||
})
|
||||
await this.#session.start()
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.#generation += 1
|
||||
this.#frames = Promise.resolve()
|
||||
this.#closeConnection()
|
||||
this.#cancelScheduledRetry()
|
||||
this.#unsubscribeSession?.()
|
||||
this.#unsubscribeSession = null
|
||||
}
|
||||
|
||||
signIn(): Promise<void> {
|
||||
return this.#session.signIn()
|
||||
}
|
||||
|
||||
signOut(): Promise<void> {
|
||||
return this.#session.signOut()
|
||||
}
|
||||
|
||||
subscribe(listener: (state: SynchronizationSnapshot) => void): () => void {
|
||||
this.#listeners.add(listener)
|
||||
listener(this.snapshot)
|
||||
return () => {
|
||||
this.#listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
#sessionChanged(state: SessionState): void {
|
||||
if (state.kind === 'authenticated') {
|
||||
this.#authenticated(state.session)
|
||||
return
|
||||
}
|
||||
this.#sessionKey = null
|
||||
this.#accessToken = null
|
||||
this.#generation += 1
|
||||
this.#frames = Promise.resolve()
|
||||
this.#closeConnection()
|
||||
this.#cancelScheduledRetry()
|
||||
this.#state.clear(state.kind === 'loading' ? 'loading' : 'signed_out')
|
||||
if (state.kind === 'error') this.#state.setStatus('error', state.message)
|
||||
this.#publish()
|
||||
}
|
||||
|
||||
#authenticated(session: AuthenticatedSession): void {
|
||||
const sessionKey = `${session.identity}\u0000${session.accessToken}`
|
||||
if (sessionKey === this.#sessionKey) return
|
||||
this.#sessionKey = sessionKey
|
||||
this.#accessToken = session.accessToken
|
||||
this.#generation += 1
|
||||
this.#frames = Promise.resolve()
|
||||
this.#closeConnection()
|
||||
this.#cancelScheduledRetry()
|
||||
this.#state.clear('loading')
|
||||
this.#publish()
|
||||
void this.#loadSnapshot(this.#generation, 'loading')
|
||||
}
|
||||
|
||||
async #loadSnapshot(
|
||||
generation: number,
|
||||
status: 'loading' | 'resyncing',
|
||||
): Promise<void> {
|
||||
const token = this.#accessToken
|
||||
if (token === null || generation !== this.#generation) return
|
||||
this.#connectionEpoch += 1
|
||||
this.#frames = Promise.resolve()
|
||||
this.#closeConnection()
|
||||
this.#cancelScheduledRetry()
|
||||
this.#state.setStatus(status)
|
||||
this.#publish()
|
||||
try {
|
||||
const jobs: JobResourceDto[] = []
|
||||
const pageCursors = new Set<string>()
|
||||
let pageCursor: string | undefined
|
||||
let eventCursor: string | null = null
|
||||
do {
|
||||
const page = await this.#api.listJobs(token, pageCursor)
|
||||
if (generation !== this.#generation) return
|
||||
eventCursor ??= page.event_cursor
|
||||
jobs.push(...page.items)
|
||||
pageCursor = page.next_cursor ?? undefined
|
||||
if (pageCursor !== undefined && !pageCursors.add(pageCursor))
|
||||
throw new Error('Job pagination cursor repeated')
|
||||
} while (pageCursor !== undefined)
|
||||
if (eventCursor === null)
|
||||
throw new Error('Job snapshot omitted its cursor')
|
||||
this.#state.installSnapshot(jobs, eventCursor)
|
||||
this.#publish()
|
||||
await this.#connect(generation)
|
||||
} catch (error) {
|
||||
if (generation !== this.#generation) return
|
||||
this.#state.setStatus('error', message(error))
|
||||
this.#publish()
|
||||
this.#scheduleSnapshotRetry(generation)
|
||||
}
|
||||
}
|
||||
|
||||
async #connect(generation: number): Promise<void> {
|
||||
const token = this.#accessToken
|
||||
const cursor = this.#state.snapshot().cursor
|
||||
if (token === null || cursor === null || generation !== this.#generation)
|
||||
return
|
||||
this.#state.setStatus('reconnecting')
|
||||
this.#publish()
|
||||
try {
|
||||
const ticket = await this.#api.createEventTicket(token)
|
||||
if (generation !== this.#generation) return
|
||||
const epoch = ++this.#connectionEpoch
|
||||
this.#state.beginConnection()
|
||||
const listener = this.#listener(generation, epoch)
|
||||
this.#connection = this.#socket.connect(ticket.ticket, cursor, listener)
|
||||
} catch {
|
||||
if (generation === this.#generation) this.#scheduleReconnect(generation)
|
||||
}
|
||||
}
|
||||
|
||||
#listener(generation: number, epoch: number): EventSocketListener {
|
||||
const current = (): boolean =>
|
||||
generation === this.#generation && epoch === this.#connectionEpoch
|
||||
return {
|
||||
opened: () => {
|
||||
if (!current()) return
|
||||
this.#state.setStatus('live')
|
||||
this.#publish()
|
||||
},
|
||||
frame: (frame) => {
|
||||
if (!current()) return
|
||||
this.#frames = this.#frames.then(async () => {
|
||||
if (!current()) return
|
||||
await this.#handleFrame(generation, epoch, frame)
|
||||
})
|
||||
},
|
||||
closed: () => {
|
||||
if (current()) this.#scheduleReconnect(generation)
|
||||
},
|
||||
failed: () => {
|
||||
if (current()) this.#scheduleReconnect(generation)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async #handleFrame(
|
||||
generation: number,
|
||||
epoch: number,
|
||||
frame: EventFrameDto,
|
||||
): Promise<void> {
|
||||
const effect = this.#state.apply(frame)
|
||||
this.#publish()
|
||||
if (await this.#performEffect(generation, epoch, effect)) {
|
||||
this.#state.commit(frame)
|
||||
this.#publish()
|
||||
}
|
||||
}
|
||||
|
||||
async #performEffect(
|
||||
generation: number,
|
||||
epoch: number,
|
||||
effect: SynchronizationEffect,
|
||||
): Promise<boolean> {
|
||||
if (effect.kind === 'none')
|
||||
return generation === this.#generation && epoch === this.#connectionEpoch
|
||||
if (effect.kind === 'resync') {
|
||||
await this.#resync(generation, epoch)
|
||||
return false
|
||||
}
|
||||
const token = this.#accessToken
|
||||
if (
|
||||
token === null ||
|
||||
generation !== this.#generation ||
|
||||
epoch !== this.#connectionEpoch
|
||||
)
|
||||
return false
|
||||
try {
|
||||
if (effect.kind === 'refetch_job') {
|
||||
const resource = await this.#api.getJob(token, effect.jobId)
|
||||
if (generation !== this.#generation || epoch !== this.#connectionEpoch)
|
||||
return false
|
||||
if (
|
||||
!this.#state.replaceJob(
|
||||
effect.jobId,
|
||||
effect.minimumRevision,
|
||||
resource,
|
||||
)
|
||||
) {
|
||||
await this.#resync(generation, epoch)
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
const operation = await this.#api.getDeletion(token, effect.operationId)
|
||||
if (generation !== this.#generation || epoch !== this.#connectionEpoch)
|
||||
return false
|
||||
this.#state.replaceDeletion(effect.operationId, operation)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
await this.#resync(generation, epoch)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async #resync(generation: number, epoch: number): Promise<void> {
|
||||
if (generation !== this.#generation || epoch !== this.#connectionEpoch)
|
||||
return
|
||||
this.#connectionEpoch += 1
|
||||
await this.#loadSnapshot(generation, 'resyncing')
|
||||
}
|
||||
|
||||
#scheduleReconnect(generation: number): void {
|
||||
if (generation !== this.#generation || this.#cancelRetry !== null) return
|
||||
this.#connectionEpoch += 1
|
||||
this.#frames = Promise.resolve()
|
||||
this.#closeConnection()
|
||||
this.#state.setStatus('reconnecting')
|
||||
this.#publish()
|
||||
this.#cancelRetry = this.#scheduler.schedule(() => {
|
||||
this.#cancelRetry = null
|
||||
void this.#connect(generation)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
#scheduleSnapshotRetry(generation: number): void {
|
||||
if (generation !== this.#generation || this.#cancelRetry !== null) return
|
||||
this.#cancelRetry = this.#scheduler.schedule(() => {
|
||||
this.#cancelRetry = null
|
||||
void this.#loadSnapshot(generation, 'resyncing')
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
#closeConnection(): void {
|
||||
const connection = this.#connection
|
||||
this.#connection = null
|
||||
connection?.close()
|
||||
}
|
||||
|
||||
#cancelScheduledRetry(): void {
|
||||
this.#cancelRetry?.()
|
||||
this.#cancelRetry = null
|
||||
}
|
||||
|
||||
#publish(): void {
|
||||
const snapshot = this.snapshot
|
||||
for (const listener of this.#listeners) listener(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
function message(error: unknown): string {
|
||||
return error instanceof Error ? error.message : 'Synchronization failed'
|
||||
}
|
||||
363
web/src/sync/runtime.unit.test.ts
Normal file
363
web/src/sync/runtime.unit.test.ts
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
import { describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
EventConnection,
|
||||
EventSocket,
|
||||
EventSocketListener,
|
||||
PublicApi,
|
||||
} from '../api/client'
|
||||
import type {
|
||||
DeletionOperationDto,
|
||||
EventTicketDto,
|
||||
JobPageDto,
|
||||
JobResourceDto,
|
||||
} from '../api/contracts'
|
||||
import type { Session, SessionState } from '../auth/session'
|
||||
import { WebClientRuntime, type Scheduler } from './runtime'
|
||||
import { heartbeat, job, jobChanged, observedAt } from './test-fixtures'
|
||||
|
||||
describe('web client runtime', () => {
|
||||
test('loads a snapshot, deduplicates events, and resyncs on a gap', async () => {
|
||||
const fixture = runtimeFixture()
|
||||
await fixture.runtime.start()
|
||||
fixture.session.emit(authenticated('token-one'))
|
||||
await live(fixture)
|
||||
|
||||
fixture.socket.frame(jobChanged(1, 'event_000001', 2, 20))
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.api.jobRequests).toEqual(['job_00000001'])
|
||||
})
|
||||
expect(fixture.runtime.snapshot.jobs[0]?.resource).toEqual(job(2))
|
||||
|
||||
fixture.socket.frame(jobChanged(2, 'event_000001', 2, 20))
|
||||
await flush()
|
||||
expect(fixture.api.jobRequests).toHaveLength(1)
|
||||
|
||||
fixture.socket.frame(heartbeat(4, 'event_000004'))
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.api.listRequests).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
test('clears state and ignores stale work on logout', async () => {
|
||||
const fixture = runtimeFixture()
|
||||
await fixture.runtime.start()
|
||||
fixture.session.emit(authenticated('token-one'))
|
||||
await live(fixture)
|
||||
|
||||
fixture.session.emit({ kind: 'signed_out' })
|
||||
|
||||
expect(fixture.runtime.snapshot.status).toBe('signed_out')
|
||||
expect(fixture.runtime.snapshot.jobs).toEqual([])
|
||||
expect(fixture.socket.connections[0]?.closed).toBe(true)
|
||||
})
|
||||
|
||||
test('takes a fresh snapshot when authorization material changes', async () => {
|
||||
const fixture = runtimeFixture()
|
||||
await fixture.runtime.start()
|
||||
fixture.session.emit(authenticated('token-one'))
|
||||
await live(fixture)
|
||||
|
||||
fixture.api.snapshot = job(5, { label: 'New authorization' })
|
||||
fixture.session.emit(authenticated('token-two'))
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.runtime.snapshot.status).toBe('live')
|
||||
expect(fixture.runtime.snapshot.jobs[0]?.resource).toEqual(
|
||||
fixture.api.snapshot,
|
||||
)
|
||||
})
|
||||
expect(fixture.api.tokens).toEqual(['token-one', 'token-two'])
|
||||
expect(fixture.socket.connections[0]?.closed).toBe(true)
|
||||
})
|
||||
|
||||
test('does not serialize a new session behind stale event work', async () => {
|
||||
const fixture = runtimeFixture()
|
||||
await fixture.runtime.start()
|
||||
fixture.session.emit(authenticated('token-one'))
|
||||
await live(fixture)
|
||||
fixture.api.nextJob = new Promise(() => {})
|
||||
fixture.socket.frame(jobChanged(1, 'event_000001', 2, 20))
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.api.jobRequests).toEqual(['job_00000001'])
|
||||
})
|
||||
|
||||
fixture.session.emit({ kind: 'signed_out' })
|
||||
fixture.api.snapshot = job(5)
|
||||
fixture.session.emit(authenticated('token-two'))
|
||||
await live(fixture)
|
||||
fixture.api.nextJob = Promise.resolve(job(6))
|
||||
fixture.socket.frame(jobChanged(1, 'event_000006', 6, 60))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.runtime.snapshot.jobs[0]?.resource).toEqual(job(6))
|
||||
})
|
||||
})
|
||||
|
||||
test('refetches deletion invalidations and reconnects from the latest cursor', async () => {
|
||||
const fixture = runtimeFixture()
|
||||
await fixture.runtime.start()
|
||||
fixture.session.emit(authenticated('token-one'))
|
||||
await live(fixture)
|
||||
fixture.socket.frame({
|
||||
schema_version: 1,
|
||||
kind: 'deletion_changed',
|
||||
event_id: 'event_000001',
|
||||
sequence: 1,
|
||||
cursor: 'cursor-event-0001',
|
||||
occurred_at: observedAt,
|
||||
job_id: 'job_00000001',
|
||||
operation_id: 'deletion_001',
|
||||
state: 'running',
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.runtime.snapshot.deletions).toEqual([fixture.api.deletion])
|
||||
})
|
||||
fixture.socket.frame(heartbeat(2, 'event_000002'))
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.runtime.snapshot.cursor).toBe('cursor-event-0002')
|
||||
})
|
||||
fixture.socket.disconnect()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.socket.cursors).toEqual([
|
||||
'cursor-snapshot-0001',
|
||||
'cursor-event-0002',
|
||||
])
|
||||
})
|
||||
expect(fixture.api.ticketRequests).toBe(2)
|
||||
})
|
||||
|
||||
test('replays an invalidation whose refetch was interrupted by reconnect', async () => {
|
||||
const fixture = runtimeFixture()
|
||||
const staleRequest = deferred<JobResourceDto | null>()
|
||||
await fixture.runtime.start()
|
||||
fixture.session.emit(authenticated('token-one'))
|
||||
await live(fixture)
|
||||
fixture.api.nextJob = staleRequest.promise
|
||||
|
||||
fixture.socket.frame(jobChanged(1, 'event_000001', 2, 20))
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.api.jobRequests).toEqual(['job_00000001'])
|
||||
})
|
||||
fixture.socket.disconnect()
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.socket.cursors).toEqual([
|
||||
'cursor-snapshot-0001',
|
||||
'cursor-snapshot-0001',
|
||||
])
|
||||
})
|
||||
|
||||
fixture.socket.frame(jobChanged(1, 'event_000001', 2, 20))
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.api.jobRequests).toHaveLength(2)
|
||||
expect(fixture.runtime.snapshot.jobs[0]?.resource).toEqual(job(2))
|
||||
expect(fixture.runtime.snapshot.cursor).toBe('cursor-event-0001')
|
||||
})
|
||||
staleRequest.resolve(job(9))
|
||||
await flush()
|
||||
expect(fixture.runtime.snapshot.jobs[0]?.resource).toEqual(job(2))
|
||||
})
|
||||
|
||||
test('does not serialize a reconnected socket behind a hung refetch', async () => {
|
||||
const fixture = runtimeFixture()
|
||||
await fixture.runtime.start()
|
||||
fixture.session.emit(authenticated('token-one'))
|
||||
await live(fixture)
|
||||
fixture.api.nextJob = new Promise(() => {})
|
||||
|
||||
fixture.socket.frame(jobChanged(1, 'event_000001', 2, 20))
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.api.jobRequests).toEqual(['job_00000001'])
|
||||
})
|
||||
fixture.socket.disconnect()
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.socket.connections).toHaveLength(2)
|
||||
})
|
||||
fixture.socket.frame({
|
||||
schema_version: 1,
|
||||
kind: 'resync_required',
|
||||
event_id: 'event_000002',
|
||||
sequence: 1,
|
||||
cursor: 'cursor-event-0002',
|
||||
occurred_at: observedAt,
|
||||
reason: 'history_unavailable',
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.api.listRequests).toBe(2)
|
||||
expect(fixture.runtime.snapshot.status).toBe('live')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function runtimeFixture(): {
|
||||
runtime: WebClientRuntime
|
||||
session: FakeSession
|
||||
api: FakeApi
|
||||
socket: FakeSocket
|
||||
} {
|
||||
const session = new FakeSession()
|
||||
const api = new FakeApi()
|
||||
const socket = new FakeSocket()
|
||||
return {
|
||||
runtime: new WebClientRuntime(
|
||||
session,
|
||||
api,
|
||||
socket,
|
||||
new ImmediateScheduler(),
|
||||
),
|
||||
session,
|
||||
api,
|
||||
socket,
|
||||
}
|
||||
}
|
||||
|
||||
async function live(fixture: ReturnType<typeof runtimeFixture>): Promise<void> {
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.runtime.snapshot.status).toBe('live')
|
||||
})
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve(value: T): void
|
||||
} {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((complete) => {
|
||||
resolve = complete
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function authenticated(accessToken: string): SessionState {
|
||||
return {
|
||||
kind: 'authenticated',
|
||||
session: { accessToken, identity: 'issuer\u0000subject' },
|
||||
}
|
||||
}
|
||||
|
||||
class FakeSession implements Session {
|
||||
state: SessionState = { kind: 'loading' }
|
||||
readonly #listeners = new Set<(state: SessionState) => void>()
|
||||
|
||||
async start(): Promise<void> {}
|
||||
async signIn(): Promise<void> {}
|
||||
async signOut(): Promise<void> {}
|
||||
|
||||
subscribe(listener: (state: SessionState) => void): () => void {
|
||||
this.#listeners.add(listener)
|
||||
listener(this.state)
|
||||
return () => {
|
||||
this.#listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
emit(state: SessionState): void {
|
||||
this.state = state
|
||||
for (const listener of this.#listeners) listener(state)
|
||||
}
|
||||
}
|
||||
|
||||
class FakeApi implements PublicApi {
|
||||
snapshot = job(1)
|
||||
readonly deletion: DeletionOperationDto = {
|
||||
id: 'deletion_001',
|
||||
job_id: 'job_00000001',
|
||||
state: 'running',
|
||||
problem: null,
|
||||
created_at: observedAt,
|
||||
updated_at: observedAt,
|
||||
}
|
||||
nextJob: Promise<JobResourceDto | null> | null = null
|
||||
listRequests = 0
|
||||
ticketRequests = 0
|
||||
readonly tokens: string[] = []
|
||||
readonly jobRequests: string[] = []
|
||||
|
||||
async listJobs(accessToken: string): Promise<JobPageDto> {
|
||||
this.listRequests += 1
|
||||
this.tokens.push(accessToken)
|
||||
return {
|
||||
items: [this.snapshot],
|
||||
next_cursor: null,
|
||||
event_cursor: 'cursor-snapshot-0001',
|
||||
}
|
||||
}
|
||||
|
||||
async getJob(
|
||||
_accessToken: string,
|
||||
jobId: string,
|
||||
): Promise<JobResourceDto | null> {
|
||||
this.jobRequests.push(jobId)
|
||||
if (this.nextJob !== null) {
|
||||
const response = this.nextJob
|
||||
this.nextJob = null
|
||||
return response
|
||||
}
|
||||
this.snapshot = job(2)
|
||||
return this.snapshot
|
||||
}
|
||||
|
||||
async getDeletion(
|
||||
_accessToken: string,
|
||||
_operationId: string,
|
||||
): Promise<DeletionOperationDto | null> {
|
||||
return this.deletion
|
||||
}
|
||||
|
||||
async createEventTicket(_accessToken: string): Promise<EventTicketDto> {
|
||||
this.ticketRequests += 1
|
||||
return {
|
||||
ticket: 'ticket_00001',
|
||||
expires_at: '2026-07-17T00:01:00Z',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface FakeConnection extends EventConnection {
|
||||
closed: boolean
|
||||
}
|
||||
|
||||
class FakeSocket implements EventSocket {
|
||||
readonly connections: FakeConnection[] = []
|
||||
readonly cursors: string[] = []
|
||||
#listener: EventSocketListener | null = null
|
||||
|
||||
connect(
|
||||
_ticket: string,
|
||||
cursor: string,
|
||||
listener: EventSocketListener,
|
||||
): EventConnection {
|
||||
this.#listener = listener
|
||||
const connection: FakeConnection = {
|
||||
closed: false,
|
||||
close(): void {
|
||||
connection.closed = true
|
||||
},
|
||||
}
|
||||
this.connections.push(connection)
|
||||
this.cursors.push(cursor)
|
||||
listener.opened()
|
||||
return connection
|
||||
}
|
||||
|
||||
frame(frame: Parameters<EventSocketListener['frame']>[0]): void {
|
||||
this.#listener?.frame(frame)
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.#listener?.closed()
|
||||
}
|
||||
}
|
||||
|
||||
class ImmediateScheduler implements Scheduler {
|
||||
schedule(callback: () => void): () => void {
|
||||
queueMicrotask(callback)
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
198
web/src/sync/state.ts
Normal file
198
web/src/sync/state.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import type {
|
||||
DeletionOperationDto,
|
||||
EventFrameDto,
|
||||
EventProgressDto,
|
||||
JobResourceDto,
|
||||
} from '../api/contracts'
|
||||
|
||||
export type SynchronizationStatus =
|
||||
'signed_out' | 'loading' | 'live' | 'reconnecting' | 'resyncing' | 'error'
|
||||
|
||||
export interface CachedJob {
|
||||
resource: JobResourceDto
|
||||
provisionalProgress: EventProgressDto | null
|
||||
}
|
||||
|
||||
export interface SynchronizationSnapshot {
|
||||
status: SynchronizationStatus
|
||||
jobs: CachedJob[]
|
||||
deletions: DeletionOperationDto[]
|
||||
cursor: string | null
|
||||
message: string | null
|
||||
}
|
||||
|
||||
export type SynchronizationEffect =
|
||||
| { kind: 'none' }
|
||||
| { kind: 'resync' }
|
||||
| { kind: 'refetch_job'; jobId: string; minimumRevision: number }
|
||||
| { kind: 'refetch_deletion'; operationId: string }
|
||||
|
||||
export class SynchronizationState {
|
||||
#status: SynchronizationStatus = 'signed_out'
|
||||
#message: string | null = null
|
||||
readonly #jobs = new Map<string, JobResourceDto>()
|
||||
readonly #deletions = new Map<string, DeletionOperationDto>()
|
||||
readonly #progress = new Map<string, EventProgressDto>()
|
||||
#cursor: string | null = null
|
||||
#nextSequence = 1
|
||||
readonly #eventIds = new BoundedSet(2048)
|
||||
|
||||
snapshot(): SynchronizationSnapshot {
|
||||
return {
|
||||
status: this.#status,
|
||||
jobs: [...this.#jobs.values()].sort(compareResources).map((resource) => ({
|
||||
resource,
|
||||
provisionalProgress: this.#progress.get(resource.id) ?? null,
|
||||
})),
|
||||
deletions: [...this.#deletions.values()].sort((left, right) =>
|
||||
left.id.localeCompare(right.id),
|
||||
),
|
||||
cursor: this.#cursor,
|
||||
message: this.#message,
|
||||
}
|
||||
}
|
||||
|
||||
setStatus(
|
||||
status: SynchronizationStatus,
|
||||
message: string | null = null,
|
||||
): void {
|
||||
this.#status = status
|
||||
this.#message = message
|
||||
}
|
||||
|
||||
clear(status: SynchronizationStatus): void {
|
||||
this.#jobs.clear()
|
||||
this.#deletions.clear()
|
||||
this.#progress.clear()
|
||||
this.#eventIds.clear()
|
||||
this.#cursor = null
|
||||
this.#nextSequence = 1
|
||||
this.setStatus(status)
|
||||
}
|
||||
|
||||
installSnapshot(resources: JobResourceDto[], cursor: string): void {
|
||||
this.#jobs.clear()
|
||||
this.#deletions.clear()
|
||||
this.#progress.clear()
|
||||
for (const resource of resources) this.#jobs.set(resource.id, resource)
|
||||
this.#cursor = cursor
|
||||
this.#eventIds.clear()
|
||||
}
|
||||
|
||||
beginConnection(): void {
|
||||
this.#nextSequence = 1
|
||||
}
|
||||
|
||||
apply(frame: EventFrameDto): SynchronizationEffect {
|
||||
if (frame.sequence !== this.#nextSequence) return { kind: 'resync' }
|
||||
this.#nextSequence += 1
|
||||
if (this.#eventIds.has(frame.event_id)) return { kind: 'none' }
|
||||
switch (frame.kind) {
|
||||
case 'heartbeat':
|
||||
return { kind: 'none' }
|
||||
case 'resync_required':
|
||||
return { kind: 'resync' }
|
||||
case 'deletion_changed':
|
||||
return {
|
||||
kind: 'refetch_deletion',
|
||||
operationId: frame.operation_id,
|
||||
}
|
||||
case 'job_changed': {
|
||||
const resource = this.#jobs.get(frame.job_id)
|
||||
if (
|
||||
resource?.resource_kind === 'job' &&
|
||||
resource.revision >= frame.job_revision
|
||||
) {
|
||||
this.#progress.delete(frame.job_id)
|
||||
return { kind: 'none' }
|
||||
}
|
||||
if (validProgress(frame.authoritative_attempt, frame.progress))
|
||||
this.#progress.set(frame.job_id, frame.progress)
|
||||
else this.#progress.delete(frame.job_id)
|
||||
return {
|
||||
kind: 'refetch_job',
|
||||
jobId: frame.job_id,
|
||||
minimumRevision: frame.job_revision,
|
||||
}
|
||||
}
|
||||
}
|
||||
return unreachable(frame)
|
||||
}
|
||||
|
||||
commit(frame: EventFrameDto): void {
|
||||
this.#cursor = frame.cursor
|
||||
this.#eventIds.add(frame.event_id)
|
||||
}
|
||||
|
||||
replaceJob(
|
||||
jobId: string,
|
||||
minimumRevision: number,
|
||||
resource: JobResourceDto | null,
|
||||
): boolean {
|
||||
if (
|
||||
resource?.resource_kind === 'job' &&
|
||||
resource.revision < minimumRevision
|
||||
)
|
||||
return false
|
||||
this.#progress.delete(jobId)
|
||||
if (resource === null) this.#jobs.delete(jobId)
|
||||
else this.#jobs.set(jobId, resource)
|
||||
return true
|
||||
}
|
||||
|
||||
replaceDeletion(
|
||||
operationId: string,
|
||||
operation: DeletionOperationDto | null,
|
||||
): void {
|
||||
if (operation === null) this.#deletions.delete(operationId)
|
||||
else this.#deletions.set(operationId, operation)
|
||||
}
|
||||
}
|
||||
|
||||
function unreachable(value: never): never {
|
||||
throw new Error(`Unsupported event frame: ${String(value)}`)
|
||||
}
|
||||
|
||||
function validProgress(
|
||||
attempt: { attempt_id: string; fence: number } | null,
|
||||
progress: EventProgressDto | null,
|
||||
): progress is EventProgressDto {
|
||||
return (
|
||||
attempt !== null &&
|
||||
progress !== null &&
|
||||
attempt.attempt_id === progress.attempt_id &&
|
||||
attempt.fence === progress.fence
|
||||
)
|
||||
}
|
||||
|
||||
function compareResources(left: JobResourceDto, right: JobResourceDto): number {
|
||||
const time = right.created_at.localeCompare(left.created_at)
|
||||
return time === 0 ? left.id.localeCompare(right.id) : time
|
||||
}
|
||||
|
||||
class BoundedSet {
|
||||
readonly #values = new Set<string>()
|
||||
readonly #order: string[] = []
|
||||
|
||||
constructor(readonly capacity: number) {}
|
||||
|
||||
add(value: string): boolean {
|
||||
if (this.#values.has(value)) return false
|
||||
this.#values.add(value)
|
||||
this.#order.push(value)
|
||||
if (this.#order.length > this.capacity) {
|
||||
const removed = this.#order.shift()
|
||||
if (removed !== undefined) this.#values.delete(removed)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
has(value: string): boolean {
|
||||
return this.#values.has(value)
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.#values.clear()
|
||||
this.#order.length = 0
|
||||
}
|
||||
}
|
||||
82
web/src/sync/state.unit.test.ts
Normal file
82
web/src/sync/state.unit.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { SynchronizationState } from './state'
|
||||
import { heartbeat, job, jobChanged } from './test-fixtures'
|
||||
|
||||
describe('synchronization state', () => {
|
||||
test('deduplicates replay while advancing the connection cursor', () => {
|
||||
const state = new SynchronizationState()
|
||||
state.installSnapshot([job(1)], 'cursor-snapshot-0001')
|
||||
state.beginConnection()
|
||||
|
||||
expect(state.apply(jobChanged(1, 'event_000001', 2, 20))).toEqual({
|
||||
kind: 'refetch_job',
|
||||
jobId: 'job_00000001',
|
||||
minimumRevision: 2,
|
||||
})
|
||||
expect(state.snapshot().jobs[0]?.provisionalProgress?.completed_bytes).toBe(
|
||||
20,
|
||||
)
|
||||
expect(state.replaceJob('job_00000001', 2, job(2))).toBe(true)
|
||||
state.commit(jobChanged(1, 'event_000001', 2, 20))
|
||||
|
||||
const duplicate = jobChanged(2, 'event_000001', 2, 20)
|
||||
expect(state.apply(duplicate)).toEqual({
|
||||
kind: 'none',
|
||||
})
|
||||
state.commit(duplicate)
|
||||
expect(state.snapshot().cursor).toBe('cursor-event-0002')
|
||||
expect(state.snapshot().jobs[0]?.provisionalProgress).toBeNull()
|
||||
})
|
||||
|
||||
test('requires increasing per-connection sequence numbers', () => {
|
||||
const state = new SynchronizationState()
|
||||
state.installSnapshot([job(1)], 'cursor-snapshot-0001')
|
||||
state.beginConnection()
|
||||
|
||||
expect(state.apply(heartbeat(2, 'event_000002'))).toEqual({
|
||||
kind: 'resync',
|
||||
})
|
||||
expect(state.snapshot().cursor).toBe('cursor-snapshot-0001')
|
||||
})
|
||||
|
||||
test('retains replay deduplication when a connection restarts', () => {
|
||||
const state = new SynchronizationState()
|
||||
state.installSnapshot([job(1)], 'cursor-snapshot-0001')
|
||||
state.beginConnection()
|
||||
const first = heartbeat(1, 'event_000001')
|
||||
state.apply(first)
|
||||
state.commit(first)
|
||||
|
||||
state.beginConnection()
|
||||
const replay = heartbeat(1, 'event_000001')
|
||||
expect(state.apply(replay)).toEqual({
|
||||
kind: 'none',
|
||||
})
|
||||
state.commit(replay)
|
||||
expect(state.snapshot().cursor).toBe('cursor-event-0001')
|
||||
})
|
||||
|
||||
test('rejects an HTTP refetch older than its invalidation', () => {
|
||||
const state = new SynchronizationState()
|
||||
state.installSnapshot([job(1)], 'cursor-snapshot-0001')
|
||||
|
||||
expect(state.replaceJob('job_00000001', 3, job(2))).toBe(false)
|
||||
expect(state.snapshot().jobs[0]?.resource).toEqual(job(1))
|
||||
expect(state.replaceJob('job_00000001', 3, job(3))).toBe(true)
|
||||
expect(state.snapshot().jobs[0]?.resource).toEqual(job(3))
|
||||
})
|
||||
|
||||
test('does not expose a progress hint with mismatched authority', () => {
|
||||
const state = new SynchronizationState()
|
||||
state.installSnapshot([job(1)], 'cursor-snapshot-0001')
|
||||
state.beginConnection()
|
||||
const frame = jobChanged(1, 'event_000001', 2, 20)
|
||||
if (frame.kind !== 'job_changed') throw new Error('invalid fixture')
|
||||
frame.progress = { ...frame.progress!, fence: 2 }
|
||||
|
||||
state.apply(frame)
|
||||
|
||||
expect(state.snapshot().jobs[0]?.provisionalProgress).toBeNull()
|
||||
})
|
||||
})
|
||||
93
web/src/sync/test-fixtures.ts
Normal file
93
web/src/sync/test-fixtures.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import type { EventFrameDto, JobDto, ProgressDto } from '../api/contracts'
|
||||
|
||||
export const observedAt = '2026-07-17T00:00:00Z'
|
||||
|
||||
export function job(revision: number, overrides: Partial<JobDto> = {}): JobDto {
|
||||
return {
|
||||
resource_kind: 'job',
|
||||
id: 'job_00000001',
|
||||
owner: 'owner_000001',
|
||||
label: 'Fixture job',
|
||||
revision,
|
||||
phase: 'Running',
|
||||
lifecycle: 'visible',
|
||||
source: {
|
||||
kind: 'http',
|
||||
display_url: 'https://example.invalid/archive',
|
||||
result_path: 'archive.bin',
|
||||
},
|
||||
target: {
|
||||
store: 'target-main',
|
||||
bucket: 'fixture-bucket',
|
||||
prefix: 'results',
|
||||
},
|
||||
authoritative_attempt: {
|
||||
attempt_id: 'attempt_0001',
|
||||
fence: 1,
|
||||
worker_incarnation: 'worker_00001',
|
||||
},
|
||||
progress: progress(10),
|
||||
checkpoint: null,
|
||||
result: null,
|
||||
failure: null,
|
||||
created_at: observedAt,
|
||||
updated_at: observedAt,
|
||||
event_cursor: 'cursor-snapshot-0001',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function progress(completedBytes: number): ProgressDto {
|
||||
return {
|
||||
attempt_id: 'attempt_0001',
|
||||
fence: 1,
|
||||
completed_bytes: completedBytes,
|
||||
total_bytes: 100,
|
||||
completed_members: 0,
|
||||
total_members: 1,
|
||||
observed_at: observedAt,
|
||||
}
|
||||
}
|
||||
|
||||
export function jobChanged(
|
||||
sequence: number,
|
||||
eventId: string,
|
||||
revision: number,
|
||||
completedBytes: number,
|
||||
): EventFrameDto {
|
||||
return {
|
||||
schema_version: 1,
|
||||
kind: 'job_changed',
|
||||
event_id: eventId,
|
||||
sequence,
|
||||
cursor: `cursor-event-${String(sequence).padStart(4, '0')}`,
|
||||
occurred_at: observedAt,
|
||||
job_id: 'job_00000001',
|
||||
job_revision: revision,
|
||||
phase: 'Running',
|
||||
lifecycle: 'visible',
|
||||
authoritative_attempt: {
|
||||
attempt_id: 'attempt_0001',
|
||||
fence: 1,
|
||||
},
|
||||
progress: {
|
||||
attempt_id: 'attempt_0001',
|
||||
fence: 1,
|
||||
completed_bytes: completedBytes,
|
||||
total_bytes: 100,
|
||||
completed_members: 0,
|
||||
total_members: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function heartbeat(sequence: number, eventId: string): EventFrameDto {
|
||||
return {
|
||||
schema_version: 1,
|
||||
kind: 'heartbeat',
|
||||
event_id: eventId,
|
||||
sequence,
|
||||
cursor: `cursor-event-${String(sequence).padStart(4, '0')}`,
|
||||
occurred_at: observedAt,
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue