@nifrajs/middleware 0.1.0-alpha.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.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -0
  3. package/dist/_utils.d.ts +27 -0
  4. package/dist/_utils.d.ts.map +1 -0
  5. package/dist/_utils.js +141 -0
  6. package/dist/_utils.js.map +1 -0
  7. package/dist/basic-auth.d.ts +27 -0
  8. package/dist/basic-auth.d.ts.map +1 -0
  9. package/dist/basic-auth.js +64 -0
  10. package/dist/basic-auth.js.map +1 -0
  11. package/dist/body-limit.d.ts +19 -0
  12. package/dist/body-limit.d.ts.map +1 -0
  13. package/dist/body-limit.js +42 -0
  14. package/dist/body-limit.js.map +1 -0
  15. package/dist/cache-control.d.ts +23 -0
  16. package/dist/cache-control.d.ts.map +1 -0
  17. package/dist/cache-control.js +35 -0
  18. package/dist/cache-control.js.map +1 -0
  19. package/dist/cache.d.ts +60 -0
  20. package/dist/cache.d.ts.map +1 -0
  21. package/dist/cache.js +224 -0
  22. package/dist/cache.js.map +1 -0
  23. package/dist/combine.d.ts +7 -0
  24. package/dist/combine.d.ts.map +1 -0
  25. package/dist/combine.js +21 -0
  26. package/dist/combine.js.map +1 -0
  27. package/dist/compression.d.ts +29 -0
  28. package/dist/compression.d.ts.map +1 -0
  29. package/dist/compression.js +105 -0
  30. package/dist/compression.js.map +1 -0
  31. package/dist/context-storage.d.ts +14 -0
  32. package/dist/context-storage.d.ts.map +1 -0
  33. package/dist/context-storage.js +30 -0
  34. package/dist/context-storage.js.map +1 -0
  35. package/dist/cors.d.ts +25 -0
  36. package/dist/cors.d.ts.map +1 -0
  37. package/dist/cors.js +75 -0
  38. package/dist/cors.js.map +1 -0
  39. package/dist/csrf.d.ts +23 -0
  40. package/dist/csrf.d.ts.map +1 -0
  41. package/dist/csrf.js +68 -0
  42. package/dist/csrf.js.map +1 -0
  43. package/dist/etag.d.ts +14 -0
  44. package/dist/etag.d.ts.map +1 -0
  45. package/dist/etag.js +95 -0
  46. package/dist/etag.js.map +1 -0
  47. package/dist/healthcheck.d.ts +25 -0
  48. package/dist/healthcheck.d.ts.map +1 -0
  49. package/dist/healthcheck.js +41 -0
  50. package/dist/healthcheck.js.map +1 -0
  51. package/dist/idempotency.d.ts +83 -0
  52. package/dist/idempotency.d.ts.map +1 -0
  53. package/dist/idempotency.js +217 -0
  54. package/dist/idempotency.js.map +1 -0
  55. package/dist/index.d.ts +33 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +33 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/ip-restriction.d.ts +21 -0
  60. package/dist/ip-restriction.d.ts.map +1 -0
  61. package/dist/ip-restriction.js +161 -0
  62. package/dist/ip-restriction.js.map +1 -0
  63. package/dist/jwt.d.ts +82 -0
  64. package/dist/jwt.d.ts.map +1 -0
  65. package/dist/jwt.js +327 -0
  66. package/dist/jwt.js.map +1 -0
  67. package/dist/language.d.ts +20 -0
  68. package/dist/language.d.ts.map +1 -0
  69. package/dist/language.js +94 -0
  70. package/dist/language.js.map +1 -0
  71. package/dist/logger.d.ts +19 -0
  72. package/dist/logger.d.ts.map +1 -0
  73. package/dist/logger.js +28 -0
  74. package/dist/logger.js.map +1 -0
  75. package/dist/method-override.d.ts +28 -0
  76. package/dist/method-override.d.ts.map +1 -0
  77. package/dist/method-override.js +67 -0
  78. package/dist/method-override.js.map +1 -0
  79. package/dist/openapi.d.ts +83 -0
  80. package/dist/openapi.d.ts.map +1 -0
  81. package/dist/openapi.js +100 -0
  82. package/dist/openapi.js.map +1 -0
  83. package/dist/powered-by.d.ts +15 -0
  84. package/dist/powered-by.d.ts.map +1 -0
  85. package/dist/powered-by.js +23 -0
  86. package/dist/powered-by.js.map +1 -0
  87. package/dist/pretty-json.d.ts +18 -0
  88. package/dist/pretty-json.d.ts.map +1 -0
  89. package/dist/pretty-json.js +88 -0
  90. package/dist/pretty-json.js.map +1 -0
  91. package/dist/rate-limit.d.ts +92 -0
  92. package/dist/rate-limit.d.ts.map +1 -0
  93. package/dist/rate-limit.js +172 -0
  94. package/dist/rate-limit.js.map +1 -0
  95. package/dist/request-id.d.ts +18 -0
  96. package/dist/request-id.d.ts.map +1 -0
  97. package/dist/request-id.js +21 -0
  98. package/dist/request-id.js.map +1 -0
  99. package/dist/security-headers.d.ts +23 -0
  100. package/dist/security-headers.d.ts.map +1 -0
  101. package/dist/security-headers.js +36 -0
  102. package/dist/security-headers.js.map +1 -0
  103. package/dist/timing.d.ts +25 -0
  104. package/dist/timing.d.ts.map +1 -0
  105. package/dist/timing.js +101 -0
  106. package/dist/timing.js.map +1 -0
  107. package/dist/token-auth.d.ts +65 -0
  108. package/dist/token-auth.d.ts.map +1 -0
  109. package/dist/token-auth.js +90 -0
  110. package/dist/token-auth.js.map +1 -0
  111. package/dist/trailing-slash.d.ts +23 -0
  112. package/dist/trailing-slash.d.ts.map +1 -0
  113. package/dist/trailing-slash.js +70 -0
  114. package/dist/trailing-slash.js.map +1 -0
  115. package/package.json +68 -0
  116. package/src/_utils.ts +163 -0
  117. package/src/basic-auth.ts +109 -0
  118. package/src/body-limit.ts +53 -0
  119. package/src/cache-control.ts +45 -0
  120. package/src/cache.ts +277 -0
  121. package/src/combine.ts +24 -0
  122. package/src/compression.ts +111 -0
  123. package/src/context-storage.ts +36 -0
  124. package/src/cors.ts +90 -0
  125. package/src/csrf.ts +102 -0
  126. package/src/etag.ts +99 -0
  127. package/src/healthcheck.ts +59 -0
  128. package/src/idempotency.ts +313 -0
  129. package/src/index.ts +110 -0
  130. package/src/ip-restriction.ts +187 -0
  131. package/src/jwt.ts +441 -0
  132. package/src/language.ts +109 -0
  133. package/src/logger.ts +44 -0
  134. package/src/method-override.ts +87 -0
  135. package/src/openapi.ts +174 -0
  136. package/src/powered-by.ts +31 -0
  137. package/src/pretty-json.ts +97 -0
  138. package/src/rate-limit.ts +248 -0
  139. package/src/request-id.ts +30 -0
  140. package/src/security-headers.ts +51 -0
  141. package/src/timing.ts +133 -0
  142. package/src/token-auth.ts +172 -0
  143. package/src/trailing-slash.ts +94 -0
