Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions apps/playground/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 14 additions & 0 deletions apps/playground/src/hooks/useLocationHref.ts
Original file line number Diff line number Diff line change
@@ -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);
}
8 changes: 5 additions & 3 deletions apps/playground/src/pages/IndexPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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<InstrumentRepository, 'files'>,
Expand All @@ -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;
}
Expand Down Expand Up @@ -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 (
Expand Down
8 changes: 7 additions & 1 deletion packages/playground-url/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/playground-url/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion packages/playground-url/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
21 changes: 19 additions & 2 deletions packages/playground-url/src/share-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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();
});
Expand Down
27 changes: 21 additions & 6 deletions packages/playground-url/src/share-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}
Expand Down
Loading