tempest-react-sdk 0.79.0 → 0.81.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -4
- package/bin/lib/component/generate.test.mjs +13 -1
- package/bin/lib/openapi/generate.hostile.test.mjs +58 -5
- package/bin/lib/openapi/generate.mjs +145 -34
- package/bin/lib/openapi/generate.test.mjs +216 -5
- package/bin/lib/openapi/schema-to-zod.mjs +4 -3
- package/dist/auth/create-tempest-auth.cjs +1 -1
- package/dist/auth/create-tempest-auth.cjs.map +1 -1
- package/dist/auth/create-tempest-auth.js +172 -103
- package/dist/auth/create-tempest-auth.js.map +1 -1
- package/dist/br/boleto.cjs +1 -1
- package/dist/br/boleto.cjs.map +1 -1
- package/dist/br/boleto.js +2 -1
- package/dist/br/boleto.js.map +1 -1
- package/dist/br.d.ts +22 -3
- package/dist/components/AuthForm/AuthFieldControl.cjs +2 -0
- package/dist/components/AuthForm/AuthFieldControl.cjs.map +1 -0
- package/dist/components/AuthForm/AuthFieldControl.js +85 -0
- package/dist/components/AuthForm/AuthFieldControl.js.map +1 -0
- package/dist/components/AuthForm/AuthForm.cjs +2 -0
- package/dist/components/AuthForm/AuthForm.cjs.map +1 -0
- package/dist/components/AuthForm/AuthForm.js +83 -0
- package/dist/components/AuthForm/AuthForm.js.map +1 -0
- package/dist/components/AuthForm/AuthForm.module.cjs +3 -0
- package/dist/components/AuthForm/AuthForm.module.cjs.map +1 -0
- package/dist/components/AuthForm/AuthForm.module.js +14 -0
- package/dist/components/AuthForm/AuthForm.module.js.map +1 -0
- package/dist/components/AuthForm/AuthLinks.cjs +2 -0
- package/dist/components/AuthForm/AuthLinks.cjs.map +1 -0
- package/dist/components/AuthForm/AuthLinks.js +29 -0
- package/dist/components/AuthForm/AuthLinks.js.map +1 -0
- package/dist/components/AuthForm/ForgotPasswordForm.cjs +2 -0
- package/dist/components/AuthForm/ForgotPasswordForm.cjs.map +1 -0
- package/dist/components/AuthForm/ForgotPasswordForm.js +32 -0
- package/dist/components/AuthForm/ForgotPasswordForm.js.map +1 -0
- package/dist/components/AuthForm/LoginForm.cjs +2 -0
- package/dist/components/AuthForm/LoginForm.cjs.map +1 -0
- package/dist/components/AuthForm/LoginForm.js +39 -0
- package/dist/components/AuthForm/LoginForm.js.map +1 -0
- package/dist/components/AuthForm/ResetPasswordForm.cjs +2 -0
- package/dist/components/AuthForm/ResetPasswordForm.cjs.map +1 -0
- package/dist/components/AuthForm/ResetPasswordForm.js +39 -0
- package/dist/components/AuthForm/ResetPasswordForm.js.map +1 -0
- package/dist/components/AuthForm/SignUpForm.cjs +2 -0
- package/dist/components/AuthForm/SignUpForm.cjs.map +1 -0
- package/dist/components/AuthForm/SignUpForm.js +32 -0
- package/dist/components/AuthForm/SignUpForm.js.map +1 -0
- package/dist/components/AuthForm/auth-defaults.cjs +2 -0
- package/dist/components/AuthForm/auth-defaults.cjs.map +1 -0
- package/dist/components/AuthForm/auth-defaults.js +54 -0
- package/dist/components/AuthForm/auth-defaults.js.map +1 -0
- package/dist/components/AuthForm/auth-schemas.cjs +2 -0
- package/dist/components/AuthForm/auth-schemas.cjs.map +1 -0
- package/dist/components/AuthForm/auth-schemas.js +26 -0
- package/dist/components/AuthForm/auth-schemas.js.map +1 -0
- package/dist/components/AuthPage/AuthPage.cjs +2 -0
- package/dist/components/AuthPage/AuthPage.cjs.map +1 -0
- package/dist/components/AuthPage/AuthPage.js +91 -0
- package/dist/components/AuthPage/AuthPage.js.map +1 -0
- package/dist/components/AuthPage/AuthPage.module.cjs +3 -0
- package/dist/components/AuthPage/AuthPage.module.cjs.map +1 -0
- package/dist/components/AuthPage/AuthPage.module.js +24 -0
- package/dist/components/AuthPage/AuthPage.module.js.map +1 -0
- package/dist/components/FilterBar/filter-apply.cjs +1 -1
- package/dist/components/FilterBar/filter-apply.cjs.map +1 -1
- package/dist/components/FilterBar/filter-apply.js +7 -7
- package/dist/forms/index.js +7 -7
- package/dist/forms/masked-inputs.cjs +1 -1
- package/dist/forms/masked-inputs.cjs.map +1 -1
- package/dist/forms/masked-inputs.js +15 -15
- package/dist/http/api-client.cjs +1 -1
- package/dist/http/api-client.cjs.map +1 -1
- package/dist/http/api-client.js +104 -97
- package/dist/http/api-client.js.map +1 -1
- package/dist/http/build-url.cjs +1 -1
- package/dist/http/build-url.cjs.map +1 -1
- package/dist/http/build-url.js +3 -1
- package/dist/http/build-url.js.map +1 -1
- package/dist/http/presigned-post.cjs +2 -0
- package/dist/http/presigned-post.cjs.map +1 -0
- package/dist/http/presigned-post.js +73 -0
- package/dist/http/presigned-post.js.map +1 -0
- package/dist/http/resumable-upload.cjs +1 -1
- package/dist/http/resumable-upload.cjs.map +1 -1
- package/dist/http/resumable-upload.js +97 -97
- package/dist/http/resumable-upload.js.map +1 -1
- package/dist/http/upload-with-progress.cjs +1 -1
- package/dist/http/upload-with-progress.cjs.map +1 -1
- package/dist/http/upload-with-progress.js +12 -12
- package/dist/http/use-describe-api-error.cjs +1 -1
- package/dist/http/use-describe-api-error.cjs.map +1 -1
- package/dist/http/use-describe-api-error.js +9 -9
- package/dist/offline/create-offline-sync.cjs +1 -1
- package/dist/offline/create-offline-sync.cjs.map +1 -1
- package/dist/offline/create-offline-sync.js +78 -56
- package/dist/offline/create-offline-sync.js.map +1 -1
- package/dist/sse/create-event-stream.cjs +1 -1
- package/dist/sse/create-event-stream.cjs.map +1 -1
- package/dist/sse/create-event-stream.js +44 -30
- package/dist/sse/create-event-stream.js.map +1 -1
- package/dist/sse/ticket-event-stream.cjs +2 -0
- package/dist/sse/ticket-event-stream.cjs.map +1 -0
- package/dist/sse/ticket-event-stream.js +41 -0
- package/dist/sse/ticket-event-stream.js.map +1 -0
- package/dist/sse/use-event-stream.cjs +1 -1
- package/dist/sse/use-event-stream.cjs.map +1 -1
- package/dist/sse/use-event-stream.js +7 -4
- package/dist/sse/use-event-stream.js.map +1 -1
- package/dist/styles/AuthForm.css +2 -0
- package/dist/styles/AuthPage.css +2 -0
- package/dist/styles/component/AuthForm.css +9 -0
- package/dist/styles/component/AuthPage.css +29 -0
- package/dist/styles/forms.css +36 -0
- package/dist/styles/manifest.json +63 -0
- package/dist/styles.css +1 -1
- package/dist/sw/background-sync.cjs +1 -1
- package/dist/sw/background-sync.cjs.map +1 -1
- package/dist/sw/background-sync.js +124 -106
- package/dist/sw/background-sync.js.map +1 -1
- package/dist/sw.d.ts +49 -15
- package/dist/tempest-react-sdk.cjs +1 -1
- package/dist/tempest-react-sdk.d.ts +809 -22
- package/dist/tempest-react-sdk.js +373 -363
- package/dist/utils/format.cjs +1 -1
- package/dist/utils/format.cjs.map +1 -1
- package/dist/utils/format.js +25 -25
- package/dist/utils/numbers.cjs +1 -1
- package/dist/utils/numbers.cjs.map +1 -1
- package/dist/utils/numbers.js +7 -7
- package/package.json +1 -1
- package/template-pwa/src/sw.ts +10 -6
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"create-offline-sync.js","names":["localStorageWatermark","available","key","value","createOfflineSync","outbox","createOfflineStore","config","watermark","idPrefix","isOnline","inflight","listeners","state","crossTab","channelName","channel","closed","notify","listener","setState","patch","event","refreshPending","push","entries","blocked","entry","cause","message","pull","since","cursor","serverTime","page","item","runOnce","startedAt","summary","runGuarded","locks","trigger","lock","lastEnqueuedAt","nextEnqueuedAt","now","randomId"],"sources":["../../src/offline/create-offline-sync.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines, function-lines — outbox drain, pull-with-watermark,\n * conflict resolution and the single-flight guard are one loop by necessity: a pull\n * that lands mid-drain must not advance the watermark past records the outbox has\n * not pushed yet. Splitting the phases hands that ordering to whoever calls them in\n * the right sequence.\n */\nimport { randomId } from \"../utils/ids\";\nimport { createOfflineStore } from \"./create-offline-store\";\n\n/**\n * Mutation kinds queued in the outbox.\n *\n * `create`/`update` carry a snapshot of the record; `delete` needs only the\n * record id. The distinction between `create` and `update` is advisory — the\n * engine treats both as \"deliver this record\" and leaves the create-vs-update\n * decision to the app's `deliver` callback (a `PUT` upsert usually ignores it).\n */\nexport type OutboxOp = \"create\" | \"update\" | \"delete\";\n\n/**\n * A single queued mutation.\n *\n * @typeParam TPayload - The record snapshot shape carried by\n * `create`/`update` entries (omitted for `delete`).\n */\nexport interface OutboxEntry<TPayload = unknown> {\n /** Stable per-entry id (generated with {@link randomId}). */\n id: string;\n /** The mutation kind. */\n op: OutboxOp;\n /** Primary key of the record the mutation targets. */\n recordId: string;\n /**\n * Epoch milliseconds when the mutation was queued, and the FIFO sort key for\n * `listPending()` and the delivery loop. Strictly increasing per engine\n * instance: entries queued inside the same millisecond are spaced by 1ms so\n * the order never depends on how the index breaks a tie.\n */\n enqueuedAt: number;\n /** How many delivery attempts have been made so far. */\n attempts: number;\n /** Last delivery error message, kept for UI/debug. */\n lastError?: string;\n /** Record snapshot for `create`/`update`. Omitted for `delete`. */\n payload?: TPayload;\n}\n\n/**\n * Why a sync run was triggered. The listed values are the common ones; any\n * string is accepted so apps can add their own telemetry labels.\n */\nexport type SyncTrigger =\n \"boot\" | \"online-event\" | \"after-mutation\" | \"manual\" | \"interval\" | (string & {});\n\n/**\n * One page of the server delta pull.\n *\n * @typeParam TRemote - The server-side item shape.\n */\nexport interface PullPage<TRemote> {\n /** Items changed since the watermark, in this page. */\n items: TRemote[];\n /** Cursor for the next page, or `null` when this is the last page. */\n nextCursor: string | null;\n /**\n * Server clock to persist as the next watermark once the whole delta is\n * applied. `null` leaves the watermark unchanged.\n */\n serverTime: string | null;\n}\n\n/** Outcome of a single {@link OfflineSync.flush} run. */\nexport interface SyncRunSummary {\n /** The trigger passed to `flush`. */\n trigger: SyncTrigger;\n /** Entries delivered to the server this run. */\n succeeded: number;\n /** Entries that failed and stay queued for the next run. */\n failed: number;\n /**\n * Entries left untried because an earlier entry for the **same record**\n * failed this run.\n *\n * They stay queued and are attempted next run, in order. Counted apart from\n * `failed` because they were never sent: reporting them as failures would\n * blame the server for a decision this engine made, and folding them in would\n * make `succeeded + failed` stop accounting for the queue.\n */\n deferred: number;\n /** Total wall-clock milliseconds the run took. */\n durationMs: number;\n /** `true` when the run was skipped because the device was offline. */\n skipped: boolean;\n /** Last delivery error message seen this run, or `null` when none failed. */\n lastError: string | null;\n}\n\n/**\n * Coarse lifecycle phase of the sync engine, surfaced through\n * {@link OfflineSync.getState} / {@link OfflineSync.subscribe} for reactive UI.\n *\n * - `\"idle\"` — nothing in flight and the last run (if any) fully succeeded.\n * - `\"syncing\"` — a `flush` run is currently in progress.\n * - `\"offline\"` — the last run was skipped because the device was offline.\n * - `\"error\"` — the last run left at least one entry queued after a failure.\n */\nexport type SyncPhase = \"idle\" | \"syncing\" | \"offline\" | \"error\";\n\n/**\n * Immutable snapshot of the engine's reactive state. A fresh object is emitted\n * to subscribers on every change, so it is safe to use as a\n * `useSyncExternalStore` snapshot (reference equality signals \"no change\").\n */\nexport interface SyncState {\n /** Current lifecycle phase. */\n phase: SyncPhase;\n /** Number of mutations still queued in the outbox. */\n pending: number;\n /** Summary of the most recent `flush` run, or `null` before the first. */\n lastSummary: SyncRunSummary | null;\n /** Message of the most recent delivery failure, or `null`. */\n lastError: string | null;\n /** Epoch ms of the last non-skipped run, or `null` before the first. */\n lastSyncedAt: number | null;\n}\n\n/**\n * Pluggable persistence for the pull watermark (the \"changed since\" cursor).\n * Pass an object with a `storageKey` to use a `localStorage`-backed default.\n */\nexport interface WatermarkStore {\n /** Read the current watermark, or `null` when none is stored. */\n get: () => string | null;\n /** Persist a new watermark. */\n set: (value: string) => void;\n /** Drop the watermark (e.g. on logout / account switch). */\n clear: () => void;\n}\n\n/**\n * Configuration for {@link createOfflineSync}.\n *\n * The engine owns the outbox, the single-flight flush, the offline guard, the\n * paginated pull loop and the watermark; the three transport callbacks\n * (`deliver`, `pullPage`, `applyRemote`) are where the app plugs in its own\n * endpoints, record shape and conflict resolution.\n *\n * @typeParam TPayload - Record snapshot carried by outbox entries.\n * @typeParam TRemote - Server item shape returned by `pullPage`.\n */\nexport interface OfflineSyncConfig<TPayload, TRemote> {\n /** IndexedDB database name for the outbox (kept separate per queue). */\n databaseName: string;\n /** Outbox object-store name. Default `\"outbox\"`. */\n tableName?: string;\n /** Outbox schema version. Default `1`. */\n version?: number;\n /** Prefix for generated entry ids. Default `\"outbox\"`. */\n idPrefix?: string;\n /**\n * Deliver one queued mutation to the server. Throwing keeps the entry\n * queued (its `attempts`/`lastError` are bumped) for the next run.\n */\n deliver: (entry: OutboxEntry<TPayload>) => Promise<void>;\n /** Fetch one page of the server delta since `since`, from `cursor`. */\n pullPage: (since: string | null, cursor: string | null) => Promise<PullPage<TRemote>>;\n /**\n * Merge one pulled item into the local store. The app owns conflict\n * resolution here (e.g. last-write-wins, keeping newer local pending\n * edits, resolving tombstones and downloading blobs).\n */\n applyRemote: (item: TRemote) => Promise<void>;\n /** Watermark persistence, or `{ storageKey }` for a `localStorage` default. */\n watermark: WatermarkStore | { storageKey: string };\n /** Called after an entry is delivered and acked. */\n onEntryDelivered?: (entry: OutboxEntry<TPayload>) => void | Promise<void>;\n /** Called after an entry fails delivery (it stays queued). */\n onEntryFailed?: (entry: OutboxEntry<TPayload>, error: unknown) => void | Promise<void>;\n /**\n * Connectivity check. Default reads `navigator.onLine` (always online in\n * non-browser environments). When it returns `false`, `flush` is skipped.\n */\n isOnline?: () => boolean;\n /**\n * Broadcast {@link SyncState} across browser tabs of the same origin via a\n * `BroadcastChannel`, so every tab shows a coherent pending count / phase\n * (e.g. one tab flushing drops the badge to zero everywhere). The outbox is\n * already a shared IndexedDB; this only shares the in-memory state. Silently\n * ignored where `BroadcastChannel` is unavailable. Default `false`.\n *\n * When the Web Locks API is available, `flush` is additionally coordinated\n * *across* tabs: only one tab runs at a time and others skip while the lock\n * is held (they pick up the result via the broadcast). Delivery should still\n * be idempotent (upsert by client id). Without Web Locks, `flush` falls back\n * to per-tab single-flight.\n */\n crossTab?: boolean;\n /** Channel name when `crossTab` is on. Default `tempest-sync:${databaseName}`. */\n broadcastChannelName?: string;\n}\n\n/**\n * Offline-first sync engine: a durable outbox plus a paginated delta pull.\n *\n * @typeParam TPayload - Record snapshot carried by outbox entries.\n */\nexport interface OfflineSync<TPayload> {\n /**\n * Queue a mutation. Returns the generated entry id.\n *\n * @param op - The mutation kind.\n * @param recordId - Primary key of the affected record.\n * @param payload - Record snapshot (for `create`/`update`).\n */\n enqueue: (op: OutboxOp, recordId: string, payload?: TPayload) => Promise<string>;\n /**\n * Run a full sync (drain the outbox, then pull the delta). Concurrent\n * calls share one in-flight promise, so triggers never overlap.\n *\n * @param trigger - Label for why the run happened. Default `\"manual\"`.\n */\n flush: (trigger?: SyncTrigger) => Promise<SyncRunSummary>;\n /** Number of mutations still queued. */\n pendingCount: () => Promise<number>;\n /** The queued mutations in FIFO order. */\n listPending: () => Promise<OutboxEntry<TPayload>[]>;\n /** Drop every queued mutation (e.g. on logout). */\n clearOutbox: () => Promise<void>;\n /** Reset the pull watermark (e.g. on logout / account switch). */\n resetWatermark: () => void;\n /** Read the current reactive {@link SyncState} snapshot synchronously. */\n getState: () => SyncState;\n /**\n * Subscribe to {@link SyncState} changes. The listener fires on every\n * enqueue, flush transition and outbox clear.\n *\n * @param listener - Called with the new snapshot on each change.\n * @returns An unsubscribe function.\n */\n subscribe: (listener: (state: SyncState) => void) => () => void;\n /**\n * Release resources held by the engine — currently the cross-tab\n * `BroadcastChannel` when `crossTab` is enabled. Safe to call when none was\n * opened. Optional in long-lived apps (the channel is reclaimed on tab\n * close); call it in tests and on teardown.\n */\n dispose: () => void;\n}\n\n/**\n * Build a `localStorage`-backed {@link WatermarkStore}. Reads/writes are no-ops\n * when `localStorage` is unavailable (SSR / non-browser).\n *\n * @param key - The `localStorage` key.\n * @returns A watermark store persisting to `localStorage`.\n */\nfunction localStorageWatermark(key: string): WatermarkStore {\n const available = typeof localStorage !== \"undefined\";\n return {\n get: () => (available ? localStorage.getItem(key) : null),\n set: (value: string) => {\n if (available) localStorage.setItem(key, value);\n },\n clear: () => {\n if (available) localStorage.removeItem(key);\n },\n };\n}\n\n/**\n * Create an offline-first sync engine over an IndexedDB outbox.\n *\n * The engine drains queued mutations to the server (`deliver`) and pulls the\n * server delta back (`pullPage` + `applyRemote`), advancing a watermark so each\n * run only fetches what changed. `flush` is single-flight and skips cleanly\n * while offline; failed deliveries stay queued with their attempt count bumped.\n *\n * Dexie is an **optional peer dependency** (via {@link createOfflineStore}) —\n * install it (`npm i dexie`) when you use this engine.\n *\n * @typeParam TPayload - Record snapshot carried by outbox entries.\n * @typeParam TRemote - Server item shape returned by `pullPage`.\n * @param config - Transport callbacks + outbox/watermark configuration.\n * @returns The sync handle (`enqueue`, `flush`, `pendingCount`, …).\n *\n * @example\n * const sync = createOfflineSync<Note, NoteDto>({\n * databaseName: \"NotesOutbox\",\n * watermark: { storageKey: \"notes.watermark\" },\n * deliver: async (entry) => {\n * if (entry.op === \"delete\") return api.remove(entry.recordId);\n * await api.upsert(entry.recordId, entry.payload!);\n * },\n * pullPage: async (since, cursor) => {\n * const page = await api.changes(since, cursor);\n * return { items: page.items, nextCursor: page.next, serverTime: page.now };\n * },\n * applyRemote: async (dto) => {\n * if (dto.deleted) return localStore.remove(dto.id);\n * await localStore.save(fromDto(dto));\n * },\n * });\n * await sync.enqueue(\"create\", note.id, note);\n * await sync.flush(\"after-mutation\");\n */\nexport function createOfflineSync<TPayload = unknown, TRemote = unknown>(\n config: OfflineSyncConfig<TPayload, TRemote>,\n): OfflineSync<TPayload> {\n const outbox = createOfflineStore<OutboxEntry<TPayload>, string>({\n databaseName: config.databaseName,\n version: config.version ?? 1,\n tableName: config.tableName ?? \"outbox\",\n indexes: \"&id, recordId, enqueuedAt\",\n keyPath: \"id\",\n });\n\n const watermark: WatermarkStore =\n \"get\" in config.watermark\n ? config.watermark\n : localStorageWatermark(config.watermark.storageKey);\n\n const idPrefix = config.idPrefix ?? \"outbox\";\n const isOnline =\n config.isOnline ?? (() => (typeof navigator !== \"undefined\" ? navigator.onLine : true));\n\n let inflight: Promise<SyncRunSummary> | null = null;\n\n const listeners = new Set<(state: SyncState) => void>();\n let state: SyncState = {\n phase: \"idle\",\n pending: 0,\n lastSummary: null,\n lastError: null,\n lastSyncedAt: null,\n };\n\n const crossTab = config.crossTab === true;\n const channelName = config.broadcastChannelName ?? `tempest-sync:${config.databaseName}`;\n const channel: BroadcastChannel | null =\n crossTab && typeof BroadcastChannel !== \"undefined\"\n ? new BroadcastChannel(channelName)\n : null;\n let closed = false;\n\n function notify(): void {\n for (const listener of listeners) listener(state);\n }\n\n function setState(patch: Partial<SyncState>): void {\n state = { ...state, ...patch };\n notify();\n if (channel && !closed) channel.postMessage(state);\n }\n\n if (channel) {\n channel.onmessage = (event: MessageEvent<SyncState>) => {\n if (closed) return;\n state = event.data;\n notify();\n };\n }\n\n async function refreshPending(): Promise<void> {\n setState({ pending: await outbox.count() });\n }\n\n /**\n * Drain the outbox in `enqueuedAt` order, one record at a time.\n *\n * Once an entry for a record fails, the remaining entries **for that record**\n * are left untried this run. `enqueuedAt` is a FIFO guarantee the engine goes\n * out of its way to keep (see `nextEnqueuedAt`), precisely because delivering\n * an `update` before the `create` it depends on is wrong — and continuing past\n * a failure broke exactly that guarantee. With a `PUT` upsert `deliver`, which\n * is the usual shape, the server would create the record from the *update*\n * payload and the retried `create` would then overwrite it with the older\n * snapshot: the user's edit disappears with nothing reported.\n *\n * Skipping is per record, not per run. Records are independent, so a record\n * the server is rejecting must not hold up every other pending change.\n *\n * @param summary - Mutated with the per-entry outcome counts.\n */\n async function push(summary: SyncRunSummary): Promise<void> {\n const entries = await outbox.list(undefined, { orderBy: \"enqueuedAt\" });\n const blocked = new Set<string>();\n for (const entry of entries) {\n if (blocked.has(entry.recordId)) {\n summary.deferred += 1;\n continue;\n }\n try {\n await config.deliver(entry);\n await outbox.delete(entry.id);\n await config.onEntryDelivered?.(entry);\n summary.succeeded += 1;\n } catch (cause) {\n const message = cause instanceof Error ? cause.message : \"delivery failed\";\n await outbox.update(entry.id, {\n attempts: entry.attempts + 1,\n lastError: message,\n } as Partial<OutboxEntry<TPayload>>);\n await config.onEntryFailed?.(entry, cause);\n blocked.add(entry.recordId);\n summary.failed += 1;\n summary.lastError = message;\n }\n }\n }\n\n async function pull(): Promise<void> {\n const since = watermark.get();\n let cursor: string | null = null;\n let serverTime: string | null;\n do {\n const page = await config.pullPage(since, cursor);\n for (const item of page.items) {\n await config.applyRemote(item);\n }\n cursor = page.nextCursor;\n serverTime = page.serverTime;\n } while (cursor);\n if (serverTime) watermark.set(serverTime);\n }\n\n async function runOnce(trigger: SyncTrigger): Promise<SyncRunSummary> {\n const startedAt = Date.now();\n const summary: SyncRunSummary = {\n trigger,\n succeeded: 0,\n failed: 0,\n deferred: 0,\n durationMs: 0,\n skipped: false,\n lastError: null,\n };\n if (!isOnline()) {\n summary.skipped = true;\n summary.durationMs = Date.now() - startedAt;\n return summary;\n }\n await push(summary);\n await pull();\n summary.durationMs = Date.now() - startedAt;\n return summary;\n }\n\n /**\n * Run one sync pass, serialized across tabs when possible.\n *\n * With `crossTab` on and the Web Locks API available, the pass is wrapped in\n * an `ifAvailable` lock request: whichever tab holds the lock runs, and the\n * others get `null` back instead of duplicating the work — their `SyncState`\n * catches up through the `BroadcastChannel`. The no-op summary keeps\n * `skipped: false` on purpose: `skipped` means \"device offline\" (it maps to\n * `phase: \"offline\"`), which is not what happened here. Falls back to a plain\n * per-tab run when `crossTab` is off or Web Locks is missing.\n *\n * @param trigger - What asked for this run, carried into the summary.\n * @returns The run summary, or an empty summary when another tab held the lock.\n */\n async function runGuarded(trigger: SyncTrigger): Promise<SyncRunSummary> {\n const locks = typeof navigator !== \"undefined\" ? navigator.locks : undefined;\n if (!crossTab || !locks) return runOnce(trigger);\n const result = await locks.request(\n `${channelName}:flush`,\n { ifAvailable: true },\n async (lock) => (lock ? runOnce(trigger) : null),\n );\n return (\n result ?? {\n trigger,\n succeeded: 0,\n failed: 0,\n deferred: 0,\n durationMs: 0,\n skipped: false,\n lastError: null,\n }\n );\n }\n\n void refreshPending();\n\n let lastEnqueuedAt = 0;\n\n /**\n * Timestamp for a new outbox entry, guaranteed to be strictly greater than\n * the previous one.\n *\n * `enqueuedAt` is the sort key for both `listPending()` and the delivery loop\n * in `flush()`. A plain `Date.now()` ties whenever two mutations are queued\n * within the same millisecond — which is normal in a burst — and a tie leaves\n * the order to the index, so a `create` could be delivered *after* the\n * `update` that depends on it. Advancing by 1ms on a tie keeps the queue FIFO\n * at the cost of a timestamp that can run a few milliseconds ahead of the wall\n * clock during a burst.\n *\n * Scope: this is per engine instance. Two tabs enqueueing in the same\n * millisecond can still tie; cross-tab FIFO would need a persisted sequence,\n * and the delivery loop is already single-flight across tabs via Web Locks.\n */\n function nextEnqueuedAt(): number {\n const now = Date.now();\n lastEnqueuedAt = now > lastEnqueuedAt ? now : lastEnqueuedAt + 1;\n return lastEnqueuedAt;\n }\n\n return {\n async enqueue(op, recordId, payload) {\n const entry: OutboxEntry<TPayload> = {\n id: randomId(idPrefix),\n op,\n recordId,\n enqueuedAt: nextEnqueuedAt(),\n attempts: 0,\n payload,\n };\n await outbox.put(entry);\n await refreshPending();\n return entry.id;\n },\n flush(trigger: SyncTrigger = \"manual\") {\n if (!inflight) {\n setState({ phase: \"syncing\" });\n inflight = runGuarded(trigger)\n .then(async (summary) => {\n await refreshPending();\n setState({\n phase: summary.skipped\n ? \"offline\"\n : summary.failed > 0\n ? \"error\"\n : \"idle\",\n lastSummary: summary,\n lastError: summary.lastError,\n lastSyncedAt: summary.skipped ? state.lastSyncedAt : Date.now(),\n });\n return summary;\n })\n .finally(() => {\n inflight = null;\n });\n }\n return inflight;\n },\n pendingCount() {\n return outbox.count();\n },\n listPending() {\n return outbox.list(undefined, { orderBy: \"enqueuedAt\" });\n },\n async clearOutbox() {\n await outbox.clear();\n await refreshPending();\n },\n resetWatermark() {\n watermark.clear();\n },\n getState() {\n return state;\n },\n subscribe(listener) {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n dispose() {\n closed = true;\n channel?.close();\n },\n };\n}\n"],"mappings":";;;AAiQA,SAASA,EAAsB,GAA6B;CACxD,IAAMC,IAAY,OAAO,eAAiB;CAC1C,OAAO;EACH,WAAYA,IAAY,aAAa,QAAQC,CAAG,IAAI;EACpD,MAAM,MAAkB;GACpB,AAAID,KAAW,aAAa,QAAQC,GAAKC,CAAK;EAClD;EACA,aAAa;GACT,AAAIF,KAAW,aAAa,WAAWC,CAAG;EAC9C;CACJ;AACJ;AAsCA,SAAgBE,EACZ,GACqB;CACrB,IAAMC,IAASC,EAAkD;EAC7D,cAAcC,EAAO;EACrB,SAASA,EAAO,WAAW;EAC3B,WAAWA,EAAO,aAAa;EAC/B,SAAS;EACT,SAAS;CACb,CAAC,GAEKC,IACF,SAASD,EAAO,YACVA,EAAO,YACPP,EAAsBO,EAAO,UAAU,UAAU,GAErDE,IAAWF,EAAO,YAAY,UAC9BG,IACFH,EAAO,mBAAoB,OAAO,YAAc,MAAc,UAAU,SAAS,KAEjFI,IAA2C,MAEzCC,oBAAY,IAAI,IAAgC,GAClDC,IAAmB;EACnB,OAAO;EACP,SAAS;EACT,aAAa;EACb,WAAW;EACX,cAAc;CAClB,GAEMC,IAAWP,EAAO,aAAa,IAC/BQ,IAAcR,EAAO,wBAAwB,gBAAgBA,EAAO,gBACpES,IACFF,KAAY,OAAO,mBAAqB,MAClC,IAAI,iBAAiBC,CAAW,IAChC,MACNE,IAAS;CAEb,SAASC,IAAe;EACpB,KAAK,IAAMC,KAAYP,GAAW,EAASC,CAAK;CACpD;CAEA,SAASO,EAAS,GAAiC;EAG/C,AAFA,IAAQ;GAAE,GAAGP;GAAO,GAAGQ;EAAM,GAC7BH,EAAO,GACHF,KAAW,CAACC,KAAQD,EAAQ,YAAYH,CAAK;CACrD;CAEA,AAAIG,MACA,EAAQ,aAAa,MAAmC;EACpD,AAAIC,MACJ,IAAQK,EAAM,MACdJ,EAAO;CACX;CAGJ,eAAeK,IAAgC;EAC3C,EAAS,EAAE,SAAS,MAAMlB,EAAO,MAAM,EAAE,CAAC;CAC9C;CAmBA,eAAemB,EAAK,GAAwC;EACxD,IAAMC,IAAU,MAAMpB,EAAO,KAAK,KAAA,GAAW,EAAE,SAAS,aAAa,CAAC,GAChEqB,oBAAU,IAAI,IAAY;EAChC,KAAK,IAAMC,KAASF,GAChB,IAAIC,EAAQ,IAAIC,EAAM,QAAQ,GAC1B,EAAQ,YAAY;OAGxB,IAAI;GAIA,AAHA,MAAMpB,EAAO,QAAQoB,CAAK,GAC1B,MAAMtB,EAAO,OAAOsB,EAAM,EAAE,GAC5B,MAAMpB,EAAO,mBAAmBoB,CAAK,GACrC,EAAQ,aAAa;EACzB,SAASC,GAAO;GACZ,IAAMC,IAAUD,aAAiB,QAAQA,EAAM,UAAU;GAQzD,AAPA,MAAMvB,EAAO,OAAOsB,EAAM,IAAI;IAC1B,UAAUA,EAAM,WAAW;IAC3B,WAAWE;GACf,CAAmC,GACnC,MAAMtB,EAAO,gBAAgBoB,GAAOC,CAAK,GACzCF,EAAQ,IAAIC,EAAM,QAAQ,GAC1B,EAAQ,UAAU,GAClB,EAAQ,YAAYE;EACxB;CAER;CAEA,eAAeC,IAAsB;EACjC,IAAMC,IAAQvB,EAAU,IAAI,GACxBwB,IAAwB,MACxBC;EACJ,GAAG;GACC,IAAMC,IAAO,MAAM3B,EAAO,SAASwB,GAAOC,CAAM;GAChD,KAAK,IAAMG,KAAQD,EAAK,OACpB,MAAM3B,EAAO,YAAY4B,CAAI;GAGjC,AADA,IAASD,EAAK,YACd,IAAaA,EAAK;EACtB,SAASF;EACT,AAAIC,KAAYzB,EAAU,IAAIyB,CAAU;CAC5C;CAEA,eAAeG,EAAQ,GAA+C;EAClE,IAAMC,IAAY,KAAK,IAAI,GACrBC,IAA0B;GAC5B;GACA,WAAW;GACX,QAAQ;GACR,UAAU;GACV,YAAY;GACZ,SAAS;GACT,WAAW;EACf;EASA,OARK5B,EAAS,KAKd,MAAMc,EAAKc,CAAO,GAClB,MAAMR,EAAK,GACX,EAAQ,aAAa,KAAK,IAAI,IAAIO,GAC3BC,MAPH,EAAQ,UAAU,IAClB,EAAQ,aAAa,KAAK,IAAI,IAAID,GAC3BC;CAMf;CAgBA,eAAeC,EAAW,GAA+C;EACrE,IAAMC,IAAQ,OAAO,YAAc,MAAc,UAAU,QAAQ,KAAA;EAOnE,OANI,CAAC1B,KAAY,CAAC0B,IAAcJ,EAAQK,CAAO,IAO3C,MANiBD,EAAM,QACvB,GAAGzB,EAAY,SACf,EAAE,aAAa,GAAK,GACpB,OAAO,MAAU2B,IAAON,EAAQK,CAAO,IAAI,IAC/C,KAEc;GACN;GACA,WAAW;GACX,QAAQ;GACR,UAAU;GACV,YAAY;GACZ,SAAS;GACT,WAAW;EACf;CAER;CAEA,EAAoB;CAEpB,IAAIE,IAAiB;CAkBrB,SAASC,IAAyB;EAC9B,IAAMC,IAAM,KAAK,IAAI;EAErB,OADA,IAAiBA,IAAMF,IAAiBE,IAAMF,IAAiB,GACxDA;CACX;CAEA,OAAO;EACH,MAAM,QAAQ,GAAI,GAAU,GAAS;GACjC,IAAMhB,IAA+B;IACjC,IAAImB,EAASrC,CAAQ;IACrB;IACA;IACA,YAAYmC,EAAe;IAC3B,UAAU;IACV;GACJ;GAGA,OAFA,MAAMvC,EAAO,IAAIsB,CAAK,GACtB,MAAMJ,EAAe,GACdI,EAAM;EACjB;EACA,MAAM,IAAuB,UAAU;GAsBnC,OArBA,AAEIhB,OADAS,EAAS,EAAE,OAAO,UAAU,CAAC,GAClBmB,EAAWE,CAAO,CAAC,CACzB,KAAK,OAAO,OACT,MAAMlB,EAAe,GACrBH,EAAS;IACL,OAAOkB,EAAQ,UACT,YACAA,EAAQ,SAAS,IACf,UACA;IACR,aAAaA;IACb,WAAWA,EAAQ;IACnB,cAAcA,EAAQ,UAAUzB,EAAM,eAAe,KAAK,IAAI;GAClE,CAAC,GACMyB,EACV,CAAC,CACD,cAAc;IACX,IAAW;GACf,CAAC,IAEF3B;EACX;EACA,eAAe;GACX,OAAON,EAAO,MAAM;EACxB;EACA,cAAc;GACV,OAAOA,EAAO,KAAK,KAAA,GAAW,EAAE,SAAS,aAAa,CAAC;EAC3D;EACA,MAAM,cAAc;GAEhB,AADA,MAAMA,EAAO,MAAM,GACnB,MAAMkB,EAAe;EACzB;EACA,iBAAiB;GACb,EAAU,MAAM;EACpB;EACA,WAAW;GACP,OAAOV;EACX;EACA,UAAU,GAAU;GAEhB,OADAD,EAAU,IAAIO,CAAQ,SACT;IACT,EAAU,OAAOA,CAAQ;GAC7B;EACJ;EACA,UAAU;GAEN,AADA,IAAS,IACTH,GAAS,MAAM;EACnB;CACJ;AACJ"}
|
|
1
|
+
{"version":3,"file":"create-offline-sync.js","names":["localStorageWatermark","available","key","value","createOfflineSync","outbox","createOfflineStore","config","watermark","idPrefix","isOnline","inflight","generation","listeners","state","crossTab","channelName","channel","closed","notify","listener","setState","patch","event","refreshPending","endSession","push","entries","blocked","entry","stillQueued","runGeneration","cause","message","pull","since","cursor","serverTime","page","item","runOnce","startedAt","summary","runGuarded","locks","trigger","lock","lastEnqueuedAt","nextEnqueuedAt","now","randomId","run"],"sources":["../../src/offline/create-offline-sync.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines, function-lines — outbox drain, pull-with-watermark,\n * conflict resolution and the single-flight guard are one loop by necessity: a pull\n * that lands mid-drain must not advance the watermark past records the outbox has\n * not pushed yet. Splitting the phases hands that ordering to whoever calls them in\n * the right sequence.\n */\nimport { randomId } from \"../utils/ids\";\nimport { createOfflineStore } from \"./create-offline-store\";\n\n/**\n * Mutation kinds queued in the outbox.\n *\n * `create`/`update` carry a snapshot of the record; `delete` needs only the\n * record id. The distinction between `create` and `update` is advisory — the\n * engine treats both as \"deliver this record\" and leaves the create-vs-update\n * decision to the app's `deliver` callback (a `PUT` upsert usually ignores it).\n */\nexport type OutboxOp = \"create\" | \"update\" | \"delete\";\n\n/**\n * A single queued mutation.\n *\n * @typeParam TPayload - The record snapshot shape carried by\n * `create`/`update` entries (omitted for `delete`).\n */\nexport interface OutboxEntry<TPayload = unknown> {\n /** Stable per-entry id (generated with {@link randomId}). */\n id: string;\n /** The mutation kind. */\n op: OutboxOp;\n /** Primary key of the record the mutation targets. */\n recordId: string;\n /**\n * Epoch milliseconds when the mutation was queued, and the FIFO sort key for\n * `listPending()` and the delivery loop. Strictly increasing per engine\n * instance: entries queued inside the same millisecond are spaced by 1ms so\n * the order never depends on how the index breaks a tie.\n */\n enqueuedAt: number;\n /** How many delivery attempts have been made so far. */\n attempts: number;\n /** Last delivery error message, kept for UI/debug. */\n lastError?: string;\n /** Record snapshot for `create`/`update`. Omitted for `delete`. */\n payload?: TPayload;\n}\n\n/**\n * Why a sync run was triggered. The listed values are the common ones; any\n * string is accepted so apps can add their own telemetry labels.\n */\nexport type SyncTrigger =\n \"boot\" | \"online-event\" | \"after-mutation\" | \"manual\" | \"interval\" | (string & {});\n\n/**\n * One page of the server delta pull.\n *\n * @typeParam TRemote - The server-side item shape.\n */\nexport interface PullPage<TRemote> {\n /** Items changed since the watermark, in this page. */\n items: TRemote[];\n /** Cursor for the next page, or `null` when this is the last page. */\n nextCursor: string | null;\n /**\n * Server clock to persist as the next watermark once the whole delta is\n * applied. `null` leaves the watermark unchanged.\n */\n serverTime: string | null;\n}\n\n/** Outcome of a single {@link OfflineSync.flush} run. */\nexport interface SyncRunSummary {\n /** The trigger passed to `flush`. */\n trigger: SyncTrigger;\n /** Entries delivered to the server this run. */\n succeeded: number;\n /** Entries that failed and stay queued for the next run. */\n failed: number;\n /**\n * Entries left untried because an earlier entry for the **same record**\n * failed this run.\n *\n * They stay queued and are attempted next run, in order. Counted apart from\n * `failed` because they were never sent: reporting them as failures would\n * blame the server for a decision this engine made, and folding them in would\n * make `succeeded + failed` stop accounting for the queue.\n */\n deferred: number;\n /** Total wall-clock milliseconds the run took. */\n durationMs: number;\n /** `true` when the run was skipped because the device was offline. */\n skipped: boolean;\n /** Last delivery error message seen this run, or `null` when none failed. */\n lastError: string | null;\n /**\n * `true` when {@link OfflineSync.clearOutbox} or {@link OfflineSync.resetWatermark}\n * ended the session while this run was in flight, so it stopped early.\n *\n * The counts cover only what happened before the stop: a delivery already in\n * progress at logout is still counted, but no later entry was sent, no further\n * page was pulled or applied, and the watermark was not written. Absent on a\n * run that went to completion.\n */\n interrupted?: true;\n}\n\n/**\n * Coarse lifecycle phase of the sync engine, surfaced through\n * {@link OfflineSync.getState} / {@link OfflineSync.subscribe} for reactive UI.\n *\n * - `\"idle\"` — nothing in flight and the last run (if any) fully succeeded.\n * - `\"syncing\"` — a `flush` run is currently in progress.\n * - `\"offline\"` — the last run was skipped because the device was offline.\n * - `\"error\"` — the last run left at least one entry queued after a failure.\n */\nexport type SyncPhase = \"idle\" | \"syncing\" | \"offline\" | \"error\";\n\n/**\n * Immutable snapshot of the engine's reactive state. A fresh object is emitted\n * to subscribers on every change, so it is safe to use as a\n * `useSyncExternalStore` snapshot (reference equality signals \"no change\").\n */\nexport interface SyncState {\n /** Current lifecycle phase. */\n phase: SyncPhase;\n /** Number of mutations still queued in the outbox. */\n pending: number;\n /** Summary of the most recent `flush` run, or `null` before the first. */\n lastSummary: SyncRunSummary | null;\n /** Message of the most recent delivery failure, or `null`. */\n lastError: string | null;\n /** Epoch ms of the last non-skipped run, or `null` before the first. */\n lastSyncedAt: number | null;\n}\n\n/**\n * Pluggable persistence for the pull watermark (the \"changed since\" cursor).\n * Pass an object with a `storageKey` to use a `localStorage`-backed default.\n */\nexport interface WatermarkStore {\n /** Read the current watermark, or `null` when none is stored. */\n get: () => string | null;\n /** Persist a new watermark. */\n set: (value: string) => void;\n /** Drop the watermark (e.g. on logout / account switch). */\n clear: () => void;\n}\n\n/**\n * Configuration for {@link createOfflineSync}.\n *\n * The engine owns the outbox, the single-flight flush, the offline guard, the\n * paginated pull loop and the watermark; the three transport callbacks\n * (`deliver`, `pullPage`, `applyRemote`) are where the app plugs in its own\n * endpoints, record shape and conflict resolution.\n *\n * @typeParam TPayload - Record snapshot carried by outbox entries.\n * @typeParam TRemote - Server item shape returned by `pullPage`.\n */\nexport interface OfflineSyncConfig<TPayload, TRemote> {\n /** IndexedDB database name for the outbox (kept separate per queue). */\n databaseName: string;\n /** Outbox object-store name. Default `\"outbox\"`. */\n tableName?: string;\n /** Outbox schema version. Default `1`. */\n version?: number;\n /** Prefix for generated entry ids. Default `\"outbox\"`. */\n idPrefix?: string;\n /**\n * Deliver one queued mutation to the server. Throwing keeps the entry\n * queued (its `attempts`/`lastError` are bumped) for the next run.\n */\n deliver: (entry: OutboxEntry<TPayload>) => Promise<void>;\n /** Fetch one page of the server delta since `since`, from `cursor`. */\n pullPage: (since: string | null, cursor: string | null) => Promise<PullPage<TRemote>>;\n /**\n * Merge one pulled item into the local store. The app owns conflict\n * resolution here (e.g. last-write-wins, keeping newer local pending\n * edits, resolving tombstones and downloading blobs).\n */\n applyRemote: (item: TRemote) => Promise<void>;\n /** Watermark persistence, or `{ storageKey }` for a `localStorage` default. */\n watermark: WatermarkStore | { storageKey: string };\n /** Called after an entry is delivered and acked. */\n onEntryDelivered?: (entry: OutboxEntry<TPayload>) => void | Promise<void>;\n /** Called after an entry fails delivery (it stays queued). */\n onEntryFailed?: (entry: OutboxEntry<TPayload>, error: unknown) => void | Promise<void>;\n /**\n * Connectivity check. Default reads `navigator.onLine` (always online in\n * non-browser environments). When it returns `false`, `flush` is skipped.\n */\n isOnline?: () => boolean;\n /**\n * Broadcast {@link SyncState} across browser tabs of the same origin via a\n * `BroadcastChannel`, so every tab shows a coherent pending count / phase\n * (e.g. one tab flushing drops the badge to zero everywhere). The outbox is\n * already a shared IndexedDB; this only shares the in-memory state. Silently\n * ignored where `BroadcastChannel` is unavailable. Default `false`.\n *\n * When the Web Locks API is available, `flush` is additionally coordinated\n * *across* tabs: only one tab runs at a time and others skip while the lock\n * is held (they pick up the result via the broadcast). Delivery should still\n * be idempotent (upsert by client id). Without Web Locks, `flush` falls back\n * to per-tab single-flight.\n */\n crossTab?: boolean;\n /** Channel name when `crossTab` is on. Default `tempest-sync:${databaseName}`. */\n broadcastChannelName?: string;\n}\n\n/**\n * Offline-first sync engine: a durable outbox plus a paginated delta pull.\n *\n * @typeParam TPayload - Record snapshot carried by outbox entries.\n */\nexport interface OfflineSync<TPayload> {\n /**\n * Queue a mutation. Returns the generated entry id.\n *\n * @param op - The mutation kind.\n * @param recordId - Primary key of the affected record.\n * @param payload - Record snapshot (for `create`/`update`).\n */\n enqueue: (op: OutboxOp, recordId: string, payload?: TPayload) => Promise<string>;\n /**\n * Run a full sync (drain the outbox, then pull the delta). Concurrent\n * calls share one in-flight promise, so triggers never overlap.\n *\n * @param trigger - Label for why the run happened. Default `\"manual\"`.\n */\n flush: (trigger?: SyncTrigger) => Promise<SyncRunSummary>;\n /** Number of mutations still queued. */\n pendingCount: () => Promise<number>;\n /** The queued mutations in FIFO order. */\n listPending: () => Promise<OutboxEntry<TPayload>[]>;\n /**\n * Drop every queued mutation (e.g. on logout).\n *\n * Also ends the current session for any `flush` already in flight: that run\n * delivers no further entry, pulls and applies nothing more and writes no\n * watermark, and resolves with `interrupted: true`. A `flush` called after this\n * starts a fresh run instead of joining the abandoned one.\n */\n clearOutbox: () => Promise<void>;\n /**\n * Reset the pull watermark (e.g. on logout / account switch).\n *\n * Ends the current session for an in-flight `flush` exactly like\n * {@link OfflineSync.clearOutbox}, so the run cannot write the ended\n * session's watermark back after the reset.\n */\n resetWatermark: () => void;\n /** Read the current reactive {@link SyncState} snapshot synchronously. */\n getState: () => SyncState;\n /**\n * Subscribe to {@link SyncState} changes. The listener fires on every\n * enqueue, flush transition and outbox clear.\n *\n * @param listener - Called with the new snapshot on each change.\n * @returns An unsubscribe function.\n */\n subscribe: (listener: (state: SyncState) => void) => () => void;\n /**\n * Release resources held by the engine — currently the cross-tab\n * `BroadcastChannel` when `crossTab` is enabled. Safe to call when none was\n * opened. Optional in long-lived apps (the channel is reclaimed on tab\n * close); call it in tests and on teardown.\n */\n dispose: () => void;\n}\n\n/**\n * Build a `localStorage`-backed {@link WatermarkStore}. Reads/writes are no-ops\n * when `localStorage` is unavailable (SSR / non-browser).\n *\n * @param key - The `localStorage` key.\n * @returns A watermark store persisting to `localStorage`.\n */\nfunction localStorageWatermark(key: string): WatermarkStore {\n const available = typeof localStorage !== \"undefined\";\n return {\n get: () => (available ? localStorage.getItem(key) : null),\n set: (value: string) => {\n if (available) localStorage.setItem(key, value);\n },\n clear: () => {\n if (available) localStorage.removeItem(key);\n },\n };\n}\n\n/**\n * Create an offline-first sync engine over an IndexedDB outbox.\n *\n * The engine drains queued mutations to the server (`deliver`) and pulls the\n * server delta back (`pullPage` + `applyRemote`), advancing a watermark so each\n * run only fetches what changed. `flush` is single-flight and skips cleanly\n * while offline; failed deliveries stay queued with their attempt count bumped.\n *\n * Dexie is an **optional peer dependency** (via {@link createOfflineStore}) —\n * install it (`npm i dexie`) when you use this engine.\n *\n * @typeParam TPayload - Record snapshot carried by outbox entries.\n * @typeParam TRemote - Server item shape returned by `pullPage`.\n * @param config - Transport callbacks + outbox/watermark configuration.\n * @returns The sync handle (`enqueue`, `flush`, `pendingCount`, …).\n *\n * @example\n * const sync = createOfflineSync<Note, NoteDto>({\n * databaseName: \"NotesOutbox\",\n * watermark: { storageKey: \"notes.watermark\" },\n * deliver: async (entry) => {\n * if (entry.op === \"delete\") return api.remove(entry.recordId);\n * await api.upsert(entry.recordId, entry.payload!);\n * },\n * pullPage: async (since, cursor) => {\n * const page = await api.changes(since, cursor);\n * return { items: page.items, nextCursor: page.next, serverTime: page.now };\n * },\n * applyRemote: async (dto) => {\n * if (dto.deleted) return localStore.remove(dto.id);\n * await localStore.save(fromDto(dto));\n * },\n * });\n * await sync.enqueue(\"create\", note.id, note);\n * await sync.flush(\"after-mutation\");\n */\nexport function createOfflineSync<TPayload = unknown, TRemote = unknown>(\n config: OfflineSyncConfig<TPayload, TRemote>,\n): OfflineSync<TPayload> {\n const outbox = createOfflineStore<OutboxEntry<TPayload>, string>({\n databaseName: config.databaseName,\n version: config.version ?? 1,\n tableName: config.tableName ?? \"outbox\",\n indexes: \"&id, recordId, enqueuedAt\",\n keyPath: \"id\",\n });\n\n const watermark: WatermarkStore =\n \"get\" in config.watermark\n ? config.watermark\n : localStorageWatermark(config.watermark.storageKey);\n\n const idPrefix = config.idPrefix ?? \"outbox\";\n const isOnline =\n config.isOnline ?? (() => (typeof navigator !== \"undefined\" ? navigator.onLine : true));\n\n let inflight: Promise<SyncRunSummary> | null = null;\n let generation = 0;\n\n const listeners = new Set<(state: SyncState) => void>();\n let state: SyncState = {\n phase: \"idle\",\n pending: 0,\n lastSummary: null,\n lastError: null,\n lastSyncedAt: null,\n };\n\n const crossTab = config.crossTab === true;\n const channelName = config.broadcastChannelName ?? `tempest-sync:${config.databaseName}`;\n const channel: BroadcastChannel | null =\n crossTab && typeof BroadcastChannel !== \"undefined\"\n ? new BroadcastChannel(channelName)\n : null;\n let closed = false;\n\n function notify(): void {\n for (const listener of listeners) listener(state);\n }\n\n function setState(patch: Partial<SyncState>): void {\n state = { ...state, ...patch };\n notify();\n if (channel && !closed) channel.postMessage(state);\n }\n\n if (channel) {\n channel.onmessage = (event: MessageEvent<SyncState>) => {\n if (closed) return;\n state = event.data;\n notify();\n };\n }\n\n async function refreshPending(): Promise<void> {\n setState({ pending: await outbox.count() });\n }\n\n /**\n * End the current session: every run started before this call is fenced off.\n *\n * Runs capture `generation` when they start and re-check it after every\n * `await`, so the next step of a run started under the previous user — the\n * next `deliver`, the next `pullPage`, an `applyRemote`, the watermark write —\n * never executes under the next user's session. The await already pending (a\n * `deliver` in flight) cannot be recalled; it was issued under the old session\n * and is only bookkept.\n *\n * Detaching `inflight` is what lets the next session's `flush` start its own\n * run instead of joining the abandoned one, which would hand the new user a\n * summary of the old user's queue and leave their own entries waiting. The\n * phase drops back to `\"idle\"` because nothing of the current session is\n * syncing any more.\n */\n function endSession(): void {\n generation += 1;\n if (inflight) {\n inflight = null;\n setState({ phase: \"idle\" });\n }\n }\n\n /**\n * Drain the outbox in `enqueuedAt` order, one record at a time.\n *\n * Once an entry for a record fails, the remaining entries **for that record**\n * are left untried this run. `enqueuedAt` is a FIFO guarantee the engine goes\n * out of its way to keep (see `nextEnqueuedAt`), precisely because delivering\n * an `update` before the `create` it depends on is wrong — and continuing past\n * a failure broke exactly that guarantee. With a `PUT` upsert `deliver`, which\n * is the usual shape, the server would create the record from the *update*\n * payload and the retried `create` would then overwrite it with the older\n * snapshot: the user's edit disappears with nothing reported.\n *\n * Skipping is per record, not per run. Records are independent, so a record\n * the server is rejecting must not hold up every other pending change.\n *\n * The entry list is a snapshot, so before each `deliver` the run checks that\n * its session is still current (see `endSession`) and that the entry is still\n * in the outbox — another tab sharing the IndexedDB may have cleared it on\n * logout. A delivery already in progress when the session ended still has its\n * row deleted or its attempt bumped, because that outcome is true for the\n * server; its `onEntryDelivered`/`onEntryFailed` callback is not fired, since\n * it would act on the next user's local state.\n *\n * @param summary - Mutated with the per-entry outcome counts.\n * @param runGeneration - The session the run started under.\n * @returns `false` when the session ended mid-drain and the run must stop.\n */\n async function push(summary: SyncRunSummary, runGeneration: number): Promise<boolean> {\n const entries = await outbox.list(undefined, { orderBy: \"enqueuedAt\" });\n const blocked = new Set<string>();\n for (const entry of entries) {\n if (blocked.has(entry.recordId)) {\n summary.deferred += 1;\n continue;\n }\n const stillQueued = await outbox.get(entry.id);\n if (generation !== runGeneration) return false;\n if (!stillQueued) continue;\n try {\n await config.deliver(entry);\n await outbox.delete(entry.id);\n summary.succeeded += 1;\n if (generation !== runGeneration) return false;\n await config.onEntryDelivered?.(entry);\n } catch (cause) {\n const message = cause instanceof Error ? cause.message : \"delivery failed\";\n await outbox.update(entry.id, {\n attempts: entry.attempts + 1,\n lastError: message,\n } as Partial<OutboxEntry<TPayload>>);\n blocked.add(entry.recordId);\n summary.failed += 1;\n summary.lastError = message;\n if (generation !== runGeneration) return false;\n await config.onEntryFailed?.(entry, cause);\n }\n }\n return generation === runGeneration;\n }\n\n /**\n * Pull the server delta page by page, then advance the watermark.\n *\n * Checks the session (see `endSession`) before every `applyRemote`, before the\n * next `pullPage` and before the watermark write: a page fetched under the\n * previous user is never merged into the local store after logout, the next\n * page is never requested with the previous user's cursor, and\n * `resetWatermark()` is never undone by the run it was meant to end.\n *\n * @param runGeneration - The session the run started under.\n * @returns `false` when the session ended mid-pull and the run stopped.\n */\n async function pull(runGeneration: number): Promise<boolean> {\n const since = watermark.get();\n let cursor: string | null = null;\n let serverTime: string | null;\n do {\n const page = await config.pullPage(since, cursor);\n for (const item of page.items) {\n if (generation !== runGeneration) return false;\n await config.applyRemote(item);\n }\n if (generation !== runGeneration) return false;\n cursor = page.nextCursor;\n serverTime = page.serverTime;\n } while (cursor);\n if (serverTime) watermark.set(serverTime);\n return true;\n }\n\n /**\n * One push-then-pull pass, stopped early when the session it belongs to ends.\n *\n * @param trigger - What asked for this run, carried into the summary.\n * @param runGeneration - The session captured when `flush` was called, not\n * when the pass starts: with Web Locks the pass can start after a logout that\n * happened while the lock was being granted, and must not adopt the new\n * session.\n * @returns The run summary, with `interrupted: true` when the session ended.\n */\n async function runOnce(trigger: SyncTrigger, runGeneration: number): Promise<SyncRunSummary> {\n const startedAt = Date.now();\n const summary: SyncRunSummary = {\n trigger,\n succeeded: 0,\n failed: 0,\n deferred: 0,\n durationMs: 0,\n skipped: false,\n lastError: null,\n };\n if (!isOnline()) {\n summary.skipped = true;\n summary.durationMs = Date.now() - startedAt;\n return summary;\n }\n const completed = (await push(summary, runGeneration)) && (await pull(runGeneration));\n if (!completed) summary.interrupted = true;\n summary.durationMs = Date.now() - startedAt;\n return summary;\n }\n\n /**\n * Run one sync pass, serialized across tabs when possible.\n *\n * With `crossTab` on and the Web Locks API available, the pass is wrapped in\n * an `ifAvailable` lock request: whichever tab holds the lock runs, and the\n * others get `null` back instead of duplicating the work — their `SyncState`\n * catches up through the `BroadcastChannel`. The no-op summary keeps\n * `skipped: false` on purpose: `skipped` means \"device offline\" (it maps to\n * `phase: \"offline\"`), which is not what happened here. Falls back to a plain\n * per-tab run when `crossTab` is off or Web Locks is missing.\n *\n * @param trigger - What asked for this run, carried into the summary.\n * @param runGeneration - The session the run belongs to (see `runOnce`).\n * @returns The run summary, or an empty summary when another tab held the lock.\n */\n async function runGuarded(\n trigger: SyncTrigger,\n runGeneration: number,\n ): Promise<SyncRunSummary> {\n const locks = typeof navigator !== \"undefined\" ? navigator.locks : undefined;\n if (!crossTab || !locks) return runOnce(trigger, runGeneration);\n const result = await locks.request(\n `${channelName}:flush`,\n { ifAvailable: true },\n async (lock) => (lock ? runOnce(trigger, runGeneration) : null),\n );\n return (\n result ?? {\n trigger,\n succeeded: 0,\n failed: 0,\n deferred: 0,\n durationMs: 0,\n skipped: false,\n lastError: null,\n }\n );\n }\n\n void refreshPending();\n\n let lastEnqueuedAt = 0;\n\n /**\n * Timestamp for a new outbox entry, guaranteed to be strictly greater than\n * the previous one.\n *\n * `enqueuedAt` is the sort key for both `listPending()` and the delivery loop\n * in `flush()`. A plain `Date.now()` ties whenever two mutations are queued\n * within the same millisecond — which is normal in a burst — and a tie leaves\n * the order to the index, so a `create` could be delivered *after* the\n * `update` that depends on it. Advancing by 1ms on a tie keeps the queue FIFO\n * at the cost of a timestamp that can run a few milliseconds ahead of the wall\n * clock during a burst.\n *\n * Scope: this is per engine instance. Two tabs enqueueing in the same\n * millisecond can still tie; cross-tab FIFO would need a persisted sequence,\n * and the delivery loop is already single-flight across tabs via Web Locks.\n */\n function nextEnqueuedAt(): number {\n const now = Date.now();\n lastEnqueuedAt = now > lastEnqueuedAt ? now : lastEnqueuedAt + 1;\n return lastEnqueuedAt;\n }\n\n return {\n async enqueue(op, recordId, payload) {\n const entry: OutboxEntry<TPayload> = {\n id: randomId(idPrefix),\n op,\n recordId,\n enqueuedAt: nextEnqueuedAt(),\n attempts: 0,\n payload,\n };\n await outbox.put(entry);\n await refreshPending();\n return entry.id;\n },\n flush(trigger: SyncTrigger = \"manual\") {\n if (!inflight) {\n setState({ phase: \"syncing\" });\n const runGeneration = generation;\n const run: Promise<SyncRunSummary> = runGuarded(trigger, runGeneration)\n .then(async (summary) => {\n await refreshPending();\n if (generation !== runGeneration) return summary;\n setState({\n phase: summary.skipped\n ? \"offline\"\n : summary.failed > 0\n ? \"error\"\n : \"idle\",\n lastSummary: summary,\n lastError: summary.lastError,\n lastSyncedAt: summary.skipped ? state.lastSyncedAt : Date.now(),\n });\n return summary;\n })\n .finally(() => {\n if (inflight === run) inflight = null;\n });\n inflight = run;\n }\n return inflight;\n },\n pendingCount() {\n return outbox.count();\n },\n listPending() {\n return outbox.list(undefined, { orderBy: \"enqueuedAt\" });\n },\n async clearOutbox() {\n endSession();\n await outbox.clear();\n await refreshPending();\n },\n resetWatermark() {\n endSession();\n watermark.clear();\n },\n getState() {\n return state;\n },\n subscribe(listener) {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n dispose() {\n closed = true;\n channel?.close();\n },\n };\n}\n"],"mappings":"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"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const e=require("../utils/json-frame.cjs");function t(t,n={}){let{withCredentials:r=!1,namedEvents:i=[],heartbeatEvents:a=[`ping`],maxRetries:o=10,initialBackoff:s=1e3,maxBackoff:c=3e4,parser:l,onOpen:u,onMessage:d,onError:f,onParseError:p,schema:m,onValidationError:h,onStatusChange:
|
|
1
|
+
const e=require("../utils/json-frame.cjs");function t(t,n={}){let{withCredentials:r=!1,namedEvents:i=[],heartbeatEvents:a=[`ping`],maxRetries:o=10,initialBackoff:s=1e3,maxBackoff:c=3e4,parser:l,onOpen:u,onMessage:d,onError:f,onParseError:p,schema:m,onValidationError:h,onUrlError:g,shouldRetryUrl:_=()=>!0,onStatusChange:v}=n,y=null,b=null,x=0,S=`idle`,C=!1,w=0;function T(e){S!==e&&(S=e,v?.(e))}function E(t,n){if(a.includes(t))return;let r=e.decodeFrame(typeof n.data==`string`?n.data:``,`createEventStream`,{parser:l,onParseError:p,schema:m,onValidationError:h});r.delivered&&d?.({event:t,data:r.data,id:n.lastEventId||void 0,raw:n})}function D(){if(C)return;if(x>=o){T(`error`);return}let e=Math.min(s*2**x,c);x+=1,b=setTimeout(k,e)}function O(e){g?.(e),_(e)?(T(`closed`),D()):T(`error`)}function k(){if(C)return;if(y&&=(y.close(),null),T(`connecting`),w+=1,typeof t==`string`){A(t);return}let e=w;Promise.resolve().then(t).then(t=>{!C&&e===w&&A(t)},t=>{!C&&e===w&&O(t)})}function A(e){let t=new EventSource(e,{withCredentials:r});y=t,t.onopen=()=>{x=0,T(`open`),u?.()},t.onmessage=e=>E(`message`,e);for(let e of i)t.addEventListener(e,t=>E(e,t));for(let e of a)t.addEventListener(e,()=>{});t.onerror=e=>{f?.(e),t.close(),y=null,T(`closed`),D()}}function j(){C=!0,b&&=(clearTimeout(b),null),x=0,y&&=(y.close(),null),T(`closed`)}function M(){b&&=(clearTimeout(b),null),x=0,C=!1,k()}return k(),{close:j,reconnect:M,get status(){return S}}}exports.createEventStream=t;
|
|
2
2
|
//# sourceMappingURL=create-event-stream.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"create-event-stream.cjs","names":["decodeFrame","require_json_frame","createEventStream","options","source","retryTimer","retries","status","closed","setStatus","next","onStatusChange","emit","heartbeatEvents","eventName","decoded","event","onMessage","scheduleReconnect","maxRetries","delay","initialBackoff","maxBackoff","connect","es","url","onOpen","name","namedEvents","onError","close","reconnect"],"sources":["../../src/sse/create-event-stream.ts"],"sourcesContent":["/**\n * @tempest-limits function-lines — the body is the SSE line protocol — accumulate\n * `data:` lines until a blank line, track `id:` for Last-Event-ID, honour `retry:` —\n * plus the reconnect that uses the id it just tracked.\n */\nimport { decodeFrame } from \"../utils/json-frame\";\nimport type { SchemaIssue, SchemaLike } from \"../utils/schema-like\";\nexport type EventStreamStatus = \"idle\" | \"connecting\" | \"open\" | \"closed\" | \"error\";\n\nexport interface EventStreamMessage<T> {\n /** Server-named event (default `\"message\"`). */\n event: string;\n /** Parsed payload — validated when `schema` is set, JSON-decoded when possible, raw string otherwise. */\n data: T;\n /** Server-supplied id, if any. */\n id?: string;\n /** Raw `MessageEvent` for advanced cases. */\n raw: MessageEvent;\n}\n\nexport interface CreateEventStreamOptions<T> {\n /** Send cookies with the EventSource handshake. Default: false. */\n withCredentials?: boolean;\n /** Subscribe to named events in addition to `message`. */\n namedEvents?: readonly string[];\n /** Treat these named events as heartbeat-only (no callback). Default: `[\"ping\"]`. */\n heartbeatEvents?: readonly string[];\n /** Max reconnect attempts. Default: 10. Pass 0 to disable reconnect. */\n maxRetries?: number;\n /** Initial backoff in ms; doubles per attempt, capped at `maxBackoff`. Default: 1000. */\n initialBackoff?: number;\n /** Maximum backoff in ms. Default: 30000. */\n maxBackoff?: number;\n /** Parse `event.data`. Defaults to JSON with raw-string fallback. */\n parser?: (raw: string) => T;\n /**\n * A frame arrived that is not valid JSON, and no `parser` was supplied.\n *\n * Registering this drops the frame instead of delivering it: the previous\n * behaviour handed `onMessage` the raw `string` announced as your message\n * type, so the failure surfaced later, at the first property access, with\n * nothing left pointing at the parse. Leave it out and that behaviour is\n * kept, with a one-time warning in development builds.\n */\n onParseError?: (error: unknown, raw: string) => void;\n /**\n * Schema every decoded frame must satisfy, from zod, valibot, arktype or\n * anything else exposing `~standard` or `.safeParse`.\n *\n * Without it nothing changes: the payload reaches `onMessage` announced as\n * `T` on the strength of the type argument alone, which is a promise about\n * the server that TypeScript cannot keep. With it, a frame that does not\n * match is **not** delivered — the same rule `onParseError` already follows —\n * and `onValidationError` hears why. The value delivered is the schema's\n * output, so coercions and defaults are honoured.\n *\n * When `parser` is also supplied, it decodes first and the schema validates\n * what it returned.\n *\n * The validation must be synchronous. A frame is decoded inside the\n * `message` handler and delivered from it, so an async schema would deliver\n * frames in whatever order their validations settled; that case is reported\n * through `onValidationError` instead of awaited.\n */\n schema?: SchemaLike<T>;\n /**\n * A frame was decoded but the `schema` refused it, so it was dropped.\n *\n * The one signal that does not depend on how the app's bundler resolves\n * `process`: the one-time development warning behind `onParseError` needs\n * `isDevBuild()` to be able to answer, and this callback is the app's own.\n */\n onValidationError?: (issues: SchemaIssue[], raw: string) => void;\n onOpen?: () => void;\n onMessage?: (message: EventStreamMessage<T>) => void;\n onError?: (error: Event) => void;\n onStatusChange?: (status: EventStreamStatus) => void;\n}\n\nexport interface EventStreamController {\n close: () => void;\n /** Force an immediate reconnect, resetting the retry counter. */\n reconnect: () => void;\n /** Current connection status. */\n readonly status: EventStreamStatus;\n}\n\n/**\n * Open a Server-Sent Events stream with automatic exponential-backoff reconnect.\n *\n * Heartbeat events (default `\"ping\"`) keep the socket alive without firing\n * `onMessage`. Pass `withCredentials: true` when the backend authenticates via\n * cookies. Call `close()` from the returned controller to tear down.\n *\n * @param url - Full SSE endpoint URL.\n * @param options - Stream configuration and callbacks.\n * @returns A controller exposing `close`, `reconnect` and the current `status`.\n */\nexport function createEventStream<T = unknown>(\n url: string,\n options: CreateEventStreamOptions<T> = {},\n): EventStreamController {\n const {\n withCredentials = false,\n namedEvents = [],\n heartbeatEvents = [\"ping\"],\n maxRetries = 10,\n initialBackoff = 1000,\n maxBackoff = 30000,\n parser,\n onOpen,\n onMessage,\n onError,\n onParseError,\n schema,\n onValidationError,\n onStatusChange,\n } = options;\n\n let source: EventSource | null = null;\n let retryTimer: ReturnType<typeof setTimeout> | null = null;\n let retries = 0;\n let status: EventStreamStatus = \"idle\";\n let closed = false;\n\n function setStatus(next: EventStreamStatus): void {\n if (status === next) return;\n status = next;\n onStatusChange?.(next);\n }\n\n function emit(eventName: string, event: MessageEvent): void {\n if (heartbeatEvents.includes(eventName)) return;\n const decoded = decodeFrame<T>(\n typeof event.data === \"string\" ? event.data : \"\",\n \"createEventStream\",\n { parser, onParseError, schema, onValidationError },\n );\n if (!decoded.delivered) return;\n onMessage?.({\n event: eventName,\n data: decoded.data,\n id: event.lastEventId || undefined,\n raw: event,\n });\n }\n\n function scheduleReconnect(): void {\n if (closed) return;\n if (retries >= maxRetries) {\n setStatus(\"error\");\n return;\n }\n const delay = Math.min(initialBackoff * 2 ** retries, maxBackoff);\n retries += 1;\n retryTimer = setTimeout(connect, delay);\n }\n\n function connect(): void {\n if (closed) return;\n if (source) source.close();\n setStatus(\"connecting\");\n\n const es = new EventSource(url, { withCredentials });\n source = es;\n\n es.onopen = () => {\n retries = 0;\n setStatus(\"open\");\n onOpen?.();\n };\n\n es.onmessage = (event) => emit(\"message\", event);\n for (const name of namedEvents) {\n es.addEventListener(name, (event) => emit(name, event as MessageEvent));\n }\n for (const name of heartbeatEvents) {\n es.addEventListener(name, () => {\n /* heartbeat — keep socket alive */\n });\n }\n\n es.onerror = (event) => {\n onError?.(event);\n es.close();\n source = null;\n setStatus(\"closed\");\n scheduleReconnect();\n };\n }\n\n function close(): void {\n closed = true;\n if (retryTimer) {\n clearTimeout(retryTimer);\n retryTimer = null;\n }\n retries = 0;\n if (source) {\n source.close();\n source = null;\n }\n setStatus(\"closed\");\n }\n\n function reconnect(): void {\n if (retryTimer) {\n clearTimeout(retryTimer);\n retryTimer = null;\n }\n retries = 0;\n closed = false;\n connect();\n }\n\n connect();\n\n return {\n close,\n reconnect,\n get status() {\n return status;\n },\n };\n}\n"],"mappings":"A,2CAkGA,SAAgBE,EACZ,EACA,EAAuC,CAAC,EACnB,CACrB,GAAM,CACF,kBAAkB,GAClB,cAAc,CAAC,EACf,kBAAkB,CAAC,MAAM,EACzB,aAAa,GACb,iBAAiB,IACjB,aAAa,IACb,SACA,SACA,YACA,UACA,eACA,SACA,oBACA,kBACAC,EAEAC,EAA6B,KAC7BC,EAAmD,KACnDC,EAAU,EACVC,EAA4B,OAC5BC,EAAS,GAEb,SAASC,EAAU,EAA+B,CAC1CF,IAAWG,IACf,EAASA,EACTC,IAAiBD,CAAI,EACzB,CAEA,SAASE,EAAK,EAAmB,EAA2B,CACxD,GAAIC,EAAgB,SAASC,CAAS,EAAG,OACzC,IAAMC,EAAUf,EAAAA,YACZ,OAAOgB,EAAM,MAAS,SAAWA,EAAM,KAAO,GAC9C,oBACA,CAAE,SAAQ,eAAc,SAAQ,mBAAkB,CACtD,EACKD,EAAQ,WACbE,IAAY,CACR,MAAOH,EACP,KAAMC,EAAQ,KACd,GAAIC,EAAM,aAAe,IAAA,GACzB,IAAKA,CACT,CAAC,CACL,CAEA,SAASE,GAA0B,CAC/B,GAAIV,EAAQ,OACZ,GAAIF,GAAWa,EAAY,CACvBV,EAAU,OAAO,EACjB,MACJ,CACA,IAAMW,EAAQ,KAAK,IAAIC,EAAiB,GAAKf,EAASgB,CAAU,EAChE,GAAW,EACX,EAAa,WAAWC,EAASH,CAAK,CAC1C,CAEA,SAASG,GAAgB,CACrB,GAAIf,EAAQ,OACRJ,GAAQA,EAAO,MAAM,EACzBK,EAAU,YAAY,EAEtB,IAAMe,EAAK,IAAI,YAAYC,EAAK,CAAE,iBAAgB,CAAC,EACnD,EAASD,EAET,EAAG,WAAe,CACd,EAAU,EACVf,EAAU,MAAM,EAChBiB,IAAS,CACb,EAEA,EAAG,UAAa,GAAUd,EAAK,UAAWI,CAAK,EAC/C,IAAK,IAAMW,KAAQC,EACfJ,EAAG,iBAAiBG,EAAO,GAAUf,EAAKe,EAAMX,CAAqB,CAAC,EAE1E,IAAK,IAAMW,KAAQd,EACfW,EAAG,iBAAiBG,MAAY,CAEhC,CAAC,EAGL,EAAG,QAAW,GAAU,CACpBE,IAAUb,CAAK,EACfQ,EAAG,MAAM,EACT,EAAS,KACTf,EAAU,QAAQ,EAClBS,EAAkB,CACtB,CACJ,CAEA,SAASY,GAAc,CACnB,EAAS,GACT,AAEIzB,KADA,aAAaA,CAAU,EACV,MAEjB,EAAU,EACV,AAEID,KADAA,EAAO,MAAM,EACJ,MAEbK,EAAU,QAAQ,CACtB,CAEA,SAASsB,GAAkB,CACvB,AAEI1B,KADA,aAAaA,CAAU,EACV,MAEjB,EAAU,EACV,EAAS,GACTkB,EAAQ,CACZ,CAIA,OAFAA,EAAQ,EAED,CACH,QACA,YACA,IAAI,QAAS,CACT,OAAOhB,CACX,CACJ,CACJ,C"}
|
|
1
|
+
{"version":3,"file":"create-event-stream.cjs","names":["decodeFrame","require_json_frame","createEventStream","options","source","retryTimer","retries","status","closed","attempt","setStatus","next","onStatusChange","emit","heartbeatEvents","eventName","decoded","event","onMessage","scheduleReconnect","maxRetries","delay","initialBackoff","maxBackoff","connect","failUrl","onUrlError","error","shouldRetryUrl","url","open","current","resolved","es","target","onOpen","name","namedEvents","onError","close","reconnect"],"sources":["../../src/sse/create-event-stream.ts"],"sourcesContent":["/**\n * @tempest-limits function-lines — the body is the SSE line protocol — accumulate\n * `data:` lines until a blank line, track `id:` for Last-Event-ID, honour `retry:` —\n * plus the reconnect that uses the id it just tracked.\n */\nimport { decodeFrame } from \"../utils/json-frame\";\nimport type { SchemaIssue, SchemaLike } from \"../utils/schema-like\";\nexport type EventStreamStatus = \"idle\" | \"connecting\" | \"open\" | \"closed\" | \"error\";\n\n/**\n * Where the stream connects: a fixed URL, or a function resolved again before\n * every connection, the first one and each reconnect.\n *\n * The function form exists for short-lived credentials carried in the URL,\n * such as the `?ticket=` of the `tempest-fastapi-sdk` SSE router. `EventSource`\n * cannot send headers, so the credential has to ride in the query, and one that\n * expires makes a fixed URL a trap: the reconnect after the expiry presents the\n * same dead credential, gets a `401`, and burns every retry on it while the user\n * is still signed in.\n */\nexport type EventStreamUrl = string | (() => string | Promise<string>);\n\nexport interface EventStreamMessage<T> {\n /** Server-named event (default `\"message\"`). */\n event: string;\n /** Parsed payload — validated when `schema` is set, JSON-decoded when possible, raw string otherwise. */\n data: T;\n /** Server-supplied id, if any. */\n id?: string;\n /** Raw `MessageEvent` for advanced cases. */\n raw: MessageEvent;\n}\n\nexport interface CreateEventStreamOptions<T> {\n /** Send cookies with the EventSource handshake. Default: false. */\n withCredentials?: boolean;\n /** Subscribe to named events in addition to `message`. */\n namedEvents?: readonly string[];\n /** Treat these named events as heartbeat-only (no callback). Default: `[\"ping\"]`. */\n heartbeatEvents?: readonly string[];\n /** Max reconnect attempts. Default: 10. Pass 0 to disable reconnect. */\n maxRetries?: number;\n /** Initial backoff in ms; doubles per attempt, capped at `maxBackoff`. Default: 1000. */\n initialBackoff?: number;\n /** Maximum backoff in ms. Default: 30000. */\n maxBackoff?: number;\n /** Parse `event.data`. Defaults to JSON with raw-string fallback. */\n parser?: (raw: string) => T;\n /**\n * A frame arrived that is not valid JSON, and no `parser` was supplied.\n *\n * Registering this drops the frame instead of delivering it: the previous\n * behaviour handed `onMessage` the raw `string` announced as your message\n * type, so the failure surfaced later, at the first property access, with\n * nothing left pointing at the parse. Leave it out and that behaviour is\n * kept, with a one-time warning in development builds.\n */\n onParseError?: (error: unknown, raw: string) => void;\n /**\n * Schema every decoded frame must satisfy, from zod, valibot, arktype or\n * anything else exposing `~standard` or `.safeParse`.\n *\n * Without it nothing changes: the payload reaches `onMessage` announced as\n * `T` on the strength of the type argument alone, which is a promise about\n * the server that TypeScript cannot keep. With it, a frame that does not\n * match is **not** delivered — the same rule `onParseError` already follows —\n * and `onValidationError` hears why. The value delivered is the schema's\n * output, so coercions and defaults are honoured.\n *\n * When `parser` is also supplied, it decodes first and the schema validates\n * what it returned.\n *\n * The validation must be synchronous. A frame is decoded inside the\n * `message` handler and delivered from it, so an async schema would deliver\n * frames in whatever order their validations settled; that case is reported\n * through `onValidationError` instead of awaited.\n */\n schema?: SchemaLike<T>;\n /**\n * A frame was decoded but the `schema` refused it, so it was dropped.\n *\n * The one signal that does not depend on how the app's bundler resolves\n * `process`: the one-time development warning behind `onParseError` needs\n * `isDevBuild()` to be able to answer, and this callback is the app's own.\n */\n onValidationError?: (issues: SchemaIssue[], raw: string) => void;\n /**\n * The URL function threw or rejected, so no connection was attempted.\n *\n * `onError` cannot carry this: it receives the `EventSource` `Event`, and a\n * URL that never resolved never had an `EventSource`.\n */\n onUrlError?: (error: unknown) => void;\n /**\n * Whether a URL-resolution failure is worth another attempt. Default: always.\n *\n * Return `false` for failures no backoff can fix — a `401` or a `403` when\n * fetching a ticket is a session decision, not a network blip — and the stream\n * stops at `status: \"error\"` instead of spending `maxRetries` on it.\n */\n shouldRetryUrl?: (error: unknown) => boolean;\n onOpen?: () => void;\n onMessage?: (message: EventStreamMessage<T>) => void;\n onError?: (error: Event) => void;\n onStatusChange?: (status: EventStreamStatus) => void;\n}\n\nexport interface EventStreamController {\n close: () => void;\n /** Force an immediate reconnect, resetting the retry counter. */\n reconnect: () => void;\n /** Current connection status. */\n readonly status: EventStreamStatus;\n}\n\n/**\n * Open a Server-Sent Events stream with automatic exponential-backoff reconnect.\n *\n * Heartbeat events (default `\"ping\"`) keep the socket alive without firing\n * `onMessage`. Pass `withCredentials: true` when the backend authenticates via\n * cookies. Call `close()` from the returned controller to tear down.\n *\n * When `url` is a function it is called before every connection, so a\n * reconnect presents a fresh credential instead of the one that expired. A\n * resolution still pending when `close()` or `reconnect()` runs is discarded.\n *\n * @param url - Full SSE endpoint URL, or a function returning it per connection.\n * @param options - Stream configuration and callbacks.\n * @returns A controller exposing `close`, `reconnect` and the current `status`.\n */\nexport function createEventStream<T = unknown>(\n url: EventStreamUrl,\n options: CreateEventStreamOptions<T> = {},\n): EventStreamController {\n const {\n withCredentials = false,\n namedEvents = [],\n heartbeatEvents = [\"ping\"],\n maxRetries = 10,\n initialBackoff = 1000,\n maxBackoff = 30000,\n parser,\n onOpen,\n onMessage,\n onError,\n onParseError,\n schema,\n onValidationError,\n onUrlError,\n shouldRetryUrl = () => true,\n onStatusChange,\n } = options;\n\n let source: EventSource | null = null;\n let retryTimer: ReturnType<typeof setTimeout> | null = null;\n let retries = 0;\n let status: EventStreamStatus = \"idle\";\n let closed = false;\n let attempt = 0;\n\n function setStatus(next: EventStreamStatus): void {\n if (status === next) return;\n status = next;\n onStatusChange?.(next);\n }\n\n function emit(eventName: string, event: MessageEvent): void {\n if (heartbeatEvents.includes(eventName)) return;\n const decoded = decodeFrame<T>(\n typeof event.data === \"string\" ? event.data : \"\",\n \"createEventStream\",\n { parser, onParseError, schema, onValidationError },\n );\n if (!decoded.delivered) return;\n onMessage?.({\n event: eventName,\n data: decoded.data,\n id: event.lastEventId || undefined,\n raw: event,\n });\n }\n\n function scheduleReconnect(): void {\n if (closed) return;\n if (retries >= maxRetries) {\n setStatus(\"error\");\n return;\n }\n const delay = Math.min(initialBackoff * 2 ** retries, maxBackoff);\n retries += 1;\n retryTimer = setTimeout(connect, delay);\n }\n\n function failUrl(error: unknown): void {\n onUrlError?.(error);\n if (!shouldRetryUrl(error)) {\n setStatus(\"error\");\n return;\n }\n setStatus(\"closed\");\n scheduleReconnect();\n }\n\n function connect(): void {\n if (closed) return;\n if (source) {\n source.close();\n source = null;\n }\n setStatus(\"connecting\");\n attempt += 1;\n if (typeof url === \"string\") {\n open(url);\n return;\n }\n const current = attempt;\n Promise.resolve()\n .then(url)\n .then(\n (resolved) => {\n if (!closed && current === attempt) open(resolved);\n },\n (error: unknown) => {\n if (!closed && current === attempt) failUrl(error);\n },\n );\n }\n\n function open(target: string): void {\n const es = new EventSource(target, { withCredentials });\n source = es;\n\n es.onopen = () => {\n retries = 0;\n setStatus(\"open\");\n onOpen?.();\n };\n\n es.onmessage = (event) => emit(\"message\", event);\n for (const name of namedEvents) {\n es.addEventListener(name, (event) => emit(name, event as MessageEvent));\n }\n for (const name of heartbeatEvents) {\n es.addEventListener(name, () => {\n /* heartbeat — keep socket alive */\n });\n }\n\n es.onerror = (event) => {\n onError?.(event);\n es.close();\n source = null;\n setStatus(\"closed\");\n scheduleReconnect();\n };\n }\n\n function close(): void {\n closed = true;\n if (retryTimer) {\n clearTimeout(retryTimer);\n retryTimer = null;\n }\n retries = 0;\n if (source) {\n source.close();\n source = null;\n }\n setStatus(\"closed\");\n }\n\n function reconnect(): void {\n if (retryTimer) {\n clearTimeout(retryTimer);\n retryTimer = null;\n }\n retries = 0;\n closed = false;\n connect();\n }\n\n connect();\n\n return {\n close,\n reconnect,\n get status() {\n return status;\n },\n };\n}\n"],"mappings":"A,2CAkIA,SAAgBE,EACZ,EACA,EAAuC,CAAC,EACnB,CACrB,GAAM,CACF,kBAAkB,GAClB,cAAc,CAAC,EACf,kBAAkB,CAAC,MAAM,EACzB,aAAa,GACb,iBAAiB,IACjB,aAAa,IACb,SACA,SACA,YACA,UACA,eACA,SACA,oBACA,aACA,qBAAuB,GACvB,kBACAC,EAEAC,EAA6B,KAC7BC,EAAmD,KACnDC,EAAU,EACVC,EAA4B,OAC5BC,EAAS,GACTC,EAAU,EAEd,SAASC,EAAU,EAA+B,CAC1CH,IAAWI,IACf,EAASA,EACTC,IAAiBD,CAAI,EACzB,CAEA,SAASE,EAAK,EAAmB,EAA2B,CACxD,GAAIC,EAAgB,SAASC,CAAS,EAAG,OACzC,IAAMC,EAAUhB,EAAAA,YACZ,OAAOiB,EAAM,MAAS,SAAWA,EAAM,KAAO,GAC9C,oBACA,CAAE,SAAQ,eAAc,SAAQ,mBAAkB,CACtD,EACKD,EAAQ,WACbE,IAAY,CACR,MAAOH,EACP,KAAMC,EAAQ,KACd,GAAIC,EAAM,aAAe,IAAA,GACzB,IAAKA,CACT,CAAC,CACL,CAEA,SAASE,GAA0B,CAC/B,GAAIX,EAAQ,OACZ,GAAIF,GAAWc,EAAY,CACvBV,EAAU,OAAO,EACjB,MACJ,CACA,IAAMW,EAAQ,KAAK,IAAIC,EAAiB,GAAKhB,EAASiB,CAAU,EAChE,GAAW,EACX,EAAa,WAAWC,EAASH,CAAK,CAC1C,CAEA,SAASI,EAAQ,EAAsB,CACnCC,IAAaC,CAAK,EACbC,EAAeD,CAAK,GAIzBjB,EAAU,QAAQ,EAClBS,EAAkB,GAJdT,EAAU,OAAO,CAKzB,CAEA,SAASc,GAAgB,CACrB,GAAIhB,EAAQ,OAOZ,GANA,AAEIJ,KADAA,EAAO,MAAM,EACJ,MAEbM,EAAU,YAAY,EACtB,GAAW,EACP,OAAOmB,GAAQ,SAAU,CACzBC,EAAKD,CAAG,EACR,MACJ,CACA,IAAME,EAAUtB,EAChB,QAAQ,QAAQ,CAAC,CACZ,KAAKoB,CAAG,CAAC,CACT,KACI,GAAa,CACN,CAACrB,GAAUuB,IAAYtB,GAASqB,EAAKE,CAAQ,CACrD,EACC,GAAmB,CACZ,CAACxB,GAAUuB,IAAYtB,GAASgB,EAAQE,CAAK,CACrD,CACJ,CACR,CAEA,SAASG,EAAK,EAAsB,CAChC,IAAMG,EAAK,IAAI,YAAYC,EAAQ,CAAE,iBAAgB,CAAC,EACtD,EAASD,EAET,EAAG,WAAe,CACd,EAAU,EACVvB,EAAU,MAAM,EAChByB,IAAS,CACb,EAEA,EAAG,UAAa,GAAUtB,EAAK,UAAWI,CAAK,EAC/C,IAAK,IAAMmB,KAAQC,EACfJ,EAAG,iBAAiBG,EAAO,GAAUvB,EAAKuB,EAAMnB,CAAqB,CAAC,EAE1E,IAAK,IAAMmB,KAAQtB,EACfmB,EAAG,iBAAiBG,MAAY,CAEhC,CAAC,EAGL,EAAG,QAAW,GAAU,CACpBE,IAAUrB,CAAK,EACfgB,EAAG,MAAM,EACT,EAAS,KACTvB,EAAU,QAAQ,EAClBS,EAAkB,CACtB,CACJ,CAEA,SAASoB,GAAc,CACnB,EAAS,GACT,AAEIlC,KADA,aAAaA,CAAU,EACV,MAEjB,EAAU,EACV,AAEID,KADAA,EAAO,MAAM,EACJ,MAEbM,EAAU,QAAQ,CACtB,CAEA,SAAS8B,GAAkB,CACvB,AAEInC,KADA,aAAaA,CAAU,EACV,MAEjB,EAAU,EACV,EAAS,GACTmB,EAAQ,CACZ,CAIA,OAFAA,EAAQ,EAED,CACH,QACA,YACA,IAAI,QAAS,CACT,OAAOjB,CACX,CACJ,CACJ,C"}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { decodeFrame as e } from "../utils/json-frame.js";
|
|
2
2
|
//#region src/sse/create-event-stream.ts
|
|
3
3
|
function t(t, n = {}) {
|
|
4
|
-
let { withCredentials: r = !1, namedEvents: i = [], heartbeatEvents: a = ["ping"], maxRetries: o = 10, initialBackoff: s = 1e3, maxBackoff: c = 3e4, parser: l, onOpen: u, onMessage: d, onError: f, onParseError: p, schema: m, onValidationError: h,
|
|
5
|
-
function
|
|
6
|
-
|
|
4
|
+
let { withCredentials: r = !1, namedEvents: i = [], heartbeatEvents: a = ["ping"], maxRetries: o = 10, initialBackoff: s = 1e3, maxBackoff: c = 3e4, parser: l, onOpen: u, onMessage: d, onError: f, onParseError: p, schema: m, onValidationError: h, onUrlError: g, shouldRetryUrl: _ = () => !0, onStatusChange: v } = n, y = null, b = null, x = 0, S = "idle", C = !1, w = 0;
|
|
5
|
+
function T(e) {
|
|
6
|
+
S !== e && (S = e, v?.(e));
|
|
7
7
|
}
|
|
8
|
-
function
|
|
8
|
+
function E(t, n) {
|
|
9
9
|
if (a.includes(t)) return;
|
|
10
10
|
let r = e(typeof n.data == "string" ? n.data : "", "createEventStream", {
|
|
11
11
|
parser: l,
|
|
@@ -20,39 +20,53 @@ function t(t, n = {}) {
|
|
|
20
20
|
raw: n
|
|
21
21
|
});
|
|
22
22
|
}
|
|
23
|
-
function
|
|
24
|
-
if (
|
|
25
|
-
if (
|
|
26
|
-
|
|
23
|
+
function D() {
|
|
24
|
+
if (C) return;
|
|
25
|
+
if (x >= o) {
|
|
26
|
+
T("error");
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
let e = Math.min(s * 2 ** x, c);
|
|
30
|
+
x += 1, b = setTimeout(k, e);
|
|
31
|
+
}
|
|
32
|
+
function O(e) {
|
|
33
|
+
g?.(e), _(e) ? (T("closed"), D()) : T("error");
|
|
34
|
+
}
|
|
35
|
+
function k() {
|
|
36
|
+
if (C) return;
|
|
37
|
+
if (y &&= (y.close(), null), T("connecting"), w += 1, typeof t == "string") {
|
|
38
|
+
A(t);
|
|
27
39
|
return;
|
|
28
40
|
}
|
|
29
|
-
let e =
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
e
|
|
42
|
-
|
|
41
|
+
let e = w;
|
|
42
|
+
Promise.resolve().then(t).then((t) => {
|
|
43
|
+
!C && e === w && A(t);
|
|
44
|
+
}, (t) => {
|
|
45
|
+
!C && e === w && O(t);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function A(e) {
|
|
49
|
+
let t = new EventSource(e, { withCredentials: r });
|
|
50
|
+
y = t, t.onopen = () => {
|
|
51
|
+
x = 0, T("open"), u?.();
|
|
52
|
+
}, t.onmessage = (e) => E("message", e);
|
|
53
|
+
for (let e of i) t.addEventListener(e, (t) => E(e, t));
|
|
54
|
+
for (let e of a) t.addEventListener(e, () => {});
|
|
55
|
+
t.onerror = (e) => {
|
|
56
|
+
f?.(e), t.close(), y = null, T("closed"), D();
|
|
43
57
|
};
|
|
44
58
|
}
|
|
45
|
-
function
|
|
46
|
-
|
|
59
|
+
function j() {
|
|
60
|
+
C = !0, b &&= (clearTimeout(b), null), x = 0, y &&= (y.close(), null), T("closed");
|
|
47
61
|
}
|
|
48
|
-
function
|
|
49
|
-
|
|
62
|
+
function M() {
|
|
63
|
+
b &&= (clearTimeout(b), null), x = 0, C = !1, k();
|
|
50
64
|
}
|
|
51
|
-
return
|
|
52
|
-
close:
|
|
53
|
-
reconnect:
|
|
65
|
+
return k(), {
|
|
66
|
+
close: j,
|
|
67
|
+
reconnect: M,
|
|
54
68
|
get status() {
|
|
55
|
-
return
|
|
69
|
+
return S;
|
|
56
70
|
}
|
|
57
71
|
};
|
|
58
72
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"create-event-stream.js","names":["createEventStream","options","source","retryTimer","retries","status","closed","setStatus","next","onStatusChange","emit","heartbeatEvents","eventName","decoded","decodeFrame","event","onMessage","scheduleReconnect","maxRetries","delay","initialBackoff","maxBackoff","connect","es","url","onOpen","name","namedEvents","onError","close","reconnect"],"sources":["../../src/sse/create-event-stream.ts"],"sourcesContent":["/**\n * @tempest-limits function-lines — the body is the SSE line protocol — accumulate\n * `data:` lines until a blank line, track `id:` for Last-Event-ID, honour `retry:` —\n * plus the reconnect that uses the id it just tracked.\n */\nimport { decodeFrame } from \"../utils/json-frame\";\nimport type { SchemaIssue, SchemaLike } from \"../utils/schema-like\";\nexport type EventStreamStatus = \"idle\" | \"connecting\" | \"open\" | \"closed\" | \"error\";\n\nexport interface EventStreamMessage<T> {\n /** Server-named event (default `\"message\"`). */\n event: string;\n /** Parsed payload — validated when `schema` is set, JSON-decoded when possible, raw string otherwise. */\n data: T;\n /** Server-supplied id, if any. */\n id?: string;\n /** Raw `MessageEvent` for advanced cases. */\n raw: MessageEvent;\n}\n\nexport interface CreateEventStreamOptions<T> {\n /** Send cookies with the EventSource handshake. Default: false. */\n withCredentials?: boolean;\n /** Subscribe to named events in addition to `message`. */\n namedEvents?: readonly string[];\n /** Treat these named events as heartbeat-only (no callback). Default: `[\"ping\"]`. */\n heartbeatEvents?: readonly string[];\n /** Max reconnect attempts. Default: 10. Pass 0 to disable reconnect. */\n maxRetries?: number;\n /** Initial backoff in ms; doubles per attempt, capped at `maxBackoff`. Default: 1000. */\n initialBackoff?: number;\n /** Maximum backoff in ms. Default: 30000. */\n maxBackoff?: number;\n /** Parse `event.data`. Defaults to JSON with raw-string fallback. */\n parser?: (raw: string) => T;\n /**\n * A frame arrived that is not valid JSON, and no `parser` was supplied.\n *\n * Registering this drops the frame instead of delivering it: the previous\n * behaviour handed `onMessage` the raw `string` announced as your message\n * type, so the failure surfaced later, at the first property access, with\n * nothing left pointing at the parse. Leave it out and that behaviour is\n * kept, with a one-time warning in development builds.\n */\n onParseError?: (error: unknown, raw: string) => void;\n /**\n * Schema every decoded frame must satisfy, from zod, valibot, arktype or\n * anything else exposing `~standard` or `.safeParse`.\n *\n * Without it nothing changes: the payload reaches `onMessage` announced as\n * `T` on the strength of the type argument alone, which is a promise about\n * the server that TypeScript cannot keep. With it, a frame that does not\n * match is **not** delivered — the same rule `onParseError` already follows —\n * and `onValidationError` hears why. The value delivered is the schema's\n * output, so coercions and defaults are honoured.\n *\n * When `parser` is also supplied, it decodes first and the schema validates\n * what it returned.\n *\n * The validation must be synchronous. A frame is decoded inside the\n * `message` handler and delivered from it, so an async schema would deliver\n * frames in whatever order their validations settled; that case is reported\n * through `onValidationError` instead of awaited.\n */\n schema?: SchemaLike<T>;\n /**\n * A frame was decoded but the `schema` refused it, so it was dropped.\n *\n * The one signal that does not depend on how the app's bundler resolves\n * `process`: the one-time development warning behind `onParseError` needs\n * `isDevBuild()` to be able to answer, and this callback is the app's own.\n */\n onValidationError?: (issues: SchemaIssue[], raw: string) => void;\n onOpen?: () => void;\n onMessage?: (message: EventStreamMessage<T>) => void;\n onError?: (error: Event) => void;\n onStatusChange?: (status: EventStreamStatus) => void;\n}\n\nexport interface EventStreamController {\n close: () => void;\n /** Force an immediate reconnect, resetting the retry counter. */\n reconnect: () => void;\n /** Current connection status. */\n readonly status: EventStreamStatus;\n}\n\n/**\n * Open a Server-Sent Events stream with automatic exponential-backoff reconnect.\n *\n * Heartbeat events (default `\"ping\"`) keep the socket alive without firing\n * `onMessage`. Pass `withCredentials: true` when the backend authenticates via\n * cookies. Call `close()` from the returned controller to tear down.\n *\n * @param url - Full SSE endpoint URL.\n * @param options - Stream configuration and callbacks.\n * @returns A controller exposing `close`, `reconnect` and the current `status`.\n */\nexport function createEventStream<T = unknown>(\n url: string,\n options: CreateEventStreamOptions<T> = {},\n): EventStreamController {\n const {\n withCredentials = false,\n namedEvents = [],\n heartbeatEvents = [\"ping\"],\n maxRetries = 10,\n initialBackoff = 1000,\n maxBackoff = 30000,\n parser,\n onOpen,\n onMessage,\n onError,\n onParseError,\n schema,\n onValidationError,\n onStatusChange,\n } = options;\n\n let source: EventSource | null = null;\n let retryTimer: ReturnType<typeof setTimeout> | null = null;\n let retries = 0;\n let status: EventStreamStatus = \"idle\";\n let closed = false;\n\n function setStatus(next: EventStreamStatus): void {\n if (status === next) return;\n status = next;\n onStatusChange?.(next);\n }\n\n function emit(eventName: string, event: MessageEvent): void {\n if (heartbeatEvents.includes(eventName)) return;\n const decoded = decodeFrame<T>(\n typeof event.data === \"string\" ? event.data : \"\",\n \"createEventStream\",\n { parser, onParseError, schema, onValidationError },\n );\n if (!decoded.delivered) return;\n onMessage?.({\n event: eventName,\n data: decoded.data,\n id: event.lastEventId || undefined,\n raw: event,\n });\n }\n\n function scheduleReconnect(): void {\n if (closed) return;\n if (retries >= maxRetries) {\n setStatus(\"error\");\n return;\n }\n const delay = Math.min(initialBackoff * 2 ** retries, maxBackoff);\n retries += 1;\n retryTimer = setTimeout(connect, delay);\n }\n\n function connect(): void {\n if (closed) return;\n if (source) source.close();\n setStatus(\"connecting\");\n\n const es = new EventSource(url, { withCredentials });\n source = es;\n\n es.onopen = () => {\n retries = 0;\n setStatus(\"open\");\n onOpen?.();\n };\n\n es.onmessage = (event) => emit(\"message\", event);\n for (const name of namedEvents) {\n es.addEventListener(name, (event) => emit(name, event as MessageEvent));\n }\n for (const name of heartbeatEvents) {\n es.addEventListener(name, () => {\n /* heartbeat — keep socket alive */\n });\n }\n\n es.onerror = (event) => {\n onError?.(event);\n es.close();\n source = null;\n setStatus(\"closed\");\n scheduleReconnect();\n };\n }\n\n function close(): void {\n closed = true;\n if (retryTimer) {\n clearTimeout(retryTimer);\n retryTimer = null;\n }\n retries = 0;\n if (source) {\n source.close();\n source = null;\n }\n setStatus(\"closed\");\n }\n\n function reconnect(): void {\n if (retryTimer) {\n clearTimeout(retryTimer);\n retryTimer = null;\n }\n retries = 0;\n closed = false;\n connect();\n }\n\n connect();\n\n return {\n close,\n reconnect,\n get status() {\n return status;\n },\n };\n}\n"],"mappings":";;AAkGA,SAAgBA,EACZ,GACA,IAAuC,CAAC,GACnB;CACrB,IAAM,EACF,qBAAkB,IAClB,iBAAc,CAAC,GACf,qBAAkB,CAAC,MAAM,GACzB,gBAAa,IACb,oBAAiB,KACjB,gBAAa,KACb,WACA,WACA,cACA,YACA,iBACA,WACA,sBACA,sBACAC,GAEAC,IAA6B,MAC7BC,IAAmD,MACnDC,IAAU,GACVC,IAA4B,QAC5BC,IAAS;CAEb,SAASC,EAAU,GAA+B;EAC9C,AAAIF,MAAWG,MACf,IAASA,GACTC,IAAiBD,CAAI;CACzB;CAEA,SAASE,EAAK,GAAmB,GAA2B;EACxD,IAAIC,EAAgB,SAASC,CAAS,GAAG;EACzC,IAAMC,IAAUC,EACZ,OAAOC,EAAM,QAAS,WAAWA,EAAM,OAAO,IAC9C,qBACA;GAAE;GAAQ;GAAc;GAAQ;EAAkB,CACtD;EACA,AAAKF,EAAQ,aACbG,IAAY;GACR,OAAOJ;GACP,MAAMC,EAAQ;GACd,IAAIE,EAAM,eAAe,KAAA;GACzB,KAAKA;EACT,CAAC;CACL;CAEA,SAASE,IAA0B;EAC/B,IAAIX,GAAQ;EACZ,IAAIF,KAAWc,GAAY;GACvB,EAAU,OAAO;GACjB;EACJ;EACA,IAAMC,IAAQ,KAAK,IAAIC,IAAiB,KAAKhB,GAASiB,CAAU;EAEhE,AADA,KAAW,GACX,IAAa,WAAWC,GAASH,CAAK;CAC1C;CAEA,SAASG,IAAgB;EACrB,IAAIhB,GAAQ;EAEZ,AADIJ,KAAQA,EAAO,MAAM,GACzBK,EAAU,YAAY;EAEtB,IAAMgB,IAAK,IAAI,YAAYC,GAAK,EAAE,mBAAgB,CAAC;EASnD,AARA,IAASD,GAET,EAAG,eAAe;GAGd,AAFA,IAAU,GACVhB,EAAU,MAAM,GAChBkB,IAAS;EACb,GAEA,EAAG,aAAa,MAAUf,EAAK,WAAWK,CAAK;EAC/C,KAAK,IAAMW,KAAQC,GACf,EAAG,iBAAiBD,IAAO,MAAUhB,EAAKgB,GAAMX,CAAqB,CAAC;EAE1E,KAAK,IAAMW,KAAQf,GACf,EAAG,iBAAiBe,SAAY,CAEhC,CAAC;EAGL,EAAG,WAAW,MAAU;GAKpB,AAJAE,IAAUb,CAAK,GACfQ,EAAG,MAAM,GACT,IAAS,MACThB,EAAU,QAAQ,GAClBU,EAAkB;EACtB;CACJ;CAEA,SAASY,IAAc;EAWnB,AAVA,IAAS,IACT,AAEI1B,OADA,aAAaA,CAAU,GACV,OAEjB,IAAU,GACV,AAEID,OADAA,EAAO,MAAM,GACJ,OAEbK,EAAU,QAAQ;CACtB;CAEA,SAASuB,IAAkB;EAOvB,AANA,AAEI3B,OADA,aAAaA,CAAU,GACV,OAEjB,IAAU,GACV,IAAS,IACTmB,EAAQ;CACZ;CAIA,OAFAA,EAAQ,GAED;EACH;EACA;EACA,IAAI,SAAS;GACT,OAAOjB;EACX;CACJ;AACJ"}
|
|
1
|
+
{"version":3,"file":"create-event-stream.js","names":["createEventStream","options","source","retryTimer","retries","status","closed","attempt","setStatus","next","onStatusChange","emit","heartbeatEvents","eventName","decoded","decodeFrame","event","onMessage","scheduleReconnect","maxRetries","delay","initialBackoff","maxBackoff","connect","failUrl","onUrlError","error","shouldRetryUrl","url","current","open","resolved","es","target","onOpen","name","namedEvents","onError","close","reconnect"],"sources":["../../src/sse/create-event-stream.ts"],"sourcesContent":["/**\n * @tempest-limits function-lines — the body is the SSE line protocol — accumulate\n * `data:` lines until a blank line, track `id:` for Last-Event-ID, honour `retry:` —\n * plus the reconnect that uses the id it just tracked.\n */\nimport { decodeFrame } from \"../utils/json-frame\";\nimport type { SchemaIssue, SchemaLike } from \"../utils/schema-like\";\nexport type EventStreamStatus = \"idle\" | \"connecting\" | \"open\" | \"closed\" | \"error\";\n\n/**\n * Where the stream connects: a fixed URL, or a function resolved again before\n * every connection, the first one and each reconnect.\n *\n * The function form exists for short-lived credentials carried in the URL,\n * such as the `?ticket=` of the `tempest-fastapi-sdk` SSE router. `EventSource`\n * cannot send headers, so the credential has to ride in the query, and one that\n * expires makes a fixed URL a trap: the reconnect after the expiry presents the\n * same dead credential, gets a `401`, and burns every retry on it while the user\n * is still signed in.\n */\nexport type EventStreamUrl = string | (() => string | Promise<string>);\n\nexport interface EventStreamMessage<T> {\n /** Server-named event (default `\"message\"`). */\n event: string;\n /** Parsed payload — validated when `schema` is set, JSON-decoded when possible, raw string otherwise. */\n data: T;\n /** Server-supplied id, if any. */\n id?: string;\n /** Raw `MessageEvent` for advanced cases. */\n raw: MessageEvent;\n}\n\nexport interface CreateEventStreamOptions<T> {\n /** Send cookies with the EventSource handshake. Default: false. */\n withCredentials?: boolean;\n /** Subscribe to named events in addition to `message`. */\n namedEvents?: readonly string[];\n /** Treat these named events as heartbeat-only (no callback). Default: `[\"ping\"]`. */\n heartbeatEvents?: readonly string[];\n /** Max reconnect attempts. Default: 10. Pass 0 to disable reconnect. */\n maxRetries?: number;\n /** Initial backoff in ms; doubles per attempt, capped at `maxBackoff`. Default: 1000. */\n initialBackoff?: number;\n /** Maximum backoff in ms. Default: 30000. */\n maxBackoff?: number;\n /** Parse `event.data`. Defaults to JSON with raw-string fallback. */\n parser?: (raw: string) => T;\n /**\n * A frame arrived that is not valid JSON, and no `parser` was supplied.\n *\n * Registering this drops the frame instead of delivering it: the previous\n * behaviour handed `onMessage` the raw `string` announced as your message\n * type, so the failure surfaced later, at the first property access, with\n * nothing left pointing at the parse. Leave it out and that behaviour is\n * kept, with a one-time warning in development builds.\n */\n onParseError?: (error: unknown, raw: string) => void;\n /**\n * Schema every decoded frame must satisfy, from zod, valibot, arktype or\n * anything else exposing `~standard` or `.safeParse`.\n *\n * Without it nothing changes: the payload reaches `onMessage` announced as\n * `T` on the strength of the type argument alone, which is a promise about\n * the server that TypeScript cannot keep. With it, a frame that does not\n * match is **not** delivered — the same rule `onParseError` already follows —\n * and `onValidationError` hears why. The value delivered is the schema's\n * output, so coercions and defaults are honoured.\n *\n * When `parser` is also supplied, it decodes first and the schema validates\n * what it returned.\n *\n * The validation must be synchronous. A frame is decoded inside the\n * `message` handler and delivered from it, so an async schema would deliver\n * frames in whatever order their validations settled; that case is reported\n * through `onValidationError` instead of awaited.\n */\n schema?: SchemaLike<T>;\n /**\n * A frame was decoded but the `schema` refused it, so it was dropped.\n *\n * The one signal that does not depend on how the app's bundler resolves\n * `process`: the one-time development warning behind `onParseError` needs\n * `isDevBuild()` to be able to answer, and this callback is the app's own.\n */\n onValidationError?: (issues: SchemaIssue[], raw: string) => void;\n /**\n * The URL function threw or rejected, so no connection was attempted.\n *\n * `onError` cannot carry this: it receives the `EventSource` `Event`, and a\n * URL that never resolved never had an `EventSource`.\n */\n onUrlError?: (error: unknown) => void;\n /**\n * Whether a URL-resolution failure is worth another attempt. Default: always.\n *\n * Return `false` for failures no backoff can fix — a `401` or a `403` when\n * fetching a ticket is a session decision, not a network blip — and the stream\n * stops at `status: \"error\"` instead of spending `maxRetries` on it.\n */\n shouldRetryUrl?: (error: unknown) => boolean;\n onOpen?: () => void;\n onMessage?: (message: EventStreamMessage<T>) => void;\n onError?: (error: Event) => void;\n onStatusChange?: (status: EventStreamStatus) => void;\n}\n\nexport interface EventStreamController {\n close: () => void;\n /** Force an immediate reconnect, resetting the retry counter. */\n reconnect: () => void;\n /** Current connection status. */\n readonly status: EventStreamStatus;\n}\n\n/**\n * Open a Server-Sent Events stream with automatic exponential-backoff reconnect.\n *\n * Heartbeat events (default `\"ping\"`) keep the socket alive without firing\n * `onMessage`. Pass `withCredentials: true` when the backend authenticates via\n * cookies. Call `close()` from the returned controller to tear down.\n *\n * When `url` is a function it is called before every connection, so a\n * reconnect presents a fresh credential instead of the one that expired. A\n * resolution still pending when `close()` or `reconnect()` runs is discarded.\n *\n * @param url - Full SSE endpoint URL, or a function returning it per connection.\n * @param options - Stream configuration and callbacks.\n * @returns A controller exposing `close`, `reconnect` and the current `status`.\n */\nexport function createEventStream<T = unknown>(\n url: EventStreamUrl,\n options: CreateEventStreamOptions<T> = {},\n): EventStreamController {\n const {\n withCredentials = false,\n namedEvents = [],\n heartbeatEvents = [\"ping\"],\n maxRetries = 10,\n initialBackoff = 1000,\n maxBackoff = 30000,\n parser,\n onOpen,\n onMessage,\n onError,\n onParseError,\n schema,\n onValidationError,\n onUrlError,\n shouldRetryUrl = () => true,\n onStatusChange,\n } = options;\n\n let source: EventSource | null = null;\n let retryTimer: ReturnType<typeof setTimeout> | null = null;\n let retries = 0;\n let status: EventStreamStatus = \"idle\";\n let closed = false;\n let attempt = 0;\n\n function setStatus(next: EventStreamStatus): void {\n if (status === next) return;\n status = next;\n onStatusChange?.(next);\n }\n\n function emit(eventName: string, event: MessageEvent): void {\n if (heartbeatEvents.includes(eventName)) return;\n const decoded = decodeFrame<T>(\n typeof event.data === \"string\" ? event.data : \"\",\n \"createEventStream\",\n { parser, onParseError, schema, onValidationError },\n );\n if (!decoded.delivered) return;\n onMessage?.({\n event: eventName,\n data: decoded.data,\n id: event.lastEventId || undefined,\n raw: event,\n });\n }\n\n function scheduleReconnect(): void {\n if (closed) return;\n if (retries >= maxRetries) {\n setStatus(\"error\");\n return;\n }\n const delay = Math.min(initialBackoff * 2 ** retries, maxBackoff);\n retries += 1;\n retryTimer = setTimeout(connect, delay);\n }\n\n function failUrl(error: unknown): void {\n onUrlError?.(error);\n if (!shouldRetryUrl(error)) {\n setStatus(\"error\");\n return;\n }\n setStatus(\"closed\");\n scheduleReconnect();\n }\n\n function connect(): void {\n if (closed) return;\n if (source) {\n source.close();\n source = null;\n }\n setStatus(\"connecting\");\n attempt += 1;\n if (typeof url === \"string\") {\n open(url);\n return;\n }\n const current = attempt;\n Promise.resolve()\n .then(url)\n .then(\n (resolved) => {\n if (!closed && current === attempt) open(resolved);\n },\n (error: unknown) => {\n if (!closed && current === attempt) failUrl(error);\n },\n );\n }\n\n function open(target: string): void {\n const es = new EventSource(target, { withCredentials });\n source = es;\n\n es.onopen = () => {\n retries = 0;\n setStatus(\"open\");\n onOpen?.();\n };\n\n es.onmessage = (event) => emit(\"message\", event);\n for (const name of namedEvents) {\n es.addEventListener(name, (event) => emit(name, event as MessageEvent));\n }\n for (const name of heartbeatEvents) {\n es.addEventListener(name, () => {\n /* heartbeat — keep socket alive */\n });\n }\n\n es.onerror = (event) => {\n onError?.(event);\n es.close();\n source = null;\n setStatus(\"closed\");\n scheduleReconnect();\n };\n }\n\n function close(): void {\n closed = true;\n if (retryTimer) {\n clearTimeout(retryTimer);\n retryTimer = null;\n }\n retries = 0;\n if (source) {\n source.close();\n source = null;\n }\n setStatus(\"closed\");\n }\n\n function reconnect(): void {\n if (retryTimer) {\n clearTimeout(retryTimer);\n retryTimer = null;\n }\n retries = 0;\n closed = false;\n connect();\n }\n\n connect();\n\n return {\n close,\n reconnect,\n get status() {\n return status;\n },\n };\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
const e=require("../http/errors.cjs"),t=require("./create-event-stream.cjs"),n=require("./use-event-stream.cjs");function r(t){return e.isApiError(t)&&e.isRetriableStatus(t.status)}function i(e){let{client:t,prefix:n=`/sse`,ticketParam:r=`ticket`}=e;return async()=>{let e=await t.post(`${n}/ticket`);if(typeof e?.ticket!=`string`||e.ticket===``)throw TypeError(`createTicketEventStream: POST ${n}/ticket returned no "ticket" string.`);return t.url(`${n}/stream`,{[r]:e.ticket})}}function a(e){let{client:n,prefix:a,ticketParam:o,shouldRetryUrl:s=r,...c}=e;return t.createEventStream(i({client:n,prefix:a,ticketParam:o}),{...c,shouldRetryUrl:s})}function o(e){let{client:t,prefix:a,ticketParam:o,shouldRetryUrl:s=r,...c}=e;return n.useEventStream(i({client:t,prefix:a,ticketParam:o}),{...c,shouldRetryUrl:s})}exports.createTicketEventStream=a,exports.isRetriableTicketError=r,exports.useTicketEventStream=o;
|
|
2
|
+
//# sourceMappingURL=ticket-event-stream.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ticket-event-stream.cjs","names":["isApiError","isRetriableStatus","createEventStream","useEventStream","require_errors","require_create_event_stream","require_use_event_stream","isRetriableTicketError","error","ticketStreamUrl","source","response","client","prefix","ticketParam","createTicketEventStream","rest","options","useTicketEventStream"],"sources":["../../src/sse/ticket-event-stream.ts"],"sourcesContent":["import { isApiError, isRetriableStatus } from \"../http/errors\";\nimport type { ApiClient } from \"../http/types\";\nimport {\n createEventStream,\n type CreateEventStreamOptions,\n type EventStreamController,\n} from \"./create-event-stream\";\nimport {\n useEventStream,\n type UseEventStreamOptions,\n type UseEventStreamResult,\n} from \"./use-event-stream\";\n\n/** Body of `POST <prefix>/ticket` on the `tempest-fastapi-sdk` SSE router. */\nexport interface SseTicketResponse {\n /** Signed stream ticket, presented as a query param when opening the stream. */\n ticket: string;\n /** Seconds the ticket is accepted for opening a stream. */\n expires_in: number;\n}\n\n/** Where the ticket comes from and how the stream URL is built. */\nexport interface TicketEventStreamSource {\n /**\n * Authenticated client the ticket is requested through, so it carries the\n * `Authorization` header or the session cookie, the CSRF header, and the\n * 401 refresh-and-replay that a hand-rolled `fetch` would lose.\n */\n client: Pick<ApiClient, \"post\" | \"url\">;\n /** Router prefix, relative to the client's base URL. Default: `\"/sse\"`. */\n prefix?: string;\n /** Query param the stream reads the ticket from. Default: `\"ticket\"`. */\n ticketParam?: string;\n}\n\nexport type CreateTicketEventStreamOptions<T> = TicketEventStreamSource &\n CreateEventStreamOptions<T>;\n\nexport type UseTicketEventStreamOptions<T> = TicketEventStreamSource & UseEventStreamOptions<T>;\n\n/**\n * Whether a failure to obtain a ticket is worth another attempt.\n *\n * Only a failure the backoff can outlast is: no response at all, a timeout, a\n * rate limit, a `5xx`. A `401` reached the caller after the client already\n * tried its refresh, and a `403` (`ACCOUNT_INACTIVE`) is the backend saying no;\n * both are session decisions, and retrying them only delays the error state the\n * app has to show. Anything that is not an API error is a contract mismatch,\n * which a retry cannot fix either.\n *\n * @param error - What the ticket request threw.\n * @returns Whether to schedule a reconnect.\n */\nexport function isRetriableTicketError(error: unknown): boolean {\n return isApiError(error) && isRetriableStatus(error.status);\n}\n\n/**\n * Build the URL function that requests a ticket and points at the stream.\n *\n * A fresh ticket is requested for every connection rather than cached for its\n * `expires_in`. Every connection after the first follows a failure, and the\n * failure may have been the ticket itself; reusing it would spend the retries\n * on a credential that is already refused, while a new ticket costs one `POST`\n * per reconnect, which the backoff already spaces out.\n *\n * @param source - Client, router prefix and ticket param name.\n * @returns A function resolving the stream URL with a fresh ticket.\n * @throws TypeError When the ticket response has no `ticket` string.\n */\nfunction ticketStreamUrl(source: TicketEventStreamSource): () => Promise<string> {\n const { client, prefix = \"/sse\", ticketParam = \"ticket\" } = source;\n return async () => {\n const response = await client.post<SseTicketResponse>(`${prefix}/ticket`);\n if (typeof response?.ticket !== \"string\" || response.ticket === \"\") {\n throw new TypeError(\n `createTicketEventStream: POST ${prefix}/ticket returned no \"ticket\" string.`,\n );\n }\n return client.url(`${prefix}/stream`, { [ticketParam]: response.ticket });\n };\n}\n\n/**\n * Open the ticket-authenticated stream of the `tempest-fastapi-sdk` SSE router.\n *\n * `POST <prefix>/ticket` through the authenticated client, then\n * `GET <prefix>/stream?ticket=…` — and again for every reconnect, because the\n * ticket only opens streams for `expires_in` seconds and a reconnect after that\n * with the old one is a `401`. A ticket request refused with a status no\n * backoff fixes (`401`, `403 ACCOUNT_INACTIVE`) stops the stream at\n * `status: \"error\"` and reaches `onUrlError`; pass `shouldRetryUrl` to replace\n * {@link isRetriableTicketError}.\n *\n * The access token never enters the URL: it travels as the client's header (or\n * cookie) on the ticket request, and the ticket in the URL opens that stream\n * and nothing else.\n *\n * @example\n * const stream = createTicketEventStream<Notification>({\n * client: api,\n * onMessage: ({ data }) => addNotification(data),\n * onUrlError: (error) => toast.error(describeApiError(error)),\n * });\n *\n * @param options - Ticket source plus every {@link createEventStream} option.\n * @returns The stream controller.\n */\nexport function createTicketEventStream<T = unknown>(\n options: CreateTicketEventStreamOptions<T>,\n): EventStreamController {\n const {\n client,\n prefix,\n ticketParam,\n shouldRetryUrl = isRetriableTicketError,\n ...rest\n } = options;\n return createEventStream<T>(ticketStreamUrl({ client, prefix, ticketParam }), {\n ...rest,\n shouldRetryUrl,\n });\n}\n\n/**\n * React form of {@link createTicketEventStream}: same ticket per connection,\n * lifecycle tied to the component like {@link useEventStream}.\n *\n * Changing `client`, `prefix` or `ticketParam` does not drop an open stream:\n * the next connection uses the new values, and `reconnect()` applies them now.\n * Reopening on identity would reconnect on every render for a client built\n * inline.\n *\n * @example\n * const { status } = useTicketEventStream<Notification>({\n * client: api,\n * enabled: isAuthenticated,\n * schema: notificationSchema,\n * onMessage: ({ data }) => addNotification(data),\n * });\n *\n * @param options - Ticket source plus every {@link useEventStream} option.\n * @returns Status, last message and `reconnect`.\n */\nexport function useTicketEventStream<T = unknown>(\n options: UseTicketEventStreamOptions<T>,\n): UseEventStreamResult<T> {\n const {\n client,\n prefix,\n ticketParam,\n shouldRetryUrl = isRetriableTicketError,\n ...rest\n } = options;\n return useEventStream<T>(ticketStreamUrl({ client, prefix, ticketParam }), {\n ...rest,\n shouldRetryUrl,\n });\n}\n"],"mappings":"A,iHAqDA,SAAgBO,EAAuB,EAAyB,CAC5D,OAAOP,EAAAA,WAAWQ,CAAK,GAAKP,EAAAA,kBAAkBO,EAAM,MAAM,CAC9D,CAeA,SAASC,EAAgB,EAAwD,CAC7E,GAAM,CAAE,SAAQ,SAAS,OAAQ,cAAc,UAAaC,EAC5D,OAAO,SAAY,CACf,IAAMC,EAAW,MAAMC,EAAO,KAAwB,GAAGC,EAAO,QAAQ,EACxE,GAAI,OAAOF,GAAU,QAAW,UAAYA,EAAS,SAAW,GAC5D,MAAU,UACN,iCAAiCE,EAAO,qCAC5C,EAEJ,OAAOD,EAAO,IAAI,GAAGC,EAAO,SAAU,EAAGC,GAAcH,EAAS,MAAO,CAAC,CAC5E,CACJ,CA2BA,SAAgBI,EACZ,EACqB,CACrB,GAAM,CACF,SACA,SACA,cACA,iBAAiBR,EACjB,GAAGS,GACHC,EACJ,OAAOf,EAAAA,kBAAqBO,EAAgB,CAAE,SAAQ,SAAQ,aAAY,CAAC,EAAG,CAC1E,GAAGO,EACH,gBACJ,CAAC,CACL,CAsBA,SAAgBE,EACZ,EACuB,CACvB,GAAM,CACF,SACA,SACA,cACA,iBAAiBX,EACjB,GAAGS,GACHC,EACJ,OAAOd,EAAAA,eAAkBM,EAAgB,CAAE,SAAQ,SAAQ,aAAY,CAAC,EAAG,CACvE,GAAGO,EACH,gBACJ,CAAC,CACL,C"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { isApiError as e, isRetriableStatus as t } from "../http/errors.js";
|
|
2
|
+
import { createEventStream as n } from "./create-event-stream.js";
|
|
3
|
+
import { useEventStream as r } from "./use-event-stream.js";
|
|
4
|
+
//#region src/sse/ticket-event-stream.ts
|
|
5
|
+
function i(n) {
|
|
6
|
+
return e(n) && t(n.status);
|
|
7
|
+
}
|
|
8
|
+
function a(e) {
|
|
9
|
+
let { client: t, prefix: n = "/sse", ticketParam: r = "ticket" } = e;
|
|
10
|
+
return async () => {
|
|
11
|
+
let e = await t.post(`${n}/ticket`);
|
|
12
|
+
if (typeof e?.ticket != "string" || e.ticket === "") throw TypeError(`createTicketEventStream: POST ${n}/ticket returned no "ticket" string.`);
|
|
13
|
+
return t.url(`${n}/stream`, { [r]: e.ticket });
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function o(e) {
|
|
17
|
+
let { client: t, prefix: r, ticketParam: o, shouldRetryUrl: s = i, ...c } = e;
|
|
18
|
+
return n(a({
|
|
19
|
+
client: t,
|
|
20
|
+
prefix: r,
|
|
21
|
+
ticketParam: o
|
|
22
|
+
}), {
|
|
23
|
+
...c,
|
|
24
|
+
shouldRetryUrl: s
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
function s(e) {
|
|
28
|
+
let { client: t, prefix: n, ticketParam: o, shouldRetryUrl: s = i, ...c } = e;
|
|
29
|
+
return r(a({
|
|
30
|
+
client: t,
|
|
31
|
+
prefix: n,
|
|
32
|
+
ticketParam: o
|
|
33
|
+
}), {
|
|
34
|
+
...c,
|
|
35
|
+
shouldRetryUrl: s
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
//#endregion
|
|
39
|
+
export { o as createTicketEventStream, i as isRetriableTicketError, s as useTicketEventStream };
|
|
40
|
+
|
|
41
|
+
//# sourceMappingURL=ticket-event-stream.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ticket-event-stream.js","names":["isRetriableTicketError","isApiError","error","isRetriableStatus","ticketStreamUrl","source","response","client","prefix","ticketParam","createTicketEventStream","rest","options","createEventStream","useTicketEventStream","useEventStream"],"sources":["../../src/sse/ticket-event-stream.ts"],"sourcesContent":["import { isApiError, isRetriableStatus } from \"../http/errors\";\nimport type { ApiClient } from \"../http/types\";\nimport {\n createEventStream,\n type CreateEventStreamOptions,\n type EventStreamController,\n} from \"./create-event-stream\";\nimport {\n useEventStream,\n type UseEventStreamOptions,\n type UseEventStreamResult,\n} from \"./use-event-stream\";\n\n/** Body of `POST <prefix>/ticket` on the `tempest-fastapi-sdk` SSE router. */\nexport interface SseTicketResponse {\n /** Signed stream ticket, presented as a query param when opening the stream. */\n ticket: string;\n /** Seconds the ticket is accepted for opening a stream. */\n expires_in: number;\n}\n\n/** Where the ticket comes from and how the stream URL is built. */\nexport interface TicketEventStreamSource {\n /**\n * Authenticated client the ticket is requested through, so it carries the\n * `Authorization` header or the session cookie, the CSRF header, and the\n * 401 refresh-and-replay that a hand-rolled `fetch` would lose.\n */\n client: Pick<ApiClient, \"post\" | \"url\">;\n /** Router prefix, relative to the client's base URL. Default: `\"/sse\"`. */\n prefix?: string;\n /** Query param the stream reads the ticket from. Default: `\"ticket\"`. */\n ticketParam?: string;\n}\n\nexport type CreateTicketEventStreamOptions<T> = TicketEventStreamSource &\n CreateEventStreamOptions<T>;\n\nexport type UseTicketEventStreamOptions<T> = TicketEventStreamSource & UseEventStreamOptions<T>;\n\n/**\n * Whether a failure to obtain a ticket is worth another attempt.\n *\n * Only a failure the backoff can outlast is: no response at all, a timeout, a\n * rate limit, a `5xx`. A `401` reached the caller after the client already\n * tried its refresh, and a `403` (`ACCOUNT_INACTIVE`) is the backend saying no;\n * both are session decisions, and retrying them only delays the error state the\n * app has to show. Anything that is not an API error is a contract mismatch,\n * which a retry cannot fix either.\n *\n * @param error - What the ticket request threw.\n * @returns Whether to schedule a reconnect.\n */\nexport function isRetriableTicketError(error: unknown): boolean {\n return isApiError(error) && isRetriableStatus(error.status);\n}\n\n/**\n * Build the URL function that requests a ticket and points at the stream.\n *\n * A fresh ticket is requested for every connection rather than cached for its\n * `expires_in`. Every connection after the first follows a failure, and the\n * failure may have been the ticket itself; reusing it would spend the retries\n * on a credential that is already refused, while a new ticket costs one `POST`\n * per reconnect, which the backoff already spaces out.\n *\n * @param source - Client, router prefix and ticket param name.\n * @returns A function resolving the stream URL with a fresh ticket.\n * @throws TypeError When the ticket response has no `ticket` string.\n */\nfunction ticketStreamUrl(source: TicketEventStreamSource): () => Promise<string> {\n const { client, prefix = \"/sse\", ticketParam = \"ticket\" } = source;\n return async () => {\n const response = await client.post<SseTicketResponse>(`${prefix}/ticket`);\n if (typeof response?.ticket !== \"string\" || response.ticket === \"\") {\n throw new TypeError(\n `createTicketEventStream: POST ${prefix}/ticket returned no \"ticket\" string.`,\n );\n }\n return client.url(`${prefix}/stream`, { [ticketParam]: response.ticket });\n };\n}\n\n/**\n * Open the ticket-authenticated stream of the `tempest-fastapi-sdk` SSE router.\n *\n * `POST <prefix>/ticket` through the authenticated client, then\n * `GET <prefix>/stream?ticket=…` — and again for every reconnect, because the\n * ticket only opens streams for `expires_in` seconds and a reconnect after that\n * with the old one is a `401`. A ticket request refused with a status no\n * backoff fixes (`401`, `403 ACCOUNT_INACTIVE`) stops the stream at\n * `status: \"error\"` and reaches `onUrlError`; pass `shouldRetryUrl` to replace\n * {@link isRetriableTicketError}.\n *\n * The access token never enters the URL: it travels as the client's header (or\n * cookie) on the ticket request, and the ticket in the URL opens that stream\n * and nothing else.\n *\n * @example\n * const stream = createTicketEventStream<Notification>({\n * client: api,\n * onMessage: ({ data }) => addNotification(data),\n * onUrlError: (error) => toast.error(describeApiError(error)),\n * });\n *\n * @param options - Ticket source plus every {@link createEventStream} option.\n * @returns The stream controller.\n */\nexport function createTicketEventStream<T = unknown>(\n options: CreateTicketEventStreamOptions<T>,\n): EventStreamController {\n const {\n client,\n prefix,\n ticketParam,\n shouldRetryUrl = isRetriableTicketError,\n ...rest\n } = options;\n return createEventStream<T>(ticketStreamUrl({ client, prefix, ticketParam }), {\n ...rest,\n shouldRetryUrl,\n });\n}\n\n/**\n * React form of {@link createTicketEventStream}: same ticket per connection,\n * lifecycle tied to the component like {@link useEventStream}.\n *\n * Changing `client`, `prefix` or `ticketParam` does not drop an open stream:\n * the next connection uses the new values, and `reconnect()` applies them now.\n * Reopening on identity would reconnect on every render for a client built\n * inline.\n *\n * @example\n * const { status } = useTicketEventStream<Notification>({\n * client: api,\n * enabled: isAuthenticated,\n * schema: notificationSchema,\n * onMessage: ({ data }) => addNotification(data),\n * });\n *\n * @param options - Ticket source plus every {@link useEventStream} option.\n * @returns Status, last message and `reconnect`.\n */\nexport function useTicketEventStream<T = unknown>(\n options: UseTicketEventStreamOptions<T>,\n): UseEventStreamResult<T> {\n const {\n client,\n prefix,\n ticketParam,\n shouldRetryUrl = isRetriableTicketError,\n ...rest\n } = options;\n return useEventStream<T>(ticketStreamUrl({ client, prefix, ticketParam }), {\n ...rest,\n shouldRetryUrl,\n });\n}\n"],"mappings":";;;;AAqDA,SAAgBA,EAAuB,GAAyB;CAC5D,OAAOC,EAAWC,CAAK,KAAKC,EAAkBD,EAAM,MAAM;AAC9D;AAeA,SAASE,EAAgB,GAAwD;CAC7E,IAAM,EAAE,WAAQ,YAAS,QAAQ,iBAAc,aAAaC;CAC5D,OAAO,YAAY;EACf,IAAMC,IAAW,MAAMC,EAAO,KAAwB,GAAGC,EAAO,QAAQ;EACxE,IAAI,OAAOF,GAAU,UAAW,YAAYA,EAAS,WAAW,IAC5D,MAAU,UACN,iCAAiCE,EAAO,qCAC5C;EAEJ,OAAOD,EAAO,IAAI,GAAGC,EAAO,UAAU,GAAGC,IAAcH,EAAS,OAAO,CAAC;CAC5E;AACJ;AA2BA,SAAgBI,EACZ,GACqB;CACrB,IAAM,EACF,WACA,WACA,gBACA,oBAAiBV,GACjB,GAAGW,MACHC;CACJ,OAAOC,EAAqBT,EAAgB;EAAE;EAAQ;EAAQ;CAAY,CAAC,GAAG;EAC1E,GAAGO;EACH;CACJ,CAAC;AACL;AAsBA,SAAgBG,EACZ,GACuB;CACvB,IAAM,EACF,WACA,WACA,gBACA,oBAAiBd,GACjB,GAAGW,MACHC;CACJ,OAAOG,EAAkBX,EAAgB;EAAE;EAAQ;EAAQ;CAAY,CAAC,GAAG;EACvE,GAAGO;EACH;CACJ,CAAC;AACL"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const e=require("../hooks/use-latest-ref.cjs"),t=require("./create-event-stream.cjs");let n=require("react");function r(r,i={}){let{enabled:a=!0,onMessage:o,...s}=i,[c,l]=(0,n.useState)(`idle`),[u,d]=(0,n.useState)(null),f=(0,n.useRef)(null),p=e.useLatestRef(o);return(0,n.useEffect)(()=>{if(!a
|
|
1
|
+
const e=require("../hooks/use-latest-ref.cjs"),t=require("./create-event-stream.cjs");let n=require("react");function r(r,i={}){let{enabled:a=!0,onMessage:o,...s}=i,[c,l]=(0,n.useState)(`idle`),[u,d]=(0,n.useState)(null),f=(0,n.useRef)(null),p=e.useLatestRef(o),m=e.useLatestRef(r),h=typeof r==`string`?r:null;return(0,n.useEffect)(()=>{if(!a||h===``){l(`idle`);return}let e=t.createEventStream(h??(()=>{let e=m.current;return typeof e==`string`?e:e()}),{...s,onStatusChange:l,onMessage:e=>{d(e),p.current?.(e)}});return f.current=e.reconnect,()=>{e.close(),f.current=null}},[h,a]),{status:c,lastMessage:u,reconnect:()=>f.current?.()}}exports.useEventStream=r;
|
|
2
2
|
//# sourceMappingURL=use-event-stream.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-event-stream.cjs","names":["useState","useRef","useLatestRef","createEventStream","require_use_latest_ref","require_create_event_stream","react","useEventStream","rest","options","status","setStatus","lastMessage","setLastMessage","reconnectRef","onMessageRef","onMessage","
|
|
1
|
+
{"version":3,"file":"use-event-stream.cjs","names":["useState","useRef","useLatestRef","createEventStream","require_use_latest_ref","require_create_event_stream","react","useEventStream","rest","options","status","setStatus","lastMessage","setLastMessage","reconnectRef","onMessageRef","onMessage","urlRef","url","fixedUrl","enabled","controller","latest","message"],"sources":["../../src/sse/use-event-stream.ts"],"sourcesContent":["import { useEffect, useRef, useState } from \"react\";\nimport { useLatestRef } from \"@/hooks/use-latest-ref\";\nimport {\n createEventStream,\n type CreateEventStreamOptions,\n type EventStreamMessage,\n type EventStreamStatus,\n type EventStreamUrl,\n} from \"./create-event-stream\";\n\nexport interface UseEventStreamOptions<T> extends Omit<\n CreateEventStreamOptions<T>,\n \"onStatusChange\"\n> {\n /** When false, the stream is not opened. Useful for \"wait for auth\". Default: true. */\n enabled?: boolean;\n}\n\nexport interface UseEventStreamResult<T> {\n status: EventStreamStatus;\n /** Last message received (excluding heartbeats). */\n lastMessage: EventStreamMessage<T> | null;\n /** Force a reconnect. */\n reconnect: () => void;\n}\n\n/**\n * React hook wrapper around {@link createEventStream}. Connection lifecycle is\n * tied to the component (and the `url`/`enabled` dependencies); the stream\n * closes on unmount.\n *\n * A string `url` reopens the stream when it changes. A function `url` does not:\n * an inline arrow is a new function every render, and keying on its identity\n * would reconnect on every render. The latest function is the one called on the\n * next connection, so call `reconnect()` when what it resolves to must change now.\n *\n * Pass `schema` to have every frame validated before it reaches `onMessage`,\n * instead of trusting the type argument: without it `data` is announced as `T`\n * on the strength of the generic alone, which is a promise about the server\n * that TypeScript cannot keep. It is read when the stream opens, so declare it\n * outside the component rather than building one inline per render.\n *\n * @example\n * useEventStream<Notification>(`${API}/notifications/stream`, {\n * enabled: !!user,\n * withCredentials: true,\n * onMessage: ({ data }) => addNotification(data),\n * });\n *\n * @example\n * // Validated: a frame that does not match never reaches `onMessage`\n * const notificationSchema = z.object({ id: z.string(), message: z.string() });\n *\n * useEventStream<Notification>(`${API}/notifications/stream`, {\n * schema: notificationSchema,\n * onValidationError: (issues) => logger.warn(\"stream drift\", { issues }),\n * onMessage: ({ data }) => addNotification(data),\n * });\n */\nexport function useEventStream<T = unknown>(\n url: EventStreamUrl,\n options: UseEventStreamOptions<T> = {},\n): UseEventStreamResult<T> {\n const { enabled = true, onMessage, ...rest } = options;\n const [status, setStatus] = useState<EventStreamStatus>(\"idle\");\n const [lastMessage, setLastMessage] = useState<EventStreamMessage<T> | null>(null);\n const reconnectRef = useRef<(() => void) | null>(null);\n\n const onMessageRef = useLatestRef(onMessage);\n const urlRef = useLatestRef(url);\n const fixedUrl = typeof url === \"string\" ? url : null;\n\n useEffect(() => {\n if (!enabled || fixedUrl === \"\") {\n setStatus(\"idle\");\n return;\n }\n\n const target: EventStreamUrl =\n fixedUrl ??\n (() => {\n const latest = urlRef.current;\n return typeof latest === \"string\" ? latest : latest();\n });\n const controller = createEventStream<T>(target, {\n ...rest,\n onStatusChange: setStatus,\n onMessage: (message) => {\n setLastMessage(message);\n onMessageRef.current?.(message);\n },\n });\n reconnectRef.current = controller.reconnect;\n\n return () => {\n controller.close();\n reconnectRef.current = null;\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [fixedUrl, enabled]);\n\n return {\n status,\n lastMessage,\n reconnect: () => reconnectRef.current?.(),\n };\n}\n"],"mappings":"A,6GA2DA,SAAgBO,EACZ,EACA,EAAoC,CAAC,EACd,CACvB,GAAM,CAAE,UAAU,GAAM,YAAW,GAAGC,GAASC,EACzC,CAACC,EAAQC,IAAA,EAAaX,EAAAA,SAAAA,CAA4B,MAAM,EACxD,CAACY,EAAaC,IAAA,EAAkBb,EAAAA,SAAAA,CAAuC,IAAI,EAC3Ec,GAAA,EAAeb,EAAAA,OAAAA,CAA4B,IAAI,EAE/Cc,EAAeb,EAAAA,aAAac,CAAS,EACrCC,EAASf,EAAAA,aAAagB,CAAG,EACzBC,EAAW,OAAOD,GAAQ,SAAWA,EAAM,KA+BjD,OA7BA,EAAAZ,EAAA,UAAA,KAAgB,CACZ,GAAI,CAACc,GAAWD,IAAa,GAAI,CAC7BR,EAAU,MAAM,EAChB,MACJ,CAQA,IAAMU,EAAalB,EAAAA,kBALfgB,QACO,CACH,IAAMG,EAASL,EAAO,QACtB,OAAO,OAAOK,GAAW,SAAWA,EAASA,EAAO,CACxD,GAC4C,CAC5C,GAAGd,EACH,eAAgBG,EAChB,UAAY,GAAY,CACpBE,EAAeU,CAAO,EACtBR,EAAa,UAAUQ,CAAO,CAClC,CACJ,CAAC,EAGD,MAFA,GAAa,QAAUF,EAAW,cAErB,CACTA,EAAW,MAAM,EACjB,EAAa,QAAU,IAC3B,CAEJ,EAAG,CAACF,EAAUC,CAAO,CAAC,EAEf,CACH,SACA,cACA,cAAiBN,EAAa,UAAU,CAC5C,CACJ,C"}
|
|
@@ -3,13 +3,16 @@ import { createEventStream as t } from "./create-event-stream.js";
|
|
|
3
3
|
import { useEffect as n, useRef as r, useState as i } from "react";
|
|
4
4
|
//#region src/sse/use-event-stream.ts
|
|
5
5
|
function a(a, o = {}) {
|
|
6
|
-
let { enabled: s = !0, onMessage: c, ...l } = o, [u, d] = i("idle"), [f, p] = i(null), m = r(null), h = e(c);
|
|
6
|
+
let { enabled: s = !0, onMessage: c, ...l } = o, [u, d] = i("idle"), [f, p] = i(null), m = r(null), h = e(c), g = e(a), _ = typeof a == "string" ? a : null;
|
|
7
7
|
return n(() => {
|
|
8
|
-
if (!s ||
|
|
8
|
+
if (!s || _ === "") {
|
|
9
9
|
d("idle");
|
|
10
10
|
return;
|
|
11
11
|
}
|
|
12
|
-
let e = t(
|
|
12
|
+
let e = t(_ ?? (() => {
|
|
13
|
+
let e = g.current;
|
|
14
|
+
return typeof e == "string" ? e : e();
|
|
15
|
+
}), {
|
|
13
16
|
...l,
|
|
14
17
|
onStatusChange: d,
|
|
15
18
|
onMessage: (e) => {
|
|
@@ -19,7 +22,7 @@ function a(a, o = {}) {
|
|
|
19
22
|
return m.current = e.reconnect, () => {
|
|
20
23
|
e.close(), m.current = null;
|
|
21
24
|
};
|
|
22
|
-
}, [
|
|
25
|
+
}, [_, s]), {
|
|
23
26
|
status: u,
|
|
24
27
|
lastMessage: f,
|
|
25
28
|
reconnect: () => m.current?.()
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-event-stream.js","names":["useEventStream","rest","options","status","setStatus","useState","lastMessage","setLastMessage","reconnectRef","useRef","onMessageRef","useLatestRef","onMessage","
|
|
1
|
+
{"version":3,"file":"use-event-stream.js","names":["useEventStream","rest","options","status","setStatus","useState","lastMessage","setLastMessage","reconnectRef","useRef","onMessageRef","useLatestRef","onMessage","urlRef","url","fixedUrl","useEffect","enabled","controller","createEventStream","latest","message"],"sources":["../../src/sse/use-event-stream.ts"],"sourcesContent":["import { useEffect, useRef, useState } from \"react\";\nimport { useLatestRef } from \"@/hooks/use-latest-ref\";\nimport {\n createEventStream,\n type CreateEventStreamOptions,\n type EventStreamMessage,\n type EventStreamStatus,\n type EventStreamUrl,\n} from \"./create-event-stream\";\n\nexport interface UseEventStreamOptions<T> extends Omit<\n CreateEventStreamOptions<T>,\n \"onStatusChange\"\n> {\n /** When false, the stream is not opened. Useful for \"wait for auth\". Default: true. */\n enabled?: boolean;\n}\n\nexport interface UseEventStreamResult<T> {\n status: EventStreamStatus;\n /** Last message received (excluding heartbeats). */\n lastMessage: EventStreamMessage<T> | null;\n /** Force a reconnect. */\n reconnect: () => void;\n}\n\n/**\n * React hook wrapper around {@link createEventStream}. Connection lifecycle is\n * tied to the component (and the `url`/`enabled` dependencies); the stream\n * closes on unmount.\n *\n * A string `url` reopens the stream when it changes. A function `url` does not:\n * an inline arrow is a new function every render, and keying on its identity\n * would reconnect on every render. The latest function is the one called on the\n * next connection, so call `reconnect()` when what it resolves to must change now.\n *\n * Pass `schema` to have every frame validated before it reaches `onMessage`,\n * instead of trusting the type argument: without it `data` is announced as `T`\n * on the strength of the generic alone, which is a promise about the server\n * that TypeScript cannot keep. It is read when the stream opens, so declare it\n * outside the component rather than building one inline per render.\n *\n * @example\n * useEventStream<Notification>(`${API}/notifications/stream`, {\n * enabled: !!user,\n * withCredentials: true,\n * onMessage: ({ data }) => addNotification(data),\n * });\n *\n * @example\n * // Validated: a frame that does not match never reaches `onMessage`\n * const notificationSchema = z.object({ id: z.string(), message: z.string() });\n *\n * useEventStream<Notification>(`${API}/notifications/stream`, {\n * schema: notificationSchema,\n * onValidationError: (issues) => logger.warn(\"stream drift\", { issues }),\n * onMessage: ({ data }) => addNotification(data),\n * });\n */\nexport function useEventStream<T = unknown>(\n url: EventStreamUrl,\n options: UseEventStreamOptions<T> = {},\n): UseEventStreamResult<T> {\n const { enabled = true, onMessage, ...rest } = options;\n const [status, setStatus] = useState<EventStreamStatus>(\"idle\");\n const [lastMessage, setLastMessage] = useState<EventStreamMessage<T> | null>(null);\n const reconnectRef = useRef<(() => void) | null>(null);\n\n const onMessageRef = useLatestRef(onMessage);\n const urlRef = useLatestRef(url);\n const fixedUrl = typeof url === \"string\" ? url : null;\n\n useEffect(() => {\n if (!enabled || fixedUrl === \"\") {\n setStatus(\"idle\");\n return;\n }\n\n const target: EventStreamUrl =\n fixedUrl ??\n (() => {\n const latest = urlRef.current;\n return typeof latest === \"string\" ? latest : latest();\n });\n const controller = createEventStream<T>(target, {\n ...rest,\n onStatusChange: setStatus,\n onMessage: (message) => {\n setLastMessage(message);\n onMessageRef.current?.(message);\n },\n });\n reconnectRef.current = controller.reconnect;\n\n return () => {\n controller.close();\n reconnectRef.current = null;\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [fixedUrl, enabled]);\n\n return {\n status,\n lastMessage,\n reconnect: () => reconnectRef.current?.(),\n };\n}\n"],"mappings":";;;;AA2DA,SAAgBA,EACZ,GACA,IAAoC,CAAC,GACd;CACvB,IAAM,EAAE,aAAU,IAAM,cAAW,GAAGC,MAASC,GACzC,CAACC,GAAQC,KAAaC,EAA4B,MAAM,GACxD,CAACC,GAAaC,KAAkBF,EAAuC,IAAI,GAC3EG,IAAeC,EAA4B,IAAI,GAE/CC,IAAeC,EAAaC,CAAS,GACrCC,IAASF,EAAaG,CAAG,GACzBC,IAAW,OAAOD,KAAQ,WAAWA,IAAM;CA+BjD,OA7BAE,QAAgB;EACZ,IAAI,CAACC,KAAWF,MAAa,IAAI;GAC7B,EAAU,MAAM;GAChB;EACJ;EAQA,IAAMG,IAAaC,EALfJ,YACO;GACH,IAAMK,IAASP,EAAO;GACtB,OAAO,OAAOO,KAAW,WAAWA,IAASA,EAAO;EACxD,IAC4C;GAC5C,GAAGnB;GACH,gBAAgBG;GAChB,YAAY,MAAY;IAEpB,AADAG,EAAec,CAAO,GACtBX,EAAa,UAAUW,CAAO;GAClC;EACJ,CAAC;EAGD,OAFA,EAAa,UAAUH,EAAW,iBAErB;GAET,AADAA,EAAW,MAAM,GACjB,EAAa,UAAU;EAC3B;CAEJ,GAAG,CAACH,GAAUE,CAAO,CAAC,GAEf;EACH;EACA;EACA,iBAAiBT,EAAa,UAAU;CAC5C;AACJ"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/* Generated by scripts/split-css.mjs — do not edit. */
|
|
2
|
+
.tempest_form_Ry1iO{width:100%}
|
|
3
|
+
.tempest_social_ZiYgW{align-items:stretch;gap:var(--tempest-space-2);flex-direction:column;display:flex}
|
|
4
|
+
.tempest_link_iisLJ{color:var(--tempest-primary);font-weight:var(--tempest-weight-semibold);border-radius:var(--tempest-radius-xs);text-decoration:none}
|
|
5
|
+
.tempest_link_iisLJ:hover{text-decoration:underline}
|
|
6
|
+
.tempest_link_iisLJ:focus-visible{outline:var(--tempest-focus-ring-width) solid var(--tempest-focus-ring-color);outline-offset:var(--tempest-focus-ring-offset)}
|
|
7
|
+
.tempest_forgot_kjNlS{font-size:var(--tempest-text-sm);align-self:flex-end}
|
|
8
|
+
.tempest_prompt_cuJKg{color:var(--tempest-text-muted);font-size:var(--tempest-text-sm);text-align:center;margin:0}
|
|
9
|
+
.tempest_hints_AYoGY{gap:var(--tempest-space-1);padding-left:var(--tempest-space-5);color:var(--tempest-text-muted);font-size:var(--tempest-text-sm);flex-direction:column;margin:0;display:flex}
|