@btst/stack 1.6.0 → 1.8.0

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 (85) hide show
  1. package/dist/api/index.cjs +7 -1
  2. package/dist/api/index.d.cts +2 -2
  3. package/dist/api/index.d.mts +2 -2
  4. package/dist/api/index.d.ts +2 -2
  5. package/dist/api/index.mjs +7 -1
  6. package/dist/client/index.cjs +6 -2
  7. package/dist/client/index.d.cts +2 -1
  8. package/dist/client/index.d.mts +2 -1
  9. package/dist/client/index.d.ts +2 -1
  10. package/dist/client/index.mjs +6 -2
  11. package/dist/index.d.cts +1 -1
  12. package/dist/index.d.mts +1 -1
  13. package/dist/index.d.ts +1 -1
  14. package/dist/packages/better-stack/src/plugins/open-api/api/generator.cjs +300 -0
  15. package/dist/packages/better-stack/src/plugins/open-api/api/generator.mjs +284 -0
  16. package/dist/packages/better-stack/src/plugins/open-api/api/plugin.cjs +115 -0
  17. package/dist/packages/better-stack/src/plugins/open-api/api/plugin.mjs +113 -0
  18. package/dist/packages/better-stack/src/plugins/open-api/db.cjs +7 -0
  19. package/dist/packages/better-stack/src/plugins/open-api/db.mjs +5 -0
  20. package/dist/packages/better-stack/src/plugins/open-api/logo.cjs +8 -0
  21. package/dist/packages/better-stack/src/plugins/open-api/logo.mjs +6 -0
  22. package/dist/packages/better-stack/src/plugins/route-docs/client/components/loading/docs-skeleton.cjs +43 -0
  23. package/dist/packages/better-stack/src/plugins/route-docs/client/components/loading/docs-skeleton.mjs +41 -0
  24. package/dist/packages/better-stack/src/plugins/route-docs/client/components/pages/docs-page.cjs +794 -0
  25. package/dist/packages/better-stack/src/plugins/route-docs/client/components/pages/docs-page.mjs +788 -0
  26. package/dist/packages/better-stack/src/plugins/route-docs/client/plugin.cjs +111 -0
  27. package/dist/packages/better-stack/src/plugins/route-docs/client/plugin.mjs +106 -0
  28. package/dist/packages/better-stack/src/plugins/route-docs/generator.cjs +244 -0
  29. package/dist/packages/better-stack/src/plugins/route-docs/generator.mjs +227 -0
  30. package/dist/packages/ui/src/components/sheet.cjs +25 -0
  31. package/dist/packages/ui/src/components/sheet.mjs +24 -1
  32. package/dist/plugins/api/index.d.cts +2 -2
  33. package/dist/plugins/api/index.d.mts +2 -2
  34. package/dist/plugins/api/index.d.ts +2 -2
  35. package/dist/plugins/blog/api/index.d.cts +1 -1
  36. package/dist/plugins/blog/api/index.d.mts +1 -1
  37. package/dist/plugins/blog/api/index.d.ts +1 -1
  38. package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
  39. package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
  40. package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
  41. package/dist/plugins/blog/client/index.d.cts +1 -1
  42. package/dist/plugins/blog/client/index.d.mts +1 -1
  43. package/dist/plugins/blog/client/index.d.ts +1 -1
  44. package/dist/plugins/blog/query-keys.d.cts +2 -2
  45. package/dist/plugins/blog/query-keys.d.mts +2 -2
  46. package/dist/plugins/blog/query-keys.d.ts +2 -2
  47. package/dist/plugins/client/index.d.cts +2 -2
  48. package/dist/plugins/client/index.d.mts +2 -2
  49. package/dist/plugins/client/index.d.ts +2 -2
  50. package/dist/plugins/open-api/api/index.cjs +9 -0
  51. package/dist/plugins/open-api/api/index.d.cts +95 -0
  52. package/dist/plugins/open-api/api/index.d.mts +95 -0
  53. package/dist/plugins/open-api/api/index.d.ts +95 -0
  54. package/dist/plugins/open-api/api/index.mjs +2 -0
  55. package/dist/plugins/route-docs/client/index.cjs +10 -0
  56. package/dist/plugins/route-docs/client/index.d.cts +126 -0
  57. package/dist/plugins/route-docs/client/index.d.mts +126 -0
  58. package/dist/plugins/route-docs/client/index.d.ts +126 -0
  59. package/dist/plugins/route-docs/client/index.mjs +1 -0
  60. package/dist/plugins/route-docs/client.css +3 -0
  61. package/dist/plugins/route-docs/style.css +19 -0
  62. package/dist/shared/{stack.ByOugz9d.d.cts → stack.u9iYV6vt.d.cts} +28 -3
  63. package/dist/shared/{stack.ByOugz9d.d.mts → stack.u9iYV6vt.d.mts} +28 -3
  64. package/dist/shared/{stack.ByOugz9d.d.ts → stack.u9iYV6vt.d.ts} +28 -3
  65. package/package.json +28 -1
  66. package/src/api/index.ts +14 -2
  67. package/src/client/index.ts +11 -4
  68. package/src/plugins/open-api/api/generator.ts +433 -0
  69. package/src/plugins/open-api/api/index.ts +8 -0
  70. package/src/plugins/open-api/api/plugin.ts +243 -0
  71. package/src/plugins/open-api/db.ts +7 -0
  72. package/src/plugins/open-api/logo.ts +7 -0
  73. package/src/plugins/route-docs/client/components/loading/docs-skeleton.tsx +82 -0
  74. package/src/plugins/route-docs/client/components/loading/index.tsx +1 -0
  75. package/src/plugins/route-docs/client/components/pages/docs-page.tsx +1240 -0
  76. package/src/plugins/route-docs/client/index.ts +7 -0
  77. package/src/plugins/route-docs/client/plugin.tsx +187 -0
  78. package/src/plugins/route-docs/client.css +3 -0
  79. package/src/plugins/route-docs/generator.ts +385 -0
  80. package/src/plugins/route-docs/index.ts +12 -0
  81. package/src/plugins/route-docs/style.css +19 -0
  82. package/src/types.ts +34 -2
  83. package/dist/shared/{stack.CcI4sYJP.d.mts → stack.DLhzx1-D.d.cts} +1 -1
  84. package/dist/shared/{stack.CcI4sYJP.d.ts → stack.DLhzx1-D.d.mts} +1 -1
  85. package/dist/shared/{stack.CcI4sYJP.d.cts → stack.DLhzx1-D.d.ts} +1 -1
