@steve02081504/fount-p2p 0.0.11 → 0.0.12

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.
Files changed (57) hide show
  1. package/README.md +1 -1
  2. package/core/bytes_codec.mjs +65 -10
  3. package/core/tcp_port.mjs +1 -1
  4. package/crypto/checkpoint_sign.mjs +2 -2
  5. package/dag/canonicalize_row.mjs +3 -3
  6. package/dag/index.mjs +2 -3
  7. package/discovery/bt/index.mjs +5 -5
  8. package/discovery/bt/runtime.mjs +3 -3
  9. package/discovery/mdns.mjs +7 -1
  10. package/discovery/nostr.mjs +53 -99
  11. package/federation/chunk_fetch_pending.mjs +4 -5
  12. package/federation/dag_order_cache.mjs +1 -1
  13. package/federation/entity_key_chain.mjs +3 -4
  14. package/federation/topo_order_memo.mjs +11 -4
  15. package/files/chunk_fetch.mjs +2 -2
  16. package/files/evfs.mjs +2 -2
  17. package/link/channel_mux.mjs +10 -10
  18. package/link/frame.mjs +76 -124
  19. package/link/handshake.mjs +5 -5
  20. package/link/pipe.mjs +49 -75
  21. package/link/providers/ble_gatt.mjs +14 -26
  22. package/link/providers/lan_tcp.mjs +17 -32
  23. package/link/providers/link_id_pipe.mjs +46 -0
  24. package/link/providers/webrtc.mjs +42 -51
  25. package/link/rtc.mjs +21 -9
  26. package/mailbox/deliver_or_store.mjs +1 -1
  27. package/node/identity.mjs +4 -4
  28. package/node/network.mjs +9 -9
  29. package/overlay/index.mjs +13 -13
  30. package/package.json +1 -1
  31. package/rooms/scoped_link.mjs +19 -35
  32. package/schemas/discovery.mjs +1 -2
  33. package/schemas/federation_pull.mjs +2 -2
  34. package/schemas/part_query.mjs +1 -1
  35. package/schemas/remote_event.mjs +1 -1
  36. package/transport/advert_ingest.mjs +20 -0
  37. package/transport/group_link_set.mjs +22 -41
  38. package/transport/ice_servers.mjs +2 -2
  39. package/transport/link_registry.mjs +113 -105
  40. package/transport/offer_answer.mjs +6 -2
  41. package/transport/peer_pool.mjs +53 -64
  42. package/transport/remote_user_room.mjs +13 -15
  43. package/transport/rtc_connection_budget.mjs +18 -4
  44. package/transport/runtime_bootstrap.mjs +8 -2
  45. package/transport/signal_crypto.mjs +25 -3
  46. package/transport/user_room.mjs +22 -15
  47. package/trust_graph/send.mjs +1 -1
  48. package/utils/emit_safe.mjs +11 -0
  49. package/utils/shuffle.mjs +13 -0
  50. package/utils/ttl_map.mjs +29 -4
  51. package/wire/ingress.mjs +1 -1
  52. package/wire/part_ingress.mjs +6 -8
  53. package/wire/part_invoke.mjs +4 -4
  54. package/wire/part_query.mjs +2 -3
  55. package/wire/part_query.tunables.json +6 -1
  56. package/wire/part_query_cache.mjs +1 -1
  57. package/wire/volatile_signature.mjs +1 -1
@@ -1,12 +1,11 @@
1
- import { noteAdvertPeerHints } from '../discovery/advert_peer_hints.mjs'
2
1
  import { advertiseTopic, subscribeTopic } from '../discovery/index.mjs'
3
- import { verifySignedAdvert } from '../link/handshake.mjs'
2
+ import { ingestSignedAdvert } from '../transport/advert_ingest.mjs'
4
3
  import { getLinkRegistry } from '../transport/link_registry.mjs'
5
4
  import {
6
- decryptSignalPacket,
7
5
  encryptSignalPacket,
8
6
  groupRendezvousTopic,
9
7
  } from '../transport/signal_crypto.mjs'
8
+ import { emitSafe } from '../utils/emit_safe.mjs'
10
9
 
11
10
  /**
12
11
  * 在指定 scope 与 roomSecret 下创建 link 层房间(discovery + registry 转发)。
@@ -18,9 +17,9 @@ import {
18
17
  */
