A doc opens at once: the page carries the document's saved state, so the editor mounts without waiting for its live room, which connects in the background and sends only what changed, with a quiet dot that turns green when it's synced; edits made before then are kept and sent once. The editor's code is preloaded from the page and fetched early when you hover a doc, the app's shared code ships in a few chunks instead of a hundred small ones, socket tickets come with the page instead of a round trip, and the artifacts service answers a page, a sidebar and a room in parallel lookups instead of a dozen in a row, reporting its database round trips in Server-Timing. The artifacts guide says how a doc opens, and CONTRIBUTING's Speed section has the numbers.
29 files+785−1070/29 viewed
| 109 | 109 | signed-in pages under 400 ms, streamed panels within a second. | |
| 110 | 110 | - Workers run without Smart Placement; `scripts/perf/placement-probe.mjs` | |
| 111 | 111 | measures a placement before you pin one. | |
| 112 | + | - A TypeScript service reports its database time too (`db;dur`, from | |
| 113 | + | `openD1` in `@g1t/contracts`): the round trips a method makes are what | |
| 114 | + | it costs, so start them together (`Promise.all`), keep what never | |
| 115 | + | changes across requests in the isolate (the artifacts service keeps a | |
| 116 | + | workspace's slug for 30 s and whether its General space exists), and | |
| 117 | + | don't wake a Durable Object for a read D1 can answer. | |
| 118 | + | - Nothing live gates a page. A page carries what it shows (a doc's text | |
| 119 | + | and its saved document), opens its sockets as it hydrates rather than | |
| 120 | + | from inside the code they feed, keeps what is typed until they answer, | |
| 121 | + | and says so with a dot, never a spinner. A page opened with an access | |
| 122 | + | token gets its socket tickets with the page (the root loader mints the | |
| 123 | + | feed's, an artifact's loader its room's) rather than asking for each. | |
| 124 | + | - Code that only the browser runs (the doc editor) is still fetched with | |
| 125 | + | the page: `vite.config.ts` notes the chunk and what it imports for the | |
| 126 | + | route's `links`, and a link to such a page warms the chunk on hover. | |
| 127 | + | Modules several routes share are grouped by who shares them | |
| 128 | + | (`codeSplitting.groups`), so a page fetches tens of files, not a | |
| 129 | + | hundred and more. Before this, opening a doc fetched 124 files | |
| 130 | + | (1,049 KB) in three waves and the editor appeared at 2.8–3.3 s; a doc | |
| 131 | + | now fetches 71 files (1,026 KB) in one wave, and against the deployed | |
| 132 | + | services the editor appears at 2.1 s, of which the room's answer is | |
| 133 | + | 0.65 s and the editor's own start 0.4 s. The room's part (the saved | |
| 134 | + | document in the page, one call to the room, the context in one round) | |
| 135 | + | is measured after deploy. | |
| 112 | 136 | ||
| 113 | 137 | ## Rate limits | |
| 114 | 138 |
| 190 | 190 | drops, the header says **Offline, changes will sync** and sends your changes | |
| 191 | 191 | when you're back. | |
| 192 | 192 | ||
| 193 | + | ### How a doc opens | |
| 194 | + | ||
| 195 | + | A doc's text is in the page itself, so it reads from the first moment, before | |
| 196 | + | any script runs. The page then connects to the doc's live room and loads the | |
| 197 | + | editor together, and the editor opens on the document the page carried: you | |
| 198 | + | can read and type at once, and the room only sends what changed since the | |
| 199 | + | doc was last saved. Anything you type before the room answers is kept and | |
| 200 | + | sent then, so nothing is lost. The dot in the header says where things stand: | |
| 201 | + | ||
| 202 | + | | Dot | Meaning | | |
| 203 | + | | --- | --- | | |
| 204 | + | | Faint, pulsing | Connecting to the live room. You can already read and edit. | | |
| 205 | + | | Green | Live: everyone's changes show as they happen. | | |
| 206 | + | | **Offline, changes will sync** | The connection dropped; your changes are kept and sent when it's back. | | |
| 207 | + | ||
| 208 | + | A doc that was never saved since this started, or one far larger than usual, | |
| 209 | + | waits for the room's copy before the editor opens, as every doc once did. | |
| 210 | + | ||
| 193 | 211 | ### Comments | |
| 194 | 212 | ||
| 195 | 213 | Select text and choose **Comment** for a passage, or write in **Discussion** |
| 33 | 33 | return hydrated; | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | − | export default function DocBody({ slug, page, folio, role, showComments, onPresence, onStatus, onFolio, onRole, onError }: FolioBodyProps) { | |
| 36 | + | export default function DocBody({ slug, page, folio, live, role, showComments, onPresence, onFolio, onRole, onError }: FolioBodyProps) { | |
| 37 | 37 | const layout = useFoliosData(); | |
| 38 | 38 | const root = useRouteLoaderData("root"); | |
| 39 | 39 | const usercontent = usercontentFrom(root); | |
| ⋯ | |||
| 87 | 87 | }, | |
| 88 | 88 | [navigate, slug, revalidator, onFolio, onRole], | |
| 89 | 89 | ); | |
| 90 | + | // The room's notices (a rename, a suggestion, a change of access), from the page's connection. | |
| 91 | + | useEffect(() => live?.onEvent(onEvent), [live, onEvent]); | |
| 90 | 92 | ||
| 91 | 93 | const decide = async (id: string, decision: "accept" | "reject") => { | |
| 92 | 94 | setSuggestions((was) => was.filter((s) => s.id !== id)); | |
| ⋯ | |||
| 259 | 261 | ||
| 260 | 262 | {/* The doc itself: live once the editor loads; its saved Markdown until then. */} | |
| 261 | 263 | <div className="mt-6"> | |
| 262 | − | {hydrated && !trashed ? ( | |
| 264 | + | {hydrated && live && !trashed ? ( | |
| 263 | 265 | <Suspense fallback={<ReadView markdown={page.text} />}> | |
| 264 | 266 | <DocEditor | |
| 265 | 267 | key={folio.id} | |
| 266 | 268 | slug={slug} | |
| 267 | 269 | folioId={folio.id} | |
| 270 | + | provider={live} | |
| 268 | 271 | role={role} | |
| 269 | 272 | me={layout?.me ?? { key: "", name: "", display_name: "", avatar: null }} | |
| 270 | 273 | mentionables={layout?.mentionables ?? []} | |
| ⋯ | |||
| 272 | 275 | suggestions={suggestions} | |
| 273 | 276 | showComments={showComments} | |
| 274 | 277 | onPresence={onPresence} | |
| 275 | − | onStatus={onStatus} | |
| 276 | − | onEvent={onEvent} | |
| 277 | 278 | onPageThreads={setThreads} | |
| 278 | 279 | renderSuggestion={(s) => <SuggestionCard suggestion={s} canDecide={editable} onDecide={decide} />} | |
| 279 | 280 | projects={[]} | |
| 13 | 13 | import { syntaxHighlighter } from "@blocknote/code-block"; | |
| 14 | 14 | import { BlockNoteViewEditor, SuggestionMenuController, ThreadsSidebar, getDefaultReactSlashMenuItems, useCreateBlockNote } from "@blocknote/react"; | |
| 15 | 15 | import { BlockNoteView } from "@blocknote/shadcn"; | |
| 16 | − | import type { DocFile, DocRole, FolioSearchHit, FolioSuggestion, FoliosLiveEvent, Result } from "@g1t/contracts"; | |
| 16 | + | import type { DocFile, DocRole, FolioSearchHit, FolioSuggestion, Result } from "@g1t/contracts"; | |
| 17 | 17 | import { AlertTriangle, AtSign, Calendar, CheckCircle2, FileCode2, FileText, GitPullRequest, Info, Link2, Sigma, Workflow } from "lucide-react"; | |
| 18 | 18 | import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | |
| 19 | 19 | ||
| ⋯ | |||
| 23 | 23 | import { CiteDialog } from "./code"; | |
| 24 | 24 | import { EditorSkeleton } from "./editor-skeleton"; | |
| 25 | 25 | import type { PageThread } from "./page-parts"; | |
| 26 | − | import { FolioProvider, type LiveStatus } from "../provider"; | |
| 26 | + | import type { FolioProvider } from "../provider"; | |
| 27 | 27 | import type { Presence } from "../shell"; | |
| 28 | 28 | import { whoAre } from "../who"; | |
| 29 | 29 | ||
| ⋯ | |||
| 33 | 33 | export type DocEditorProps = { | |
| 34 | 34 | slug: string; | |
| 35 | 35 | folioId: string; | |
| 36 | + | /** The folio's live document, opened by the page as it hydrated (routes/workspace/folios/folio.tsx). */ | |
| 37 | + | provider: FolioProvider; | |
| 36 | 38 | role: DocRole; | |
| 37 | 39 | me: { key: string; name: string; display_name: string; avatar: string | null }; | |
| 38 | 40 | mentionables: DocMentionable[]; | |
| ⋯ | |||
| 40 | 42 | suggestions: FolioSuggestion[]; | |
| 41 | 43 | showComments: boolean; | |
| 42 | 44 | onPresence?: (people: Presence[]) => void; | |
| 43 | − | onStatus?: (status: LiveStatus) => void; | |
| 44 | − | onEvent?: (event: FoliosLiveEvent) => void; | |
| 45 | 45 | /** The suggestion cards' own UI, drawn by the page beside the blocks each one changes. */ | |
| 46 | 46 | renderSuggestion?: (suggestion: FolioSuggestion) => ReactNode; | |
| 47 | 47 | /** Comments on the whole doc (not on a passage), live from the document. */ | |
| ⋯ | |||
| 82 | 82 | } | |
| 83 | 83 | ||
| 84 | 84 | export default function DocEditor(props: DocEditorProps) { | |
| 85 | − | const { slug, folioId } = props; | |
| 86 | − | const [provider, setProvider] = useState<FolioProvider | null>(null); | |
| 87 | − | const [synced, setSynced] = useState(false); | |
| 88 | − | ||
| 85 | + | const { provider } = props; | |
| 86 | + | // A document the page carried is here already: the editor opens on it | |
| 87 | + | // now, and the room's answer (what changed since the save) merges in | |
| 88 | + | // as it arrives. Without one, the editor waits for the room's copy, so | |
| 89 | + | // it never starts from an empty document of its own; offline, after a | |
| 90 | + | // moment, from what we have. | |
| 91 | + | const [ready, setReady] = useState(provider.seeded || provider.status === "synced"); | |
| 89 | 92 | useEffect(() => { | |
| 90 | − | const scheme = window.location.protocol === "https:" ? "wss" : "ws"; | |
| 91 | − | const live = new FolioProvider(`${scheme}://${window.location.host}/${slug}/-/artifacts/live?folio=${encodeURIComponent(folioId)}`); | |
| 92 | − | setProvider(live); | |
| 93 | − | setSynced(false); | |
| 94 | − | // Mount the editor once the document is here, so it never starts from | |
| 95 | − | // an empty doc of its own; offline, after a moment, from what we have. | |
| 96 | − | const fallback = setTimeout(() => setSynced(true), 4000); | |
| 97 | − | const off = live.onStatus((status) => { | |
| 98 | − | props.onStatus?.(status); | |
| 99 | − | if (status === "synced") setSynced(true); | |
| 93 | + | if (provider.seeded) return; | |
| 94 | + | const fallback = setTimeout(() => setReady(true), 4000); | |
| 95 | + | const off = provider.onStatus((status) => { | |
| 96 | + | if (status === "synced") setReady(true); | |
| 100 | 97 | }); | |
| 101 | − | const offEvent = live.onEvent((event) => props.onEvent?.(event)); | |
| 102 | 98 | return () => { | |
| 103 | 99 | clearTimeout(fallback); | |
| 104 | 100 | off(); | |
| 105 | − | offEvent(); | |
| 106 | − | live.destroy(); | |
| 107 | 101 | }; | |
| 108 | − | // The callbacks are read through props each time; reconnect only for a new folio. | |
| 109 | − | // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 110 | − | }, [slug, folioId]); | |
| 102 | + | }, [provider]); | |
| 111 | 103 | ||
| 112 | − | if (!provider || !synced) return <EditorSkeleton />; | |
| 113 | − | return <LiveEditor {...props} provider={provider} />; | |
| 104 | + | if (!ready) return <EditorSkeleton />; | |
| 105 | + | return <LiveEditor {...props} />; | |
| 114 | 106 | } | |
| 115 | 107 | ||
| 116 | − | function LiveEditor({ slug, folioId, role, me, mentionables, usercontent, suggestions, showComments, onPresence, renderSuggestion, onPageThreads, projects, provider }: DocEditorProps & { provider: FolioProvider }) { | |
| 108 | + | function LiveEditor({ slug, folioId, role, me, mentionables, usercontent, suggestions, showComments, onPresence, renderSuggestion, onPageThreads, projects, provider }: DocEditorProps) { | |
| 117 | 109 | const editable = canDo(role, "edit"); | |
| 118 | 110 | // BlockNote's own parts follow the page's Appearance (lib/theme.ts). | |
| 119 | 111 | const theme = useDrawnTheme(); | |
| 9 | 9 | import { FileText, LayoutDashboard, type LucideIcon, PenTool, Presentation } from "lucide-react"; | |
| 10 | 10 | import { type ComponentType, type LazyExoticComponent, lazy } from "react"; | |
| 11 | 11 | ||
| 12 | − | import type { LiveStatus } from "./provider"; | |
| 12 | + | import type { FolioProvider } from "./provider"; | |
| 13 | 13 | import type { Presence } from "./shell"; | |
| 14 | 14 | ||
| 15 | 15 | /** | |
| 16 | 16 | * What a kind's page body is given: the folio's page and the folio as it | |
| 17 | − | * is now, the viewer's role, and the header's comment toggle; it tells the | |
| 18 | − | * header who is here, how the connection is, and what changed live. | |
| 17 | + | * is now, its live connection (opened by the page as it hydrates, before | |
| 18 | + | * the body's code arrives), the viewer's role, and the header's comment | |
| 19 | + | * toggle; it tells the header who is here and what changed live. | |
| 19 | 20 | */ | |
| 20 | 21 | export type FolioBodyProps = { | |
| 21 | 22 | slug: string; | |
| 22 | 23 | page: FolioPage; | |
| 23 | 24 | folio: Folio; | |
| 25 | + | /** The folio's live document and presence; null until the page hydrates. */ | |
| 26 | + | live: FolioProvider | null; | |
| 24 | 27 | role: DocRole; | |
| 25 | 28 | showComments: boolean; | |
| 26 | 29 | onPresence: (people: Presence[]) => void; | |
| 27 | − | onStatus: (status: LiveStatus) => void; | |
| 28 | 30 | /** A rename or another change to the folio, from the room. */ | |
| 29 | 31 | onFolio: (folio: Folio) => void; | |
| 30 | 32 | /** The viewer's role changed; null: their access ended. */ | |
| ⋯ | |||
| 45 | 47 | Body: LazyExoticComponent<ComponentType<FolioBodyProps>> | null; | |
| 46 | 48 | }; | |
| 47 | 49 | ||
| 50 | + | /** | |
| 51 | + | * Starts fetching the doc editor's code (BlockNote, Yjs, the blocks) in | |
| 52 | + | * the browser, so it is here by the time it is wanted: as a doc's page | |
| 53 | + | * hydrates, together with its body rather than after it, and when a | |
| 54 | + | * link to a doc is hovered or focused. Nothing is fetched twice. | |
| 55 | + | */ | |
| 56 | + | let editorWarmed = false; | |
| 57 | + | export function warmDocEditor(): void { | |
| 58 | + | if (editorWarmed || import.meta.env.SSR) return; | |
| 59 | + | editorWarmed = true; | |
| 60 | + | void import("./doc/editor").catch(() => { | |
| 61 | + | editorWarmed = false; | |
| 62 | + | }); | |
| 63 | + | } | |
| 64 | + | ||
| 48 | 65 | export const FOLIO_KIND_UI: Record<FolioKind, FolioKindUi> = { | |
| 49 | − | doc: { label: FOLIO_KIND_LABELS.doc, icon: FileText, tone: "text-info bg-info/12", ready: true, mobileEditable: true, Body: lazy(() => import("./doc/body")) }, | |
| 66 | + | doc: { | |
| 67 | + | label: FOLIO_KIND_LABELS.doc, | |
| 68 | + | icon: FileText, | |
| 69 | + | tone: "text-info bg-info/12", | |
| 70 | + | ready: true, | |
| 71 | + | mobileEditable: true, | |
| 72 | + | Body: lazy(() => { | |
| 73 | + | warmDocEditor(); | |
| 74 | + | return import("./doc/body"); | |
| 75 | + | }), | |
| 76 | + | }, | |
| 50 | 77 | slides: { label: FOLIO_KIND_LABELS.slides, icon: Presentation, tone: "text-warn bg-warn/12", ready: false, mobileEditable: false, Body: null }, | |
| 51 | 78 | design: { label: FOLIO_KIND_LABELS.design, icon: PenTool, tone: "text-merged bg-merged/12", ready: false, mobileEditable: false, Body: null }, | |
| 52 | 79 | dashboard: { label: FOLIO_KIND_LABELS.dashboard, icon: LayoutDashboard, tone: "text-success bg-success/12", ready: false, beta: true, mobileEditable: false, Body: null }, | |
| 14 | 14 | import { Card } from "../ui/card"; | |
| 15 | 15 | import { Hint } from "../ui/hint"; | |
| 16 | 16 | import { useFoliosData } from "./actions"; | |
| 17 | − | import { FolioThumbnail, KindIcon } from "./kinds"; | |
| 17 | + | import { FolioThumbnail, KindIcon, warmDocEditor } from "./kinds"; | |
| 18 | + | ||
| 19 | + | /** | |
| 20 | + | * On a link to a doc: as the link's own `prefetch="intent"` fetches the | |
| 21 | + | * page's code and data on hover or focus, this fetches the editor's, so | |
| 22 | + | * opening it is a navigation and nothing more. | |
| 23 | + | */ | |
| 24 | + | export function warmOnIntent(folio: Pick<Folio, "kind">): { onMouseEnter?: () => void; onFocus?: () => void; onTouchStart?: () => void } { | |
| 25 | + | if (folio.kind !== "doc") return {}; | |
| 26 | + | return { onMouseEnter: warmDocEditor, onFocus: warmDocEditor, onTouchStart: warmDocEditor }; | |
| 27 | + | } | |
| 18 | 28 | import { FolioMenu } from "./menu"; | |
| 19 | 29 | import { AccessMark } from "./parts"; | |
| 20 | 30 | ||
| ⋯ | |||
| 41 | 51 | <li className="group relative flex items-center gap-3 px-3 py-2 transition-colors hover:bg-raised/60 sm:px-4"> | |
| 42 | 52 | <KindIcon kind={folio.kind} /> | |
| 43 | 53 | <span className="flex min-w-0 grow items-center gap-2"> | |
| 44 | − | <Link to={folio.path} prefetch="intent" className="min-w-0 truncate text-sm text-fg after:absolute after:inset-0 hover:text-accent"> | |
| 54 | + | <Link to={folio.path} prefetch="intent" {...warmOnIntent(folio)} className="min-w-0 truncate text-sm text-fg after:absolute after:inset-0 hover:text-accent"> | |
| 45 | 55 | {folio.icon && <span className="mr-1.5">{folio.icon}</span>} | |
| 46 | 56 | {folio.title || "Untitled"} | |
| 47 | 57 | </Link> | |
| ⋯ | |||
| 96 | 106 | <div className="flex items-start gap-2.5 px-3.5 pt-3 pb-3"> | |
| 97 | 107 | <KindIcon kind={folio.kind} size={14} box={24} /> | |
| 98 | 108 | <span className="min-w-0 grow"> | |
| 99 | − | <Link to={folio.path} prefetch="intent" className="line-clamp-1 text-sm font-medium text-fg after:absolute after:inset-0 group-hover:text-accent"> | |
| 109 | + | <Link to={folio.path} prefetch="intent" {...warmOnIntent(folio)} className="line-clamp-1 text-sm font-medium text-fg after:absolute after:inset-0 group-hover:text-accent"> | |
| 100 | 110 | {folio.icon && <span className="mr-1">{folio.icon}</span>} | |
| 101 | 111 | {folio.title || "Untitled"} | |
| 102 | 112 | </Link> | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import * as decoding from "lib0/decoding"; | |
| 5 | + | import * as encoding from "lib0/encoding"; | |
| 6 | + | import * as syncProtocol from "y-protocols/sync"; | |
| 7 | + | import * as Y from "yjs"; | |
| 8 | + | ||
| 9 | + | import { FolioProvider, pageStateBytes } from "./provider.ts"; | |
| 10 | + | ||
| 11 | + | // The provider is browser code; the little it needs of the browser, a page address and sockets, is faked here. | |
| 12 | + | (globalThis as { location?: unknown }).location = { href: "https://g1t.test/acme/-/artifacts/x", protocol: "https:" }; | |
| 13 | + | ||
| 14 | + | class FakeSocket { | |
| 15 | + | static readonly CONNECTING = 0; | |
| 16 | + | static readonly OPEN = 1; | |
| 17 | + | static readonly CLOSED = 3; | |
| 18 | + | static instances: FakeSocket[] = []; | |
| 19 | + | readyState = FakeSocket.CONNECTING; | |
| 20 | + | binaryType = "blob"; | |
| 21 | + | sent: (Uint8Array | string)[] = []; | |
| 22 | + | onopen: (() => void) | null = null; | |
| 23 | + | onmessage: ((event: { data: unknown }) => void) | null = null; | |
| 24 | + | onclose: ((event: { code: number }) => void) | null = null; | |
| 25 | + | readonly url: string; | |
| 26 | + | constructor(url: string) { | |
| 27 | + | this.url = url; | |
| 28 | + | FakeSocket.instances.push(this); | |
| 29 | + | } | |
| 30 | + | send(data: Uint8Array | string) { | |
| 31 | + | this.sent.push(data); | |
| 32 | + | } | |
| 33 | + | close(code = 1000) { | |
| 34 | + | this.readyState = FakeSocket.CLOSED; | |
| 35 | + | this.onclose?.({ code }); | |
| 36 | + | } | |
| 37 | + | /** The server's turn: a frame for the client. */ | |
| 38 | + | receive(bytes: Uint8Array) { | |
| 39 | + | this.onmessage?.({ data: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) }); | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | (globalThis as { WebSocket?: unknown }).WebSocket = FakeSocket; | |
| 43 | + | ||
| 44 | + | const MESSAGE_SYNC = 0; | |
| 45 | + | ||
| 46 | + | /** A room as src/folios/room.ts behaves: its own step 1 on connect, and an answer to every sync message. */ | |
| 47 | + | function room(doc: Y.Doc, socket: FakeSocket) { | |
| 48 | + | const step1 = encoding.createEncoder(); | |
| 49 | + | encoding.writeVarUint(step1, MESSAGE_SYNC); | |
| 50 | + | syncProtocol.writeSyncStep1(step1, doc); | |
| 51 | + | socket.receive(encoding.toUint8Array(step1)); | |
| 52 | + | return { | |
| 53 | + | /** Reads what the client sent since, answering as the room would. */ | |
| 54 | + | exchange() { | |
| 55 | + | for (const frame of socket.sent.splice(0)) { | |
| 56 | + | if (typeof frame === "string") continue; | |
| 57 | + | const decoder = decoding.createDecoder(frame); | |
| 58 | + | if (decoding.readVarUint(decoder) !== MESSAGE_SYNC) continue; | |
| 59 | + | const encoder = encoding.createEncoder(); | |
| 60 | + | encoding.writeVarUint(encoder, MESSAGE_SYNC); | |
| 61 | + | syncProtocol.readSyncMessage(decoder, encoder, doc, "room"); | |
| 62 | + | if (encoding.length(encoder) > 1) socket.receive(encoding.toUint8Array(encoder)); | |
| 63 | + | } | |
| 64 | + | }, | |
| 65 | + | }; | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | function frames(socket: FakeSocket): number[] { | |
| 69 | + | return socket.sent.filter((f): f is Uint8Array => typeof f !== "string").map((f) => decoding.readVarUint(decoding.createDecoder(f))); | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | test("a doc seeded from the page's state opens at once, and what is typed before the room answers reaches it, once", (t) => { | |
| 73 | + | FakeSocket.instances = []; | |
| 74 | + | // The room's document, as the page carried its saved state. | |
| 75 | + | const server = new Y.Doc(); | |
| 76 | + | server.getText("t").insert(0, "hello"); | |
| 77 | + | const carried = Y.encodeStateAsUpdate(server); | |
| 78 | + | // Someone else edited after the save: the room is ahead of the page. | |
| 79 | + | server.getText("t").insert(0, "Oh, "); | |
| 80 | + | ||
| 81 | + | const provider = new FolioProvider("wss://g1t.test/acme/-/artifacts/live?folio=f1", { state: carried }); | |
| 82 | + | t.after(() => provider.destroy()); | |
| 83 | + | assert.equal(provider.seeded, true); | |
| 84 | + | assert.equal(provider.doc.getText("t").toString(), "hello", "the editor can open on the page's document before any socket"); | |
| 85 | + | assert.equal(provider.status, "connecting"); | |
| 86 | + | const socket = FakeSocket.instances[0]!; | |
| 87 | + | assert.equal(socket.url, "wss://g1t.test/acme/-/artifacts/live?folio=f1"); | |
| 88 | + | ||
| 89 | + | // Typed before the socket opened: kept in the document, nothing sent yet. | |
| 90 | + | provider.doc.getText("t").insert(5, " world"); | |
| 91 | + | assert.deepEqual(frames(socket), [], "nothing goes down a socket that is not open"); | |
| 92 | + | ||
| 93 | + | socket.readyState = FakeSocket.OPEN; | |
| 94 | + | socket.onopen?.(); | |
| 95 | + | assert.deepEqual(frames(socket), [MESSAGE_SYNC, 1, 3], "on open: our state vector, our presence, and a question about who is here"); | |
| 96 | + | const theRoom = room(server, socket); | |
| 97 | + | theRoom.exchange(); | |
| 98 | + | theRoom.exchange(); | |
| 99 | + | assert.equal(provider.status, "synced"); | |
| 100 | + | assert.equal(server.getText("t").toString(), "Oh, hello world", "the room got the early edit, and only once"); | |
| 101 | + | assert.equal(provider.doc.getText("t").toString(), "Oh, hello world", "the page got what changed since the save"); | |
| 102 | + | ||
| 103 | + | // From here, an edit goes straight down the socket. | |
| 104 | + | provider.doc.getText("t").insert(0, "> "); | |
| 105 | + | assert.deepEqual(frames(socket), [MESSAGE_SYNC]); | |
| 106 | + | theRoom.exchange(); | |
| 107 | + | assert.equal(server.getText("t").toString(), "> Oh, hello world"); | |
| 108 | + | provider.destroy(); | |
| 109 | + | }); | |
| 110 | + | ||
| 111 | + | test("without a saved state the document starts empty and is not seeded, and the room's copy fills it", (t) => { | |
| 112 | + | FakeSocket.instances = []; | |
| 113 | + | const server = new Y.Doc(); | |
| 114 | + | server.getText("t").insert(0, "from the room"); | |
| 115 | + | const provider = new FolioProvider("wss://g1t.test/acme/-/artifacts/live?folio=f2", { state: null }); | |
| 116 | + | t.after(() => provider.destroy()); | |
| 117 | + | assert.equal(provider.seeded, false); | |
| 118 | + | const socket = FakeSocket.instances[0]!; | |
| 119 | + | socket.readyState = FakeSocket.OPEN; | |
| 120 | + | socket.onopen?.(); | |
| 121 | + | const theRoom = room(server, socket); | |
| 122 | + | theRoom.exchange(); | |
| 123 | + | assert.equal(provider.status, "synced"); | |
| 124 | + | assert.equal(provider.doc.getText("t").toString(), "from the room"); | |
| 125 | + | provider.destroy(); | |
| 126 | + | }); | |
| 127 | + | ||
| 128 | + | test("a state that is not a Yjs update is ignored, and the provider waits for the room as before", (t) => { | |
| 129 | + | FakeSocket.instances = []; | |
| 130 | + | const provider = new FolioProvider("wss://g1t.test/acme/-/artifacts/live?folio=f3", { state: new Uint8Array([1, 2, 3, 4, 5, 6, 7]) }); | |
| 131 | + | t.after(() => provider.destroy()); | |
| 132 | + | assert.equal(provider.seeded, false); | |
| 133 | + | provider.destroy(); | |
| 134 | + | }); | |
| 135 | + | ||
| 136 | + | test("the page's base64 state becomes bytes, and anything else becomes nothing", (t) => { | |
| 137 | + | const bytes = new Uint8Array([0, 1, 2, 250, 251, 252]); | |
| 138 | + | assert.deepEqual(pageStateBytes(btoa(String.fromCharCode(...bytes))), bytes); | |
| 139 | + | assert.equal(pageStateBytes(null), null); | |
| 140 | + | assert.equal(pageStateBytes(""), null); | |
| 141 | + | assert.equal(pageStateBytes("not base64!"), null); | |
| 142 | + | }); |
| 9 | 9 | * | |
| 10 | 10 | * It reconnects with backoff when the socket drops, and says where it is | |
| 11 | 11 | * (`status`) so the page can show "Offline, changes will sync". | |
| 12 | + | * | |
| 13 | + | * Given the document as the page's data carries it (the state the room | |
| 14 | + | * last saved), it starts from that: the editor opens on it at once, and | |
| 15 | + | * the room's answer to our state vector is only what changed since. Any | |
| 16 | + | * edit made before the room answers stays in the document and goes out | |
| 17 | + | * in that same exchange (sync step 2), so nothing typed early is lost. | |
| 12 | 18 | * Browser-only. | |
| 13 | 19 | */ | |
| 14 | 20 | import type { FoliosLiveEvent } from "@g1t/contracts"; | |
| ⋯ | |||
| 18 | 24 | import * as syncProtocol from "y-protocols/sync"; | |
| 19 | 25 | import * as Y from "yjs"; | |
| 20 | 26 | ||
| 21 | − | import { openLive } from "../../lib/live-socket"; | |
| 22 | − | import { heldOpen } from "../../lib/notify-store"; | |
| 27 | + | // With extensions, so provider.test.ts runs under Node as the other tests do. | |
| 28 | + | import { openLive } from "../../lib/live-socket.ts"; | |
| 29 | + | import { heldOpen } from "../../lib/notify-store.ts"; | |
| 23 | 30 | ||
| 24 | 31 | const MESSAGE_SYNC = 0; | |
| 25 | 32 | const MESSAGE_AWARENESS = 1; | |
| ⋯ | |||
| 27 | 34 | ||
| 28 | 35 | export type LiveStatus = "connecting" | "synced" | "offline" | "closed"; | |
| 29 | 36 | ||
| 37 | + | /** The document a page carries (base64 of a Yjs update), as bytes; null when it carries none or it is not base64. */ | |
| 38 | + | export function pageStateBytes(state: string | null | undefined): Uint8Array | null { | |
| 39 | + | if (!state) return null; | |
| 40 | + | try { | |
| 41 | + | return Uint8Array.from(atob(state), (c) => c.charCodeAt(0)); | |
| 42 | + | } catch { | |
| 43 | + | return null; | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 30 | 47 | export class FolioProvider { | |
| 31 | 48 | readonly doc: Y.Doc; | |
| 32 | 49 | readonly awareness: awarenessProtocol.Awareness; | |
| 33 | 50 | status: LiveStatus = "connecting"; | |
| 51 | + | /** Whether the document started from the page's saved state (so the editor need not wait for the room). */ | |
| 52 | + | readonly seeded: boolean; | |
| 34 | 53 | private socket: WebSocket | null = null; | |
| 35 | 54 | private attempts = 0; | |
| 36 | 55 | /** When the current connection opened; the backoff starts over only once one holds. */ | |
| ⋯ | |||
| 43 | 62 | private readonly statusListeners = new Set<(status: LiveStatus) => void>(); | |
| 44 | 63 | private readonly eventListeners = new Set<(event: FoliosLiveEvent) => void>(); | |
| 45 | 64 | ||
| 46 | − | constructor( | |
| 47 | − | private readonly url: string, | |
| 48 | − | doc?: Y.Doc, | |
| 49 | − | ) { | |
| 50 | − | this.doc = doc ?? new Y.Doc(); | |
| 65 | + | private readonly url: string; | |
| 66 | + | ||
| 67 | + | constructor(url: string, options: { doc?: Y.Doc; state?: Uint8Array | null } = {}) { | |
| 68 | + | this.url = url; | |
| 69 | + | this.doc = options.doc ?? new Y.Doc(); | |
| 70 | + | let seeded = false; | |
| 71 | + | if (options.state?.byteLength) { | |
| 72 | + | try { | |
| 73 | + | // No origin of our own: the seed is the room's work, not an edit to send it. | |
| 74 | + | Y.applyUpdate(this.doc, options.state, "seed"); | |
| 75 | + | seeded = true; | |
| 76 | + | } catch (error) { | |
| 77 | + | console.error("artifacts: the saved document could not be read; waiting for the room", error); | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | this.seeded = seeded; | |
| 51 | 81 | this.awareness = new awarenessProtocol.Awareness(this.doc); | |
| 52 | 82 | this.doc.on("update", this.onDocUpdate); | |
| 53 | 83 | this.awareness.on("update", this.onAwarenessUpdate); | |
| 68 | 68 | <div className="min-w-0 grow"> | |
| 69 | 69 | <Crumbs items={crumbs} /> | |
| 70 | 70 | </div> | |
| 71 | − | {status === "offline" && ( | |
| 71 | + | {status === "offline" ? ( | |
| 72 | 72 | <Hint label="Your changes are kept and sync when the connection is back."> | |
| 73 | − | <span className="flex items-center gap-1 text-xs text-warn" tabIndex={0}> | |
| 73 | + | <span className="flex items-center gap-1 text-xs text-warn" tabIndex={0} data-live={status}> | |
| 74 | 74 | <WifiOff size={13} /> <span className="max-sm:hidden">Offline, changes will sync</span> | |
| 75 | 75 | </span> | |
| 76 | 76 | </Hint> | |
| 77 | + | ) : ( | |
| 78 | + | // A quiet dot: green once the room is in step with this page, faint | |
| 79 | + | // until then. The page is readable and editable either way; what is | |
| 80 | + | // typed before the room answers goes to it then (provider.ts). | |
| 81 | + | <Hint label={status === "synced" ? "Live: everyone's changes show as they happen." : status === "closed" ? "No longer live." : "Connecting to the live document…"}> | |
| 82 | + | <span className="flex size-8 shrink-0 items-center justify-center" tabIndex={0} aria-label={status === "synced" ? "Live" : status === "closed" ? "Not live" : "Connecting"} data-live={status}> | |
| 83 | + | <span className={`size-2 rounded-full ${status === "synced" ? "bg-success" : status === "closed" ? "bg-line" : "bg-faint/50 animate-pulse"}`} aria-hidden="true" /> | |
| 84 | + | </span> | |
| 85 | + | </Hint> | |
| 77 | 86 | )} | |
| 78 | 87 | {others.length > 0 && ( | |
| 79 | 88 | <span className="flex items-center -space-x-1.5 max-sm:hidden" aria-label={`${others.length} others here`}> |
| 20 | 20 | import { foliosRequest, useFoliosAction, useFoliosData } from "./actions"; | |
| 21 | 21 | import { RepoDocsDialog } from "./doc/code"; | |
| 22 | 22 | import { FOLIO_KIND_UI, FolioGlyph, KindIcon } from "./kinds"; | |
| 23 | + | import { warmOnIntent } from "./list"; | |
| 23 | 24 | import { SpaceIcon } from "./parts"; | |
| 24 | 25 | ||
| 25 | 26 | const ROW = "group flex h-8 items-center gap-1.5 rounded-md pr-1 text-[0.8125rem] transition-colors"; | |
| ⋯ | |||
| 122 | 123 | > | |
| 123 | 124 | <ChevronRight size={13} className={`transition-transform ${expanded ? "rotate-90" : ""}`} /> | |
| 124 | 125 | </button> | |
| 125 | − | <NavLink to={`/${slug}/-/artifacts/${folioSlug(item.title, item.id)}`} prefetch="intent" className="flex min-w-0 grow items-center gap-1.5"> | |
| 126 | + | <NavLink to={`/${slug}/-/artifacts/${folioSlug(item.title, item.id)}`} prefetch="intent" {...warmOnIntent(item)} className="flex min-w-0 grow items-center gap-1.5"> | |
| 126 | 127 | <span className="flex w-4 shrink-0 justify-center"> | |
| 127 | 128 | <FolioGlyph folio={item} size={14} /> | |
| 128 | 129 | </span> | |
| 17 | 17 | viaToken = on; | |
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | + | /** A ticket a page's loader minted with the page, as routes/notify/ticket.ts would have. */ | |
| 21 | + | export type OfferedTicket = { ticket: string; expires_at: string }; | |
| 22 | + | ||
| 23 | + | const offered = new Map<string, OfferedTicket>(); | |
| 24 | + | ||
| 25 | + | /** | |
| 26 | + | * A ticket minted on the server with the page (the root loader's for the | |
| 27 | + | * feed, an artifact's for its room), so the first socket opens without | |
| 28 | + | * first asking `/-/live/ticket`: one round trip less before anything is | |
| 29 | + | * live. Each is used once, and never past its minute. | |
| 30 | + | */ | |
| 31 | + | export function offerTicket(path: string, ticket: OfferedTicket | null | undefined): void { | |
| 32 | + | if (ticket) offered.set(path, ticket); | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /** The offered ticket for `path`, if one is left and still good. */ | |
| 36 | + | export function takeOfferedTicket(path: string, now = Date.now()): string | null { | |
| 37 | + | const found = offered.get(path); | |
| 38 | + | if (!found) return null; | |
| 39 | + | offered.delete(path); | |
| 40 | + | return Date.parse(found.expires_at) - now > 5_000 ? found.ticket : null; | |
| 41 | + | } | |
| 42 | + | ||
| 20 | 43 | /** `wss://<this site><path>?<params>`, with a ticket when one was given. */ | |
| 21 | 44 | export function liveAddress(path: string, params: Record<string, string | null | undefined>, ticket: string | null): string { | |
| 22 | 45 | const url = new URL(path, location.href); | |
| ⋯ | |||
| 58 | 81 | open(liveAddress(path, params(), null)); | |
| 59 | 82 | return; | |
| 60 | 83 | } | |
| 84 | + | const minted = takeOfferedTicket(path); | |
| 85 | + | if (minted) { | |
| 86 | + | open(liveAddress(path, params(), minted)); | |
| 87 | + | return; | |
| 88 | + | } | |
| 61 | 89 | void ticketFor(path).then((ticket) => { | |
| 62 | 90 | if (!cancelled()) open(liveAddress(path, params(), ticket)); | |
| 63 | 91 | }); | |
| 2 | 2 | ||
| 3 | 3 | import type { User } from "@g1t/contracts"; | |
| 4 | 4 | ||
| 5 | − | import { getViewer } from "./session.server"; | |
| 5 | + | import { getViewer, roleIn } from "./session.server"; | |
| 6 | 6 | import { identity } from "./services.server"; | |
| 7 | − | import { ticketViewer } from "./socket-ticket"; | |
| 8 | − | import { websiteUser } from "./website-token"; | |
| 7 | + | import { issueTicket, socketPath, ticketViewer } from "./socket-ticket"; | |
| 8 | + | import { bearerToken, websiteUser } from "./website-token"; | |
| 9 | 9 | ||
| 10 | 10 | let isolateSecret: string | null = null; | |
| 11 | 11 | ||
| ⋯ | |||
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | 27 | /** | |
| 28 | + | * A socket ticket minted with a page, for a page opened with an access | |
| 29 | + | * token: what `GET /-/live/ticket?path=` would answer, so the page's | |
| 30 | + | * first socket opens without that round trip (lib/live-socket.ts | |
| 31 | + | * `offerTicket`). Null for a session (its sockets carry the cookie) and | |
| 32 | + | * for a path that is not one of the site's sockets, or not the viewer's | |
| 33 | + | * workspace's. | |
| 34 | + | */ | |
| 35 | + | export async function socketTicketFor(context: Parameters<typeof getViewer>[0], request: Request, path: string): Promise<{ ticket: string; expires_at: string } | null> { | |
| 36 | + | const viewer = getViewer(context); | |
| 37 | + | const token = bearerToken(request); | |
| 38 | + | if (!viewer || !token || !viewer.token?.website) return null; | |
| 39 | + | const socket = socketPath(path); | |
| 40 | + | if (!socket || (socket.workspace && !roleIn(viewer, socket.workspace))) return null; | |
| 41 | + | return issueTicket(ticketSecret(), { token, userId: viewer.id, path: socket.path }); | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /** | |
| 28 | 45 | * Who opens a live socket: the session or token the request carries, as | |
| 29 | 46 | * on any page, or else the person a socket ticket was made for | |
| 30 | 47 | * (lib/socket-ticket.ts), whose token is checked again now. | |
| 3 | 3 | ||
| 4 | 4 | import type { User, Viewer } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | − | import { liveAddress, openLive, setLiveViaToken } from "./live-socket.ts"; | |
| 6 | + | import { liveAddress, offerTicket, openLive, setLiveViaToken, takeOfferedTicket } from "./live-socket.ts"; | |
| 7 | 7 | import { TICKET_PARAM, TICKET_ROUTE, TICKET_SECONDS, issueTicket, openTicket, socketPath, ticketViewer } from "./socket-ticket.ts"; | |
| 8 | 8 | import { tokenVerdict, websiteUser } from "./website-token.ts"; | |
| 9 | 9 | ||
| ⋯ | |||
| 156 | 156 | openLive(CHAT, () => ({}), () => (late = true), () => true); | |
| 157 | 157 | await new Promise((resolve) => setTimeout(resolve, 10)); | |
| 158 | 158 | assert.equal(late, false); | |
| 159 | + | ||
| 160 | + | // A ticket the page's loader minted opens the first socket with no | |
| 161 | + | // request; it is used once, and not when its minute is nearly up. | |
| 162 | + | asked.length = 0; | |
| 163 | + | offerTicket(CHAT, { ticket: "st1.minted", expires_at: new Date(Date.now() + 50_000).toISOString() }); | |
| 164 | + | const minted: string[] = []; | |
| 165 | + | openLive(CHAT, () => ({ channel: "ch_2" }), (address) => minted.push(address), () => false); | |
| 166 | + | assert.deepEqual(minted, ["wss://g1t.sh/acme/-/chat/live?channel=ch_2&ticket=st1.minted"], "synchronously, from the offered ticket"); | |
| 167 | + | assert.deepEqual(asked, []); | |
| 168 | + | const again = await new Promise<string>((resolve) => openLive(CHAT, () => ({ channel: "ch_2" }), resolve, () => false)); | |
| 169 | + | assert.equal(again, "wss://g1t.sh/acme/-/chat/live?channel=ch_2&ticket=st1.abc", "the next socket asks, as the offer was used"); | |
| 170 | + | assert.deepEqual(asked, [`/-/live/ticket?path=${encodeURIComponent(CHAT)}`]); | |
| 171 | + | offerTicket(CHAT, { ticket: "st1.stale", expires_at: new Date(Date.now() + 2_000).toISOString() }); | |
| 172 | + | assert.equal(takeOfferedTicket(CHAT), null, "an offer about to expire is not used"); | |
| 173 | + | offerTicket(CHAT, null); | |
| 174 | + | assert.equal(takeOfferedTicket(CHAT), null); | |
| 159 | 175 | } finally { | |
| 160 | 176 | setLiveViaToken(false); | |
| 161 | 177 | Object.defineProperty(globalThis, "location", { value: was.location, configurable: true }); | |
| 50 | 50 | import { RELOADED_KEY, RELOAD_GIVE_UP_MS, clientNavigated, reloadFixes, reloadedBefore } from "./lib/stale-build"; | |
| 51 | 51 | import { useNonce } from "./lib/nonce"; | |
| 52 | 52 | import { isNeedsSignIn } from "./lib/website-token"; | |
| 53 | − | import { setLiveViaToken } from "./lib/live-socket"; | |
| 53 | + | import { offerTicket, setLiveViaToken } from "./lib/live-socket"; | |
| 54 | + | import { socketTicketFor } from "./lib/socket-ticket.server"; | |
| 54 | 55 | import { LiveNotifications } from "./components/notifications/live-notifications"; | |
| 55 | 56 | import { THEME_COLOR, THEME_COOKIE, readTheme, themeAttribute, useThemeChoice } from "./lib/theme"; | |
| 56 | 57 | ||
| ⋯ | |||
| 102 | 103 | return { | |
| 103 | 104 | user, | |
| 104 | 105 | shell, | |
| 106 | + | // For a page opened with an access token: the feed socket's ticket, | |
| 107 | + | // minted here so the feed opens without asking for one (lib/live-socket.ts). | |
| 108 | + | liveTicket: user?.token?.website ? await socketTicketFor(context, request, "/-/live") : null, | |
| 105 | 109 | // Whether they folded the sidebar away, so the page is drawn that way from the start. | |
| 106 | 110 | sidebarClosed: sidebarClosed(readCookie(cookies, SIDEBAR_COOKIE)), | |
| 107 | 111 | // Their Appearance, so the page is drawn in it from the first byte (lib/theme.ts). | |
| ⋯ | |||
| 342 | 346 | const root = loaded ?? (inBrowser ? lastRoot : undefined); | |
| 343 | 347 | const user = root?.user; | |
| 344 | 348 | // A page opened with an access token signs its live sockets in with tickets (lib/live-socket.ts). | |
| 345 | − | if (inBrowser) setLiveViaToken(Boolean(user?.token?.website)); | |
| 349 | + | if (inBrowser) { | |
| 350 | + | setLiveViaToken(Boolean(user?.token?.website)); | |
| 351 | + | offerTicket("/-/live", root?.liveTicket); | |
| 352 | + | } | |
| 346 | 353 | const { pathname, search } = useLocation(); | |
| 347 | 354 | // Drawn around the error page too: a 404 keeps the sidebar out of a | |
| 348 | 355 | // project or workspace the viewer cannot see. | |
| 1 | 1 | import { FOLIO_KIND_NOUNS, folioIdFrom, type DocRole, type Folio, type FolioPage } from "@g1t/contracts"; | |
| 2 | 2 | import { Lock } from "lucide-react"; | |
| 3 | 3 | import { Suspense, useCallback, useEffect, useState } from "react"; | |
| 4 | − | import { Link, data, redirect, useNavigate } from "react-router"; | |
| 4 | + | import { type HtmlLinkDescriptor, Link, data, redirect, useNavigate } from "react-router"; | |
| 5 | + | import editorChunk from "virtual:g1t-editor-chunk"; | |
| 5 | 6 | ||
| 6 | 7 | import type { Route } from "./+types/folio"; | |
| 7 | 8 | import { foliosRequest } from "../../../components/folios/actions"; | |
| 8 | 9 | import { EditorSkeleton } from "../../../components/folios/doc/editor-skeleton"; | |
| 9 | 10 | import { FOLIO_KIND_UI } from "../../../components/folios/kinds"; | |
| 10 | − | import type { LiveStatus } from "../../../components/folios/provider"; | |
| 11 | + | import { FolioProvider, type LiveStatus, pageStateBytes } from "../../../components/folios/provider"; | |
| 11 | 12 | import { FolioHeader, type Presence } from "../../../components/folios/shell"; | |
| 12 | 13 | import { ErrorText } from "../../../components/ui"; | |
| 13 | 14 | import { Button } from "../../../components/ui/button"; | |
| 15 | + | import { type OfferedTicket, offerTicket } from "../../../lib/live-socket"; | |
| 14 | 16 | import { page as pageMeta } from "../../../lib/meta"; | |
| 15 | 17 | import { folios } from "../../../lib/services.server"; | |
| 16 | 18 | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| 19 | + | import { socketTicketFor } from "../../../lib/socket-ticket.server"; | |
| 17 | 20 | ||
| 18 | 21 | export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) { | |
| 19 | 22 | const f = loaded && "page" in loaded ? loaded.page.folio : null; | |
| 20 | 23 | return pageMeta(args, { title: `${f ? `${f.icon ? `${f.icon} ` : ""}${f.title || "Untitled"}` : "Artifact"} · ${params.owner} · g1t`, description: f?.excerpt, type: "article" }); | |
| 21 | 24 | } | |
| 22 | 25 | ||
| 23 | − | type Loaded = { page: FolioPage } | { denied: string }; | |
| 26 | + | /** | |
| 27 | + | * The doc editor's code and what it imports, fetched with the page rather | |
| 28 | + | * than after the page's own code has run and asked for them. The server | |
| 29 | + | * knows the files (vite.config.ts `editorChunk`); the browser reads them | |
| 30 | + | * back from the page the server sent, so both render the same links. | |
| 31 | + | */ | |
| 32 | + | function editorFiles(): string[] { | |
| 33 | + | if (editorChunk.length) return editorChunk; | |
| 34 | + | if (typeof document === "undefined") return []; | |
| 35 | + | return [...document.querySelectorAll('link[rel="modulepreload"][data-doc-editor]')].map((link) => link.getAttribute("href") ?? "").filter(Boolean); | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | export const links: Route.LinksFunction = () => editorFiles().map((href) => ({ rel: "modulepreload", href, "data-doc-editor": "" }) as unknown as HtmlLinkDescriptor); | |
| 24 | 39 | ||
| 40 | + | /** The live socket's path in this workspace (lib/socket-ticket.ts `socketPath`). */ | |
| 41 | + | const livePath = (slug: string) => `/${slug}/-/artifacts/live`; | |
| 42 | + | ||
| 43 | + | type Loaded = { page: FolioPage; ticket: OfferedTicket | null } | { denied: string }; | |
| 44 | + | ||
| 25 | 45 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 26 | 46 | const viewer = requireUser(context, request); | |
| 27 | 47 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 28 | 48 | const id = folioIdFrom(params.folio); | |
| 29 | 49 | if (!id) throw data(null, { status: 404 }); | |
| 30 | − | const found = await folios.page(params.owner.toLowerCase(), viewer, id); | |
| 50 | + | const slug = params.owner.toLowerCase(); | |
| 51 | + | // A page opened with an access token gets its room's socket ticket with | |
| 52 | + | // the page, so the socket opens without asking for one (lib/live-socket.ts). | |
| 53 | + | const [found, ticket] = await Promise.all([folios.page(slug, viewer, id), socketTicketFor(context, request, livePath(slug))]); | |
| 31 | 54 | if (!found.ok) { | |
| 32 | 55 | // One they can't read and one that doesn't exist look the same: both can be asked for. | |
| 33 | 56 | if (found.error.code === "not_found" || found.error.code === "forbidden") return data<Loaded>({ denied: id }, { status: 403 }); | |
| ⋯ | |||
| 36 | 59 | // An old address (renamed since) goes to the current one. | |
| 37 | 60 | const url = new URL(request.url); | |
| 38 | 61 | if (url.pathname !== found.value.folio.path) throw redirect(`${found.value.folio.path}${url.search}`); | |
| 39 | − | return { page: found.value } as Loaded; | |
| 62 | + | return { page: found.value, ticket } as Loaded; | |
| 40 | 63 | } | |
| 41 | 64 | ||
| 42 | 65 | /** One artifact: the shared header, then its kind's page (a doc's editor, for now). */ | |
| 43 | 66 | export default function FolioView({ loaderData, params }: Route.ComponentProps) { | |
| 44 | 67 | const slug = params.owner.toLowerCase(); | |
| 45 | 68 | if ("denied" in loaderData) return <NeedAccess slug={slug} id={loaderData.denied} />; | |
| 46 | − | return <Opened slug={slug} page={loaderData.page} />; | |
| 69 | + | return <Opened slug={slug} page={loaderData.page} ticket={loaderData.ticket} />; | |
| 47 | 70 | } | |
| 48 | 71 | ||
| 49 | − | function Opened({ slug, page }: { slug: string; page: FolioPage }) { | |
| 72 | + | /** | |
| 73 | + | * The page connects to the folio's room as soon as it hydrates, before | |
| 74 | + | * the body's code (and the editor's, far larger) has arrived, and seeds | |
| 75 | + | * the document from the state the page carried, so the editor opens on | |
| 76 | + | * it at once and the room sends only what changed since. The room never | |
| 77 | + | * gates the page: the text is readable from the first byte, the editor | |
| 78 | + | * works before the room answers, and the header's dot says when it has. | |
| 79 | + | */ | |
| 80 | + | function useLive(slug: string, page: FolioPage, ticket: OfferedTicket | null): { live: FolioProvider | null; status: LiveStatus } { | |
| 81 | + | const [live, setLive] = useState<FolioProvider | null>(null); | |
| 82 | + | const [status, setStatus] = useState<LiveStatus>("connecting"); | |
| 83 | + | const id = page.folio.id; | |
| 84 | + | const state = page.state; | |
| 85 | + | useEffect(() => { | |
| 86 | + | offerTicket(livePath(slug), ticket); | |
| 87 | + | const scheme = window.location.protocol === "https:" ? "wss" : "ws"; | |
| 88 | + | const provider = new FolioProvider(`${scheme}://${window.location.host}${livePath(slug)}?folio=${encodeURIComponent(id)}`, { state: pageStateBytes(state) }); | |
| 89 | + | const off = provider.onStatus(setStatus); | |
| 90 | + | setLive(provider); | |
| 91 | + | return () => { | |
| 92 | + | off(); | |
| 93 | + | provider.destroy(); | |
| 94 | + | setLive(null); | |
| 95 | + | }; | |
| 96 | + | // The same folio keeps its connection through a revalidation; only another folio reconnects. | |
| 97 | + | // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 98 | + | }, [slug, id]); | |
| 99 | + | return { live, status }; | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | function Opened({ slug, page, ticket }: { slug: string; page: FolioPage; ticket: OfferedTicket | null }) { | |
| 50 | 103 | const navigate = useNavigate(); | |
| 51 | 104 | const [folio, setFolio] = useState<Folio>(page.folio); | |
| 52 | 105 | useEffect(() => setFolio(page.folio), [page.folio]); | |
| 53 | 106 | const [role, setRole] = useState<DocRole>(page.folio.viewer_role); | |
| 54 | 107 | useEffect(() => setRole(page.folio.viewer_role), [page.folio.viewer_role, page.folio.id]); | |
| 55 | 108 | const [presence, setPresence] = useState<Presence[]>([]); | |
| 56 | − | const [status, setStatus] = useState<LiveStatus>("connecting"); | |
| 109 | + | const { live, status } = useLive(slug, page, ticket); | |
| 57 | 110 | const [showComments, setShowComments] = useState(false); | |
| 58 | 111 | const [error, setError] = useState<string | null>(null); | |
| 59 | 112 | const onRole = useCallback((next: DocRole | null) => next && setRole(next), []); | |
| ⋯ | |||
| 84 | 137 | </div> | |
| 85 | 138 | } | |
| 86 | 139 | > | |
| 87 | − | <Body slug={slug} page={page} folio={folio} role={role} showComments={showComments} onPresence={setPresence} onStatus={setStatus} onFolio={setFolio} onRole={onRole} onError={setError} /> | |
| 140 | + | <Body slug={slug} page={page} folio={folio} live={live} role={role} showComments={showComments} onPresence={setPresence} onFolio={setFolio} onRole={onRole} onError={setError} /> | |
| 88 | 141 | </Suspense> | |
| 89 | 142 | ) : ( | |
| 90 | 143 | <p className="mx-auto mt-16 max-w-md text-center text-sm text-muted">This is a {FOLIO_KIND_NOUNS[folio.kind]}. Opening {ui.label.toLowerCase()} here is coming soon.</p> | |
| 1 | + | /** | |
| 2 | + | * The doc editor's chunk and what it imports, as the browser build wrote | |
| 3 | + | * them (vite.config.ts `editorChunk`): their addresses in the server | |
| 4 | + | * build, none in the browser build and in development. | |
| 5 | + | */ | |
| 6 | + | declare module "virtual:g1t-editor-chunk" { | |
| 7 | + | const files: string[]; | |
| 8 | + | export default files; | |
| 9 | + | } |
| 23 | 23 | "~/*": ["./app/*"] | |
| 24 | 24 | }, | |
| 25 | 25 | "esModuleInterop": true, | |
| 26 | − | "resolveJsonModule": true | |
| 26 | + | "resolveJsonModule": true, | |
| 27 | + | // Browser modules tested under Node (`npm test`) name the files they | |
| 28 | + | // import with their extension, as Node needs (components/folios/provider.ts). | |
| 29 | + | "allowImportingTsExtensions": true | |
| 27 | 30 | } | |
| 28 | 31 | } |
| 1 | 1 | import { reactRouter } from "@react-router/dev/vite"; | |
| 2 | 2 | import { cloudflare } from "@cloudflare/vite-plugin"; | |
| 3 | 3 | import tailwindcss from "@tailwindcss/vite"; | |
| 4 | − | import { defineConfig } from "vite"; | |
| 4 | + | import { defineConfig, type Plugin } from "vite"; | |
| 5 | 5 | ||
| 6 | + | /** | |
| 7 | + | * The doc editor's chunk and everything it imports, so a doc's page can | |
| 8 | + | * ask the browser to fetch them with the page (`<link rel="modulepreload">`, | |
| 9 | + | * routes/workspace/folios/folio.tsx `links`) rather than after the page's | |
| 10 | + | * own code has run and asked for them. The editor is only ever imported | |
| 11 | + | * lazily (it runs in the browser alone), so no route lists it among its | |
| 12 | + | * modules; this notes its files as the browser build writes them, for the | |
| 13 | + | * server build that follows in the same run. In development there are no | |
| 14 | + | * such files, and the page adds no links. | |
| 15 | + | */ | |
| 16 | + | function editorChunk(): Plugin { | |
| 17 | + | const id = "virtual:g1t-editor-chunk"; | |
| 18 | + | let files: string[] = []; | |
| 19 | + | return { | |
| 20 | + | name: "g1t-editor-chunk", | |
| 21 | + | resolveId(source) { | |
| 22 | + | return source === id ? `\0${id}` : null; | |
| 23 | + | }, | |
| 24 | + | load(source) { | |
| 25 | + | return source === `\0${id}` ? `export default ${JSON.stringify(files)};` : null; | |
| 26 | + | }, | |
| 27 | + | generateBundle(_options, bundle) { | |
| 28 | + | if (this.environment.name !== "client") return; | |
| 29 | + | const chunks = Object.values(bundle).filter((output) => output.type === "chunk"); | |
| 30 | + | const editor = chunks.find((chunk) => chunk.moduleIds.some((moduleId) => moduleId.replace(/\\/g, "/").endsWith("/app/components/folios/doc/editor.tsx"))); | |
| 31 | + | if (!editor) return; | |
| 32 | + | // The chunk first, then what it statically imports, each once. | |
| 33 | + | const seen = new Set<string>(); | |
| 34 | + | const walk = (fileName: string) => { | |
| 35 | + | if (seen.has(fileName)) return; | |
| 36 | + | seen.add(fileName); | |
| 37 | + | const chunk = chunks.find((c) => c.fileName === fileName); | |
| 38 | + | for (const imported of chunk?.imports ?? []) walk(imported); | |
| 39 | + | }; | |
| 40 | + | walk(editor.fileName); | |
| 41 | + | files = [...seen].map((fileName) => `/${fileName}`); | |
| 42 | + | }, | |
| 43 | + | }; | |
| 44 | + | } | |
| 45 | + | ||
| 6 | 46 | export default defineConfig({ | |
| 7 | 47 | plugins: [ | |
| 8 | 48 | cloudflare({ viteEnvironment: { name: "ssr" } }), | |
| 9 | 49 | tailwindcss(), | |
| 10 | 50 | reactRouter(), | |
| 51 | + | editorChunk(), | |
| 11 | 52 | ], | |
| 12 | 53 | resolve: { | |
| 13 | 54 | tsconfigPaths: true, | |
| ⋯ | |||
| 16 | 57 | rolldownOptions: { | |
| 17 | 58 | output: { | |
| 18 | 59 | codeSplitting: { | |
| 19 | − | // Icons are shared by nearly every page, a few hundred bytes each: | |
| 20 | − | // one file for all of them, not a request per icon. | |
| 21 | − | groups: [{ name: "icons", test: /[\\/]node_modules[\\/]lucide-react[\\/]/ }], | |
| 60 | + | groups: [ | |
| 61 | + | // Icons are shared by nearly every page, a few hundred bytes each: | |
| 62 | + | // one file for all of them, not a request per icon. | |
| 63 | + | { name: "icons", test: /[\\/]node_modules[\\/]lucide-react[\\/]/, priority: 2 }, | |
| 64 | + | // The app's own components and helpers that several routes | |
| 65 | + | // share: grouped by which routes use them, and small groups | |
| 66 | + | // merged into their neighbours, so a page fetches a few files | |
| 67 | + | // of them rather than one per module. A signed-in page fetched | |
| 68 | + | // 124 files, most of them a kilobyte or two, each a request and | |
| 69 | + | // a `modulepreload` to parse. Modules one route alone uses stay | |
| 70 | + | // with that route. Packages are left to automatic splitting: a | |
| 71 | + | // group over them merged what the editor needs with what the | |
| 72 | + | // diagram renderers need, and every doc fetched both. | |
| 73 | + | { name: "app", test: /[\\/]apps[\\/]web[\\/]app[\\/](components|lib)[\\/]/, minShareCount: 2, entriesAware: true, entriesAwareMergeThreshold: 24_000, priority: 1 }, | |
| 74 | + | ], | |
| 22 | 75 | }, | |
| 23 | 76 | }, | |
| 24 | 77 | }, | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { openD1, sessionConstraint, timedD1, type D1Timing } from "./d1.ts"; | |
| 5 | + | ||
| 6 | + | /** A binding that answers every query after a tick, and remembers what batch was given. */ | |
| 7 | + | function fakeDb() { | |
| 8 | + | const given: object[][] = []; | |
| 9 | + | const statement = (query: string, binds: unknown[] = []) => ({ | |
| 10 | + | query, | |
| 11 | + | binds, | |
| 12 | + | bind: (...args: unknown[]) => statement(query, args), | |
| 13 | + | all: async () => ({ results: [{ query, binds }] }), | |
| 14 | + | first: async () => ({ query, binds }), | |
| 15 | + | run: async () => ({ success: true }), | |
| 16 | + | raw: async () => [[query]], | |
| 17 | + | }); | |
| 18 | + | const db = { | |
| 19 | + | prepare: (query: string) => statement(query), | |
| 20 | + | batch: async (statements: object[]) => { | |
| 21 | + | given.push(statements); | |
| 22 | + | return statements.map(() => ({ success: true })); | |
| 23 | + | }, | |
| 24 | + | withSession: (constraint?: string) => ({ ...db, constraint, getBookmark: () => "bm-1" }), | |
| 25 | + | }; | |
| 26 | + | return { db, given }; | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | test("timedD1 counts each statement's trip and each batch, and keeps bindings", async () => { | |
| 30 | + | const { db, given } = fakeDb(); | |
| 31 | + | const timing: D1Timing = { trips: 0, ms: 0 }; | |
| 32 | + | const timed = timedD1(db, timing); | |
| 33 | + | const first = await timed.prepare("SELECT ?").bind(1).first(); | |
| 34 | + | assert.deepEqual(first, { query: "SELECT ?", binds: [1] }); | |
| 35 | + | await timed.prepare("SELECT 2").all(); | |
| 36 | + | await timed.prepare("UPDATE t").run(); | |
| 37 | + | await timed.prepare("SELECT 3").raw(); | |
| 38 | + | assert.equal(timing.trips, 4); | |
| 39 | + | const a = timed.prepare("A").bind("x"); | |
| 40 | + | const b = timed.prepare("B"); | |
| 41 | + | await timed.batch([a, b]); | |
| 42 | + | assert.equal(timing.trips, 5, "a batch is one trip however many statements it carries"); | |
| 43 | + | // The binding got its own statements back, not the counting wrappers. | |
| 44 | + | assert.equal(given.length, 1); | |
| 45 | + | assert.deepEqual( | |
| 46 | + | given[0]!.map((s) => (s as { query: string; binds: unknown[] }).query), | |
| 47 | + | ["A", "B"], | |
| 48 | + | ); | |
| 49 | + | assert.deepEqual((given[0]![0] as { binds: unknown[] }).binds, ["x"]); | |
| 50 | + | assert.ok(timing.ms >= 0); | |
| 51 | + | }); | |
| 52 | + | ||
| 53 | + | test("openD1 reports the service time, the database time and the bookmark", async () => { | |
| 54 | + | const { db } = fakeDb(); | |
| 55 | + | const opened = openD1(db, new Request("https://service/rpc/x", { headers: { "x-d1-bookmark": "first-unconstrained" } })); | |
| 56 | + | await opened.db.prepare("SELECT 1").all(); | |
| 57 | + | await opened.db.prepare("SELECT 2").all(); | |
| 58 | + | const answered = opened.finish(Response.json({ ok: true })); | |
| 59 | + | const timing = answered.headers.get("server-timing") ?? ""; | |
| 60 | + | assert.match(timing, /^svc;dur=\d+;desc="session", db;dur=\d+;desc="2 round trips"$/); | |
| 61 | + | assert.equal(answered.headers.get("x-d1-bookmark"), "bm-1"); | |
| 62 | + | assert.deepEqual(await answered.json(), { ok: true }); | |
| 63 | + | }); | |
| 64 | + | ||
| 65 | + | test("openD1 without a session reads the primary and says so, with no db metric when nothing was read", async () => { | |
| 66 | + | const { db } = fakeDb(); | |
| 67 | + | const opened = openD1(db, new Request("https://service/rpc/x")); | |
| 68 | + | const answered = opened.finish(new Response("ok")); | |
| 69 | + | assert.equal(answered.headers.get("server-timing"), `svc;dur=${/\d+/.exec(answered.headers.get("server-timing") ?? "")?.[0]};desc="primary"`); | |
| 70 | + | assert.equal(answered.headers.get("x-d1-bookmark"), null); | |
| 71 | + | }); | |
| 72 | + | ||
| 73 | + | test("sessionConstraint takes the two first-* words and a bookmark, and starts anything else on the primary", () => { | |
| 74 | + | assert.equal(sessionConstraint(null), null); | |
| 75 | + | assert.equal(sessionConstraint("first-primary"), "first-primary"); | |
| 76 | + | assert.equal(sessionConstraint("first-unconstrained"), "first-unconstrained"); | |
| 77 | + | assert.equal(sessionConstraint("0000abc-DEF"), "0000abc-DEF"); | |
| 78 | + | assert.equal(sessionConstraint("not a bookmark!"), "first-primary"); | |
| 79 | + | }); |
| 36 | 36 | /** A D1 binding, as far as sessions need it. */ | |
| 37 | 37 | type SessionCapable = { withSession(constraintOrBookmark?: string): { getBookmark(): string | null } }; | |
| 38 | 38 | ||
| 39 | + | /** How long a request waited on D1, and in how many round trips. */ | |
| 40 | + | export type D1Timing = { trips: number; ms: number }; | |
| 41 | + | ||
| 42 | + | /** The methods of a prepared statement that go to the database. */ | |
| 43 | + | const TRIPS = new Set(["all", "first", "run", "raw"]); | |
| 44 | + | ||
| 45 | + | /** | |
| 46 | + | * `db` with every round trip counted into `timing`: each `all`, `first`, | |
| 47 | + | * `run` and `raw` of a statement, and each `batch`. Statements given to | |
| 48 | + | * `batch` are unwrapped, so the binding sees its own. The `db;dur` metric | |
| 49 | + | * crates/kit/src/d1.rs reports for the Rust services, for the TypeScript | |
| 50 | + | * ones; the site reads both (apps/web/app/lib/perf.ts `databaseTime`). | |
| 51 | + | */ | |
| 52 | + | export function timedD1<D extends object>(db: D, timing: D1Timing): D { | |
| 53 | + | const raw = new WeakMap<object, object>(); | |
| 54 | + | const trip = async <T>(work: () => Promise<T>): Promise<T> => { | |
| 55 | + | const from = Date.now(); | |
| 56 | + | try { | |
| 57 | + | return await work(); | |
| 58 | + | } finally { | |
| 59 | + | timing.trips += 1; | |
| 60 | + | timing.ms += Date.now() - from; | |
| 61 | + | } | |
| 62 | + | }; | |
| 63 | + | const statement = (s: object): object => { | |
| 64 | + | const wrapped = new Proxy(s, { | |
| 65 | + | get(target, prop) { | |
| 66 | + | const value = Reflect.get(target, prop, target); | |
| 67 | + | if (typeof value !== "function") return value; | |
| 68 | + | if (prop === "bind") return (...args: unknown[]) => statement(value.apply(target, args)); | |
| 69 | + | if (typeof prop === "string" && TRIPS.has(prop)) return (...args: unknown[]) => trip(() => value.apply(target, args)); | |
| 70 | + | return value.bind(target); | |
| 71 | + | }, | |
| 72 | + | }); | |
| 73 | + | raw.set(wrapped, s); | |
| 74 | + | return wrapped; | |
| 75 | + | }; | |
| 76 | + | return new Proxy(db, { | |
| 77 | + | get(target, prop) { | |
| 78 | + | const value = Reflect.get(target, prop, target); | |
| 79 | + | if (typeof value !== "function") return value; | |
| 80 | + | if (prop === "prepare") return (query: string) => statement(value.call(target, query)); | |
| 81 | + | if (prop === "batch") return (statements: object[]) => trip(() => value.call(target, statements.map((s) => raw.get(s) ?? s))); | |
| 82 | + | return value.bind(target); | |
| 83 | + | }, | |
| 84 | + | }); | |
| 85 | + | } | |
| 86 | + | ||
| 39 | 87 | /** | |
| 40 | 88 | * The database for an RPC `request`: a session, seen as the binding, when | |
| 41 | 89 | * the caller asked for one (it answers `prepare` and `batch`, all a request | |
| 42 | − | * path uses), the binding itself otherwise. `finish` adds the bookmark and | |
| 43 | − | * the time taken to the answer. | |
| 90 | + | * path uses), the binding itself otherwise, with its round trips counted. | |
| 91 | + | * `finish` adds the bookmark, the time taken and the database time to the | |
| 92 | + | * answer. | |
| 44 | 93 | */ | |
| 45 | − | export function openD1<D extends SessionCapable>(db: D, request: Request): { db: D; finish(response: Response): Response } { | |
| 94 | + | export function openD1<D extends SessionCapable>(db: D, request: Request): { db: D; timing: D1Timing; finish(response: Response): Response } { | |
| 46 | 95 | const started = Date.now(); | |
| 47 | 96 | const asked = sessionConstraint(request.headers.get(BOOKMARK_HEADER)); | |
| 48 | 97 | const session = asked ? db.withSession(asked) : null; | |
| 98 | + | const timing: D1Timing = { trips: 0, ms: 0 }; | |
| 49 | 99 | return { | |
| 50 | − | db: (session ?? db) as D, | |
| 100 | + | db: timedD1((session ?? db) as D, timing), | |
| 101 | + | timing, | |
| 51 | 102 | finish(response) { | |
| 52 | 103 | const answered = new Response(response.body, response); | |
| 53 | − | answered.headers.append("server-timing", `svc;dur=${Date.now() - started};desc="${session ? "session" : "primary"}"`); | |
| 104 | + | const db = timing.trips ? `, db;dur=${timing.ms};desc="${timing.trips} round trip${timing.trips === 1 ? "" : "s"}"` : ""; | |
| 105 | + | answered.headers.append("server-timing", `svc;dur=${Date.now() - started};desc="${session ? "session" : "primary"}"${db}`); | |
| 54 | 106 | const bookmark = session?.getBookmark(); | |
| 55 | 107 | if (bookmark) answered.headers.set(BOOKMARK_HEADER, bookmark); | |
| 56 | 108 | return answered; |
| 270 | 270 | folio: Folio; | |
| 271 | 271 | /** Its text rendition when last saved (a doc's Markdown): what shows until the live editor loads. */ | |
| 272 | 272 | text: string; | |
| 273 | + | /** | |
| 274 | + | * The document as last saved (a Yjs update, base64), so the editor | |
| 275 | + | * opens from it at once and syncs the difference with its room; null | |
| 276 | + | * when none is kept yet or it is too large to carry, and the editor | |
| 277 | + | * waits for the room. | |
| 278 | + | */ | |
| 279 | + | state: string | null; | |
| 273 | 280 | /** The docs it sits under, from the top, that the viewer can read. */ | |
| 274 | 281 | breadcrumbs: FolioRef[]; | |
| 275 | 282 | /** What sits under it (a doc's sub-pages) that the viewer can read. */ |
| 1 | + | -- The document itself, saved beside its text rendition each time a folio's | |
| 2 | + | -- room saves (src/persist.ts `saveFolio`): the Yjs state the page hands the | |
| 3 | + | -- browser, so a doc opens from it before its room answers, and what an | |
| 4 | + | -- emptied room is refilled with. NULL until the first save after this | |
| 5 | + | -- migration, and for a document too large for a row; either way the page | |
| 6 | + | -- waits for the room, as every page did before. | |
| 7 | + | ALTER TABLE folios ADD COLUMN state BLOB; |
| 40 | 40 | edited_at: string; | |
| 41 | 41 | trashed_at: string | null; | |
| 42 | 42 | trashed_by: string | null; | |
| 43 | + | /** The saved Yjs state (migration 0006), only when a read asks for it. */ | |
| 44 | + | state?: ArrayBuffer | number[] | null; | |
| 43 | 45 | }; | |
| 44 | 46 | ||
| 45 | 47 | /** Every column but the text, as lists read them. */ |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { cleanCover, cleanIcon, cleanSource, cleanTarget, cleanTitle, decodeCursor, depthOf, encodeCursor, listLimit, sharedTops, subtreeHeight, treeNodes } from "./list.ts"; | |
| 4 | + | import { MAX_PAGE_STATE, cleanCover, cleanIcon, cleanSource, cleanTarget, cleanTitle, decodeCursor, depthOf, encodeCursor, listLimit, pageState, sharedTops, subtreeHeight, treeNodes } from "./list.ts"; | |
| 5 | + | ||
| 6 | + | test("a page carries the saved document as base64, and none when there is none or it is too large", () => { | |
| 7 | + | assert.equal(pageState(null), null); | |
| 8 | + | assert.equal(pageState(new Uint8Array(0).buffer), null); | |
| 9 | + | const bytes = new Uint8Array(70_000).map((_, i) => i % 251); | |
| 10 | + | const carried = pageState(bytes.buffer); | |
| 11 | + | assert.ok(carried); | |
| 12 | + | assert.deepEqual(Uint8Array.from(atob(carried!), (c) => c.charCodeAt(0)), bytes); | |
| 13 | + | // D1 may hand a BLOB back as an array of numbers. | |
| 14 | + | assert.equal(pageState([104, 105]), btoa("hi")); | |
| 15 | + | assert.equal(pageState(new Uint8Array(MAX_PAGE_STATE + 1).buffer), null); | |
| 16 | + | }); | |
| 5 | 17 | ||
| 6 | 18 | test("titles, icons, covers and sources are cleaned", () => { | |
| 7 | 19 | assert.equal(cleanTitle(" Q4 roadmap \n"), "Q4 roadmap"); |
| 16 | 16 | export const MAX_LIMIT = 100; | |
| 17 | 17 | /** A note on an edit, a template's description: at most this long. */ | |
| 18 | 18 | export const MAX_NOTE = 500; | |
| 19 | + | /** | |
| 20 | + | * The largest saved document a page carries to the browser (base64, in | |
| 21 | + | * the page's data). Past it the editor waits for the room, as it did for | |
| 22 | + | * every doc before the state was kept. | |
| 23 | + | */ | |
| 24 | + | export const MAX_PAGE_STATE = 512 * 1024; | |
| 25 | + | ||
| 26 | + | /** A folio's saved Yjs state as the page carries it: base64, or null when none is kept or it is too large. */ | |
| 27 | + | export function pageState(state: ArrayBuffer | ArrayLike<number> | null | undefined): string | null { | |
| 28 | + | if (!state) return null; | |
| 29 | + | const bytes = new Uint8Array(state as ArrayBuffer); | |
| 30 | + | if (!bytes.byteLength || bytes.byteLength > MAX_PAGE_STATE) return null; | |
| 31 | + | let binary = ""; | |
| 32 | + | for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); | |
| 33 | + | return btoa(binary); | |
| 34 | + | } | |
| 19 | 35 | ||
| 20 | 36 | export function cleanTitle(title: unknown, max = MAX_TITLE): string { | |
| 21 | 37 | return [...String(title ?? "").replace(/\s+/g, " ").trim()].slice(0, max).join(""); |
| 230 | 230 | ||
| 231 | 231 | /** | |
| 232 | 232 | * Names the folio and its kind, and fills an empty document: from a | |
| 233 | − | * Yjs state (a duplicate), or the kind's seed from text or a spec (a | |
| 234 | − | * template, an agent's new folio, or blank). | |
| 233 | + | * Yjs state (a duplicate, or what D1 saved), or the kind's seed from | |
| 234 | + | * text or a spec (a template, an agent's new folio, or blank). Returns | |
| 235 | + | * whether the document has content afterwards: called with nothing to | |
| 236 | + | * fill it with, an empty room says false and the service reads what | |
| 237 | + | * was saved, so the usual open costs one call and no D1 read. | |
| 235 | 238 | */ | |
| 236 | − | async ensure(init: { folio_id: string; kind: FolioKind; workspace_slug: string; text?: string | null; spec?: unknown; state?: Uint8Array | null }): Promise<void> { | |
| 239 | + | async ensure(init: { folio_id: string; kind: FolioKind; workspace_slug: string; text?: string | null; spec?: unknown; state?: Uint8Array | null }): Promise<boolean> { | |
| 237 | 240 | this.setMeta("folio_id", init.folio_id); | |
| 238 | 241 | this.setMeta("kind", init.kind); | |
| 239 | 242 | if (init.workspace_slug) this.setMeta("workspace_slug", init.workspace_slug); | |
| 240 | 243 | const doc = this.load(); | |
| 241 | 244 | const model = this.model(); | |
| 242 | − | if (!model.isEmpty(doc)) return; | |
| 245 | + | if (!model.isEmpty(doc)) return true; | |
| 246 | + | if (init.text === undefined && init.spec === undefined && init.state == null) return false; | |
| 243 | 247 | // No origin: filling a new room is its "created" version (written by the service), nobody's edit. | |
| 244 | 248 | if (init.state) Y.applyUpdate(doc, init.state); | |
| 245 | 249 | else doc.transact(() => model.seed(doc, { text: init.text ?? null, spec: init.spec })); | |
| 250 | + | return true; | |
| 246 | 251 | } | |
| 247 | 252 | ||
| 248 | 253 | /** The folio in its agent form. */ |
| 110 | 110 | } from "./access-store.ts"; | |
| 111 | 111 | import { agentMayFind, agentReach, audienceRule, type AgentReach, type AudienceRule } from "./agents.ts"; | |
| 112 | 112 | import { publishFolioEvent } from "./events.ts"; | |
| 113 | − | import { MAX_DEPTH, cleanCover, cleanIcon, cleanNote, cleanSource, cleanTarget, cleanTitle, decodeCursor, depthOf, encodeCursor, listLimit, sharedTops, slugOf, subtreeHeight, treeNodes } from "./list.ts"; | |
| 113 | + | import { MAX_DEPTH, cleanCover, cleanIcon, cleanNote, cleanSource, cleanTarget, cleanTitle, decodeCursor, depthOf, encodeCursor, listLimit, pageState, sharedTops, slugOf, subtreeHeight, treeNodes } from "./list.ts"; | |
| 114 | 114 | import { REQUEST_RECIPIENTS, claimAccessRequest } from "./requests.ts"; | |
| 115 | 115 | import type { FolioRoom } from "./room.ts"; | |
| 116 | 116 | import { builtinFolioTemplate, builtinFolioTemplates } from "./templates.ts"; | |
| ⋯ | |||
| 205 | 205 | ); | |
| 206 | 206 | } | |
| 207 | 207 | ||
| 208 | − | /** The room, named and given its kind and (when empty) its saved text. */ | |
| 208 | + | /** | |
| 209 | + | * The room, named and given its kind. A room that is empty (new, or its | |
| 210 | + | * storage gone) is filled from what D1 saved: the document's state when | |
| 211 | + | * there is one, so it stays the very document every reader was given | |
| 212 | + | * (`page` hands the same state to the browser), else its text. The | |
| 213 | + | * common case, a room with the document already, costs one call. | |
| 214 | + | */ | |
| 209 | 215 | private async ready(workspace: Workspace, row: FolioRow) { | |
| 210 | 216 | const room = this.room(row.id); | |
| 211 | − | let text = row.text; | |
| 212 | − | if (!text) text = (await this.db.prepare("SELECT text FROM folios WHERE id = ?").bind(row.id).first<{ text: string }>())?.text ?? ""; | |
| 213 | − | await room.ensure({ folio_id: row.id, kind: row.kind, workspace_slug: workspace.slug, text }); | |
| 217 | + | const named = { folio_id: row.id, kind: row.kind, workspace_slug: workspace.slug }; | |
| 218 | + | if (await room.ensure(named)) return room; | |
| 219 | + | const saved = await this.db.prepare("SELECT text, state FROM folios WHERE id = ?").bind(row.id).first<{ text: string; state: ArrayBuffer | number[] | null }>(); | |
| 220 | + | await room.ensure({ ...named, text: saved?.text ?? row.text ?? "", state: saved?.state ? new Uint8Array(saved.state as ArrayBuffer) : null }); | |
| 214 | 221 | return room; | |
| 215 | 222 | } | |
| 216 | 223 | ||
| ⋯ | |||
| 221 | 228 | if (!found.ok) return found; | |
| 222 | 229 | const workspace = found.value; | |
| 223 | 230 | const user = viewer!; | |
| 224 | − | await this.who.ensureDefault(workspace, user); | |
| 225 | − | const person = await this.who.viewerPerson(workspace, user); | |
| 231 | + | // Their teams (identity), the spaces (D1) and the General check (D1, | |
| 232 | + | // once per isolate) start together: one round, not three. | |
| 233 | + | const [person] = await Promise.all([this.who.viewerPerson(workspace, user), this.who.ensureDefault(workspace, user), this.who.allSpaces(workspace)]); | |
| 234 | + | // Read again only when General was just made (which forgets the spaces). | |
| 226 | 235 | const spaces = await this.who.spacesFor(workspace, person); | |
| 227 | 236 | return ok({ workspace, viewer: user, key: userKey(user), person, spaces, spaceById: new Map(spaces.map((s) => [s.row.id, s])), owner: this.who.viewerOwner(user, workspace.slug) }); | |
| 228 | 237 | } | |
| ⋯ | |||
| 243 | 252 | * read it at all. `opening` counts as opening its link (the `folio` | |
| 244 | 253 | * read and the live socket), which is what makes a link folio readable. | |
| 245 | 254 | */ | |
| 246 | − | private async open(ctx: Ctx, folioId: unknown, need: DocRole, options: { trashed?: boolean; opening?: boolean; text?: boolean } = {}): Promise<Result<{ row: FolioRow; role: DocRole; found: Ancestry }>> { | |
| 247 | − | const columns = options.text ? FOLIO_COLUMNS.replace("'' AS text", "text") : FOLIO_COLUMNS; | |
| 255 | + | private async open(ctx: Ctx, folioId: unknown, need: DocRole, options: { trashed?: boolean; opening?: boolean; text?: boolean; state?: boolean } = {}): Promise<Result<{ row: FolioRow; role: DocRole; found: Ancestry }>> { | |
| 256 | + | let columns = options.text ? FOLIO_COLUMNS.replace("'' AS text", "text") : FOLIO_COLUMNS; | |
| 257 | + | if (options.state) columns += ", state"; | |
| 248 | 258 | const row = await this.db.prepare(`SELECT ${columns} FROM folios WHERE id = ? AND workspace_id = ?`).bind(String(folioId ?? ""), ctx.workspace.id).first<FolioRow>(); | |
| 249 | 259 | if (!row) return fail("not_found", "No such artifact."); | |
| 250 | 260 | if (row.trashed_at && !options.trashed) return fail("not_found", "That artifact is in the trash."); | |
| ⋯ | |||
| 274 | 284 | const readable = rows.filter((r) => roles.get(r.id)); | |
| 275 | 285 | if (!readable.length) return []; | |
| 276 | 286 | const ids = readable.map((r) => r.id); | |
| 277 | − | const [favorites, counts, kids, stale] = await Promise.all([ | |
| 287 | + | // The people (identity, the slow part) with the rows' D1 reads, not after them. | |
| 288 | + | const [favorites, counts, kids, stale, people] = await Promise.all([ | |
| 278 | 289 | this.db.prepare("SELECT folio_id FROM folio_favorites WHERE user_id = ? AND folio_id IN (SELECT value FROM json_each(?))").bind(ctx.viewer.id, json(ids)).all<{ folio_id: string }>(), | |
| 279 | 290 | this.db.prepare("SELECT folio_id, COUNT(*) AS n FROM folio_grants WHERE folio_id IN (SELECT value FROM json_each(?)) GROUP BY folio_id").bind(json(ids)).all<{ folio_id: string; n: number }>(), | |
| 280 | 291 | this.db.prepare("SELECT DISTINCT parent_id FROM folios WHERE parent_id IN (SELECT value FROM json_each(?)) AND trashed_at IS NULL").bind(json(ids)).all<{ parent_id: string }>(), | |
| 281 | 292 | this.staleIds(ids), | |
| 293 | + | this.who.profiles( | |
| 294 | + | ctx.workspace, | |
| 295 | + | readable.flatMap((r) => [r.owner, r.created_by, ...(r.edited_by ? [r.edited_by] : [])]), | |
| 296 | + | ), | |
| 282 | 297 | ]); | |
| 283 | − | const people = await this.who.profiles( | |
| 284 | − | ctx.workspace, | |
| 285 | − | readable.flatMap((r) => [r.owner, r.created_by, ...(r.edited_by ? [r.edited_by] : [])]), | |
| 286 | − | ); | |
| 287 | 298 | const fav = new Set(favorites.results.map((f) => f.folio_id)); | |
| 288 | 299 | const shared = new Map(counts.results.map((c) => [c.folio_id, c.n])); | |
| 289 | 300 | const parents = new Set(kids.results.map((k) => k.parent_id)); | |
| ⋯ | |||
| 431 | 442 | } | |
| 432 | 443 | ||
| 433 | 444 | async sidebar(a: Args): Promise<Result<FoliosSidebar>> { | |
| 434 | − | const found = await this.ctx(a.workspace, a.viewer); | |
| 445 | + | // The spaces they joined need only who they are: read with the context, not after it. | |
| 446 | + | const [found, joined] = await Promise.all([ | |
| 447 | + | this.ctx(a.workspace, a.viewer), | |
| 448 | + | this.db | |
| 449 | + | .prepare("SELECT space_id FROM space_joins WHERE user_id = ?") | |
| 450 | + | .bind(String(a.viewer?.id ?? "")) | |
| 451 | + | .all<{ space_id: string }>(), | |
| 452 | + | ]); | |
| 435 | 453 | if (!found.ok) return found; | |
| 436 | 454 | const ctx = found.value; | |
| 437 | − | const joins = new Set( | |
| 438 | − | (await this.db.prepare("SELECT space_id FROM space_joins WHERE user_id = ?").bind(ctx.viewer.id).all<{ space_id: string }>()).results.map((r) => r.space_id), | |
| 439 | − | ); | |
| 455 | + | const joins = new Set(joined.results.map((r) => r.space_id)); | |
| 440 | 456 | // Joined open spaces (General always), team spaces of theirs, Members-only spaces they're in. | |
| 441 | 457 | const shown = ctx.spaces.filter((s) => s.role && !s.row.archived_at && (s.row.kind !== "workspace" || s.row.is_default || joins.has(s.row.id))); | |
| 442 | 458 | const keys = personKeys(ctx.person); | |
| ⋯ | |||
| 480 | 496 | ]); | |
| 481 | 497 | const all = [...spaceRows.results, ...privateRows.results, ...sharedRows.results, ...favoriteRows.results]; | |
| 482 | 498 | const unique = [...new Map(all.map((r) => [r.id, r])).values()]; | |
| 483 | − | const { roles } = await this.roles(ctx, unique); | |
| 499 | + | // Roles and staleness together: staleness is asked of every tree row, and read only for the readable ones. | |
| 500 | + | const [{ roles }, staleAll] = await Promise.all([this.roles(ctx, unique), this.staleIds([...spaceRows.results, ...privateRows.results].map((r) => r.id))]); | |
| 484 | 501 | const can = (r: FolioRow) => !!roles.get(r.id); | |
| 485 | 502 | const inSpaces = spaceRows.results.filter(can); | |
| 486 | 503 | const mine = privateRows.results.filter(can); | |
| 487 | − | const stale = await this.staleIds([...inSpaces, ...mine].map((r) => r.id)); | |
| 504 | + | const stale = new Set([...inSpaces, ...mine].map((r) => r.id).filter((id) => staleAll.has(id))); | |
| 488 | 505 | const elsewhere = new Set([...inSpaces, ...mine].map((r) => r.id)); | |
| 489 | 506 | const spaceCounts = new Map<string, number>(); | |
| 490 | 507 | for (const r of inSpaces) spaceCounts.set(r.space_id!, (spaceCounts.get(r.space_id!) ?? 0) + 1); | |
| ⋯ | |||
| 541 | 558 | const found = await this.ctx(a.workspace, a.viewer); | |
| 542 | 559 | if (!found.ok) return found; | |
| 543 | 560 | const ctx = found.value; | |
| 544 | − | const opened = await this.open(ctx, a.folio_id, "view", { trashed: true, opening: true, text: true }); | |
| 561 | + | const opened = await this.open(ctx, a.folio_id, "view", { trashed: true, opening: true, text: true, state: true }); | |
| 545 | 562 | if (!opened.ok) return opened; | |
| 546 | 563 | const { row, role } = opened.value; | |
| 547 | 564 | const at = now(); | |
| ⋯ | |||
| 552 | 569 | .run(), | |
| 553 | 570 | ); | |
| 554 | 571 | const above = row.path.split("/").filter((id) => id && id !== row.id); | |
| 555 | − | const [aboveRows, childRows, linkRows] = await Promise.all([ | |
| 572 | + | // The ancestors' and the parents' rows are already known: the folio | |
| 573 | + | // itself, its suggestions, and what sits around it, all in one round. | |
| 574 | + | const known = { roles: new Map([[row.id, role]]), found: opened.value.found }; | |
| 575 | + | const [aboveRows, childRows, linkRows, [folio], suggestions] = await Promise.all([ | |
| 556 | 576 | foliosById(this.db, above), | |
| 557 | 577 | this.db.prepare(`SELECT ${FOLIO_COLUMNS} FROM folios WHERE parent_id = ? AND trashed_at IS NULL ORDER BY position LIMIT 200`).bind(row.id).all<FolioRow>(), | |
| 558 | 578 | this.db | |
| 559 | 579 | .prepare(`SELECT ${folioColumns("f")} FROM folio_links l JOIN folios f ON f.id = l.from_folio WHERE l.to_folio = ? AND f.workspace_id = ? AND f.trashed_at IS NULL LIMIT 200`) | |
| 560 | 580 | .bind(row.id, ctx.workspace.id) | |
| 561 | 581 | .all<FolioRow>(), | |
| 582 | + | this.toFolios(ctx, [row], known), | |
| 583 | + | row.kind === "doc" ? this.openSuggestions(ctx, row) : Promise.resolve([] as FolioSuggestion[]), | |
| 562 | 584 | ]); | |
| 563 | 585 | const parents = above.map((id) => aboveRows.get(id)).filter((r): r is FolioRow => !!r); | |
| 564 | 586 | const others = [...parents, ...childRows.results, ...linkRows.results.filter((r) => r.id !== row.id)]; | |
| 565 | 587 | const { roles } = await this.roles(ctx, others); | |
| 566 | 588 | const readable = (list: FolioRow[]) => list.filter((r) => roles.get(r.id)).map((r) => this.ref(ctx.workspace.slug, r)); | |
| 567 | − | const [folio] = await this.toFolios(ctx, [row], { roles: new Map([[row.id, role]]), found: opened.value.found }); | |
| 568 | 589 | return ok({ | |
| 569 | 590 | folio: folio!, | |
| 570 | 591 | text: row.text, | |
| 592 | + | state: pageState(row.state), | |
| 571 | 593 | breadcrumbs: readable(parents), | |
| 572 | 594 | children: readable(childRows.results), | |
| 573 | 595 | backlinks: readable(linkRows.results.filter((r) => r.id !== row.id)), | |
| 574 | − | suggestions: row.kind === "doc" ? await this.openSuggestions(ctx, row) : [], | |
| 596 | + | suggestions, | |
| 575 | 597 | }); | |
| 576 | 598 | } | |
| 577 | 599 | ||
| ⋯ | |||
| 2449 | 2471 | const { row, role } = opened.value; | |
| 2450 | 2472 | if (row.trashed_at) return new Response("That artifact is in the trash\n", { status: 410 }); | |
| 2451 | 2473 | if (!kindModel(row.kind)) return new Response("That kind of artifact isn't here yet\n", { status: 409 }); | |
| 2452 | − | const room = await this.ready(ctx.workspace, row); | |
| 2453 | − | const member = (await this.who.profiles(ctx.workspace, [ctx.key])).get(ctx.key)!; | |
| 2474 | + | // The room (one call, when it has the document) and who this is (identity) together. | |
| 2475 | + | const [room, member] = await Promise.all([this.ready(ctx.workspace, row), this.who.profiles(ctx.workspace, [ctx.key]).then((people) => people.get(ctx.key)!)]); | |
| 2454 | 2476 | const headers = new Headers(request.headers); | |
| 2455 | 2477 | headers.delete(DOCS_VIEWER_HEADER); | |
| 2456 | 2478 | headers.set(ROOM_MEMBER_HEADER, JSON.stringify({ folio_id: row.id, workspace_slug: ctx.workspace.slug, key: ctx.key, member, role })); | |
| 191 | 191 | const last = input.editors[input.editors.length - 1] ?? null; | |
| 192 | 192 | const statements: D1PreparedStatement[] = []; | |
| 193 | 193 | const preview = r.preview ? JSON.stringify(r.preview) : null; | |
| 194 | + | // The document itself, so a page opens from it before its room answers | |
| 195 | + | // (folios/service.ts `page`), and an emptied room is refilled with the | |
| 196 | + | // very same document (`ready`). Too large for a row: none is kept, and | |
| 197 | + | // the page waits for the room as before. | |
| 198 | + | statements.push(db.prepare("UPDATE folios SET state = ? WHERE id = ?").bind(input.state.byteLength <= MAX_VERSION_STATE ? input.state : null, folio.id)); | |
| 194 | 199 | // A folio made from text that already reads as the kind renders it still needs its card. | |
| 195 | 200 | if (!changed && preview !== folio.preview) statements.push(db.prepare("UPDATE folios SET preview = ? WHERE id = ?").bind(preview, folio.id)); | |
| 196 | 201 | if (changed) { |
| 57 | 57 | ||
| 58 | 58 | export const now = () => new Date().toISOString(); | |
| 59 | 59 | ||
| 60 | + | /** | |
| 61 | + | * Kept across requests in this isolate: the workspace behind a slug for | |
| 62 | + | * half a minute (identity answers it in ~80 ms, and every folio call | |
| 63 | + | * starts with it), and which workspaces already have their General | |
| 64 | + | * space (made once, never unmade). A rename reaches the old slug within | |
| 65 | + | * that half minute, which is what the site's own redirect allows. | |
| 66 | + | */ | |
| 67 | + | const WORKSPACE_TTL_MS = 30_000; | |
| 68 | + | const workspaces = new Map<string, { at: number; value: Promise<Workspace | null> }>(); | |
| 69 | + | const defaultsMade = new Set<string>(); | |
| 70 | + | ||
| 71 | + | /** For tests: forgets everything kept across requests. */ | |
| 72 | + | export function forgetKept(): void { | |
| 73 | + | workspaces.clear(); | |
| 74 | + | defaultsMade.clear(); | |
| 75 | + | } | |
| 76 | + | ||
| 60 | 77 | export function rulesOf(space: Pick<Space, "row" | "members">): SpaceRules { | |
| 61 | 78 | return { kind: space.row.kind, team: space.row.team, default_role: space.row.default_role, members: space.members }; | |
| 62 | 79 | } | |
| ⋯ | |||
| 83 | 100 | const key = String(slug ?? "").toLowerCase(); | |
| 84 | 101 | let found = this.workspaces.get(key); | |
| 85 | 102 | if (!found) { | |
| 86 | − | found = identityClient(this.env.IDENTITY).getWorkspace(key).catch(() => null); | |
| 103 | + | const kept = workspaces.get(key); | |
| 104 | + | if (kept && Date.now() - kept.at < WORKSPACE_TTL_MS) found = kept.value; | |
| 105 | + | else { | |
| 106 | + | found = identityClient(this.env.IDENTITY).getWorkspace(key).catch(() => null); | |
| 107 | + | workspaces.set(key, { at: Date.now(), value: found }); | |
| 108 | + | // Only an answer is kept: a miss or a failure is asked again next time. | |
| 109 | + | void found.then((w) => { | |
| 110 | + | if (!w) workspaces.delete(key); | |
| 111 | + | }); | |
| 112 | + | } | |
| 87 | 113 | this.workspaces.set(key, found); | |
| 88 | 114 | } | |
| 89 | 115 | return found; | |
| ⋯ | |||
| 246 | 272 | ||
| 247 | 273 | /** Makes the workspace's General space, once. */ | |
| 248 | 274 | async ensureDefault(workspace: Workspace, viewer: User): Promise<void> { | |
| 275 | + | if (defaultsMade.has(workspace.id)) return; | |
| 249 | 276 | const db = this.env.DB; | |
| 250 | 277 | const found = await db.prepare("SELECT id FROM spaces WHERE workspace_id = ? AND is_default = 1").bind(workspace.id).first<{ id: string }>(); | |
| 251 | − | if (found) return; | |
| 278 | + | if (found) { | |
| 279 | + | defaultsMade.add(workspace.id); | |
| 280 | + | return; | |
| 281 | + | } | |
| 252 | 282 | const taken = new Set((await db.prepare("SELECT slug FROM spaces WHERE workspace_id = ?").bind(workspace.id).all<{ slug: string }>()).results.map((r) => r.slug)); | |
| 253 | 283 | await db | |
| 254 | 284 | .prepare( | |
| ⋯ | |||
| 256 | 286 | ) | |
| 257 | 287 | .bind(newId("spc"), workspace.id, freeSlug("general", taken), userKey(viewer), now()) | |
| 258 | 288 | .run(); | |
| 289 | + | defaultsMade.add(workspace.id); | |
| 259 | 290 | this.forgetSpaces(); | |
| 260 | 291 | } | |
| 261 | 292 | ||