diff --git a/.changeset/snapshot-sharing-doc-catalog.md b/.changeset/snapshot-sharing-doc-catalog.md new file mode 100644 index 00000000000..6888509e9e6 --- /dev/null +++ b/.changeset/snapshot-sharing-doc-catalog.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut-core": patch +--- + +Let AI assistants find the guide to sharing net snapshots. diff --git a/.changeset/snapshot-sharing-guide.md b/.changeset/snapshot-sharing-guide.md new file mode 100644 index 00000000000..504834b3141 --- /dev/null +++ b/.changeset/snapshot-sharing-guide.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +Document sharing complete net snapshots through links on the demo website. diff --git a/apps/petrinaut-website/README.md b/apps/petrinaut-website/README.md index c39c51c2224..3ea9cd03afa 100644 --- a/apps/petrinaut-website/README.md +++ b/apps/petrinaut-website/README.md @@ -115,6 +115,25 @@ With Brunch configured, `/?brunchTracer=construction` opens a separately identif This candidate proves only root arc/weight progression. Other root classes, deletion/recreation, arc connectivity changes, layout/title, components and subnets are unavailable, not silently approximated. The why result keeps origin separate from subsequent recorded changes and attempts; weight corrections resolve their own governing revision. Basis remains operation-level and semantic utility unassessed. Unrecorded intervening content prevents attribution; failed, stale, no-op and conflicting results are not causes. `test:construction-progression` uses actual Chrome with synthetic native SDK responses, not paid or genuine/provider-class admission. +## Snapshot links + +**Share** in the top bar captures a document for a self-contained `/share#v1.br.` +link. The optional current view uses the same validated query parameters as example +and local-document routes. View navigation preserves the fragment, and browser +history can move between snapshots. + +Snapshots use compact canonical JSON, Brotli quality 11, and unpadded base64url. +The codec loads in a dedicated worker when needed. Encoding and decoding cap the +document at 2 MiB and the fragment at 16,000 characters; decoding enforces its +output limit incrementally. Each worker is terminated on completion, cancellation, +or a 30-second timeout. Larger documents can be downloaded from the Share dialog. + +A snapshot opens read-only without writing a saved document. **Make a local copy** +creates a new UUID and preserves the current view. Snapshot fragments are removed +from Sentry events, transactions, spans, and breadcrumbs before transmission. + +See the [sharing guide](../../libs/@hashintel/petrinaut/docs/sharing.md) for the user flow. + ## Example embeds and oEmbed Canonical example pages live below `/examples`. The JSON oEmbed endpoint at diff --git a/apps/petrinaut-website/package.json b/apps/petrinaut-website/package.json index a7e185d39c6..d92455db1c7 100644 --- a/apps/petrinaut-website/package.json +++ b/apps/petrinaut-website/package.json @@ -34,6 +34,7 @@ "@sentry/react": "10.64.0", "@tanstack/react-router": "1.170.31", "ai": "6.0.182", + "brotli-wasm": "3.0.1", "immer": "10.1.3", "react": "19.2.6", "react-dom": "19.2.6", diff --git a/apps/petrinaut-website/src/examples/full-example-page.tsx b/apps/petrinaut-website/src/examples/full-example-page.tsx index b5066840511..5417ae45a50 100644 --- a/apps/petrinaut-website/src/examples/full-example-page.tsx +++ b/apps/petrinaut-website/src/examples/full-example-page.tsx @@ -1,126 +1,32 @@ -import { useEffect, useState } from "react"; - -import { Button } from "@hashintel/ds-components"; -import { css } from "@hashintel/ds-helpers/css"; -import { Petrinaut } from "@hashintel/petrinaut/ui"; - +import { + ReadonlyDocumentPage, + type ReadonlyDocumentPageProps, +} from "../main/app/readonly-document-page"; import { getOEmbedDiscoveryUrl } from "./oembed-discovery"; import { getReadonlyExampleHandle } from "./readonly-example-handle"; -import { useSharedSearchNavigation } from "./use-shared-search-navigation"; import type { LoadedExample } from "./catalog"; -import type { SharedExampleSearch } from "./example-search"; - -const pageStyle = css({ - width: "[100vw]", - height: "[100vh]", - minWidth: "0", - minHeight: "0", - overflow: "hidden", -}); - -const titleStyle = css({ - minWidth: "0", - overflow: "hidden", - color: "neutral.s90", - fontSize: "sm", - fontWeight: "medium", - textOverflow: "ellipsis", - whiteSpace: "nowrap", -}); -export type FullExamplePageProps = { - example: LoadedExample; - onFork: () => void; - /** Writes the shared search subset back to the page URL. */ - onSearchChange: ( - search: SharedExampleSearch, - history: "push" | "replace", - ) => void; - search: SharedExampleSearch; -}; +export type FullExamplePageProps = Omit< + ReadonlyDocumentPageProps, + "handle" | "title" +> & { example: LoadedExample }; export const FullExamplePage = ({ example, - onFork, - onSearchChange, - search, -}: FullExamplePageProps) => { - const [forkError, setForkError] = useState(null); - const handle = getReadonlyExampleHandle(example); - const navigation = useSharedSearchNavigation(search, onSearchChange); - - const forkLocalCopy = () => { - try { - onFork(); - setForkError(null); - } catch { - setForkError( - "Your browser couldn't save a copy. Free up browser storage and try again.", - ); - } - }; - - useEffect(() => { - const previousTitle = document.title; - document.title = `${example.catalog.title} · Petrinaut`; - return () => { - document.title = previousTitle; - }; - }, [example.catalog.title]); - - // The website is a client-rendered SPA, so the oEmbed discovery link cannot - // be baked into index.html; React 19 hoists this into document.head. - // Consumers that execute the page's JavaScript can then discover the same - // production oEmbed endpoint used by server integrations. - const discoveryUrl = getOEmbedDiscoveryUrl(example.catalog.slug, search); - - return ( -
- - {forkError && ( -
- {forkError} -
- )} - - Make a local copy - - ), - topBarStart: ( - {example.catalog.title} - ), - }} - title={example.catalog.title} - /> -
- ); -}; + ...props +}: FullExamplePageProps) => ( + <> + + + +); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx index 1c9a6136e13..a9c71d6884c 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx @@ -59,6 +59,7 @@ import { useSharedSearchNavigation, withClearedSharedLocation, } from "../../../examples/use-shared-search-navigation"; +import { ShareSnapshotButton } from "../../../sharing/share-snapshot-button"; import { VOICE_REQUEST_ID_HEADER } from "../../../voice-diagnostics"; import { CommandPalette } from "../command-palette"; import { useSentryFeedbackAction } from "../sentry-feedback-button"; @@ -1140,6 +1141,18 @@ export const LocalStorageDemoApp = ({ readonly={false} setTitle={setTitle} title={currentDocument.title} + slots={{ + topBarEnd: ( + ({ + title: currentDocument.title, + definition: + activeHandle.handle.doc() ?? currentDocument.definition, + })} + search={search} + /> + ), + }} viewportActions={[sentryFeedbackAction]} /> diff --git a/apps/petrinaut-website/src/main/app/readonly-document-page.tsx b/apps/petrinaut-website/src/main/app/readonly-document-page.tsx new file mode 100644 index 00000000000..772e124d55e --- /dev/null +++ b/apps/petrinaut-website/src/main/app/readonly-document-page.tsx @@ -0,0 +1,125 @@ +import { useEffect, useState } from "react"; + +import { Button } from "@hashintel/ds-components"; +import { css } from "@hashintel/ds-helpers/css"; +import { Petrinaut } from "@hashintel/petrinaut/ui"; + +import { useSharedSearchNavigation } from "../../examples/use-shared-search-navigation"; +import { ShareSnapshotButton } from "../../sharing/share-snapshot-button"; + +import type { SharedExampleSearch } from "../../examples/example-search"; +import type { PetrinautDocHandle } from "@hashintel/petrinaut-core"; + +export type ReadonlyDocumentPageProps = { + handle: PetrinautDocHandle; + title: string; + onFork: () => void; + onSearchChange: ( + search: SharedExampleSearch, + history: "push" | "replace", + ) => void; + search: SharedExampleSearch; +}; + +export const ReadonlyDocumentPage = ({ + handle, + title, + onFork, + onSearchChange, + search, +}: ReadonlyDocumentPageProps) => { + const [forkError, setForkError] = useState(null); + const navigation = useSharedSearchNavigation(search, onSearchChange); + const forkLocalCopy = () => { + try { + onFork(); + setForkError(null); + } catch { + setForkError( + "Your browser couldn't save a copy. Free up browser storage and try again.", + ); + } + }; + useEffect(() => { + const previousTitle = document.title; + document.title = `${title} · Petrinaut`; + return () => { + document.title = previousTitle; + }; + }, [title]); + + return ( +
+ {forkError && ( +
+ {forkError} +
+ )} + + ({ title, definition: handle.doc()! })} + search={search} + /> + + + ), + topBarStart: ( + + {title} + + ), + }} + title={title} + /> +
+ ); +}; diff --git a/apps/petrinaut-website/src/routes/-share.test.tsx b/apps/petrinaut-website/src/routes/-share.test.tsx new file mode 100644 index 00000000000..9294874be2c --- /dev/null +++ b/apps/petrinaut-website/src/routes/-share.test.tsx @@ -0,0 +1,201 @@ +/// +/** @vitest-environment jsdom */ +import { createRequire } from "node:module"; + +import { + createMemoryHistory, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +import { sirModel } from "@hashintel/petrinaut-core/examples"; + +import { routeTree } from "../routeTree.gen"; +import { + parseSnapshot, + serializeSnapshot, + type Snapshot, +} from "../sharing/snapshot"; +import { openSnapshot } from "../sharing/snapshot-client"; +import { + compressSnapshot, + decompressSnapshot, +} from "../sharing/snapshot-codec"; + +import type { ReadonlyDocumentPageProps } from "../main/app/readonly-document-page"; +import type { BrotliWasmType } from "brotli-wasm"; +import type { ReactNode } from "react"; + +vi.mock("../sharing/snapshot-client", () => ({ openSnapshot: vi.fn() })); +vi.mock("../petricon-page", () => ({ PetriconPage: () => null })); +vi.mock("../main/app/ai-experiments-demo/ai-experiments-demo", () => ({ + AiExperimentsDemo: () => null, +})); +vi.mock("../main/app/brunch-demo/brunch-demo-app", () => ({ + BrunchDemoApp: () => null, +})); +vi.mock("../main/app/local-storage-demo/local-storage-demo-app", () => ({ + LocalStorageDemoApp: ({ initialNetId }: { initialNetId?: string }) => ( +
{initialNetId}
+ ), +})); +vi.mock("../main/app/optimization-demo/browser-optimization-provider", () => ({ + BrowserOptimizationProvider: ({ children }: { children: ReactNode }) => + children, +})); +vi.mock("../main/app/readonly-document-page", () => ({ + ReadonlyDocumentPage: ({ + handle, + title, + search, + onSearchChange, + onFork, + }: ReadonlyDocumentPageProps) => ( +
+

{title}

+ + {String(handle.capabilities?.readonly)} + + {search.mode ?? "edit"} + + + +
+ ), +})); + +const brotli = createRequire(import.meta.url)("brotli-wasm") as BrotliWasmType; +const encodeSnapshot = (input: Snapshot, codec: BrotliWasmType) => + compressSnapshot(serializeSnapshot(input), codec); +const decodeSnapshot = (hash: string, codec: BrotliWasmType) => + parseSnapshot(decompressSnapshot(hash, codec)); +const hash = encodeSnapshot( + { title: "Shared SIR", definition: sirModel.petriNetDefinition }, + brotli, +); + +beforeEach(() => { + vi.stubGlobal("scrollTo", vi.fn()); + const entries = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => entries.get(key) ?? null, + setItem: (key: string, value: string) => entries.set(key, value), + removeItem: (key: string) => entries.delete(key), + clear: () => entries.clear(), + key: (index: number) => [...entries.keys()][index] ?? null, + get length() { + return entries.size; + }, + } satisfies Storage); + vi.mocked(openSnapshot).mockImplementation(async (input) => + decodeSnapshot(input, brotli), + ); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +const open = async (path: string) => { + const router = createRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: [path] }), + defaultPendingMinMs: 0, + }); + await act(async () => { + await router.load(); + }); + render(); + return router; +}; + +test("opens a snapshot in a fresh browser without saving a document", async () => { + await open(`/share?mode=simulate&view=scenarios#${hash}`); + expect(await screen.findByText("Shared SIR")).toBeTruthy(); + expect(screen.getByTestId("readonly").textContent).toBe("true"); + expect(screen.getByTestId("view").textContent).toBe("simulate"); + expect(localStorage.getItem("petrinaut-sdcpn")).toBeNull(); +}); + +test("retains the snapshot through view changes and Back/Forward", async () => { + const router = await open(`/share#${hash}`); + await screen.findByText("Shared SIR"); + fireEvent.click(screen.getByText("Scenarios")); + await waitFor(() => + expect(router.state.location.search.mode).toBe("simulate"), + ); + expect(router.state.location.hash).toBe(hash); + fireEvent.click(screen.getByText("Edit")); + await waitFor(() => + expect(router.state.location.search.mode).toBeUndefined(), + ); + await act(async () => { + router.history.back(); + }); + await waitFor(() => + expect(screen.getByTestId("view").textContent).toBe("simulate"), + ); + await act(async () => { + router.history.forward(); + }); + await waitFor(() => + expect(screen.getByTestId("view").textContent).toBe("edit"), + ); + expect(openSnapshot).toHaveBeenCalledTimes(1); +}); + +test("forks into a new local UUID and preserves the current view", async () => { + const router = await open(`/share?mode=simulate&view=scenarios#${hash}`); + await screen.findByText("Shared SIR"); + fireEvent.click(screen.getByText("Make a local copy")); + await screen.findByTestId("local-document"); + expect(router.state.location.pathname).toMatch(/^\/local\/[0-9a-f-]{36}$/u); + expect(router.state.location.hash).toBe(""); + expect(router.state.location.search.mode).toBe("simulate"); + expect(localStorage.getItem("petrinaut-sdcpn")).toContain( + "Shared SIR (copy)", + ); +}); + +test("changing the fragment loads a different document", async () => { + const router = await open(`/share#${hash}`); + await screen.findByText("Shared SIR"); + const nextHash = encodeSnapshot( + { title: "Another snapshot", definition: sirModel.petriNetDefinition }, + brotli, + ); + await act(async () => { + await router.navigate({ to: "/share", hash: nextHash }); + }); + expect(await screen.findByText("Another snapshot")).toBeTruthy(); +}); + +test.each(["", "v1.br.broken", "v2.br.AAAA"])( + "handles invalid or unsupported snapshot %s without saving", + async (input) => { + await open(`/share#${input}`); + expect(await screen.findByText("Couldn't open snapshot")).toBeTruthy(); + expect(localStorage.getItem("petrinaut-sdcpn")).toBeNull(); + }, +); diff --git a/apps/petrinaut-website/src/routes/share.tsx b/apps/petrinaut-website/src/routes/share.tsx new file mode 100644 index 00000000000..6f03f383486 --- /dev/null +++ b/apps/petrinaut-website/src/routes/share.tsx @@ -0,0 +1,125 @@ +import { + createFileRoute, + useLocation, + useNavigate, + useSearch, +} from "@tanstack/react-router"; +import { useEffect, useState } from "react"; + +import { css } from "@hashintel/ds-helpers/css"; +import { + createJsonDocHandle, + type PetrinautDocHandle, +} from "@hashintel/petrinaut-core"; + +import { validateSharedExampleSearch } from "../examples/example-search"; +import { saveLocalStorageNet } from "../main/app/local-storage-demo/use-local-storage-sdcpns"; +import { BrowserOptimizationProvider } from "../main/app/optimization-demo/browser-optimization-provider"; +import { ReadonlyDocumentPage } from "../main/app/readonly-document-page"; +import { + SnapshotError, + snapshotErrorMessage, + type Snapshot, + type SnapshotErrorCode, +} from "../sharing/snapshot"; +import { openSnapshot } from "../sharing/snapshot-client"; +import { NotFoundPage } from "./-not-found-page"; + +type SnapshotState = + | { kind: "loading" } + | { kind: "ready"; snapshot: Snapshot; handle: PetrinautDocHandle } + | { kind: "error"; code: SnapshotErrorCode }; + +const SnapshotDocument = ({ hash }: { hash: string }) => { + const [state, setState] = useState({ kind: "loading" }); + const search = useSearch({ from: "/share" }); + const navigate = useNavigate({ from: "/share" }); + useEffect(() => { + const controller = new AbortController(); + void openSnapshot(hash, controller.signal).then( + (snapshot) => { + if (controller.signal.aborted) return; + setState({ + kind: "ready", + snapshot, + handle: createJsonDocHandle({ + id: `snapshot:${crypto.randomUUID()}`, + initial: snapshot.definition, + capabilities: { readonly: true }, + historyLimit: 0, + }), + }); + }, + (error: unknown) => { + if (!controller.signal.aborted) { + setState({ + kind: "error", + code: error instanceof SnapshotError ? error.code : "unavailable", + }); + } + }, + ); + return () => controller.abort(); + }, [hash]); + + if (state.kind === "loading") + return ( +
+

Opening snapshot…

+
+ ); + if (state.kind === "error") + return ( + + ); + const { snapshot, handle } = state; + return ( + + { + void navigate({ + search: nextSearch, + hash, + replace: history === "replace", + }); + }} + onFork={() => { + const net = saveLocalStorageNet(window.localStorage, { + title: `${snapshot.title} (copy)`, + petriNetDefinition: structuredClone(snapshot.definition), + }); + void navigate({ + to: "/local/$uuid", + params: { uuid: net.uuid }, + search, + hash: "", + }); + }} + /> + + ); +}; + +const ShareRoute = () => { + const hash = useLocation({ select: (location) => location.hash }); + return ; +}; + +export const Route = createFileRoute("/share")({ + component: ShareRoute, + validateSearch: validateSharedExampleSearch, +}); diff --git a/apps/petrinaut-website/src/sentry/instrument.ts b/apps/petrinaut-website/src/sentry/instrument.ts index 63e190a9c03..cd04f556719 100644 --- a/apps/petrinaut-website/src/sentry/instrument.ts +++ b/apps/petrinaut-website/src/sentry/instrument.ts @@ -5,10 +5,16 @@ import * as Sentry from "@sentry/react"; +import { stripSnapshotLinks } from "./strip-snapshot-links"; + Sentry.init({ dsn: __SENTRY_DSN__, enabled: __ENVIRONMENT__ === "production", environment: __ENVIRONMENT__, + beforeBreadcrumb: stripSnapshotLinks, + beforeSend: stripSnapshotLinks, + beforeSendTransaction: stripSnapshotLinks, + beforeSendSpan: stripSnapshotLinks, integrations: [ Sentry.browserApiErrorsIntegration(), Sentry.browserTracingIntegration(), diff --git a/apps/petrinaut-website/src/sentry/strip-snapshot-links.test.ts b/apps/petrinaut-website/src/sentry/strip-snapshot-links.test.ts new file mode 100644 index 00000000000..60047bef0b5 --- /dev/null +++ b/apps/petrinaut-website/src/sentry/strip-snapshot-links.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "vitest"; + +import { stripSnapshotLinks } from "./strip-snapshot-links"; + +test("removes snapshot payloads from event URLs, breadcrumbs, and span attributes", () => { + const url = "https://demo.petrinaut.org/share?mode=simulate#v1.br.secret"; + const event = { + request: { url }, + breadcrumbs: [{ data: { from: url, to: "/local/uuid" } }], + spans: [{ data: { "url.full": url } }], + }; + const cleaned = stripSnapshotLinks(event); + expect(JSON.stringify(cleaned)).not.toContain("secret"); + expect(cleaned.request.url).toBe( + "https://demo.petrinaut.org/share?mode=simulate#[snapshot]", + ); + expect(cleaned.breadcrumbs[0]?.data.to).toBe("/local/uuid"); + expect(event.request.url).toBe(url); +}); + +test.each([ + "/share#invalid-payload", + "/share#v3.unknown.data", + "/share#v1.br.payload%20truncated", +])("also removes malformed and future payloads from %s", (url) => { + expect(stripSnapshotLinks(url)).toBe("/share#[snapshot]"); +}); + +test("preserves other URLs and non-plain objects", () => { + const error = new Error("Failure"); + expect( + stripSnapshotLinks({ url: "/docs#sharing", error, count: 3, empty: null }), + ).toEqual({ url: "/docs#sharing", error, count: 3, empty: null }); +}); diff --git a/apps/petrinaut-website/src/sentry/strip-snapshot-links.ts b/apps/petrinaut-website/src/sentry/strip-snapshot-links.ts new file mode 100644 index 00000000000..af9fd105161 --- /dev/null +++ b/apps/petrinaut-website/src/sentry/strip-snapshot-links.ts @@ -0,0 +1,22 @@ +export const stripSnapshotLinks = (value: Value): Value => { + if (typeof value === "string") { + return value.replace( + /(\/share(?:\?[^#\s"]*)?)#[^\s"<>]*/gu, + "$1#[snapshot]", + ) as Value; + } + if (Array.isArray(value)) return value.map(stripSnapshotLinks) as Value; + if ( + value !== null && + typeof value === "object" && + Object.getPrototypeOf(value) === Object.prototype + ) { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + key, + stripSnapshotLinks(entry), + ]), + ) as Value; + } + return value; +}; diff --git a/apps/petrinaut-website/src/sharing/share-snapshot-button.test.tsx b/apps/petrinaut-website/src/sharing/share-snapshot-button.test.tsx new file mode 100644 index 00000000000..a172a7a1d3f --- /dev/null +++ b/apps/petrinaut-website/src/sharing/share-snapshot-button.test.tsx @@ -0,0 +1,186 @@ +/** @vitest-environment jsdom */ +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +import { serializeSDCPN } from "@hashintel/petrinaut-core"; +import { sirModel } from "@hashintel/petrinaut-core/examples"; + +import { ShareSnapshotButton } from "./share-snapshot-button"; +import { SnapshotError } from "./snapshot"; +import { prepareSnapshot } from "./snapshot-client"; + +vi.mock("./snapshot-client", async (importOriginal) => ({ + ...(await importOriginal()), + prepareSnapshot: vi.fn(), +})); + +const writeText = vi.fn().mockResolvedValue(undefined); +beforeEach(() => { + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + vi.mocked(prepareSnapshot).mockResolvedValue("v1.br.encoded"); +}); +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +const open = () => { + const snapshot = { + title: "SIR", + definition: structuredClone(sirModel.petriNetDefinition), + }; + render( + snapshot} + search={{ mode: "simulate", view: "scenarios" }} + />, + ); + fireEvent.click(screen.getByRole("button", { name: "Share" })); + return snapshot; +}; + +test("captures a snapshot and copies its link with an optional current view", async () => { + const original = open(); + original.title = "Changed later"; + const input = await screen.findByRole("textbox", { name: "Snapshot link" }); + expect(vi.mocked(prepareSnapshot).mock.calls[0]?.[0].title).toBe("SIR"); + expect(input.getAttribute("value")).toContain("mode=simulate"); + fireEvent.click(screen.getByRole("button", { name: "Copy snapshot link" })); + await screen.findByText("Link copied."); + expect(writeText).toHaveBeenLastCalledWith( + expect.stringContaining("mode=simulate"), + ); + fireEvent.click( + screen.getByRole("checkbox", { name: "Include current view" }), + ); + await waitFor(() => expect(input.getAttribute("value")).not.toContain("?")); + fireEvent.click(screen.getByRole("button", { name: "Copy snapshot link" })); + await waitFor(() => + expect(writeText).toHaveBeenLastCalledWith( + `${window.location.origin}/share#v1.br.encoded`, + ), + ); + expect(prepareSnapshot).toHaveBeenCalledTimes(1); +}); + +test("offers a file instead of a copyable link when compression exceeds the limit", async () => { + vi.mocked(prepareSnapshot).mockRejectedValue(new SnapshotError("too-large")); + const createObjectURL = vi + .fn<(blob: Blob) => string>() + .mockReturnValue("blob:snapshot"); + const revokeObjectURL = vi.fn(); + vi.stubGlobal( + "URL", + class extends URL { + static createObjectURL = createObjectURL; + static revokeObjectURL = revokeObjectURL; + }, + ); + const download = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => {}); + const captured = structuredClone(open()); + expect(await screen.findByRole("alert")).toHaveProperty( + "textContent", + "This snapshot is too large for a link. Share the downloaded file instead.", + ); + expect( + screen.getByRole("button", { name: "Copy snapshot link" }), + ).toHaveProperty("disabled", true); + fireEvent.click(screen.getByRole("button", { name: "Download file" })); + expect(download).toHaveBeenCalledOnce(); + expect(download.mock.instances[0]).toHaveProperty("download", "SIR.yaml"); + expect(download.mock.instances[0]).toHaveProperty("href", "blob:snapshot"); + const blob = createObjectURL.mock.calls[0]?.[0]; + expect(blob).toBeInstanceOf(Blob); + if (!blob) throw new Error("The snapshot download was not created"); + expect(blob.type).toBe("application/yaml"); + const content = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === "string") resolve(reader.result); + else reject(new Error("The snapshot file could not be read as text")); + }; + reader.onerror = () => reject(reader.error); + reader.readAsText(blob); + }); + expect(content).toBe( + serializeSDCPN({ + title: captured.title, + petriNetDefinition: captured.definition, + }), + ); + await waitFor(() => + expect(revokeObjectURL).toHaveBeenCalledWith("blob:snapshot"), + ); +}); + +test("retains a selectable link when clipboard access fails", async () => { + writeText.mockRejectedValueOnce(new Error("Clipboard denied")); + open(); + await screen.findByRole("textbox", { name: "Snapshot link" }); + fireEvent.click(screen.getByRole("button", { name: "Copy snapshot link" })); + expect(await screen.findByRole("alert")).toHaveProperty( + "textContent", + "Couldn't copy the link. Select it above and copy it manually.", + ); + expect(screen.getByRole("textbox", { name: "Snapshot link" })).toHaveProperty( + "readOnly", + true, + ); +}); + +test("cancels preparation when the dialog unmounts", async () => { + vi.mocked(prepareSnapshot).mockImplementation(() => new Promise(() => {})); + open(); + await screen.findByText("Preparing your link…"); + const signal = vi.mocked(prepareSnapshot).mock.calls[0]?.[1]; + cleanup(); + expect(signal?.aborted).toBe(true); +}); + +test("keeps the copied URL stable while clipboard access is pending", async () => { + let finishCopy: () => void = () => {}; + writeText.mockImplementationOnce( + () => + new Promise((resolve) => { + finishCopy = resolve; + }), + ); + open(); + const input = await screen.findByRole("textbox", { name: "Snapshot link" }); + const originalUrl = input.getAttribute("value"); + fireEvent.click(screen.getByRole("button", { name: "Copy snapshot link" })); + const checkbox = screen.getByRole("checkbox", { + name: "Include current view", + }); + expect(checkbox).toHaveProperty("disabled", true); + const copying = screen.getByRole("button", { name: "Copying…" }); + expect(copying).toHaveProperty("disabled", true); + fireEvent.click(copying); + expect(writeText).toHaveBeenCalledOnce(); + expect(writeText).toHaveBeenCalledWith(originalUrl); + await act(async () => finishCopy()); + expect(await screen.findByText("Link copied.")).toBeTruthy(); + expect(checkbox).toHaveProperty("disabled", false); + fireEvent.click(checkbox); + await waitFor(() => expect(input.getAttribute("value")).not.toContain("?")); + expect(screen.queryByText("Link copied.")).toBeNull(); +}); diff --git a/apps/petrinaut-website/src/sharing/share-snapshot-button.tsx b/apps/petrinaut-website/src/sharing/share-snapshot-button.tsx new file mode 100644 index 00000000000..1d183bdaf10 --- /dev/null +++ b/apps/petrinaut-website/src/sharing/share-snapshot-button.tsx @@ -0,0 +1,201 @@ +import { useEffect, useState } from "react"; + +import { Button, Checkbox, Dialog } from "@hashintel/ds-components"; +import { css } from "@hashintel/ds-helpers/css"; +import { serializeSDCPN } from "@hashintel/petrinaut-core"; + +import { + SnapshotError, + snapshotErrorMessage, + type Snapshot, + type SnapshotErrorCode, +} from "./snapshot"; +import { prepareSnapshot, snapshotUrl } from "./snapshot-client"; + +import type { SharedExampleSearch } from "../examples/example-search"; + +type CapturedSnapshot = { snapshot: Snapshot; search: SharedExampleSearch }; +type PreparedLink = + | { kind: "loading" } + | { kind: "ready"; hash: string } + | { kind: "error"; code: SnapshotErrorCode }; + +const downloadSnapshot = ({ definition, title }: Snapshot) => { + const content = serializeSDCPN({ petriNetDefinition: definition, title }); + const url = URL.createObjectURL( + new Blob([content], { type: "application/yaml" }), + ); + const link = document.createElement("a"); + link.href = url; + link.download = `${(title.trim() === "" ? "snapshot" : title).replace(/[^a-z0-9_-]/giu, "_")}.yaml`; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 0); +}; + +const ShareSnapshotDialog = ({ + captured, + onClose, +}: { + captured: CapturedSnapshot; + onClose: () => void; +}) => { + const [includeView, setIncludeView] = useState(true); + const [prepared, setPrepared] = useState({ kind: "loading" }); + const [copyState, setCopyState] = useState< + "idle" | "copying" | "copied" | "failed" + >("idle"); + useEffect(() => { + const controller = new AbortController(); + void prepareSnapshot(captured.snapshot, controller.signal).then( + (hash) => { + if (!controller.signal.aborted) setPrepared({ kind: "ready", hash }); + }, + (error: unknown) => { + if (!controller.signal.aborted) { + setPrepared({ + kind: "error", + code: error instanceof SnapshotError ? error.code : "unavailable", + }); + } + }, + ); + return () => controller.abort(); + }, [captured.snapshot]); + + const url = + prepared.kind === "ready" + ? snapshotUrl( + window.location.origin, + prepared.hash, + includeView ? captured.search : {}, + ) + : null; + const copy = async () => { + if (url === null || copyState === "copying") return; + setCopyState("copying"); + try { + await navigator.clipboard.writeText(url); + setCopyState("copied"); + } catch { + setCopyState("failed"); + } + }; + + return ( + + + +
+ { + setIncludeView(value); + setCopyState("idle"); + }} + /> + {url !== null && ( + event.currentTarget.select()} + className={css({ + width: "full", + minWidth: "0", + borderWidth: "thin", + borderColor: "neutral.s20", + borderRadius: "md", + padding: "3", + fontSize: "sm", + background: "neutral.s05", + })} + /> + )} +

+ {prepared.kind === "loading" + ? "Preparing your link…" + : prepared.kind === "error" + ? snapshotErrorMessage(prepared.code) + : copyState === "copied" + ? "Link copied." + : copyState === "failed" + ? "Couldn't copy the link. Select it above and copy it manually." + : "The document is included in the link. No upload is needed."} +

+
+
+ downloadSnapshot(captured.snapshot)} + > + Download file + + } + actions={ + + } + /> +
+ ); +}; + +export const ShareSnapshotButton = ({ + getSnapshot, + search, +}: { + getSnapshot: () => Snapshot; + search: SharedExampleSearch; +}) => { + const [captured, setCaptured] = useState(null); + return ( + <> + + {captured !== null && ( + setCaptured(null)} + /> + )} + + ); +}; diff --git a/apps/petrinaut-website/src/sharing/snapshot-client.test.ts b/apps/petrinaut-website/src/sharing/snapshot-client.test.ts new file mode 100644 index 00000000000..76f5957f897 --- /dev/null +++ b/apps/petrinaut-website/src/sharing/snapshot-client.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +import { sirModel } from "@hashintel/petrinaut-core/examples"; + +import { serializeSnapshot, SnapshotError } from "./snapshot"; +import { openSnapshot, prepareSnapshot } from "./snapshot-client"; + +import type { SnapshotResponse } from "./snapshot-worker-protocol"; + +const workers: SnapshotWorker[] = []; +class SnapshotWorker { + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + onmessageerror: (() => void) | null = null; + postMessage = vi.fn(); + terminate = vi.fn(); + + constructor() { + workers.push(this); + } + + reply(data: SnapshotResponse) { + this.onmessage?.(new MessageEvent("message", { data })); + } +} + +const latestWorker = () => { + const worker = workers.at(-1); + if (!worker) throw new Error("A snapshot worker should have started"); + return worker; +}; +const snapshot = { title: "SIR", definition: sirModel.petriNetDefinition }; + +beforeEach(() => { + workers.length = 0; + vi.useFakeTimers(); + vi.stubGlobal("Worker", SnapshotWorker); +}); +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +test("passes serialized bytes to the worker and releases it after compression", async () => { + const pending = prepareSnapshot(snapshot, new AbortController().signal); + const worker = latestWorker(); + expect(worker.postMessage).toHaveBeenCalledWith({ + kind: "encode", + bytes: serializeSnapshot(snapshot), + }); + worker.reply({ kind: "encoded", hash: "v1.br.example" }); + await expect(pending).resolves.toBe("v1.br.example"); + expect(worker.terminate).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); +}); + +test("validates decompressed bytes before returning a shared document", async () => { + const pending = openSnapshot("v1.br.example", new AbortController().signal); + latestWorker().reply({ + kind: "decoded", + bytes: new TextEncoder().encode("null"), + }); + await expect(pending).rejects.toEqual(new SnapshotError("invalid")); + expect(latestWorker().terminate).toHaveBeenCalledOnce(); +}); + +test("terminates a worker when its operation is cancelled", async () => { + const controller = new AbortController(); + const pending = openSnapshot("v1.br.example", controller.signal); + controller.abort(); + await expect(pending).rejects.toHaveProperty("name", "AbortError"); + expect(latestWorker().terminate).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); +}); + +test("does not start a worker for an already cancelled operation", async () => { + await expect( + openSnapshot("v1.br.example", AbortSignal.abort()), + ).rejects.toHaveProperty("name", "AbortError"); + expect(workers).toHaveLength(0); +}); + +test("stops a stalled worker after thirty seconds", async () => { + const pending = openSnapshot("v1.br.example", new AbortController().signal); + const rejection = expect(pending).rejects.toEqual( + new SnapshotError("unavailable"), + ); + await vi.advanceTimersByTimeAsync(30_000); + await rejection; + expect(latestWorker().terminate).toHaveBeenCalledOnce(); +}); + +test.each(["onerror", "onmessageerror"] as const)( + "releases a worker after %s", + async (event) => { + const pending = openSnapshot("v1.br.example", new AbortController().signal); + latestWorker()[event]?.(); + await expect(pending).rejects.toEqual(new SnapshotError("unavailable")); + expect(latestWorker().terminate).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + }, +); diff --git a/apps/petrinaut-website/src/sharing/snapshot-client.ts b/apps/petrinaut-website/src/sharing/snapshot-client.ts new file mode 100644 index 00000000000..28f092d26b2 --- /dev/null +++ b/apps/petrinaut-website/src/sharing/snapshot-client.ts @@ -0,0 +1,101 @@ +import { + canonicalSearchString, + type SharedExampleSearch, +} from "../examples/example-search"; +import { + maxSnapshotHashLength, + parseSnapshot, + serializeSnapshot, + SnapshotError, + type Snapshot, +} from "./snapshot"; + +import type { + SnapshotRequest, + SnapshotResponse, +} from "./snapshot-worker-protocol"; + +const requestSnapshot = ( + request: SnapshotRequest, + signal: AbortSignal, +): Promise => + new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new DOMException("Snapshot request aborted", "AbortError")); + return; + } + const worker = new Worker( + new URL("./snapshot-worker.ts", import.meta.url), + { type: "module" }, + ); + const listeners = new AbortController(); + let timeout: ReturnType; + const finish = () => { + worker.terminate(); + clearTimeout(timeout); + listeners.abort(); + }; + const abort = () => { + finish(); + reject(new DOMException("Snapshot request aborted", "AbortError")); + }; + timeout = setTimeout(() => { + finish(); + reject(new SnapshotError("unavailable")); + }, 30_000); + signal.addEventListener("abort", abort, { + once: true, + signal: listeners.signal, + }); + worker.onmessage = (event: MessageEvent) => { + finish(); + if (event.data.kind === "error") + reject(new SnapshotError(event.data.code)); + else resolve(event.data); + }; + const fail = () => { + finish(); + reject(new SnapshotError("unavailable")); + }; + worker.onerror = fail; + worker.onmessageerror = fail; + try { + worker.postMessage(request); + } catch { + fail(); + } + }); + +export const prepareSnapshot = async ( + snapshot: Snapshot, + signal: AbortSignal, +): Promise => { + const response = await requestSnapshot( + { kind: "encode", bytes: serializeSnapshot(snapshot) }, + signal, + ); + if (response.kind !== "encoded") throw new SnapshotError("unavailable"); + return response.hash; +}; + +export const openSnapshot = async ( + hash: string, + signal: AbortSignal, +): Promise => { + if (hash.length > maxSnapshotHashLength) throw new SnapshotError("too-large"); + if (!hash) throw new SnapshotError("invalid"); + const response = await requestSnapshot({ kind: "decode", hash }, signal); + if (response.kind !== "decoded") throw new SnapshotError("unavailable"); + return parseSnapshot(response.bytes); +}; + +export const snapshotUrl = ( + origin: string, + hash: string, + search: SharedExampleSearch = {}, +): string => { + const url = new URL("/share", origin); + url.hash = hash; + url.search = canonicalSearchString(search); + return url.href; +}; diff --git a/apps/petrinaut-website/src/sharing/snapshot-codec.ts b/apps/petrinaut-website/src/sharing/snapshot-codec.ts new file mode 100644 index 00000000000..4087c1ab1c2 --- /dev/null +++ b/apps/petrinaut-website/src/sharing/snapshot-codec.ts @@ -0,0 +1,105 @@ +import type { BrotliWasmType } from "brotli-wasm"; + +export const maxSnapshotHashLength = 16_000; +export const maxSnapshotBytes = 2 * 1024 * 1024; +const prefix = "v1.br."; + +export type SnapshotErrorCode = + | "invalid" + | "unsupported" + | "too-large" + | "unavailable"; + +export class SnapshotError extends Error { + constructor(public readonly code: SnapshotErrorCode) { + super(code); + } +} + +export const snapshotErrorMessage = (code: SnapshotErrorCode): string => { + switch (code) { + case "invalid": + return "This snapshot link is incomplete or invalid. Ask the sender to copy it again."; + case "unsupported": + return "This snapshot uses a newer link format. Refresh Petrinaut and try again."; + case "too-large": + return "This snapshot is too large for a link. Share the downloaded file instead."; + case "unavailable": + return "Petrinaut couldn't prepare this snapshot. Please try again."; + } +}; + +const toBase64Url = (bytes: Uint8Array): string => { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary) + .replace(/\+/gu, "-") + .replace(/\//gu, "_") + .replace(/=+$/u, ""); +}; + +export const compressSnapshot = ( + bytes: Uint8Array, + brotli: BrotliWasmType, +): string => { + if (bytes.length > maxSnapshotBytes) throw new SnapshotError("too-large"); + const hash = prefix + toBase64Url(brotli.compress(bytes, { quality: 11 })); + if (hash.length > maxSnapshotHashLength) throw new SnapshotError("too-large"); + return hash; +}; + +export const decompressSnapshot = ( + hash: string, + brotli: BrotliWasmType, +): Uint8Array => { + if (hash.length > maxSnapshotHashLength) throw new SnapshotError("too-large"); + if (!hash.startsWith(prefix)) { + throw new SnapshotError(/^v\d+\./u.test(hash) ? "unsupported" : "invalid"); + } + const encoded = hash.slice(prefix.length); + if (!/^[A-Za-z0-9_-]+$/u.test(encoded)) throw new SnapshotError("invalid"); + try { + const binary = atob(encoded.replace(/-/gu, "+").replace(/_/gu, "/")); + const compressed = Uint8Array.from(binary, (character) => + character.charCodeAt(0), + ); + if (toBase64Url(compressed) !== encoded) throw new SnapshotError("invalid"); + const stream = new brotli.DecompressStream(); + const chunks: Uint8Array[] = []; + let size = 0; + let offset = 0; + try { + for (;;) { + const result = stream.decompress(compressed.subarray(offset), 32_768); + try { + const chunk = result.buf; + size += chunk.length; + if (size > maxSnapshotBytes) throw new SnapshotError("too-large"); + chunks.push(chunk); + offset += result.input_offset; + if (result.code === brotli.BrotliStreamResultCode.ResultSuccess) { + if (offset !== compressed.length) + throw new SnapshotError("invalid"); + break; + } + if (result.code !== brotli.BrotliStreamResultCode.NeedsMoreOutput) { + throw new SnapshotError("invalid"); + } + } finally { + result.free(); + } + } + } finally { + stream.free(); + } + const output = new Uint8Array(size); + let position = 0; + for (const chunk of chunks) { + output.set(chunk, position); + position += chunk.length; + } + return output; + } catch (error) { + throw error instanceof SnapshotError ? error : new SnapshotError("invalid"); + } +}; diff --git a/apps/petrinaut-website/src/sharing/snapshot-worker-protocol.ts b/apps/petrinaut-website/src/sharing/snapshot-worker-protocol.ts new file mode 100644 index 00000000000..86b31f69730 --- /dev/null +++ b/apps/petrinaut-website/src/sharing/snapshot-worker-protocol.ts @@ -0,0 +1,10 @@ +import type { SnapshotErrorCode } from "./snapshot-codec"; + +export type SnapshotRequest = + | { kind: "encode"; bytes: Uint8Array } + | { kind: "decode"; hash: string }; + +export type SnapshotResponse = + | { kind: "encoded"; hash: string } + | { kind: "decoded"; bytes: Uint8Array } + | { kind: "error"; code: SnapshotErrorCode }; diff --git a/apps/petrinaut-website/src/sharing/snapshot-worker.ts b/apps/petrinaut-website/src/sharing/snapshot-worker.ts new file mode 100644 index 00000000000..dcdd85bea32 --- /dev/null +++ b/apps/petrinaut-website/src/sharing/snapshot-worker.ts @@ -0,0 +1,30 @@ +import brotliPromise from "brotli-wasm"; + +import { + decompressSnapshot, + compressSnapshot, + SnapshotError, +} from "./snapshot-codec"; + +import type { + SnapshotRequest, + SnapshotResponse, +} from "./snapshot-worker-protocol"; + +self.onmessage = async (event: MessageEvent) => { + let response: SnapshotResponse; + try { + const brotli = await brotliPromise; + const request = event.data; + response = + request.kind === "encode" + ? { kind: "encoded", hash: compressSnapshot(request.bytes, brotli) } + : { kind: "decoded", bytes: decompressSnapshot(request.hash, brotli) }; + } catch (error) { + response = { + kind: "error", + code: error instanceof SnapshotError ? error.code : "unavailable", + }; + } + self.postMessage(response); +}; diff --git a/apps/petrinaut-website/src/sharing/snapshot.test.ts b/apps/petrinaut-website/src/sharing/snapshot.test.ts new file mode 100644 index 00000000000..799dbd2e6bc --- /dev/null +++ b/apps/petrinaut-website/src/sharing/snapshot.test.ts @@ -0,0 +1,149 @@ +/// +import { createRequire } from "node:module"; +import { brotliCompressSync } from "node:zlib"; + +import { expect, test } from "vitest"; + +import { parseSDCPNFile, serializeSDCPN } from "@hashintel/petrinaut-core"; +import * as examples from "@hashintel/petrinaut-core/examples"; + +import { + parseSnapshot, + serializeSnapshot, + type Snapshot, + maxSnapshotBytes, + maxSnapshotHashLength, + SnapshotError, +} from "./snapshot"; +import { snapshotUrl } from "./snapshot-client"; +import { compressSnapshot, decompressSnapshot } from "./snapshot-codec"; + +import type { BrotliWasmType } from "brotli-wasm"; + +const brotli = createRequire(import.meta.url)("brotli-wasm") as BrotliWasmType; +const encodeSnapshot = (input: Snapshot, codec: BrotliWasmType) => + compressSnapshot(serializeSnapshot(input), codec); +const decodeSnapshot = (hash: string, codec: BrotliWasmType) => + parseSnapshot(decompressSnapshot(hash, codec)); +const snapshot = { + title: "Épidémie 🦠 / 複製", + definition: examples.sirModel.petriNetDefinition, +}; +const compressedHash = (text: string) => + `v1.br.${brotliCompressSync(Buffer.from(text)).toString("base64url")}`; + +test.each(Object.entries(examples))( + "round-trips the full %s example through URL-safe Brotli", + (_name, example) => { + const input = { + title: snapshot.title, + definition: example.petriNetDefinition, + }; + const parsed = parseSDCPNFile( + JSON.parse( + serializeSDCPN({ + petriNetDefinition: input.definition, + title: input.title, + format: "json", + }), + ) as unknown, + ); + expect(parsed.ok).toBe(true); + if (!parsed.ok) throw new Error(parsed.error); + const { title, ...definition } = parsed.sdcpn; + const hash = encodeSnapshot(input, brotli); + expect(hash).toMatch(/^v1\.br\.[A-Za-z0-9_-]+$/u); + expect(decodeSnapshot(hash, brotli)).toEqual({ title, definition }); + expect(hash.length).toBeLessThan(JSON.stringify(input).length); + }, +); + +test("decodes compatible snapshots produced independently of the browser encoder", () => { + const document = serializeSDCPN({ + petriNetDefinition: snapshot.definition, + title: snapshot.title, + format: "json", + }); + expect(decodeSnapshot(compressedHash(document), brotli).title).toBe( + snapshot.title, + ); +}); + +test.each(["", "garbage", "v1.br.", "v1.br.%%%%", "v1.br.A", "v1.br.AB"])( + "rejects malformed link %s", + (hash) => { + expect(() => decodeSnapshot(hash, brotli)).toThrow( + new SnapshotError("invalid"), + ); + }, +); + +test("rejects future link formats", () => { + expect(() => decodeSnapshot("v2.br.AAAA", brotli)).toThrow( + new SnapshotError("unsupported"), + ); +}); + +test("rejects truncated compressed data and trailing bytes", () => { + const hash = encodeSnapshot(snapshot, brotli); + const bytes = Buffer.from(hash.slice("v1.br.".length), "base64url"); + expect(() => + decodeSnapshot( + `v1.br.${bytes.subarray(0, -4).toString("base64url")}`, + brotli, + ), + ).toThrow(new SnapshotError("invalid")); + expect(() => + decodeSnapshot( + `v1.br.${Buffer.concat([bytes, Buffer.from([0])]).toString("base64url")}`, + brotli, + ), + ).toThrow(new SnapshotError("invalid")); +}); + +test.each(["not json", "null", '{"places":42}'])( + "rejects decompressed content that is not a document", + (text) => { + expect(() => decodeSnapshot(compressedHash(text), brotli)).toThrow( + new SnapshotError("invalid"), + ); + }, +); + +test("bounds the encoded link before decompression", () => { + expect(() => + decodeSnapshot("a".repeat(maxSnapshotHashLength + 1), brotli), + ).toThrow(new SnapshotError("too-large")); +}); + +test("stops decompression when a short link expands beyond the document limit", () => { + const hash = compressedHash("a".repeat(maxSnapshotBytes + 1)); + expect(hash.length).toBeLessThan(maxSnapshotHashLength); + expect(() => decodeSnapshot(hash, brotli)).toThrow( + new SnapshotError("too-large"), + ); +}); + +test("refuses documents larger than the input limit", () => { + expect(() => + encodeSnapshot( + { ...snapshot, title: "a".repeat(maxSnapshotBytes) }, + brotli, + ), + ).toThrow(new SnapshotError("too-large")); +}); + +test("puts only resumable view parameters in the query, with the document in the fragment", () => { + const hash = encodeSnapshot(snapshot, brotli); + const url = new URL( + snapshotUrl("https://demo.petrinaut.org", hash, { + mode: "simulate", + view: "scenarios", + subnet: "subnet / α", + }), + ); + expect(url.pathname).toBe("/share"); + expect(url.hash).toBe(`#${hash}`); + expect(url.searchParams.get("subnet")).toBe("subnet / α"); + expect(new URL(snapshotUrl(url.origin, hash)).search).toBe(""); +}); diff --git a/apps/petrinaut-website/src/sharing/snapshot.ts b/apps/petrinaut-website/src/sharing/snapshot.ts new file mode 100644 index 00000000000..78834015d8a --- /dev/null +++ b/apps/petrinaut-website/src/sharing/snapshot.ts @@ -0,0 +1,49 @@ +/** + * @layerRoot website.sharing + * @role Encodes complete documents in share links and opens independent snapshots + */ +import { + parseSDCPNFile, + serializeSDCPN, + type SDCPN, +} from "@hashintel/petrinaut-core"; + +import { maxSnapshotBytes, SnapshotError } from "./snapshot-codec"; + +export { + maxSnapshotBytes, + maxSnapshotHashLength, + SnapshotError, + snapshotErrorMessage, + type SnapshotErrorCode, +} from "./snapshot-codec"; + +export type Snapshot = { title: string; definition: SDCPN }; + +export const serializeSnapshot = (snapshot: Snapshot): Uint8Array => { + const document = JSON.parse( + serializeSDCPN({ + petriNetDefinition: snapshot.definition, + title: snapshot.title, + format: "json", + }), + ) as unknown; + const bytes = new TextEncoder().encode(JSON.stringify(document)); + if (bytes.length > maxSnapshotBytes) throw new SnapshotError("too-large"); + return bytes; +}; + +export const parseSnapshot = (bytes: Uint8Array): Snapshot => { + if (bytes.length > maxSnapshotBytes) throw new SnapshotError("too-large"); + try { + const json: unknown = JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode(bytes), + ); + const parsed = parseSDCPNFile(json); + if (!parsed.ok) throw new SnapshotError("invalid"); + const { title, ...definition } = parsed.sdcpn; + return { title, definition }; + } catch (error) { + throw error instanceof SnapshotError ? error : new SnapshotError("invalid"); + } +}; diff --git a/libs/@hashintel/petrinaut-core/src/ai.ts b/libs/@hashintel/petrinaut-core/src/ai.ts index 0f18105957d..ec534a19708 100644 --- a/libs/@hashintel/petrinaut-core/src/ai.ts +++ b/libs/@hashintel/petrinaut-core/src/ai.ts @@ -98,6 +98,7 @@ export const createExperimentToolName = "createExperiment"; export const petrinautDocNames = [ "drawing-a-net", + "sharing", "petri-net-extensions", "useful-patterns", "simulation", @@ -116,6 +117,8 @@ export const petrinautDocNames = [ export type PetrinautDocName = (typeof petrinautDocNames)[number]; export const petrinautDocSummaries: Record = { + sharing: + "Demo website snapshot links: capture a complete net, optionally include the current view, open read-only, make a local copy, and download files when links are too large.", "drawing-a-net": "Top bar (mode selector, menu, version history, active experiments), canvas, sidebars, adding nodes, arcs, selection, keyboard shortcuts, import/export, auto-layout.", "petri-net-extensions": diff --git a/libs/@hashintel/petrinaut/docs/README.md b/libs/@hashintel/petrinaut/docs/README.md index 62f80cdcf23..2db37a956e8 100644 --- a/libs/@hashintel/petrinaut/docs/README.md +++ b/libs/@hashintel/petrinaut/docs/README.md @@ -30,6 +30,7 @@ Petrinaut has three global modes in the top bar, though **Actual** is only enabl ## Contents - [Drawing a Net](drawing-a-net.md) -- Top bar, canvas, sidebars, adding nodes and connecting arcs, selection, keyboard shortcuts, import/export, auto-layout. +- [Sharing a Net](sharing.md) -- Share a snapshot link, include the current view, and save an editable local copy. - [Petri Net Extensions](petri-net-extensions.md) -- Types, dynamics, transition kernels, firing rules, read/inhibitor arcs, as well as parameters and state visualizers. - [Useful Patterns](useful-patterns.md) -- Common modelling techniques, including duration and resource pools. - [Simulation](simulation.md) -- Set initial state, run a single simulation, use the timeline, control playback. diff --git a/libs/@hashintel/petrinaut/docs/sharing.md b/libs/@hashintel/petrinaut/docs/sharing.md new file mode 100644 index 00000000000..6847f8b2315 --- /dev/null +++ b/libs/@hashintel/petrinaut/docs/sharing.md @@ -0,0 +1,29 @@ +# Sharing a Net + +On the Petrinaut demo website, choose **Share** in the top bar to create a link containing a copy of the current net. Recipients can open it in another browser without an account. + +## Create a snapshot link + +1. Open the net and choose **Share**. +2. Leave **Include current view** selected to include the current mode, scenario, subnet, and selected item. Clear it to open the snapshot at its default view. +3. Wait for the link to appear, then choose **Copy snapshot link**. + +The snapshot includes the net's title, layout, descriptions, code, parameters, types, subnets, scenarios, and metric definitions. It captures the document when you open the Share dialog. Close and reopen the dialog to capture later edits. + +Running simulations, result history, AI conversations, and browser preferences are not included. Including the current view restores a location in the editor; it does not resume a running simulation. + +The document is compressed into the link. Anyone with the complete link can open the snapshot. Your later edits do not change it, and deleting your local document does not revoke it. + +## Open a shared snapshot + +A snapshot opens read-only. You can inspect the net, navigate between views, and run simulations. Choose **Make a local copy** to save an editable copy in your browser. The copy receives its own local document URL and keeps your current view. + +Simply opening a snapshot does not add it to your saved documents. You can bookmark the snapshot link or make a local copy to return to it later. + +## Share a file instead + +Choose **Download file** in the Share dialog to export the captured net as a YAML file. The recipient can import it from **Menu > Import**. + +Large nets may exceed the snapshot-link limit. In that case, the dialog offers the file download and leaves **Copy snapshot link** disabled. Files are also useful when an application truncates a long link. + +If a snapshot will not open, ask the sender for the complete link or a file export. A message about a newer snapshot format means you should refresh Petrinaut before trying again. diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/petrinaut-docs-content.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/petrinaut-docs-content.ts index 378529c02c8..7b8e055ec42 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/petrinaut-docs-content.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/petrinaut-docs-content.ts @@ -14,6 +14,7 @@ import experiments from "../../../../../../docs/experiments.md?raw"; import petriNetExtensions from "../../../../../../docs/petri-net-extensions.md?raw"; import preview from "../../../../../../docs/preview.md?raw"; import scenarios from "../../../../../../docs/scenarios.md?raw"; +import sharing from "../../../../../../docs/sharing.md?raw"; import simulation from "../../../../../../docs/simulation.md?raw"; import usefulPatterns from "../../../../../../docs/useful-patterns.md?raw"; import visualSettings from "../../../../../../docs/visual-settings.md?raw"; @@ -31,6 +32,7 @@ export const stripImages = (markdown: string): string => .replace(tripleBlankLinePattern, "\n\n"); const rawDocsByName: Record = { + sharing, "drawing-a-net": drawingANet, "petri-net-extensions": petriNetExtensions, "useful-patterns": usefulPatterns, diff --git a/yarn.lock b/yarn.lock index ceb57b2290c..cc512f5074b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -965,6 +965,7 @@ __metadata: "@vitejs/plugin-react": "npm:6.1.0" "@whatwg-node/server": "npm:0.10.18" ai: "npm:6.0.182" + brotli-wasm: "npm:3.0.1" fast-check: "npm:4.9.0" immer: "npm:10.1.3" oxc-transform-react: "npm:0.145.0" @@ -23808,6 +23809,13 @@ __metadata: languageName: node linkType: hard +"brotli-wasm@npm:3.0.1": + version: 3.0.1 + resolution: "brotli-wasm@npm:3.0.1" + checksum: 10c0/b458b9fe7c31a5e8255133bd26a258f79fb1b816a8d609b45a662726476315e63c539bcb7d5b8bd542cc9a7d2c91fcd4ce27154d19c33d1932f5b1618e09f23b + languageName: node + linkType: hard + "browser-fs-access@npm:^0.31.0": version: 0.31.2 resolution: "browser-fs-access@npm:0.31.2"