@darthcav/ts-http-server 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +118 -0
- package/dist/__tests__/launcher.test.d.ts +2 -0
- package/dist/__tests__/launcher.test.d.ts.map +1 -0
- package/dist/__tests__/launcher.test.js +116 -0
- package/dist/__tests__/launcher.test.js.map +1 -0
- package/dist/defaults/defaultErrorHandler.d.ts +14 -0
- package/dist/defaults/defaultErrorHandler.d.ts.map +1 -0
- package/dist/defaults/defaultErrorHandler.js +58 -0
- package/dist/defaults/defaultErrorHandler.js.map +1 -0
- package/dist/defaults/defaultFastifyOptions.d.ts +18 -0
- package/dist/defaults/defaultFastifyOptions.d.ts.map +1 -0
- package/dist/defaults/defaultFastifyOptions.js +25 -0
- package/dist/defaults/defaultFastifyOptions.js.map +1 -0
- package/dist/defaults/defaultPlugins.d.ts +19 -0
- package/dist/defaults/defaultPlugins.d.ts.map +1 -0
- package/dist/defaults/defaultPlugins.js +84 -0
- package/dist/defaults/defaultPlugins.js.map +1 -0
- package/dist/defaults/defaultRoutes.d.ts +10 -0
- package/dist/defaults/defaultRoutes.d.ts.map +1 -0
- package/dist/defaults/defaultRoutes.js +38 -0
- package/dist/defaults/defaultRoutes.js.map +1 -0
- package/dist/defaults/getConsoleFastifyLogger.d.ts +15 -0
- package/dist/defaults/getConsoleFastifyLogger.d.ts.map +1 -0
- package/dist/defaults/getConsoleFastifyLogger.js +20 -0
- package/dist/defaults/getConsoleFastifyLogger.js.map +1 -0
- package/dist/hooks/onResponse.d.ts +18 -0
- package/dist/hooks/onResponse.d.ts.map +1 -0
- package/dist/hooks/onResponse.js +26 -0
- package/dist/hooks/onResponse.js.map +1 -0
- package/dist/hooks/preHandler.d.ts +18 -0
- package/dist/hooks/preHandler.d.ts.map +1 -0
- package/dist/hooks/preHandler.js +19 -0
- package/dist/hooks/preHandler.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/launcher.d.ts +17 -0
- package/dist/launcher.d.ts.map +1 -0
- package/dist/launcher.js +55 -0
- package/dist/launcher.js.map +1 -0
- package/dist/start.d.ts +2 -0
- package/dist/start.d.ts.map +1 -0
- package/dist/start.js +24 -0
- package/dist/start.js.map +1 -0
- package/dist/types.d.ts +46 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +78 -0
- package/src/__tests__/launcher.test.ts +136 -0
- package/src/defaults/defaultErrorHandler.ts +69 -0
- package/src/defaults/defaultFastifyOptions.ts +29 -0
- package/src/defaults/defaultPlugins.ts +90 -0
- package/src/defaults/defaultRoutes.ts +42 -0
- package/src/defaults/getConsoleFastifyLogger.ts +27 -0
- package/src/hooks/onResponse.ts +34 -0
- package/src/hooks/preHandler.ts +25 -0
- package/src/index.ts +28 -0
- package/src/launcher.ts +74 -0
- package/src/public/README.md +3 -0
- package/src/start.ts +30 -0
- package/src/types.ts +55 -0
- package/src/views/README.md +3 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { equal, ok } from "node:assert/strict"
|
|
2
|
+
import { after, before, describe, it } from "node:test"
|
|
3
|
+
import { setTimeout } from "node:timers/promises"
|
|
4
|
+
import FastifyAccepts from "@fastify/accepts"
|
|
5
|
+
import type { Logger } from "@logtape/logtape"
|
|
6
|
+
import type { RouteOptions } from "fastify"
|
|
7
|
+
import launcher from "../launcher.ts"
|
|
8
|
+
import type { FSTPlugin } from "../types.ts"
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Minimal test logger (no real I/O)
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
const noop = (): void => {}
|
|
15
|
+
const testLogger = {
|
|
16
|
+
category: ["test"],
|
|
17
|
+
info: noop,
|
|
18
|
+
error: noop,
|
|
19
|
+
warn: noop,
|
|
20
|
+
debug: noop,
|
|
21
|
+
getChild: () => testLogger,
|
|
22
|
+
} as unknown as Logger
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Minimal plugins and routes (no EJS/static required)
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
const plugins = new Map<string, FSTPlugin>([
|
|
29
|
+
["@fastify/accepts", { plugin: FastifyAccepts }],
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
const routes = new Map<string, RouteOptions>([
|
|
33
|
+
[
|
|
34
|
+
"JSON_OK",
|
|
35
|
+
{
|
|
36
|
+
method: "GET",
|
|
37
|
+
url: "/",
|
|
38
|
+
handler: async (_request, reply) => reply.send({ ok: true }),
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
[
|
|
42
|
+
"JSON_405",
|
|
43
|
+
{
|
|
44
|
+
method: ["POST", "PUT", "DELETE", "PATCH"],
|
|
45
|
+
url: "/",
|
|
46
|
+
handler: async (_request, reply) => {
|
|
47
|
+
reply.header("allow", "GET, HEAD")
|
|
48
|
+
return reply.status(405).send({ statusCode: 405 })
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
[
|
|
53
|
+
"ERROR_ROUTE",
|
|
54
|
+
{
|
|
55
|
+
method: "GET",
|
|
56
|
+
url: "/error",
|
|
57
|
+
handler: async () => {
|
|
58
|
+
throw new Error("test server error")
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
])
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// HTTP server suite
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
describe("launcher [HTTP]", () => {
|
|
69
|
+
const port = 19001
|
|
70
|
+
const base = `http://localhost:${port}`
|
|
71
|
+
let server: import("fastify").FastifyInstance
|
|
72
|
+
|
|
73
|
+
before(async () => {
|
|
74
|
+
server = launcher({
|
|
75
|
+
logger: testLogger,
|
|
76
|
+
locals: { port },
|
|
77
|
+
plugins,
|
|
78
|
+
routes,
|
|
79
|
+
opts: { disableRequestLogging: true },
|
|
80
|
+
})
|
|
81
|
+
await setTimeout(500)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
after(async () => {
|
|
85
|
+
await setTimeout(200)
|
|
86
|
+
await server.close()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it("GET / → 200 JSON", async () => {
|
|
90
|
+
const res = await fetch(`${base}/`)
|
|
91
|
+
equal(res.status, 200)
|
|
92
|
+
const body = (await res.json()) as { ok: boolean }
|
|
93
|
+
equal(body.ok, true)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("POST / → 405", async () => {
|
|
97
|
+
const res = await fetch(`${base}/`, { method: "POST" })
|
|
98
|
+
equal(res.status, 405)
|
|
99
|
+
equal(res.headers.get("allow"), "GET, HEAD")
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it("GET /missing → 404 JSON", async () => {
|
|
103
|
+
const res = await fetch(`${base}/missing`, {
|
|
104
|
+
headers: { accept: "application/json" },
|
|
105
|
+
})
|
|
106
|
+
equal(res.status, 404)
|
|
107
|
+
equal(
|
|
108
|
+
res.headers.get("content-type"),
|
|
109
|
+
"application/json; charset=utf-8",
|
|
110
|
+
)
|
|
111
|
+
const body = (await res.json()) as { statusCode: number }
|
|
112
|
+
ok(body.statusCode)
|
|
113
|
+
equal(body.statusCode, 404)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it("GET /missing → 404 plain text", async () => {
|
|
117
|
+
const res = await fetch(`${base}/missing`, {
|
|
118
|
+
headers: { accept: "text/plain" },
|
|
119
|
+
})
|
|
120
|
+
equal(res.status, 404)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it("GET /error → 500 JSON", async () => {
|
|
124
|
+
const res = await fetch(`${base}/error`, {
|
|
125
|
+
headers: { accept: "application/json" },
|
|
126
|
+
})
|
|
127
|
+
equal(res.status, 500)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it("GET /error → 500 plain text", async () => {
|
|
131
|
+
const res = await fetch(`${base}/error`, {
|
|
132
|
+
headers: { accept: "text/plain" },
|
|
133
|
+
})
|
|
134
|
+
equal(res.status, 500)
|
|
135
|
+
})
|
|
136
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Boom } from "@hapi/boom"
|
|
2
|
+
import type { FastifyError, FastifyReply, FastifyRequest } from "fastify"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fastify error handler supporting Boom errors and generic errors.
|
|
6
|
+
*
|
|
7
|
+
* Negotiates the response format via `@fastify/accepts`:
|
|
8
|
+
* - `text/html` — renders `_error.ejs` (requires `@fastify/view`).
|
|
9
|
+
* - `application/json` — JSON payload.
|
|
10
|
+
* - Otherwise — plain-text.
|
|
11
|
+
*
|
|
12
|
+
* For Boom errors the HTTP status code and payload come from `error.output`.
|
|
13
|
+
* For generic errors a 500 is used unless the reply already has a 4xx/5xx status.
|
|
14
|
+
*/
|
|
15
|
+
export default async function defaultErrorHandler(
|
|
16
|
+
error: FastifyError,
|
|
17
|
+
request: FastifyRequest,
|
|
18
|
+
reply: FastifyReply,
|
|
19
|
+
): Promise<void> {
|
|
20
|
+
const accept = request.accepts()
|
|
21
|
+
const boom = error as unknown as Boom
|
|
22
|
+
|
|
23
|
+
if (boom.isBoom) {
|
|
24
|
+
const payload = boom.output.payload
|
|
25
|
+
payload.message = boom.message
|
|
26
|
+
|
|
27
|
+
reply.status(payload.statusCode)
|
|
28
|
+
|
|
29
|
+
switch (accept.type(["html", "json"])) {
|
|
30
|
+
case "html":
|
|
31
|
+
return reply.type("text/html").view("_error", {
|
|
32
|
+
menu_name: "",
|
|
33
|
+
header: `HTTP error ${payload.statusCode} (${payload.message})`,
|
|
34
|
+
uri: request.url,
|
|
35
|
+
status: payload.statusCode,
|
|
36
|
+
message: payload.message,
|
|
37
|
+
})
|
|
38
|
+
case "json":
|
|
39
|
+
return reply.type("application/json").send(payload)
|
|
40
|
+
default:
|
|
41
|
+
return reply
|
|
42
|
+
.type("text/plain")
|
|
43
|
+
.send(`${payload.error} :: ${payload.message}`)
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
if (
|
|
47
|
+
!reply.statusCode ||
|
|
48
|
+
reply.statusCode < 400 ||
|
|
49
|
+
reply.statusCode > 599
|
|
50
|
+
) {
|
|
51
|
+
reply.status(500)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
switch (accept.type(["html", "json"])) {
|
|
55
|
+
case "html":
|
|
56
|
+
return reply.type("text/html").view("_error", {
|
|
57
|
+
menu_name: "",
|
|
58
|
+
header: `HTTP error ${reply.statusCode} (${error.message})`,
|
|
59
|
+
uri: request.url,
|
|
60
|
+
status: reply.statusCode,
|
|
61
|
+
message: error.message,
|
|
62
|
+
})
|
|
63
|
+
case "json":
|
|
64
|
+
return reply.type("application/json").send(error)
|
|
65
|
+
default:
|
|
66
|
+
return reply.type("text/plain").send(JSON.stringify(error))
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import type { Logger } from "@logtape/logtape"
|
|
3
|
+
import type { FastifyServerOptions } from "fastify"
|
|
4
|
+
import getConsoleFastifyLogger from "./getConsoleFastifyLogger.ts"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Returns base `FastifyServerOptions` used as defaults in {@link launcher}.
|
|
8
|
+
*
|
|
9
|
+
* Uses `getConsoleFastifyLogger` as `loggerInstance` so that all Fastify
|
|
10
|
+
* internal logs are routed through LogTape under `logger.category`.
|
|
11
|
+
* Built-in per-request logging is disabled in favor of the {@link preHandler}
|
|
12
|
+
* and {@link onResponse} hooks, which emit structured access log lines.
|
|
13
|
+
*
|
|
14
|
+
* Generates request IDs via `crypto.randomUUID()` and enables proxy trust.
|
|
15
|
+
* Callers may override any field by spreading their own options on top.
|
|
16
|
+
*
|
|
17
|
+
* @param logger - The application LogTape logger; its category is used as the
|
|
18
|
+
* base category for Fastify's own logger.
|
|
19
|
+
*/
|
|
20
|
+
export default function defaultFastifyOptions(
|
|
21
|
+
logger: Logger,
|
|
22
|
+
): FastifyServerOptions {
|
|
23
|
+
return {
|
|
24
|
+
genReqId: () => randomUUID(),
|
|
25
|
+
trustProxy: true,
|
|
26
|
+
disableRequestLogging: true,
|
|
27
|
+
loggerInstance: getConsoleFastifyLogger([...logger.category]),
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
import FastifyAccepts from "@fastify/accepts"
|
|
3
|
+
import FastifyCompress from "@fastify/compress"
|
|
4
|
+
import FastifyCors from "@fastify/cors"
|
|
5
|
+
import FastifyEtag from "@fastify/etag"
|
|
6
|
+
import FastifyHelmet from "@fastify/helmet"
|
|
7
|
+
import FastifyStatic from "@fastify/static"
|
|
8
|
+
import FastifyView from "@fastify/view"
|
|
9
|
+
import Ejs from "ejs"
|
|
10
|
+
import type { FSTPlugin, LauncherLocals } from "../types.ts"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Builds the default plugin map for use with `launcher`.
|
|
14
|
+
*
|
|
15
|
+
* Registers: `@fastify/accepts`, `@fastify/compress`,
|
|
16
|
+
* `@fastify/cors`, `@fastify/etag`, `@fastify/helmet`,
|
|
17
|
+
* `@fastify/view` (EJS), and `@fastify/static`.
|
|
18
|
+
*
|
|
19
|
+
* @param opts.locals - Application locals; `locals.pkg` is exposed as the
|
|
20
|
+
* default context for every EJS view.
|
|
21
|
+
* @param opts.baseDir - Optional base directory for resolving the `src/` folder; defaults to the parent of `import.meta.dirname`.
|
|
22
|
+
* @returns A `Map` of plugin names to plugin entries, suitable for passing as
|
|
23
|
+
* the `plugins` field of `LauncherOptions`.
|
|
24
|
+
*/
|
|
25
|
+
export default function defaultPlugins(opts: {
|
|
26
|
+
locals: LauncherLocals
|
|
27
|
+
baseDir?: string | null
|
|
28
|
+
}): Map<string, FSTPlugin> {
|
|
29
|
+
const { locals, baseDir = null } = opts
|
|
30
|
+
const plugins = new Map<string, FSTPlugin>()
|
|
31
|
+
const srcDir = baseDir
|
|
32
|
+
? join(baseDir, "src")
|
|
33
|
+
: join(import.meta.dirname, "..")
|
|
34
|
+
|
|
35
|
+
plugins.set("@fastify/accepts", {
|
|
36
|
+
plugin: FastifyAccepts,
|
|
37
|
+
})
|
|
38
|
+
plugins.set("@fastify/compress", {
|
|
39
|
+
plugin: FastifyCompress,
|
|
40
|
+
})
|
|
41
|
+
plugins.set("@fastify/cors", {
|
|
42
|
+
plugin: FastifyCors,
|
|
43
|
+
opts: { origin: true },
|
|
44
|
+
})
|
|
45
|
+
plugins.set("@fastify/etag", {
|
|
46
|
+
plugin: FastifyEtag,
|
|
47
|
+
opts: { algorithm: "sha1" },
|
|
48
|
+
})
|
|
49
|
+
plugins.set("@fastify/helmet", {
|
|
50
|
+
plugin: FastifyHelmet,
|
|
51
|
+
opts: {
|
|
52
|
+
global: true,
|
|
53
|
+
contentSecurityPolicy: {
|
|
54
|
+
directives: {
|
|
55
|
+
fontSrc: [
|
|
56
|
+
"'self'",
|
|
57
|
+
"https://fonts.googleapis.com/",
|
|
58
|
+
"https://fonts.gstatic.com/",
|
|
59
|
+
],
|
|
60
|
+
scriptSrc: [
|
|
61
|
+
"'self'",
|
|
62
|
+
"'unsafe-inline'",
|
|
63
|
+
"https://cdn.jsdelivr.net/",
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
hsts: {
|
|
68
|
+
maxAge: 31536000,
|
|
69
|
+
includeSubDomains: true,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
plugins.set("@fastify/view", {
|
|
74
|
+
plugin: FastifyView,
|
|
75
|
+
opts: {
|
|
76
|
+
engine: { ejs: Ejs },
|
|
77
|
+
root: join(srcDir, "views"),
|
|
78
|
+
defaultContext: { pkg: locals.pkg },
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
plugins.set("@fastify/static", {
|
|
82
|
+
plugin: FastifyStatic,
|
|
83
|
+
opts: {
|
|
84
|
+
root: join(srcDir, "public"),
|
|
85
|
+
prefix: "/",
|
|
86
|
+
},
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
return plugins
|
|
90
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { methodNotAllowed, notAcceptable } from "@hapi/boom"
|
|
2
|
+
import type { RouteOptions } from "fastify"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Returns the default route map used in {@link launcher}.
|
|
6
|
+
*
|
|
7
|
+
* Registers:
|
|
8
|
+
* - `GET /` — renders `index.ejs` for `text/html`, throws 406 otherwise.
|
|
9
|
+
* - `DELETE|PATCH|POST|PUT|OPTIONS /` — responds with 405 Method Not Allowed.
|
|
10
|
+
*/
|
|
11
|
+
export default function defaultRoutes(): Map<string, RouteOptions> {
|
|
12
|
+
const routes = new Map<string, RouteOptions>()
|
|
13
|
+
|
|
14
|
+
routes.set("INDEX", {
|
|
15
|
+
method: "GET",
|
|
16
|
+
url: "/",
|
|
17
|
+
exposeHeadRoute: true,
|
|
18
|
+
handler: async (request, reply) => {
|
|
19
|
+
const accept = request.accepts()
|
|
20
|
+
switch (accept.type(["html"])) {
|
|
21
|
+
case "html":
|
|
22
|
+
return reply.type("text/html").view("index.ejs", {
|
|
23
|
+
menu_name: "index",
|
|
24
|
+
header: "Welcome page",
|
|
25
|
+
})
|
|
26
|
+
default:
|
|
27
|
+
throw notAcceptable()
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
routes.set("INDEX_405", {
|
|
33
|
+
method: ["DELETE", "PATCH", "POST", "PUT", "OPTIONS"],
|
|
34
|
+
url: "/",
|
|
35
|
+
handler: async (_request, reply) => {
|
|
36
|
+
reply.header("allow", "GET, HEAD")
|
|
37
|
+
throw methodNotAllowed()
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
return routes
|
|
42
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getLogTapeFastifyLogger,
|
|
3
|
+
type PinoLevel,
|
|
4
|
+
type PinoLikeLogger,
|
|
5
|
+
} from "@logtape/fastify"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Returns a `PinoLikeLogger` for Fastify scoped to the given category.
|
|
9
|
+
*
|
|
10
|
+
* Assumes LogTape has already been configured by the caller (e.g. via
|
|
11
|
+
* `getConsoleLogger`). All records at or above `level` are forwarded
|
|
12
|
+
* by Fastify to the active LogTape sinks.
|
|
13
|
+
*
|
|
14
|
+
* @param name - Logger category array passed to `getLogTapeFastifyLogger`
|
|
15
|
+
* (e.g. `["my-app", "fastify"]`).
|
|
16
|
+
* @param level - Minimum Pino log level to pass through. Defaults to `"info"`.
|
|
17
|
+
* @returns A Pino-compatible logger backed by LogTape.
|
|
18
|
+
*/
|
|
19
|
+
export default function getConsoleFastifyLogger(
|
|
20
|
+
name: string[],
|
|
21
|
+
level: PinoLevel = "info",
|
|
22
|
+
): PinoLikeLogger {
|
|
23
|
+
return getLogTapeFastifyLogger({
|
|
24
|
+
category: name,
|
|
25
|
+
level,
|
|
26
|
+
})
|
|
27
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { FastifyReply, FastifyRequest } from "fastify"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fastify `onResponse` hook that logs completed request details:
|
|
5
|
+
*
|
|
6
|
+
* ```
|
|
7
|
+
* {ip} -- {method} {url} HTTP/{httpVersion} {status} {size} {elapsed}ms "{referrer}" "{userAgent}"
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* Intended to be registered via:
|
|
11
|
+
* ```ts
|
|
12
|
+
* fastify.addHook("onResponse", onResponse)
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* Uses `reply.log.info` so each log record is automatically correlated with
|
|
16
|
+
* the request ID assigned by Fastify.
|
|
17
|
+
*/
|
|
18
|
+
export default async function onResponse(
|
|
19
|
+
request: FastifyRequest,
|
|
20
|
+
reply: FastifyReply,
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
const contentLength = reply.getHeader("content-length")
|
|
23
|
+
const size = contentLength != null ? Number(contentLength) : "-"
|
|
24
|
+
|
|
25
|
+
if (reply.statusCode < 400) {
|
|
26
|
+
reply.log.info(
|
|
27
|
+
`${request.ip} -- ${request.method} ${request.url} HTTP/${request.raw.httpVersion} ${reply.statusCode} ${size} ${Math.round(reply.elapsedTime)}ms "${request.headers["referer"] ?? "-"}" "${request.headers["user-agent"] ?? "-"}"`,
|
|
28
|
+
)
|
|
29
|
+
} else {
|
|
30
|
+
reply.log.error(
|
|
31
|
+
`${request.ip} -- ${request.method} ${request.url} HTTP/${request.raw.httpVersion} ${reply.statusCode} ${size} ${Math.round(reply.elapsedTime)}ms "${request.headers["referer"] ?? "-"}" "${request.headers["user-agent"] ?? "-"}"`,
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { FastifyReply, FastifyRequest } from "fastify"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fastify `preHandler` hook that logs incoming request details:
|
|
5
|
+
*
|
|
6
|
+
* ```
|
|
7
|
+
* Incoming request: {method} {url} HTTP/{httpVersion} from {ip}
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* Intended to be registered via:
|
|
11
|
+
* ```ts
|
|
12
|
+
* fastify.addHook("preHandler", preHandler)
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* Uses `request.log.info` so each log record is automatically correlated with
|
|
16
|
+
* the request ID assigned by Fastify.
|
|
17
|
+
*/
|
|
18
|
+
export default async function preHandler(
|
|
19
|
+
request: FastifyRequest,
|
|
20
|
+
_reply: FastifyReply,
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
request.log.debug(
|
|
23
|
+
`Incoming request [${request.id}]: ${request.method} ${request.url} HTTP/${request.raw.httpVersion} from ${request.ip}`,
|
|
24
|
+
)
|
|
25
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A TypeScript HTTP server library built on Fastify for Node.js >= 25.
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import defaultErrorHandler from "./defaults/defaultErrorHandler.ts"
|
|
8
|
+
import defaultFastifyOptions from "./defaults/defaultFastifyOptions.ts"
|
|
9
|
+
import defaultPlugins from "./defaults/defaultPlugins.ts"
|
|
10
|
+
import defaultRoutes from "./defaults/defaultRoutes.ts"
|
|
11
|
+
import onResponse from "./hooks/onResponse.ts"
|
|
12
|
+
import preHandler from "./hooks/preHandler.ts"
|
|
13
|
+
import launcher from "./launcher.ts"
|
|
14
|
+
|
|
15
|
+
export type {
|
|
16
|
+
FSTPlugin,
|
|
17
|
+
LauncherLocals,
|
|
18
|
+
LauncherOptions,
|
|
19
|
+
} from "./types.ts"
|
|
20
|
+
export {
|
|
21
|
+
defaultErrorHandler,
|
|
22
|
+
defaultFastifyOptions,
|
|
23
|
+
defaultPlugins,
|
|
24
|
+
defaultRoutes,
|
|
25
|
+
launcher,
|
|
26
|
+
preHandler,
|
|
27
|
+
onResponse,
|
|
28
|
+
}
|
package/src/launcher.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import process, { env } from "node:process"
|
|
2
|
+
import { notFound } from "@hapi/boom"
|
|
3
|
+
import Fastify, { type FastifyInstance } from "fastify"
|
|
4
|
+
import defaultErrorHandler from "./defaults/defaultErrorHandler.ts"
|
|
5
|
+
import defaultFastifyOptions from "./defaults/defaultFastifyOptions.ts"
|
|
6
|
+
import { onResponse, preHandler } from "./index.ts"
|
|
7
|
+
import type { LauncherOptions } from "./types.ts"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Creates and starts a Fastify HTTP server with the given configuration.
|
|
11
|
+
*
|
|
12
|
+
* Steps performed:
|
|
13
|
+
* 1. Creates a `FastifyInstance` merging {@link defaultFastifyOptions} with `opts`.
|
|
14
|
+
* 2. Decorates the instance with `locals` and any extra `decorators`.
|
|
15
|
+
* 3. Registers all `plugins` and `routes`.
|
|
16
|
+
* 4. Sets a `notFound` handler (throws Boom 404) and {@link defaultErrorHandler}.
|
|
17
|
+
* 5. Registers {@link preHandler} and {@link onResponse} hooks.
|
|
18
|
+
* 6. Calls `fastify.listen()` and invokes the optional `done` callback.
|
|
19
|
+
*
|
|
20
|
+
* @returns The `FastifyInstance` (e.g. for use with `fastify.close()`).
|
|
21
|
+
*/
|
|
22
|
+
export default function launcher({
|
|
23
|
+
logger,
|
|
24
|
+
locals,
|
|
25
|
+
plugins,
|
|
26
|
+
routes,
|
|
27
|
+
decorators,
|
|
28
|
+
opts,
|
|
29
|
+
done,
|
|
30
|
+
}: LauncherOptions): FastifyInstance {
|
|
31
|
+
const host = locals?.host ?? "localhost"
|
|
32
|
+
const port = locals?.port ?? Number(env["CONTAINER_EXPOSE_PORT"] ?? 8888)
|
|
33
|
+
|
|
34
|
+
const fastify = Fastify({
|
|
35
|
+
...defaultFastifyOptions(logger),
|
|
36
|
+
...opts,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
fastify.decorate("locals", locals)
|
|
40
|
+
|
|
41
|
+
if (decorators instanceof Map) {
|
|
42
|
+
for (const [key, value] of decorators) {
|
|
43
|
+
fastify.decorate(key, value)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const value of plugins.values()) {
|
|
48
|
+
void fastify.register(value.plugin, value.opts ?? {})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const value of routes.values()) {
|
|
52
|
+
fastify.route(value)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
fastify.setNotFoundHandler(async (_request, _reply) => {
|
|
56
|
+
throw notFound()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
fastify.setErrorHandler(defaultErrorHandler)
|
|
60
|
+
|
|
61
|
+
// TODO: Add hook for `onRequestAbort`
|
|
62
|
+
fastify.addHook("preHandler", preHandler)
|
|
63
|
+
fastify.addHook("onResponse", onResponse)
|
|
64
|
+
|
|
65
|
+
fastify.listen({ host, port }, (error) => {
|
|
66
|
+
if (error) {
|
|
67
|
+
logger.error(`${error.message}`)
|
|
68
|
+
process.exit(1)
|
|
69
|
+
}
|
|
70
|
+
done?.()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
return fastify
|
|
74
|
+
}
|
package/src/start.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import process from "node:process"
|
|
2
|
+
import { getConsoleLogger, main } from "@darthcav/ts-utils"
|
|
3
|
+
import pkg from "../package.json" with { type: "json" }
|
|
4
|
+
import { defaultPlugins, defaultRoutes, launcher } from "./index.ts"
|
|
5
|
+
|
|
6
|
+
const logger = await getConsoleLogger(pkg.name, "info")
|
|
7
|
+
|
|
8
|
+
main(pkg.name, logger, false, () => {
|
|
9
|
+
const locals = { pkg }
|
|
10
|
+
const plugins = defaultPlugins({ locals })
|
|
11
|
+
const routes = defaultRoutes()
|
|
12
|
+
|
|
13
|
+
const fastify = launcher({ logger, locals, plugins, routes })
|
|
14
|
+
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
|
15
|
+
process.on(signal, async (signal) =>
|
|
16
|
+
fastify
|
|
17
|
+
.close()
|
|
18
|
+
.then(() => {
|
|
19
|
+
logger.error(
|
|
20
|
+
`Process interrupted and server closed. Received signal: ${signal}`,
|
|
21
|
+
)
|
|
22
|
+
process.exit(0)
|
|
23
|
+
})
|
|
24
|
+
.catch((error) => {
|
|
25
|
+
logger.error(`Server shutdown error: ${error}`)
|
|
26
|
+
process.exit(1)
|
|
27
|
+
}),
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
})
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Logger } from "@logtape/logtape"
|
|
2
|
+
import type {
|
|
3
|
+
FastifyPluginAsync,
|
|
4
|
+
FastifyPluginCallback,
|
|
5
|
+
FastifyPluginOptions,
|
|
6
|
+
FastifyServerOptions,
|
|
7
|
+
RouteOptions,
|
|
8
|
+
} from "fastify"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A plugin entry combining a Fastify plugin function with its options,
|
|
12
|
+
* stored in the plugins `Map` passed to {@link launcher}.
|
|
13
|
+
*/
|
|
14
|
+
export type FSTPlugin = {
|
|
15
|
+
/** The Fastify plugin function to register. */
|
|
16
|
+
// biome-ignore lint/suspicious/noExplicitAny: third-party plugins use varied option types
|
|
17
|
+
plugin: FastifyPluginCallback<any> | FastifyPluginAsync<any>
|
|
18
|
+
/** Optional options forwarded to the plugin on registration. */
|
|
19
|
+
opts?: FastifyPluginOptions
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Application locals decorated onto the Fastify instance and available
|
|
24
|
+
* throughout the request lifecycle.
|
|
25
|
+
*/
|
|
26
|
+
export type LauncherLocals = {
|
|
27
|
+
/** Package metadata (e.g. contents of `package.json`). */
|
|
28
|
+
pkg?: object
|
|
29
|
+
/** Hostname the server will bind to. */
|
|
30
|
+
host?: string
|
|
31
|
+
/** Port the server will listen on. */
|
|
32
|
+
port?: number
|
|
33
|
+
/** Any additional application-specific locals. */
|
|
34
|
+
[key: string]: unknown
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Options passed to the {@link launcher} function.
|
|
39
|
+
*/
|
|
40
|
+
export type LauncherOptions = {
|
|
41
|
+
/** Logger instance used for error and info output. */
|
|
42
|
+
logger: Logger
|
|
43
|
+
/** Application locals decorated onto the Fastify instance. */
|
|
44
|
+
locals: LauncherLocals
|
|
45
|
+
/** Map of named plugins to register. */
|
|
46
|
+
plugins: Map<string, FSTPlugin>
|
|
47
|
+
/** Map of named routes to register. */
|
|
48
|
+
routes: Map<string, RouteOptions>
|
|
49
|
+
/** Map of named decorators to add to the Fastify instance. */
|
|
50
|
+
decorators?: Map<string, unknown>
|
|
51
|
+
/** Optional Fastify server options (merged over {@link defaultFastifyOptions}). */
|
|
52
|
+
opts?: FastifyServerOptions
|
|
53
|
+
/** Optional callback invoked once the server is listening. */
|
|
54
|
+
done?: () => void
|
|
55
|
+
}
|