codec-url
v0.2.6
Published
Framework-agnostic URL codec: structured state ⟷ tiny URL-safe string
Readme
codec-url — URL Codec Library
codec-url is a framework-agnostic URL codec (Rust → WASM core) that plugs into existing URL/state libraries instead of replacing them.
Single responsibility: Structured state ⇄ tiny URL-safe string
Why codec-url?
Every URL library eventually reads/writes:
string | null ←→ URLSearchParamscodec-url provides the encoding/decoding layer between your application state and the URL string. It composes with — not competes against — React Router, Vue Router, TanStack Router, nuqs, use-query-params, and SvelteKit.
Features
- Deterministic — Same state always produces the same encoded string
- Reversible — Lossless encode/decode cycle
- URL-safe — base64url output (no special characters)
- Versioned — Schema version embedded in every payload
- Small — 6-20x smaller than raw query strings
- Fast — <1ms encode/decode (schema mode: ~10x faster than schema-less)
- Framework agnostic — Zero framework imports in core
- First-class adapters — Native integrations for popular routing libraries
Installation
npm install codec-urlQuick Start (Schema-less Mode)
No schema required — works with any serializable object immediately:
import { initCodecUrl, createCodec } from 'codec-url'
await initCodecUrl()
const codec = createCodec()
const state = {
brands: ['nike', 'puma'],
priceMin: 1000,
priceMax: 5000,
inStock: true,
sort: 'price',
page: 3,
}
const encoded = codec.encode(state)
// => "eyJicmFuZCI6WzEsMl0sInByaWNlTWluI..."
const decoded = codec.decode(encoded)
// => { brands: [1, 2], priceMin: 1000, priceMax: 5000, ... }Schema Mode (Maximum Compression)
For maximum compression and type safety, define a schema:
import { initCodecUrl, createCodec, defineSchema, u8, u16, array, enumType, bool } from 'codec-url'
await initCodecUrl()
const schema = defineSchema({
brands: array(u8, 10), // brand IDs (max 10 items)
priceMin: u16,
priceMax: u16,
inStock: bool,
sort: enumType(['price', 'rating', 'newest']),
page: u8,
})
const codec = createCodec(schema, {
defaults: {
brands: [],
priceMin: 0,
priceMax: 0,
inStock: false,
sort: 'price',
page: 1,
},
})
const state = {
brands: [1, 2], // brand IDs, not names
priceMin: 1000,
priceMax: 5000,
inStock: true,
sort: 'price',
page: 3,
}
const encoded = codec.encode(state)
// => "AQ..." (12 bytes vs 77 bytes raw query string)
const decoded = codec.decode(encoded)
// => { brands: [1, 2], priceMin: 1000, priceMax: 5000, inStock: true, sort: 'price', page: 3 }API
Core
import { initCodecUrl, createCodec, defineSchema, u8, u16, u32, bool, f32, array, tuple, enumType, string } from 'codec-url'
// Initialize WASM (required for schema-less mode)
await initCodecUrl()
// Schema-less mode (any serializable object)
const codec = createCodec()
// Schema mode (bit-packed, max compression)
const codec = createCodec(schema, options)
// Schema DSL
const schema = defineSchema({
fieldName: u8 | u16 | u32 | bool | f32,
fieldName: array(u8, maxLength),
fieldName: tuple(u16, u16),
fieldName: enumType(['value1', 'value2', ...]),
fieldName: string(maxBytes),
})Options
const codec = createCodec(schema, {
paramName: 'd', // URL param name (default: 'd')
version: 1, // payload version (default: 1)
defaults: {...}, // default state for decode failures
migrate: { // version migration functions
1: (old) => newState,
},
})Framework Adapters
React Router
import { useSearchParams } from 'react-router-dom'
import { createCodec } from 'codec-url'
import { bindToReactRouter } from 'codec-url/react-router'
const codec = createCodec()
const bound = bindToReactRouter(codec)
function App() {
const [params, setParams] = useSearchParams()
const state = bound.read(params)
const updateState = (newState) => {
setParams(bound.write(newState))
}
// Use state...
}nuqs
import { useQueryState } from 'nuqs'
import { createCodec } from 'codec-url'
import { urlCodecParser } from 'codec-url/nuqs'
const codec = createCodec()
const parser = urlCodecParser(codec)
function App() {
const [state, setState] = useQueryState('d', parser)
// Use state...
}use-query-params
import { useQueryParam, UrlCodecParam } from 'use-query-params'
import { createCodec } from 'codec-url'
const codec = createCodec()
function App() {
const [state, setState] = useQueryParam('d', UrlCodecParam(codec))
// Use state...
}Vue Router
import { useRoute, useRouter } from 'vue-router'
import { createCodec } from 'codec-url'
import { bindToVueRouter } from 'codec-url/vue-router'
const codec = createCodec()
const bound = bindToVueRouter(codec)
function App() {
const route = useRoute()
const router = useRouter()
const state = bound.read(route.query)
const updateState = (newState) => {
router.replace({ query: bound.write(newState) })
}
// Use state...
}SvelteKit
import { page } from '$app/stores'
import { goto } from '$app/navigation'
import { createCodec } from 'codec-url'
import { bindToSvelteKit } from 'codec-url/sveltekit'
const codec = createCodec()
const bound = bindToSvelteKit(codec)
$: state = bound.read($page.url.searchParams)
function updateState(newState) {
const params = new URLSearchParams(bound.write(newState))
goto(`?${params}`)
}TanStack Router
import { createRoute, useSearch, useNavigate } from '@tanstack/react-router'
import { createCodec } from 'codec-url'
import { tanstackCodecSearch, bindToTanStackRouter } from 'codec-url/tanstack-router'
const codec = createCodec()
const bound = bindToTanStackRouter(codec)
// Define route with codec as validateSearch
const productRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'products',
validateSearch: tanstackCodecSearch(codec),
})
function Products() {
const search = useSearch({ from: productRoute.id })
const navigate = useNavigate({ from: productRoute.id })
// search.d is already decoded!
const updateState = (newState) => {
navigate({
search: (prev) => ({ ...prev, ...bound.write(newState) }),
})
}
// Use search.d...
}Zod Integration
import { z } from 'zod'
import { createCodec } from 'codec-url'
import { wrapParserWithCodec } from 'codec-url/zod'
const schema = z.object({
brands: z.array(z.number()),
priceMin: z.number(),
priceMax: z.number(),
inStock: z.boolean(),
sort: z.enum(['price', 'rating', 'newest']),
page: z.number().int().positive(),
})
const codec = createCodec()
const binding = wrapParserWithCodec(schema, codec)
// Decode: URL → codec.decode → zod.parse
const state = binding.decode(urlParam)
// Encode: state → codec.encode → URL
const urlParam = binding.encode(state)Performance Benchmarks
Compression Ratio (vs. raw query string)
| Mode | Raw Query String | Encoded | Compression | |------|-----------------|---------|-------------| | Raw | 77 bytes | 77 bytes | 1x (baseline) | | Schema-less | 77 bytes | 115 bytes | 0.67x | | Schema | 77 bytes | 12 bytes | 6.42x smaller |
Note: Schema-less mode adds overhead due to the deflate compression algorithm, which doesn't compress well on small payloads. It shines with larger state objects.
Throughput (Node.js)
| Mode | Encode (ops/sec) | Decode (ops/sec) | p99 Encode | p99 Decode | |------|------------------|------------------|------------|------------| | Schema-less | ~72,000 | ~45,000 | ~0.03ms | ~0.05ms | | Schema | ~1,120,000 | ~421,000 | ~0.001ms | ~0.007ms |
Schema mode is ~15x faster for encoding and ~9x faster for decoding than schema-less mode.
WASM Size
| Metric | Size | |--------|------| | WASM (gzipped) | ~22 KB | | Target | <80 KB ✓ |
Demo
Example: https://codec-url.netlify.app
Configuration
Vite
For Vite-based projects, install the required plugins for WASM support:
npm install vite-plugin-wasm vite-plugin-top-level-await// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import wasm from 'vite-plugin-wasm'
import topLevelAwait from 'vite-plugin-top-level-await'
export default defineConfig({
plugins: [react(), wasm(), topLevelAwait()],
})Vite (Development Server)
For vite dev, if you encounter ESM integration errors, enable the WASM plugins:
npm install vite-plugin-wasm vite-plugin-top-level-await// vite.config.ts
import { defineConfig } from 'vite'
import wasm from 'vite-plugin-wasm'
import topLevelAwait from 'vite-plugin-top-level-await'
export default defineConfig({
plugins: [wasm(), topLevelAwait()],
})Next.js
For Next.js App Router projects, ensure the WASM file is properly served. Add to next.config.js:
/** @type {import('next').NextConfig} */
const nextConfig = {
webpack: (config) => {
config.experiments = config.experiments || {}
config.experiments.asyncWebAssembly = true
return config
},
}
module.exports = nextConfigFor Pages Router, use next.config.js:
/** @type {import('next').NextConfig} */
const nextConfig = {
webpack: (config, { isServer }) => {
if (!isServer) {
config.resolve.fallback = config.resolve.fallback || {}
config.resolve.fallback.fs = false
}
return config
},
}
module.exports = nextConfigImportant: When using codec-url with Next.js, initialize the WASM module outside of React rendering:
// lib/codec-url.ts (singleton, imported once)
import { initCodecUrl, createCodec } from 'codec-url'
let codec: ReturnType<typeof createCodec> | null = null
export async function getCodec() {
if (!codec) {
await initCodecUrl()
codec = createCodec()
}
return codec
}Error Handling
- Invalid payload → returns default state (never throws during render)
- Dev mode warnings via
console.warnin development - Version mismatch → attempts migration, falls back to defaults
Versioning & Migration
Payload format: [version][data]
const codec = createCodec(schema, {
version: 2,
migrate: {
1: (old) => ({
...old,
sort: old.sort === 'alpha' ? 'rating' : old.sort, // migration logic
}),
},
})Project Structure
codec-url/
��── src/
│ ├── core/ # WASM + JS wrapper (zero framework knowledge)
│ │ ├── codec.ts # Main codec implementation
│ │ ├── schema.ts # Schema DSL
│ │ ├── bits.ts # Bit packing utilities
│ │ ├── base64url.ts
│ │ └── wasm-loader.ts
│ ├── adapters/ # Framework bindings
│ │ ├── react-router.ts
│ │ ├── nuqs.ts
│ │ ├── use-query-params.ts
│ │ ├── vue-router.ts
│ │ ├── sveltekit.ts
│ │ ├── tanstack-router.ts
│ │ └── zod.ts
├── bench/ # Vitest benchmark suite
└── docs/License
MIT
