offline-first, p2p synced, atproto enabled, feed reader
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

add message framing to webrtc (max size is easy to hit)

+261 -98
-9
src/feedline/client/action-dispatcher.ts
··· 5 5 import {FeedAddAction} from '#feedline/schema/actions' 6 6 7 7 import {FeedlineDatabase} from './database' 8 - import {ReqEvent} from './worker-api' 9 8 10 9 export class FeedlineActionDispatcher { 11 10 #abort: AbortController 12 11 #identity: RealmIdentity 13 - #worker: Worker 14 12 15 13 constructor(_database: FeedlineDatabase, identity: RealmIdentity, worker: Worker, signal?: AbortSignal) { 16 14 this.#abort = controllerWithSignals(signal) 17 15 this.#identity = identity 18 - this.#worker = worker 19 16 } 20 17 21 18 destroy() { ··· 33 30 reason: 'feed-add', 34 31 }, 35 32 }) 36 - 37 - this.#worker.postMessage({ 38 - typ: 'evt', 39 - msg: 'feed:fetch', 40 - dat: {urls: [url]}, 41 - } satisfies ReqEvent) 42 33 } 43 34 }
+28 -12
src/feedline/client/action-handler.ts
··· 98 98 async #feedAdd(action: FeedAddAction) { 99 99 await this.#db.feeds.add({ 100 100 url: action.dat.url, 101 - lock: action.dat.lock, 101 + lock: action.dat.lock ?? null, 102 102 subscription: { 103 103 health: 100, 104 104 interval: 'daily', ··· 119 119 await this.#db.feeds.update(action.dat.url, action.dat.payload) 120 120 } 121 121 122 - async #entryPatch(action: EntryPatchAction) { 122 + #entryPatch(action: EntryPatchAction) { 123 123 // this should check locks and clocks 124 - const key: [string, string] = [action.dat.feedUrl, action.dat.guid] 125 - console.log('entry patch:', key, action.dat.payload) 124 + return this.#db 125 + .transaction('rw', ['feeds', 'entries'], async (tx) => { 126 + const feed = await tx.feeds.get(action.dat.feedurl) 127 + if (!feed) throw new Error('fkey constraint error, expected feedurl to exist') 126 128 127 - await this.#db.entries.upsert(key, { 128 - tags: [], 129 - links: [], 130 - meta: {}, 131 - ...action.dat.payload, 132 - guid: action.dat.guid, 133 - feedUrl: action.dat.feedUrl, 134 - }) 129 + const key: [string, string] = [action.dat.feedurl, action.dat.guid] 130 + const extant = await tx.entries.get(key) 131 + if (extant) { 132 + await tx.entries.update(key, action.dat.payload) 133 + } else { 134 + await tx.entries.add({ 135 + tags: [], 136 + links: [], 137 + meta: {}, 138 + lock: null, 139 + // defaults ^^ 140 + ...action.dat.payload, 141 + // overrides vv 142 + guid: action.dat.guid, 143 + feedurl: action.dat.feedurl, 144 + }) 145 + } 146 + }) 147 + .catch((exc: unknown) => { 148 + console.error('error entry patching?', exc) 149 + throw exc 150 + }) 135 151 } 136 152 }
+1 -4
src/feedline/client/database.ts
··· 18 18 super(`feedline-${identid}`) 19 19 20 20 this.version(1).stores({ 21 - // feeds indexed by URL (primary key), tags, lock state, status, medium 22 21 feeds: '&url, *tags.tag, lock.at, lock.by, subscription.status, medium', 23 - 24 - // entries with compound key [guid, feedUrl], indexed by feedUrl, tags, lock, timestamps 25 - entries: '&[feedUrl+guid], feedUrl, *tags.tag, lock.at, lock.by, publishedAt, fetchedAt', 22 + entries: '&[feedurl+guid], *tags.tag, lock.at, lock.by, publishedAt, fetchedAt', 26 23 }) 27 24 } 28 25
+31 -43
src/feedline/client/feed-fetcher.ts
··· 1 - import {IndexableType} from 'dexie' 1 + import {IndexableType, liveQuery, Subscription} from 'dexie' 2 2 import {parseFeed as parseFeedsmith} from 'feedsmith' 3 3 import {Atom, Rss} from 'feedsmith/types' 4 4 ··· 29 29 #owner: IdentID 30 30 #apihost: string 31 31 32 + #subscription: Subscription 33 + 32 34 constructor(identid: IdentID, apihost: string, signal?: AbortSignal) { 33 35 super() 34 36 ··· 39 41 this.#owner = identid 40 42 this.#apihost = apihost 41 43 42 - this.#refreshLoop().catch((exc: unknown) => { 43 - if (this.#abort.signal.aborted) return 44 + const query = liveQuery(async () => await this.#db.feeds.where('subscription.status').equals('pending').primaryKeys()) 45 + this.#subscription = query.subscribe((urls) => { 46 + console.log('pendingFeeds', urls) 47 + 48 + const feeds = this.#db.feeds.where('url').anyOf(urls) 49 + this.#db.withLock('feeds', feeds, this.#clock, this.#owner, this.#processLocked).catch((exc: unknown) => { 50 + if (this.#abort.signal.aborted) return 44 51 45 - const error = normalizeError(exc) 46 - console.error('unexpected error out of refresh loop', error.name, error.message) 47 - this.dispatchCustomEvent('error', error) 52 + const error = normalizeError(exc) 53 + console.error('unexpected error out of refresh loop', error.name, error.message) 54 + this.dispatchCustomEvent('error', error) 55 + }) 48 56 }) 49 57 } 50 58 51 59 destroy() { 52 60 this.#abort.abort(new Error('fetcher shutting down')) 53 - } 54 - 55 - async #refreshLoop() { 56 - do { 57 - try { 58 - await this.#processPending() 59 - await sleep(10_000, this.#abort.signal) 60 - } catch (exc: unknown) { 61 - if (this.#abort.signal.aborted) return 62 - 63 - const error = normalizeError(exc) 64 - console.error('unexpected error out of refresh loop', error.name, error.message) 65 - this.dispatchCustomEvent('error', error) 66 - } 67 - } while (!this.#abort.signal.aborted) 61 + this.#subscription.unsubscribe() 68 62 } 69 63 70 64 async processUrls(urls: string[]) { ··· 72 66 return this.#db.withLock('feeds', processFeeds, this.#clock, this.#owner, this.#processLocked) 73 67 } 74 68 75 - #processPending() { 76 - const pendingFeeds = this.#db.feeds.where('subscription.status').equals('pending') 77 - console.log('pendingFeeds', pendingFeeds) 78 - return this.#db.withLock('feeds', pendingFeeds, this.#clock, this.#owner, this.#processLocked) 79 - } 80 - 81 69 async #fetchFeedProxy(feed: Feed): Promise<[Response, boolean]> { 82 70 const proxyUrl = `${this.#apihost}/api/cors?url=${encodeURI(feed.url)}` 83 71 return [await fetch(proxyUrl), true] ··· 114 102 const [parsedFeed, parsedEntries] = parseFeed(feed.url, content) 115 103 116 104 // diff against existing entries - only sync new or updated entries 117 - const existingEntries = await this.#db.entries.where('feedUrl').equals(feed.url).toArray() 105 + const existingEntries = await this.#db.entries.where('feedurl').equals(feed.url).toArray() 118 106 const existingMap = new Map(existingEntries.map((e) => [e.guid, e])) 119 107 120 108 const newOrUpdatedEntries = parsedEntries.filter((parsed) => { ··· 169 157 } 170 158 171 159 #postFeedPatch( 172 - feedUrl: string, 160 + feedurl: string, 173 161 changes: Partial<Feed>, 174 162 entries: Entry[], 175 163 subscription: NonNullable<Feed['subscription']>, ··· 179 167 clk: this.#clock.now(), 180 168 msg: 'feed:patch', 181 169 dat: { 182 - url: feedUrl, 170 + url: feedurl, 183 171 payload: { 184 172 ...changes, 185 173 lock: null, ··· 193 181 clk: this.#clock.now(), 194 182 msg: 'entry:patch', 195 183 dat: { 196 - feedUrl, 184 + feedurl, 197 185 guid: entry.guid, 198 186 payload: entry, 199 187 }, ··· 203 191 } 204 192 } 205 193 206 - function parseFeed(feedUrl: string, content: string): [Partial<Feed>, Entry[]] { 194 + function parseFeed(feedurl: string, content: string): [Partial<Feed>, Entry[]] { 207 195 const result = parseFeedsmith(content) 208 196 switch (result.format) { 209 197 case 'rss': 210 - return mapRssToSchema(feedUrl, result.feed as Rss.Feed<string>) 198 + return mapRssToSchema(feedurl, result.feed as Rss.Feed<string>) 211 199 case 'atom': 212 - return mapAtomToSchema(feedUrl, result.feed as Atom.Feed<string>) 200 + return mapAtomToSchema(feedurl, result.feed as Atom.Feed<string>) 213 201 default: 214 202 throw new Error('unsupported feed type') 215 203 } ··· 280 268 } 281 269 } 282 270 283 - function mapRssToEntry(feedUrl: string, item: Rss.Item<string>, fetchedAt: number): Entry { 271 + function mapRssToEntry(feedurl: string, item: Rss.Item<string>, fetchedAt: number): Entry { 284 272 const entryTags: Tag[] = [] 285 273 const entryLinks: Link[] = [] 286 274 ··· 344 332 : undefined 345 333 346 334 return { 347 - feedUrl, 348 - guid: item.guid?.value ?? item.link ?? `${feedUrl}#${item.title}`, 335 + feedurl, 336 + guid: item.guid?.value ?? item.link ?? `${feedurl}#${item.title}`, 349 337 lock: null, 350 338 tags: entryTags, 351 339 title: item.title ?? null, ··· 372 360 } 373 361 } 374 362 375 - function mapRssToSchema(feedUrl: string, rss: Rss.Feed<string>): [Partial<Feed>, Entry[]] { 363 + function mapRssToSchema(feedurl: string, rss: Rss.Feed<string>): [Partial<Feed>, Entry[]] { 376 364 const feed = mapRssToFeed(rss) 377 365 const now = Date.now() 378 - const entries = rss.items == null ? [] : rss.items.map((item) => mapRssToEntry(feedUrl, item, now)) 366 + const entries = rss.items == null ? [] : rss.items.map((item) => mapRssToEntry(feedurl, item, now)) 379 367 380 368 return [feed, entries] 381 369 } ··· 424 412 } 425 413 } 426 414 427 - function mapAtomToEntry(feedUrl: string, entry: Atom.Entry<string>, fetchedAt: number): Entry { 415 + function mapAtomToEntry(feedurl: string, entry: Atom.Entry<string>, fetchedAt: number): Entry { 428 416 const entryTags: Tag[] = [] 429 417 const entryLinks: Link[] = [] 430 418 ··· 452 440 : undefined 453 441 454 442 return { 455 - feedUrl, 443 + feedurl, 456 444 guid: entry.id, 457 445 lock: null, 458 446 tags: entryTags, ··· 479 467 } 480 468 } 481 469 482 - function mapAtomToSchema(feedUrl: string, atom: Atom.Feed<string>): [Partial<Feed>, Entry[]] { 470 + function mapAtomToSchema(feedurl: string, atom: Atom.Feed<string>): [Partial<Feed>, Entry[]] { 483 471 const feed = mapAtomToFeed(atom) 484 472 const now = Date.now() 485 - const entries = atom.entries?.map((entry) => mapAtomToEntry(feedUrl, entry, now)) ?? [] 473 + const entries = atom.entries?.map((entry) => mapAtomToEntry(feedurl, entry, now)) ?? [] 486 474 487 475 return [feed, entries] 488 476 }
+2 -2
src/feedline/schema/actions-entry.ts
··· 8 8 export const entryPatchSchema = makeActionSchema( 9 9 'entry:patch', 10 10 z.object({ 11 - guid: z.string(), // Entry GUID from feed 12 - feedUrl: z.url(), // Which feed this entry belongs to 11 + guid: z.string(), // entry GUID from feed 12 + feedurl: z.url(), // which feed this entry belongs to 13 13 payload: entrySchema.partial(), 14 14 }), 15 15 )
+1 -1
src/feedline/schema/entry.ts
··· 6 6 import {lockSchema} from './lock' 7 7 8 8 export const entrySchema = z.object({ 9 - feedUrl: z.url(), 9 + feedurl: z.url(), 10 10 guid: z.string(), 11 11 lock: lockSchema.nullable(), 12 12
+190 -25
src/lib/client/webrtc.ts
··· 6 6 import {Fence} from '#lib/async/fence' 7 7 import {normalizeError} from '#lib/errors' 8 8 import {TypedEventTarget} from '#lib/events' 9 + import { sleep } from '#lib/async/sleep.js' 9 10 10 11 export const rtcSessionDescriptionSchema = z.object({ 11 12 sdp: z.string().optional(), ··· 42 43 export type RTCSignalPayload = z.infer<typeof rtcSignalPayloadSchema> 43 44 44 45 export type PeerConnectionStats = z.infer<typeof peerConnectionStatsSchema> 45 - export type DataChannelSendable = string | Blob | ArrayBuffer | ArrayBufferView<ArrayBuffer> 46 + export type DataChannelSendable = string 46 47 47 48 export type DataChannelEventMap = { 48 49 signal: CustomEvent<RTCSignalData> 49 50 connect: CustomEvent<never> 50 51 close: CustomEvent<never> 51 52 error: CustomEvent<Error> 52 - data: CustomEvent<string | ArrayBuffer> 53 + data: CustomEvent<string> 53 54 } 54 55 56 + type DataChunk = { buffer: Uint8Array<ArrayBuffer>, compressed: boolean } 57 + 55 58 const BUFFER_LOWWATER = 512 * 1024 // ~512KB 56 59 60 + const CHUNK_SIZE = 32 * 1024 61 + const COMPRESSION_THRESHOLD = 8 * 1024 62 + const MESSAGE_MAX_SIZE = 100 * 1024 * 1024 // 100MB 63 + 64 + const ChunkFlags = { 65 + START: 0x01, 66 + COMPRESSED: 0x02, 67 + FINAL: 0x04, 68 + } 69 + 57 70 export class DataChannelPeer< 58 71 T extends DataChannelEventMap = DataChannelEventMap, 59 72 > extends TypedEventTarget<T> { 60 73 readonly initiator: boolean 74 + #abort: AbortController 61 75 62 76 #peer: RTCPeerConnection 77 + #polite: boolean 78 + #candidates: RTCIceCandidateInit[] = [] 63 79 #chan: RTCDataChannel | null = null 64 - #candidates: RTCIceCandidateInit[] = [] 80 + #queue = new BlockingQueue<DataChannelSendable>() 65 81 66 - #abort: AbortController 67 - #queue = new BlockingQueue<DataChannelSendable>() 68 - #polite: boolean 82 + #chunkIncoming: (DataChunk & { offset: number }) | null = null 83 + #chunkBuffer = new BlockingQueue<DataChunk>() 69 84 70 85 constructor(initiator: boolean, config?: RTCConfiguration, signal?: AbortSignal) { 71 86 super() ··· 188 203 #setupDataChannel() { 189 204 if (!this.#chan) return 190 205 206 + this.#chan.binaryType = "arraybuffer" 207 + 191 208 const opts = {signal: this.#abort.signal} 192 - 193 209 this.#chan.addEventListener('open', () => this.dispatchCustomEvent('connect'), opts) 194 210 this.#chan.addEventListener('close', () => this.dispatchCustomEvent('close'), opts) 195 211 this.#chan.addEventListener('error', (e) => this.dispatchCustomEvent('error', normalizeError(e)), opts) 196 - this.#chan.addEventListener( 197 - 'message', 198 - (e: MessageEvent<string | ArrayBuffer>) => this.dispatchCustomEvent('data', e.data), 199 - opts, 200 - ) 212 + this.#chan.addEventListener('message', (e: MessageEvent<ArrayBuffer>) => this.#incomingChunk(e.data), opts) 201 213 202 214 // drain loop blocks on lowwater, which it handles internally 203 215 void this.#drainLoop(this.#chan).catch((exc: unknown) => { 204 216 console.error('unexpected error in peer drain loop!', exc) 205 217 this.dispatchCustomEvent('error', normalizeError(exc)) 206 218 }) 219 + 220 + // incoming loop blocks on buffer 221 + void this.#incomingLoop().catch((exc: unknown) => { 222 + console.error('unexpected error in peer incoming loop!', exc) 223 + this.dispatchCustomEvent('error', normalizeError(exc)) 224 + }) 207 225 } 226 + 227 + /// 208 228 209 229 signal(data: unknown) { 210 230 const parsed = rtcSignalDataSchema.safeParse(data) ··· 275 295 } 276 296 } 277 297 298 + /// 299 + 278 300 send(data: DataChannelSendable): void { 279 301 this.#queue.enqueue(data) 280 302 } ··· 292 314 293 315 try { 294 316 let data: DataChannelSendable 295 - while ((data = await this.#queue.dequeue(this.#abort.signal))) { 296 - await lowwater.enter(this.#abort.signal) // wait for peer buffer capacity 317 + send: while ((data = await this.#queue.dequeue(this.#abort.signal))) { 318 + let index = 0 319 + const chunks = this.#chunkMessage(data as string) 320 + for await (const chunk of chunks) { 321 + await lowwater.enter(this.#abort.signal) // wait for peer buffer capacity 322 + console.log('sending chunk', index++) 297 323 298 - // channel closed while waiting? 299 - if (chan.readyState !== 'open') { 300 - this.#queue.prequeue(data) 301 - break 324 + // channel closed while waiting? 325 + if (chan.readyState !== 'open') { 326 + // TODO: the whole chunk is messed up, what do do here? 327 + this.#queue.prequeue(data) 328 + break send 329 + } 330 + 331 + try { 332 + chan.send(chunk) 333 + } catch (exc: unknown) { 334 + // TODO: the whole chunk is messed up, what do do here? 335 + this.#queue.prequeue(data) 336 + this.dispatchCustomEvent('error', normalizeError(exc)) 337 + await sleep(100) // don't spin hard 338 + break // break the chunk loop to restart the message 339 + } finally { 340 + console.log('chan bufferedAmount:', chan.bufferedAmount) 341 + lowwater.value = chan.bufferedAmount <= BUFFER_LOWWATER 342 + } 302 343 } 344 + } 345 + } catch (exc: unknown) { 346 + if (this.#abort.signal.aborted) return 347 + throw exc 348 + } 349 + } 350 + 351 + async *#chunkMessage(message: string): AsyncGenerator<ArrayBuffer> { 352 + const encoded = new TextEncoder().encode(message) 353 + 354 + let payload = encoded 355 + let isCompressed = false 356 + 357 + // maybe compress 358 + if (encoded.length > COMPRESSION_THRESHOLD) { 359 + const stream = new Blob([encoded]).stream().pipeThrough(new CompressionStream("gzip")) 360 + const compressed = new Uint8Array(await new Response(stream).arrayBuffer()) 361 + 362 + if (compressed.length < encoded.length) { 363 + isCompressed = true 364 + payload = compressed 365 + } 366 + } 367 + 368 + // chunk it up 369 + let offset = 0 370 + let isFirst = true 371 + 372 + const totalLength = payload.length 373 + while (offset < totalLength) { 374 + const headerSize = isFirst ? 5 : 1 375 + const maxPayload = CHUNK_SIZE - headerSize 376 + const chunkSize = Math.min(maxPayload, totalLength - offset) 377 + const isFinal = offset + chunkSize >= totalLength 378 + 379 + const chunk = new ArrayBuffer(headerSize + chunkSize) 380 + const bytes = new Uint8Array(chunk) 381 + const view = new DataView(chunk) 382 + 383 + // byte 0 = flags 384 + view.setUint8( 385 + 0, 386 + (isFinal ? ChunkFlags.FINAL : 0) | 387 + (isCompressed ? ChunkFlags.COMPRESSED : 0) | 388 + (isFirst ? ChunkFlags.START : 0), 389 + ) 390 + 391 + // bytes 1-4 = length (first only) 392 + if (isFirst) { 393 + view.setUint32(1, totalLength) 394 + } 395 + 396 + // rest of a slice of the payload 397 + bytes.set(payload.subarray(offset, offset + chunkSize), headerSize) 398 + 399 + // then yield this slice and we'll start over 400 + yield chunk 401 + 402 + offset += chunkSize 403 + isFirst = false 404 + } 405 + } 406 + 407 + /// 408 + 409 + #incomingChunk(data: ArrayBuffer) { 410 + const chunk = new Uint8Array(data) 411 + const view = new DataView(chunk.buffer) 412 + 413 + const flags = chunk[0] 414 + const isFinal = (flags & ChunkFlags.FINAL) !== 0 415 + const isCompressed = (flags & ChunkFlags.COMPRESSED) !== 0 416 + const isStart = (flags & ChunkFlags.START) !== 0 417 + 418 + console.log('received chunk', this.#chunkIncoming?.offset || 0, isFinal, isCompressed) 419 + 420 + if (isStart) { 421 + if (this.#chunkIncoming) { 422 + console.warn('received start chunk while read in progress, discarding previous') 423 + this.#chunkIncoming = null 424 + } 425 + 426 + // first chunk, includes total length 427 + const totalLength = view.getUint32(1) 428 + if (totalLength > MESSAGE_MAX_SIZE) { 429 + console.error('message too large', totalLength) 430 + return 431 + } 432 + 433 + this.#chunkIncoming = { 434 + buffer: new Uint8Array(totalLength), 435 + offset: 0, 436 + compressed: isCompressed 437 + } 438 + 439 + this.#chunkIncoming.buffer.set(chunk.subarray(5), 0) 440 + this.#chunkIncoming.offset = chunk.length - 5 // header length 441 + } 442 + else if (this.#chunkIncoming) { 443 + // continuation chunk 444 + this.#chunkIncoming.buffer.set(chunk.subarray(1), this.#chunkIncoming.offset) 445 + this.#chunkIncoming.offset += chunk.length - 1 // header length 446 + } 447 + else { 448 + console.warn('received orphan chunk, ignoring') 449 + return 450 + } 451 + 452 + if (isFinal && this.#chunkIncoming) { 453 + this.#chunkBuffer.enqueue(this.#chunkIncoming) 454 + this.#chunkIncoming = null 455 + } 456 + } 457 + 458 + async #incomingLoop() { 459 + try { 460 + let chunk: DataChunk 461 + while ((chunk = await this.#chunkBuffer.dequeue(this.#abort.signal))) { 462 + let payload = chunk.buffer 303 463 304 464 try { 305 - chan.send(data as string) 306 - // typescript gets hungup on the type check, because of how the dom types for data channel do 307 - // overloads but we know that the data we have can go through, and at runtime it'll do whatever 308 - // dispatch it was going to anyway 465 + if (chunk.compressed) { 466 + const stream = new Blob([payload]).stream().pipeThrough(new DecompressionStream("gzip")) 467 + payload = new Uint8Array(await new Response(stream).arrayBuffer()) 468 + } 469 + 470 + const message = new TextDecoder().decode(payload) 471 + 472 + console.log('finished chunk', chunk.compressed, chunk.buffer.length, message.length) 473 + this.dispatchCustomEvent('data', message) 309 474 } catch (exc: unknown) { 310 - this.#queue.prequeue(data) 475 + console.error('dropping incoming message due to error:', exc) 311 476 this.dispatchCustomEvent('error', normalizeError(exc)) 312 477 continue 313 478 } 314 - 315 - lowwater.value = chan.bufferedAmount <= BUFFER_LOWWATER 316 479 } 317 480 } catch (exc: unknown) { 318 481 if (this.#abort.signal.aborted) return 319 482 throw exc 320 483 } 321 484 } 485 + 486 + /// 322 487 323 488 destroy(error?: Error) { 324 489 this.#chan?.close()
+7 -1
src/realm/client/connection.ts
··· 314 314 } 315 315 } 316 316 317 + batchBroadcast<T>(payload: T[], per = 10) { 318 + for (let i = 0; i < payload.length; i += per) { 319 + this.broadcast(payload.slice(i, i + per)) 320 + } 321 + } 322 + 317 323 destroy(error?: Error) { 318 324 error = error || new Error('shutting down') 319 325 ··· 625 631 } 626 632 } 627 633 628 - #socketSendActions(actions: RealmAction[], batch_size = 100) { 634 + #socketSendActions(actions: RealmAction[], batch_size = 10) { 629 635 for (let i = 0; i < actions.length; i += batch_size) { 630 636 const batch = actions.slice(i, i + batch_size) 631 637 this.#socketQueue.enqueue(JSON.stringify(batch))
+1 -1
src/realm/client/identity.ts
··· 73 73 const actions = Array.isArray(action) ? action : [action] 74 74 75 75 await this.actions.recordActions(actions) 76 - this.#connection?.broadcast(actions) 76 + this.#connection?.batchBroadcast(actions) 77 77 } 78 78 79 79 #onTick = (e: CustomEvent<Timestamp>) => {