@athenaintel/react 0.53.1 → 0.55.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/README.md +37 -1
- package/dist/agent-runtime/src/call-recap.cjs +2 -3
- package/dist/agent-runtime/src/call-recap.cjs.map +1 -1
- package/dist/agent-runtime/src/call-recap.d.ts +1 -2
- package/dist/agent-runtime/src/call-recap.js +1 -2
- package/dist/agent-runtime/src/call-recap.js.map +1 -1
- package/dist/agent-runtime/src/deep-agent-transport-recipe.cjs +7 -3
- package/dist/agent-runtime/src/deep-agent-transport-recipe.cjs.map +1 -1
- package/dist/agent-runtime/src/deep-agent-transport-recipe.d.ts +10 -2
- package/dist/agent-runtime/src/deep-agent-transport-recipe.js +8 -4
- package/dist/agent-runtime/src/deep-agent-transport-recipe.js.map +1 -1
- package/dist/agent-runtime/src/dictation-session.d.ts +158 -0
- package/dist/agent-runtime/src/edit-command.cjs +15 -3
- package/dist/agent-runtime/src/edit-command.cjs.map +1 -1
- package/dist/agent-runtime/src/edit-command.d.ts +8 -0
- package/dist/agent-runtime/src/edit-command.js +15 -3
- package/dist/agent-runtime/src/edit-command.js.map +1 -1
- package/dist/agent-runtime/src/generated/runconfig-contract.cjs +10 -0
- package/dist/agent-runtime/src/generated/runconfig-contract.cjs.map +1 -1
- package/dist/agent-runtime/src/generated/runconfig-contract.d.ts +25 -1
- package/dist/agent-runtime/src/generated/runconfig-contract.js +10 -0
- package/dist/agent-runtime/src/generated/runconfig-contract.js.map +1 -1
- package/dist/agent-runtime/src/history-pages.cjs +279 -0
- package/dist/agent-runtime/src/history-pages.cjs.map +1 -0
- package/dist/agent-runtime/src/history-pages.d.ts +252 -0
- package/dist/agent-runtime/src/history-pages.js +279 -0
- package/dist/agent-runtime/src/history-pages.js.map +1 -0
- package/dist/agent-runtime/src/index.d.ts +8 -7
- package/dist/agent-runtime/src/message-window.cjs +111 -7
- package/dist/agent-runtime/src/message-window.cjs.map +1 -1
- package/dist/agent-runtime/src/message-window.d.ts +66 -6
- package/dist/agent-runtime/src/message-window.js +112 -8
- package/dist/agent-runtime/src/message-window.js.map +1 -1
- package/dist/agent-runtime/src/offline-status.cjs.map +1 -1
- package/dist/agent-runtime/src/offline-status.d.ts +64 -2
- package/dist/agent-runtime/src/offline-status.js.map +1 -1
- package/dist/agent-runtime/src/runtime.d.ts +4 -3
- package/dist/agent-runtime/src/subagent-transcripts.cjs +23 -1
- package/dist/agent-runtime/src/subagent-transcripts.cjs.map +1 -1
- package/dist/agent-runtime/src/subagent-transcripts.js +25 -3
- package/dist/agent-runtime/src/subagent-transcripts.js.map +1 -1
- package/dist/agent-runtime/src/subagents.cjs +112 -0
- package/dist/agent-runtime/src/subagents.cjs.map +1 -1
- package/dist/agent-runtime/src/subagents.d.ts +103 -0
- package/dist/agent-runtime/src/subagents.js +113 -1
- package/dist/agent-runtime/src/subagents.js.map +1 -1
- package/dist/agent-runtime/src/thread-handoff-protocol.d.ts +37 -9
- package/dist/agent-runtime/src/thread-handoff.d.ts +1 -1
- package/dist/agent-runtime/src/tool-metadata.cjs +8 -0
- package/dist/agent-runtime/src/tool-metadata.cjs.map +1 -1
- package/dist/agent-runtime/src/tool-metadata.js +8 -0
- package/dist/agent-runtime/src/tool-metadata.js.map +1 -1
- package/dist/agent-runtime/src/use-deep-agent-thread.cjs +22 -2
- package/dist/agent-runtime/src/use-deep-agent-thread.cjs.map +1 -1
- package/dist/agent-runtime/src/use-deep-agent-thread.d.ts +9 -1
- package/dist/agent-runtime/src/use-deep-agent-thread.js +23 -3
- package/dist/agent-runtime/src/use-deep-agent-thread.js.map +1 -1
- package/dist/agent-runtime/src/use-deep-agent-window-sync.cjs +106 -25
- package/dist/agent-runtime/src/use-deep-agent-window-sync.cjs.map +1 -1
- package/dist/agent-runtime/src/use-deep-agent-window-sync.d.ts +41 -6
- package/dist/agent-runtime/src/use-deep-agent-window-sync.js +109 -28
- package/dist/agent-runtime/src/use-deep-agent-window-sync.js.map +1 -1
- package/dist/agent-runtime/src/use-thread-handoff-flow.d.ts +5 -9
- package/dist/agent-runtime/src/voice-presentation.cjs +34 -0
- package/dist/agent-runtime/src/voice-presentation.cjs.map +1 -1
- package/dist/agent-runtime/src/voice-presentation.d.ts +35 -0
- package/dist/agent-runtime/src/voice-presentation.js +34 -0
- package/dist/agent-runtime/src/voice-presentation.js.map +1 -1
- package/dist/assets/AthenaAssetEmbed.cjs +4 -2
- package/dist/assets/AthenaAssetEmbed.cjs.map +1 -1
- package/dist/assets/AthenaAssetEmbed.d.ts +17 -8
- package/dist/assets/AthenaAssetEmbed.js +4 -2
- package/dist/assets/AthenaAssetEmbed.js.map +1 -1
- package/dist/assets/use-asset-embed.cjs.map +1 -1
- package/dist/assets/use-asset-embed.d.ts +7 -5
- package/dist/assets/use-asset-embed.js.map +1 -1
- package/dist/auth/account-identity.cjs +69 -0
- package/dist/auth/account-identity.cjs.map +1 -0
- package/dist/auth/account-identity.d.ts +57 -0
- package/dist/auth/account-identity.js +69 -0
- package/dist/auth/account-identity.js.map +1 -0
- package/dist/chat/AthenaChat.cjs +1 -1
- package/dist/chat/AthenaChat.cjs.map +1 -1
- package/dist/chat/AthenaChat.js +1 -1
- package/dist/chat/AthenaChat.js.map +1 -1
- package/dist/chat/super-grouping/SuperGroupingCard.cjs +51 -43
- package/dist/chat/super-grouping/SuperGroupingCard.cjs.map +1 -1
- package/dist/chat/super-grouping/SuperGroupingCard.js +51 -43
- package/dist/chat/super-grouping/SuperGroupingCard.js.map +1 -1
- package/dist/chat/super-grouping/SuperGroupingTranscript.cjs +9 -4
- package/dist/chat/super-grouping/SuperGroupingTranscript.cjs.map +1 -1
- package/dist/chat/super-grouping/SuperGroupingTranscript.js +9 -4
- package/dist/chat/super-grouping/SuperGroupingTranscript.js.map +1 -1
- package/dist/chat/super-grouping/activity-timeline.cjs +7 -4
- package/dist/chat/super-grouping/activity-timeline.cjs.map +1 -1
- package/dist/chat/super-grouping/activity-timeline.d.ts +10 -1
- package/dist/chat/super-grouping/activity-timeline.js +7 -4
- package/dist/chat/super-grouping/activity-timeline.js.map +1 -1
- package/dist/chat/super-grouping/earlier-steps-button.cjs +50 -0
- package/dist/chat/super-grouping/earlier-steps-button.cjs.map +1 -0
- package/dist/chat/super-grouping/earlier-steps-button.d.ts +11 -0
- package/dist/chat/super-grouping/earlier-steps-button.js +50 -0
- package/dist/chat/super-grouping/earlier-steps-button.js.map +1 -0
- package/dist/chat/super-grouping/toolkit-map.cjs +1 -0
- package/dist/chat/super-grouping/toolkit-map.cjs.map +1 -1
- package/dist/chat/super-grouping/toolkit-map.js +1 -0
- package/dist/chat/super-grouping/toolkit-map.js.map +1 -1
- package/dist/chat/super-grouping/use-turn-steps.cjs +28 -0
- package/dist/chat/super-grouping/use-turn-steps.cjs.map +1 -0
- package/dist/chat/super-grouping/use-turn-steps.d.ts +23 -0
- package/dist/chat/super-grouping/use-turn-steps.js +28 -0
- package/dist/chat/super-grouping/use-turn-steps.js.map +1 -0
- package/dist/chat/use-statewire-approval-request.cjs +5 -5
- package/dist/chat/use-statewire-approval-request.cjs.map +1 -1
- package/dist/chat/use-statewire-approval-request.d.ts +7 -0
- package/dist/chat/use-statewire-approval-request.js +5 -5
- package/dist/chat/use-statewire-approval-request.js.map +1 -1
- package/dist/chat-ui/mentions/use-account-workspace-id.cjs +18 -58
- package/dist/chat-ui/mentions/use-account-workspace-id.cjs.map +1 -1
- package/dist/chat-ui/mentions/use-account-workspace-id.js +18 -58
- package/dist/chat-ui/mentions/use-account-workspace-id.js.map +1 -1
- package/dist/chat-ui/messages/voice-delegation-card.cjs +93 -25
- package/dist/chat-ui/messages/voice-delegation-card.cjs.map +1 -1
- package/dist/chat-ui/messages/voice-delegation-card.d.ts +16 -7
- package/dist/chat-ui/messages/voice-delegation-card.js +95 -27
- package/dist/chat-ui/messages/voice-delegation-card.js.map +1 -1
- package/dist/chat-ui/statewire.cjs +6 -0
- package/dist/chat-ui/statewire.cjs.map +1 -1
- package/dist/chat-ui/statewire.d.ts +3 -1
- package/dist/chat-ui/statewire.js +7 -1
- package/dist/chat-ui/statewire.js.map +1 -1
- package/dist/chat-ui/thread/handoff-cursor.cjs +2 -2
- package/dist/chat-ui/thread/handoff-cursor.cjs.map +1 -1
- package/dist/chat-ui/thread/handoff-cursor.js +3 -3
- package/dist/chat-ui/thread/handoff-cursor.js.map +1 -1
- package/dist/chat-ui/thread/statewire-history-edge.cjs +4 -0
- package/dist/chat-ui/thread/statewire-history-edge.cjs.map +1 -1
- package/dist/chat-ui/thread/statewire-history-edge.d.ts +11 -1
- package/dist/chat-ui/thread/statewire-history-edge.js +5 -1
- package/dist/chat-ui/thread/statewire-history-edge.js.map +1 -1
- package/dist/collab/client.cjs +134 -7
- package/dist/collab/client.cjs.map +1 -1
- package/dist/collab/client.d.ts +44 -0
- package/dist/collab/client.js +134 -7
- package/dist/collab/client.js.map +1 -1
- package/dist/collab/index.d.ts +1 -1
- package/dist/composer/QuoteContext.cjs +3 -3
- package/dist/composer/QuoteContext.cjs.map +1 -1
- package/dist/composer/QuoteContext.js +3 -3
- package/dist/composer/QuoteContext.js.map +1 -1
- package/dist/keryx-http/src/initial-sync.cjs +121 -0
- package/dist/keryx-http/src/initial-sync.cjs.map +1 -0
- package/dist/keryx-http/src/initial-sync.d.ts +77 -0
- package/dist/keryx-http/src/initial-sync.js +104 -0
- package/dist/keryx-http/src/initial-sync.js.map +1 -0
- package/dist/keryx-http/src/unconfirmed-update-attributions.cjs +8 -0
- package/dist/keryx-http/src/unconfirmed-update-attributions.cjs.map +1 -1
- package/dist/keryx-http/src/unconfirmed-update-attributions.d.ts +6 -0
- package/dist/keryx-http/src/unconfirmed-update-attributions.js +8 -0
- package/dist/keryx-http/src/unconfirmed-update-attributions.js.map +1 -1
- package/dist/provider/AthenaProvider.cjs +11 -3
- package/dist/provider/AthenaProvider.cjs.map +1 -1
- package/dist/provider/AthenaProvider.js +12 -4
- package/dist/provider/AthenaProvider.js.map +1 -1
- package/dist/provider/statewire-window-context.cjs +6 -0
- package/dist/provider/statewire-window-context.cjs.map +1 -1
- package/dist/provider/statewire-window-context.d.ts +10 -1
- package/dist/provider/statewire-window-context.js +6 -0
- package/dist/provider/statewire-window-context.js.map +1 -1
- package/dist/react/package.json.cjs +1 -1
- package/dist/react/package.json.js +1 -1
- package/dist/runtime/useAthenaStatewireRuntime.cjs +2 -0
- package/dist/runtime/useAthenaStatewireRuntime.cjs.map +1 -1
- package/dist/runtime/useAthenaStatewireRuntime.d.ts +7 -1
- package/dist/runtime/useAthenaStatewireRuntime.js +2 -0
- package/dist/runtime/useAthenaStatewireRuntime.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/threads/api.cjs +1 -2
- package/dist/threads/api.cjs.map +1 -1
- package/dist/threads/api.d.ts +3 -2
- package/dist/threads/api.js +1 -2
- package/dist/threads/api.js.map +1 -1
- package/dist/threads/sessions-query.cjs +2 -0
- package/dist/threads/sessions-query.cjs.map +1 -1
- package/dist/threads/sessions-query.js +2 -0
- package/dist/threads/sessions-query.js.map +1 -1
- package/dist/threads/statewire-thread-list.cjs +5 -7
- package/dist/threads/statewire-thread-list.cjs.map +1 -1
- package/dist/threads/statewire-thread-list.js +7 -9
- package/dist/threads/statewire-thread-list.js.map +1 -1
- package/dist/tools/subagent-task-tool-ui.cjs +15 -5
- package/dist/tools/subagent-task-tool-ui.cjs.map +1 -1
- package/dist/tools/subagent-task-tool-ui.d.ts +5 -4
- package/dist/tools/subagent-task-tool-ui.js +16 -6
- package/dist/tools/subagent-task-tool-ui.js.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"QuoteContext.js","sources":["../../src/composer/QuoteContext.tsx"],"sourcesContent":["import {\n createContext,\n type RefObject,\n useContext,\n useEffect,\n useMemo,\n useState,\n type ReactNode,\n} from 'react';\nimport { useStore } from 'zustand';\nimport { createStore, type StoreApi } from 'zustand/vanilla';\n\nexport interface QuoteData {\n /** The selected/quoted text. */\n text: string;\n /** Title of the source (e.g., asset title, document name). */\n sourceTitle?: string;\n /** Asset ID of the source (for attribution). */\n sourceAssetId?: string;\n /** Message ID of the source message (when quoting from chat). */\n sourceMessageId?: string;\n /** Role of the source message ('assistant' | 'user'). */\n sourceRole?: string;\n}\n\ninterface QuoteContextValue {\n quote: QuoteData | null;\n setQuote: (quote: QuoteData | null) => void;\n clearQuote: () => void;\n}\n\nconst QuoteCtx = createContext<QuoteContextValue | null>(null);\n\ninterface QuoteStoreState {\n quote: QuoteData | null;\n}\n\n/**\n * A composer's pending quote, kept outside React (`createQuoteStore`), like\n * `AttachmentStore`: one per chat when a host shows several.\n */\nexport type QuoteStore = StoreApi<QuoteStoreState>;\n\n/** An empty `QuoteStore`, for `<QuoteProvider store>`. */\nexport function createQuoteStore(): QuoteStore {\n return createStore<QuoteStoreState>(() => ({ quote: null }));\n}\n\n/**\n * The composer's pending quote. Like \\`AttachmentProvider\\`, a `store` makes\n * it that store's, and without one it reuses a provider already mounted\n * above, so a host can keep the quote across a remounting \\`AthenaChat\\`.\n */\nexport function QuoteProvider({ children, store }: { children: ReactNode; store?: QuoteStore }) {\n const outer = useContext(QuoteCtx);\n if (store) return <StoreQuoteProvider store={store}>{children}</StoreQuoteProvider>;\n if (outer) return <>{children}</>;\n return <OwnQuoteProvider>{children}</OwnQuoteProvider>;\n}\n\nfunction OwnQuoteProvider({ children }: { children: ReactNode }) {\n const [store] = useState(createQuoteStore);\n return <StoreQuoteProvider store={store}>{children}</StoreQuoteProvider>;\n}\n\nfunction StoreQuoteProvider({ children, store }: { children: ReactNode; store: QuoteStore }) {\n const quote = useStore(store, (state) => state.quote);\n const actions = useMemo(\n () => ({\n setQuote: (next: QuoteData | null) => store.setState({ quote: next }),\n clearQuote: () => store.setState({ quote: null }),\n }),\n [store],\n );\n const value = useMemo(() => ({ quote, ...actions }), [quote, actions]);\n\n return (\n <QuoteCtx.Provider value={value}>\n {children}\n </QuoteCtx.Provider>\n );\n}\n\nexport function useQuote(): QuoteContextValue {\n const ctx = useContext(QuoteCtx);\n if (!ctx) {\n throw new Error('[AthenaSDK] useQuote must be used within <QuoteProvider>');\n }\n return ctx;\n}\n\n/**\n * Whether a message came from a frame embedded directly in this window, for\n * any origin: the window of one of this document's iframes (an identity\n * comparison, safe cross-origin), or, for a frame inside a shadow root, a\n * window whose `parent` is this window (`parent` is readable on a\n * cross-origin window). A message port, a worker, this window itself, a\n * grandchild frame or an unrelated window is refused. `win` is the window the\n * message came to (default: this one), e.g. a popped-out chat's.\n */\nexport function isDirectChildFrame(\n source: MessageEventSource | null,\n win: Window = window,\n): boolean {\n if (!source || source === win) return false;\n for (const frame of win.document.querySelectorAll('iframe')) {\n if (frame.contentWindow === source) return true;\n }\n try {\n return Reflect.get(source, 'parent') === win;\n } catch {\n return false;\n }\n}\n\n/** `AthenaChat`'s thread root: the chats `useQuoteFromPostMessage` tells apart. */\nconst CHAT_ROOT_SELECTOR = '.aui-thread-root';\n\n/**\n * The chat the person last focused in each window, which takes a quote that\n * names no chat (a selection outside every chat, an embedded frame's text)\n * when that window shows several. A focus, not data: no state of the chats\n * themselves.\n */\nconst lastFocusedChatRoots = new WeakMap<Document, Element>();\n\n/** The element a node is, or sits in; duck-typed, since a pop-out window's nodes are another realm's. */\nfunction elementOf(node: Node | null | undefined): Element | null {\n if (!node) return null;\n return node.nodeType === 1 ? (node as Element) : node.parentElement;\n}\n\nexport interface QuoteFromPostMessageOptions {\n /**\n * An element inside the chat this composer belongs to. With several chats in\n * one window, a ⌘L selection goes to the chat it was made in, and one made\n * outside every chat (or an embedded frame's text) to the chat the person\n * last focused; the keys and an embedded frame's messages are heard in this\n * element's own window, a pop-out included. Without it, every quote is this\n * composer's, as before.\n */\n scope?: RefObject<Element | null>;\n}\n\n/**\n * Listens for:\n * 1. Cmd+L / Ctrl+L keydown — grabs the current text selection and sets it as a quote.\n * `preventDefault` stops the browser from focusing the URL bar.\n * 2. PostMessage events from child iframes (asset panels, embedded content)\n * with type `athena-add-to-composer`, only from a frame embedded directly\n * in this window ({@link isDirectChildFrame}).\n *\n * Mount this once per composer inside AthenaProvider (`AthenaChat` does).\n */\nexport function useQuoteFromPostMessage({ scope }: QuoteFromPostMessageOptions = {}) {\n const { setQuote } = useQuote();\n\n useEffect(() => {\n const doc = scope?.current?.ownerDocument ?? document;\n const win = doc.defaultView ?? window;\n const chatRoot = () => scope?.current?.closest(CHAT_ROOT_SELECTOR) ?? null;\n // Whether this composer takes a quote made at `anchor` (null: one that names no chat).\n const takes = (anchor: Node | null) => {\n const mine = chatRoot();\n if (!mine) return true;\n const owner = elementOf(anchor)?.closest(CHAT_ROOT_SELECTOR) ?? null;\n if (owner) return owner === mine;\n const remembered = lastFocusedChatRoots.get(doc);\n const focused = remembered?.isConnected ? remembered : doc.querySelector(CHAT_ROOT_SELECTOR);\n return focused === mine;\n };\n\n const handleFocusIn = (event: FocusEvent) => {\n const mine = chatRoot();\n const target = event.target as Node | null;\n if (mine && target && typeof target.nodeType === 'number' && mine.contains(target)) {\n lastFocusedChatRoots.set(doc, mine);\n }\n };\n\n // ── Cmd+L / Ctrl+L keyboard handler ──────────────────────────────\n const handleKeydown = (e: KeyboardEvent) => {\n if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'l') {\n const sel = doc.getSelection();\n const selection = sel?.toString()?.trim();\n\n if (selection && selection.length > 0 && takes(sel?.anchorNode ?? null)) {\n e.preventDefault();\n\n // Walk up from the selection anchor to find the message container.\n // assistant-ui message roots have data-role=\"assistant\" or data-role=\"user\".\n let messageId: string | undefined;\n let role: string | undefined;\n let node = elementOf(sel?.anchorNode);\n while (node && node !== doc.body) {\n const dataRole = node.getAttribute('data-role');\n if (dataRole === 'assistant' || dataRole === 'user') {\n role = dataRole;\n // assistant-ui sets data-message-id on message roots\n messageId = node.getAttribute('data-message-id') ?? undefined;\n break;\n }\n node = node.parentElement;\n }\n\n setQuote({ text: selection, sourceRole: role, sourceMessageId: messageId });\n }\n }\n };\n\n // ── PostMessage handler (from iframes) ───────────────────────────\n // Accepts messages from any origin, but only from a frame embedded\n // directly in this window: the SDK consumer controls which iframes are\n // embedded, and that is the trust boundary. A popup, an opener or another\n // tab holding a reference to this window cannot stage composer text.\n const handleMessage = (event: MessageEvent) => {\n const data = event.data;\n if (\n data &&\n typeof data === 'object' &&\n data.type === 'athena-add-to-composer' &&\n typeof data.text === 'string' &&\n data.text.trim().length > 0 &&\n isDirectChildFrame(event.source, win) &&\n takes(null)\n ) {\n setQuote({\n text: data.text,\n sourceTitle: data.sourceTitle,\n sourceAssetId: data.sourceAssetId,\n });\n }\n };\n\n doc.addEventListener('keydown', handleKeydown, true); // capture phase to beat the browser\n doc.addEventListener('focusin', handleFocusIn, true);\n win.addEventListener('message', handleMessage);\n\n return () => {\n doc.removeEventListener('keydown', handleKeydown, true);\n doc.removeEventListener('focusin', handleFocusIn, true);\n win.removeEventListener('message', handleMessage);\n };\n }, [scope, setQuote]);\n}\n"],"names":["_a"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"QuoteContext.js","sources":["../../src/composer/QuoteContext.tsx"],"sourcesContent":["import {\n createContext,\n type RefObject,\n useContext,\n useEffect,\n useMemo,\n useState,\n type ReactNode,\n} from 'react';\nimport { useStore } from 'zustand';\nimport { createStore, type StoreApi } from 'zustand/vanilla';\n\nexport interface QuoteData {\n /** The selected/quoted text. */\n text: string;\n /** Title of the source (e.g., asset title, document name). */\n sourceTitle?: string;\n /** Asset ID of the source (for attribution). */\n sourceAssetId?: string;\n /** Message ID of the source message (when quoting from chat). */\n sourceMessageId?: string;\n /** Role of the source message ('assistant' | 'user'). */\n sourceRole?: string;\n}\n\ninterface QuoteContextValue {\n quote: QuoteData | null;\n setQuote: (quote: QuoteData | null) => void;\n clearQuote: () => void;\n}\n\nconst QuoteCtx = createContext<QuoteContextValue | null>(null);\n\ninterface QuoteStoreState {\n quote: QuoteData | null;\n}\n\n/**\n * A composer's pending quote, kept outside React (`createQuoteStore`), like\n * `AttachmentStore`: one per chat when a host shows several.\n */\nexport type QuoteStore = StoreApi<QuoteStoreState>;\n\n/** An empty `QuoteStore`, for `<QuoteProvider store>`. */\nexport function createQuoteStore(): QuoteStore {\n return createStore<QuoteStoreState>(() => ({ quote: null }));\n}\n\n/**\n * The composer's pending quote. Like \\`AttachmentProvider\\`, a `store` makes\n * it that store's, and without one it reuses a provider already mounted\n * above, so a host can keep the quote across a remounting \\`AthenaChat\\`.\n */\nexport function QuoteProvider({ children, store }: { children: ReactNode; store?: QuoteStore }) {\n const outer = useContext(QuoteCtx);\n if (store) return <StoreQuoteProvider store={store}>{children}</StoreQuoteProvider>;\n if (outer) return <>{children}</>;\n return <OwnQuoteProvider>{children}</OwnQuoteProvider>;\n}\n\nfunction OwnQuoteProvider({ children }: { children: ReactNode }) {\n const [store] = useState(createQuoteStore);\n return <StoreQuoteProvider store={store}>{children}</StoreQuoteProvider>;\n}\n\nfunction StoreQuoteProvider({ children, store }: { children: ReactNode; store: QuoteStore }) {\n const quote = useStore(store, (state) => state.quote);\n const actions = useMemo(\n () => ({\n setQuote: (next: QuoteData | null) => store.setState({ quote: next }),\n clearQuote: () => store.setState({ quote: null }),\n }),\n [store],\n );\n const value = useMemo(() => ({ quote, ...actions }), [quote, actions]);\n\n return (\n <QuoteCtx.Provider value={value}>\n {children}\n </QuoteCtx.Provider>\n );\n}\n\nexport function useQuote(): QuoteContextValue {\n const ctx = useContext(QuoteCtx);\n if (!ctx) {\n throw new Error('[AthenaSDK] useQuote must be used within <QuoteProvider>');\n }\n return ctx;\n}\n\n/**\n * Whether a message came from a frame embedded directly in this window, for\n * any origin: the window of one of this document's iframes (an identity\n * comparison, safe cross-origin), or, for a frame inside a shadow root, a\n * window whose `parent` is this window (`parent` is readable on a\n * cross-origin window). A message port, a worker, this window itself, a\n * grandchild frame or an unrelated window is refused. `win` is the window the\n * message came to (default: this one), e.g. a popped-out chat's.\n */\nexport function isDirectChildFrame(\n source: MessageEventSource | null,\n win: Window = window,\n): boolean {\n if (!source || source === win) return false;\n for (const frame of win.document.querySelectorAll('iframe')) {\n if (frame.contentWindow === source) return true;\n }\n try {\n return Reflect.get(source, 'parent') === win;\n } catch {\n return false;\n }\n}\n\n/** `AthenaChat`'s thread root: the chats `useQuoteFromPostMessage` tells apart. */\nconst CHAT_ROOT_SELECTOR = '.aui-thread-root';\n\n/**\n * The chat the person last focused in each window, which takes a quote that\n * names no chat (a selection outside every chat, an embedded frame's text)\n * when that window shows several. A focus, not data: no state of the chats\n * themselves.\n */\nconst lastFocusedChatRoots = new WeakMap<Document, Element>();\n\n/** The element a node is, or sits in; duck-typed, since a pop-out window's nodes are another realm's. */\nfunction elementOf(node: Node | null | undefined): Element | null {\n if (!node) return null;\n return node.nodeType === 1 ? (node as Element) : node.parentElement;\n}\n\nexport interface QuoteFromPostMessageOptions {\n /**\n * An element inside the chat this composer belongs to. With several chats in\n * one window, a ⌘L selection goes to the chat it was made in, and one made\n * outside every chat (or an embedded frame's text) to the chat the person\n * last focused; the keys and an embedded frame's messages are heard in this\n * element's own window, a pop-out included. Without it, every quote is this\n * composer's, as before.\n */\n scope?: RefObject<Element | null>;\n}\n\n/**\n * Listens for:\n * 1. Cmd+L / Ctrl+L keydown — grabs the current text selection and sets it as a quote.\n * `preventDefault` stops the browser from focusing the URL bar.\n * 2. PostMessage events from child iframes (asset panels, embedded content)\n * with type `athena-add-to-composer`, only from a frame embedded directly\n * in this window ({@link isDirectChildFrame}).\n *\n * Mount this once per composer inside AthenaProvider (`AthenaChat` does).\n */\nexport function useQuoteFromPostMessage({ scope }: QuoteFromPostMessageOptions = {}) {\n const { setQuote } = useQuote();\n\n useEffect(() => {\n const doc = scope?.current?.ownerDocument ?? document;\n const win = doc.defaultView ?? window;\n const chatRoot = () => scope?.current?.closest(CHAT_ROOT_SELECTOR) ?? null;\n // Whether this composer takes a quote made at `anchor` (null: one that names no chat).\n const takes = (anchor: Node | null) => {\n const mine = chatRoot();\n if (!mine) return true;\n const owner = elementOf(anchor)?.closest(CHAT_ROOT_SELECTOR) ?? null;\n if (owner) return owner === mine;\n const remembered = lastFocusedChatRoots.get(doc);\n const focused = remembered?.isConnected ? remembered : doc.querySelector(CHAT_ROOT_SELECTOR);\n return focused === mine;\n };\n\n const handleFocusIn = (event: FocusEvent) => {\n const mine = chatRoot();\n const target = event.target as Node | null;\n if (mine && target && typeof target.nodeType === 'number' && mine.contains(target)) {\n lastFocusedChatRoots.set(doc, mine);\n }\n };\n\n // ── Cmd+L / Ctrl+L keyboard handler ──────────────────────────────\n const handleKeydown = (e: KeyboardEvent) => {\n if ((e.metaKey || e.ctrlKey) && e.key?.toLowerCase() === 'l') {\n const sel = doc.getSelection();\n const selection = sel?.toString()?.trim();\n\n if (selection && selection.length > 0 && takes(sel?.anchorNode ?? null)) {\n e.preventDefault();\n\n // Walk up from the selection anchor to find the message container.\n // assistant-ui message roots have data-role=\"assistant\" or data-role=\"user\".\n let messageId: string | undefined;\n let role: string | undefined;\n let node = elementOf(sel?.anchorNode);\n while (node && node !== doc.body) {\n const dataRole = node.getAttribute('data-role');\n if (dataRole === 'assistant' || dataRole === 'user') {\n role = dataRole;\n // assistant-ui sets data-message-id on message roots\n messageId = node.getAttribute('data-message-id') ?? undefined;\n break;\n }\n node = node.parentElement;\n }\n\n setQuote({ text: selection, sourceRole: role, sourceMessageId: messageId });\n }\n }\n };\n\n // ── PostMessage handler (from iframes) ───────────────────────────\n // Accepts messages from any origin, but only from a frame embedded\n // directly in this window: the SDK consumer controls which iframes are\n // embedded, and that is the trust boundary. A popup, an opener or another\n // tab holding a reference to this window cannot stage composer text.\n const handleMessage = (event: MessageEvent) => {\n const data = event.data;\n if (\n data &&\n typeof data === 'object' &&\n data.type === 'athena-add-to-composer' &&\n typeof data.text === 'string' &&\n data.text.trim().length > 0 &&\n isDirectChildFrame(event.source, win) &&\n takes(null)\n ) {\n setQuote({\n text: data.text,\n sourceTitle: data.sourceTitle,\n sourceAssetId: data.sourceAssetId,\n });\n }\n };\n\n doc.addEventListener('keydown', handleKeydown, true); // capture phase to beat the browser\n doc.addEventListener('focusin', handleFocusIn, true);\n win.addEventListener('message', handleMessage);\n\n return () => {\n doc.removeEventListener('keydown', handleKeydown, true);\n doc.removeEventListener('focusin', handleFocusIn, true);\n win.removeEventListener('message', handleMessage);\n };\n }, [scope, setQuote]);\n}\n"],"names":["_a"],"mappings":";;;;AA+BA,MAAM,WAAW,cAAwC,IAAI;AAatD,SAAS,mBAA+B;AAC7C,SAAO,YAA6B,OAAO,EAAE,OAAO,OAAO;AAC7D;AAOO,SAAS,cAAc,EAAE,UAAU,SAAsD;AAC9F,QAAM,QAAQ,WAAW,QAAQ;AACjC,MAAI,MAAO,QAAO,oBAAC,oBAAA,EAAmB,OAAe,UAAS;AAC9D,MAAI,MAAO,QAAO,oBAAA,UAAA,EAAG,SAAA,CAAS;AAC9B,SAAO,oBAAC,oBAAkB,UAAS;AACrC;AAEA,SAAS,iBAAiB,EAAE,YAAqC;AAC/D,QAAM,CAAC,KAAK,IAAI,SAAS,gBAAgB;AACzC,SAAO,oBAAC,oBAAA,EAAmB,OAAe,SAAA,CAAS;AACrD;AAEA,SAAS,mBAAmB,EAAE,UAAU,SAAqD;AAC3F,QAAM,QAAQ,SAAS,OAAO,CAAC,UAAU,MAAM,KAAK;AACpD,QAAM,UAAU;AAAA,IACd,OAAO;AAAA,MACL,UAAU,CAAC,SAA2B,MAAM,SAAS,EAAE,OAAO,MAAM;AAAA,MACpE,YAAY,MAAM,MAAM,SAAS,EAAE,OAAO,MAAM;AAAA,IAAA;AAAA,IAElD,CAAC,KAAK;AAAA,EAAA;AAER,QAAM,QAAQ,QAAQ,OAAO,EAAE,OAAO,GAAG,QAAA,IAAY,CAAC,OAAO,OAAO,CAAC;AAErE,SACE,oBAAC,SAAS,UAAT,EAAkB,OAChB,SAAA,CACH;AAEJ;AAEO,SAAS,WAA8B;AAC5C,QAAM,MAAM,WAAW,QAAQ;AAC/B,MAAI,CAAC,KAAK;AACR,UAAM,IAAI,MAAM,0DAA0D;AAAA,EAC5E;AACA,SAAO;AACT;AAWO,SAAS,mBACd,QACA,MAAc,QACL;AACT,MAAI,CAAC,UAAU,WAAW,IAAK,QAAO;AACtC,aAAW,SAAS,IAAI,SAAS,iBAAiB,QAAQ,GAAG;AAC3D,QAAI,MAAM,kBAAkB,OAAQ,QAAO;AAAA,EAC7C;AACA,MAAI;AACF,WAAO,QAAQ,IAAI,QAAQ,QAAQ,MAAM;AAAA,EAC3C,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAGA,MAAM,qBAAqB;AAQ3B,MAAM,2CAA2B,QAAA;AAGjC,SAAS,UAAU,MAA+C;AAChE,MAAI,CAAC,KAAM,QAAO;AAClB,SAAO,KAAK,aAAa,IAAK,OAAmB,KAAK;AACxD;AAwBO,SAAS,wBAAwB,EAAE,MAAA,IAAuC,IAAI;AACnF,QAAM,EAAE,SAAA,IAAa,SAAA;AAErB,YAAU,MAAM;;AACd,UAAM,QAAM,oCAAO,YAAP,mBAAgB,kBAAiB;AAC7C,UAAM,MAAM,IAAI,eAAe;AAC/B,UAAM,WAAW,MAAA;;AAAM,eAAAA,MAAA,+BAAO,YAAP,gBAAAA,IAAgB,QAAQ,wBAAuB;AAAA;AAEtE,UAAM,QAAQ,CAAC,WAAwB;;AACrC,YAAM,OAAO,SAAA;AACb,UAAI,CAAC,KAAM,QAAO;AAClB,YAAM,UAAQA,MAAA,UAAU,MAAM,MAAhB,gBAAAA,IAAmB,QAAQ,wBAAuB;AAChE,UAAI,cAAc,UAAU;AAC5B,YAAM,aAAa,qBAAqB,IAAI,GAAG;AAC/C,YAAM,WAAU,yCAAY,eAAc,aAAa,IAAI,cAAc,kBAAkB;AAC3F,aAAO,YAAY;AAAA,IACrB;AAEA,UAAM,gBAAgB,CAAC,UAAsB;AAC3C,YAAM,OAAO,SAAA;AACb,YAAM,SAAS,MAAM;AACrB,UAAI,QAAQ,UAAU,OAAO,OAAO,aAAa,YAAY,KAAK,SAAS,MAAM,GAAG;AAClF,6BAAqB,IAAI,KAAK,IAAI;AAAA,MACpC;AAAA,IACF;AAGA,UAAM,gBAAgB,CAAC,MAAqB;;AAC1C,WAAK,EAAE,WAAW,EAAE,cAAYA,MAAA,EAAE,QAAF,gBAAAA,IAAO,mBAAkB,KAAK;AAC5D,cAAM,MAAM,IAAI,aAAA;AAChB,cAAM,aAAY,gCAAK,eAAL,mBAAiB;AAEnC,YAAI,aAAa,UAAU,SAAS,KAAK,OAAM,2BAAK,eAAc,IAAI,GAAG;AACvE,YAAE,eAAA;AAIF,cAAI;AACJ,cAAI;AACJ,cAAI,OAAO,UAAU,2BAAK,UAAU;AACpC,iBAAO,QAAQ,SAAS,IAAI,MAAM;AAChC,kBAAM,WAAW,KAAK,aAAa,WAAW;AAC9C,gBAAI,aAAa,eAAe,aAAa,QAAQ;AACnD,qBAAO;AAEP,0BAAY,KAAK,aAAa,iBAAiB,KAAK;AACpD;AAAA,YACF;AACA,mBAAO,KAAK;AAAA,UACd;AAEA,mBAAS,EAAE,MAAM,WAAW,YAAY,MAAM,iBAAiB,WAAW;AAAA,QAC5E;AAAA,MACF;AAAA,IACF;AAOA,UAAM,gBAAgB,CAAC,UAAwB;AAC7C,YAAM,OAAO,MAAM;AACnB,UACE,QACA,OAAO,SAAS,YAChB,KAAK,SAAS,4BACd,OAAO,KAAK,SAAS,YACrB,KAAK,KAAK,KAAA,EAAO,SAAS,KAC1B,mBAAmB,MAAM,QAAQ,GAAG,KACpC,MAAM,IAAI,GACV;AACA,iBAAS;AAAA,UACP,MAAM,KAAK;AAAA,UACX,aAAa,KAAK;AAAA,UAClB,eAAe,KAAK;AAAA,QAAA,CACrB;AAAA,MACH;AAAA,IACF;AAEA,QAAI,iBAAiB,WAAW,eAAe,IAAI;AACnD,QAAI,iBAAiB,WAAW,eAAe,IAAI;AACnD,QAAI,iBAAiB,WAAW,aAAa;AAE7C,WAAO,MAAM;AACX,UAAI,oBAAoB,WAAW,eAAe,IAAI;AACtD,UAAI,oBAAoB,WAAW,eAAe,IAAI;AACtD,UAAI,oBAAoB,WAAW,aAAa;AAAA,IAClD;AAAA,EACF,GAAG,CAAC,OAAO,QAAQ,CAAC;AACtB;"}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
const decoding = require("lib0/decoding");
|
|
4
|
+
function _interopNamespaceDefault(e) {
|
|
5
|
+
const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } });
|
|
6
|
+
if (e) {
|
|
7
|
+
for (const k in e) {
|
|
8
|
+
if (k !== "default") {
|
|
9
|
+
const d = Object.getOwnPropertyDescriptor(e, k);
|
|
10
|
+
Object.defineProperty(n, k, d.get ? d : {
|
|
11
|
+
enumerable: true,
|
|
12
|
+
get: () => e[k]
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
n.default = e;
|
|
18
|
+
return Object.freeze(n);
|
|
19
|
+
}
|
|
20
|
+
const decoding__namespace = /* @__PURE__ */ _interopNamespaceDefault(decoding);
|
|
21
|
+
const INITIAL_SYNC_PARAM = "initialSync";
|
|
22
|
+
const INITIAL_SYNC_DIFF = "diff";
|
|
23
|
+
const INITIAL_SYNC_CAPABILITY_HEADER = "x-keryx-initial-sync";
|
|
24
|
+
function keryxRoomReadUrl({
|
|
25
|
+
wsUrl,
|
|
26
|
+
token,
|
|
27
|
+
branch
|
|
28
|
+
}) {
|
|
29
|
+
let parsed;
|
|
30
|
+
try {
|
|
31
|
+
parsed = new URL(wsUrl);
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const scheme = parsed.protocol === "wss:" ? "https:" : parsed.protocol === "ws:" ? "http:" : null;
|
|
36
|
+
if (scheme === null) return null;
|
|
37
|
+
const match = /^(.*?)\/(?:api\/ws\/v1|ws)\/([^/]+)\/([^/]+)\/?$/.exec(parsed.pathname);
|
|
38
|
+
if (match === null) return null;
|
|
39
|
+
const [, prefix = "", org = "", docId = ""] = match;
|
|
40
|
+
const read = new URL(`${scheme}//${parsed.host}${prefix}/api/ydoc/v1/${org}/${docId}`);
|
|
41
|
+
read.searchParams.set("yauth", token);
|
|
42
|
+
read.searchParams.set("branch", branch);
|
|
43
|
+
return read.toString();
|
|
44
|
+
}
|
|
45
|
+
async function readRoomOverHttp({
|
|
46
|
+
url,
|
|
47
|
+
signal,
|
|
48
|
+
fetchImpl = fetch
|
|
49
|
+
}) {
|
|
50
|
+
var _a;
|
|
51
|
+
const response = await fetchImpl(url, { headers: { Accept: "application/octet-stream" }, signal });
|
|
52
|
+
if (!response.ok) {
|
|
53
|
+
await ((_a = response.body) == null ? void 0 : _a.cancel().catch(() => void 0));
|
|
54
|
+
throw new Error(`GET /ydoc answered ${response.status}`);
|
|
55
|
+
}
|
|
56
|
+
const body = new Uint8Array(await response.arrayBuffer());
|
|
57
|
+
const decoded = decoding__namespace.readAny(decoding__namespace.createDecoder(body));
|
|
58
|
+
if (decoded === null || typeof decoded !== "object" || !(decoded.doc instanceof Uint8Array)) {
|
|
59
|
+
throw new Error("GET /ydoc answered a body without a document");
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
// a copy: lib0 hands out a view into the body
|
|
63
|
+
update: new Uint8Array(decoded.doc),
|
|
64
|
+
answersSyncStep1: response.headers.get(INITIAL_SYNC_CAPABILITY_HEADER) === INITIAL_SYNC_DIFF,
|
|
65
|
+
bytes: body.byteLength
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const MEMO_PREFIX = "keryx-initial-sync:";
|
|
69
|
+
const INITIAL_SYNC_UNSUPPORTED_TTL_MS = 60 * 60 * 1e3;
|
|
70
|
+
const INITIAL_SYNC_SUPPORTED_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
71
|
+
function keryxOriginOf(wsUrl) {
|
|
72
|
+
try {
|
|
73
|
+
const parsed = new URL(wsUrl);
|
|
74
|
+
return `${parsed.protocol}//${parsed.host}`;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function rememberInitialSyncSupport({
|
|
80
|
+
storage,
|
|
81
|
+
origin,
|
|
82
|
+
supported,
|
|
83
|
+
now = Date.now()
|
|
84
|
+
}) {
|
|
85
|
+
try {
|
|
86
|
+
storage == null ? void 0 : storage.setItem(`${MEMO_PREFIX}${origin}`, JSON.stringify({ supported, at: now }));
|
|
87
|
+
} catch {
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function knownInitialSyncSupport({
|
|
91
|
+
storage,
|
|
92
|
+
origin,
|
|
93
|
+
now = Date.now()
|
|
94
|
+
}) {
|
|
95
|
+
let raw = null;
|
|
96
|
+
try {
|
|
97
|
+
raw = (storage == null ? void 0 : storage.getItem(`${MEMO_PREFIX}${origin}`)) ?? null;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
if (raw === null) return null;
|
|
102
|
+
try {
|
|
103
|
+
const memo = JSON.parse(raw);
|
|
104
|
+
if (typeof memo.supported !== "boolean" || typeof memo.at !== "number") return null;
|
|
105
|
+
const ttl = memo.supported ? INITIAL_SYNC_SUPPORTED_TTL_MS : INITIAL_SYNC_UNSUPPORTED_TTL_MS;
|
|
106
|
+
return now - memo.at <= ttl ? memo.supported : null;
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
exports.INITIAL_SYNC_CAPABILITY_HEADER = INITIAL_SYNC_CAPABILITY_HEADER;
|
|
112
|
+
exports.INITIAL_SYNC_DIFF = INITIAL_SYNC_DIFF;
|
|
113
|
+
exports.INITIAL_SYNC_PARAM = INITIAL_SYNC_PARAM;
|
|
114
|
+
exports.INITIAL_SYNC_SUPPORTED_TTL_MS = INITIAL_SYNC_SUPPORTED_TTL_MS;
|
|
115
|
+
exports.INITIAL_SYNC_UNSUPPORTED_TTL_MS = INITIAL_SYNC_UNSUPPORTED_TTL_MS;
|
|
116
|
+
exports.keryxOriginOf = keryxOriginOf;
|
|
117
|
+
exports.keryxRoomReadUrl = keryxRoomReadUrl;
|
|
118
|
+
exports.knownInitialSyncSupport = knownInitialSyncSupport;
|
|
119
|
+
exports.readRoomOverHttp = readRoomOverHttp;
|
|
120
|
+
exports.rememberInitialSyncSupport = rememberInitialSyncSupport;
|
|
121
|
+
//# sourceMappingURL=initial-sync.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"initial-sync.cjs","sources":["../../../../keryx-http/src/initial-sync.ts"],"sourcesContent":["/**\n * Opening a big room HTTP-first (Word at scale, 2026-11-02).\n *\n * A Keryx socket's initial sync step 2 is the whole document, sent as one frame that uWS deflates on\n * Keryx's event loop: 1.4-1.8 s for a 132 MiB Word room, during which no other room on that server\n * is served, then 17 MiB on the wire. A Keryx that carries the `@y/hub` patch's diffed initial sync\n * answers a socket that connects with `?initialSync=diff` with only what the client lacks past its\n * own sync step 1, and serves `GET /ydoc` compressed (zstd, brotli or gzip, as the browser names\n * them) from a kept copy. So a client opening a big room reads it over HTTP first - decoded by the\n * browser as it downloads - applies it as the server's state, and opens its socket asking for the\n * rest: usually just the delete set.\n *\n * A Keryx says it answers that way in its `GET /ydoc` answer to a browser\n * (`X-Keryx-Initial-Sync: diff`, exposed to cross-origin readers). A Keryx without the patch says\n * nothing: its document is then not used, the socket opens as before, and the origin is remembered\n * as one that does not answer, so the next open skips the read.\n */\n\nimport * as decoding from 'lib0/decoding';\n\n/** The socket query parameter, and its one value, that ask for a diffed initial sync. */\nexport const INITIAL_SYNC_PARAM = 'initialSync';\nexport const INITIAL_SYNC_DIFF = 'diff';\n\n/** The `GET /ydoc` response header with which a Keryx says it answers `?initialSync=diff`. */\nexport const INITIAL_SYNC_CAPABILITY_HEADER = 'x-keryx-initial-sync';\n\n/**\n * The `GET /ydoc` URL of the room a socket URL names: `wss://host/ws/<org>/<docId>` (or the\n * `/api/ws/v1/` spelling) becomes `https://host/api/ydoc/v1/<org>/<docId>?yauth=…&branch=…`. `null`\n * for a URL of any other shape.\n */\nexport function keryxRoomReadUrl({\n wsUrl,\n token,\n branch,\n}: {\n wsUrl: string;\n token: string;\n branch: string;\n}): string | null {\n let parsed: URL;\n try {\n parsed = new URL(wsUrl);\n } catch {\n return null;\n }\n const scheme = parsed.protocol === 'wss:' ? 'https:' : parsed.protocol === 'ws:' ? 'http:' : null;\n if (scheme === null) return null;\n const match = /^(.*?)\\/(?:api\\/ws\\/v1|ws)\\/([^/]+)\\/([^/]+)\\/?$/.exec(parsed.pathname);\n if (match === null) return null;\n const [, prefix = '', org = '', docId = ''] = match;\n const read = new URL(`${scheme}//${parsed.host}${prefix}/api/ydoc/v1/${org}/${docId}`);\n read.searchParams.set('yauth', token);\n read.searchParams.set('branch', branch);\n return read.toString();\n}\n\n/** A room read over HTTP. */\nexport interface RoomReadOverHttp {\n /** The room's state as one update (empty for a room never written). */\n update: Uint8Array;\n /** Keryx said it answers a socket's sync step 1 (`INITIAL_SYNC_CAPABILITY_HEADER`). */\n answersSyncStep1: boolean;\n /** The decoded body's size. */\n bytes: number;\n}\n\n/**\n * Read a room over `GET /ydoc` (`url` from {@link keryxRoomReadUrl}). The runtime's `fetch` names\n * and decodes the content codings. Throws on a non-2xx answer or a body that is not a lib0 `{ doc }`.\n */\nexport async function readRoomOverHttp({\n url,\n signal,\n fetchImpl = fetch,\n}: {\n url: string;\n signal?: AbortSignal;\n fetchImpl?: typeof fetch;\n}): Promise<RoomReadOverHttp> {\n const response = await fetchImpl(url, { headers: { Accept: 'application/octet-stream' }, signal });\n if (!response.ok) {\n await response.body?.cancel().catch(() => undefined);\n throw new Error(`GET /ydoc answered ${response.status}`);\n }\n const body = new Uint8Array(await response.arrayBuffer());\n const decoded = decoding.readAny(decoding.createDecoder(body)) as { doc?: unknown } | null;\n if (decoded === null || typeof decoded !== 'object' || !(decoded.doc instanceof Uint8Array)) {\n throw new Error('GET /ydoc answered a body without a document');\n }\n return {\n // a copy: lib0 hands out a view into the body\n update: new Uint8Array(decoded.doc),\n answersSyncStep1: response.headers.get(INITIAL_SYNC_CAPABILITY_HEADER) === INITIAL_SYNC_DIFF,\n bytes: body.byteLength,\n };\n}\n\n/** Where {@link rememberInitialSyncSupport} keeps what it learned: `localStorage`'s surface. */\nexport interface InitialSyncSupportStorage {\n getItem(key: string): string | null;\n setItem(key: string, value: string): void;\n}\n\nconst MEMO_PREFIX = 'keryx-initial-sync:';\n/** A Keryx that answered without the header is asked again an hour later: it may have been upgraded. */\nexport const INITIAL_SYNC_UNSUPPORTED_TTL_MS = 60 * 60 * 1000;\n/** A Keryx that answers is believed for a week; one rolled back meanwhile just sends whole documents. */\nexport const INITIAL_SYNC_SUPPORTED_TTL_MS = 7 * 24 * 60 * 60 * 1000;\n\n/** The origin a socket URL connects to, as the memo keys it (`wss://host`). */\nexport function keryxOriginOf(wsUrl: string): string | null {\n try {\n const parsed = new URL(wsUrl);\n return `${parsed.protocol}//${parsed.host}`;\n } catch {\n return null;\n }\n}\n\n/** Remember whether the Keryx at `origin` answered with the capability header. */\nexport function rememberInitialSyncSupport({\n storage,\n origin,\n supported,\n now = Date.now(),\n}: {\n storage: InitialSyncSupportStorage | null;\n origin: string;\n supported: boolean;\n now?: number;\n}): void {\n try {\n storage?.setItem(`${MEMO_PREFIX}${origin}`, JSON.stringify({ supported, at: now }));\n } catch {\n // a full or refused storage only costs the next open a read\n }\n}\n\n/**\n * What is known of the Keryx at `origin`: `true` (it answers a socket's sync step 1), `false` (it\n * did not, within the last hour) or `null` (not known: read it and see).\n */\nexport function knownInitialSyncSupport({\n storage,\n origin,\n now = Date.now(),\n}: {\n storage: InitialSyncSupportStorage | null;\n origin: string;\n now?: number;\n}): boolean | null {\n let raw: string | null = null;\n try {\n raw = storage?.getItem(`${MEMO_PREFIX}${origin}`) ?? null;\n } catch {\n return null;\n }\n if (raw === null) return null;\n try {\n const memo = JSON.parse(raw) as { supported?: unknown; at?: unknown };\n if (typeof memo.supported !== 'boolean' || typeof memo.at !== 'number') return null;\n const ttl = memo.supported ? INITIAL_SYNC_SUPPORTED_TTL_MS : INITIAL_SYNC_UNSUPPORTED_TTL_MS;\n return now - memo.at <= ttl ? memo.supported : null;\n } catch {\n return null;\n }\n}\n"],"names":["decoding"],"mappings":";;;;;;;;;;;;;;;;;;;;AAqBO,MAAM,qBAAqB;AAC3B,MAAM,oBAAoB;AAG1B,MAAM,iCAAiC;AAOvC,SAAS,iBAAiB;AAAA,EAC/B;AAAA,EACA;AAAA,EACA;AACF,GAIkB;AAChB,MAAI;AACJ,MAAI;AACF,aAAS,IAAI,IAAI,KAAK;AAAA,EACxB,QAAQ;AACN,WAAO;AAAA,EACT;AACA,QAAM,SAAS,OAAO,aAAa,SAAS,WAAW,OAAO,aAAa,QAAQ,UAAU;AAC7F,MAAI,WAAW,KAAM,QAAO;AAC5B,QAAM,QAAQ,mDAAmD,KAAK,OAAO,QAAQ;AACrF,MAAI,UAAU,KAAM,QAAO;AAC3B,QAAM,CAAA,EAAG,SAAS,IAAI,MAAM,IAAI,QAAQ,EAAE,IAAI;AAC9C,QAAM,OAAO,IAAI,IAAI,GAAG,MAAM,KAAK,OAAO,IAAI,GAAG,MAAM,gBAAgB,GAAG,IAAI,KAAK,EAAE;AACrF,OAAK,aAAa,IAAI,SAAS,KAAK;AACpC,OAAK,aAAa,IAAI,UAAU,MAAM;AACtC,SAAO,KAAK,SAAA;AACd;AAgBA,eAAsB,iBAAiB;AAAA,EACrC;AAAA,EACA;AAAA,EACA,YAAY;AACd,GAI8B;;AAC5B,QAAM,WAAW,MAAM,UAAU,KAAK,EAAE,SAAS,EAAE,QAAQ,8BAA8B,QAAQ;AACjG,MAAI,CAAC,SAAS,IAAI;AAChB,YAAM,cAAS,SAAT,mBAAe,SAAS,MAAM,MAAM;AAC1C,UAAM,IAAI,MAAM,sBAAsB,SAAS,MAAM,EAAE;AAAA,EACzD;AACA,QAAM,OAAO,IAAI,WAAW,MAAM,SAAS,aAAa;AACxD,QAAM,UAAUA,oBAAS,QAAQA,oBAAS,cAAc,IAAI,CAAC;AAC7D,MAAI,YAAY,QAAQ,OAAO,YAAY,YAAY,EAAE,QAAQ,eAAe,aAAa;AAC3F,UAAM,IAAI,MAAM,8CAA8C;AAAA,EAChE;AACA,SAAO;AAAA;AAAA,IAEL,QAAQ,IAAI,WAAW,QAAQ,GAAG;AAAA,IAClC,kBAAkB,SAAS,QAAQ,IAAI,8BAA8B,MAAM;AAAA,IAC3E,OAAO,KAAK;AAAA,EAAA;AAEhB;AAQA,MAAM,cAAc;AAEb,MAAM,kCAAkC,KAAK,KAAK;AAElD,MAAM,gCAAgC,IAAI,KAAK,KAAK,KAAK;AAGzD,SAAS,cAAc,OAA8B;AAC1D,MAAI;AACF,UAAM,SAAS,IAAI,IAAI,KAAK;AAC5B,WAAO,GAAG,OAAO,QAAQ,KAAK,OAAO,IAAI;AAAA,EAC3C,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAGO,SAAS,2BAA2B;AAAA,EACzC;AAAA,EACA;AAAA,EACA;AAAA,EACA,MAAM,KAAK,IAAA;AACb,GAKS;AACP,MAAI;AACF,uCAAS,QAAQ,GAAG,WAAW,GAAG,MAAM,IAAI,KAAK,UAAU,EAAE,WAAW,IAAI,IAAA,CAAK;AAAA,EACnF,QAAQ;AAAA,EAER;AACF;AAMO,SAAS,wBAAwB;AAAA,EACtC;AAAA,EACA;AAAA,EACA,MAAM,KAAK,IAAA;AACb,GAImB;AACjB,MAAI,MAAqB;AACzB,MAAI;AACF,WAAM,mCAAS,QAAQ,GAAG,WAAW,GAAG,MAAM,QAAO;AAAA,EACvD,QAAQ;AACN,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,KAAM,QAAO;AACzB,MAAI;AACF,UAAM,OAAO,KAAK,MAAM,GAAG;AAC3B,QAAI,OAAO,KAAK,cAAc,aAAa,OAAO,KAAK,OAAO,SAAU,QAAO;AAC/E,UAAM,MAAM,KAAK,YAAY,gCAAgC;AAC7D,WAAO,MAAM,KAAK,MAAM,MAAM,KAAK,YAAY;AAAA,EACjD,QAAQ;AACN,WAAO;AAAA,EACT;AACF;;;;;;;;;;;"}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opening a big room HTTP-first (Word at scale, 2026-11-02).
|
|
3
|
+
*
|
|
4
|
+
* A Keryx socket's initial sync step 2 is the whole document, sent as one frame that uWS deflates on
|
|
5
|
+
* Keryx's event loop: 1.4-1.8 s for a 132 MiB Word room, during which no other room on that server
|
|
6
|
+
* is served, then 17 MiB on the wire. A Keryx that carries the `@y/hub` patch's diffed initial sync
|
|
7
|
+
* answers a socket that connects with `?initialSync=diff` with only what the client lacks past its
|
|
8
|
+
* own sync step 1, and serves `GET /ydoc` compressed (zstd, brotli or gzip, as the browser names
|
|
9
|
+
* them) from a kept copy. So a client opening a big room reads it over HTTP first - decoded by the
|
|
10
|
+
* browser as it downloads - applies it as the server's state, and opens its socket asking for the
|
|
11
|
+
* rest: usually just the delete set.
|
|
12
|
+
*
|
|
13
|
+
* A Keryx says it answers that way in its `GET /ydoc` answer to a browser
|
|
14
|
+
* (`X-Keryx-Initial-Sync: diff`, exposed to cross-origin readers). A Keryx without the patch says
|
|
15
|
+
* nothing: its document is then not used, the socket opens as before, and the origin is remembered
|
|
16
|
+
* as one that does not answer, so the next open skips the read.
|
|
17
|
+
*/
|
|
18
|
+
/** The socket query parameter, and its one value, that ask for a diffed initial sync. */
|
|
19
|
+
export declare const INITIAL_SYNC_PARAM = "initialSync";
|
|
20
|
+
export declare const INITIAL_SYNC_DIFF = "diff";
|
|
21
|
+
/** The `GET /ydoc` response header with which a Keryx says it answers `?initialSync=diff`. */
|
|
22
|
+
export declare const INITIAL_SYNC_CAPABILITY_HEADER = "x-keryx-initial-sync";
|
|
23
|
+
/**
|
|
24
|
+
* The `GET /ydoc` URL of the room a socket URL names: `wss://host/ws/<org>/<docId>` (or the
|
|
25
|
+
* `/api/ws/v1/` spelling) becomes `https://host/api/ydoc/v1/<org>/<docId>?yauth=…&branch=…`. `null`
|
|
26
|
+
* for a URL of any other shape.
|
|
27
|
+
*/
|
|
28
|
+
export declare function keryxRoomReadUrl({ wsUrl, token, branch, }: {
|
|
29
|
+
wsUrl: string;
|
|
30
|
+
token: string;
|
|
31
|
+
branch: string;
|
|
32
|
+
}): string | null;
|
|
33
|
+
/** A room read over HTTP. */
|
|
34
|
+
export interface RoomReadOverHttp {
|
|
35
|
+
/** The room's state as one update (empty for a room never written). */
|
|
36
|
+
update: Uint8Array;
|
|
37
|
+
/** Keryx said it answers a socket's sync step 1 (`INITIAL_SYNC_CAPABILITY_HEADER`). */
|
|
38
|
+
answersSyncStep1: boolean;
|
|
39
|
+
/** The decoded body's size. */
|
|
40
|
+
bytes: number;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Read a room over `GET /ydoc` (`url` from {@link keryxRoomReadUrl}). The runtime's `fetch` names
|
|
44
|
+
* and decodes the content codings. Throws on a non-2xx answer or a body that is not a lib0 `{ doc }`.
|
|
45
|
+
*/
|
|
46
|
+
export declare function readRoomOverHttp({ url, signal, fetchImpl, }: {
|
|
47
|
+
url: string;
|
|
48
|
+
signal?: AbortSignal;
|
|
49
|
+
fetchImpl?: typeof fetch;
|
|
50
|
+
}): Promise<RoomReadOverHttp>;
|
|
51
|
+
/** Where {@link rememberInitialSyncSupport} keeps what it learned: `localStorage`'s surface. */
|
|
52
|
+
export interface InitialSyncSupportStorage {
|
|
53
|
+
getItem(key: string): string | null;
|
|
54
|
+
setItem(key: string, value: string): void;
|
|
55
|
+
}
|
|
56
|
+
/** A Keryx that answered without the header is asked again an hour later: it may have been upgraded. */
|
|
57
|
+
export declare const INITIAL_SYNC_UNSUPPORTED_TTL_MS: number;
|
|
58
|
+
/** A Keryx that answers is believed for a week; one rolled back meanwhile just sends whole documents. */
|
|
59
|
+
export declare const INITIAL_SYNC_SUPPORTED_TTL_MS: number;
|
|
60
|
+
/** The origin a socket URL connects to, as the memo keys it (`wss://host`). */
|
|
61
|
+
export declare function keryxOriginOf(wsUrl: string): string | null;
|
|
62
|
+
/** Remember whether the Keryx at `origin` answered with the capability header. */
|
|
63
|
+
export declare function rememberInitialSyncSupport({ storage, origin, supported, now, }: {
|
|
64
|
+
storage: InitialSyncSupportStorage | null;
|
|
65
|
+
origin: string;
|
|
66
|
+
supported: boolean;
|
|
67
|
+
now?: number;
|
|
68
|
+
}): void;
|
|
69
|
+
/**
|
|
70
|
+
* What is known of the Keryx at `origin`: `true` (it answers a socket's sync step 1), `false` (it
|
|
71
|
+
* did not, within the last hour) or `null` (not known: read it and see).
|
|
72
|
+
*/
|
|
73
|
+
export declare function knownInitialSyncSupport({ storage, origin, now, }: {
|
|
74
|
+
storage: InitialSyncSupportStorage | null;
|
|
75
|
+
origin: string;
|
|
76
|
+
now?: number;
|
|
77
|
+
}): boolean | null;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import * as decoding from "lib0/decoding";
|
|
2
|
+
const INITIAL_SYNC_PARAM = "initialSync";
|
|
3
|
+
const INITIAL_SYNC_DIFF = "diff";
|
|
4
|
+
const INITIAL_SYNC_CAPABILITY_HEADER = "x-keryx-initial-sync";
|
|
5
|
+
function keryxRoomReadUrl({
|
|
6
|
+
wsUrl,
|
|
7
|
+
token,
|
|
8
|
+
branch
|
|
9
|
+
}) {
|
|
10
|
+
let parsed;
|
|
11
|
+
try {
|
|
12
|
+
parsed = new URL(wsUrl);
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const scheme = parsed.protocol === "wss:" ? "https:" : parsed.protocol === "ws:" ? "http:" : null;
|
|
17
|
+
if (scheme === null) return null;
|
|
18
|
+
const match = /^(.*?)\/(?:api\/ws\/v1|ws)\/([^/]+)\/([^/]+)\/?$/.exec(parsed.pathname);
|
|
19
|
+
if (match === null) return null;
|
|
20
|
+
const [, prefix = "", org = "", docId = ""] = match;
|
|
21
|
+
const read = new URL(`${scheme}//${parsed.host}${prefix}/api/ydoc/v1/${org}/${docId}`);
|
|
22
|
+
read.searchParams.set("yauth", token);
|
|
23
|
+
read.searchParams.set("branch", branch);
|
|
24
|
+
return read.toString();
|
|
25
|
+
}
|
|
26
|
+
async function readRoomOverHttp({
|
|
27
|
+
url,
|
|
28
|
+
signal,
|
|
29
|
+
fetchImpl = fetch
|
|
30
|
+
}) {
|
|
31
|
+
var _a;
|
|
32
|
+
const response = await fetchImpl(url, { headers: { Accept: "application/octet-stream" }, signal });
|
|
33
|
+
if (!response.ok) {
|
|
34
|
+
await ((_a = response.body) == null ? void 0 : _a.cancel().catch(() => void 0));
|
|
35
|
+
throw new Error(`GET /ydoc answered ${response.status}`);
|
|
36
|
+
}
|
|
37
|
+
const body = new Uint8Array(await response.arrayBuffer());
|
|
38
|
+
const decoded = decoding.readAny(decoding.createDecoder(body));
|
|
39
|
+
if (decoded === null || typeof decoded !== "object" || !(decoded.doc instanceof Uint8Array)) {
|
|
40
|
+
throw new Error("GET /ydoc answered a body without a document");
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
// a copy: lib0 hands out a view into the body
|
|
44
|
+
update: new Uint8Array(decoded.doc),
|
|
45
|
+
answersSyncStep1: response.headers.get(INITIAL_SYNC_CAPABILITY_HEADER) === INITIAL_SYNC_DIFF,
|
|
46
|
+
bytes: body.byteLength
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const MEMO_PREFIX = "keryx-initial-sync:";
|
|
50
|
+
const INITIAL_SYNC_UNSUPPORTED_TTL_MS = 60 * 60 * 1e3;
|
|
51
|
+
const INITIAL_SYNC_SUPPORTED_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
52
|
+
function keryxOriginOf(wsUrl) {
|
|
53
|
+
try {
|
|
54
|
+
const parsed = new URL(wsUrl);
|
|
55
|
+
return `${parsed.protocol}//${parsed.host}`;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function rememberInitialSyncSupport({
|
|
61
|
+
storage,
|
|
62
|
+
origin,
|
|
63
|
+
supported,
|
|
64
|
+
now = Date.now()
|
|
65
|
+
}) {
|
|
66
|
+
try {
|
|
67
|
+
storage == null ? void 0 : storage.setItem(`${MEMO_PREFIX}${origin}`, JSON.stringify({ supported, at: now }));
|
|
68
|
+
} catch {
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function knownInitialSyncSupport({
|
|
72
|
+
storage,
|
|
73
|
+
origin,
|
|
74
|
+
now = Date.now()
|
|
75
|
+
}) {
|
|
76
|
+
let raw = null;
|
|
77
|
+
try {
|
|
78
|
+
raw = (storage == null ? void 0 : storage.getItem(`${MEMO_PREFIX}${origin}`)) ?? null;
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
if (raw === null) return null;
|
|
83
|
+
try {
|
|
84
|
+
const memo = JSON.parse(raw);
|
|
85
|
+
if (typeof memo.supported !== "boolean" || typeof memo.at !== "number") return null;
|
|
86
|
+
const ttl = memo.supported ? INITIAL_SYNC_SUPPORTED_TTL_MS : INITIAL_SYNC_UNSUPPORTED_TTL_MS;
|
|
87
|
+
return now - memo.at <= ttl ? memo.supported : null;
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export {
|
|
93
|
+
INITIAL_SYNC_CAPABILITY_HEADER,
|
|
94
|
+
INITIAL_SYNC_DIFF,
|
|
95
|
+
INITIAL_SYNC_PARAM,
|
|
96
|
+
INITIAL_SYNC_SUPPORTED_TTL_MS,
|
|
97
|
+
INITIAL_SYNC_UNSUPPORTED_TTL_MS,
|
|
98
|
+
keryxOriginOf,
|
|
99
|
+
keryxRoomReadUrl,
|
|
100
|
+
knownInitialSyncSupport,
|
|
101
|
+
readRoomOverHttp,
|
|
102
|
+
rememberInitialSyncSupport
|
|
103
|
+
};
|
|
104
|
+
//# sourceMappingURL=initial-sync.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"initial-sync.js","sources":["../../../../keryx-http/src/initial-sync.ts"],"sourcesContent":["/**\n * Opening a big room HTTP-first (Word at scale, 2026-11-02).\n *\n * A Keryx socket's initial sync step 2 is the whole document, sent as one frame that uWS deflates on\n * Keryx's event loop: 1.4-1.8 s for a 132 MiB Word room, during which no other room on that server\n * is served, then 17 MiB on the wire. A Keryx that carries the `@y/hub` patch's diffed initial sync\n * answers a socket that connects with `?initialSync=diff` with only what the client lacks past its\n * own sync step 1, and serves `GET /ydoc` compressed (zstd, brotli or gzip, as the browser names\n * them) from a kept copy. So a client opening a big room reads it over HTTP first - decoded by the\n * browser as it downloads - applies it as the server's state, and opens its socket asking for the\n * rest: usually just the delete set.\n *\n * A Keryx says it answers that way in its `GET /ydoc` answer to a browser\n * (`X-Keryx-Initial-Sync: diff`, exposed to cross-origin readers). A Keryx without the patch says\n * nothing: its document is then not used, the socket opens as before, and the origin is remembered\n * as one that does not answer, so the next open skips the read.\n */\n\nimport * as decoding from 'lib0/decoding';\n\n/** The socket query parameter, and its one value, that ask for a diffed initial sync. */\nexport const INITIAL_SYNC_PARAM = 'initialSync';\nexport const INITIAL_SYNC_DIFF = 'diff';\n\n/** The `GET /ydoc` response header with which a Keryx says it answers `?initialSync=diff`. */\nexport const INITIAL_SYNC_CAPABILITY_HEADER = 'x-keryx-initial-sync';\n\n/**\n * The `GET /ydoc` URL of the room a socket URL names: `wss://host/ws/<org>/<docId>` (or the\n * `/api/ws/v1/` spelling) becomes `https://host/api/ydoc/v1/<org>/<docId>?yauth=…&branch=…`. `null`\n * for a URL of any other shape.\n */\nexport function keryxRoomReadUrl({\n wsUrl,\n token,\n branch,\n}: {\n wsUrl: string;\n token: string;\n branch: string;\n}): string | null {\n let parsed: URL;\n try {\n parsed = new URL(wsUrl);\n } catch {\n return null;\n }\n const scheme = parsed.protocol === 'wss:' ? 'https:' : parsed.protocol === 'ws:' ? 'http:' : null;\n if (scheme === null) return null;\n const match = /^(.*?)\\/(?:api\\/ws\\/v1|ws)\\/([^/]+)\\/([^/]+)\\/?$/.exec(parsed.pathname);\n if (match === null) return null;\n const [, prefix = '', org = '', docId = ''] = match;\n const read = new URL(`${scheme}//${parsed.host}${prefix}/api/ydoc/v1/${org}/${docId}`);\n read.searchParams.set('yauth', token);\n read.searchParams.set('branch', branch);\n return read.toString();\n}\n\n/** A room read over HTTP. */\nexport interface RoomReadOverHttp {\n /** The room's state as one update (empty for a room never written). */\n update: Uint8Array;\n /** Keryx said it answers a socket's sync step 1 (`INITIAL_SYNC_CAPABILITY_HEADER`). */\n answersSyncStep1: boolean;\n /** The decoded body's size. */\n bytes: number;\n}\n\n/**\n * Read a room over `GET /ydoc` (`url` from {@link keryxRoomReadUrl}). The runtime's `fetch` names\n * and decodes the content codings. Throws on a non-2xx answer or a body that is not a lib0 `{ doc }`.\n */\nexport async function readRoomOverHttp({\n url,\n signal,\n fetchImpl = fetch,\n}: {\n url: string;\n signal?: AbortSignal;\n fetchImpl?: typeof fetch;\n}): Promise<RoomReadOverHttp> {\n const response = await fetchImpl(url, { headers: { Accept: 'application/octet-stream' }, signal });\n if (!response.ok) {\n await response.body?.cancel().catch(() => undefined);\n throw new Error(`GET /ydoc answered ${response.status}`);\n }\n const body = new Uint8Array(await response.arrayBuffer());\n const decoded = decoding.readAny(decoding.createDecoder(body)) as { doc?: unknown } | null;\n if (decoded === null || typeof decoded !== 'object' || !(decoded.doc instanceof Uint8Array)) {\n throw new Error('GET /ydoc answered a body without a document');\n }\n return {\n // a copy: lib0 hands out a view into the body\n update: new Uint8Array(decoded.doc),\n answersSyncStep1: response.headers.get(INITIAL_SYNC_CAPABILITY_HEADER) === INITIAL_SYNC_DIFF,\n bytes: body.byteLength,\n };\n}\n\n/** Where {@link rememberInitialSyncSupport} keeps what it learned: `localStorage`'s surface. */\nexport interface InitialSyncSupportStorage {\n getItem(key: string): string | null;\n setItem(key: string, value: string): void;\n}\n\nconst MEMO_PREFIX = 'keryx-initial-sync:';\n/** A Keryx that answered without the header is asked again an hour later: it may have been upgraded. */\nexport const INITIAL_SYNC_UNSUPPORTED_TTL_MS = 60 * 60 * 1000;\n/** A Keryx that answers is believed for a week; one rolled back meanwhile just sends whole documents. */\nexport const INITIAL_SYNC_SUPPORTED_TTL_MS = 7 * 24 * 60 * 60 * 1000;\n\n/** The origin a socket URL connects to, as the memo keys it (`wss://host`). */\nexport function keryxOriginOf(wsUrl: string): string | null {\n try {\n const parsed = new URL(wsUrl);\n return `${parsed.protocol}//${parsed.host}`;\n } catch {\n return null;\n }\n}\n\n/** Remember whether the Keryx at `origin` answered with the capability header. */\nexport function rememberInitialSyncSupport({\n storage,\n origin,\n supported,\n now = Date.now(),\n}: {\n storage: InitialSyncSupportStorage | null;\n origin: string;\n supported: boolean;\n now?: number;\n}): void {\n try {\n storage?.setItem(`${MEMO_PREFIX}${origin}`, JSON.stringify({ supported, at: now }));\n } catch {\n // a full or refused storage only costs the next open a read\n }\n}\n\n/**\n * What is known of the Keryx at `origin`: `true` (it answers a socket's sync step 1), `false` (it\n * did not, within the last hour) or `null` (not known: read it and see).\n */\nexport function knownInitialSyncSupport({\n storage,\n origin,\n now = Date.now(),\n}: {\n storage: InitialSyncSupportStorage | null;\n origin: string;\n now?: number;\n}): boolean | null {\n let raw: string | null = null;\n try {\n raw = storage?.getItem(`${MEMO_PREFIX}${origin}`) ?? null;\n } catch {\n return null;\n }\n if (raw === null) return null;\n try {\n const memo = JSON.parse(raw) as { supported?: unknown; at?: unknown };\n if (typeof memo.supported !== 'boolean' || typeof memo.at !== 'number') return null;\n const ttl = memo.supported ? INITIAL_SYNC_SUPPORTED_TTL_MS : INITIAL_SYNC_UNSUPPORTED_TTL_MS;\n return now - memo.at <= ttl ? memo.supported : null;\n } catch {\n return null;\n }\n}\n"],"names":[],"mappings":";AAqBO,MAAM,qBAAqB;AAC3B,MAAM,oBAAoB;AAG1B,MAAM,iCAAiC;AAOvC,SAAS,iBAAiB;AAAA,EAC/B;AAAA,EACA;AAAA,EACA;AACF,GAIkB;AAChB,MAAI;AACJ,MAAI;AACF,aAAS,IAAI,IAAI,KAAK;AAAA,EACxB,QAAQ;AACN,WAAO;AAAA,EACT;AACA,QAAM,SAAS,OAAO,aAAa,SAAS,WAAW,OAAO,aAAa,QAAQ,UAAU;AAC7F,MAAI,WAAW,KAAM,QAAO;AAC5B,QAAM,QAAQ,mDAAmD,KAAK,OAAO,QAAQ;AACrF,MAAI,UAAU,KAAM,QAAO;AAC3B,QAAM,CAAA,EAAG,SAAS,IAAI,MAAM,IAAI,QAAQ,EAAE,IAAI;AAC9C,QAAM,OAAO,IAAI,IAAI,GAAG,MAAM,KAAK,OAAO,IAAI,GAAG,MAAM,gBAAgB,GAAG,IAAI,KAAK,EAAE;AACrF,OAAK,aAAa,IAAI,SAAS,KAAK;AACpC,OAAK,aAAa,IAAI,UAAU,MAAM;AACtC,SAAO,KAAK,SAAA;AACd;AAgBA,eAAsB,iBAAiB;AAAA,EACrC;AAAA,EACA;AAAA,EACA,YAAY;AACd,GAI8B;;AAC5B,QAAM,WAAW,MAAM,UAAU,KAAK,EAAE,SAAS,EAAE,QAAQ,8BAA8B,QAAQ;AACjG,MAAI,CAAC,SAAS,IAAI;AAChB,YAAM,cAAS,SAAT,mBAAe,SAAS,MAAM,MAAM;AAC1C,UAAM,IAAI,MAAM,sBAAsB,SAAS,MAAM,EAAE;AAAA,EACzD;AACA,QAAM,OAAO,IAAI,WAAW,MAAM,SAAS,aAAa;AACxD,QAAM,UAAU,SAAS,QAAQ,SAAS,cAAc,IAAI,CAAC;AAC7D,MAAI,YAAY,QAAQ,OAAO,YAAY,YAAY,EAAE,QAAQ,eAAe,aAAa;AAC3F,UAAM,IAAI,MAAM,8CAA8C;AAAA,EAChE;AACA,SAAO;AAAA;AAAA,IAEL,QAAQ,IAAI,WAAW,QAAQ,GAAG;AAAA,IAClC,kBAAkB,SAAS,QAAQ,IAAI,8BAA8B,MAAM;AAAA,IAC3E,OAAO,KAAK;AAAA,EAAA;AAEhB;AAQA,MAAM,cAAc;AAEb,MAAM,kCAAkC,KAAK,KAAK;AAElD,MAAM,gCAAgC,IAAI,KAAK,KAAK,KAAK;AAGzD,SAAS,cAAc,OAA8B;AAC1D,MAAI;AACF,UAAM,SAAS,IAAI,IAAI,KAAK;AAC5B,WAAO,GAAG,OAAO,QAAQ,KAAK,OAAO,IAAI;AAAA,EAC3C,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAGO,SAAS,2BAA2B;AAAA,EACzC;AAAA,EACA;AAAA,EACA;AAAA,EACA,MAAM,KAAK,IAAA;AACb,GAKS;AACP,MAAI;AACF,uCAAS,QAAQ,GAAG,WAAW,GAAG,MAAM,IAAI,KAAK,UAAU,EAAE,WAAW,IAAI,IAAA,CAAK;AAAA,EACnF,QAAQ;AAAA,EAER;AACF;AAMO,SAAS,wBAAwB;AAAA,EACtC;AAAA,EACA;AAAA,EACA,MAAM,KAAK,IAAA;AACb,GAImB;AACjB,MAAI,MAAqB;AACzB,MAAI;AACF,WAAM,mCAAS,QAAQ,GAAG,WAAW,GAAG,MAAM,QAAO;AAAA,EACvD,QAAQ;AACN,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,KAAM,QAAO;AACzB,MAAI;AACF,UAAM,OAAO,KAAK,MAAM,GAAG;AAC3B,QAAI,OAAO,KAAK,cAAc,aAAa,OAAO,KAAK,OAAO,SAAU,QAAO;AAC/E,UAAM,MAAM,KAAK,YAAY,gCAAgC;AAC7D,WAAO,MAAM,KAAK,MAAM,MAAM,KAAK,YAAY;AAAA,EACjD,QAAQ;AACN,WAAO;AAAA,EACT;AACF;"}
|
|
@@ -99,6 +99,14 @@ class UnconfirmedUpdateAttributions {
|
|
|
99
99
|
get pending() {
|
|
100
100
|
return this.runs.map(copyOf);
|
|
101
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Whether the room keeps a write Keryx has not acknowledged. Such a room's socket must not ask for
|
|
104
|
+
* a diffed initial sync (`./initial-sync.ts`): it tells which kept writes Keryx already holds from
|
|
105
|
+
* the whole document Keryx sends when the socket opens, and a diff leaves out what it holds.
|
|
106
|
+
*/
|
|
107
|
+
get keepsWrites() {
|
|
108
|
+
return this.runs.length > 0;
|
|
109
|
+
}
|
|
102
110
|
/** Writes an earlier session kept, older than any written here: they go out first. */
|
|
103
111
|
restore(writes) {
|
|
104
112
|
this.runs = bounded([...writes.map(copyOf), ...this.runs]);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"unconfirmed-update-attributions.cjs","sources":["../../../../keryx-http/src/unconfirmed-update-attributions.ts"],"sourcesContent":["/**\n * The attributed writes a room has not seen Keryx acknowledge, kept so that a write made while the\n * room's socket is closed still reaches Keryx with its attributions.\n *\n * A write made inside `runWithDocUpdateAttributions` leaves as its own sync update followed by the\n * attributions trailer (`attributingWebSocket`), but only if the room's socket is open as it is\n * made. One made while it is closed (offline, reconnecting, before the first connection) reaches\n * Keryx inside the reconnect's sync step 2: the client's answer to Keryx's sync step 1, everything\n * it holds that Keryx lacks. Keryx reads no trailer after a sync step 2, so it would count the\n * write as the person's edit.\n *\n * So the room keeps the content ids of every write made in a scope, with the scope's attributions,\n * until Keryx sends those ids back: the echo of the update that carried them, or the whole document\n * Keryx sends when a socket opens (`received`; `@y/websocket` confirms its own unconfirmed updates\n * the same way). When the socket sends a sync step 2, each kept write's part of it goes out first,\n * oldest first, as its own sync update with its trailer, and the sync step 2 follows without those\n * ids (`framesFor`). The parts are cut from the sync step 2 itself, which already holds only what\n * Keryx lacks past its state vector. One socket keeps the order, so Keryx appends each marked update\n * before the person's sync step 2.\n *\n * A room Keryx holds nothing of (its sync step 1 names no client: a document created on the device,\n * its first push) is born whole: assets are born as one write of their real initial state, signed by\n * their creator, and housekeeping inside a new room is part of that state, not system work on\n * anyone's content. Its sync step 2 goes out whole, as the creator's write. What the room kept stays\n * kept until Keryx echoes the push, like any write: a push lost with the socket goes out whole again\n * if Keryx still holds nothing when the room reconnects, and is split like any reconnect's if\n * someone wrote to the room meanwhile.\n *\n * That answer waits for Keryx's document (`connect`). Keryx sends its sync step 1 and, in the same\n * cork, its whole document, but `@y/websocket` answers the sync step 1 as it arrives. A kept write\n * Keryx already appended (its echo lost with the socket) is still kept then, and the sync step 2\n * holds the client's whole delete set, so its deletes would go out again as a marked update, which\n * Keryx appends: it drops what it holds from the first sync step 2 only. Held until the next frame,\n * the answer is split once Keryx's document has dropped what it holds.\n *\n * What Keryx records (`patches/@y%2Fhub@0.10.1.patch`):\n * - An update's content map attributes every id it carries, inserts and deletes, read from its\n * bytes: an update that cannot integrate yet (an item next to one the person typed offline, the\n * next clocks of the person's own client) is attributed like any other. Peers hold it until the\n * sync step 2 behind it brings what it needs.\n * - An id is attributed by the first stream entry that carries it (`getDoc`'s `excludeContentMap`),\n * so a later copy changes no merged history. The live change notices read each entry's own\n * content map, though (keryx/src/doc-watcher.ts), and the first sync step 2's strip of what Keryx\n * holds reads the known ids when the frame arrives, possibly before the update ahead of it is in\n * the stream (one whose content map the compute pool builds). A copy inside the sync step 2\n * would then be announced as the person's: the sync step 2 never carries a kept write's ids.\n *\n * Bounded: consecutive writes of one scope merge into one run, and past\n * {@link MAX_ATTRIBUTED_RUNS} runs the oldest is let go (its content then reaches Keryx in a sync\n * step 2, as the person's). A room's local copy keeps them across sessions\n * ({@link unconfirmedAttributedWritesOf}, {@link restoreUnconfirmedAttributedWrites}); without one,\n * a write the room kept is forgotten with the page.\n */\nimport {\n type ContentIds,\n createContentIdsFromUpdate,\n decodeStateVector,\n type Doc,\n excludeContentIds,\n intersectContentIds,\n intersectUpdateWithContentIds,\n mergeContentIds,\n} from '@y/y';\nimport {\n type AttributedWritesSocket,\n readVarUint,\n type UnconfirmedAttributedWrites,\n type UpdateAttribution,\n withUpdateAttributionsTrailer,\n writeVarUint,\n} from './update-attributions.js';\n\n/** y-protocols: a sync message, the sync step 1 (a state vector) and the sub-types that carry content. */\nconst MESSAGE_SYNC = 0;\nconst SYNC_STEP_1 = 0;\nconst SYNC_STEP_2 = 1;\nconst SYNC_UPDATE = 2;\n\n/** Writes made in one attribution scope, by the content ids they wrote (inserts and deletes). */\nexport interface AttributedWrites {\n ids: ContentIds;\n attributions: readonly UpdateAttribution[];\n}\n\n/**\n * The most runs a room keeps; a run is one scope's writes since a write of another scope. Housekeeping\n * all shares one scope, so runs pile up only as Quick Edit steps interleave with it.\n */\nexport const MAX_ATTRIBUTED_RUNS = 64;\n\nfunction noIds(ids: ContentIds): boolean {\n return ids.inserts.isEmpty() && ids.deletes.isEmpty();\n}\n\nfunction sameAttributions(\n a: readonly UpdateAttribution[],\n b: readonly UpdateAttribution[]\n): boolean {\n return (\n a.length === b.length &&\n a.every((pair, index) => pair.k === b[index]?.k && pair.v === b[index]?.v)\n );\n}\n\n/** The sync message `frame` is, when it is exactly one (nothing after its payload). */\nfunction readSyncFrame(frame: Uint8Array): { syncType: number; payload: Uint8Array } | null {\n const type = readVarUint({ bytes: frame, start: 0 });\n if (type?.value !== MESSAGE_SYNC) return null;\n const syncType = readVarUint({ bytes: frame, start: type.next });\n if (syncType === null) return null;\n const length = readVarUint({ bytes: frame, start: syncType.next });\n if (length === null || length.next + length.value !== frame.length) return null;\n return { syncType: syncType.value, payload: frame.subarray(length.next) };\n}\n\n/** A sync message of `syncType` carrying `payload`, as y-protocols writes one. */\nfunction syncFrame({ syncType, payload }: { syncType: number; payload: Uint8Array }): Uint8Array {\n const head: number[] = [];\n writeVarUint({ out: head, value: MESSAGE_SYNC });\n writeVarUint({ out: head, value: syncType });\n writeVarUint({ out: head, value: payload.length });\n const frame = new Uint8Array(head.length + payload.length);\n frame.set(head, 0);\n frame.set(payload, head.length);\n return frame;\n}\n\n/** Whether `stateVector` (a sync step 1's) names no client: Keryx holds nothing of the room. */\nfunction namesNoClient(stateVector: Uint8Array): boolean {\n try {\n return decodeStateVector(stateVector).size === 0;\n } catch {\n return false;\n }\n}\n\n/** The content ids an update carries, or `null` for bytes that are not one. */\nfunction idsOf(update: Uint8Array): ContentIds | null {\n try {\n return createContentIdsFromUpdate(update);\n } catch {\n return null;\n }\n}\n\nfunction copyOf({ ids, attributions }: AttributedWrites): AttributedWrites {\n return { ids, attributions: attributions.map(({ k, v }) => ({ k, v })) };\n}\n\n/** `runs` without the oldest past {@link MAX_ATTRIBUTED_RUNS}. */\nfunction bounded(runs: AttributedWrites[]): AttributedWrites[] {\n return runs.length > MAX_ATTRIBUTED_RUNS ? runs.slice(runs.length - MAX_ATTRIBUTED_RUNS) : runs;\n}\n\n/** What a page reads of a room's tracker, whichever copy of this module made it. */\ninterface KeptWrites {\n readonly pending: readonly AttributedWrites[];\n restore(writes: readonly AttributedWrites[]): void;\n}\n\n/**\n * Each document's tracker in the page, and what a destroyed one still kept for the next tracker made\n * for the same document (a transport rebuilt over a retained document; a local copy read into a\n * document before its room's transport exists). On `globalThis`, not in module scope, for the reason\n * the scopes are (`update-attributions.ts`): the SDK's dist inlines its own copy of this module, and\n * a local copy reading a room through another copy must find the same tracker. Weak: each entry goes\n * with its document.\n */\nconst ROOMS = Symbol.for('@athenaintel/keryx-http/unconfirmed-update-attributions');\n\ninterface Rooms {\n live: WeakMap<object, KeptWrites>;\n left: WeakMap<object, AttributedWrites[]>;\n}\n\ntype RoomsHost = typeof globalThis & {\n [ROOMS]?: Rooms;\n};\n\n// process-local: browser-only, each entry keyed by the document whose writes it holds\nfunction rooms(): Rooms {\n const host = globalThis as RoomsHost;\n return (host[ROOMS] ??= { live: new WeakMap(), left: new WeakMap() });\n}\n\n/**\n * The attributed writes Keryx has not acknowledged for `doc`, oldest first: its room's, or what a\n * destroyed room left for the next. What a room's local copy keeps of them.\n */\nexport function unconfirmedAttributedWritesOf(doc: object): readonly AttributedWrites[] {\n const { live, left } = rooms();\n return live.get(doc)?.pending ?? left.get(doc)?.map(copyOf) ?? [];\n}\n\n/**\n * Hands `doc`'s room attributed writes an earlier session kept (the room's local copy), or keeps\n * them for the next tracker made for it. They are older than anything written since, so they go\n * out first.\n */\nexport function restoreUnconfirmedAttributedWrites({\n doc,\n writes,\n}: {\n doc: object;\n writes: readonly AttributedWrites[];\n}): void {\n if (writes.length === 0) return;\n const { live, left } = rooms();\n const room = live.get(doc);\n if (room) {\n room.restore(writes);\n return;\n }\n left.set(doc, bounded([...writes.map(copyOf), ...(left.get(doc) ?? [])]));\n}\n\n/**\n * The two above, as a page hands them to its local copies (`@athenaintel/local-copy`'s\n * `openLocalCopyStore({ attributedWrites })`, `createLocalCopiesPage({ attributedWrites })`): the\n * copy records a room's kept writes with its edits and restores an ended session's to its room.\n */\nexport const roomAttributedWrites = {\n of: unconfirmedAttributedWritesOf,\n restore: restoreUnconfirmedAttributedWrites,\n};\n\n/**\n * One room's attributed writes Keryx has not acknowledged (see the module comment). Made once per\n * room by its transport, which hands it to every socket it opens (`attributingWebSocket`'s\n * `unconfirmed`) and destroys it with the room; a tracker made for the same document later takes\n * over what it still kept, and what its local copy kept from an earlier session\n * ({@link restoreUnconfirmedAttributedWrites}). Not for a room that only listens: its socket sends\n * nothing to split.\n */\nexport class UnconfirmedUpdateAttributions implements UnconfirmedAttributedWrites, KeptWrites {\n private runs: AttributedWrites[];\n private readonly doc: Doc;\n private readonly attributionsFor: () => readonly UpdateAttribution[] | undefined;\n private readonly isRemoteOrigin: (origin: unknown) => boolean;\n\n constructor({\n doc,\n attributionsFor,\n isRemoteOrigin,\n }: {\n doc: Doc;\n /** The attributions of the update being written now: what the socket's trailer reads. */\n attributionsFor: () => readonly UpdateAttribution[] | undefined;\n /** True for a transaction the transport applied (Keryx's state, a peer's edit). */\n isRemoteOrigin: (origin: unknown) => boolean;\n }) {\n this.doc = doc;\n this.attributionsFor = attributionsFor;\n this.isRemoteOrigin = isRemoteOrigin;\n const { live, left } = rooms();\n this.runs = left.get(doc) ?? [];\n left.delete(doc);\n live.set(doc, this);\n doc.on('update', this.handleUpdate);\n }\n\n private readonly handleUpdate = (update: Uint8Array, origin: unknown): void => {\n if (this.isRemoteOrigin(origin)) return;\n const attributions = this.attributionsFor();\n if (!attributions || attributions.length === 0) return;\n const ids = idsOf(update);\n if (!ids || noIds(ids)) return;\n const last = this.runs.at(-1);\n if (last && sameAttributions(last.attributions, attributions)) {\n last.ids = mergeContentIds([last.ids, ids]);\n return;\n }\n this.runs = bounded([...this.runs, copyOf({ ids, attributions })]);\n };\n\n /** The attributed writes Keryx has not acknowledged, oldest first. */\n get pending(): readonly AttributedWrites[] {\n return this.runs.map(copyOf);\n }\n\n /** Writes an earlier session kept, older than any written here: they go out first. */\n restore(writes: readonly AttributedWrites[]): void {\n this.runs = bounded([...writes.map(copyOf), ...this.runs]);\n }\n\n /**\n * One socket of the room. While the room keeps writes, the socket's sync step 2 (its answer to\n * Keryx's sync step 1) waits for the next frame Keryx sends, its whole document, which drops what\n * Keryx already holds before the answer is split (see the module comment); every frame sent\n * meanwhile waits behind it, in order. When Keryx's sync step 1 names no client, the room is new\n * to Keryx: its answer goes out whole, born as one write, and what the room kept waits for its\n * echo. Nothing kept, or a socket that cannot hear Keryx: every frame goes out as it is sent.\n */\n connect({\n send,\n hears,\n }: {\n send: (frame: Uint8Array) => void;\n hears: boolean;\n }): AttributedWritesSocket {\n let held: Uint8Array[] | null = null;\n /** Keryx's sync step 1 on this socket named no client: it holds nothing of the room. */\n let roomIsNew = false;\n const sendNow = (frame: Uint8Array) => {\n for (const out of this.framesFor(frame)) send(out);\n };\n return {\n received: (frame) => {\n const message = readSyncFrame(frame);\n if (message?.syncType === SYNC_STEP_1) roomIsNew = namesNoClient(message.payload);\n this.received(frame);\n if (held === null) return;\n const waiting = held;\n held = null;\n for (const next of waiting) sendNow(next);\n },\n send: (frame) => {\n if (held !== null) {\n held.push(frame);\n return;\n }\n const syncType = readSyncFrame(frame)?.syncType;\n if (roomIsNew && syncType === SYNC_STEP_2) {\n send(frame);\n return;\n }\n if (hears && this.runs.length > 0 && syncType === SYNC_STEP_2) {\n held = [frame];\n return;\n }\n sendNow(frame);\n },\n };\n }\n\n /** Keryx sent `frame`: the ids it carries are Keryx's, kept no longer. */\n received(frame: Uint8Array): void {\n if (this.runs.length === 0) return;\n const message = readSyncFrame(frame);\n if (message === null) return;\n if (message.syncType !== SYNC_STEP_2 && message.syncType !== SYNC_UPDATE) return;\n const held = idsOf(message.payload);\n if (!held) return;\n this.runs = this.runs\n .map(({ ids, attributions }) => ({ ids: excludeContentIds(ids, held), attributions }))\n .filter((run) => !noIds(run.ids));\n }\n\n /**\n * What goes out in place of `frame`: `[frame]` itself, except a sync step 2 carrying kept writes,\n * which goes out behind each of them as its own marked sync update.\n */\n framesFor(frame: Uint8Array): readonly Uint8Array[] {\n if (this.runs.length === 0) return [frame];\n const message = readSyncFrame(frame);\n if (message === null || message.syncType !== SYNC_STEP_2) return [frame];\n const answer = message.payload;\n const all = idsOf(answer);\n if (!all) return [frame];\n const frames: Uint8Array[] = [];\n const marked: ContentIds[] = [];\n for (const run of this.runs) {\n const part = intersectContentIds(run.ids, all);\n if (noIds(part)) continue;\n frames.push(\n withUpdateAttributionsTrailer({\n message: syncFrame({\n syncType: SYNC_UPDATE,\n payload: intersectUpdateWithContentIds(answer, part),\n }),\n attributions: run.attributions,\n })\n );\n marked.push(part);\n }\n if (marked.length === 0) return [frame];\n const rest = excludeContentIds(all, mergeContentIds(marked));\n frames.push(\n syncFrame({ syncType: SYNC_STEP_2, payload: intersectUpdateWithContentIds(answer, rest) })\n );\n return frames;\n }\n\n /** Stops keeping; what is still kept waits for the next tracker made for the document. */\n destroy(): void {\n this.doc.off('update', this.handleUpdate);\n const { live, left } = rooms();\n if (live.get(this.doc) === this) live.delete(this.doc);\n if (this.runs.length > 0) left.set(this.doc, this.runs);\n this.runs = [];\n }\n}\n"],"names":["readVarUint","writeVarUint","decodeStateVector","createContentIdsFromUpdate","mergeContentIds","excludeContentIds","intersectContentIds","withUpdateAttributionsTrailer","intersectUpdateWithContentIds"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"unconfirmed-update-attributions.cjs","sources":["../../../../keryx-http/src/unconfirmed-update-attributions.ts"],"sourcesContent":["/**\n * The attributed writes a room has not seen Keryx acknowledge, kept so that a write made while the\n * room's socket is closed still reaches Keryx with its attributions.\n *\n * A write made inside `runWithDocUpdateAttributions` leaves as its own sync update followed by the\n * attributions trailer (`attributingWebSocket`), but only if the room's socket is open as it is\n * made. One made while it is closed (offline, reconnecting, before the first connection) reaches\n * Keryx inside the reconnect's sync step 2: the client's answer to Keryx's sync step 1, everything\n * it holds that Keryx lacks. Keryx reads no trailer after a sync step 2, so it would count the\n * write as the person's edit.\n *\n * So the room keeps the content ids of every write made in a scope, with the scope's attributions,\n * until Keryx sends those ids back: the echo of the update that carried them, or the whole document\n * Keryx sends when a socket opens (`received`; `@y/websocket` confirms its own unconfirmed updates\n * the same way). When the socket sends a sync step 2, each kept write's part of it goes out first,\n * oldest first, as its own sync update with its trailer, and the sync step 2 follows without those\n * ids (`framesFor`). The parts are cut from the sync step 2 itself, which already holds only what\n * Keryx lacks past its state vector. One socket keeps the order, so Keryx appends each marked update\n * before the person's sync step 2.\n *\n * A room Keryx holds nothing of (its sync step 1 names no client: a document created on the device,\n * its first push) is born whole: assets are born as one write of their real initial state, signed by\n * their creator, and housekeeping inside a new room is part of that state, not system work on\n * anyone's content. Its sync step 2 goes out whole, as the creator's write. What the room kept stays\n * kept until Keryx echoes the push, like any write: a push lost with the socket goes out whole again\n * if Keryx still holds nothing when the room reconnects, and is split like any reconnect's if\n * someone wrote to the room meanwhile.\n *\n * That answer waits for Keryx's document (`connect`). Keryx sends its sync step 1 and, in the same\n * cork, its whole document, but `@y/websocket` answers the sync step 1 as it arrives. A kept write\n * Keryx already appended (its echo lost with the socket) is still kept then, and the sync step 2\n * holds the client's whole delete set, so its deletes would go out again as a marked update, which\n * Keryx appends: it drops what it holds from the first sync step 2 only. Held until the next frame,\n * the answer is split once Keryx's document has dropped what it holds.\n *\n * What Keryx records (`patches/@y%2Fhub@0.10.1.patch`):\n * - An update's content map attributes every id it carries, inserts and deletes, read from its\n * bytes: an update that cannot integrate yet (an item next to one the person typed offline, the\n * next clocks of the person's own client) is attributed like any other. Peers hold it until the\n * sync step 2 behind it brings what it needs.\n * - An id is attributed by the first stream entry that carries it (`getDoc`'s `excludeContentMap`),\n * so a later copy changes no merged history. The live change notices read each entry's own\n * content map, though (keryx/src/doc-watcher.ts), and the first sync step 2's strip of what Keryx\n * holds reads the known ids when the frame arrives, possibly before the update ahead of it is in\n * the stream (one whose content map the compute pool builds). A copy inside the sync step 2\n * would then be announced as the person's: the sync step 2 never carries a kept write's ids.\n *\n * Bounded: consecutive writes of one scope merge into one run, and past\n * {@link MAX_ATTRIBUTED_RUNS} runs the oldest is let go (its content then reaches Keryx in a sync\n * step 2, as the person's). A room's local copy keeps them across sessions\n * ({@link unconfirmedAttributedWritesOf}, {@link restoreUnconfirmedAttributedWrites}); without one,\n * a write the room kept is forgotten with the page.\n */\nimport {\n type ContentIds,\n createContentIdsFromUpdate,\n decodeStateVector,\n type Doc,\n excludeContentIds,\n intersectContentIds,\n intersectUpdateWithContentIds,\n mergeContentIds,\n} from '@y/y';\nimport {\n type AttributedWritesSocket,\n readVarUint,\n type UnconfirmedAttributedWrites,\n type UpdateAttribution,\n withUpdateAttributionsTrailer,\n writeVarUint,\n} from './update-attributions.js';\n\n/** y-protocols: a sync message, the sync step 1 (a state vector) and the sub-types that carry content. */\nconst MESSAGE_SYNC = 0;\nconst SYNC_STEP_1 = 0;\nconst SYNC_STEP_2 = 1;\nconst SYNC_UPDATE = 2;\n\n/** Writes made in one attribution scope, by the content ids they wrote (inserts and deletes). */\nexport interface AttributedWrites {\n ids: ContentIds;\n attributions: readonly UpdateAttribution[];\n}\n\n/**\n * The most runs a room keeps; a run is one scope's writes since a write of another scope. Housekeeping\n * all shares one scope, so runs pile up only as Quick Edit steps interleave with it.\n */\nexport const MAX_ATTRIBUTED_RUNS = 64;\n\nfunction noIds(ids: ContentIds): boolean {\n return ids.inserts.isEmpty() && ids.deletes.isEmpty();\n}\n\nfunction sameAttributions(\n a: readonly UpdateAttribution[],\n b: readonly UpdateAttribution[]\n): boolean {\n return (\n a.length === b.length &&\n a.every((pair, index) => pair.k === b[index]?.k && pair.v === b[index]?.v)\n );\n}\n\n/** The sync message `frame` is, when it is exactly one (nothing after its payload). */\nfunction readSyncFrame(frame: Uint8Array): { syncType: number; payload: Uint8Array } | null {\n const type = readVarUint({ bytes: frame, start: 0 });\n if (type?.value !== MESSAGE_SYNC) return null;\n const syncType = readVarUint({ bytes: frame, start: type.next });\n if (syncType === null) return null;\n const length = readVarUint({ bytes: frame, start: syncType.next });\n if (length === null || length.next + length.value !== frame.length) return null;\n return { syncType: syncType.value, payload: frame.subarray(length.next) };\n}\n\n/** A sync message of `syncType` carrying `payload`, as y-protocols writes one. */\nfunction syncFrame({ syncType, payload }: { syncType: number; payload: Uint8Array }): Uint8Array {\n const head: number[] = [];\n writeVarUint({ out: head, value: MESSAGE_SYNC });\n writeVarUint({ out: head, value: syncType });\n writeVarUint({ out: head, value: payload.length });\n const frame = new Uint8Array(head.length + payload.length);\n frame.set(head, 0);\n frame.set(payload, head.length);\n return frame;\n}\n\n/** Whether `stateVector` (a sync step 1's) names no client: Keryx holds nothing of the room. */\nfunction namesNoClient(stateVector: Uint8Array): boolean {\n try {\n return decodeStateVector(stateVector).size === 0;\n } catch {\n return false;\n }\n}\n\n/** The content ids an update carries, or `null` for bytes that are not one. */\nfunction idsOf(update: Uint8Array): ContentIds | null {\n try {\n return createContentIdsFromUpdate(update);\n } catch {\n return null;\n }\n}\n\nfunction copyOf({ ids, attributions }: AttributedWrites): AttributedWrites {\n return { ids, attributions: attributions.map(({ k, v }) => ({ k, v })) };\n}\n\n/** `runs` without the oldest past {@link MAX_ATTRIBUTED_RUNS}. */\nfunction bounded(runs: AttributedWrites[]): AttributedWrites[] {\n return runs.length > MAX_ATTRIBUTED_RUNS ? runs.slice(runs.length - MAX_ATTRIBUTED_RUNS) : runs;\n}\n\n/** What a page reads of a room's tracker, whichever copy of this module made it. */\ninterface KeptWrites {\n readonly pending: readonly AttributedWrites[];\n restore(writes: readonly AttributedWrites[]): void;\n}\n\n/**\n * Each document's tracker in the page, and what a destroyed one still kept for the next tracker made\n * for the same document (a transport rebuilt over a retained document; a local copy read into a\n * document before its room's transport exists). On `globalThis`, not in module scope, for the reason\n * the scopes are (`update-attributions.ts`): the SDK's dist inlines its own copy of this module, and\n * a local copy reading a room through another copy must find the same tracker. Weak: each entry goes\n * with its document.\n */\nconst ROOMS = Symbol.for('@athenaintel/keryx-http/unconfirmed-update-attributions');\n\ninterface Rooms {\n live: WeakMap<object, KeptWrites>;\n left: WeakMap<object, AttributedWrites[]>;\n}\n\ntype RoomsHost = typeof globalThis & {\n [ROOMS]?: Rooms;\n};\n\n// process-local: browser-only, each entry keyed by the document whose writes it holds\nfunction rooms(): Rooms {\n const host = globalThis as RoomsHost;\n return (host[ROOMS] ??= { live: new WeakMap(), left: new WeakMap() });\n}\n\n/**\n * The attributed writes Keryx has not acknowledged for `doc`, oldest first: its room's, or what a\n * destroyed room left for the next. What a room's local copy keeps of them.\n */\nexport function unconfirmedAttributedWritesOf(doc: object): readonly AttributedWrites[] {\n const { live, left } = rooms();\n return live.get(doc)?.pending ?? left.get(doc)?.map(copyOf) ?? [];\n}\n\n/**\n * Hands `doc`'s room attributed writes an earlier session kept (the room's local copy), or keeps\n * them for the next tracker made for it. They are older than anything written since, so they go\n * out first.\n */\nexport function restoreUnconfirmedAttributedWrites({\n doc,\n writes,\n}: {\n doc: object;\n writes: readonly AttributedWrites[];\n}): void {\n if (writes.length === 0) return;\n const { live, left } = rooms();\n const room = live.get(doc);\n if (room) {\n room.restore(writes);\n return;\n }\n left.set(doc, bounded([...writes.map(copyOf), ...(left.get(doc) ?? [])]));\n}\n\n/**\n * The two above, as a page hands them to its local copies (`@athenaintel/local-copy`'s\n * `openLocalCopyStore({ attributedWrites })`, `createLocalCopiesPage({ attributedWrites })`): the\n * copy records a room's kept writes with its edits and restores an ended session's to its room.\n */\nexport const roomAttributedWrites = {\n of: unconfirmedAttributedWritesOf,\n restore: restoreUnconfirmedAttributedWrites,\n};\n\n/**\n * One room's attributed writes Keryx has not acknowledged (see the module comment). Made once per\n * room by its transport, which hands it to every socket it opens (`attributingWebSocket`'s\n * `unconfirmed`) and destroys it with the room; a tracker made for the same document later takes\n * over what it still kept, and what its local copy kept from an earlier session\n * ({@link restoreUnconfirmedAttributedWrites}). Not for a room that only listens: its socket sends\n * nothing to split.\n */\nexport class UnconfirmedUpdateAttributions implements UnconfirmedAttributedWrites, KeptWrites {\n private runs: AttributedWrites[];\n private readonly doc: Doc;\n private readonly attributionsFor: () => readonly UpdateAttribution[] | undefined;\n private readonly isRemoteOrigin: (origin: unknown) => boolean;\n\n constructor({\n doc,\n attributionsFor,\n isRemoteOrigin,\n }: {\n doc: Doc;\n /** The attributions of the update being written now: what the socket's trailer reads. */\n attributionsFor: () => readonly UpdateAttribution[] | undefined;\n /** True for a transaction the transport applied (Keryx's state, a peer's edit). */\n isRemoteOrigin: (origin: unknown) => boolean;\n }) {\n this.doc = doc;\n this.attributionsFor = attributionsFor;\n this.isRemoteOrigin = isRemoteOrigin;\n const { live, left } = rooms();\n this.runs = left.get(doc) ?? [];\n left.delete(doc);\n live.set(doc, this);\n doc.on('update', this.handleUpdate);\n }\n\n private readonly handleUpdate = (update: Uint8Array, origin: unknown): void => {\n if (this.isRemoteOrigin(origin)) return;\n const attributions = this.attributionsFor();\n if (!attributions || attributions.length === 0) return;\n const ids = idsOf(update);\n if (!ids || noIds(ids)) return;\n const last = this.runs.at(-1);\n if (last && sameAttributions(last.attributions, attributions)) {\n last.ids = mergeContentIds([last.ids, ids]);\n return;\n }\n this.runs = bounded([...this.runs, copyOf({ ids, attributions })]);\n };\n\n /** The attributed writes Keryx has not acknowledged, oldest first. */\n get pending(): readonly AttributedWrites[] {\n return this.runs.map(copyOf);\n }\n\n /**\n * Whether the room keeps a write Keryx has not acknowledged. Such a room's socket must not ask for\n * a diffed initial sync (`./initial-sync.ts`): it tells which kept writes Keryx already holds from\n * the whole document Keryx sends when the socket opens, and a diff leaves out what it holds.\n */\n get keepsWrites(): boolean {\n return this.runs.length > 0;\n }\n\n /** Writes an earlier session kept, older than any written here: they go out first. */\n restore(writes: readonly AttributedWrites[]): void {\n this.runs = bounded([...writes.map(copyOf), ...this.runs]);\n }\n\n /**\n * One socket of the room. While the room keeps writes, the socket's sync step 2 (its answer to\n * Keryx's sync step 1) waits for the next frame Keryx sends, its whole document, which drops what\n * Keryx already holds before the answer is split (see the module comment); every frame sent\n * meanwhile waits behind it, in order. When Keryx's sync step 1 names no client, the room is new\n * to Keryx: its answer goes out whole, born as one write, and what the room kept waits for its\n * echo. Nothing kept, or a socket that cannot hear Keryx: every frame goes out as it is sent.\n */\n connect({\n send,\n hears,\n }: {\n send: (frame: Uint8Array) => void;\n hears: boolean;\n }): AttributedWritesSocket {\n let held: Uint8Array[] | null = null;\n /** Keryx's sync step 1 on this socket named no client: it holds nothing of the room. */\n let roomIsNew = false;\n const sendNow = (frame: Uint8Array) => {\n for (const out of this.framesFor(frame)) send(out);\n };\n return {\n received: (frame) => {\n const message = readSyncFrame(frame);\n if (message?.syncType === SYNC_STEP_1) roomIsNew = namesNoClient(message.payload);\n this.received(frame);\n if (held === null) return;\n const waiting = held;\n held = null;\n for (const next of waiting) sendNow(next);\n },\n send: (frame) => {\n if (held !== null) {\n held.push(frame);\n return;\n }\n const syncType = readSyncFrame(frame)?.syncType;\n if (roomIsNew && syncType === SYNC_STEP_2) {\n send(frame);\n return;\n }\n if (hears && this.runs.length > 0 && syncType === SYNC_STEP_2) {\n held = [frame];\n return;\n }\n sendNow(frame);\n },\n };\n }\n\n /** Keryx sent `frame`: the ids it carries are Keryx's, kept no longer. */\n received(frame: Uint8Array): void {\n if (this.runs.length === 0) return;\n const message = readSyncFrame(frame);\n if (message === null) return;\n if (message.syncType !== SYNC_STEP_2 && message.syncType !== SYNC_UPDATE) return;\n const held = idsOf(message.payload);\n if (!held) return;\n this.runs = this.runs\n .map(({ ids, attributions }) => ({ ids: excludeContentIds(ids, held), attributions }))\n .filter((run) => !noIds(run.ids));\n }\n\n /**\n * What goes out in place of `frame`: `[frame]` itself, except a sync step 2 carrying kept writes,\n * which goes out behind each of them as its own marked sync update.\n */\n framesFor(frame: Uint8Array): readonly Uint8Array[] {\n if (this.runs.length === 0) return [frame];\n const message = readSyncFrame(frame);\n if (message === null || message.syncType !== SYNC_STEP_2) return [frame];\n const answer = message.payload;\n const all = idsOf(answer);\n if (!all) return [frame];\n const frames: Uint8Array[] = [];\n const marked: ContentIds[] = [];\n for (const run of this.runs) {\n const part = intersectContentIds(run.ids, all);\n if (noIds(part)) continue;\n frames.push(\n withUpdateAttributionsTrailer({\n message: syncFrame({\n syncType: SYNC_UPDATE,\n payload: intersectUpdateWithContentIds(answer, part),\n }),\n attributions: run.attributions,\n })\n );\n marked.push(part);\n }\n if (marked.length === 0) return [frame];\n const rest = excludeContentIds(all, mergeContentIds(marked));\n frames.push(\n syncFrame({ syncType: SYNC_STEP_2, payload: intersectUpdateWithContentIds(answer, rest) })\n );\n return frames;\n }\n\n /** Stops keeping; what is still kept waits for the next tracker made for the document. */\n destroy(): void {\n this.doc.off('update', this.handleUpdate);\n const { live, left } = rooms();\n if (live.get(this.doc) === this) live.delete(this.doc);\n if (this.runs.length > 0) left.set(this.doc, this.runs);\n this.runs = [];\n }\n}\n"],"names":["readVarUint","writeVarUint","decodeStateVector","createContentIdsFromUpdate","mergeContentIds","excludeContentIds","intersectContentIds","withUpdateAttributionsTrailer","intersectUpdateWithContentIds"],"mappings":"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"}
|
|
@@ -114,6 +114,12 @@ export declare class UnconfirmedUpdateAttributions implements UnconfirmedAttribu
|
|
|
114
114
|
private readonly handleUpdate;
|
|
115
115
|
/** The attributed writes Keryx has not acknowledged, oldest first. */
|
|
116
116
|
get pending(): readonly AttributedWrites[];
|
|
117
|
+
/**
|
|
118
|
+
* Whether the room keeps a write Keryx has not acknowledged. Such a room's socket must not ask for
|
|
119
|
+
* a diffed initial sync (`./initial-sync.ts`): it tells which kept writes Keryx already holds from
|
|
120
|
+
* the whole document Keryx sends when the socket opens, and a diff leaves out what it holds.
|
|
121
|
+
*/
|
|
122
|
+
get keepsWrites(): boolean;
|
|
117
123
|
/** Writes an earlier session kept, older than any written here: they go out first. */
|
|
118
124
|
restore(writes: readonly AttributedWrites[]): void;
|
|
119
125
|
/**
|
|
@@ -97,6 +97,14 @@ class UnconfirmedUpdateAttributions {
|
|
|
97
97
|
get pending() {
|
|
98
98
|
return this.runs.map(copyOf);
|
|
99
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Whether the room keeps a write Keryx has not acknowledged. Such a room's socket must not ask for
|
|
102
|
+
* a diffed initial sync (`./initial-sync.ts`): it tells which kept writes Keryx already holds from
|
|
103
|
+
* the whole document Keryx sends when the socket opens, and a diff leaves out what it holds.
|
|
104
|
+
*/
|
|
105
|
+
get keepsWrites() {
|
|
106
|
+
return this.runs.length > 0;
|
|
107
|
+
}
|
|
100
108
|
/** Writes an earlier session kept, older than any written here: they go out first. */
|
|
101
109
|
restore(writes) {
|
|
102
110
|
this.runs = bounded([...writes.map(copyOf), ...this.runs]);
|