@estiva-app/interop 0.1.2 → 0.3.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/src/projection.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  * anything: it becomes an integration written against one app, which is the
13
13
  * thing the whole exercise argues against.
14
14
  */
15
- import { encodeNaddr, pointerToAddress, referenceToPointer, type AddressPointer } from '@estiva-app/protocol'
15
+ import { decodeNevent, encodeNaddr, encodeNevent, pointerToAddress, referenceToPointer, type AddressPointer, type EventPointer } from '@estiva-app/protocol'
16
16
  import { parseProfile, type Profile, type SignedEvent } from '@estiva-app/protocol'
17
17
 
18
18
  /** Query the relay. Returns matching events; shape mirrors the HTTP bridge. */
@@ -311,6 +311,19 @@ function resolveActions(
311
311
 
312
312
  const tagValue = (e: SignedEvent, name: string) => e.tags.find((t) => t[0] === name)?.[1]
313
313
 
314
+ /**
315
+ * Does the event carry this tag with this value — on **any** of them?
316
+ *
317
+ * `tagValue` reads the first tag with a given name, which is the wrong test for
318
+ * deciding whether an event would have matched a filter: a relay's `#a` matches
319
+ * if *any* `a` tag equals the wanted value, and an event may legitimately carry
320
+ * several. Used where a filter's own predicate is re-applied to a merged
321
+ * response (SHI-13), so that "did this come back because of that filter?" is
322
+ * answered the way the relay answered it.
323
+ */
324
+ const hasTagValue = (e: SignedEvent, name: string, value: string) =>
325
+ e.tags.some((t) => t[0] === name && t[1] === value)
326
+
314
327
  /** A manifest event's `content`, or null when it is not parseable JSON. */
315
328
  function parseManifest(event: SignedEvent): Manifest | null {
316
329
  try {
@@ -358,16 +371,100 @@ function webTemplate(event: SignedEvent, entity: string): string | undefined {
358
371
  return undefined
359
372
  }
360
373
 
361
- export async function resolveManifest(
362
- pointer: AddressPointer,
363
- query: QueryFn,
364
- ): Promise<{
374
+ /** What {@link resolveManifest} answers with. */
375
+ export interface ResolvedManifest {
365
376
  manifest: Manifest
366
377
  address: string
367
378
  viaRecommendation: boolean
368
379
  /** NIP-89 `web` template, `<bech32>` not yet substituted. */
369
380
  webTemplate?: string
370
- } | null> {
381
+ }
382
+
383
+ /**
384
+ * A memo for the half of a resolve that does not change between refreshes.
385
+ *
386
+ * Resolving one reference costs four round trips, and **two of them are NIP-89
387
+ * discovery** — the author's `kind:31989` recommendation, then the `kind:31990`
388
+ * manifest itself. A consumer that re-resolves on a timer pays for both every
389
+ * time, and they answer the same thing until an app republishes its manifest.
390
+ * Measured on Ship, where a reference widget re-resolves on the poll: 4
391
+ * requests per reference per tick, identical on the second resolve, against a
392
+ * relay that meters reads at 300 a minute.
393
+ *
394
+ * **Owned by the caller, not this module.** A module-level cache would be
395
+ * invisible global state shared by every consumer in the process, impossible to
396
+ * scope to a screen and awkward to reset in a test. A caller that wants no
397
+ * caching passes nothing and gets exactly the old behaviour.
398
+ *
399
+ * Deliberately only the manifest. The object, its changes, its comments and its
400
+ * children are the parts a refresh exists to notice, and caching those is how
401
+ * a live widget becomes a screenshot.
402
+ */
403
+ export interface ProjectionCache {
404
+ /** Forget everything. Worth calling after publishing a manifest. */
405
+ clear(): void
406
+ /** @internal */
407
+ lookup(key: string, now: number): { value: ResolvedManifest | null } | undefined
408
+ /** @internal */
409
+ remember(key: string, value: ResolvedManifest | null, now: number): void
410
+ }
411
+
412
+ /**
413
+ * How long a manifest may be believed without asking again.
414
+ *
415
+ * Five minutes is a compromise with one real cost: republish a manifest and
416
+ * consumers keep drawing the old projection for up to that long. That is
417
+ * recoverable and self-correcting, where the alternative — asking twice per
418
+ * reference per tick, for ever — is neither.
419
+ */
420
+ export const MANIFEST_TTL_MS = 5 * 60_000
421
+
422
+ export function createProjectionCache(ttlMs: number = MANIFEST_TTL_MS): ProjectionCache {
423
+ const entries = new Map<string, { at: number; value: ResolvedManifest | null }>()
424
+ return {
425
+ clear: () => entries.clear(),
426
+ lookup(key, now) {
427
+ const found = entries.get(key)
428
+ if (!found) return undefined
429
+ // `>=`, not `>`: a TTL of 0 must mean "never believe it", and an entry
430
+ // exactly at the boundary has expired rather than being on its last tick.
431
+ if (now - found.at >= ttlMs) {
432
+ entries.delete(key)
433
+ return undefined
434
+ }
435
+ return { value: found.value }
436
+ },
437
+ remember(key, value, now) {
438
+ entries.set(key, { at: now, value })
439
+ },
440
+ }
441
+ }
442
+
443
+ /**
444
+ * A negative answer is cached too.
445
+ *
446
+ * "No app claims this kind" costs the same two round trips as a hit and is just
447
+ * as stable. Caching only successes would leave the expensive case — a
448
+ * reference nothing can draw — paying full price on every tick for ever.
449
+ */
450
+ export async function resolveManifest(
451
+ pointer: AddressPointer,
452
+ query: QueryFn,
453
+ cache?: ProjectionCache,
454
+ ): Promise<ResolvedManifest | null> {
455
+ const key = `${pointer.kind}:${pointer.pubkey}`
456
+ const now = Date.now()
457
+ const memo = cache?.lookup(key, now)
458
+ if (memo) return memo.value
459
+ const answer = await resolveManifestUncached(pointer, query)
460
+ cache?.remember(key, answer, now)
461
+ return answer
462
+ }
463
+
464
+ async function resolveManifestUncached(
465
+ pointer: AddressPointer,
466
+ query: QueryFn,
467
+ ): Promise<ResolvedManifest | null> {
371
468
  const parse = parseManifest
372
469
  const addressOf = (event: SignedEvent) =>
373
470
  `${event.kind}:${event.pubkey}:${tagValue(event, 'd') ?? ''}`
@@ -901,6 +998,87 @@ function buildObject(args: {
901
998
  * unresolvable reference in a chat message should degrade to plain text, not
902
999
  * break the message around it.
903
1000
  */
1001
+ /**
1002
+ * Resolve one event by id — `nevent1…`, or a bare 64-hex id.
1003
+ *
1004
+ * The counterpart to `resolveForeignObject`, and the reason it has to exist:
1005
+ * **not every object has an address.** A `kind:9` message carries no `d`, so
1006
+ * `(kind, pubkey, d)` cannot be built for it and every resolver keyed on an
1007
+ * address is blind to it. Measured on production during PRO-6; PRO-11 is this.
1008
+ *
1009
+ * ## What it does *not* do, and why the function is short
1010
+ *
1011
+ * A regular event is immutable and has no folded state, so there is no `records`
1012
+ * rule to apply, no change events to fetch, and no "current value" that differs
1013
+ * from what is on the event. It also cannot be the target of an `a` tag, so it
1014
+ * has no comments addressed to it and **no actions** — a change names its target
1015
+ * by address, and there is nothing here to name. That absence is the model being
1016
+ * honest rather than a gap to fill later.
1017
+ *
1018
+ * ## Two round trips, and the order depends on the reference
1019
+ *
1020
+ * A manifest is found by kind. An `nevent` *may* carry its kind, and when it
1021
+ * does the manifest and the event can be fetched together. When it does not —
1022
+ * a bare id, which is what a pasted `e` tag gives you — the event has to be
1023
+ * read first to learn what kind it is. Both paths are supported because both
1024
+ * arrive in practice, and a resolver that required the richer form would refuse
1025
+ * references other clients legitimately produce.
1026
+ */
1027
+ export async function resolveForeignEvent(
1028
+ reference: string,
1029
+ query: QueryFn,
1030
+ /** Defaults to asking the relay. The browser passes a cached lookup. */
1031
+ lookupPeople?: PeopleFn,
1032
+ /** See {@link ProjectionCache}. Omitting it is exactly the old behaviour. */
1033
+ cache?: ProjectionCache,
1034
+ ): Promise<ForeignObject | null> {
1035
+ let pointer: EventPointer
1036
+ try {
1037
+ pointer = /^[0-9a-f]{64}$/i.test(reference.replace(/^nostr:/i, ''))
1038
+ ? { id: reference.replace(/^nostr:/i, '').toLowerCase(), relays: [] }
1039
+ : decodeNevent(reference)
1040
+ } catch {
1041
+ return null
1042
+ }
1043
+
1044
+ const [root] = await query([{ ids: [pointer.id], limit: 1 }])
1045
+ if (!root) {
1046
+ // Nothing to draw and nothing to say about it: unlike an addressable
1047
+ // object, there is no manifest resolved yet that could name the app or
1048
+ // offer a way in. `unreachable` needs a projection to be a useful state.
1049
+ return null
1050
+ }
1051
+
1052
+ // Whoever signed it is the app's own author, which is what a `#k` lookup
1053
+ // needs when no recommendation exists. The pointer's `author` is a hint and
1054
+ // may disagree with the event; the event wins, because it is the thing.
1055
+ const resolved = await resolveManifest(
1056
+ { kind: root.kind, pubkey: root.pubkey, identifier: '', relays: pointer.relays },
1057
+ query,
1058
+ cache,
1059
+ )
1060
+ if (!resolved) return null
1061
+
1062
+ const projection = resolved.manifest.projections?.[String(root.kind)]
1063
+ if (!projection) return null
1064
+
1065
+ const object = buildChildObject({
1066
+ root,
1067
+ manifest: resolved.manifest,
1068
+ projection,
1069
+ records: foldRuleOf(resolved.manifest),
1070
+ webTemplate: resolved.webTemplate,
1071
+ viaRecommendation: resolved.viaRecommendation,
1072
+ // The reference as given, so "open this in the app that owns it" points at
1073
+ // the event rather than at nothing. A bare id is upgraded to an `nevent`
1074
+ // carrying what we now know, which is more than the caller had.
1075
+ nevent: encodeNevent({ id: root.id, relays: pointer.relays, pubkey: root.pubkey, kind: root.kind }),
1076
+ })
1077
+
1078
+ const people = await (lookupPeople ?? peopleViaRelay(query))(pubkeysIn(object))
1079
+ return { ...object, people }
1080
+ }
1081
+
904
1082
  export async function resolveForeignObject(
905
1083
  naddr: string,
906
1084
  query: QueryFn,
@@ -912,6 +1090,11 @@ export async function resolveForeignObject(
912
1090
  * recursing forever. See `MAX_LIST_DEPTH`.
913
1091
  */
914
1092
  depth = 0,
1093
+ /**
1094
+ * Optional memo for the NIP-89 discovery half. See {@link ProjectionCache} —
1095
+ * omitting it is exactly the old behaviour.
1096
+ */
1097
+ cache?: ProjectionCache,
915
1098
  ): Promise<ForeignObject | null> {
916
1099
  let pointer: AddressPointer
917
1100
  try {
@@ -923,7 +1106,7 @@ export async function resolveForeignObject(
923
1106
  }
924
1107
  const address = pointerToAddress(pointer)
925
1108
 
926
- const resolved = await resolveManifest(pointer, query)
1109
+ const resolved = await resolveManifest(pointer, query, cache)
927
1110
  if (!resolved) return null
928
1111
  const { manifest, viaRecommendation } = resolved
929
1112
  // Substituted here rather than in the component: `<bech32>` is a NIP-89
@@ -935,15 +1118,29 @@ export async function resolveForeignObject(
935
1118
  if (!projection) return null
936
1119
  const records = foldRuleOf(manifest)
937
1120
 
938
- // One round trip for the root, its changes and its comments. `#a` on both the
939
- // change and the comment kind, because both point at the object by *address*
940
- // rather than by event id — which is what makes them survive the author
941
- // replacing the root event.
1121
+ /*
1122
+ One round trip for the root, its changes, its comments **and its children**.
1123
+
1124
+ `#a` on both the change and the comment kind, because both point at the
1125
+ object by *address* rather than by event id — which is what makes them
1126
+ survive the author replacing the root event.
1127
+
1128
+ The children used to be a second round trip, issued after the root came
1129
+ back. They never needed to be: the child filter is built from the manifest
1130
+ and the *pointer*, and an addressable event's `d` is `pointer.identifier` by
1131
+ definition — it is what we just queried by. So once the manifest is known,
1132
+ nothing about the child filter depends on the root's contents (SHI-13).
1133
+
1134
+ That is the difference between two requests per reference per refresh and
1135
+ one, and with the manifest memoised it is the whole cost of a tick.
1136
+ */
1137
+ const childFilter = childFilterFor({ projection, manifest, pointer, depth })
942
1138
  const events = await query([
943
1139
  { kinds: [pointer.kind], authors: [pointer.pubkey], '#d': [pointer.identifier], limit: 1 },
944
1140
  // Only when the app actually declares a change kind — see `foldRuleOf`.
945
1141
  ...(manifest.records ? [{ kinds: [manifest.records.changeKind], '#a': [address], limit: 500 }] : []),
946
1142
  { kinds: commentKinds, '#a': [address], limit: 200 },
1143
+ ...(childFilter ? [childFilter.filter] : []),
947
1144
  ])
948
1145
 
949
1146
  const root = events.find(
@@ -981,8 +1178,19 @@ export async function resolveForeignObject(
981
1178
  records,
982
1179
  )
983
1180
 
1181
+ /*
1182
+ Matched on the comment filter's own criteria — the kind **and** the `a` tag.
1183
+
1184
+ Kind alone was safe while this was its own query: the relay only returned
1185
+ what the comment filter asked for. Now that the children ride in the same
1186
+ request (SHI-13), a child sharing the comment kind would arrive here too and
1187
+ be counted as a comment on its own parent. Peek is exactly that shape: its
1188
+ Topic declares `kind:9` messages as children and `kind:9` as its comment
1189
+ kind, so every message in the Folder would have become a comment on the
1190
+ Topic — a widget silently showing a conversation twice.
1191
+ */
984
1192
  const comments = events
985
- .filter((e) => commentKinds.includes(e.kind))
1193
+ .filter((e) => commentKinds.includes(e.kind) && hasTagValue(e, 'a', address))
986
1194
  .sort(byOrder)
987
1195
  .map((e) => ({ id: e.id, author: e.pubkey, body: e.content, createdAt: e.created_at }))
988
1196
 
@@ -1011,12 +1219,10 @@ export async function resolveForeignObject(
1011
1219
  A child may be a regular event with no address of its own (Peek's messages
1012
1220
  are), so this builds by event rather than by pointer.
1013
1221
  */
1014
- const children = await resolveChildren({
1015
- projection,
1016
- root,
1222
+ const children = childrenFrom({
1223
+ events,
1224
+ childFilter,
1017
1225
  manifest,
1018
- query,
1019
- depth,
1020
1226
  webTemplate: resolved.webTemplate,
1021
1227
  viaRecommendation,
1022
1228
  })
@@ -1035,48 +1241,82 @@ export async function resolveForeignObject(
1035
1241
  }
1036
1242
 
1037
1243
  /**
1038
- * Resolve a projection's `list` slot into child objects.
1244
+ * The filter for a projection's `list` slot, or nothing.
1245
+ *
1246
+ * Split out from fetching so it can be built **before** the root event is in
1247
+ * hand and merged into the object's own round trip (SHI-13). Everything it
1248
+ * needs is in the manifest and the pointer: an addressable event's `d` *is*
1249
+ * `pointer.identifier`, since that is what the root filter matches on, so
1250
+ * reading it back off the root taught us nothing we did not already know.
1039
1251
  *
1040
- * Returns undefined when no `list` is declared — distinct from `[]`, which
1041
- * means "declared, and nothing matched". A renderer needs to tell "this holds
1042
- * nothing" from "this holds no list".
1252
+ * Returns undefined when no `list` is declared — distinct from a declared list
1253
+ * that matches nothing, which a renderer must be able to tell apart. The two
1254
+ * other "declared but not renderable" cases are folded in here too, and both
1255
+ * come back as `[]` from {@link childrenFrom}: a depth budget already spent,
1256
+ * and a child kind the manifest never says how to draw.
1043
1257
  */
1044
- async function resolveChildren(args: {
1258
+ function childFilterFor(args: {
1045
1259
  projection: { widget: string | string[]; slots: Record<string, SlotSpec | SlotSpec[]> }
1046
- root: SignedEvent
1047
1260
  manifest: Manifest
1048
- query: QueryFn
1261
+ pointer: AddressPointer
1049
1262
  depth: number
1050
- webTemplate?: string
1051
- viaRecommendation: boolean
1052
- }): Promise<ForeignObject[] | undefined> {
1053
- const { projection, root, manifest, query, depth, webTemplate, viaRecommendation } = args
1263
+ }): { filter: Record<string, unknown>; kind: number; via: string; parent: string } | null | undefined {
1264
+ const { projection, manifest, pointer, depth } = args
1054
1265
  const spec = projection.slots.list
1055
1266
  const children = !Array.isArray(spec) ? spec?.children : undefined
1056
1267
  if (!children) return undefined
1057
1268
  // The consumer's budget, not the manifest's — see MAX_LIST_DEPTH.
1058
- if (depth >= MAX_LIST_DEPTH) return []
1059
-
1060
- const childProjection = manifest.projections?.[String(children.kind)]
1269
+ if (depth >= MAX_LIST_DEPTH) return null
1061
1270
  // A declared list whose child kind has no projection is not renderable, and
1062
1271
  // an empty list is the honest answer: the objects exist, this app has not
1063
1272
  // said how to draw them.
1064
- if (!childProjection) return []
1273
+ if (!manifest.projections?.[String(children.kind)]) return null
1065
1274
 
1066
- const identifier = tagValue(root, 'd') ?? ''
1067
1275
  const parent =
1068
1276
  children.match === 'identifier'
1069
- ? identifier
1070
- : pointerToAddress({ kind: root.kind, pubkey: root.pubkey, identifier, relays: [] })
1277
+ ? pointer.identifier
1278
+ : pointerToAddress({ ...pointer, relays: [] })
1071
1279
 
1072
- const found = await query([
1073
- { kinds: [children.kind], [`#${children.via}`]: [parent], limit: children.limit ?? 100 },
1074
- ])
1280
+ return {
1281
+ filter: { kinds: [children.kind], [`#${children.via}`]: [parent], limit: children.limit ?? 100 },
1282
+ kind: children.kind,
1283
+ via: children.via,
1284
+ parent,
1285
+ }
1286
+ }
1075
1287
 
1288
+ /**
1289
+ * The child objects, picked back out of the merged result set.
1290
+ *
1291
+ * **Matched on the filter's own criteria, never on kind alone.** Peek's Topic
1292
+ * declares `kind:9` messages as its children and `kind:9` as its comment kind,
1293
+ * so a merged response carries both under one number and only the tag tells
1294
+ * them apart. An event can honestly be both — Ship writes a `kind:9` with an
1295
+ * `a` naming the object *and* an `h` naming the Folder — and it appeared in
1296
+ * both result sets when these were two queries. Re-applying each filter's own
1297
+ * predicate reproduces that, rather than making them compete.
1298
+ */
1299
+ function childrenFrom(args: {
1300
+ events: SignedEvent[]
1301
+ childFilter: ReturnType<typeof childFilterFor>
1302
+ manifest: Manifest
1303
+ webTemplate?: string
1304
+ viaRecommendation: boolean
1305
+ }): ForeignObject[] | undefined {
1306
+ const { events, childFilter, manifest, webTemplate, viaRecommendation } = args
1307
+ if (childFilter === undefined) return undefined
1308
+ if (childFilter === null) return []
1309
+
1310
+ const childProjection = manifest.projections?.[String(childFilter.kind)]
1311
+ if (!childProjection) return []
1076
1312
  const records = foldRuleOf(manifest)
1077
- return found.sort(byOrder).map((event) =>
1078
- buildChildObject({ root: event, manifest, projection: childProjection, records, webTemplate, viaRecommendation }),
1079
- )
1313
+
1314
+ return events
1315
+ .filter((e) => e.kind === childFilter.kind && hasTagValue(e, childFilter.via, childFilter.parent))
1316
+ .sort(byOrder)
1317
+ .map((event) =>
1318
+ buildChildObject({ root: event, manifest, projection: childProjection, records, webTemplate, viaRecommendation }),
1319
+ )
1080
1320
  }
1081
1321
 
1082
1322
  /**
@@ -1098,8 +1338,17 @@ function buildChildObject(args: {
1098
1338
  records: RecordsRule
1099
1339
  webTemplate?: string
1100
1340
  viaRecommendation: boolean
1341
+ /**
1342
+ * The `nevent1…` for a non-addressable object, when the caller has one.
1343
+ *
1344
+ * Only `resolveForeignEvent` passes it: a child reached through a `list` slot
1345
+ * is drawn inside its parent and has nowhere of its own to open, while an
1346
+ * event somebody referenced directly does. Without it a message has no
1347
+ * `openUrl` at all, which is PRO-11's "its web link opens that message".
1348
+ */
1349
+ nevent?: string
1101
1350
  }): ForeignObject {
1102
- const { root, manifest, projection, webTemplate, viaRecommendation } = args
1351
+ const { root, manifest, projection, webTemplate, viaRecommendation, nevent } = args
1103
1352
  const identifier = tagValue(root, 'd')
1104
1353
  const addressable = identifier !== undefined
1105
1354
  const pointer: AddressPointer = {
@@ -1126,7 +1375,16 @@ function buildChildObject(args: {
1126
1375
  meta,
1127
1376
  comments: [],
1128
1377
  folder: tagValue(root, 'h'),
1129
- openUrl: naddr ? webTemplate?.replace('<bech32>', naddr) : undefined,
1378
+ /*
1379
+ `<bech32>` is whichever form this object actually has.
1380
+
1381
+ NIP-89's template says nothing about which NIP-19 entity it will be handed
1382
+ — Ship's declares `naddr` in its own tag because every Ship object is
1383
+ addressable, and a template for an app with non-addressable objects is
1384
+ handed an `nevent`. Substituting the one the object *has* is what makes a
1385
+ single template serve both, and what stops a message linking to nothing.
1386
+ */
1387
+ openUrl: (naddr ?? nevent) ? webTemplate?.replace('<bech32>', (naddr ?? nevent) as string) : undefined,
1130
1388
  // See the note above: nothing can be declared to act on a regular event.
1131
1389
  actions: [],
1132
1390
  viaRecommendation,
@@ -1376,6 +1634,8 @@ export async function resolveFolderProject(
1376
1634
  * own `list` is a budget check rather than a thing nobody remembered.
1377
1635
  */
1378
1636
  depth = 0,
1637
+ /** See {@link ProjectionCache}. Omitting it is exactly the old behaviour. */
1638
+ cache?: ProjectionCache,
1379
1639
  ): Promise<FolderProject | null> {
1380
1640
  // The render loop this forbids is not hypothetical: two folders naming each
1381
1641
  // other's projects resolve forever, and the manifest declaring them is
@@ -1588,7 +1848,7 @@ export async function resolveFolderProject(
1588
1848
 
1589
1849
  // 3. Now the authoritative manifest — the object's author gets to say which
1590
1850
  // app renders their project (kind:31989), same as for an inline reference.
1591
- const resolved = await resolveManifest(pointer, query)
1851
+ const resolved = await resolveManifest(pointer, query, cache)
1592
1852
  if (!resolved) {
1593
1853
  return null
1594
1854
  }