@ordius/adonisjs-shortlink
v2.2.0
Published
A standalone URL shortener service for AdonisJS v7
Readme
@ordius/adonisjs-shortlink
A standalone URL shortener service for AdonisJS v7. Bring your own Lucid model, get a type-safe, stateless service for creating, resolving and tracking shortlinks across one or more domains.
This is v2, a breaking rewrite. 1.x lives on npm as
@mixxtor/adonisjs-shortlinkand is untouched — see Migrating from 1.x if you're coming from it.
Install
node ace add @ordius/adonisjs-shortlinkThis prompts you to also generate a redirect controller and routes file, then:
- publishes
config/shortlink.ts - publishes
app/models/shortlink.ts - publishes a
database/migrations/*_create_shortlinks_table.tsmigration - registers the provider in
adonisrc.ts - adds
SHORTLINK_DOMAINto.env(and its validation instart/env.ts)
Then run the migration and set the domain:
node ace migration:runSHORTLINK_DOMAIN=short.yourdomain.comConfig reference
config/shortlink.ts calls defineConfig(...), which returns a config provider — validated
and defaulted lazily, once the app resolves it (not at import time).
| Key | Type | Default | Notes |
| -------------------- | ----------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| model | () => Promise<{ default: Model }> | — | Required. Lazy import, e.g. () => import('#models/shortlink') |
| domain | string | — | Required. Bare host or full URL — normalized to a hostname |
| domains | string[] | [] | Extra hosts served alongside domain |
| protocol | 'http' \| 'https' | 'https' | |
| prefix | string | '' | Normalized to '' or /x, no trailing slash |
| redirectStatusCode | 301 \| 302 \| 307 \| 308 | 302 | 301 is cached forever by browsers — a slug repointed to a new URL would keep redirecting old visitors to the previous one |
| trackClicks | boolean | true | |
| slug.length | number | 8 | Minimum 4 |
| slug.alphabet | string | base62 (A-Za-z0-9) | At least 2 unique characters |
| slug.pattern | RegExp | /^[A-Za-z0-9_-]{1,255}$/ | Validates both custom and generated slugs |
| slug.reserved | string[] | [] | Case-insensitive |
| slug.maxAttempts | number | 5 | Collision retries for generated slugs |
| allowedProtocols | string[] | ['http:', 'https:'] | Checked against new URL(originalUrl).protocol |
import env from '#start/env'
import { defineConfig } from '@ordius/adonisjs-shortlink'
import type { InferShortlinkModel } from '@ordius/adonisjs-shortlink/types'
const shortlinkConfig = defineConfig({
model: () => import('#models/shortlink'),
domain: env.get('SHORTLINK_DOMAIN'),
domains: ['legacy-short.example.com'],
prefix: 's',
slug: { reserved: ['api', 'admin'] },
})
export default shortlinkConfig
declare module '@ordius/adonisjs-shortlink/types' {
interface ShortlinkModels extends InferShortlinkModel<typeof shortlinkConfig> {}
}The declare module block types the shortlink service (and its container binding) with
your model — that's how service.find() etc. return InstanceType<YourModel> instead of the
generic base type.
Model contract
Your model owns its table, connection and attribute naming (plain Lucid, no config
coupling). The package depends on table columns, not on any particular attribute naming
convention: it reads each column's attribute name off your model once, at config-resolve time
(model.$keys.columnsToAttributes), and uses that everywhere internally. That means an app
using SnakeCaseNamingStrategy with declare original_url: string works exactly the same as
the default camelCase model below — you never have to rename your attributes to match this
package.
Required columns: domain, slug, original_url, clicks. Optional: metadata — omit it
and the service simply never writes it.
import { DateTime } from 'luxon'
import { BaseModel, column } from '@adonisjs/lucid/orm'
export default class Shortlink extends BaseModel {
static table = 'shortlinks'
@column({ isPrimary: true })
declare id: number
@column()
declare domain: string
@column()
declare slug: string
@column()
declare originalUrl: string
@column()
declare clicks: number
@column({
prepare: (value: Record<string, unknown> | null) =>
value === null || value === undefined ? null : JSON.stringify(value),
// pg's jsonb driver already returns an object — only parse a raw string
consume: (value: unknown) => (typeof value === 'string' ? JSON.parse(value) : value),
})
declare metadata: Record<string, unknown> | null
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
}A model that already uses snake_case attributes works unchanged — no camelCase renaming, no extra config:
import { BaseModel, SnakeCaseNamingStrategy, column } from '@adonisjs/lucid/orm'
export default class Shortlink extends BaseModel {
static table = 'shortlinks'
static namingStrategy = new SnakeCaseNamingStrategy()
@column({ isPrimary: true })
declare id: number
@column()
declare domain: string
@column()
declare slug: string
@column()
declare original_url: string
@column()
declare clicks: number
}Either way, the public shortlink service API keeps the same (camelCase) vocabulary —
create(originalUrl, …), update(row, { originalUrl }), findByUrl(...) — regardless of how
your model names its attributes; only the underlying column resolution adapts.
Add whatever extra columns you need (referer, createdBy, group, ...) — they're reachable
through attributes on create/update, under whatever attribute name your model gives them.
The migration enforces uniqueness on (domain, slug), not slug alone.
Service API
Everything goes through the shortlink service — a stateless class, generic over your model:
import shortlink from '@ordius/adonisjs-shortlink/services/main'URLs (pure)
shortlink.url('abc123') // 'https://short.example.com/s/abc123'
shortlink.url('abc123', { domain: 'other.example.com' }) // throws E_UNKNOWN_DOMAIN
shortlink.parse('https://short.example.com/s/abc123') // { domain, slug } | null
shortlink.servesDomain('short.example.com') // booleanSlugs (pure)
shortlink.generateSlug() // crypto.randomInt-based, unbiased
shortlink.isReserved('admin') // boolean, case-insensitive
shortlink.assertValidSlug('my-slug') // throws E_INVALID_SLUG / E_SLUG_RESERVEDReads
A domain defaults to the primary one; an unconfigured domain returns null (it never throws
on a read — only writes do, since an unknown domain there is a caller mistake worth
surfacing).
await shortlink.find(id)
await shortlink.findBySlug('abc123', { domain: 'short.example.com' })
await shortlink.findByUrl('https://example.com/a')Writes
// Generated slug
const link = await shortlink.create('https://example.com/a')
// Custom slug, extra domain, extra columns
const admin = await shortlink.create('https://example.com/a', {
slug: 'summer-sale',
domain: 'short.example.com',
metadata: { campaign: 'summer' },
attributes: { createdBy: user.id },
})
// By URL + domain, idempotent for sequential calls
const link2 = await shortlink.firstOrCreate('https://example.com/a')
await shortlink.update(link, { originalUrl: 'https://example.com/b' })
await shortlink.delete(link)Original URLs are validated (new URL() must parse, protocol must be in
allowedProtocols) and stored exactly as given — never rewritten. attributes can never
override id/slug/domain/originalUrl/clicks, regardless of what you pass.
Custom slugs are validated then checked for availability; a race that still hits the unique
index comes back as E_SLUG_TAKEN rather than a raw database error. Generated slugs retry up
to slug.maxAttempts times on collision — except inside a transaction you passed in via
client, where a failed insert would abort it, so there's exactly one attempt.
Clicks
await shortlink.recordClick(link) // or shortlink.recordClick(link.id, 5)An atomic increment('clicks', n) — it never touches updatedAt and never fires model hooks,
so concurrent redirects can't lose counts the way clicks += 1; save() would.
Redirect controller example
Generated for you if you accept the setup prompt (app/controllers/shortlinks_controller.ts,
preloaded via #start/shortlinks):
import type { HttpContext } from '@adonisjs/core/http'
import shortlink from '@ordius/adonisjs-shortlink/services/main'
export default class ShortlinksController {
async redirect({ params, request, response, logger }: HttpContext) {
const link = await shortlink.findBySlug(params.slug, {
domain: request.hostname() ?? undefined,
})
if (!link) return response.notFound({ error: 'Shortlink not found' })
if (shortlink.config.trackClicks) {
shortlink.recordClick(link).catch((error) => logger.error({ err: error }, 'click failed'))
}
return response.redirect().status(shortlink.config.redirectStatusCode).toPath(link.originalUrl)
}
}Management endpoints (create/update/delete) are intentionally not generated — wire them up in your own app, behind auth.
Multi-domain
domain (the primary host) and domains (extra hosts served alongside it) work as shown in
Config reference. node ace add/configure only ever defines the
SHORTLINK_DOMAIN env var for the primary domain — extra hosts aren't env-driven, add them
directly to the domains array in config/shortlink.ts. The generated routes file registers
one GET ${prefix}/:slug per configured domain via .domain(). Reads/writes scope to a domain
through the domain option (defaulting to the primary one).
Errors
All thrown via createError, so you can branch on error.code instead of parsing messages:
| Export | code | status |
| -------------------------- | ------------------------------------ | ------ |
| E_SLUG_TAKEN | E_SHORTLINK_SLUG_TAKEN | 409 |
| E_SLUG_RESERVED | E_SHORTLINK_SLUG_RESERVED | 422 |
| E_INVALID_SLUG | E_SHORTLINK_INVALID_SLUG | 422 |
| E_INVALID_URL | E_SHORTLINK_INVALID_URL | 422 |
| E_UNKNOWN_DOMAIN | E_SHORTLINK_UNKNOWN_DOMAIN | 422 |
| E_SLUG_GENERATION_FAILED | E_SHORTLINK_SLUG_GENERATION_FAILED | 500 |
import { errors } from '@ordius/adonisjs-shortlink'
try {
await shortlink.create(url, { slug: 'taken' })
} catch (error) {
if (error instanceof errors.E_SLUG_TAKEN) {
return response.conflict({ error: error.message })
}
throw error
}Migrating from 1.x
v2 is a breaking rewrite published under a new package name (@ordius/... instead of
@mixxtor/...), so 1.x apps are unaffected until they opt in. What changed and why:
- Config is a provider, resolved lazily.
defineConfig()used to validate at import time and return a plain object; now it returns aConfigProvider, resolved (and validated) once the app boots. Read it via theshortlinkservice's.config, not by importing the config file directly. - The service is stateless and generic, constructed once from the resolved config. There's
no
setBaseUrl()mutating shared state for every caller anymore. - Clicks are a real atomic increment.
incrementClicks()on the model (clicks += 1; save()) is gone — it lost updates under concurrency and bumpedupdatedAt. Useshortlink.recordClick(). - Slugs are generated with
crypto.randomInt(unbiased) instead ofbyte % length(modulo-biased), and a collision retries a bounded number of times instead of falling back to an unchecked longer slug. - Errors are typed, not bare
Errorstrings — see the table above. - Lookups are unambiguous.
updateOrCreate(slugOrUrl)used to matchslug = x OR original_url = x, which could hit the wrong row.firstOrCreate/findByUrlmatch by URL (+ domain) explicitly. - URLs are validated. A
javascript:/data:URL is rejected (E_INVALID_URL) instead of being stored as a redirect target. - Multi-domain support.
domain/domainsreplace the single implicit domain; uniqueness is scoped to(domain, slug). - The model owns its table/connection — no more
enabled/connection/tableNameconfig keys that only the model read, creating a config ↔ model import cycle. luxonis gone from the package's own dependencies (it was only used for types and risked a second copy). Your model still needs it directly if you use@column.dateTime, same as any Lucid app.
License
MIT License — see LICENSE for details.
