@beignet/next 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/src/index.ts ADDED
@@ -0,0 +1,519 @@
1
+ import type { ClientConfig } from "@beignet/core/client";
2
+ import { createClient } from "@beignet/core/client";
3
+ import type { AnyPorts, StoragePort } from "@beignet/core/ports";
4
+ import type {
5
+ ProvidedPortsOfList,
6
+ ServiceProvider,
7
+ } from "@beignet/core/providers";
8
+ import type {
9
+ ContractLike,
10
+ CreateServerOptions,
11
+ Handler,
12
+ HttpRequestLike,
13
+ HttpResponse,
14
+ ResolveContract,
15
+ RouteDef,
16
+ StandardSchemaV1,
17
+ } from "@beignet/core/server";
18
+ import { createServer } from "@beignet/core/server";
19
+
20
+ export type {
21
+ CreateServerOptions,
22
+ RouteGroup,
23
+ ServerInstance,
24
+ } from "@beignet/core/server";
25
+ export {
26
+ contractsFromRoutes,
27
+ createServer,
28
+ defineRouteGroup,
29
+ defineRoutes,
30
+ } from "@beignet/core/server";
31
+
32
+ type AnyProvider = ServiceProvider<
33
+ unknown,
34
+ // biome-ignore lint/suspicious/noExplicitAny: provider config types are erased at this level
35
+ StandardSchemaV1<any, any>,
36
+ AnyPorts
37
+ >;
38
+
39
+ export type NextClientConfig<TProvidedHeaders extends string = never> = Omit<
40
+ ClientConfig<TProvidedHeaders>,
41
+ "baseUrl"
42
+ > & {
43
+ /**
44
+ * Explicit base URL for both browser and server calls.
45
+ *
46
+ * In browser code, omit this to use same-origin relative requests.
47
+ */
48
+ baseUrl?: string | (() => string | undefined);
49
+ /**
50
+ * Server-only fallback base URL for SSR, prefetching, tests, and server actions.
51
+ * Browser calls ignore this value unless `baseUrl` is also provided.
52
+ */
53
+ serverBaseUrl?: string | (() => string | undefined);
54
+ };
55
+
56
+ export type OpenAPIServer = {
57
+ url: string;
58
+ description?: string;
59
+ variables?: Record<
60
+ string,
61
+ {
62
+ default: string;
63
+ enum?: string[];
64
+ description?: string;
65
+ }
66
+ >;
67
+ };
68
+
69
+ export type CreateOpenAPIHandlerOptions = {
70
+ title: string;
71
+ version: string;
72
+ description?: string;
73
+ servers?: OpenAPIServer[] | ((req: Request) => OpenAPIServer[]);
74
+ jsonMediaType?: string;
75
+ securitySchemes?: Record<string, unknown>;
76
+ security?: Array<Record<string, string[]>>;
77
+ schemaIntrospector?: unknown;
78
+ headers?: HeadersInit | ((req: Request) => HeadersInit | undefined);
79
+ /**
80
+ * Add the current request origin as the OpenAPI server when `servers` is omitted.
81
+ *
82
+ * @default true
83
+ */
84
+ inferServersFromRequest?: boolean;
85
+ };
86
+
87
+ export type CreateSwaggerUIHandlerOptions = {
88
+ title?: string;
89
+ specUrl?: string | ((req: Request) => string);
90
+ headers?: HeadersInit | ((req: Request) => HeadersInit | undefined);
91
+ };
92
+
93
+ export type CreateStorageRouteOptions = {
94
+ /**
95
+ * Public path prefix that maps to storage keys.
96
+ *
97
+ * @default "/storage"
98
+ */
99
+ basePath?: string;
100
+ /**
101
+ * Additional response headers for served objects.
102
+ */
103
+ headers?: HeadersInit | ((req: Request) => HeadersInit | undefined);
104
+ };
105
+
106
+ export interface NextServer<Ctx, Ports extends AnyPorts = AnyPorts> {
107
+ api: (req: Request) => Promise<Response>;
108
+ route: <CLike extends ContractLike>(
109
+ contractLike: CLike,
110
+ ) => {
111
+ handle: (
112
+ fn: Handler<Ctx, ResolveContract<CLike>>,
113
+ ) => (req: Request) => Promise<Response>;
114
+ };
115
+ createContextFromNext: () => Promise<Ctx>;
116
+ contracts: readonly ContractLike[];
117
+ stop: () => Promise<void>;
118
+ ports: Ports;
119
+ }
120
+
121
+ export function toRequestLike(req: Request): HttpRequestLike {
122
+ return {
123
+ method: req.method,
124
+ url: req.url,
125
+ headers: new Headers(req.headers),
126
+ raw: req,
127
+ json: () => req.json(),
128
+ text: () => req.text(),
129
+ arrayBuffer: () => req.arrayBuffer(),
130
+ blob: () => req.blob(),
131
+ formData: () => req.formData(),
132
+ clone: () => toRequestLike(req.clone()),
133
+ };
134
+ }
135
+
136
+ export function toNextResponse(resLike: HttpResponse): Response {
137
+ if (resLike instanceof Response) {
138
+ return resLike;
139
+ }
140
+
141
+ const headers = new Headers(resLike.headers);
142
+ const body = resLike.body;
143
+
144
+ if (body === undefined || body === null) {
145
+ return new Response(null, { status: resLike.status, headers });
146
+ }
147
+
148
+ // biome-ignore lint/suspicious/noExplicitAny: Body can be any JSON-serializable value
149
+ return Response.json(body as any, { status: resLike.status, headers });
150
+ }
151
+
152
+ function resolveConfigValue(
153
+ value: string | (() => string | undefined) | undefined,
154
+ ): string | undefined {
155
+ return typeof value === "function" ? value() : value;
156
+ }
157
+
158
+ /**
159
+ * Resolve the right Beignet client base URL for Next.js.
160
+ *
161
+ * Browser calls default to same-origin relative URLs. Server calls need an
162
+ * absolute URL, so this helper checks `NEXT_PUBLIC_API_URL`, then Vercel's
163
+ * deployment URL, then localhost.
164
+ */
165
+ export function resolveNextBaseUrl(
166
+ options: NextClientConfig<string> = {},
167
+ ): string {
168
+ const explicitBaseUrl = resolveConfigValue(options.baseUrl);
169
+ if (explicitBaseUrl !== undefined) return explicitBaseUrl;
170
+
171
+ if (process.env.NEXT_PUBLIC_API_URL) return process.env.NEXT_PUBLIC_API_URL;
172
+
173
+ if (typeof window !== "undefined") return "";
174
+
175
+ const serverBaseUrl = resolveConfigValue(options.serverBaseUrl);
176
+ if (serverBaseUrl !== undefined) return serverBaseUrl;
177
+
178
+ if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
179
+
180
+ return `http://localhost:${process.env.PORT || 3000}`;
181
+ }
182
+
183
+ /**
184
+ * Create a Beignet client with Next.js-friendly base URL defaults.
185
+ */
186
+ export function createNextClient<const TProvidedHeaders extends string = never>(
187
+ config: NextClientConfig<TProvidedHeaders> = {},
188
+ ) {
189
+ const {
190
+ baseUrl: _baseUrl,
191
+ serverBaseUrl: _serverBaseUrl,
192
+ ...clientConfig
193
+ } = config;
194
+
195
+ return createClient<TProvidedHeaders>({
196
+ ...clientConfig,
197
+ baseUrl: resolveNextBaseUrl(config),
198
+ });
199
+ }
200
+
201
+ function resolveHeaders(
202
+ headers:
203
+ | HeadersInit
204
+ | ((req: Request) => HeadersInit | undefined)
205
+ | undefined,
206
+ req: Request,
207
+ ): HeadersInit | undefined {
208
+ return typeof headers === "function" ? headers(req) : headers;
209
+ }
210
+
211
+ function hasControlCharacter(value: string): boolean {
212
+ for (const char of value) {
213
+ const code = char.charCodeAt(0);
214
+ if (code <= 31 || code === 127) return true;
215
+ }
216
+
217
+ return false;
218
+ }
219
+
220
+ function normalizeStorageBasePath(basePath: string | undefined): string {
221
+ const path = basePath ?? "/storage";
222
+
223
+ if (!path.startsWith("/")) {
224
+ throw new Error("Storage route basePath must start with '/'.");
225
+ }
226
+
227
+ if (path.includes("?") || path.includes("#")) {
228
+ throw new Error("Storage route basePath must be a pathname.");
229
+ }
230
+
231
+ return path.replace(/\/+$/, "") || "/";
232
+ }
233
+
234
+ function keyFromStorageRequest(req: Request, basePath: string): string | null {
235
+ const pathname = new URL(req.url).pathname;
236
+ const prefix = basePath === "/" ? "/" : `${basePath}/`;
237
+
238
+ if (
239
+ basePath !== "/" &&
240
+ pathname !== basePath &&
241
+ !pathname.startsWith(prefix)
242
+ ) {
243
+ return null;
244
+ }
245
+
246
+ const encodedKey =
247
+ basePath === "/" ? pathname.slice(1) : pathname.slice(prefix.length);
248
+
249
+ if (!encodedKey) return null;
250
+
251
+ try {
252
+ const key = encodedKey
253
+ .split("/")
254
+ .map((segment) => decodeURIComponent(segment))
255
+ .join("/");
256
+
257
+ const segments = key.split("/");
258
+ if (
259
+ !key ||
260
+ key.includes("\\") ||
261
+ hasControlCharacter(key) ||
262
+ segments.some(
263
+ (segment) =>
264
+ segment === "" ||
265
+ segment === "." ||
266
+ segment === ".." ||
267
+ segment === ".beignet-storage-meta",
268
+ )
269
+ ) {
270
+ return null;
271
+ }
272
+
273
+ return key;
274
+ } catch {
275
+ return null;
276
+ }
277
+ }
278
+
279
+ function storageNotFoundResponse(): Response {
280
+ return new Response(null, { status: 404 });
281
+ }
282
+
283
+ async function storageResponse(
284
+ storage: StoragePort,
285
+ req: Request,
286
+ basePath: string,
287
+ options: CreateStorageRouteOptions,
288
+ includeBody: boolean,
289
+ ): Promise<Response> {
290
+ const key = keyFromStorageRequest(req, basePath);
291
+ if (!key) return storageNotFoundResponse();
292
+
293
+ const object = await storage.get(key);
294
+ if (!object || object.visibility !== "public") {
295
+ return storageNotFoundResponse();
296
+ }
297
+
298
+ const headers = new Headers(resolveHeaders(options.headers, req));
299
+ headers.set("content-length", String(object.size));
300
+ headers.set("last-modified", object.lastModified.toUTCString());
301
+
302
+ if (object.contentType && !headers.has("content-type")) {
303
+ headers.set("content-type", object.contentType);
304
+ }
305
+
306
+ if (object.cacheControl && !headers.has("cache-control")) {
307
+ headers.set("cache-control", object.cacheControl);
308
+ }
309
+
310
+ return new Response(includeBody ? object.stream() : null, {
311
+ status: 200,
312
+ headers,
313
+ });
314
+ }
315
+
316
+ function currentRequestServer(req: Request): OpenAPIServer {
317
+ const url = new URL(req.url);
318
+ return {
319
+ url: `${url.protocol}//${url.host}`,
320
+ description: "Current server",
321
+ };
322
+ }
323
+
324
+ /**
325
+ * Create a Next.js route handler that returns an OpenAPI document.
326
+ *
327
+ * Requires `@beignet/core/openapi` and the `zod` peer dependency.
328
+ */
329
+ export function createOpenAPIHandler(
330
+ contracts: readonly ContractLike[],
331
+ options: CreateOpenAPIHandlerOptions,
332
+ ): (req: Request) => Promise<Response> {
333
+ return async (req: Request) => {
334
+ const { contractsToOpenAPI } = await import("@beignet/core/openapi");
335
+ const servers =
336
+ typeof options.servers === "function"
337
+ ? options.servers(req)
338
+ : (options.servers ??
339
+ (options.inferServersFromRequest === false
340
+ ? undefined
341
+ : [currentRequestServer(req)]));
342
+
343
+ const spec = contractsToOpenAPI(contracts, {
344
+ title: options.title,
345
+ version: options.version,
346
+ description: options.description,
347
+ servers,
348
+ jsonMediaType: options.jsonMediaType,
349
+ securitySchemes: options.securitySchemes as never,
350
+ security: options.security,
351
+ schemaIntrospector: options.schemaIntrospector as never,
352
+ });
353
+
354
+ const headers = new Headers(resolveHeaders(options.headers, req));
355
+ if (!headers.has("content-type")) {
356
+ headers.set("content-type", "application/json");
357
+ }
358
+
359
+ return Response.json(spec, { headers });
360
+ };
361
+ }
362
+
363
+ function escapeHtml(value: string): string {
364
+ return value
365
+ .replaceAll("&", "&amp;")
366
+ .replaceAll("<", "&lt;")
367
+ .replaceAll(">", "&gt;")
368
+ .replaceAll('"', "&quot;");
369
+ }
370
+
371
+ function createSwaggerUIHtml(title: string, specUrl: string): string {
372
+ return `<!DOCTYPE html>
373
+ <html lang="en">
374
+ <head>
375
+ <meta charset="UTF-8">
376
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
377
+ <title>${escapeHtml(title)}</title>
378
+ <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui.css" integrity="sha384-+yyzNgM3K92sROwsXxYCxaiLWxWJ0G+v/9A+qIZ2rgefKgkdcmJI+L601cqPD/Ut" crossorigin="anonymous" />
379
+ <style>
380
+ body { margin: 0; padding: 0; }
381
+ .topbar { display: none; }
382
+ </style>
383
+ </head>
384
+ <body>
385
+ <div id="swagger-ui"></div>
386
+ <script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-bundle.js" integrity="sha384-qn5tagrAjZi8cSmvZ+k3zk4+eDEEUcP9myuR2J6V+/H6rne++v6ChO7EeHAEzqxQ" crossorigin="anonymous"></script>
387
+ <script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-standalone-preset.js" integrity="sha384-SiLF+uYBf9lVQW98s/XUYP14enXJN31bn0zu3BS1WFqr5hvnMF+w132WkE/v0uJw" crossorigin="anonymous"></script>
388
+ <script>
389
+ window.onload = function() {
390
+ window.ui = SwaggerUIBundle({
391
+ url: ${JSON.stringify(specUrl)},
392
+ dom_id: '#swagger-ui',
393
+ deepLinking: true,
394
+ presets: [
395
+ SwaggerUIBundle.presets.apis,
396
+ SwaggerUIStandalonePreset
397
+ ],
398
+ plugins: [
399
+ SwaggerUIBundle.plugins.DownloadUrl
400
+ ],
401
+ layout: "StandaloneLayout",
402
+ defaultModelsExpandDepth: 1,
403
+ defaultModelExpandDepth: 1,
404
+ docExpansion: "list",
405
+ filter: true,
406
+ showRequestHeaders: true,
407
+ tryItOutEnabled: true
408
+ });
409
+ };
410
+ </script>
411
+ </body>
412
+ </html>`;
413
+ }
414
+
415
+ /**
416
+ * Create a Next.js route handler that serves Swagger UI for an OpenAPI route.
417
+ */
418
+ export function createSwaggerUIHandler(
419
+ options: CreateSwaggerUIHandlerOptions = {},
420
+ ): (req: Request) => Response {
421
+ return (req: Request) => {
422
+ const specUrl =
423
+ typeof options.specUrl === "function"
424
+ ? options.specUrl(req)
425
+ : (options.specUrl ?? "/api/openapi");
426
+ const headers = new Headers(resolveHeaders(options.headers, req));
427
+ if (!headers.has("content-type")) {
428
+ headers.set("content-type", "text/html; charset=utf-8");
429
+ }
430
+
431
+ return new Response(
432
+ createSwaggerUIHtml(options.title ?? "API Documentation", specUrl),
433
+ { headers },
434
+ );
435
+ };
436
+ }
437
+
438
+ /**
439
+ * Create Next.js route handlers that serve public storage objects.
440
+ *
441
+ * Missing objects, private objects, invalid keys, and paths outside `basePath`
442
+ * all return 404 so storage routes do not leak object existence.
443
+ */
444
+ export function createStorageRoute(
445
+ storage: StoragePort,
446
+ options: CreateStorageRouteOptions = {},
447
+ ): {
448
+ GET: (req: Request) => Promise<Response>;
449
+ HEAD: (req: Request) => Promise<Response>;
450
+ } {
451
+ const basePath = normalizeStorageBasePath(options.basePath);
452
+
453
+ return {
454
+ GET: (req) => storageResponse(storage, req, basePath, options, true),
455
+ HEAD: (req) => storageResponse(storage, req, basePath, options, false),
456
+ };
457
+ }
458
+
459
+ export async function createNextServer<
460
+ Ctx,
461
+ Ports extends AnyPorts = AnyPorts,
462
+ Routes extends readonly RouteDef<Ctx>[] = readonly RouteDef<Ctx>[],
463
+ Providers extends readonly AnyProvider[] = readonly AnyProvider[],
464
+ >(
465
+ options: CreateServerOptions<Ctx, Ports, Routes, Providers>,
466
+ ): Promise<NextServer<Ctx, Ports & ProvidedPortsOfList<Providers>>> {
467
+ const runtime = await createServer(options);
468
+
469
+ const wrap = (handler: (req: HttpRequestLike) => Promise<HttpResponse>) => {
470
+ return async (req: Request) => {
471
+ const res = await handler(toRequestLike(req));
472
+ return toNextResponse(res);
473
+ };
474
+ };
475
+
476
+ return {
477
+ api: wrap(runtime.api),
478
+ route: (contract) => ({
479
+ handle: (fn) => wrap(runtime.route(contract).handle(fn)),
480
+ }),
481
+ createContextFromNext: async () => {
482
+ const { cookies, headers } = await import("next/headers");
483
+ const h = await headers();
484
+ const c = await cookies();
485
+
486
+ // Minimal Request-like object for Next.js Server Components.
487
+ // Note: Server Components don't have a real HTTP URL; this is a synthetic placeholder.
488
+ // Do not rely on `url` for routing, security, or network behavior.
489
+ // Extended with cookies property for easier access in createContext.
490
+ // The json() and text() methods return empty values since there's no actual HTTP request body.
491
+ const req: HttpRequestLike & {
492
+ cookies?: { get: (key: string) => string | undefined };
493
+ } = {
494
+ method: "GET",
495
+ // Use a clearly non-real URL to avoid implying a real network endpoint.
496
+ url: "http://core/server-component.invalid",
497
+ headers: new Headers(h),
498
+ // Server Components don't have a request body, so these return empty values
499
+ json: async () => ({}),
500
+ text: async () => "",
501
+ arrayBuffer: async () => new ArrayBuffer(0),
502
+ blob: async () => new Blob(),
503
+ formData: async () => new FormData(),
504
+ clone: () => req,
505
+ cookies: {
506
+ get: (key: string) => c.get(key)?.value,
507
+ },
508
+ };
509
+
510
+ return options.createContext({
511
+ req,
512
+ ports: runtime.ports,
513
+ });
514
+ },
515
+ contracts: runtime.contracts,
516
+ stop: () => runtime.stop(),
517
+ ports: runtime.ports,
518
+ };
519
+ }