Skip to content

feat(web): shrink share URLs ~30-50% via minify + deflate#144

Merged
naorsabag merged 3 commits into
masterfrom
feat/web-shorter-share-urls
May 12, 2026
Merged

feat(web): shrink share URLs ~30-50% via minify + deflate#144
naorsabag merged 3 commits into
masterfrom
feat/web-shorter-share-urls

Conversation

@naorsabag
Copy link
Copy Markdown
Owner

@naorsabag naorsabag commented May 12, 2026

Summary

Playground share URLs were getting unusably long — order-flow.yaml topped out at 4,359 chars, which Slack / email / WhatsApp mangle past ~2,000. This PR keeps the local-first contract (no backend, nothing leaves the machine) but cuts URL length 27–54% by improving the encoding.

Approach

  1. Minify the YAML before compressing. Parse + restringify with 1-space indent and no line wrap. Drops comments and whitespace the renderer doesn't read — typically 20–40% byte reduction before compression even runs.
  2. DEFLATE-raw via fflate (~8 KB gzipped, sync). Picked over the browser-native CompressionStream('deflate-raw') because the latter is async and several callers in AppFragment.tsx rely on encodeFragment being sync (e.g. the memoised "which example matches the current hash?" check).
  3. Base64url + ~1 version prefix. ~ is outside lz-string's output alphabet ([A-Za-z0-9$_-]), so the prefix is unambiguous and v0 share URLs in the wild keep resolving via the legacy decode path.

Measured savings (bundled example flows)

Flow Old (lz-string) New (minify + deflate) Savings
simple-crud 1,122 chars 609 chars 46%
auth-flow 1,424 chars 1,046 chars 27%
order-flow 4,321 chars 2,834 chars 34%
self-loops 2,215 chars 1,409 chars 36%
type-variants 2,162 chars 1,002 chars 54%

What I considered and didn't do

  • Brotli. ~20% denser than DEFLATE on these inputs, but not exposed by the browser's CompressionStream and the WASM polyfills push 30+ KB into the bundle. Not worth it for a constant-factor improvement.
  • A real key→YAML backend (Cloudflare Workers + KV). Would get true short IDs (?s=ab3xK9, ~50 chars regardless of flow size) but breaks the README's "local-first / your code never leaves your machine" promise. Explicitly out of scope here per the chosen direction.

Test plan

  • npm run typecheck -w @openhop/web passes
  • npm test -w @openhop/web — 45/45 pass (3 new tests covering v1 prefix, legacy decode round-trip, and new-shorter-than-legacy length check)
  • npm run build -w @openhop/web builds cleanly
  • npx prettier --check clean on touched files
  • Manual: open https://naorsabag.github.io/openhop/ after deploy, click each bundled example — confirm the URL hash now starts with ~1 and the flow renders
  • Manual: paste an old (v0 / lz-string) share URL — confirm it still resolves to the same flow (backward-compat)
  • Manual: hit Save in the editor, paste the new short URL in a fresh tab — confirm round-trip

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Share URLs use a new versioned, more efficient encoding producing shorter links while preserving backward compatibility.
  • Bug Fixes / Reliability

    • Added stricter validation and size limits to reject malformed or oversized share payloads and mitigate decompression attacks.
  • Tests

    • Expanded tests to cover new encoding, legacy decoding, structural YAML equivalence, and new failure/limit cases.

Review Change Stack

GitHub Pages playground share URLs were 1.1K-4.4K chars because the
hash held lz-string-compressed raw YAML. Slack/email/WhatsApp start
mangling URLs past ~2K, and order-flow.yaml at 4,359 chars was
unusable in many clients.

This switches the encoder to:

  1. Parse + restringify the YAML with 1-space indent and no line
     wrap. Drops comments and whitespace the renderer never reads.
  2. DEFLATE-raw via fflate (sync, ~8KB gzipped — no async refactor
     needed for the existing callers in AppFragment).
  3. Base64url + a '~1' version prefix so the decoder can tell
     v1 fragments from legacy lz-string ones.

Legacy decode path is kept so any v0 share URL already in the wild
keeps resolving. '~' is outside lz-string's output alphabet, so the
prefix is unambiguous.