@@ -4,10 +4,11 @@ import type {
4
4
  ClientLibConfig,
5
5
  ClientLib,
6
6
  ClientPlugin,
7
+ ClientStackContext,
7
8
  PluginRoutes,
8
9
  Sitemap,
9
10
  } from "../types";
10
- export type { ClientPlugin } from "../types";
11
+ export type { ClientPlugin, ClientStackContext } from "../types";
11
12
 
12
13
  /**
13
14
  * Creates the client library with plugin support
@@ -60,15 +61,21 @@ export function createStackClient<
60
61
  TPlugins extends Record<string, ClientPlugin<any, any>>,
61
62
  TRoutes extends PluginRoutes<TPlugins> = PluginRoutes<TPlugins>,
62
63
  >(config: ClientLibConfig<TPlugins>): ClientLib<TRoutes> {
63
- const { plugins } = config;
64
+ const { plugins, basePath } = config;
64
65
 
65
66
  // Collect all routes from all plugins
66
67
  // We build this with type assertions to preserve literal keys
67
68
  const allRoutes = {} as TRoutes;
68
69
 
70
+ // Create the context object to pass to plugin routes
71
+ const clientStackContext: ClientStackContext<TPlugins> = {
72
+ plugins,
73
+ basePath,
74
+ };
75
+
69
76
  for (const [pluginKey, plugin] of Object.entries(plugins)) {
70
- // Add routes
71
- const pluginRoutes = plugin.routes();
77
+ // Add routes - pass the context for plugins that need introspection (e.g., routeDocs)
78
+ const pluginRoutes = plugin.routes(clientStackContext);
72
79
  Object.assign(allRoutes, pluginRoutes);
73
80
  }
74
81
 
@@ -0,0 +1,433 @@
1
+ import type { Endpoint } from "better-call";
2
+ import type { BetterStackContext } from "../../../types";
3
+ import * as z from "zod";
4
+
5
+ /**
6
+ * OpenAPI path operation object
7
+ */
8
+ export interface PathOperation {
9
+ tags?: string[];
10
+ operationId?: string;
11
+ description?: string;
12
+ summary?: string;
13
+ parameters?: OpenAPIParameter[];
14
+ requestBody?: {
15
+ required?: boolean;
16
+ content: {
17
+ "application/json": {
18
+ schema: Record<string, any>;
19
+ };
20
+ };
21
+ };
22
+ responses: Record<string, any>;
23
+ }
24
+
25
+ export interface OpenAPIParameter {
26
+ name: string;
27
+ in: "query" | "path" | "header";
28
+ required?: boolean;
29
+ schema: Record<string, any>;
30
+ description?: string;
31
+ }
32
+
33
+ /**
34
+ * Convert :param to {param} for OpenAPI path format
35
+ */
36
+ function toOpenApiPath(path: string): string {
37
+ return path
38
+ .split("/")
39
+ .map((part) => (part.startsWith(":") ? `{${part.slice(1)}}` : part))
40
+ .join("/");
41
+ }
42
+
43
+ /**
44
+ * Get the primitive type from a Zod type
45
+ */
46
+ function getTypeFromZodType(
47
+ zodType: z.ZodType<any>,
48
+ ): "string" | "number" | "boolean" | "array" | "object" | "integer" {
49
+ // const typeName = zodType._zpiSkeleton?.type || zodType.constructor.name;
50
+
51
+ if (zodType instanceof z.ZodString) return "string";
52
+ if (zodType instanceof z.ZodNumber) return "number";
53
+ if (zodType instanceof z.ZodBoolean) return "boolean";
54
+ if (zodType instanceof z.ZodArray) return "array";
55
+ if (zodType instanceof z.ZodObject) return "object";
56
+
57
+ // Fallback based on type property if available
58
+ const type = (zodType as any).type;
59
+ if (type === "string") return "string";
60
+ if (type === "number") return "number";
61
+ if (type === "boolean") return "boolean";
62
+ if (type === "array") return "array";
63
+ if (type === "object") return "object";
64
+
65
+ return "string";
66
+ }
67
+
68
+ /**
69
+ * Process a Zod type into an OpenAPI schema
70
+ */
71
+ function processZodType(zodType: z.ZodType<any>): Record<string, any> {
72
+ // Handle optional - unwrap and process inner type
73
+ // Optionality is handled by the `required` array in parent object schemas,
74
+ // NOT by adding `nullable: true` (which would incorrectly allow null values)
75
+ if (zodType instanceof z.ZodOptional) {
76
+ const innerType =
77
+ (zodType as any)._def?.innerType || (zodType as any).unwrap?.();
78
+ if (innerType) {
79
+ return processZodType(innerType);
80
+ }
81
+ }
82
+
83
+ // Handle nullable
84
+ if (zodType instanceof z.ZodNullable) {
85
+ const innerType =
86
+ (zodType as any)._def?.innerType || (zodType as any).unwrap?.();
87
+ if (innerType) {
88
+ const innerSchema = processZodType(innerType);
89
+ return {
90
+ ...innerSchema,
91
+ nullable: true,
92
+ };
93
+ }
94
+ }
95
+
96
+ // Handle default - unwrap and process inner type, including default value
97
+ if (zodType instanceof z.ZodDefault) {
98
+ const innerType = (zodType as any)._def?.innerType;
99
+ const defaultValue = (zodType as any)._def?.defaultValue?.();
100
+ if (innerType) {
101
+ const innerSchema = processZodType(innerType);
102
+ // Include the default value in the OpenAPI schema if it's JSON-serializable
103
+ if (defaultValue !== undefined) {
104
+ return {
105
+ ...innerSchema,
106
+ default: defaultValue,
107
+ };
108
+ }
109
+ return innerSchema;
110
+ }
111
+ }
112
+
113
+ // Handle object
114
+ if (zodType instanceof z.ZodObject) {
115
+ const shape = (zodType as any).shape || (zodType as any)._def?.shape?.();
116
+ if (shape) {
117
+ const properties: Record<string, any> = {};
118
+ const required: string[] = [];
119
+
120
+ for (const [key, value] of Object.entries(shape)) {
121
+ if (value instanceof z.ZodType) {
122
+ properties[key] = processZodType(value);
123
+ if (!(value instanceof z.ZodOptional)) {
124
+ required.push(key);
125
+ }
126
+ }
127
+ }
128
+
129
+ return {
130
+ type: "object",
131
+ properties,
132
+ ...(required.length > 0 ? { required } : {}),
133
+ };
134
+ }
135
+ }
136
+
137
+ // Handle array
138
+ if (zodType instanceof z.ZodArray) {
139
+ const elementType = (zodType as any)._def?.type || (zodType as any).element;
140
+ return {
141
+ type: "array",
142
+ items: elementType ? processZodType(elementType) : { type: "string" },
143
+ };
144
+ }
145
+
146
+ // Handle enum
147
+ if (zodType instanceof z.ZodEnum) {
148
+ const values = (zodType as any)._def?.values || (zodType as any).options;
149
+ return {
150
+ type: "string",
151
+ enum: values,
152
+ };
153
+ }
154
+
155
+ // Handle literal
156
+ if (zodType instanceof z.ZodLiteral) {
157
+ const value = (zodType as any)._def?.value || (zodType as any).value;
158
+ // Map JavaScript typeof to OpenAPI 3.1 types correctly
159
+ // Note: typeof null === "object" in JS, but OpenAPI 3.1 has "null" type
160
+ let type: string;
161
+ if (value === null) {
162
+ type = "null";
163
+ } else if (value === undefined) {
164
+ // undefined is not a valid JSON/OpenAPI value, treat as nullable
165
+ return { nullable: true };
166
+ } else {
167
+ type = typeof value;
168
+ }
169
+ return {
170
+ type,
171
+ const: value,
172
+ };
173
+ }
174
+
175
+ // Handle union
176
+ if (zodType instanceof z.ZodUnion) {
177
+ const options = (zodType as any)._def?.options || (zodType as any).options;
178
+ if (options && Array.isArray(options)) {
179
+ return {
180
+ oneOf: options.map((opt: z.ZodType<any>) => processZodType(opt)),
181
+ };
182
+ }
183
+ }
184
+
185
+ // Handle coerce types
186
+ if ((zodType as any)._def?.coerce) {
187
+ const innerType = (zodType as any)._def?.innerType;
188
+ if (innerType) {
189
+ return processZodType(innerType);
190
+ }
191
+ }
192
+
193
+ // Default to primitive type
194
+ return {
195
+ type: getTypeFromZodType(zodType),
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Extract query parameters from endpoint options
201
+ */
202
+ function getParameters(options: any): OpenAPIParameter[] {
203
+ const parameters: OpenAPIParameter[] = [];
204
+
205
+ // Handle query parameters
206
+ if (options.query instanceof z.ZodObject) {
207
+ const shape =
208
+ (options.query as any).shape || (options.query as any)._def?.shape?.();
209
+ if (shape) {
210
+ for (const [key, value] of Object.entries(shape)) {
211
+ if (value instanceof z.ZodType) {
212
+ parameters.push({
213
+ name: key,
214
+ in: "query",
215
+ required: !(value instanceof z.ZodOptional),
216
+ schema: processZodType(value),
217
+ });
218
+ }
219
+ }
220
+ }
221
+ }
222
+
223
+ // Handle path parameters from params schema
224
+ if (options.params instanceof z.ZodObject) {
225
+ const shape =
226
+ (options.params as any).shape || (options.params as any)._def?.shape?.();
227
+ if (shape) {
228
+ for (const [key, value] of Object.entries(shape)) {
229
+ if (value instanceof z.ZodType) {
230
+ parameters.push({
231
+ name: key,
232
+ in: "path",
233
+ required: true,
234
+ schema: processZodType(value),
235
+ });
236
+ }
237
+ }
238
+ }
239
+ }
240
+
241
+ return parameters;
242
+ }
243
+
244
+ /**
245
+ * Extract request body schema from endpoint options
246
+ *
247
+ * Handles any Zod type as request body including:
248
+ * - ZodObject (most common)
249
+ * - ZodArray (batch/bulk operations)
250
+ * - ZodUnion (multiple accepted formats)
251
+ * - ZodOptional (optional body)
252
+ * - ZodNullable, ZodEnum, ZodLiteral, etc.
253
+ */
254
+ function getRequestBody(
255
+ options: any,
256
+ ): PathOperation["requestBody"] | undefined {
257
+ if (!options.body) return undefined;
258
+
259
+ // Handle any Zod type - processZodType already handles all Zod types
260
+ if (options.body instanceof z.ZodType) {
261
+ const schema = processZodType(options.body);
262
+
263
+ // Determine if body is required:
264
+ // - ZodOptional: not required
265
+ // - ZodNullable: required but can be null (nullable is set in schema)
266
+ // - Everything else: required
267
+ const isOptional = options.body instanceof z.ZodOptional;
268
+
269
+ return {
270
+ required: !isOptional,
271
+ content: {
272
+ "application/json": {
273
+ schema,
274
+ },
275
+ },
276
+ };
277
+ }
278
+
279
+ return undefined;
280
+ }
281
+
282
+ /**
283
+ * Create a fresh error schema object to avoid circular references in JSON serialization
284
+ */
285
+ function createErrorSchema(): Record<string, any> {
286
+ return {
287
+ type: "object",
288
+ properties: {
289
+ message: { type: "string" },
290
+ },
291
+ required: ["message"],
292
+ };
293
+ }
294
+
295
+ /**
296
+ * Generate standard error responses (creates fresh objects to avoid circular refs)
297
+ */
298
+ function getErrorResponses(): Record<string, any> {
299
+ return {
300
+ "400": {
301
+ description: "Bad Request",
302
+ content: { "application/json": { schema: createErrorSchema() } },
303
+ },
304
+ "401": {
305
+ description: "Unauthorized",
306
+ content: { "application/json": { schema: createErrorSchema() } },
307
+ },
308
+ "403": {
309
+ description: "Forbidden",
310
+ content: { "application/json": { schema: createErrorSchema() } },
311
+ },
312
+ "404": {
313
+ description: "Not Found",
314
+ content: { "application/json": { schema: createErrorSchema() } },
315
+ },
316
+ "500": {
317
+ description: "Internal Server Error",
318
+ content: { "application/json": { schema: createErrorSchema() } },
319
+ },
320
+ };
321
+ }
322
+
323
+ /**
324
+ * Generate OpenAPI 3.1 schema from Better Stack context
325
+ */
326
+ export function generateOpenAPISchema(
327
+ context: BetterStackContext,
328
+ options?: { title?: string; description?: string; version?: string },
329
+ ): Record<string, any> {
330
+ const paths: Record<string, Record<string, PathOperation>> = {};
331
+ const tags: Array<{ name: string; description: string }> = [];
332
+
333
+ // Iterate over all plugins
334
+ for (const [pluginKey, plugin] of Object.entries(context.plugins)) {
335
+ // Skip the open-api plugin itself
336
+ if (pluginKey === "openApi" || plugin.name === "open-api") {
337
+ continue;
338
+ }
339
+
340
+ // Get plugin routes
341
+ const pluginRoutes = plugin.routes(context.adapter, context);
342
+
343
+ // Create tag for this plugin
344
+ const tagName = pluginKey.charAt(0).toUpperCase() + pluginKey.slice(1);
345
+ tags.push({
346
+ name: tagName,
347
+ description: `${tagName} plugin endpoints`,
348
+ });
349
+
350
+ // Process each endpoint
351
+ for (const [routeKey, endpoint] of Object.entries(pluginRoutes)) {
352
+ const ep = endpoint as Endpoint;
353
+
354
+ // Access endpoint properties
355
+ const path = (ep as any).path;
356
+ const endpointOptions = (ep as any).options || {};
357
+ const method = (endpointOptions.method || "GET").toLowerCase();
358
+
359
+ if (!path) continue;
360
+
361
+ const openApiPath = toOpenApiPath(path);
362
+
363
+ // Initialize path object if needed
364
+ if (!paths[openApiPath]) {
365
+ paths[openApiPath] = {};
366
+ }
367
+
368
+ // Build operation object
369
+ const operation: PathOperation = {
370
+ tags: [tagName],
371
+ operationId: `${pluginKey}_${routeKey}`,
372
+ summary: endpointOptions.metadata?.openapi?.summary,
373
+ description: endpointOptions.metadata?.openapi?.description,
374
+ parameters: getParameters(endpointOptions),
375
+ responses: {
376
+ "200": {
377
+ description: "Successful response",
378
+ content: {
379
+ "application/json": {
380
+ schema: { type: "object" },
381
+ },
382
+ },
383
+ },
384
+ ...getErrorResponses(),
385
+ },
386
+ };
387
+
388
+ // Add request body for POST/PUT/PATCH
389
+ if (["post", "put", "patch"].includes(method)) {
390
+ const requestBody = getRequestBody(endpointOptions);
391
+ if (requestBody) {
392
+ operation.requestBody = requestBody;
393
+ }
394
+ }
395
+
396
+ paths[openApiPath][method] = operation;
397
+ }
398
+ }
399
+
400
+ return {
401
+ openapi: "3.1.0",
402
+ info: {
403
+ title: options?.title || "Better Stack API",
404
+ description:
405
+ options?.description ||
406
+ "API Reference for your Better Stack application",
407
+ version: options?.version || "1.0.0",
408
+ },
409
+ servers: [
410
+ {
411
+ url: context.basePath,
412
+ description: "API Server",
413
+ },
414
+ ],
415
+ tags,
416
+ paths,
417
+ components: {
418
+ securitySchemes: {
419
+ bearerAuth: {
420
+ type: "http",
421
+ scheme: "bearer",
422
+ description: "Bearer token authentication",
423
+ },
424
+ cookieAuth: {
425
+ type: "apiKey",
426
+ in: "cookie",
427
+ name: "session",
428
+ description: "Session cookie authentication",
429
+ },
430
+ },
431
+ },
432
+ };
433
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ openApiBackendPlugin,
3
+ type OpenAPIOptions,
4
+ type ScalarTheme,
5
+ type OpenApiRouter,
6
+ } from "./plugin";
7
+
8
+ export { generateOpenAPISchema } from "./generator";
@@ -0,0 +1,243 @@
1
+ import { defineBackendPlugin } from "@btst/stack/plugins/api";
2
+ import { createEndpoint } from "@btst/stack/plugins/api";
3
+ import { openApiSchema } from "../db";
4
+ import { generateOpenAPISchema } from "./generator";
5
+ import { logo } from "../logo";
6
+ import type { BetterStackContext } from "../../../types";
7
+
8
+ /**
9
+ * Scalar API Reference themes
10
+ */
11
+ export type ScalarTheme =
12
+ | "alternate"
13
+ | "default"
14
+ | "moon"
15
+ | "purple"
16
+ | "solarized"
17
+ | "bluePlanet"
18
+ | "saturn"
19
+ | "kepler"
20
+ | "mars"
21
+ | "deepSpace"
22
+ | "laserwave"
23
+ | "none";
24
+
25
+ /**
26
+ * OpenAPI plugin configuration options
27
+ */
28
+ export interface OpenAPIOptions {
29
+ /**
30
+ * The path to the OpenAPI reference page
31
+ * This path is relative to the API base path
32
+ * @default "/reference"
33
+ */
34
+ path?: string;
35
+
36
+ /**
37
+ * Disable the default HTML reference page
38
+ * Only the JSON schema endpoint will be available
39
+ * @default false
40
+ */
41
+ disableDefaultReference?: boolean;
42
+
43
+ /**
44
+ * Theme for the Scalar API Reference page
45
+ * @default "default"
46
+ */
47
+ theme?: ScalarTheme;
48
+
49
+ /**
50
+ * CSP nonce for inline scripts
51
+ * Required for strict Content Security Policy
52
+ */
53
+ nonce?: string;
54
+
55
+ /**
56
+ * Custom title for the API documentation
57
+ * @default "Better Stack API"
58
+ */
59
+ title?: string;
60
+
61
+ /**
62
+ * Custom description for the API documentation
63
+ */
64
+ description?: string;
65
+
66
+ /**
67
+ * API version string
68
+ * @default "1.0.0"
69
+ */
70
+ version?: string;
71
+ }
72
+
73
+ /**
74
+ * Escape HTML entities to prevent XSS and ensure proper rendering
75
+ */
76
+ function escapeHtml(str: string): string {
77
+ return str
78
+ .replace(/&/g, "&amp;")
79
+ .replace(/</g, "&lt;")
80
+ .replace(/>/g, "&gt;")
81
+ .replace(/"/g, "&quot;")
82
+ .replace(/'/g, "&#39;");
83
+ }
84
+
85
+ /**
86
+ * Escape JSON for safe embedding in HTML script tags.
87
+ * Replaces < with \u003c to prevent </script> from closing the tag prematurely.
88
+ * This is valid JSON and will be parsed correctly.
89
+ */
90
+ function escapeJsonForHtml(json: string): string {
91
+ return json.replace(/</g, "\\u003c");
92
+ }
93
+
94
+ /**
95
+ * Generate the HTML page for Scalar API Reference
96
+ */
97
+ function getScalarHTML(
98
+ schema: Record<string, any>,
99
+ theme: ScalarTheme = "default",
100
+ nonce?: string,
101
+ ): string {
102
+ const nonceAttr = nonce ? ` nonce="${escapeHtml(nonce)}"` : "";
103
+ const encodedLogo = encodeURIComponent(logo);
104
+
105
+ const title = schema.info?.title || "API Reference";
106
+ const description = schema.info?.description || "API Reference";
107
+
108
+ return `<!doctype html>
109
+ <html>
110
+ <head>
111
+ <title>${escapeHtml(title)}</title>
112
+ <meta charset="utf-8" />
113
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
114
+ </head>
115
+ <body>
116
+ <script
117
+ id="api-reference"
118
+ type="application/json"${nonceAttr}>
119
+ ${escapeJsonForHtml(JSON.stringify(schema))}
120
+ </script>
121
+ <script${nonceAttr}>
122
+ var configuration = {
123
+ favicon: "data:image/svg+xml;utf8,${encodedLogo}",
124
+ theme: "${theme}",
125
+ metaData: {
126
+ title: ${JSON.stringify(title)},
127
+ description: ${JSON.stringify(description)},
128
+ }
129
+ }
130
+
131
+ document.getElementById('api-reference').dataset.configuration =
132
+ JSON.stringify(configuration)
133
+ </script>
134
+ <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"${nonceAttr}></script>
135
+ </body>
136
+ </html>`;
137
+ }
138
+
139
+ /**
140
+ * OpenAPI plugin for Better Stack
141
+ *
142
+ * Automatically generates OpenAPI 3.1 documentation for all registered plugins.
143
+ * Provides both a JSON schema endpoint and an interactive Scalar UI reference page.
144
+ *
145
+ * @example
146
+ * ```ts
147
+ * const { handler } = betterStack({
148
+ * basePath: "/api/data",
149
+ * plugins: {
150
+ * blog: blogBackendPlugin(),
151
+ * cms: cmsBackendPlugin({ ... }),
152
+ * openApi: openApiBackendPlugin({ theme: "moon" }),
153
+ * },
154
+ * adapter: (db) => createMemoryAdapter(db)({}),
155
+ * });
156
+ *
157
+ * // Access:
158
+ * // - GET /api/data/open-api/schema - JSON schema
159
+ * // - GET /api/data/reference - Interactive Scalar UI
160
+ * ```
161
+ */
162
+ export const openApiBackendPlugin = (options?: OpenAPIOptions) => {
163
+ const referencePath = options?.path ?? "/reference";
164
+
165
+ // Store context for use in endpoint handlers
166
+ let storedContext: BetterStackContext | null = null;
167
+
168
+ return defineBackendPlugin({
169
+ name: "open-api",
170
+ dbPlugin: openApiSchema,
171
+
172
+ routes: (_adapter, context) => {
173
+ // Store context for endpoint handlers
174
+ storedContext = context ?? null;
175
+
176
+ const generateSchema = createEndpoint(
177
+ "/open-api/schema",
178
+ {
179
+ method: "GET",
180
+ },
181
+ async (ctx) => {
182
+ if (!storedContext) {
183
+ throw ctx.error(500, {
184
+ message: "OpenAPI context not available",
185
+ });
186
+ }
187
+
188
+ const schema = generateOpenAPISchema(storedContext, {
189
+ title: options?.title,
190
+ description: options?.description,
191
+ version: options?.version,
192
+ });
193
+
194
+ return schema;
195
+ },
196
+ );
197
+
198
+ const reference = createEndpoint(
199
+ referencePath,
200
+ {
201
+ method: "GET",
202
+ },
203
+ async (ctx) => {
204
+ if (options?.disableDefaultReference) {
205
+ throw ctx.error(404, {
206
+ message: "Reference page is disabled",
207
+ });
208
+ }
209
+
210
+ if (!storedContext) {
211
+ throw ctx.error(500, {
212
+ message: "OpenAPI context not available",
213
+ });
214
+ }
215
+
216
+ const schema = generateOpenAPISchema(storedContext, {
217
+ title: options?.title,
218
+ description: options?.description,
219
+ version: options?.version,
220
+ });
221
+
222
+ return new Response(
223
+ getScalarHTML(schema, options?.theme, options?.nonce),
224
+ {
225
+ headers: {
226
+ "Content-Type": "text/html; charset=utf-8",
227
+ },
228
+ },
229
+ );
230
+ },
231
+ );
232
+
233
+ return {
234
+ generateSchema,
235
+ reference,
236
+ } as const;
237
+ },
238
+ });
239
+ };
240
+
241
+ export type OpenApiRouter = ReturnType<
242
+ ReturnType<typeof openApiBackendPlugin>["routes"]
243
+ >;