xink is a directory-based filesystem router for APIs, acting as a Vite plugin. Under the hood, it uses a trie URL router, which is fast and scalable.
It supports the bun and deno runtimes; but be aware that we currently rely on access to the filesystem during runtime initialization, so we don't currently support edge environments. We have some documentation for Cloudflare Workers here, but it's also not supported yet.
We're currently in the alpha phase of development and welcome contributions. Please see our contributing guide.
- Your directory structure defines your API paths*.
- An endpoint's handlers are well organized in single route file.
- HTTP-based handler names (e.g. GET, POST) free you from a lot of naming work.
- Simple data validation with your favorite library.
- Easy OpenAPI integration.
- No-fuss setup with our
xk
CLI tool. - Native handling of 404 and 405 (Method Not Allowed) responses.
* - requires a route file at the end of each path.
- Adapters for other runtimes and environments (help wanted)
- Support {Bun,Deno}.serve
- Config Vitest tests
- API Vitest tests
- CLI tool to setup project
- Official docs site
- Publish to JSR?
- OpenAPI integration?
npx xk create my-api
If you'd like to set a basepath for your entire API, you can do so in your project's entrypoint file. This must be a string that begins with a forward slash (/
).
/* e.g. index.ts */
import { Xink } from "@xinkjs/xink"
const api = new Xink()
api.path('/api')
await api.init()
export default {
fetch(req: Request) {
return api.fetch(req)
}
}
Routes are created in src/routes
. Each directory under this path represents a route segment.
At the end of a route path, a javascript or typescript route
file should export one or more functions. These functions are named based on the HTTP method they serve. You can also define a default export, for any unhandled request methods. A route will not be registered unless this file exists.
xink supports these route handler exports: GET
, POST
, PUT
, PATCH
, DELETE
, HEAD
, OPTIONS
, default
/* route endpoint example, src/routes/route.ts */
import type { RequestEvent } from '@xinkjs/xink'
export const GET = (event: RequestEvent) => {
return event.text('Welcome to xink!')
}
/* handle all other http methods */
export default (event: RequestEvent) => {
return event.json({ message: `Hello ${event.method}!` })
}
Currently supported route types, in order of match priority:
- static:
/hello/there/world
- specific:
/hello/miss-[name]
(combo of static and dynamic segment) - matcher:
/hello/[name=word]
(where 'word' references a function, which tests if the value of thename
parameter matches) - dynamic:
/hello/[name]
- rest:
/hello/[...rest]
(essentially a wildcard, but must be at the end of a route)
/* src/routes/blog/[article]/route.ts */
import { type RequestEvent } from '@xinkjs/xink'
export const GET = async ({ params, html }: RequestEvent) => {
const article = await getArticle(params.article)
return html(article)
}
Because of the way the xin URL router works, the rest segment's param is accessed with '*'
.
/* src/routes/hello/[...rest]/route.js */
export const GET = ({ params }) => {
return new Response(`Hello ${params['*']}!`) // not `params.rest`
}
You can think of these as validators for route parameter segments (params
). This feature came before validators; you don't need to use both to validate your params. However, one advantage of a matcher is that it can be defined once and used for as many routes as you'd like.
You can validate route params by creating files in src/params
. Each file in this directory needs to export a match
function that takes in a string and returns a boolean. When true
is returned, the param matches and the router either continues to try and match the rest of the route or returns the route if this is the last segment. Returning false
indicates the param does not match, and the router keeps searching for a route.
This feature does not populate event.valid
, like validators does.
/* src/params/fruits.ts */
export const match = (param: string) => {
const fruits = new Set(['apple', 'orange', 'grape'])
return fruits.has(param)
}
The above would be used in your route segment like so: /src/routes/[fruit=fruits]/route.ts
, where the label on the right side of the =
character should be the same as the filename (minus the extension) in src/params
.
So, for the fruits example, if a request was made to /apple
, it would match, but a request to /banana
would not.
xin provides the following built-in matchers, but they can be overridden by creating your own file definitions:
/* word */
(param) => /^\w+$/.test(param)
/* letter */
(param) => /^[a-z]+$/i.test(param)
/* number */
(param) => /^\d+$/.test(param)
xink uses the same middleware strategy as SvelteKit (ok, we stole it). You export a single function named handle
. Place your middleware in src/middleware
, with the main file being middleware.[js|ts]
. This gives you access to both the event (includes the Request) and the Response.
/* src/middleware/middleware.ts */
import { type Handle } from '@xinkjs/xink'
export const handle: Handle = (event, resolve) => {
/**
* You can handle requests to endpoints that don't
* exist in src/routes.
*/
if (event.url.pathname === '/synthetic')
return new Response('Hit synthetic route.')
/* You can do something with the request. */
/* Let the router determine a response. */
const response = resolve(event)
/* You can do something with the response. */
return response
}
For multiple middleware, use the sequence
helper. Each middleware function should take in an event
and resolve
function, and return resolve(event)
. For this example, we're defining each middleware in middleware.ts
, but the first
and second
functions could easily be in another file and imported.
/* src/middleware/middleware.ts */
import { type Handle, sequence } from '@xinkjs/xink'
const first: Handle = (event, resolve) => {
console.log('hit first middleware')
// do something
return resolve(event)
}
const second: Handle = (event, resolve) => {
console.log('hit second middleware')
// do something
return resolve(event)
}
/* Middleware is handled in series. */
export const handle: Handle = sequence(first, second)
You can define hooks for each route, with the HOOKS
export. They are run for any configured method for a route, including the default
export. They are called after global middleware but before the route handler. Validation also happens before they are called, so you have access to validated data (see next section).
- Have access to
Request
. - Do not have access to
Response
. - Must return either
null
orevent
. Ifevent
is changed, it needs to be returned in order to access it in handlers. - Functions can be sync or async.
- Are not guaranteed to run in any particular order.
/* src/routes/route.ts */
import logger from 'pino'
export const HOOKS = {
state: (event: RequestEvent) => {
event.locals.state = { some: 'thing' }
return event
},
log: () => {
logger().info('Hello from Pino!')
return null
},
poki: async (event: RequestEvent) => {
const res = await fetch('https://pokeapi.co/api/v2/pokemon/pikachu')
event.locals.poki = await res.json()
return event
}
}
export const GET = (event) => {
console.log(event.locals.state.some) // thing
return new Response('Hello GET')
}
export const POST = (event) => {
console.log(event.locals.state.some) // thing
return new Response('Hello POST')
}
Validate incoming route data for types form
, json
, route params
, or query
search params. Validated data is available as an object within event.valid
. You can validate using either validator functions or schemas.
Any thrown errors can be handled by handleError()
(see further below).
VALIDATORS
is a built-in HOOKS
object. For each route handler and data type, you can define a function that returns an object of validated data.
In the Zod example below, only json data which matches your schema, will be available in event.valid.json
; in this case, for POST requests.
/* src/routes/route.js */
import { z } from 'zod'
const VALIDATORS = {
POST: {
json: (z.object({
hello: z.string(),
goodbye: z.number()
})).parse // notice we pass a parse function, not just the schema.
}
}
export const HOOKS = {
VALIDATORS,
someOtherHook: () => null
}
/**
* Assuming the following json is passed in:
* {
* hello: 'world',
* goodbye: 42,
* cya: 'later'
* }
*/
export const POST = async (event) => {
console.log(event.valid.json)
// { hello: 'world', goodbye: 42 } }
/* Do something and return a response. */
}
Instead of using a validation library, you can also define a normal function with your own validation logic, then return the validated data as an object.
We clone the request during validation. This allows you to access the original request body within route handlers, if desired.
import * as v from 'valibot'
import type { RequestEvent, Validators } from '@xinkjs/xink'
const post_json_schema = v.object({
hello: v.string(),
goodbye: v.number()
})
type PostTypes = {
json: v.InferInput<typeof post_json_schema>;
}
const VALIDATORS: Validators = {
POST: {
json: v.parser(post_json_schema)
}
}
export const HOOKS = {
VALIDATORS
}
export const POST = async (event: RequestEvent<PostTypes>) => {
// IDE autocomplete/types for "hello" and "goodbye" for event.valid.json.
const valid_json = event.valid.json
/* Do something and return a response. */
}
SCHEMAS
is a built-in HOOKS
object. For each route handler and data type, you can define a schema to validate data. You can only use SCHEMAS when using a validation library that is Standard Schema compliant. You can use types with this as well.
/* src/routes/route.js */
import * as v from 'valibot'
const SCHEMAS = {
POST: {
json: v.object({
hello: v.string(),
goodbye: v.number()
})
}
}
export const HOOKS = {
SCHEMAS,
someOtherHook: () => null
}
/**
* Assuming the following json is passed in:
* {
* hello: 'world',
* goodbye: 42,
* cya: 'later'
* }
*/
export const POST = async (event) => {
console.log(event.valid.json)
// { hello: 'world', goodbye: 42 } }
/* Do something and return a response. */
}
If you need to handle thrown errors separately, especially for errors from validation libraries, create an error.[ts|js]
file in src
, that exports a handleError
function. This can also be used to handle other errors not caught by a try/catch.
/* src/error.ts */
import { json } from "@xinkjs/xink"
import { ZodError } from "zod"
export const handleError = (e) => {
if (e instanceof ZodError)
return json({ data: null, error: e })
}
import { json, StandardSchemaError } from "@xinkjs/xink"
export const handleError = (e: any) => {
if (e instanceof StandardSchemaError)
return json({
data: null,
error: JSON.parse(e.message) // use JSON.parse to return as an array
})
/* Handle other errors. */
return json({
data: null,
error: e.message
})
}
All helper functions are available within event
but can also be top-level imported if needed.
Returns an html response. It sends a Content-Length
header and a Content-Type
header of text/html
.
export const GET = (event) => {
return event.html(`<div>You chose ${event.params.fruit}</div>`)
}
Returns a text response. By default, it sends a Content-Length
header and a Content-Type
header of text/plain
.
export const GET = (event) => {
return event.text(`Hello World!`)
}
Returns a json response. By default, it sends a Content-Length
header and a Content-Type
header of application/json
.
export const GET = (event) => {
return event.json({ hello: 'world' })
}
Returns a redirect response.
export const GET = (event) => {
return event.redirect(status: number, location: string)
}
event.setHeaders
You cannot set cookies using this method; instead, use
event.cookies
.
/* src/routes/route.js */
export const GET = ({ setHeaders }) => {
setHeaders({
'Powered-By': 'xink'
})
}
event.cookies
, with methods delete
, get
, getAll
, and set
.
/* src/routes/route.js */
export const GET = ({ cookies }) => {
cookies.set('xink', 'is awesome', { maxAge: 60 * 60 * 24 * 365 })
const cookie_value = cookies.get('xink')
const all_cookies = cookies.getAll()
cookies.delete('xink')
}
event.locals
is available for you to define custom information per request. These are typically defined in middleware and used in route handlers.
/* src/middleware/middleware.ts */
export const handle: Handle = (event, resolve) => {
event.locals.xink = "some value"
return resolve(event)
}
/* src/routes/route.ts */
import { type RequestEvent } from '@xinkjs/xink'
export const GET = (event: RequestEvent) => {
console.log(event.locals.xink) // some value
}
To type your locals, create an api.d.ts
file in your src
folder.
declare global {
namespace Api {
interface Locals {
xink: string;
}
}
}
export {}
xink makes it easy for you to create OpenAPI reference docs, powered by Scalar to render them in a browser.
First, define your OPENAPI
export within each route file.
/* src/routes/route.ts */
import * as v from 'valibot'
import { toJsonSchema } from "@valibot/to-json-schema"
const post_json_schema = v.object({
hello: v.string(),
goodbye: v.string()
})
const post_res_schema = v.object({
data: v.nullable(v.object({
message: v.string()
})),
error: v.nullable(v.unknown())
})
/* Define route endpoints. */
...
/* Define OpenAPI definitions. */
export const OPENAPI = {
post: {
summary: "Post root route",
requestBody: {
required: true,
content: {
"application/json": {
schema: toJsonSchema(post_json_schema)
}
}
},
responses: {
200: {
description: "OK",
content: {
"application/json": {
schema: toJsonSchema(post_res_schema)
}
}
}
}
}
}
Then, within your project's entrypoint file, define your desired path to the OpenAPI docs, and any optional metadata. When the router is started, you can visit the reference docs in a browser - e.g. http://localhost:3000/reference
/* e.g. index.ts */
import { Xink } from "@xinkjs/xink"
const api = new Xink()
api.openapi({
path: "/reference",
data: {
"openapi": "3.1.0",
"info": {
"title": "Xink API",
"version": "0.0.0"
}
}
})
await api.init()
export default {
fetch(req: Request) {
return api.fetch(req)
}
}
If a requested route does not exist, a 404 is returned.
If a requested route exists but there is no matching or default method, a 405 is returned with an Allow
header indicating the available methods.
If a request header of if-none-match
exists and matches the response etag
header, a 304 is returned with the following headers (if they exist on the response):
cache-control
, content-location
, date
, expires
, set-cookie
, vary
You can set these in the plugin's configuration.
type XinkConfig = {
runtime: 'bun' | 'cloudflare' | 'deno';
check_origin?: boolean;
entrypoint?: string;
out_dir?: string;
serve_options?: { [key: string]: any; }; // for Bun and Deno users (see next section)
}
/* vite.config.js */
import { xink } from '@xinkjs/xink'
import { defineConfig } from 'vite'
export default defineConfig(async function () {
return {
plugins: [
await xink({
runtime: 'bun',
entrypoint: 'server.js'
})
]
}
})
For Bun and Deno users, you can declare serve options in xink's plugin configuration. Any other runtimes will ignore these options. Be aware that these options are only relevant for build
and preview
, not dev
.
Bun supports adding these within your entrypoint's default export, if you'd like to declare them there.
/* vite.config.js */
import { xink } from '@xinkjs/xink'
import { defineConfig } from 'vite'
export default defineConfig(async function () {
return {
plugins: [
await xink({
runtime: 'bun',
serve_options: {
port: 3500
}
})
]
}
})
To build your app, run the relevant command per runtime:
bun run build
deno task build
npm run build
For Cloudflare, you can deploy your api with npm run deploy
.
Use $lib
for importing from src/lib
, instead of having to deal with things like ../../utils.ts
. This requires extending your tsconfig.json file. If you used the xk
CLI tool to create your project, this should already be done for you.
"extends": "./.xink/tsconfig.json",
import { thing } from '$lib/utils.ts'
To use and type your Bindings(env), create an api.d.ts
file in your src
folder or add to the existing file if you're already defining Api.Locals
.
/* src/api.d.ts */
declare global {
namespace Env {
interface Bindings {
MY_BUCKET: R2Bucket
HELLO: string;
}
}
// namespace Api {
// interface Locals {}
// }
}
export {}
// or, if you have a need to type your env within your root index.ts file,
// then you need to export Env so that you can import Env from this file (see below for an example).
export { Env }
Doing this also makes env
and ctx
available via event
.
/* index.ts */
import { Xink, type Context } from "@xinkjs/xink"
import type { Env } from "./src/api.d.ts"
const api = new Xink()
await api.init()
export default {
fetch(req: Request, env: Env.Bindings, ctx: Context) {
return api.fetch(req, env, ctx)
}
}
- CSRF protection: checks content type and origin (ref). If you don't want this, set
check_origin
tofalse
in the xink plugin configuration.
type XinkConfig = {
runtime: 'bun' | 'cloudflare' | 'deno';
check_origin?: boolean; // true
entrypoint?: string; // (derived from runtime)
out_dir?: string; // build
serve_options?: { [key: string]: any; };
}
type AtLeastOne<T, P> = { [K in keyof T]: Pick<T, K> }[keyof T]
interface Context {
waitUntil(promise: Promise<unknown>): void
passThroughOnException(): void
}
type Cookies = {
delete(name: string, options?: SerializeOptions): void;
get(name: string, options?: ParseOptions): string | undefined;
getAll(options?: ParseOptions): Array<{ name: string, value: string }>;
set(name: string, value: string, options?: SerializeOptions): void;
}
type Handle = (event: RequestEvent, resolve: ResolveEvent) => MaybePromise<Response>;
type Params = { [key: string]: string };
type Store = { [key: string]: any };
interface AllowedValidatorTypes {
form?: any;
json?: any;
params?: any;
query?: any;
}
interface RequestEvent<V extends AllowedValidatorTypes = AllowedValidatorTypes> {
cookies: Cookies;
ctx: Context;
env: Env.Bindings;
headers: Omit<Headers, 'toJSON' | 'count' | 'getAll'>;
html: (data: any, init?: ResponseInit | undefined): Response;
json: (data: any, init?: ResponseInit | undefined): Response;
locals: Api.Locals,
params: Params;
redirect: (status: number, location: string): never;
request: Request;
store: Store | null;
setHeaders: (headers: { [key: string]: any; }) => void;
text: (data: string, init?: ResponseInit | undefined): Response;
url: Omit<URL, 'createObjectURL' | 'revokeObjectURL' | 'canParse'>;
valid: V
}
interface Validators {
GET?: AtLeastOne<AllowedValidatorTypes, 'form' | 'json' | 'params' | 'query'>;
POST?: AtLeastOne<AllowedValidatorTypes, 'form' | 'json' | 'params' | 'query'>;
PUT?: AtLeastOne<AllowedValidatorTypes, 'form' | 'json' | 'params' | 'query'>;
PATCH?: AtLeastOne<AllowedValidatorTypes, 'form' | 'json' | 'params' | 'query'>;
DELETE?: AtLeastOne<AllowedValidatorTypes, 'form' | 'json' | 'params' | 'query'>;
HEAD?: AtLeastOne<AllowedValidatorTypes, 'form' | 'json' | 'params' | 'query'>;
OPTIONS?: AtLeastOne<AllowedValidatorTypes, 'form' | 'json' | 'params' | 'query'>;
default?: AtLeastOne<AllowedValidatorTypes, 'form' | 'json' | 'params' | 'query'>;
}
xink stands on the shoulders of giants. A special shoutout and tip of the hat goes to SvelteKit, as I used some of their code for various things; as it was not feasible to fork their code directly, and there's no reason to re-invent the wheel. Therefore, I've added a copyright for them in the license and marked relevant code with a short attribution.
Thanks to Deigo Rodríguez Baquero for donating the xk
npm package.
Pronounced "zinc", the name is based on the Georgian word khinkali; which is a type of dumpling in the country of Georgia. The transcription is /ˈxink'ali/. To be clear: khinkali's beginning proununciation is dissimilar from "zinc".