@@ -0,0 +1,109 @@
1
+ import { definePlugin } from "@nifrajs/core"
2
+
3
+ export interface LanguageMatch {
4
+ readonly language: string
5
+ readonly matched: "exact" | "base" | "wildcard" | "default"
6
+ }
7
+
8
+ export interface LanguageOptions<L extends readonly string[]> {
9
+ readonly supported: L
10
+ readonly defaultLanguage: L[number]
11
+ /** Emit `Content-Language`. Default `true`. */
12
+ readonly header?: boolean
13
+ }
14
+
15
+ interface Range {
16
+ readonly tag: string
17
+ readonly q: number
18
+ readonly order: number
19
+ }
20
+
21
+ function parseAcceptLanguage(header: string | null): Range[] {
22
+ if (header === null) return []
23
+ const ranges: Range[] = []
24
+ let order = 0
25
+ for (const part of header.split(",")) {
26
+ const pieces = part.split(";").map((p) => p.trim())
27
+ const tag = (pieces[0] ?? "").toLowerCase()
28
+ if (tag === "") continue
29
+ let q = 1
30
+ for (const piece of pieces.slice(1)) {
31
+ const [name, raw] = piece.split("=")
32
+ if (name?.toLowerCase() !== "q" || raw === undefined) continue
33
+ const parsed = Number(raw)
34
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
35
+ q = 0
36
+ } else {
37
+ q = parsed
38
+ }
39
+ }
40
+ if (q > 0) ranges.push({ tag, q, order })
41
+ order += 1
42
+ }
43
+ return ranges.sort((a, b) => b.q - a.q || a.order - b.order)
44
+ }
45
+
46
+ function baseOf(tag: string): string {
47
+ const dash = tag.indexOf("-")
48
+ return dash === -1 ? tag : tag.slice(0, dash)
49
+ }
50
+
51
+ /**
52
+ * Pick the best supported language for an `Accept-Language` header. Exact tags win, then compatible
53
+ * base-language matches, then `*`, then the configured default.
54
+ */
55
+ export function pickLanguage<const L extends readonly string[]>(
56
+ header: string | null,
57
+ supported: L,
58
+ defaultLanguage: L[number],
59
+ ): LanguageMatch {
60
+ if (supported.length === 0) throw new Error("pickLanguage: supported must not be empty")
61
+ if (!supported.includes(defaultLanguage)) {
62
+ throw new Error("pickLanguage: defaultLanguage must be in supported")
63
+ }
64
+ const lower = new Map(supported.map((value) => [value.toLowerCase(), value] as const))
65
+ const ranges = parseAcceptLanguage(header)
66
+ for (const range of ranges) {
67
+ if (range.tag === "*") return { language: defaultLanguage, matched: "wildcard" }
68
+ const exact = lower.get(range.tag)
69
+ if (exact !== undefined) return { language: exact, matched: "exact" }
70
+ const requestedBase = baseOf(range.tag)
71
+ for (const value of supported) {
72
+ const candidate = value.toLowerCase()
73
+ if (baseOf(candidate) === requestedBase) return { language: value, matched: "base" }
74
+ }
75
+ }
76
+ return { language: defaultLanguage, matched: "default" }
77
+ }
78
+
79
+ /**
80
+ * Derives `c.language` from `Accept-Language` and emits `Content-Language` by default.
81
+ */
82
+ export function language<const L extends readonly string[]>(options: LanguageOptions<L>) {
83
+ const { supported, defaultLanguage } = options
84
+ if (supported.length === 0) throw new Error("language: supported must not be empty")
85
+ if (!supported.includes(defaultLanguage)) {
86
+ throw new Error("language: defaultLanguage must be in supported")
87
+ }
88
+ const emitHeader = options.header !== false
89
+ const matches = new WeakMap<Request, LanguageMatch>()
90
+
91
+ return definePlugin("language", (app) =>
92
+ app
93
+ .derive((c) => {
94
+ const match = pickLanguage(c.req.headers.get("accept-language"), supported, defaultLanguage)
95
+ matches.set(c.req, match)
96
+ return { language: match.language, languageMatch: match }
97
+ })
98
+ .onResponse((res, req) => {
99
+ if (!emitHeader) return res
100
+ const match = matches.get(req)
101
+ if (match === undefined) return res
102
+ matches.delete(req)
103
+ if (res.headers.has("content-language")) return res
104
+ const headers = new Headers(res.headers)
105
+ headers.set("content-language", match.language)
106
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
107
+ }),
108
+ )
109
+ }
package/src/logger.ts ADDED
@@ -0,0 +1,44 @@
1
+ import { definePlugin } from "@nifrajs/core"
2
+
3
+ /** Structured fields logged per request. */
4
+ export interface RequestLogFields {
5
+ readonly method: string
6
+ readonly path: string
7
+ readonly status: number
8
+ /** Wall-clock duration in ms (0 if the start couldn't be paired). */
9
+ readonly ms: number
10
+ }
11
+
12
+ export interface LoggerOptions {
13
+ /** Sink for each line. Default: `console.log(JSON.stringify(fields))`. Route to your own logger here. */
14
+ readonly log?: (fields: RequestLogFields) => void
15
+ }
16
+
17
+ /**
18
+ * A {@link definePlugin} plugin that logs one structured line per request — method, path, status,
19
+ * and duration — via `onRequest`/`onResponse` (so it covers 404s and errors too). The start time is
20
+ * paired to the request through a `WeakMap` (no per-request allocation leak). Idempotent.
21
+ */
22
+ export function logger(options: LoggerOptions = {}) {
23
+ // A request logger's whole job is to log; the default writes JSON to stdout, routable via `log`.
24
+ const sink = options.log ?? ((fields: RequestLogFields) => console.log(JSON.stringify(fields)))
25
+ const starts = new WeakMap<Request, number>()
26
+ return definePlugin("logger", (app) =>
27
+ app
28
+ .onRequest((req) => {
29
+ starts.set(req, performance.now())
30
+ return undefined
31
+ })
32
+ .onResponse((res, req) => {
33
+ const start = starts.get(req)
34
+ starts.delete(req)
35
+ sink({
36
+ method: req.method,
37
+ path: new URL(req.url).pathname,
38
+ status: res.status,
39
+ ms: start === undefined ? 0 : Math.round(performance.now() - start),
40
+ })
41
+ return res
42
+ }),
43
+ )
44
+ }
@@ -0,0 +1,87 @@
1
+ import { definePlugin, METHODS, type Method } from "@nifrajs/core"
2
+ import { jsonError } from "./_utils.ts"
3
+
4
+ export interface MethodOverrideOptions {
5
+ /**
6
+ * Header carrying the desired method. Default `"x-http-method-override"`.
7
+ * Set `false` to disable header-based override.
8
+ */
9
+ readonly header?: string | false
10
+ /**
11
+ * Query parameter carrying the desired method. Disabled by default because query-tunneled writes are
12
+ * easier to trigger accidentally from links/forms. Use only with CSRF protection on browser routes.
13
+ */
14
+ readonly query?: string | false
15
+ /** Original request methods allowed to tunnel. Default `["POST"]`. */
16
+ readonly methods?: readonly string[]
17
+ /** Target methods accepted from the override source. Default `["PUT", "PATCH", "DELETE"]`. */
18
+ readonly allowed?: readonly Method[]
19
+ /** Invalid override values reject with `400` by default. Use `"ignore"` for legacy clients. */
20
+ readonly onInvalid?: "reject" | "ignore"
21
+ }
22
+
23
+ const SUPPORTED = new Set<string>(METHODS)
24
+ const DEFAULT_ALLOWED = ["PUT", "PATCH", "DELETE"] as const
25
+
26
+ function normalizeMethod(value: string): Method | null {
27
+ const method = value.trim().toUpperCase()
28
+ if (!SUPPORTED.has(method)) return null
29
+ return method as Method
30
+ }
31
+
32
+ function singleQueryValue(req: Request, name: string): string | null | false {
33
+ const values = new URL(req.url).searchParams.getAll(name)
34
+ if (values.length === 0) return null
35
+ if (values.length > 1) return false
36
+ return values[0] ?? null
37
+ }
38
+
39
+ function replacement(req: Request, method: Method): Request {
40
+ return new Request(req, { method })
41
+ }
42
+
43
+ /**
44
+ * HTTP method override for clients that can only send `POST`. The middleware rewrites the request
45
+ * before routing, so handlers, validation, and response hooks all see the overridden method.
46
+ *
47
+ * Header override is enabled by default; query override is opt-in. Form-body override is deliberately
48
+ * not implemented because reading the body before routing would consume or buffer it for every write.
49
+ */
50
+ export function methodOverride(options: MethodOverrideOptions = {}) {
51
+ const header = options.header === undefined ? "x-http-method-override" : options.header
52
+ const query = options.query ?? false
53
+ const sourceMethods = new Set((options.methods ?? ["POST"]).map((m) => m.toUpperCase()))
54
+ const allowed = new Set<Method>(options.allowed ?? DEFAULT_ALLOWED)
55
+ const rejectInvalid = options.onInvalid !== "ignore"
56
+
57
+ if (header !== false && header.trim() === "") throw new Error("methodOverride: header is empty")
58
+ if (query !== false && query.trim() === "") throw new Error("methodOverride: query is empty")
59
+
60
+ return definePlugin("methodOverride", (app) =>
61
+ app.onRequest((req) => {
62
+ if (!sourceMethods.has(req.method.toUpperCase())) return undefined
63
+
64
+ const values: string[] = []
65
+ if (header !== false) {
66
+ const value = req.headers.get(header)
67
+ if (value !== null) values.push(value)
68
+ }
69
+ if (query !== false) {
70
+ const value = singleQueryValue(req, query)
71
+ if (value === false)
72
+ return rejectInvalid ? jsonError(400, "invalid_method_override") : undefined
73
+ if (value !== null) values.push(value)
74
+ }
75
+ if (values.length === 0) return undefined
76
+ if (values.length > 1 && new Set(values.map((v) => v.trim().toUpperCase())).size > 1) {
77
+ return rejectInvalid ? jsonError(400, "invalid_method_override") : undefined
78
+ }
79
+
80
+ const method = normalizeMethod(values[0] ?? "")
81
+ if (method === null || !allowed.has(method)) {
82
+ return rejectInvalid ? jsonError(400, "invalid_method_override") : undefined
83
+ }
84
+ return method === req.method ? undefined : replacement(req, method)
85
+ }),
86
+ )
87
+ }
package/src/openapi.ts ADDED
@@ -0,0 +1,174 @@
1
+ import { definePlugin } from "@nifrajs/core"
2
+ // The OpenAPI generator only — `@nifrajs/schema/openapi` is pure (no TypeBox runtime), so the openapi()
3
+ // plugin gets full `t`-schema introspection without pulling the `t` builder into every consumer.
4
+ import { toOpenAPI } from "@nifrajs/schema/openapi"
5
+
6
+ /** A registered route as seen by {@link buildOpenApiDocument} — structurally a `@nifrajs/core`
7
+ * `RouteDescriptor` (so `app.routes()` is passed straight through). */
8
+ export interface RouteLike {
9
+ readonly method: string
10
+ readonly path: string
11
+ // `| undefined` (not just `?`) so a core `RouteDescriptor` — whose `schema` is a *required*
12
+ // `RouteSchema | undefined` — is assignable under `exactOptionalPropertyTypes`.
13
+ readonly schema?:
14
+ | { readonly body?: unknown; readonly query?: unknown; readonly response?: unknown }
15
+ | undefined
16
+ }
17
+
18
+ export interface OpenApiInfo {
19
+ readonly title?: string
20
+ readonly version?: string
21
+ readonly description?: string
22
+ }
23
+
24
+ export interface OpenApiServer {
25
+ readonly url: string
26
+ readonly description?: string
27
+ }
28
+
29
+ export interface OpenApiTag {
30
+ readonly name: string
31
+ readonly description?: string
32
+ }
33
+
34
+ /** A security requirement: scheme name → required scopes (`[]` = no scopes). */
35
+ export type SecurityRequirement = Readonly<Record<string, readonly string[]>>
36
+
37
+ /** Scalar API-reference UI options. */
38
+ export interface OpenApiUiOptions {
39
+ /** Where to serve the UI page. Default `"/reference"`. */
40
+ readonly path?: string
41
+ /** Page title. Defaults to `info.title`. */
42
+ readonly title?: string
43
+ /** The Scalar script URL (loaded from a CDN). Default jsDelivr's `@scalar/api-reference`. */
44
+ readonly cdn?: string
45
+ }
46
+
47
+ export interface OpenApiOptions {
48
+ readonly info?: OpenApiInfo
49
+ readonly servers?: readonly OpenApiServer[]
50
+ /** Tag definitions (top-level `tags`). Reference them from an operation via `operations`. */
51
+ readonly tags?: readonly OpenApiTag[]
52
+ /** Reusable security schemes → `components.securitySchemes` (e.g. `{ bearer: { type: "http", scheme: "bearer" } }`). */
53
+ readonly securitySchemes?: Readonly<Record<string, Record<string, unknown>>>
54
+ /** Document-wide security requirement; override per-operation via `operations`. */
55
+ readonly security?: readonly SecurityRequirement[]
56
+ /** Where the plugin serves the document. Default `"/openapi.json"`. */
57
+ readonly path?: string
58
+ /** Exclude routes from the document (the doc path itself is always excluded). */
59
+ readonly exclude?: (route: { readonly method: string; readonly path: string }) => boolean
60
+ /** Per-operation overrides keyed by `"GET /users/:id"`, shallow-merged over the generated skeleton —
61
+ * the escape hatch for rich request/response schemas, tags, and per-op security (Standard Schema
62
+ * can't be introspected). */
63
+ readonly operations?: Readonly<Record<string, Record<string, unknown>>>
64
+ /** Also serve a Scalar API-reference UI page rendering the spec (`true` → `/reference`). */
65
+ readonly ui?: boolean | OpenApiUiOptions
66
+ }
67
+
68
+ /**
69
+ * Build an OpenAPI 3.1 document from a route list. Delegates to `@nifrajs/schema`'s `toOpenAPI`, so a
70
+ * route validated with `t` (TypeBox) emits full field-level request/query/response schemas plus
71
+ * `$ref`-reused `components.schemas`; a BYO Standard Schema (zod/valibot/arktype) exposes no portable
72
+ * JSON-Schema form, so its body/response is omitted (supply it via `options.operations`). Path params,
73
+ * wildcards, tags, security, and servers always emit. Exported so you can also generate the doc at
74
+ * build time (write it to disk in CI) without booting the server.
75
+ */
76
+ export function buildOpenApiDocument(
77
+ routes: readonly RouteLike[],
78
+ options: OpenApiOptions = {},
79
+ ): Record<string, unknown> {
80
+ const docPath = options.path ?? "/openapi.json"
81
+ // toOpenAPI has no exclude hook, so filter here: never document the doc endpoint itself, then apply
82
+ // the caller's exclude predicate.
83
+ const included = routes.filter(
84
+ (route) =>
85
+ route.path !== docPath &&
86
+ options.exclude?.({ method: route.method, path: route.path }) !== true,
87
+ )
88
+ // toOpenAPI duck-types an "app" by a `routes()` method (its `isApp`); this shim lets the runtime
89
+ // plugin reuse the rich generator over a plain route list without holding a live Server reference.
90
+ // The cast is required because toOpenAPI's parameter is a nominal `ContractShape | Server` union,
91
+ // and the shim only satisfies it structurally.
92
+ const appShim = { routes: () => included } as unknown as Parameters<typeof toOpenAPI>[0]
93
+ return toOpenAPI(appShim, {
94
+ title: options.info?.title ?? "nifra API",
95
+ version: options.info?.version ?? "0.0.0",
96
+ ...(options.info?.description !== undefined ? { description: options.info.description } : {}),
97
+ ...(options.servers !== undefined ? { servers: options.servers } : {}),
98
+ ...(options.tags !== undefined ? { tags: options.tags } : {}),
99
+ ...(options.security !== undefined ? { security: options.security } : {}),
100
+ ...(options.securitySchemes !== undefined ? { securitySchemes: options.securitySchemes } : {}),
101
+ ...(options.operations !== undefined ? { operations: options.operations } : {}),
102
+ }) as unknown as Record<string, unknown>
103
+ }
104
+
105
+ const SCALAR_CDN = "https://cdn.jsdelivr.net/npm/@scalar/api-reference"
106
+
107
+ /**
108
+ * Minimal HTML that mounts the Scalar API reference against a spec URL. The interpolated values are
109
+ * dev-provided config (not request input), but they're still escaped so a stray quote or `<` can't
110
+ * break the markup.
111
+ */
112
+ function scalarPage(specUrl: string, title: string, cdn: string): string {
113
+ const attr = (s: string): string => s.replace(/&/g, "&amp;").replace(/"/g, "&quot;")
114
+ const text = (s: string): string => s.replace(/&/g, "&amp;").replace(/</g, "&lt;")
115
+ return `<!doctype html>
116
+ <html>
117
+ <head>
118
+ <title>${text(title)}</title>
119
+ <meta charset="utf-8" />
120
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
121
+ </head>
122
+ <body>
123
+ <script id="api-reference" data-url="${attr(specUrl)}"></script>
124
+ <script src="${attr(cdn)}"></script>
125
+ </body>
126
+ </html>`
127
+ }
128
+
129
+ /**
130
+ * Serve an OpenAPI 3.1 document (a structural subset — see {@link buildOpenApiDocument}) at
131
+ * `options.path` (default `/openapi.json`), generated from the app's registered routes. Generation is
132
+ * **lazy + memoized**: it reads `app.routes()` on the first request, by which point every route is
133
+ * registered — so the plugin's own position in the chain doesn't matter.
134
+ *
135
+ * Pass `ui: true` (or `ui: { path, title, cdn }`) to also serve a Scalar API-reference page (default
136
+ * `/reference`) that renders the spec. The page loads Scalar from a CDN; pin/self-host via `ui.cdn`,
137
+ * and remember to allow that origin if you ship a `script-src` Content-Security-Policy.
138
+ *
139
+ * ```ts
140
+ * app.use(openapi({ info: { title: "My API", version: "1.0.0" }, ui: true }))
141
+ * ```
142
+ */
143
+ export function openapi(options: OpenApiOptions = {}) {
144
+ const docPath = options.path ?? "/openapi.json"
145
+ const ui: OpenApiUiOptions | undefined =
146
+ options.ui === true ? {} : options.ui === false ? undefined : options.ui
147
+ const uiPath = ui !== undefined ? (ui.path ?? "/reference") : undefined
148
+ let cached: string | undefined
149
+ let uiCached: string | undefined
150
+ return definePlugin("openapi", (app) => {
151
+ // Capture `app` (the same mutable builder every later `.get()`/`.post()` registers on) and read its
152
+ // routes lazily, at request time — so all routes exist regardless of where the plugin sits.
153
+ app.register("GET", docPath, undefined, () => {
154
+ cached ??= JSON.stringify(
155
+ buildOpenApiDocument(app.routes(), {
156
+ ...options,
157
+ path: docPath,
158
+ // Don't document the UI page itself (the doc path is already excluded by buildOpenApiDocument).
159
+ exclude: (route) => route.path === uiPath || options.exclude?.(route) === true,
160
+ }),
161
+ )
162
+ return new Response(cached, { headers: { "content-type": "application/json" } })
163
+ })
164
+ if (ui !== undefined && uiPath !== undefined) {
165
+ const title = ui.title ?? options.info?.title ?? "nifra API"
166
+ const cdn = ui.cdn ?? SCALAR_CDN
167
+ app.register("GET", uiPath, undefined, () => {
168
+ uiCached ??= scalarPage(docPath, title, cdn)
169
+ return new Response(uiCached, { headers: { "content-type": "text/html; charset=utf-8" } })
170
+ })
171
+ }
172
+ return app
173
+ })
174
+ }
@@ -0,0 +1,31 @@
1
+ import type { Middleware } from "@nifrajs/core"
2
+ import { withHeaders } from "./_utils.ts"
3
+
4
+ export interface PoweredByOptions {
5
+ /** Header name. Default `"x-powered-by"`. */
6
+ readonly header?: string
7
+ /** Header value. Default `"Nifra"`. */
8
+ readonly value?: string
9
+ /** Do not overwrite an existing header by default. */
10
+ readonly respectExisting?: boolean
11
+ }
12
+
13
+ /**
14
+ * Opt-in `X-Powered-By` style header. Nifra does not emit this by default; use it only when you want a
15
+ * public framework/product marker.
16
+ */
17
+ export function poweredBy(options: PoweredByOptions = {}): Middleware {
18
+ const header = options.header ?? "x-powered-by"
19
+ const value = options.value ?? "Nifra"
20
+ const respectExisting = options.respectExisting !== false
21
+ if (header.trim() === "") throw new Error("poweredBy: header is empty")
22
+ if (/[\r\n]/.test(value)) throw new Error("poweredBy: value contains a newline")
23
+
24
+ return {
25
+ name: "powered-by",
26
+ onResponse(res) {
27
+ if (respectExisting && res.headers.has(header)) return res
28
+ return withHeaders(res, (headers) => headers.set(header, value))
29
+ },
30
+ }
31
+ }
@@ -0,0 +1,97 @@
1
+ import { definePlugin } from "@nifrajs/core"
2
+
3
+ export interface PrettyJsonOptions {
4
+ /** JSON indentation spaces. Default `2`. */
5
+ readonly spaces?: number
6
+ /** Maximum response bytes to inspect. Default `1_000_000`; larger responses pass through. */
7
+ readonly maxBytes?: number
8
+ /** Append a final newline. Default `true`. */
9
+ readonly newline?: boolean
10
+ /** Optional query toggle. When set, pretty printing runs only when the parameter is present. */
11
+ readonly query?: string | false
12
+ /** Only pretty-print matching requests. Default `true`. */
13
+ readonly enabled?: boolean | ((request: Request) => boolean)
14
+ }
15
+
16
+ const JSON_TYPE = /^(?:application\/json|[^/]+\/[^;]+\+json)(?:\s*;|$)/i
17
+
18
+ function isJson(contentType: string): boolean {
19
+ return JSON_TYPE.test(contentType)
20
+ }
21
+
22
+ function parseLength(value: string | null): number | undefined {
23
+ if (value === null) return undefined
24
+ if (!/^(?:0|[1-9]\d*)$/.test(value)) return undefined
25
+ return Number(value)
26
+ }
27
+
28
+ async function readTextCapped(res: Response, maxBytes: number): Promise<string | null> {
29
+ const body = res.clone().body
30
+ if (body === null) return null
31
+ const reader = body.getReader()
32
+ const decoder = new TextDecoder()
33
+ let total = 0
34
+ let text = ""
35
+ try {
36
+ while (true) {
37
+ const { done, value } = await reader.read()
38
+ if (done) return text + decoder.decode()
39
+ total += value.byteLength
40
+ if (total > maxBytes) {
41
+ await reader.cancel()
42
+ return null
43
+ }
44
+ text += decoder.decode(value, { stream: true })
45
+ }
46
+ } catch {
47
+ return null
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Pretty-print JSON responses for debugging and developer-facing APIs. It only touches JSON content,
53
+ * skips encoded responses, caps inspection size, and leaves invalid JSON untouched.
54
+ */
55
+ export function prettyJson(options: PrettyJsonOptions = {}) {
56
+ const spaces = options.spaces ?? 2
57
+ if (!Number.isInteger(spaces) || spaces < 0 || spaces > 10) {
58
+ throw new Error("prettyJson: spaces must be an integer from 0 to 10")
59
+ }
60
+ const maxBytes = options.maxBytes ?? 1_000_000
61
+ if (!Number.isInteger(maxBytes) || maxBytes < 0) {
62
+ throw new Error("prettyJson: maxBytes must be a non-negative integer")
63
+ }
64
+ const newline = options.newline !== false
65
+ const query = options.query ?? false
66
+ if (query !== false && query.trim() === "") throw new Error("prettyJson: query is empty")
67
+ const enabled = options.enabled ?? true
68
+ const isEnabled = typeof enabled === "function" ? enabled : enabled ? () => true : () => false
69
+
70
+ return definePlugin("prettyJson", (app) =>
71
+ app.onResponse(async (res, req) => {
72
+ if (!isEnabled(req)) return res
73
+ if (query !== false && !new URL(req.url).searchParams.has(query)) return res
74
+ if (res.body === null || res.headers.has("content-encoding")) return res
75
+ if (!isJson(res.headers.get("content-type") ?? "")) return res
76
+ const declared = parseLength(res.headers.get("content-length"))
77
+ if (declared !== undefined && declared > maxBytes) return res
78
+
79
+ const text = await readTextCapped(res, maxBytes)
80
+ if (text === null) return res
81
+ let parsed: unknown
82
+ try {
83
+ parsed = JSON.parse(text)
84
+ } catch {
85
+ return res
86
+ }
87
+
88
+ const headers = new Headers(res.headers)
89
+ headers.delete("content-length")
90
+ return new Response(`${JSON.stringify(parsed, null, spaces)}${newline ? "\n" : ""}`, {
91
+ status: res.status,
92
+ statusText: res.statusText,
93
+ headers,
94
+ })
95
+ }),
96
+ )
97
+ }