Measured on the bundled example flows:

  simple-crud   1122 -> 609   (46% shorter)
  auth-flow     1424 -> 1046  (27%)
  order-flow    4321 -> 2834  (34%)
  self-loops    2215 -> 1409  (36%)
  type-variants 2162 -> 1002  (54%)

Adds 3 tests (v1 prefix, legacy decode round-trip, "new shorter than
legacy"); the original round-trip test now compares parsed objects
since minification rewrites whitespace.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 12, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: dd43dd5e-3629-4f24-b738-8872cf7d29d9

📥 Commits

Reviewing files that changed from the base of the PR and between c2755a7 and e8c2dfd.

📒 Files selected for processing (1)
  • packages/web/src/lib/share-url.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/web/src/lib/share-url.ts

Walkthrough

Adds a v1 ~1 share-fragment format: YAML minify → DEFLATE (fflate) → base64url; decoder recognizes ~1 with safety limits and falls back to legacy lz-string. Tests updated for semantic round-trips, compression checks, backward compatibility, malformed inputs, abuse limits, and tightened URL assertions.

Changes

Share URL v1 format and compression

Layer / File(s) Summary
v1 share fragment format with DEFLATE compression and dependency
packages/web/package.json, packages/web/src/lib/share-url.ts
Adds fflate runtime dependency. Implements v1 encoding: YAML minify → deflateSync → base64url with ~1 prefix. Adds version-aware decodeFragment with input and inflated-size guards, returning null on corruption; falls back to legacy lz-string for non-~1 fragments.
Test coverage for v1 format, compression, and backward compatibility
packages/web/__tests__/share-url.test.ts
Updates tests to import lz-string/yaml and add helper utilities. Round-trip tests compare parsed YAML objects (semantic equality). Asserts require ~1-prefixed v1 fragments, verify v1 is smaller than raw YAML and legacy lz-string for sample input, confirm legacy decoding returns verbatim YAML, add malformed ~1 and safety tests for oversized inputs/decompression bombs, and tighten buildShareUrl regex for URL-safe #~1 fragments.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: reducing share URL length (~30-50%) via minification and DEFLATE compression, which is the primary objective evident across all modified files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/web-shorter-share-urls

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/web/src/lib/share-url.ts`:
- Around line 69-73: The v1 inflate path (check against V1_PREFIX,
base64UrlToBytes, and inflateSync) currently decompresses untrusted data without
bounds; to fix, enforce a strict size cap by rejecting fragments whose
base64UrlToBytes result exceeds a safe threshold (e.g. 64KB) before calling
inflateSync, and return null for over-limit fragments; optionally, if your
zlib/pako implementation supports it, call inflateSync with explicit
output/chunk sizing options to pre-allocate or cap decompressed output. Ensure
the checks reference fragment, V1_PREFIX and base64UrlToBytes so the guard runs
immediately prior to the TextDecoder/inflateSync usage.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9dc3edd1-886b-49f4-a02f-fc2ee19f3102

📥 Commits

Reviewing files that changed from the base of the PR and between 204e13d and 5d12291.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json, !package-lock.json
📒 Files selected for processing (3)
  • packages/web/__tests__/share-url.test.ts
  • packages/web/package.json
  • packages/web/src/lib/share-url.ts

Comment thread packages/web/src/lib/share-url.ts
Per review on PR #144: decodeFragment was calling inflateSync on
untrusted base64-decoded bytes with no size guard. A small base64url
payload that compresses 1000x would let a hostile share URL allocate
tens of megabytes (worst case ~64 KB input * ~1032x DEFLATE ratio).

Adds two caps:

- MAX_FRAGMENT_BYTES (64 KB) on the compressed input. fflate has no
  streaming-abort hook, so capping the input is what bounds the worst
  case allocation regardless of expansion ratio.
- MAX_INFLATED_BYTES (1 MB) on the post-inflate output. Soft cap — by
  this point fflate has already allocated, but the result is bounded
  and the function refuses to surface the data to callers.

The largest legitimate flow in examples/ compresses to ~3 KB and
inflates to ~9 KB, so 64 KB / 1 MB caps leave 20-100x headroom for
legitimate inputs.

Adds two tests: oversized-input rejection and a real DEFLATE-bomb
rejection (2 MB of zeros compressed to ~2 KB, which fits under the
input cap but blows past the output cap).
@naorsabag
Copy link
Copy Markdown
Owner Author

Addressed in c2755a7. Added two caps to decodeFragment for v1 fragments:

  • MAX_FRAGMENT_BYTES = 64 KB on the compressed input (post-base64-decode). Bounds the worst-case allocation regardless of DEFLATE's expansion ratio.
  • MAX_INFLATED_BYTES = 1 MB on the inflated output. Soft cap — by this point fflate has already allocated, but the function refuses to return the data, so callers never see it.

I considered using fflate's streaming Inflate API to abort mid-decompression, but verified that fflate's internal buffering still materialises the full output before invoking the chunk callback — so streaming doesn't actually save memory for already-buffered inputs. The two-cap defense above is the practical equivalent.

Largest legit flow in examples/ compresses to ~3 KB and inflates to ~9 KB, so 64 KB / 1 MB caps leave 20-100× headroom. Two new tests cover the oversized-input path and a real DEFLATE bomb (2 MB of zeros that compresses to ~2 KB — fits under the input cap but trips the output cap).

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/web/src/lib/share-url.ts`:
- Around line 79-83: The code currently calls
base64UrlToBytes(fragment.slice(V1_PREFIX.length)) before checking size,
allowing huge base64 input to be decoded and allocated; instead, check the
encoded fragment length first (the substring after V1_PREFIX) against a safe max
encoded size derived from MAX_FRAGMENT_BYTES (e.g. ceil(MAX_FRAGMENT_BYTES *
4/3) plus a small allowance for padding/URL-safe differences) and return null if
it exceeds that limit, only then call base64UrlToBytes and proceed to
inflateSync; apply this check where fragment.startsWith(V1_PREFIX) is handled
and reference fragment, V1_PREFIX, base64UrlToBytes, MAX_FRAGMENT_BYTES, and
inflateSync.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6eca3be0-d3b9-4531-825f-df2e402abccf

📥 Commits

Reviewing files that changed from the base of the PR and between 5d12291 and c2755a7.

📒 Files selected for processing (2)
  • packages/web/__tests__/share-url.test.ts
  • packages/web/src/lib/share-url.ts

Comment thread packages/web/src/lib/share-url.ts
Per follow-up review on PR #144: the previous patch checked the decoded
byte length, but the base64url payload was already fully decoded into a
binary string by atob() before that check ran. A multi-megabyte hash
could still force a sizable intermediate allocation + copy loop before
being rejected.

Adds MAX_FRAGMENT_BASE64URL_CHARS = ceil(MAX_FRAGMENT_BYTES / 3) * 4 and
checks the encoded-payload string length first. Lets a hostile hash get
refused in constant time, before any atob work runs. The byte-length
check stays in as defense-in-depth (the string cap allows up to ~2
extra bytes through depending on padding).
@naorsabag
Copy link
Copy Markdown
Owner Author

Addressed in e8c2dfd. The pre-decode string-length cap is now in place:

const MAX_FRAGMENT_BASE64URL_CHARS = Math.ceil(MAX_FRAGMENT_BYTES / 3) * 4
// ...
const payload = fragment.slice(V1_PREFIX.length)
if (payload.length > MAX_FRAGMENT_BASE64URL_CHARS) return null
const bytes = base64UrlToBytes(payload)
if (bytes.length > MAX_FRAGMENT_BYTES) return null

A multi-megabyte hash gets rejected in constant time, before atob() allocates the intermediate binary string. The byte-length check stays in as defense-in-depth (the string-length cap can let up to ~2 extra bytes through depending on padding).

Re the first (line 86) comment in this review batch: that one is the same size-cap concern from the previous round and is already addressed in c2755a7 — the comment itself shows the "✅ Addressed" trailer.

@naorsabag naorsabag merged commit 133845d into master May 12, 2026
8 checks passed
@naorsabag naorsabag deleted the feat/web-shorter-share-urls branch May 15, 2026 16:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant