Routing within a single endpoint
Gracile doesn’t aim to replace full-fledged HTTP frameworks like Express, Hono, Elysia, Fastify, etc.
However, you might want to colocate JSON endpoints with your front-end, to achieve what is often called the “Back-For-Front” pattern.
It’s when you want to put a thin bridge whose role is to authenticate with and curate your “real” backends.
That being said, Gracile is full-stack but is leaning more on the front-end side.
This is perfect for the typical scenario where “serverless” is chosen (aka more or less custom JS runtime running near the user).
But it is also a good practice to not put too much processing weight on the rendering side of your app/site, whether it is server-rendered upfront, or not.
Single entry point API routes can also be used to set up OpenAPI driven handlers, GraphQL, tRPC…
But we will stick with pure JSON here.
Note that when possible, it’s clearer to keep a file-based routing, like /api/pet/[id].ts
, but sometimes it’s more maintainable to use programmatic routing in a single file.
For that, we can use a [...rest].ts
“catch-all” route and process the URL forward ourselves.
Pre-requisites
In this recipe, we will explore the URLPattern
API to help us with routing.
At the time this guide
is written, isn’t supported widely, whether on server runtimes or browsers.
Fortunately, there is a polyfill available, which is the one used under the hood by Gracile already to achieve file-based routing.
npm i urlpattern-polyfill
Files
Here is a very contrived example, from where you can elaborate with POST
, more URLPattern
,
hand-shakes and data sourcing from/to your back-ends…
Defining more route patterns for your single-file API entry point will start to make sense over just using the file-based routing.
(Like already said above, /api/pet/:id/
could already be defined in the Gracile file router with /api/pet/[id].ts
).
But we just want to demonstrate the basic primitives for now:
📄 /src/routes/api/[...path].ts
import { defineRoutefunction defineRoute<GetHandlerData extends R.HandlerDataHtml = undefined, PostHandlerData extends R.HandlerDataHtml = undefined, CatchAllHandlerData extends R.HandlerDataHtml = undefined, StaticPathOptions extends R.StaticPathOptionsGeneric | undefined = undefined, RouteContext extends R.RouteContextGeneric = {
...;
}>(options: {
handler?: StaticPathOptions extends object ? never : R.Handler<CatchAllHandlerData> | {
GET?: R.Handler<GetHandlerData>;
POST?: R.Handler<PostHandlerData>;
QUERY?: R.Handler<Response>;
PUT?: R.Handler<Response>;
PATCH?: R.Handler<Response>;
DELETE?: R.Handler<Response>;
HEAD?: R.Handler<Response>;
OPTIONS?: R.Handler<Response>;
} | undefined;
staticPaths?: () => R.MaybePromise<StaticPathOptions[]> | undefined;
prerender?: boolean | undefined;
document?: R.DocumentTemplate<RouteContext> | undefined;
template?: R.BodyTemplate<RouteContext> | undefined;
}): (RouteModule: typeof R.RouteModule) => R.RouteModule
Defines a file-based route for Gracile to consume.
} from '@gracile/gracile/route';
Usually defined as a global in the browser.
import { URLPatternclass URLPattern
} from 'urlpattern-polyfill';
const petDbconst petDb: {
id: number;
name: string;
type: string;
}[]
= [
{ idid: number
: 1, namename: string
: 'Rantanplan', typetype: string
: 'dog' },
{ idid: number
: 1, namename: string
: 'Felix', typetype: string
: 'cat' },
];
const petStorePatternconst petStorePattern: URLPattern
= new URLPatternnew URLPattern(init?: URLPatternInput, baseURL?: string): URLPattern
(
'/api/pet/:id/',
'http://localhost:9898/',
);
export default defineRoutedefineRoute<undefined, undefined, Response, undefined, {
url: URL;
props: never;
params: Parameters;
}>(options: {
handler?: {
GET?: Handler<undefined> | undefined;
POST?: Handler<...> | undefined;
... 5 more ...;
OPTIONS?: Handler<...> | undefined;
} | Handler<...> | undefined;
staticPaths?: (() => MaybePromise<...> | undefined) | undefined;
prerender?: boolean | undefined;
document?: DocumentTemplate<...> | undefined;
template?: BodyTemplate<...> | undefined;
}): (RouteModule: typeof RouteModule) => RouteModule
Defines a file-based route for Gracile to consume.
({
handlerhandler?: {
GET?: Handler<undefined> | undefined;
POST?: Handler<undefined> | undefined;
QUERY?: Handler<Response> | undefined;
PUT?: Handler<Response> | undefined;
PATCH?: Handler<...> | undefined;
DELETE?: Handler<...> | undefined;
HEAD?: Handler<...> | undefined;
OPTIONS?: Handler<...> | undefined;
} | Handler<...> | undefined
A function or an object containing functions named after HTTP methods.
A handler can return either a standard Response
that will terminate the
request pipeline, or any object to populate the current route template
and document contexts.
: (contextcontext: {
url: URL;
params: Parameters;
request: Request;
locals: Gracile.Locals;
responseInit: ResponseInit;
}
) => {
if (contextcontext: {
url: URL;
params: Parameters;
request: Request;
locals: Gracile.Locals;
responseInit: ResponseInit;
}
.requestrequest: Request
.methodRequest.method: string
Returns request's HTTP method, which is "GET" by default.
!== 'GET')
return Responsevar Response: {
new (body?: BodyInit | null, init?: ResponseInit): Response;
prototype: Response;
error(): Response;
json(data: any, init?: ResponseInit): Response;
redirect(url: string | URL, status?: number): Response;
}
This Fetch API interface represents the response to a request.
.jsonfunction json(data: any, init?: ResponseInit): Response
(
{ successsuccess: boolean
: false, messagemessage: string
: `Only "GET" is allowed.` },
{ statusResponseInit.status?: number | undefined
: 405 },
);
const resultconst result: URLPatternResult | null
= petStorePatternconst petStorePattern: URLPattern
.execURLPattern.exec(input?: URLPatternInput, baseURL?: string): URLPatternResult | null
(contextcontext: {
url: URL;
params: Parameters;
request: Request;
locals: Gracile.Locals;
responseInit: ResponseInit;
}
.urlurl: URL
);
const idconst id: string | undefined
= resultconst result: URLPatternResult | null
?.pathnameURLPatternResult.pathname: URLPatternComponentResult
.groupsURLPatternComponentResult.groups: {
[key: string]: string | undefined;
} | undefined
?.idstring | undefined
;
if (!Numbervar Number: NumberConstructor
An object that represents a number of any kind. All JavaScript numbers are 64-bit floating-point numbers.
.isNaNNumberConstructor.isNaN(number: unknown): boolean
Returns a Boolean value that indicates whether a value is the reserved value NaN (not a
number). Unlike the global isNaN(), Number.isNaN() doesn't forcefully convert the parameter
to a number. Only values of the type number, that are also NaN, result in true.
(idconst id: string | undefined
)) {
const foundPetconst foundPet: {
id: number;
name: string;
type: string;
} | undefined
= petDbconst petDb: {
id: number;
name: string;
type: string;
}[]
.findArray<{ id: number; name: string; type: string; }>.find(predicate: (value: {
id: number;
name: string;
type: string;
}, index: number, obj: {
id: number;
name: string;
type: string;
}[]) => unknown, thisArg?: any): {
id: number;
name: string;
type: string;
} | undefined (+1 overload)
Returns the value of the first element in the array where predicate is true, and undefined
otherwise.
((petpet: {
id: number;
name: string;
type: string;
}
) => petpet: {
id: number;
name: string;
type: string;
}
.idid: number
=== Numbervar Number: NumberConstructor
(value?: any) => number
An object that represents a number of any kind. All JavaScript numbers are 64-bit floating-point numbers.
(idconst id: string | undefined
));
if (foundPetconst foundPet: {
id: number;
name: string;
type: string;
} | undefined
) return Responsevar Response: {
new (body?: BodyInit | null, init?: ResponseInit): Response;
prototype: Response;
error(): Response;
json(data: any, init?: ResponseInit): Response;
redirect(url: string | URL, status?: number): Response;
}
This Fetch API interface represents the response to a request.
.jsonfunction json(data: any, init?: ResponseInit): Response
({ successsuccess: boolean
: true, datadata: {
id: number;
name: string;
type: string;
}
: foundPetconst foundPet: {
id: number;
name: string;
type: string;
}
});
return Responsevar Response: {
new (body?: BodyInit | null, init?: ResponseInit): Response;
prototype: Response;
error(): Response;
json(data: any, init?: ResponseInit): Response;
redirect(url: string | URL, status?: number): Response;
}
This Fetch API interface represents the response to a request.
.jsonfunction json(data: any, init?: ResponseInit): Response
(
{ successsuccess: boolean
: false, messagemessage: string
: `Pet "${idconst id: string | undefined
}" not found!` },
{ statusResponseInit.status?: number | undefined
: 404 },
);
}
return Responsevar Response: {
new (body?: BodyInit | null, init?: ResponseInit): Response;
prototype: Response;
error(): Response;
json(data: any, init?: ResponseInit): Response;
redirect(url: string | URL, status?: number): Response;
}
This Fetch API interface represents the response to a request.
.jsonfunction json(data: any, init?: ResponseInit): Response
(
{ successsuccess: boolean
: false, messagemessage: string
: `Unknown API route for "${contextcontext: {
url: URL;
params: Parameters;
request: Request;
locals: Gracile.Locals;
responseInit: ResponseInit;
}
.urlurl: URL
}"` },
{ statusResponseInit.status?: number | undefined
: 400 },
);
},
});
📄 /src/routes/my-api-consumer.ts
import { htmlconst html: (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult<1>
Interprets a template literal as an HTML template that can efficiently
render to and update a container.
const header = (title: string) => html`<h1>${title}</h1>`;
The html
tag returns a description of the DOM to render as a value. It is
lazy, meaning no work is done until the template is rendered. When rendering,
if a template comes from the same expression as a previously rendered result,
it's efficiently updated instead of replaced.
} from 'lit';
import { defineRoutefunction defineRoute<GetHandlerData extends R.HandlerDataHtml = undefined, PostHandlerData extends R.HandlerDataHtml = undefined, CatchAllHandlerData extends R.HandlerDataHtml = undefined, StaticPathOptions extends R.StaticPathOptionsGeneric | undefined = undefined, RouteContext extends R.RouteContextGeneric = {
...;
}>(options: {
handler?: StaticPathOptions extends object ? never : R.Handler<CatchAllHandlerData> | {
GET?: R.Handler<GetHandlerData>;
POST?: R.Handler<PostHandlerData>;
QUERY?: R.Handler<Response>;
PUT?: R.Handler<Response>;
PATCH?: R.Handler<Response>;
DELETE?: R.Handler<Response>;
HEAD?: R.Handler<Response>;
OPTIONS?: R.Handler<Response>;
} | undefined;
staticPaths?: () => R.MaybePromise<StaticPathOptions[]> | undefined;
prerender?: boolean | undefined;
document?: R.DocumentTemplate<RouteContext> | undefined;
template?: R.BodyTemplate<RouteContext> | undefined;
}): (RouteModule: typeof R.RouteModule) => R.RouteModule
Defines a file-based route for Gracile to consume.
} from '@gracile/gracile/route';
import { documentconst document: () => ServerRenderedTemplate
} from '../document.js';
export default defineRoutedefineRoute<undefined, undefined, undefined, undefined, {
url: URL;
props: undefined;
params: Parameters;
}>(options: {
handler?: Handler<undefined> | {
GET?: Handler<undefined> | undefined;
... 6 more ...;
OPTIONS?: Handler<...> | undefined;
} | undefined;
staticPaths?: (() => MaybePromise<...> | undefined) | undefined;
prerender?: boolean | undefined;
document?: DocumentTemplate<...> | undefined;
template?: BodyTemplate<...> | undefined;
}): (RouteModule: typeof RouteModule) => RouteModule
Defines a file-based route for Gracile to consume.
({
documentdocument?: DocumentTemplate<{
url: URL;
props: undefined;
params: Parameters;
}> | undefined
A function that returns a server only template.
Route context is provided at runtime during the build.
: (contextcontext: {
url: URL;
props: undefined;
params: Parameters;
}
) => documentfunction document(): ServerRenderedTemplate
(contextcontext: {
url: URL;
props: undefined;
params: Parameters;
}
),
templatetemplate?: BodyTemplate<{
url: URL;
props: undefined;
params: Parameters;
}> | undefined
A function that returns a server only or a Lit client hydratable template.
Route context is provided at runtime during the build.
: () => htmlconst html: (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult<1>
Interprets a template literal as an HTML template that can efficiently
render to and update a container.
const header = (title: string) => html`<h1>${title}</h1>`;
The html
tag returns a description of the DOM to render as a value. It is
lazy, meaning no work is done until the template is rendered. When rendering,
if a template comes from the same expression as a previously rendered result,
it's efficiently updated instead of replaced.
`
<script type="module">
const pet = await fetch('/api/pet/1/').then((r) => r.json());
const noPet = await fetch('/api/pet/10/').then((r) => r.json());
const wrongMethod = await fetch('/api/pet/1/', {
method: 'DELETE',
}).then((r) => r.json());
console.log({ pet, noPet, wrongMethod });
</script>
<h1>My JSON API consumer</h1>
`,
});