HTTP
The HTTP module is all about the integration with a platform's HTTP server. It creates a common standards-based layer for agnostic servers and also provides nice abstractions for other parts of Gruber to consume.
The module is based around route definitions created with defineRoute. This pulls together a few standards to make a route definition that you can easily pass around.
- Fetch API Request & Response objects
- URL objects
- URLPattern for route matching
import { defineRoute } from "gruber";
// A route is a first-class thing, it can easily be passed around and used
export const helloRoute = defineRoute({
method: "GET",
pathname: "/hello/:name",
handler({ params }) {
return new Response(`Hello, ${params.name}!`);
},
});
Once you have a few routes, they can be passed to a FetchRouter to serve them.
import { FetchRouter } from "gruber";
import { helloRoute } from "./hello-route.ts";
const router = new FetchRouter({
routes: [helloRoute],
});
Depending on the platform you're running on you can use the FetchRouter with the platform's HTTP server.
See Deno or Node.js for more.
Other notable parts of the HTTP module are:
- CORS — a development utility for applying CORS headers
- ServerSentEventStream — for streaming Server-sent events responses using the Streams API
- HTTPError — a custom error subclass for throwing HTTP errors directly in routes
There is an unstable AuthorizationService for checking request authorization.
Miscellaneous
includesScope
Check whether a provided scope meets the requirement of the expected scope
The idea is that a parent scope contains all children scopes, recursively. So if you find all the parents of a given scope, you can test it against a scope that has been provided by a user.
For example user:books:read will match against:
user:books:readuser:booksuser
So if any of those scopes are authorized, access can be granted.
includesScope("user:books:read", "user:books:read"); // true
includesScope("user:books", "user:books:read"); // true
includesScope("user", "user:books:read"); // true
includesScope("user", "user:podcasts"); // true
includesScope("user:books", "user:podcasts"); // false
Cors
unstable
A development utility for apply CORS headers to a HTTP server using standard Request and Response objects.
This really should not be used in production, I built this with the intention that whatever reverse-proxy the app is deployed behind would manage these headers instead.
This implementation was adapted from expressjs/cors, mostly to modernise it and remove features that weren't needed for this development-intended class.
It will:
- set
Access-Control-Allow-Methodsto all methods - mirror headers in
Access-Control-Request-Headersfrom the request - properly set the
Varyheader for any request header that varies the response - set
Access-Control-Allow-Originbased on theoptions.originsoption, allowing the origin if it is in the array or if the array includes* - set
Access-Control-Allow-Credentialsif opted in throughoptions.credentials
const cors = new Cors({
origins: ['http://localhost:8080'],
credentials: true
})
const request = new Request('http://localhost:3000/books/')
const response = Response.json({})
const result = cors.apply(request, response)
ServerSentEventMessage
type
Represents a message in the Server-Sent Event protocol
// All fields are optional
const message = {
comment: 'hello there',
event: 'my-event',
data: JSON.stringify({ lots: 'of', things: true }),
id: 42,
retry: 3600
}
comment
Ignored by the client, can be used to prevent connections from timing out
data
The data field for the message. Split by new lines.
event
A string identifying the type of event described. If specified this event is triggered, otherwise a "message" will be dispatched.
id
The event ID to set the EventSource object's last event ID value.
retry
The reconnection time. If the connection to the server is lost, the browser will wait for the specified time before attempting to reconnect.
ServerSentEventStream
Transforms server-sent message objects into strings for the client. more info.
You can then write ServerSentEventMessage to that stream over time.
const data = [{ data: "hello there" }]
// Get a stream somehow, then pipe it through
const stream = ReadableStream.from<ServerSentEventMessage>(data)
.pipeThrough(new ServerSentEventStream());
const response = new Response(stream, {
headers: {
"content-type": "text/event-stream",
"cache-control": "no-cache",
},
});
Routing
defineRoute
defineRoute is the way of specifying how your server handles a specific bit of web traffic.
It returns the RouteDefinition which can be passed around and used in various places.
Mainly it is passed to a FetchRouter to serve web requests.
export const helloRoute = defineRoute({
method: "GET",
pathname: "/hello/:name",
handler({ request, url, params }) {
return new Response(`Hello, ${params.name}!`);
}
})
FetchRouter
FetchRouter is a web-native router for routes defined with defineRoute.
const routes = [defineRoute("..."), defineRoute("..."), defineRoute("...")];
const router = new FetchRouter({ routes });
All options to the FetchRouter constructor are optional
and you can create a router without any options if you want.
routes are the route definitions you want the router to processes,
the router will handle a request based on the first route that matches.
So order is important.
errorHandler(error, request) is called if a 5xx HTTPError is caught, including unknown errors.
It is called with the offending error and the request it is associated with.
NOTE: The
errorHandlercould do more in the future, like create it's own Response or mutate the existing response. This has not been designed and is left open to future development if it becomes important.
log is an unstable option to turn on HTTP logging, it can be a boolean or middleware function. It also logs HTTP errors if not already configured through errorHandler.
cors is an unstable option to apply a CORS instance to all requests and adds an OPTIONS route handler
findMatches
Find each matching route in turn
let request = new Request('...')
for (const match of router.findMatches(request)) {
// do something with the request and/or break the loop
}
getResponse
Process all routes and get a HTTP Response.
const response = router.getResponse(
new Request('http://localhost/pathname')
)
NOTE: it would be nice to align this with the Fetch API
fetchmethod signature.
handleError
Attempt to handle an error thrown from a route's handler,
checking for well-known HTTPError instance or converting unknown errors into one.
The HTTPError is then used to convert the error into a HTTP Response.
If the error is server-based it will trigger the FetchRouter's errorHandler.
const response = router.handleError(request, new Error("Something went wrong"))
processMatches
internal
Take an iterator of route matches and convert them into a HTTP Response
by executing the route's handler.
It will return the first route to return a Response object
or throw a HTTPError if no routes matched.
const response = await router.processMatches(request, matches)
processRoute
Execute a route's handler to generate a HTTP Response
HTTPError
HTTPError
A custom Error subclass that represents an HTTP error to be returned to the user.
This allows routes to throw specific HTTP errors directly and FetchRouter knows how to handle them and turn them into HTTP Responses
You can use well-known errors like below, you can also pass a BodyInit to customise the response body.
throw HTTPError.badRequest()
throw HTTPError.unauthorized()
throw HTTPError.notFound()
throw HTTPError.internalServerError()
throw HTTPError.notImplemented()
// The plan is to add more error well-known codes as they are needed
You can also manually construct the error:
const teapot = new HTTPError(418, "I'm a teapot");
body
A custom body to send to the client
headers
Extra headers to send to the client
status
The HTTP status to return ~ status
statusText
The status text to return ~ statusText
toResponse
Convert the HTTPError into a HTTP Response object
taking into account the status, statusText and headers fields on the error.
const error = new HTTPError(418, "I'm a teapot");
error.toResponse() // Response
Validation
getRequestBody
unstable
Get and parse well-known request bodies based on the Content-Type header supplied
// Parse a application/x-www-form-urlencoded
// or multipart/form-data request
const formData = await getRequestBody(
new Request('http://localhost:8000', { body: new FormData() })
)
// Parse an application/json request
const json = await getRequestBody(
new Request('http://localhost:8000', {
body: JSON.stringify({ hello: 'world' }),
headers: { 'Content-Type': 'application/json' },
})
)
assertRequestBody
unstable
Validate the body of a request against a StandardSchema or Structure.
This will throw nice HTTPError errors that are caught by gruber and sent along to the user.
const struct = Structure.object({ name: Structure.string() })
const body1 = await assertRequestBody(struct, new Request('…'))
NOTE — you need to await the function when passing a
Request
or from a JavaScript value:
const body2 = assertRequestBody(struct, { … })
const body3 = assertRequestBody(struct, new FormData(…))
const body3 = assertRequestBody(struct, new URLSearchParams(…))
you can use any StandardSchema library with this:
import { z } from 'zod'
const body4 = assertRequestBody(
z.object({ name: z.string() }),
{ name: "Geoff Testington" }
)
debug
{
"includesScope": {
"entrypoint": "http/mod.ts",
"id": "includesScope",
"name": "includesScope",
"content": "Check whether a provided scope meets the requirement of the expected scope\n\nThe idea is that a parent scope contains all children scopes, recursively.\nSo if you find all the parents of a given scope, you can test it against a scope that has been provided by a user.\n\nFor example `user:books:read` will match against:\n- `user:books:read`\n- `user:books`\n- `user`\n\nSo if any of those scopes are authorized, access can be granted.\n\n```js\nincludesScope(\"user:books:read\", \"user:books:read\"); // true\nincludesScope(\"user:books\", \"user:books:read\"); // true\nincludesScope(\"user\", \"user:books:read\"); // true\nincludesScope(\"user\", \"user:podcasts\"); // true\nincludesScope(\"user:books\", \"user:podcasts\"); // false\n```",
"tags": {
"group": "Miscellaneous"
},
"children": {}
},
"Cors": {
"entrypoint": "http/mod.ts",
"id": "Cors",
"name": "Cors",
"content": "A development utility for apply CORS headers to a HTTP server using standard\n[Request](https://developer.mozilla.org/en-US/docs/Web/API/Request)\nand [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) objects.\n\n> This really **should not** be used in production, I built this with the intention that\n> whatever reverse-proxy the app is deployed behind would manage these headers instead.\n\nThis implementation was adapted from [expressjs/cors](https://github.com/expressjs/cors),\nmostly to modernise it and remove features that weren't needed for this development-intended class.\n\nIt will:\n\n- set `Access-Control-Allow-Methods` to all methods\n- mirror headers in `Access-Control-Request-Headers` from the request\n- properly set the `Vary` header for any request header that varies the response\n- set `Access-Control-Allow-Origin` based on the `options.origins` option, allowing the origin if it is in the array or if the array includes `*`\n- set `Access-Control-Allow-Credentials` if opted in through `options.credentials`\n\n```js\nconst cors = new Cors({\n origins: ['http://localhost:8080'],\n credentials: true\n})\n\nconst request = new Request('http://localhost:3000/books/')\nconst response = Response.json({})\n\nconst result = cors.apply(request, response)\n```",
"tags": {
"unstable": "true",
"group": "Miscellaneous"
},
"children": {}
},
"defineRoute": {
"entrypoint": "http/mod.ts",
"id": "defineRoute",
"name": "defineRoute",
"content": "`defineRoute` is the way of specifying how your server handles a specific bit of web traffic.\nIt returns the RouteDefinition which can be passed around and used in various places.\nMainly it is passed to a `FetchRouter` to serve web requests.\n\n```js\nexport const helloRoute = defineRoute({\n\tmethod: \"GET\",\n\tpathname: \"/hello/:name\",\n\thandler({ request, url, params }) {\n\t\treturn new Response(`Hello, ${params.name}!`);\n\t}\n})\n```",
"tags": {
"group": "Routing"
},
"children": {}
},
"FetchRouter": {
"entrypoint": "http/mod.ts",
"id": "FetchRouter",
"name": "FetchRouter",
"content": "`FetchRouter` is a web-native router for routes defined with `defineRoute`.\n\n```js\nconst routes = [defineRoute(\"...\"), defineRoute(\"...\"), defineRoute(\"...\")];\n\nconst router = new FetchRouter({ routes });\n```\n\nAll options to the `FetchRouter` constructor are optional\nand you can create a router without any options if you want.\n\n`routes` are the route definitions you want the router to processes,\nthe router will handle a request based on the first route that matches.\nSo order is important.\n\n`errorHandler(error, request)` is called if a 5xx `HTTPError` is caught, including unknown errors.\nIt is called with the offending error and the request it is associated with.\n\n> NOTE: The `errorHandler` could do more in the future,\n> like create it's own Response or mutate the existing response.\n> This has not been designed and is left open to future development if it becomes important.\n\n`log` is an **unstable** option to turn on HTTP logging, it can be a boolean or middleware function. It also logs HTTP errors if not already configured through `errorHandler`.\n\n`cors` is an **unstable** option to apply a [CORS](#cors) instance to all requests and adds an `OPTIONS` route handler",
"tags": {
"group": "Routing"
},
"children": {
"findMatches": {
"id": "FetchRouter#findMatches",
"name": "findMatches",
"content": "Find each matching route in turn\n\n```js\nlet request = new Request('...')\n\nfor (const match of router.findMatches(request)) {\n\t// do something with the request and/or break the loop\n}\n```",
"tags": {
"group": "Miscellaneous"
},
"children": {}
},
"processMatches": {
"id": "FetchRouter#processMatches",
"name": "processMatches",
"content": "Take an iterator of route matches and convert them into a HTTP Response\nby executing the route's handler.\nIt will return the first route to return a `Response` object\nor throw a `HTTPError` if no routes matched.\n\n```js\nconst response = await router.processMatches(request, matches)\n```",
"tags": {
"internal": "true",
"group": "Miscellaneous"
},
"children": {}
},
"processRoute": {
"id": "FetchRouter#processRoute",
"name": "processRoute",
"content": "Execute a route's handler to generate a HTTP `Response`",
"tags": {
"group": "Miscellaneous"
},
"children": {}
},
"handleError": {
"id": "FetchRouter#handleError",
"name": "handleError",
"content": "Attempt to handle an error thrown from a route's handler,\nchecking for well-known HTTPError instance or converting unknown errors into one.\nThe HTTPError is then used to convert the error into a HTTP `Response`.\n\nIf the error is server-based it will trigger the `FetchRouter`'s `errorHandler`.\n\n```js\nconst response = router.handleError(request, new Error(\"Something went wrong\"))\n```",
"tags": {
"group": "Miscellaneous"
},
"children": {}
},
"getResponse": {
"id": "FetchRouter#getResponse",
"name": "getResponse",
"content": "Process all routes and get a HTTP Response.\n\n```js\nconst response = router.getResponse(\n\tnew Request('http://localhost/pathname')\n)\n```\n\n> NOTE: it would be nice to align this with the Fetch API `fetch` method signature.",
"tags": {
"group": "Miscellaneous"
},
"children": {}
}
}
},
"HTTPError": {
"entrypoint": "http/mod.ts",
"id": "HTTPError",
"name": "HTTPError",
"content": "A custom [Error](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error)\nsubclass that represents an HTTP error to be returned to the user.\n\nThis allows routes to throw specific HTTP errors directly and\n[FetchRouter](#fetchrouter) knows how to handle them and turn them into HTTP Responses\n\n\n\nYou can use well-known errors like below, you can also pass a [BodyInit](https://developer.mozilla.org/en-US/docs/Web/API/Response/Response#body) to customise the response body.\n\n```js\nthrow HTTPError.badRequest()\nthrow HTTPError.unauthorized()\nthrow HTTPError.notFound()\nthrow HTTPError.internalServerError()\nthrow HTTPError.notImplemented()\n\n// The plan is to add more error well-known codes as they are needed\n```\n\nYou can also manually construct the error:\n\n```js\nconst teapot = new HTTPError(418, \"I'm a teapot\");\n```",
"tags": {
"group": "HTTPError"
},
"children": {
"status": {
"id": "HTTPError#status",
"name": "status",
"content": "The HTTP status to return ~ [status](https://developer.mozilla.org/en-US/docs/Web/API/Response/status)",
"tags": {
"group": "Miscellaneous"
},
"children": {}
},
"statusText": {
"id": "HTTPError#statusText",
"name": "statusText",
"content": "The status text to return ~ [statusText](https://developer.mozilla.org/en-US/docs/Web/API/Response/statusText)",
"tags": {
"group": "Miscellaneous"
},
"children": {}
},
"body": {
"id": "HTTPError#body",
"name": "body",
"content": "A custom body to send to the client",
"tags": {
"group": "Miscellaneous"
},
"children": {}
},
"headers": {
"id": "HTTPError#headers",
"name": "headers",
"content": "Extra headers to send to the client",
"tags": {
"group": "Miscellaneous"
},
"children": {}
},
"toResponse": {
"id": "HTTPError#toResponse",
"name": "toResponse",
"content": "Convert the HTTPError into a HTTP `Response` object\ntaking into account the `status`, `statusText` and `headers` fields on the error.\n\n```js\nconst error = new HTTPError(418, \"I'm a teapot\");\n\nerror.toResponse() // Response\n```",
"tags": {
"group": "Miscellaneous"
},
"children": {}
},
"HTTPError.badRequest": {
"id": "HTTPError.badRequest",
"name": "badRequest",
"content": "[400 Bad Request](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400)\n\n```js\nthrow HTTPError.badRequest()\n```",
"tags": {
"group": "Miscellaneous"
},
"children": {}
},
"HTTPError.unauthorized": {
"id": "HTTPError.unauthorized",
"name": "unauthorized",
"content": "[401 Unauthorized](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401)\n\n```js\nthrow HTTPError.unauthorized()\n```",
"tags": {
"group": "Miscellaneous"
},
"children": {}
},
"HTTPError.notFound": {
"id": "HTTPError.notFound",
"name": "notFound",
"content": "[404 Not Found](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404)\n\n```js\nthrow HTTPError.notFound()\n```",
"tags": {
"group": "Miscellaneous"
},
"children": {}
},
"HTTPError.internalServerError": {
"id": "HTTPError.internalServerError",
"name": "internalServerError",
"content": "[500 Internal Server Error](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500)\n\n```js\nthrow HTTPError.internalServerError()\n```",
"tags": {
"group": "Miscellaneous"
},
"children": {}
},
"HTTPError.notImplemented": {
"id": "HTTPError.notImplemented",
"name": "notImplemented",
"content": "[500 Not Implemented](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/501)\n\n```js\nthrow HTTPError.notImplemented()\n```",
"tags": {
"group": "Miscellaneous"
},
"children": {}
}
}
},
"getRequestBody": {
"entrypoint": "http/mod.ts",
"id": "getRequestBody",
"name": "getRequestBody",
"content": "Get and parse well-known request bodies based on the Content-Type header supplied\n\n```js\n\n// Parse a application/x-www-form-urlencoded\n// or multipart/form-data request\nconst formData = await getRequestBody(\n new Request('http://localhost:8000', { body: new FormData() })\n)\n\n// Parse an application/json request\nconst json = await getRequestBody(\n new Request('http://localhost:8000', {\n body: JSON.stringify({ hello: 'world' }),\n headers: { 'Content-Type': 'application/json' },\n })\n)\n```",
"tags": {
"unstable": "true",
"group": "Validation"
},
"children": {}
},
"assertRequestBody": {
"entrypoint": "http/mod.ts",
"id": "assertRequestBody",
"name": "assertRequestBody",
"content": "Validate the body of a request against a [StandardSchema](https://standardschema.dev/) or `Structure`.\nThis will throw nice [HTTPError](#httperror) errors that are caught by gruber and sent along to the user.\n\n```js\nconst struct = Structure.object({ name: Structure.string() })\n\nconst body1 = await assertRequestBody(struct, new Request('…'))\n```\n\n> **NOTE** — you need to await the function when passing a `Request`\n\nor from a JavaScript value:\n\n```js\nconst body2 = assertRequestBody(struct, { … })\nconst body3 = assertRequestBody(struct, new FormData(…))\nconst body3 = assertRequestBody(struct, new URLSearchParams(…))\n```\n\nyou can use any StandardSchema library with this:\n\n```js\nimport { z } from 'zod'\n\nconst body4 = assertRequestBody(\n z.object({ name: z.string() }),\n { name: \"Geoff Testington\" }\n)\n```",
"tags": {
"unstable": "true",
"group": "Validation"
},
"children": {}
},
"ServerSentEventMessage": {
"entrypoint": "http/mod.ts",
"id": "ServerSentEventMessage",
"name": "ServerSentEventMessage",
"content": "Represents a message in the [Server-Sent Event protocol](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#fields)\n\n```js\n// All fields are optional\nconst message = {\n comment: 'hello there',\n event: 'my-event',\n data: JSON.stringify({ lots: 'of', things: true }),\n id: 42,\n retry: 3600\n}\n```",
"tags": {
"group": "Miscellaneous",
"type": "true"
},
"children": {
"comment": {
"id": "ServerSentEventMessage#comment",
"name": "comment",
"content": "Ignored by the client, can be used to prevent connections from timing out",
"tags": {
"group": "Miscellaneous"
},
"children": {}
},
"event": {
"id": "ServerSentEventMessage#event",
"name": "event",
"content": "A string identifying the type of event described. If specified this event is triggered, otherwise a \"message\" will be dispatched.",
"tags": {
"group": "Miscellaneous"
},
"children": {}
},
"data": {
"id": "ServerSentEventMessage#data",
"name": "data",
"content": "The data field for the message. Split by new lines.",
"tags": {
"group": "Miscellaneous"
},
"children": {}
},
"id": {
"id": "ServerSentEventMessage#id",
"name": "id",
"content": "The event ID to set the `EventSource` object's last event ID value.",
"tags": {
"group": "Miscellaneous"
},
"children": {}
},
"retry": {
"id": "ServerSentEventMessage#retry",
"name": "retry",
"content": "The reconnection time. If the connection to the server is lost, the browser will wait for the specified time before attempting to reconnect.",
"tags": {
"group": "Miscellaneous"
},
"children": {}
}
}
},
"ServerSentEventStream": {
"entrypoint": "http/mod.ts",
"id": "ServerSentEventStream",
"name": "ServerSentEventStream",
"content": "Transforms server-sent message objects into strings for the client.\n[more info](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events).\n\nYou can then write [ServerSentEventMessage](#serversenteventmessage) to that stream over time.\n\n```js\nconst data = [{ data: \"hello there\" }]\n\n// Get a stream somehow, then pipe it through\nconst stream = ReadableStream.from(data)\n .pipeThrough(new ServerSentEventStream());\n\nconst response = new Response(stream, {\n headers: {\n \"content-type\": \"text/event-stream\",\n \"cache-control\": \"no-cache\",\n },\n});\n```",
"tags": {
"group": "Miscellaneous"
},
"children": {}
}
}