@estiva-app/platform

中文文档
0.7.1发布于 2026/10/18MITTypeScriptES Module零依赖构建来源已验证近 30 天下载 2489

Browser lifecycle two Estiva apps share: the one live relay socket a tab should hold, its credential read per connect, the network coming back, when a tab re-reads, and the copy on the device a reload draws from.

原始使用文档
文档目录9 节
  1. Why it exists
  2. Usage
  3. Watching one channel, or a whole workspace
  4. One thing to know before relying on watchFolders
  5. Refreshing, and waiting when the relay says so
  6. A copy on the device, so a reload draws at once
  7. Loading what is opened next, without slowing a click
  8. What this deliberately does not know
  9. License

@estiva-app/platform

The browser lifecycle two Estiva apps share.

@estiva-app/protocol owns the wire — event construction, ids, signatures, and the two relay clients — and deliberately contains no fold. This package sits above it and holds the things that are about running in a tab: the one socket a tab should have, its credential read on every connect, and the network coming back.

npm install @estiva-app/platform

@estiva-app/protocol is a peer dependency.

Why it exists

createLiveRelay and createChannelSubscriptions were extracted into protocol with a single caller, and the plumbing a second app needs never moved with them. It stayed in peek-app/src/nostr/liveTopics.ts, which ADR 0002 §10 scores ❌ on constraint 1 (imports) and ❌ on constraint 2 (environment injected), and calls "the bill SHA-7 is paying".

Three behaviours, each of which fails as a healthy-looking socket that delivers nothing:

  • One instance per tab. max_subscriptions is per connection and the relay authenticates a connection once, so a connection per component mount is wrong rather than merely wasteful. React StrictMode makes it concrete: anything owned by an effect is built twice.
  • The credential read per connect, never captured. A token captured at module load survives a silent renewal as a dead credential, and a reconnect then signs its 22242 with it.
  • The network coming back. Reconnection is driven by onclose, and a socket whose peer became unreachable stays OPEN until TCP gives up. Without an online/offline signal it reports live, delivers nothing, and never retries.

Usage

import {
  browserOnlineSource,
  createLiveClientHolder,
} from '@estiva-app/platform'

// The app holds the one module-scope binding. This package holds none — that
// is the constraint-2 failure it exists to stop repeating.
const holder = createLiveClientHolder()

export function liveClient() {
  return holder.get({
    relayUrl: RELAY_URL,
    // Read fresh on every connect attempt.
    getCredential: () => {
      const token = validToken()
      return token && { accessToken: token.accessToken, pubkey: token.pubkey }
    },
    sign: signViaEstivaId,
    online: browserOnlineSource(window),
    subscriptionPrefix: 'my-app-',
    // Told what happened, never asked what to do — your freshness model stays
    // yours.
    observe: { onState: recordRelayState, onOnline: recordOnline },
  })
}

Watching one channel, or a whole workspace

Two paths, and the difference is a cost rather than a preference.

// Per channel, refcounted: one REQ each, every consumer sees every event.
// What a message projection wants.
client.subscriptions.subscribe(channelUuid, (event) => project(event))

// A whole workspace in one REQ per 128 Folders. You are told which Folder
// changed, never what changed.
client.watchFolders(folderIds, (folder) => refresh(folder), { kinds: [9, 1111] })

REQ is a client→server frame, so it is metered on the relay's LimitType::WsEvents. Measured against production: 50 unpaced REQs are accepted and the 51st is refused rate-limited: quota exceeded. So a 34-Folder workspace on the per-channel path spends 34 frames on every connect and every reconnect; watchFolders spends one.

Holding subscriptions, by contrast, costs nothing measurable — the probe round trip was 44.2 ms whether 1, 34 or 100 were open.

One thing to know before relying on watchFolders

The wide filter is measured, not assumed: one filter carrying 34 #h delivered 34 of 34 live against wss://estiva.estiva.app.

It is also a property of the deployed relay branch. nfb-demo-kinds carries a plural extract_channel_ids_from_filters that returns the whole channel set; on origin/main's singular version the same filter registers as a global subscription, and fan_out_scoped branches on the event's channel_id and never consults it for anything carrying an h. That build delivers 0 of 34, with no error at all — EOSE, live, silence.

So anything depending on this should be able to answer "did an event I did not publish actually arrive?", because nothing else distinguishes the two cases. Pass kinds while you are at it: a kindless wide filter was refused outright on that other build with restricted: p-gated events require #p matching your pubkey.

Refreshing, and waiting when the relay says so

Buzz meters POST /query at 300 a minute per pubkey, so every tab and app signed in as one person shares one allowance. Build one budget and one scheduler per tab:

import { createRefreshScheduler, createRelayBudget } from '@estiva-app/platform'

// The transport writes it; the cadence reads it.
export const relayBudget = createRelayBudget({
  // Optional: the app's other tabs learn a pause from this one.
  channel: new BroadcastChannel('my-app-relay-budget'),
})
export const refreshScheduler = createRefreshScheduler({ document, window, budget: relayBudget })

// In the transport, on every answer:
if (status === 429 || isRateLimited(body)) relayBudget.noteRateLimited(body)

