diff --git a/apps/playground/AGENTS.md b/apps/playground/AGENTS.md index 53ef3b54f..26fba76c5 100644 --- a/apps/playground/AGENTS.md +++ b/apps/playground/AGENTS.md @@ -94,8 +94,10 @@ narrower because a bundle is evaluated server-side: whoever may create an instru code on the API. `testing/src/specs/authorization.spec.ts` pins the contract. Share links come from `@opendatacapture/playground-url`, which lz-string-compresses file contents into -a query parameter. Its `$EditorFile` requires `content` to be a UTF-8 string, so binary assets do not -survive a share URL. +the URL fragment. A link that differs only in its fragment does not reload the page, so `IndexPage` +reads the URL through `useLocationHref`, which re-renders on `hashchange`; reading `location.href` +directly means pasting a second share link into an open tab does nothing. Its `$EditorFile` requires +`content` to be a UTF-8 string, so binary assets do not survive a share URL. ## Odds and ends diff --git a/apps/playground/src/hooks/useLocationHref.ts b/apps/playground/src/hooks/useLocationHref.ts new file mode 100644 index 000000000..485dd3ee6 --- /dev/null +++ b/apps/playground/src/hooks/useLocationHref.ts @@ -0,0 +1,14 @@ +import { useSyncExternalStore } from 'react'; + +function subscribe(onChange: () => void) { + window.addEventListener('hashchange', onChange); + return () => window.removeEventListener('hashchange', onChange); +} + +function getHref() { + return window.location.href; +} + +export function useLocationHref() { + return useSyncExternalStore(subscribe, getHref); +} diff --git a/apps/playground/src/pages/IndexPage.tsx b/apps/playground/src/pages/IndexPage.tsx index c0fe61ace..4701c3c08 100644 --- a/apps/playground/src/pages/IndexPage.tsx +++ b/apps/playground/src/pages/IndexPage.tsx @@ -7,6 +7,7 @@ import esbuildWasmUrl from 'esbuild-wasm/esbuild.wasm?url'; import { Header } from '@/components/Header'; import { MainContent } from '@/components/MainContent'; import { Viewer } from '@/components/Viewer'; +import { useLocationHref } from '@/hooks/useLocationHref'; import type { InstrumentRepository } from '@/models/instrument-repository.model'; import { useAppStore } from '@/store'; @@ -20,6 +21,7 @@ const IndexPage = () => { const setSelectedInstrument = useAppStore((store) => store.setSelectedInstrument); const removeInstrument = useAppStore((store) => store.removeInstrument); const instruments = useAppStore((store) => store.instruments); + const href = useLocationHref(); const isSameInstrument = ( instrumentA: Pick, @@ -40,7 +42,7 @@ const IndexPage = () => { useEffect(() => { let id: null | string = null; try { - const decodedInstrument = decodeShareURL(new URL(location.href)); + const decodedInstrument = decodeShareURL(new URL(href)); if (!decodedInstrument) { return; } @@ -76,9 +78,9 @@ const IndexPage = () => { removeInstrument(id); } }; - }, [location.href]); + }, [href]); - const isFullscreen = isFullscreenShareURL(new URL(location.href)); + const isFullscreen = isFullscreenShareURL(new URL(href)); if (isFullscreen) { return ( diff --git a/packages/playground-url/AGENTS.md b/packages/playground-url/AGENTS.md index 4d0ba1466..322709e46 100644 --- a/packages/playground-url/AGENTS.md +++ b/packages/playground-url/AGENTS.md @@ -17,13 +17,19 @@ workspace. `pnpm build` runs `scripts/build.js`, which esbuilds `src/cli.ts` int nothing else — `dist` exists only for the `bin`. A library change needs no build; a CLI change does. **A share URL can carry UTF-8 text and nothing else.** Files are `JSON.stringify`d and lz-string -compressed into a query parameter, so images, audio and video cannot be represented at all. The CLI +compressed into the URL fragment, so images, audio and video cannot be represented at all. The CLI skips them with a warning; `TEXT_FILE_EXT_REGEX` and `BINARY_FILE_EXT_REGEX` in `src/cli.ts` are the allowlist and the known-skip list, and both must track what the playground editor accepts. The encoding is a wire format for links people have already shared. Changing what `encodeFiles` writes invalidates every existing link, so `decodeShareURL` has to keep reading the old shape. +**The payload lives in the fragment (`#files=…&label=…&fullscreen=1`), never the query string.** A +query string is sent to the server, and a large instrument pushes the request line past +`http-server`'s header limit (HTTP 431). Links created before the switch used `?files=…`, so +`getShareParams` in `src/share-url.ts` falls back to the query string when the fragment has no +`files` — keep that fallback. + ## Tests `pnpm exec vitest --project playground-url`. The one test file, `src/share-url.test.ts`, sits beside diff --git a/packages/playground-url/README.md b/packages/playground-url/README.md index c9a232d56..aca5fd6cd 100644 --- a/packages/playground-url/README.md +++ b/packages/playground-url/README.md @@ -2,7 +2,7 @@ Generate shareable [Open Data Capture playground](https://playground.opendatacapture.org) links from instrument source files. -A playground link embeds a snapshot of an instrument's source files directly in the URL (lz-string compressed). Anyone who opens the link gets that instrument loaded into the playground — no server or account required. +A playground link embeds a snapshot of an instrument's source files directly in the URL fragment (lz-string compressed), which the browser never sends to the server, so link size is not bound by server header limits. Anyone who opens the link gets that instrument loaded into the playground — no server or account required. ## Library @@ -13,7 +13,7 @@ const url = generatePlaygroundURL({ files: [{ name: 'index.ts', content: 'export default { /* ... */ };' }], label: 'My Instrument' }); -// => https://playground.opendatacapture.org/?files=...&label=... +// => https://playground.opendatacapture.org/#files=...&label=... ``` | Export | Description | diff --git a/packages/playground-url/src/cli.test.ts b/packages/playground-url/src/cli.test.ts index 81190d482..c79110a6d 100644 --- a/packages/playground-url/src/cli.test.ts +++ b/packages/playground-url/src/cli.test.ts @@ -122,7 +122,7 @@ describe('cli', () => { fs.writeFileSync(path.join(tmpDir, 'index.ts'), 'export default {};'); process.argv = ['node', 'cli.js', tmpDir, '--base-url', 'https://example.org/some/path']; await import('./cli.js'); - expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('https://example.org/?')); + expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('https://example.org/#')); }); it('should exit with an error when --base-url is not a valid URL', async () => { diff --git a/packages/playground-url/src/share-url.test.ts b/packages/playground-url/src/share-url.test.ts index 8859c28fe..398bf6f2f 100644 --- a/packages/playground-url/src/share-url.test.ts +++ b/packages/playground-url/src/share-url.test.ts @@ -22,8 +22,12 @@ describe('encodeShareURL', () => { it('encodes against the hosted playground by default', () => { const url = encodeShareURL(instrument); expect(url.origin).toBe(DEFAULT_PLAYGROUND_URL); - expect(url.searchParams.get('files')).toBeTruthy(); - expect(url.searchParams.get('label')).toBeTruthy(); + }); + + it('should carry the payload in the fragment, so it is never sent to the server', () => { + const url = encodeShareURL({ ...instrument, fullscreen: true }); + expect(url.search).toBe(''); + expect(url.hash).toMatch(/files=.+&label=.+&fullscreen=1/); }); it('honours a custom base URL', () => { @@ -48,6 +52,19 @@ describe('decodeShareURL', () => { expect(decoded).toEqual(instrument); }); + it('should round-trip an instrument larger than a server header limit', () => { + const content = Array.from({ length: 5000 }, () => crypto.randomUUID()).join('\n'); + const largeInstrument = { ...instrument, files: [{ content, name: 'index.ts' }] }; + expect(decodeShareURL(encodeShareURL(largeInstrument))).toEqual(largeInstrument); + }); + + it('should still decode a query string link, so links shared before the fragment format keep working', () => { + const url = new URL(DEFAULT_PLAYGROUND_URL); + url.search = encodeShareURL({ ...instrument, fullscreen: true }).hash.slice(1); + expect(decodeShareURL(url)).toEqual(instrument); + expect(isFullscreenShareURL(url)).toBe(true); + }); + it('returns null when no instrument is present', () => { expect(decodeShareURL(new URL(DEFAULT_PLAYGROUND_URL))).toBeNull(); }); diff --git a/packages/playground-url/src/share-url.ts b/packages/playground-url/src/share-url.ts index 1aad45613..0138f54de 100644 --- a/packages/playground-url/src/share-url.ts +++ b/packages/playground-url/src/share-url.ts @@ -25,6 +25,17 @@ function encodeFiles(files: EditorFile[]): string { return lz.compressToEncodedURIComponent(JSON.stringify($EditorFiles.parse(files))); } +/** + * Links carry their payload in the fragment, which the browser never sends to + * the server. Older links used the query string, where a large instrument + * exceeds the server's header limit (HTTP 431); it is still read so that links + * already shared keep working. + */ +function getShareParams(url: URL): URLSearchParams { + const fragmentParams = new URLSearchParams(url.hash.slice(1)); + return fragmentParams.has('files') ? fragmentParams : url.searchParams; +} + /** * Encode an instrument's source files into a playground share URL. Anyone who * opens the returned link gets a snapshot of the provided files loaded into the @@ -37,24 +48,28 @@ function encodeShareURL({ label }: EncodeShareURLOptions): ShareURL { const url = new URL(baseURL) as ShareURL; - url.searchParams.append('files', encodeFiles(files)); - url.searchParams.append('label', lz.compressToEncodedURIComponent(label)); + const params = new URLSearchParams({ + files: encodeFiles(files), + label: lz.compressToEncodedURIComponent(label) + }); if (fullscreen) { - url.searchParams.append('fullscreen', '1'); + params.append('fullscreen', '1'); } + url.hash = params.toString(); url.size = new TextEncoder().encode(url.href).length; return url; } /** Returns `true` if the URL requests the fullscreen, read-only preview mode. */ function isFullscreenShareURL(url: URL): boolean { - return url.searchParams.get('fullscreen') === '1'; + return getShareParams(url).get('fullscreen') === '1'; } /** Decode an instrument from a playground share URL, or `null` if the URL carries no instrument. */ function decodeShareURL(url: URL): null | PlaygroundInstrument { - const encodedFiles = url.searchParams.get('files'); - const encodedLabel = url.searchParams.get('label'); + const params = getShareParams(url); + const encodedFiles = params.get('files'); + const encodedLabel = params.get('label'); if (!(encodedFiles && encodedLabel)) { return null; }