19
18
  export function createScopedLinkRoom(options) {
20
19
  const registry = getLinkRegistry()
21
- const scope = String(options.scope)
20
+ const { scope } = options
22
21
  const topic = groupRendezvousTopic(options.roomSecret)
23
- const allowNode = typeof options.allowNode === 'function' ? options.allowNode : () => true
22
+ const allowNode = options.allowNode ?? (() => true)
24
23
  /** @type {Set<string>} */
25
24
  const discoveredPeers = new Set()
26
25
  /** @type {Set<string>} */
@@ -41,16 +40,6 @@ export function createScopedLinkRoom(options) {
41
40
  return [...discoveredPeers].filter(nodeHash => allowNode(nodeHash) && registry.getLink(nodeHash))
42
41
  }
43
42
 
44
- /**
45
- * @param {Set<(peerId: string) => void>} listeners 回调集合
46
- * @param {string} peerId 节点 hash
47
- * @returns {void}
48
- */
49
- function emit(listeners, peerId) {
50
- for (const listener of listeners)
51
- try { listener(peerId) } catch { /* ignore */ }
52
- }
53
-
54
43
  /**
55
44
  * @param {string} peerId 节点 hash
56
45
  * @returns {void}
@@ -58,7 +47,7 @@ export function createScopedLinkRoom(options) {
58
47
  function notePeerJoin(peerId) {
59
48
  if (!peerId || announcedPeers.has(peerId)) return
60
49
  announcedPeers.add(peerId)
61
- emit(joinListeners, peerId)
50
+ emitSafe(joinListeners, peerId)
62
51
  }
63
52
 
64
53
  /**
@@ -68,7 +57,7 @@ export function createScopedLinkRoom(options) {
68
57
  function notePeerLeave(peerId) {
69
58
  if (!peerId || !announcedPeers.has(peerId)) return
70
59
  announcedPeers.delete(peerId)
71
- emit(leaveListeners, peerId)
60
+ emitSafe(leaveListeners, peerId)
72
61
  }
73
62
 
74
63
  /**
@@ -76,10 +65,9 @@ export function createScopedLinkRoom(options) {
76
65
  * @returns {{ handler: ((payload: unknown, peerId: string) => void) | null, backlog: Array<{ payload: unknown, peerId: string }> }} action 槽(含待处理 backlog)
77
66
  */
78
67
  function getActionEntry(name) {
79
- const key = String(name)
80
- if (!actionEntries.has(key))
81
- actionEntries.set(key, { handler: null, backlog: [] })
82
- return actionEntries.get(key)
68
+ if (!actionEntries.has(name))
69
+ actionEntries.set(name, { handler: null, backlog: [] })
70
+ return actionEntries.get(name)
83
71
  }
84
72
 
85
73
  return {
@@ -90,7 +78,7 @@ export function createScopedLinkRoom(options) {
90
78
  await registry.ensureRuntime()
91
79
  cleanups.add(registry.subscribeScope(scope, (senderNodeHash, envelope) => {
92
80
  if (!allowNode(senderNodeHash)) return
93
- const entry = actionEntries.get(String(envelope?.action || ''))
81
+ const entry = actionEntries.get(envelope.action)
94
82
  if (!entry) return
95
83
  if (entry.handler) entry.handler(envelope.payload, senderNodeHash)
96
84
  else entry.backlog.push({ payload: envelope.payload, peerId: senderNodeHash })
@@ -104,14 +92,11 @@ export function createScopedLinkRoom(options) {
104
92
  notePeerLeave(nodeHash)
105
93
  }))
106
94
  cleanups.add(await subscribeTopic(topic, async (bytes, meta) => {
107
- const packet = decryptSignalPacket(topic, bytes)
108
- if (packet?.type !== 'advert' || !packet.body) return
109
- const verifiedNodeHash = await verifySignedAdvert(topic, packet.body)
110
- if (!verifiedNodeHash || !allowNode(verifiedNodeHash)) return
111
- noteAdvertPeerHints(verifiedNodeHash, packet.body, meta)
112
- discoveredPeers.add(verifiedNodeHash)
113
- await registry.ensureLinkToNode(verifiedNodeHash).catch(() => null)
114
- if (registry.getLink(verifiedNodeHash)) notePeerJoin(verifiedNodeHash)
95
+ const ingested = await ingestSignedAdvert(topic, bytes, meta)
96
+ if (!ingested || !allowNode(ingested.verifiedNodeHash)) return
97
+ discoveredPeers.add(ingested.verifiedNodeHash)
98
+ await registry.ensureLinkToNode(ingested.verifiedNodeHash).catch(() => null)
99
+ if (registry.getLink(ingested.verifiedNodeHash)) notePeerJoin(ingested.verifiedNodeHash)
115
100
  }))
116
101
  cleanups.add(await advertiseTopic(topic, encryptSignalPacket(topic, {
117
102
  type: 'advert',
@@ -135,22 +120,21 @@ export function createScopedLinkRoom(options) {
135
120
  * @returns {[(payload: unknown, peerId?: string | string[] | null) => Promise<void>, (handler: (payload: unknown, peerId: string) => void) => void]} [send, onReceive] 发送与订阅元组
136
121
  */
137
122
  makeAction(name) {
138
- const actionName = String(name)
139
123
  return [
140
124
  async (payload, peerId = null) => {
141
125
  if (Array.isArray(peerId)) {
142
126
  await Promise.all(peerId.map(targetPeerId =>
143
- registry.sendToNodeLink(targetPeerId, { scope, action: actionName, payload })))
127
+ registry.sendToNodeLink(targetPeerId, { scope, action: name, payload })))
144
128
  return
145
129
  }
146
130
  if (peerId)
147
- await registry.sendToNodeLink(peerId, { scope, action: actionName, payload })
131
+ await registry.sendToNodeLink(peerId, { scope, action: name, payload })
148
132
  else
149
133
  await Promise.all(activePeerIds().map(targetPeerId =>
150
- registry.sendToNodeLink(targetPeerId, { scope, action: actionName, payload })))
134
+ registry.sendToNodeLink(targetPeerId, { scope, action: name, payload })))
151
135
  },
152
136
  handler => {
153
- const entry = getActionEntry(actionName)
137
+ const entry = getActionEntry(name)
154
138
  entry.handler = handler
155
139
  for (const pending of entry.backlog.splice(0))
156
140
  handler(pending.payload, pending.peerId)
@@ -34,8 +34,7 @@ function sanitizeDiscoveryAdvertisement(ad) {
34
34
  * @returns {object[]} 已清扫广告
35
35
  */
36
36
  function sanitizeDiscoveryAdvertisements(ads) {
37
- if (!Array.isArray(ads)) return []
38
- return ads.slice(0, MAX_DISCOVERY_ADS).map(sanitizeDiscoveryAdvertisement).filter(Boolean)
37
+ return (ads || []).slice(0, MAX_DISCOVERY_ADS).map(sanitizeDiscoveryAdvertisement).filter(Boolean)
39
38
  }
40
39
 
41
40
  /**
@@ -30,8 +30,8 @@ export function parsePullAttestation(attestation) {
30
30
  const signature = String(attestation.signature || '').trim().toLowerCase()
31
31
  if (!isHex64(requesterPubKeyHash) || !groupId || !Number.isFinite(timestamp) || !isSignatureHex128(signature))
32
32
  return null
33
- const wantIds = Array.isArray(attestation.wantIds)
34
- ? [...new Set(attestation.wantIds.map(id => String(id).trim().toLowerCase()).filter(id => EVENT_ID_HEX.test(id)))]
33
+ const wantIds = attestation.wantIds?.length
34
+ ? [...new Set(attestation.wantIds.map(id => id.trim().toLowerCase()).filter(id => EVENT_ID_HEX.test(id)))]
35
35
  : undefined
36
36
  return {
37
37
  requesterPubKeyHash,
@@ -107,7 +107,7 @@ export function parsePartQueryReq(value, tunables = partQueryTunables) {
107
107
  if (!Object.prototype.hasOwnProperty.call(value, 'query')) return null
108
108
  if (measureJsonBytes(value.query) > (tunables.maxQueryBytes ?? 2048)) return null
109
109
  const ttl = clampPartQueryTtl(value.ttl, tunables.maxTtl)
110
- if (ttl == null) return null
110
+ if (!ttl) return null
111
111
  const budget = clampPartQueryBudget(value.budget, tunables.maxHits)
112
112
  return {
113
113
  requestId,
@@ -9,7 +9,7 @@ export function validateRemoteEventShape(event) {
9
9
  if (!event?.type || typeof event.type !== 'string')
10
10
  throw new Error('remote event: type required')
11
11
  assertHex64(event.id, 'remote event.id')
12
- if (!Array.isArray(event.prev_event_ids))
12
+ if (!event.prev_event_ids)
13
13
  throw new Error('remote event: prev_event_ids must be array')
14
14
  for (const parentId of event.prev_event_ids)
15
15
  assertHex64(parentId, 'remote event.prev_event_id')
@@ -0,0 +1,20 @@
1
+ import { noteAdvertPeerHints } from '../discovery/advert_peer_hints.mjs'
2
+ import { verifySignedAdvert } from '../link/handshake.mjs'
3
+
4
+ import { decryptSignalPacket } from './signal_crypto.mjs'
5
+
6
+ /**
7
+ * 解密验签 advert,写入 peer hints;失败返回 null。
8
+ * @param {string} topic rendezvous 主题
9
+ * @param {Uint8Array} bytes 加密 advert 载荷
10
+ * @param {object} [meta] 发现元数据(如 relayUrl)
11
+ * @returns {Promise<{ verifiedNodeHash: string, body: object } | null>} 验签后的节点与 body
12
+ */
13
+ export async function ingestSignedAdvert(topic, bytes, meta) {
14
+ const packet = decryptSignalPacket(topic, bytes)
15
+ if (packet?.type !== 'advert' || !packet.body) return null
16
+ const verifiedNodeHash = await verifySignedAdvert(topic, packet.body)
17
+ if (!verifiedNodeHash) return null
18
+ noteAdvertPeerHints(verifiedNodeHash, packet.body, meta)
19
+ return { verifiedNodeHash, body: packet.body }
20
+ }
@@ -1,18 +1,16 @@
1
- import { noteAdvertPeerHints } from '../discovery/advert_peer_hints.mjs'
2
1
  import { advertiseTopic, subscribeTopic } from '../discovery/index.mjs'
3
- import { verifySignedAdvert } from '../link/handshake.mjs'
4
2
  import { loadPeerPoolView } from '../node/network.mjs'
5
3
  import { loadReputation } from '../node/reputation_store.mjs'
4
+ import { emitSafe } from '../utils/emit_safe.mjs'
6
5
 
6
+ import { ingestSignedAdvert } from './advert_ingest.mjs'
7
7
  import { getLinkRegistry } from './link_registry.mjs'
8
8
  import { resolveFederationPoolLimits, selectLinkTargetsFromMembers } from './peer_pool.mjs'
9
9
  import {
10
- decryptSignalPacket,
11
10
  encryptSignalPacket,
12
11
  groupRendezvousTopic,
13
12
  } from './signal_crypto.mjs'
14
13
 
15
-
16
14
  /**
17
15
  * 创建基于 link registry 的群组联邦房间。
18
16
  * @param {object} options 选项
@@ -25,10 +23,10 @@ import {
25
23
  export function createGroupLinkSet(options) {
26
24
  const registry = options.registry ?? getLinkRegistry()
27
25
  const autoconnect = options.autoconnect !== false
28
- const groupId = String(options.groupId)
26
+ const { groupId } = options
29
27
  const scope = `group:${groupId}`
30
28
  const topic = groupRendezvousTopic(options.roomSecret)
31
- const members = new Set((Array.isArray(options.members) ? options.members : []).map(String))
29
+ const members = new Set(options.members || [])
32
30
  const selfNodeHash = registry.localIdentity.nodeHash
33
31
  const groupSettings = options.groupSettings ?? {}
34
32
  // 初始成员是调用方明确知道的引导集合(如 introducer/creator/seed),作为必连锚点保证引导期连通。
@@ -59,17 +57,6 @@ export function createGroupLinkSet(options) {
59
57
  cleanups.add(cleanup)
60
58
  }
61
59
 
62
- /**
63
- * 向 peer 事件监听器集合广播。
64
- * @param {Set<(peerId: string) => void>} listeners 监听器集合
65
- * @param {string} peerId 目标 peer id
66
- * @returns {void}
67
- */
68
- function emitPeerListeners(listeners, peerId) {
69
- for (const listener of listeners)
70
- try { listener(peerId) } catch { /* ignore */ }
71
- }
72
-
73
60
  /**
74
61
  * 记录 peer 加入并通知监听器。
75
62
  * @param {string} peerId 加入的 peer id
@@ -78,7 +65,7 @@ export function createGroupLinkSet(options) {
78
65
  function notePeerJoin(peerId) {
79
66
  if (!peerId || announcedPeers.has(peerId)) return
80
67
  announcedPeers.add(peerId)
81
- emitPeerListeners(peerJoinListeners, peerId)
68
+ emitSafe(peerJoinListeners, peerId)
82
69
  }
83
70
 
84
71
  /**
@@ -89,7 +76,7 @@ export function createGroupLinkSet(options) {
89
76
  function notePeerLeave(peerId) {
90
77
  if (!peerId || !announcedPeers.has(peerId)) return
91
78
  announcedPeers.delete(peerId)
92
- emitPeerListeners(peerLeaveListeners, peerId)
79
+ emitSafe(peerLeaveListeners, peerId)
93
80
  }
94
81
 
95
82
  /**
@@ -98,13 +85,12 @@ export function createGroupLinkSet(options) {
98
85
  * @returns {void}
99
86
  */
100
87
  function notePeerCandidate(nodeHash) {
101
- const normalized = String(nodeHash || '')
102
- if (!normalized || normalized === selfNodeHash || members.has(normalized)) return
103
- members.add(normalized)
88
+ if (!nodeHash || nodeHash === selfNodeHash || members.has(nodeHash)) return
89
+ members.add(nodeHash)
104
90
  registry.registerScopeInterest(scope, [...members])
105
91
  // 若链路先于"得知其群成员身份"建立(如 warmup/连节点先连上,之后才收到其群 advert/envelope),
106
92
  // onLinkUp 当时因 !members.has 被跳过,这里补发 peer-join,触发 bootstrap flush(member_join 自证推送)。
107
- if (registry.getLink(normalized)) notePeerJoin(normalized)
93
+ if (registry.getLink(nodeHash)) notePeerJoin(nodeHash)
108
94
  scheduleDial()
109
95
  }
110
96
 
@@ -143,10 +129,9 @@ export function createGroupLinkSet(options) {
143
129
  * @returns {{ handler: ((payload: unknown, peerId: string) => void) | null, backlog: Array<{ payload: unknown, peerId: string }> }} action 条目
144
130
  */
145
131
  function getActionEntry(name) {
146
- const key = String(name)
147
- if (!actionEntries.has(key))
148
- actionEntries.set(key, { handler: null, backlog: [] })
149
- return actionEntries.get(key)
132
+ if (!actionEntries.has(name))
133
+ actionEntries.set(name, { handler: null, backlog: [] })
134
+ return actionEntries.get(name)
150
135
  }
151
136
 
152
137
  /**
@@ -171,7 +156,7 @@ export function createGroupLinkSet(options) {
171
156
  registry.registerScopeInterest(scope, [...members])
172
157
  registerCleanup(registry.subscribeScope(scope, (senderNodeHash, envelope) => {
173
158
  notePeerCandidate(senderNodeHash)
174
- const entry = actionEntries.get(String(envelope?.action || ''))
159
+ const entry = actionEntries.get(envelope.action)
175
160
  if (entry)
176
161
  if (entry.handler) entry.handler(envelope.payload, senderNodeHash)
177
162
  else entry.backlog.push({ payload: envelope.payload, peerId: senderNodeHash })
@@ -188,13 +173,10 @@ export function createGroupLinkSet(options) {
188
173
  notePeerLeave(nodeHash)
189
174
  }))
190
175
  registerCleanup(await subscribeTopic(topic, async (bytes, meta) => {
191
- const packet = decryptSignalPacket(topic, bytes)
192
- if (packet?.type !== 'advert' || !packet.body) return
193
- const verifiedNodeHash = await verifySignedAdvert(topic, packet.body)
194
- if (!verifiedNodeHash || verifiedNodeHash === selfNodeHash) return
195
- noteAdvertPeerHints(verifiedNodeHash, packet.body, meta)
176
+ const ingested = await ingestSignedAdvert(topic, bytes, meta)
177
+ if (!ingested || ingested.verifiedNodeHash === selfNodeHash) return
196
178
  // 发现新成员:并入成员集并去抖重算稀疏建链目标(notePeerCandidate 内部会 scheduleDial)。
197
- notePeerCandidate(verifiedNodeHash)
179
+ notePeerCandidate(ingested.verifiedNodeHash)
198
180
  }))
199
181
  registerCleanup(await advertiseTopic(topic, encryptSignalPacket(topic, {
200
182
  type: 'advert',
@@ -229,7 +211,7 @@ export function createGroupLinkSet(options) {
229
211
  * @returns {string | null} 对端 id;无链路时为 null
230
212
  */
231
213
  getPeerIdByNodeHash(nodeHash) {
232
- return registry.getLink(nodeHash) ? String(nodeHash) : null
214
+ return registry.getLink(nodeHash) ? nodeHash : null
233
215
  },
234
216
  /**
235
217
  * 向单个 peer 发送 scope action。
@@ -239,7 +221,7 @@ export function createGroupLinkSet(options) {
239
221
  * @returns {Promise<boolean>} 是否发送成功
240
222
  */
241
223
  async sendToPeer(peerId, actionName, payload) {
242
- return await registry.sendToNodeLink(peerId, { scope, action: String(actionName), payload })
224
+ return await registry.sendToNodeLink(peerId, { scope, action: actionName, payload })
243
225
  },
244
226
  /**
245
227
  * 向单个或全部在线成员发送 action。
@@ -252,7 +234,7 @@ export function createGroupLinkSet(options) {
252
234
  if (peerId) return await this.sendToPeer(peerId, actionName, payload) ? 1 : 0
253
235
  let sent = 0
254
236
  for (const { peerId: targetPeerId } of activeRoster())
255
- if (await registry.sendToNodeLink(targetPeerId, { scope, action: String(actionName), payload })) sent++
237
+ if (await registry.sendToNodeLink(targetPeerId, { scope, action: actionName, payload })) sent++
256
238
  return sent
257
239
  },
258
240
  /**
@@ -299,17 +281,16 @@ export function createGroupLinkSet(options) {
299
281
  * @returns {[(payload: unknown, peerId?: string | string[] | null) => Promise<void>, (handler: (payload: unknown, peerId: string) => void) => void]} send 与 on 函数对
300
282
  */
301
283
  makeAction(name) {
302
- const actionName = String(name)
303
284
  return [
304
285
  async (payload, peerId = null) => {
305
286
  if (Array.isArray(peerId)) {
306
- await Promise.all(peerId.map(targetPeerId => this.sendToPeer(targetPeerId, actionName, payload)))
287
+ await Promise.all(peerId.map(targetPeerId => this.sendToPeer(targetPeerId, name, payload)))
307
288
  return
308
289
  }
309
- await this.send(actionName, payload, peerId)
290
+ await this.send(name, payload, peerId)
310
291
  },
311
292
  handler => {
312
- const entry = getActionEntry(actionName)
293
+ const entry = getActionEntry(name)
313
294
  entry.handler = handler
314
295
  for (const pending of entry.backlog.splice(0))
315
296
  handler(pending.payload, pending.peerId)
@@ -46,7 +46,7 @@ function normalizeIceEntry(raw) {
46
46
  * @returns {{ urls: string, username?: string, credential?: string }[]} 合法 ICE 列表
47
47
  */
48
48
  export function resolveIceServers(groupSettings) {
49
- const fromSettings = Array.isArray(groupSettings?.iceServers) ? groupSettings.iceServers : []
49
+ const fromSettings = groupSettings?.iceServers || []
50
50
  const out = []
51
51
  for (const raw of fromSettings) {
52
52
  const entry = normalizeIceEntry(raw)
@@ -62,7 +62,7 @@ export function resolveIceServers(groupSettings) {
62
62
  * @returns {{ urls: string, username?: string, credential?: string }[]} 校验后的 ICE 列表
63
63
  */
64
64
  export function sanitizeIceServersForSettings(raw) {
65
- if (!Array.isArray(raw)) return [...DEFAULT_ICE_SERVERS]
65
+ if (!raw?.length) return [...DEFAULT_ICE_SERVERS]
66
66
  const out = []
67
67
  for (const item of raw) {
68
68
  const entry = normalizeIceEntry(item)