feat(web): shrink share URLs ~30-50% via minify + deflate#144
Conversation
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.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
WalkthroughAdds a v1 ChangesShare URL v1 format and compression
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json,!package-lock.json
📒 Files selected for processing (3)
packages/web/__tests__/share-url.test.tspackages/web/package.jsonpackages/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).
|
Addressed in
I considered using fflate's streaming Largest legit flow in |
There was a problem hiding this comment.
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
📒 Files selected for processing (2)
packages/web/__tests__/share-url.test.tspackages/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).
|
Addressed in 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 nullA multi-megabyte hash gets rejected in constant time, before 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 |
Summary
Playground share URLs were getting unusably long —
order-flow.yamltopped 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
fflate(~8 KB gzipped, sync). Picked over the browser-nativeCompressionStream('deflate-raw')because the latter is async and several callers inAppFragment.tsxrely onencodeFragmentbeing sync (e.g. the memoised "which example matches the current hash?" check).~1version 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)
simple-crudauth-floworder-flowself-loopstype-variantsWhat I considered and didn't do
CompressionStreamand the WASM polyfills push 30+ KB into the bundle. Not worth it for a constant-factor improvement.?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/webpassesnpm 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/webbuilds cleanlynpx prettier --checkclean on touched files~1and the flow renders🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes / Reliability
Tests