// In a hook: focus, becoming visible, and every 30 s while visible. A tab
// switch raises two events and costs one read; nothing runs while paused.
useEffect(() => refreshScheduler.subscribe(reload, { interval: !live }), [live])

// Before retrying a refused read: every waiter goes after the longest pause
// anybody learned, not its own shorter one.
await relayBudget.whenClear()

Nothing here gates a read. A read somebody asked for by clicking still goes out at once; only the cadence backs off.

A copy on the device, so a reload draws at once

createEventSnapshot keeps what the relay told one person — signed events, never a folded view — and answers the same filters from it on the next load. Fold its answer as you fold the relay's, then send the relay the read as before and fold again: the relay's answer replaces the copy's.

import { createEventSnapshot, indexedDbSnapshotStorage, snapshotScope } from '@estiva-app/platform'

const storage = indexedDbSnapshotStorage(indexedDB, 'my-app-copy')
const copy = createEventSnapshot({
  storage,
  scope: snapshotScope(relayUrl, pubkey),
  version: 1, // bump when what you fold from it changes meaning
})

// In the transport, on every answer the relay gives:
copy.absorb(filters, events)

// Before the relay answers: null means the copy cannot answer this read whole.
await copy.ready
const drawnFirst = copy.answer(filters)

// Signing out: every copy goes, and one still open — in this tab or another —
// can never write the person's data back (the storage counts sign-outs).
copy.discard()
await storage.clear()

It answers a read it has seen answered, and a pooled list it has not, when reads the same in every other field (limit aside) were answered whole and together named every value in it — '#e': [the newest messages] after the newest changed. since and until are the clock and not part of the question.

Every answer is also a correction: an event the copy holds that matches a filter the relay just answered, and is not in that answer, is dropped — a deletion, or a Folder the person lost. A kind:5 drops what its author signed. keepPlaintext keeps the plaintext of the person's own blob beside the event, so an unchanged blob needs no decrypt on the next load. Never publish over something drawn from the copy: it may be older than the relay.

Loading what is opened next, without slowing a click

createPacer holds a tab's socket reads to the relay's meter (buzz: 50 REQs per 5 s per pubkey), in two lanes. A click read — what the person asked for — goes newest first, and may spill to HTTP after waiting. An ahead read takes only what a window leaves over: never while a click waits, never past perWindow - reserve, over HTTP only when its own spill allows — the app gives it a smaller HTTP allowance than a click's. promote() moves the ahead reads still waiting into the click lane, for the moment the person acts: an app's caches hand a click the read already out for the same thing. createInFlight keeps the same split for REQs on the wire.

loadAhead runs an app's list of steps in order, a few at a time, once; the first that throws stops it, and what loaded stays.

import { createInFlight, createPacer, loadAhead } from '@estiva-app/platform'

const pacer = createPacer({ perWindow: 40, windowMs: 5_000, reserve: 15 })
const wire = createInFlight(4) // two of them at most for `ahead`

// In the socket reader:
await pacer.take(reqs, { lane, spill })
const release = await wire.acquire(lane)

// Once the first page has drawn:
const loader = loadAhead([
  { label: 'Topics', run: () => readTopicsAhead() },
  { label: 'Open work 1', run: () => readTopicAhead(first) },
])
addEventListener('pointerdown', () => pacer.promote(), true)
const { loaded, stoppedBy } = await loader.done

What a step reads and where it keeps it are the app's: a step that reads into the same cache a click reads from is what makes the click draw at once.

What this deliberately does not know

Where anything is stored, beyond the copy's storage you hand it. Peek's version took a LiveProjectionApi, and extracting that as-is would have baked a Convex assumption into a package two apps depend on. This delivers frames and connection state and nothing else, so it bets on neither outcome of "do we need Convex": if Convex stays nothing here changes, and if it goes this is already right.

Your freshness states and their copy are product voice and stay in your app. observe is how they get what they need.

License

MIT

版本与发布记录

@estiva-app/platform 共发布 10 个版本,首次发布于 2026/09/09;过去 12 个月发布了 10 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.7.1latest2026/10/1827 天前
0.7.02026/10/1530 天前
0.6.12026/10/1430 天前
0.6.02026/10/141 个月前
0.5.02026/10/131 个月前
0.4.02026/09/291 个月前

关于 @estiva-app/platform 的常见问题

@estiva-app/platform 是什么?

Browser lifecycle two Estiva apps share: the one live relay socket a tab should hold, its credential read per connect, the network coming back, when a tab re-reads, and the copy on the device a reload draws from.

如何安装 @estiva-app/platform?

在项目目录运行 npm install @estiva-app/platform,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:@estiva-app/protocol(>=0.15.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@estiva-app/platform 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@estiva-app/platform 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@estiva-app/platform 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=20。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@estiva-app/platform 可以商用吗?

@estiva-app/platform 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@estiva-app/platform 的下载量有多少?

根据 npm 官方下载统计,@estiva-app/platform 在 2026/10/14 – 2026/11/12 这 30 天内被下载 2,489 次,日均约 83 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@estiva-app/platform 还在维护吗?

最新版本 0.7.1 发布于 2026/10/18(27 天前);至今共发布 10 个版本,其中过去 12 个月发布了 10 个;首次发布于 2026/09/09。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。