A tiny, self-hostable shortener for Tab Share
links -- the ones that carry a whole tab collection in the URL #fragment and
can run to several kilobytes.
Part of the Tab Share suite: the companion to the Tab Share browser extension (Chrome Web Store), which packs a group of tabs into one link that opens in any browser with no extension. This service shortens that link -- and because it just redirects to the viewer, the short URL opens the same way, no extension either. (The extension links here from Options -> Shorten links, where Tab Share shortener is the recommended provider.)
- Any length. A 40-page collection is ~4 KB; a 100-page encrypted one ~6 KB. This service takes it (POST body, or a large GET) where TinyURL / is.gd choke.
- Two code styles.
code-- short and random:s.example.com/k7Rm2pqwords-- readable, Twitch-clip style:s.example.com/swift-amber-otter
- No account, aggregate-only stats. By day: per-link hit counts, the
referring host of each click, and the visitor's browser family + major version
-- no IP, no geolocation, no full User-Agent, no cookies, no third-party
trackers, never sold. See PRIVACY.md; turn it all off with
SHORTENER_ANALYTICS=0. Identical links are de-duplicated. - Optional admin panel at
/admin(token-gated, off by default): link table (target host only; a destination is revealed one link at a time), revoke / bulk revoke or delete by filter, manual/vanity links, host-allowlist editor, the analytics above, an on-request histogram of the domains people bundle (reddit.com, never the post), and a Prometheus/admin/metricsendpoint. See SELF-HOSTING.md. - Not an open redirector. It only shortens links pointing at a host on its allowlist (your Tab Share viewer), which is what keeps it off the phishing radar.
- No npm dependencies. Plain Node 20+ (24+ for the SQLite backend). Storage
is a JSON file or
node:sqlite, both built in. Vendors two files insrc/lib/-- lz-string (MIT) and the Tab Share share-codec (AGPL-3.0, same project) -- used to decode a link's bundled-page domains for the admin histogram. A Cloudflare Worker port and Linux / Windows installers are indeploy/.
Licensed AGPL-3.0-only (it's a network service -- same terms as Tab Share).
npm test # store + Node server + Worker checks, no network
npm run dev # http://localhost:8779, accepts localhost:8777 + kaikayy.github.io targets
npm run gen:worker # regenerate deploy/worker-words.js after editing src/words.mjs# shorten something
curl -X POST localhost:8779/api/shorten \
-H 'content-type: application/json' \
-d '{"url":"https://kaikayy.github.io/multi-link-share/#NoZg...","mode":"words"}'
# -> {"code":"swift-amber-otter","shortUrl":"http://localhost:8779/swift-amber-otter",...}Tab Share ships a built-in Tab Share shortener provider (Options -> Shorten
links) with a Normal / Readable words toggle. It's pre-filled with the
first-party public instance at https://s.kaikay.de, which allow-lists the
built-in viewer -- so the default setup needs no configuration. Off until you
turn it on; still opt-in per link.
The public
s.kaikay.deinstance currently shortens links pointing atkaikayy.github.ioonly (the built-in viewer). If you run your own viewer, self-host the shortener too (below), or open an issue to have your host added to the public instance's allowlist.
To point it at your own instance, put its address in that same field. For a
local dev instance: npm run dev here, then set the address to
http://localhost:8779 in a DEV_LOCALHOST=1 extension build.
Native contract: GET <address>/new?url=<enc> (add &mode=words) -> the short
URL as plain text.
What each shortener keeps when someone opens a short link:
| Shortener | Logged on each click | Cookies / 3rd-party trackers | Data sold or shared |
|---|---|---|---|
this one (s.kaikay.de or self-hosted) |
aggregate day counts only: hits, referrer host, browser family + major version. No IP, no geolocation, no full User-Agent, no per-visitor row. | none | never |
| da.gd (built into the extension) | a hit counter; small open-source service, no ad or analytics scripts | none observed | no |
| TinyURL (built into the extension) | IP address, browser type + version, referring URLs, timestamps; forwards your full referrer to the destination | yes | not stated in its policy |
| Bitly (not offered) | per click: timestamp, IP, user-agent, country, city, device, browser, referring domain | yes | yes -- selling click analytics is a paid feature |
TinyURL / Bitly rows are summarised from their published policies and independent testing as of 2026 and can change. Full detail for this one is in PRIVACY.md.
| Route | ||
|---|---|---|
POST /api/shorten |
{ url, mode?, ttlDays? } |
201 { code, shortUrl, mode, expires, keepToken } |
POST /api/keep |
{ code, keepToken, ttlDays? } |
200 { code, expires } -- pin a link (or re-set its TTL) |
GET /new?url=<enc>&mode= |
compat shim | 200 text/plain short URL; keep token in the X-Keep-Token header |
GET /:code |
302 to the target (HTML meta-refresh if the target is very long) |
|
GET /api/health |
200 { ok, store, allowedHosts, ... } |
|
/admin, /admin/api/* |
Bearer / cookie token |
panel + JSON API (incl. POST /admin/api/links/bulk); 404 when SHORTENER_ADMIN_TOKEN is unset |
GET /admin/metrics |
Bearer / cookie token |
Prometheus text exposition |
How long a short link lives. By default a new short code stops resolving
30 days after it was created (SHORTENER_TTL_DAYS). The full share link the
caller also copied is never affected -- only the short code is forgotten. To keep
one for good, POST /api/keep with the keepToken you got back at creation
(ttlDays: 0 pins it; a positive ttlDays sets a fresh window). An operator can
also pin or expire any link from the admin panel. Set SHORTENER_TTL_DAYS=0 to
make links never expire by default. Links created before this default existed
(shortener < 0.3.0, or while SHORTENER_TTL_DAYS was 0) carry no expiry and
keep resolving -- the TTL is stamped once, when the link is made.
See CONTRACT.md for the exact rules a response/redirect must
follow, and SELF-HOSTING.md to deploy.
Every knob is an env var -- full list and defaults in src/config.mjs.
The ones you'll actually set:
| Env | Default | |
|---|---|---|
SHORTENER_BASE |
http://localhost:8779 |
public origin, no trailing slash |
SHORTENER_HOSTS |
kaikayy.github.io |
comma list of allowed target hosts (empty = open mode) |
SHORTENER_HOSTS_FILE |
-- | newline-delimited host file, merged with SHORTENER_HOSTS; re-read on SIGHUP |
SHORTENER_LOG |
off | 1 or a dir: log redirects (truncated IP) to per-day files |
SHORTENER_PORT / SHORTENER_HOST |
8779 / 127.0.0.1 |
bind address |
SHORTENER_STORE |
data/links.json |
store file path |
SHORTENER_STORE_BACKEND |
infer from path | file or sqlite (Node 24+) |
SHORTENER_TTL_DAYS |
30 |
default link lifetime in days (0 = forever); per-link override via ttlDays / POST /api/keep |
SHORTENER_RATE |
30 |
creates per IP per minute (0 = off) |
SHORTENER_TRUST_PROXY |
off | set 1 behind a reverse proxy to read X-Forwarded-For |
SHORTENER_ADMIN_TOKEN |
off | set a long random string to enable /admin; unset = the tree 404s |
SHORTENER_ANALYTICS |
1 |
0 disables redirect analytics; SHORTENER_ANALYTICS_DAYS sets retention (365) |
Bugs -> an issue. Questions and ideas -> Discussions. See CONTRIBUTING.md. Version history in CHANGELOG.md.
Found a vulnerability? See SECURITY.md -- please report it privately, not as a public issue.
If this project is useful to you, you can support development on Ko-fi:
