| 1 | import { randomBytes } from "node:crypto"; |
| 2 | |
| 3 | export interface FrameBinding { |
| 4 | prefix: string; |
| 5 | frameTreeNodeId: number; |
| 6 | docId: string; |
| 7 | } |
| 8 | |
| 9 | // One snapshot of one document version. The token is the only handle Go ever |
| 10 | // sees; the snapshotId is what the page-side registry was stamped with. |
| 11 | export interface DocumentBinding { |
| 12 | tabId: string; |
| 13 | epoch: number; |
| 14 | snapshotId: string; |
| 15 | frames: FrameBinding[]; |
| 16 | } |
| 17 | |
| 18 | export interface ParsedRef { |
| 19 | prefix: string; |
| 20 | ref: string; |
| 21 | } |
| 22 | |
| 23 | const REF_PATTERN = /^(f\d+)?(e\d+)$/; |
| 24 | |
| 25 | export function parseRef(ref: string): ParsedRef | null { |
| 26 | const match = REF_PATTERN.exec(ref); |
| 27 | if (!match) return null; |
| 28 | return { prefix: match[1] ?? "", ref }; |
| 29 | } |
| 30 | |
| 31 | export function randomToken(bytes = 16): string { |
| 32 | return randomBytes(bytes).toString("hex"); |
| 33 | } |
| 34 | |
| 35 | export class DocumentRegistry { |
| 36 | private readonly byToken = new Map<string, DocumentBinding>(); |
| 37 | private readonly byTab = new Map<string, string>(); |
| 38 | |
| 39 | constructor(private readonly mint: () => string = randomToken) {} |
| 40 | |
| 41 | // A new snapshot replaces the tab's previous token: refs from an older |
| 42 | // snapshot must fail as stale rather than land on a re-rendered page. |
| 43 | issue(binding: DocumentBinding): string { |
| 44 | this.invalidateTab(binding.tabId); |
| 45 | const token = this.mint(); |
| 46 | this.byToken.set(token, binding); |
| 47 | this.byTab.set(binding.tabId, token); |
| 48 | return token; |
| 49 | } |
| 50 | |
| 51 | // After an action left the document intact the same refs stay valid, so |
| 52 | // the binding is re-issued under a fresh token and the old one retired. |
| 53 | rotate(token: string): string | null { |
| 54 | const binding = this.byToken.get(token); |
| 55 | if (!binding) return null; |
| 56 | this.byToken.delete(token); |
| 57 | const next = this.mint(); |
| 58 | this.byToken.set(next, binding); |
| 59 | this.byTab.set(binding.tabId, next); |
| 60 | return next; |
| 61 | } |
| 62 | |
| 63 | lookup(token: string): DocumentBinding | undefined { |
| 64 | return this.byToken.get(token); |
| 65 | } |
| 66 | |
| 67 | currentToken(tabId: string): string | undefined { |
| 68 | return this.byTab.get(tabId); |
| 69 | } |
| 70 | |
| 71 | invalidateTab(tabId: string): void { |
| 72 | const token = this.byTab.get(tabId); |
| 73 | if (token !== undefined) this.byToken.delete(token); |
| 74 | this.byTab.delete(tabId); |
| 75 | } |
| 76 | |
| 77 | clear(): void { |
| 78 | this.byToken.clear(); |
| 79 | this.byTab.clear(); |
| 80 | } |
| 81 | } |
| 82 |