@xinkjs/xink
v0.6.3
Published
A directory-based javascript API router
Downloads
1,068
Readme
@xinkjs/xink
xink is a directory-based API router and a Vite plugin.
It requires Vite v6, which is currently in beta.
Road to Alpha
- [x] Bun
- [x] Deno
- [x] Cloudflare Workers -
request
only; no bindings (env
) or context (ctx
)
Wishlist
- [x] Support {Bun,Deno}.serve
- [ ] Cloudflare Workers
- [ ] Publish to JSR
- [ ] OpenAPI integration
- [ ] Vitest tests
- [ ] CLI tool to setup project
Route Types
xink uses the xin URL router, which was forked from @medleyjs/router. However, since xink is designed for directory-based routing, we use brackets to define special route segments for parameters, etc.
xin's currently supported route types, in order of match priority:
- static:
/hello/there/world
- specific:
/hello/miss-[name]
- 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)
Setup
Create a new project, then install Vite v6 and xink as dev dependencies.
bun add -D vite@beta @xinkjs/xink
deno add -D npm:vite@beta npm:@xinkjs/xink
[p]npm add -D vite@beta @xinkjs/xink
Vite plugin configuration
Create or ensure there is a "vite.config.js" file in your project's root directory.
/* vite.config.js */
import { xink } from '@xinkjs/xink'
import { defineConfig } from 'vite'
export default defineConfig(async function () {
return {
plugins: [
await xink({ runtime: 'bun' })
]
}
})
type XinkConfig = {
runtime: 'bun' | 'cloudflare' | 'deno';
check_origin?: boolean; // true
entrypoint?: string;
out_dir?: string; // build
serve_options?: { [key: string]: any; };
}
For the xink plugin configuration:
- you must provide a
runtime
value entrypoint
is optional, but should be the name of the server file in the root of your project. Below are the defaults for each supported runtime; so, you only need to set this if you're using something different. Note that these must only be filenames, not paths - your entrypoint must always be in the root directory of your project.- Bun,
index.ts
- Cloudflare,
index.ts
- Deno,
main.ts
- Bun,
tsconfig
If you're using typescript, add the below to your tsconfig. This will satisfy types when using the $lib
alias in your code.
"extends": "./.xink/tsconfig.json",
Scripts/Tasks
Setup your package.json or deno.json scripts. If you change your build output directory, be sure to adjust accordingly.
In the future, we hope to have a cli installer which sets these automatically.
Bun/Cloudflare
/* package.json */
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
Deno
/* deno.json */
"tasks": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"nodeModulesDir": "auto"
Use
In your project root, create your server's entrypoint file, e.g. index.[js|ts]
.
We are expecting a default export from this file, so you can't explicitly use Bun.serve()
or Deno.serve()
. However, you can declare options for .serve()
in the xink plugin config.
Bun does support declaring serve options within a default export, if you'd like to add them below
fetch() {}
.
/* entrypoint file */
import { Xink } from '@xinkjs/xink'
const api = new Xink()
await api.init()
export default {
fetch(req: Request) {
return api.fetch(req)
}
}
Create Routes
Routes are created in src/routes
. Each folder under this path represents a route segment.
At the end of a route segment, a javascript or typescript route
file should export one or more functions for each HTTP method it will serve. You can also define a fallback
, for any unhandled request methods.
xink supports these verbs and function names: 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'fallback'
/* src/routes/blog/[article]/route.ts */
import { json, text, type RequestEvent } from '@xinkjs/xink'
export const GET = async ({ params }: RequestEvent) => {
const article = await getArticle(params.article)
return text(`You asked for ${article.title}`)
}
export const POST = async ({ request }: RequestEvent) => {
return json(await request.json())
}
export const fallback = ({ request }: RequestEvent) => {
return text(`Hello ${request.method}`)
}
Middleware
xink uses the same middleware strategy as SvelteKit. You export a single function named handle
. Place your middleware in src/middleware
, with the main file being middleware.[js|ts]
.
/* 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)
.serve()
options
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
}
})
]
}
})
Setting Headers
Use event.setHeaders()
to set response headers. Be aware that you cannot set cookies using this method, but should instead use event.cookies
.
/* src/routes/route.js */
export const GET = ({ setHeaders }) => {
setHeaders({
'Powered-By': 'xink'
})
}
Cookie Handling
xink provides event.cookies
inside of middleware and your route handlers, 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')
}
Locals
event.locals
is available for you to define custom information per server request. This is an object where you can simply set the property and value, and then use it later in the request. This is often used in middleware, which then passes the values to routes.
/* 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
}
Validation
Validate incoming route data for form
s, json
, or query
parameters. Validated data is available as an object within event.valid.[form | json | query]
.
To define validators, export a validators
object from your route file. The first level of object properties define what HTTP handler the validation should apply to; this can include the fallback
handler. Below that, you can define a parser for form
, json
, and/or query
validation.
/* src/routes/route.js */
import { z } from 'zod'
export const POST = async (event) => {
const received_json = event.valid.json // { hello: 'world', goodbye: 42 } }
/* Do something and return a response. */
}
export const validators = {
POST: {
json: (z.object({
hello: z.string(),
goodbye: z.number()
})).parse
}
}
Any data which does not match your schema is not passed to event.valid
. For example, if the json data for the above route also had a thing: "one"
property, thing
would not be available within the event.valid.json
object.
Below is an example using valibot
.
import * as v from 'valibot'
export const POST = async (event) => {
const received_json = event.valid.json
/* Do something and return a response. */
}
export const validators = {
POST: {
json: v.parser({
hello: v.string(),
goodbye: v.number()
})
}
}
Be sure to look at the section below, to handle errors thrown by your validation library.
Handling Errors
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.
/* src/error.ts */
import { json } from "@xinkjs/xink"
import { ZodError } from "zod"
export const handleError = (e: any) => {
if (e instanceof ZodError)
return json({ data: null, error: e })
}
Parameter Matchers
You can think of these as validators for parameter route segments.
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.
/* 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
.
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)
Rest (Wildcard) Segments
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`
}
Import Aliases
- Use
$lib
for importing fromsrc/lib
, instead of having to deal with things like../../utils.ts
. This requires extending your tsconfig.json file; see doc for setup.import { thing } from '$lib/utils.ts'
Helper Functions
html
Returns an html response. It sends a Content-Length
header and a Content-Type
header of text/html
.
import { html } from '@xinkjs/xink'
export const GET = (event) => {
return html(`<div>You chose ${event.params.fruit}</div>`)
}
text
Returns a text response. By default, it sends a Content-Length
header and a Content-Type
header of text/plain
.
import { text } from '@xinkjs/xink'
export const GET = () => {
return text(`Hello World!`)
}
json
Returns a json response. By default, it sends a Content-Length
header and a Content-Type
header of application/json
.
import { json } from '@xinkjs/xink'
export const GET = () => {
return json({ hello: 'world' })
}
redirect
Returns a redirect response.
import { redirect } from '@xinkjs/xink'
export const GET = () => {
return redirect(status: number, location: string)
}
Additional Features
- CSRF protection: checks content type and origin (ref). If you don't want this, set
check_origin
tofalse
in the xink plugin configuration. - etag helper: returns 304 with no body, instead of 200 with body, if request etag equals response etag.
Types
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 };
type RequestEvent = {
cookies: Cookies;
headers: Omit<Headers, 'toJSON' | 'count' | 'getAll'>;
locals: { [key: string]: any },
params: Params;
request: Request;
store: Store | null; // HTTP methods and cooresponding handlers; used internally.
setHeaders: (headers: { [key: string]: any; }) => void;
url: Omit<URL, 'createObjectURL' | 'revokeObjectURL' | 'canParse'>;
valid: {
form?: { [key: string]: any };
json?: { [key: string]: any };
query?: { [key: string]: any };
}
}
interface Validators {
GET?: AtLeastOne<AllowedValidatorTypes, 'form' | 'json' | 'query'>;
POST?: AtLeastOne<AllowedValidatorTypes, 'form' | 'json' | 'query'>;
PUT?: AtLeastOne<AllowedValidatorTypes, 'form' | 'json' | 'query'>;
PATCH?: AtLeastOne<AllowedValidatorTypes, 'form' | 'json' | 'query'>;
DELETE?: AtLeastOne<AllowedValidatorTypes, 'form' | 'json' | 'query'>;
HEAD?: AtLeastOne<AllowedValidatorTypes, 'form' | 'json' | 'query'>;
OPTIONS?: AtLeastOne<AllowedValidatorTypes, 'form' | 'json' | 'query'>;
fallback?: AtLeastOne<AllowedValidatorTypes, 'form' | 'json' | 'query'>;
}
Attributions
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.
Origins
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".