@zubyjs/next 1.0.83

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/nft.js ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Dependency tracing using @vercel/nft
3
+ * Finds all imports and dependencies of API route files
4
+ * and updates imports to point to original folders
5
+ */
6
+ import { dirname, resolve, relative } from 'path';
7
+ import { existsSync } from 'fs';
8
+ /**
9
+ * Trace all imports in a file and create a map for rewriting them
10
+ * This analyzes the wrapper file location and creates relative paths
11
+ * back to the original import locations
12
+ */
13
+ export async function createImportMap(apiFilePath, wrapperFilePath) {
14
+ const importMap = {};
15
+ try {
16
+ const fs = await import('fs/promises');
17
+ const content = await fs.readFile(apiFilePath, 'utf-8');
18
+ // Parse import/require statements
19
+ const importRegex = /(?:import|require)\s*\(?\s*['"]([^'"]+)['"]\s*\)?/g;
20
+ let match;
21
+ while ((match = importRegex.exec(content)) !== null) {
22
+ const importPath = match[1];
23
+ // Skip external packages and absolute paths
24
+ if (importPath.startsWith('@') ||
25
+ importPath.startsWith('/') ||
26
+ !importPath.includes('.') ||
27
+ importPath.includes('zuby')) {
28
+ continue;
29
+ }
30
+ // Resolve the import path relative to the API file
31
+ const resolvedPath = resolveImportPath(apiFilePath, importPath);
32
+ if (resolvedPath) {
33
+ // Calculate the correct relative path from wrapper to the actual import
34
+ const wrapperDir = dirname(wrapperFilePath);
35
+ const correctRelativePath = relative(wrapperDir, resolvedPath);
36
+ // Normalize for cross-platform compatibility
37
+ const normalizedPath = correctRelativePath.replace(/\\/g, '/');
38
+ importMap[importPath] = normalizedPath.startsWith('.')
39
+ ? normalizedPath
40
+ : `./${normalizedPath}`;
41
+ }
42
+ }
43
+ }
44
+ catch (error) {
45
+ console.warn(`Failed to create import map for ${apiFilePath}:`, error);
46
+ }
47
+ return importMap;
48
+ }
49
+ /**
50
+ * Resolve import path relative to the importing file
51
+ */
52
+ export function resolveImportPath(fromFile, importPath) {
53
+ const fromDir = dirname(fromFile);
54
+ // Try the import path as-is
55
+ let resolved = resolve(fromDir, importPath);
56
+ if (existsSync(resolved))
57
+ return resolved;
58
+ // Try with .ts extension
59
+ if (existsSync(`${resolved}.ts`))
60
+ return `${resolved}.ts`;
61
+ if (existsSync(`${resolved}.tsx`))
62
+ return `${resolved}.tsx`;
63
+ if (existsSync(`${resolved}.js`))
64
+ return `${resolved}.js`;
65
+ if (existsSync(`${resolved}.jsx`))
66
+ return `${resolved}.jsx`;
67
+ if (existsSync(`${resolved}.mjs`))
68
+ return `${resolved}.mjs`;
69
+ if (existsSync(`${resolved}.cjs`))
70
+ return `${resolved}.cjs`;
71
+ // Try as directory with index file
72
+ if (existsSync(`${resolved}/index.ts`))
73
+ return `${resolved}/index.ts`;
74
+ if (existsSync(`${resolved}/index.tsx`))
75
+ return `${resolved}/index.tsx`;
76
+ if (existsSync(`${resolved}/index.js`))
77
+ return `${resolved}/index.js`;
78
+ if (existsSync(`${resolved}/index.jsx`))
79
+ return `${resolved}/index.jsx`;
80
+ return null;
81
+ }
82
+ /**
83
+ * Rewrite imports in wrapper content to point to original locations
84
+ * Updates relative imports to reference the correct paths from zuby-pages
85
+ */
86
+ export function rewriteImportsInWrapper(wrapperContent, importMap) {
87
+ let updatedContent = wrapperContent;
88
+ for (const [originalPath, correctPath] of Object.entries(importMap)) {
89
+ // Match different import/require patterns
90
+ const patterns = [
91
+ new RegExp(`from\\s+['"]${escapeRegex(originalPath)}['"]`, 'g'),
92
+ new RegExp(`require\\s*\\(\\s*['"]${escapeRegex(originalPath)}['"]\\s*\\)`, 'g'),
93
+ new RegExp(`import\\s+['"]${escapeRegex(originalPath)}['"]`, 'g'),
94
+ ];
95
+ for (const pattern of patterns) {
96
+ updatedContent = updatedContent.replace(pattern, (match) => {
97
+ return match.replace(originalPath, correctPath);
98
+ });
99
+ }
100
+ }
101
+ return updatedContent;
102
+ }
103
+ /**
104
+ * Escape special regex characters
105
+ */
106
+ function escapeRegex(str) {
107
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
108
+ }
109
+ /**
110
+ * Get all relative imports from a file
111
+ */
112
+ export async function getRelativeImports(filePath) {
113
+ const imports = [];
114
+ try {
115
+ const fs = await import('fs/promises');
116
+ const content = await fs.readFile(filePath, 'utf-8');
117
+ // Parse import/require statements
118
+ const importRegex = /(?:import|require)\s*\(?\s*['"]([^'"]+)['"]\s*\)?/g;
119
+ let match;
120
+ while ((match = importRegex.exec(content)) !== null) {
121
+ const importPath = match[1];
122
+ // Only include relative imports
123
+ if (importPath.startsWith('.')) {
124
+ imports.push(importPath);
125
+ }
126
+ }
127
+ }
128
+ catch (error) {
129
+ console.warn(`Could not read ${filePath}:`, error);
130
+ }
131
+ return imports;
132
+ }
133
+ /**
134
+ * Get all external dependencies (not relative imports)
135
+ */
136
+ export async function getExternalDependencies(filePath) {
137
+ const external = [];
138
+ try {
139
+ const fs = await import('fs/promises');
140
+ const content = await fs.readFile(filePath, 'utf-8');
141
+ // Parse import/require statements
142
+ const importRegex = /(?:import|require)\s*\(?\s*['"]([^'"]+)['"]\s*\)?/g;
143
+ let match;
144
+ while ((match = importRegex.exec(content)) !== null) {
145
+ const importPath = match[1];
146
+ // Only include external packages (not relative imports or zuby)
147
+ if (!importPath.startsWith('.') &&
148
+ !importPath.startsWith('/') &&
149
+ !importPath.includes('zuby')) {
150
+ external.push(importPath);
151
+ }
152
+ }
153
+ }
154
+ catch (error) {
155
+ console.warn(`Could not read ${filePath}:`, error);
156
+ }
157
+ return external;
158
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@zubyjs/next",
3
+ "version": "1.0.83",
4
+ "description": "Zuby.js Next.js compatibility plugin",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "scripts": {
8
+ "release": "cd ./dist && npm publish --access public && cd ..",
9
+ "bump-version": "npm version patch",
10
+ "build": "rm -rf dist/ stage/ && mkdir dist && tsc && cp -rf package.json README.md stage/next/src/* dist/ && rm -rf stage/",
11
+ "push-build": "npm run build && cd dist && yalc push --force && cd ..",
12
+ "test": "vitest run",
13
+ "test:coverage": "vitest run --coverage"
14
+ },
15
+ "publishConfig": {
16
+ "directory": "dist",
17
+ "linkDirectory": true
18
+ },
19
+ "peerDependencies": {
20
+ "zuby": "^1.0.0"
21
+ },
22
+ "bugs": {
23
+ "url": "https://gitlab.com/futrou/zuby.js/-/issues",
24
+ "email": "zuby@futrou.com"
25
+ },
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://gitlab.com/futrou/zuby.js.git"
30
+ },
31
+ "homepage": "https://zubyjs.com",
32
+ "keywords": [
33
+ "zuby-plugin",
34
+ "zuby",
35
+ "next",
36
+ "seo"
37
+ ],
38
+ "engines": {
39
+ "node": ">=18"
40
+ }
41
+ }
package/types.d.ts ADDED
@@ -0,0 +1,125 @@
1
+ /// <reference types="node" resolution-mode="require"/>
2
+ /// <reference types="node" resolution-mode="require"/>
3
+ /**
4
+ * Next.js API request object for compatibility
5
+ * Represents an incoming HTTP request in an API route
6
+ */
7
+ export interface NextApiRequest {
8
+ /**
9
+ * The request URL
10
+ */
11
+ url?: string;
12
+ /**
13
+ * The request method (GET, POST, PUT, DELETE, PATCH, etc.)
14
+ */
15
+ method?: string;
16
+ /**
17
+ * Query parameters from the URL
18
+ * @example { id: '123', name: 'test' }
19
+ */
20
+ query: Record<string, string | string[]>;
21
+ /**
22
+ * Request cookies
23
+ * @example { sessionId: 'abc123' }
24
+ */
25
+ cookies: Record<string, string>;
26
+ /**
27
+ * Request headers
28
+ */
29
+ headers: Record<string, string | string[] | undefined>;
30
+ /**
31
+ * The request body
32
+ */
33
+ body?: any;
34
+ /**
35
+ * The base URL
36
+ */
37
+ baseUrl?: string;
38
+ /**
39
+ * The path after removing query string
40
+ */
41
+ pathname?: string;
42
+ /**
43
+ * HTTP version
44
+ */
45
+ httpVersion?: string;
46
+ /**
47
+ * The IP address of the requester
48
+ */
49
+ socket?: {
50
+ remoteAddress?: string;
51
+ };
52
+ }
53
+ /**
54
+ * Generic Next.js API response object for compatibility
55
+ * Represents the response object in an API route with typed body
56
+ * @template TBody The type of the response body
57
+ */
58
+ export interface NextApiResponse<TBody = any> {
59
+ /**
60
+ * HTTP status code
61
+ */
62
+ statusCode: number;
63
+ /**
64
+ * Response headers
65
+ */
66
+ headers: Record<string, string | string[] | number>;
67
+ /**
68
+ * Set the status code (chainable)
69
+ */
70
+ status(code: number): this;
71
+ /**
72
+ * Set a header (chainable)
73
+ */
74
+ setHeader(name: string, value: string | string[] | number): this;
75
+ /**
76
+ * Get a header
77
+ */
78
+ getHeader(name: string): string | string[] | number | undefined;
79
+ /**
80
+ * Check if header was sent
81
+ */
82
+ headersSent: boolean;
83
+ /**
84
+ * Send a JSON response
85
+ */
86
+ json(body: TBody): void;
87
+ /**
88
+ * Send a text response
89
+ */
90
+ send(body: any): void;
91
+ /**
92
+ * Send a specific status with body
93
+ */
94
+ end(body?: any): void;
95
+ /**
96
+ * Write data to the response
97
+ */
98
+ write(chunk: string | Buffer): void;
99
+ /**
100
+ * Redirect to a URL
101
+ */
102
+ redirect(statusOrUrl: string | number, url?: string): void;
103
+ /**
104
+ * The actual response body
105
+ */
106
+ _responseBody?: any;
107
+ /**
108
+ * Whether response has been sent
109
+ */
110
+ _isResponseSent?: boolean;
111
+ }
112
+ /**
113
+ * Next.js API handler type for single method handlers
114
+ * @template TBody The type of the response body
115
+ * @example
116
+ * export default function handler(req: NextApiRequest, res: NextApiResponse<string>) {
117
+ * return new Response('Hello world');
118
+ * }
119
+ */
120
+ export type NextApiHandler<TBody = any> = (req: NextApiRequest, res: NextApiResponse<TBody>) => Response | Promise<Response> | void | Promise<void>;
121
+ /**
122
+ * Next.js API method handler for specific HTTP methods
123
+ * @template TBody The type of the response body
124
+ */
125
+ export type NextApiMethodHandler<TBody = any> = (req: NextApiRequest, res: NextApiResponse<TBody>) => Response | Promise<Response> | void | Promise<void>;
package/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/utils.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ import type { TemplateFile } from 'zuby/templates/types.js';
2
+ export interface NextPage extends TemplateFile {
3
+ hasGetStaticProps?: boolean;
4
+ hasGetServerSideProps?: boolean;
5
+ }
6
+ /**
7
+ * Scan the Next.js pages/api directory and convert to Zuby handler paths
8
+ * @param pagesDir The pages directory path
9
+ * @returns Array of TemplateFile objects for handlers
10
+ */
11
+ export declare function scanNextApiPages(pagesDir: string): Promise<TemplateFile[]>;
12
+ /**
13
+ * Scan the Next.js pages directory for React pages (excluding api subdirectory)
14
+ * @param pagesDir The pages directory path
15
+ * @returns Array of NextPage objects
16
+ */
17
+ export declare function scanNextPages(pagesDir: string): Promise<NextPage[]>;
18
+ /**
19
+ * Convert Next.js API route module to Zuby handler format
20
+ * Wraps default export and method exports (get, post, put, delete, patch)
21
+ * to return Response objects compatible with Zuby.js
22
+ */
23
+ export declare function wrapNextApiModule(module: any): any;
package/utils.js ADDED
@@ -0,0 +1,336 @@
1
+ import { relative } from 'path';
2
+ import { readdir, stat } from 'fs/promises';
3
+ /**
4
+ * Scan the Next.js pages/api directory and convert to Zuby handler paths
5
+ * @param pagesDir The pages directory path
6
+ * @returns Array of TemplateFile objects for handlers
7
+ */
8
+ export async function scanNextApiPages(pagesDir) {
9
+ const apiDir = `${pagesDir}/api`;
10
+ const handlers = [];
11
+ try {
12
+ await scanDirectory(apiDir, apiDir, handlers, 'handler');
13
+ }
14
+ catch (error) {
15
+ // API directory might not exist, which is fine
16
+ if (error?.code !== 'ENOENT') {
17
+ throw error;
18
+ }
19
+ }
20
+ return handlers;
21
+ }
22
+ /**
23
+ * Scan the Next.js pages directory for React pages (excluding api subdirectory)
24
+ * @param pagesDir The pages directory path
25
+ * @returns Array of NextPage objects
26
+ */
27
+ export async function scanNextPages(pagesDir) {
28
+ const pages = [];
29
+ try {
30
+ await scanPageDirectory(pagesDir, pagesDir, pages);
31
+ }
32
+ catch (error) {
33
+ // Pages directory might not exist, which is fine
34
+ if (error?.code !== 'ENOENT') {
35
+ throw error;
36
+ }
37
+ }
38
+ return pages;
39
+ }
40
+ /**
41
+ * Recursively scan a directory for API route files or page files
42
+ */
43
+ async function scanDirectory(currentDir, baseDir, items, type = 'handler') {
44
+ const entries = await readdir(currentDir, { withFileTypes: true });
45
+ for (const entry of entries) {
46
+ const fullPath = `${currentDir}/${entry.name}`;
47
+ const stats = await stat(fullPath);
48
+ if (stats.isDirectory()) {
49
+ await scanDirectory(fullPath, baseDir, items, type);
50
+ }
51
+ else {
52
+ const relativePath = relative(baseDir, fullPath);
53
+ const routePath = `/${relativePath
54
+ .replace(/\\/g, '/')
55
+ .replace(/\.(tsx?|jsx?)$/, '')
56
+ .replace(/\/index$/, '')
57
+ .replace(/\[(.+?)\]/g, ':$1')}`;
58
+ items.push({
59
+ path: routePath,
60
+ filename: fullPath,
61
+ templateType: type,
62
+ });
63
+ }
64
+ }
65
+ }
66
+ /**
67
+ * Recursively scan the pages directory for React page components
68
+ * Excludes the api subdirectory
69
+ */
70
+ async function scanPageDirectory(currentDir, baseDir, pages) {
71
+ const entries = await readdir(currentDir, { withFileTypes: true });
72
+ for (const entry of entries) {
73
+ // Skip api directory
74
+ if (entry.isDirectory() && entry.name === 'api') {
75
+ continue;
76
+ }
77
+ const fullPath = `${currentDir}/${entry.name}`;
78
+ const stats = await stat(fullPath);
79
+ if (stats.isDirectory()) {
80
+ await scanPageDirectory(fullPath, baseDir, pages);
81
+ }
82
+ else {
83
+ const content = await readFileContent(fullPath);
84
+ const hasGetStaticProps = /export\s+(async\s+)?function\s+getStaticProps|export\s+const\s+getStaticProps/.test(content);
85
+ const hasGetServerSideProps = /export\s+(async\s+)?function\s+getServerSideProps|export\s+const\s+getServerSideProps/.test(content);
86
+ const relativePath = relative(baseDir, fullPath);
87
+ const routePath = `/${relativePath
88
+ .replace(/\\/g, '/')
89
+ .replace(/\.(tsx?)$/, '')
90
+ .replace(/\/index$/, '')}`;
91
+ pages.push({
92
+ path: routePath,
93
+ filename: fullPath,
94
+ templateType: 'page',
95
+ hasGetStaticProps,
96
+ hasGetServerSideProps,
97
+ });
98
+ }
99
+ }
100
+ }
101
+ /**
102
+ * Read file content as string
103
+ */
104
+ async function readFileContent(filePath) {
105
+ try {
106
+ const fs = await import('fs/promises');
107
+ return await fs.readFile(filePath, 'utf-8');
108
+ }
109
+ catch {
110
+ return '';
111
+ }
112
+ }
113
+ /**
114
+ * Convert Next.js API route module to Zuby handler format
115
+ * Wraps default export and method exports (get, post, put, delete, patch)
116
+ * to return Response objects compatible with Zuby.js
117
+ */
118
+ export function wrapNextApiModule(module) {
119
+ const wrapped = {
120
+ __nextApiHandlers: true,
121
+ };
122
+ // Handle default export (all methods)
123
+ if (typeof module.default === 'function') {
124
+ wrapped.default = wrapHandler(module.default);
125
+ }
126
+ // Handle method-specific exports
127
+ const httpMethods = ['get', 'post', 'put', 'delete', 'patch', 'head', 'options'];
128
+ for (const method of httpMethods) {
129
+ if (typeof module[method] === 'function') {
130
+ wrapped[method.toUpperCase()] = wrapHandler(module[method]);
131
+ }
132
+ }
133
+ return wrapped;
134
+ }
135
+ /**
136
+ * Wrap a Next.js API handler to return a Response object
137
+ */
138
+ function wrapHandler(handler) {
139
+ return async (context) => {
140
+ const req = createNextApiRequest(context);
141
+ const res = createNextApiResponse();
142
+ try {
143
+ const result = await handler(req, res);
144
+ // If handler returns a Response, use it
145
+ if (result instanceof Response) {
146
+ return result;
147
+ }
148
+ // If response was explicitly sent, use that
149
+ if (res._isResponseSent && res._responseBody !== undefined) {
150
+ const body = res._responseBody;
151
+ const contentType = res.headers['content-type'] || 'application/json';
152
+ return new Response(typeof body === 'string' ? body : JSON.stringify(body), {
153
+ status: res.statusCode,
154
+ headers: normalizeHeaders(res.headers),
155
+ });
156
+ }
157
+ // Default: return empty 200 response
158
+ return new Response(null, {
159
+ status: res.statusCode || 200,
160
+ headers: normalizeHeaders(res.headers),
161
+ });
162
+ }
163
+ catch (error) {
164
+ // Return error response
165
+ return new Response(JSON.stringify({
166
+ error: 'Internal Server Error',
167
+ message: error instanceof Error ? error.message : 'Unknown error',
168
+ }), {
169
+ status: 500,
170
+ headers: { 'content-type': 'application/json' },
171
+ });
172
+ }
173
+ };
174
+ }
175
+ /**
176
+ * Create a Next.js API request object from Zuby context
177
+ */
178
+ function createNextApiRequest(context) {
179
+ const url = new URL(context.url || '/', 'http://localhost');
180
+ const query = {};
181
+ // Parse query parameters
182
+ url.searchParams.forEach((value, key) => {
183
+ if (query[key]) {
184
+ if (Array.isArray(query[key])) {
185
+ query[key].push(value);
186
+ }
187
+ else {
188
+ query[key] = [query[key], value];
189
+ }
190
+ }
191
+ else {
192
+ query[key] = value;
193
+ }
194
+ });
195
+ // Add path parameters
196
+ if (context.params) {
197
+ Object.assign(query, context.params);
198
+ }
199
+ const req = {
200
+ url: context.url || '/',
201
+ method: context.request?.method || 'GET',
202
+ query,
203
+ cookies: parseCookies(context.request?.headers?.get('cookie')),
204
+ headers: headersToRecord(context.request?.headers),
205
+ baseUrl: `${url.protocol}//${url.host}`,
206
+ pathname: url.pathname,
207
+ body: context.body,
208
+ };
209
+ // Add socket info for client IP
210
+ if (context.clientAddress) {
211
+ req.socket = { remoteAddress: context.clientAddress };
212
+ }
213
+ return req;
214
+ }
215
+ /**
216
+ * Create a Next.js API response object
217
+ */
218
+ function createNextApiResponse() {
219
+ const res = {
220
+ statusCode: 200,
221
+ headers: {},
222
+ headersSent: false,
223
+ _responseBody: undefined,
224
+ _isResponseSent: false,
225
+ status(code) {
226
+ this.statusCode = code;
227
+ return this;
228
+ },
229
+ setHeader(name, value) {
230
+ this.headers[name.toLowerCase()] = value;
231
+ return this;
232
+ },
233
+ getHeader(name) {
234
+ return this.headers[name.toLowerCase()];
235
+ },
236
+ json(body) {
237
+ this.setHeader('content-type', 'application/json');
238
+ this._responseBody = JSON.stringify(body);
239
+ this._isResponseSent = true;
240
+ return this;
241
+ },
242
+ send(body) {
243
+ this._responseBody = body;
244
+ this._isResponseSent = true;
245
+ if (typeof body === 'string') {
246
+ this.setHeader('content-type', 'text/plain');
247
+ }
248
+ else if (typeof body === 'object') {
249
+ this.setHeader('content-type', 'application/json');
250
+ this._responseBody = JSON.stringify(body);
251
+ }
252
+ return this;
253
+ },
254
+ end(body) {
255
+ if (body !== undefined) {
256
+ this.send(body);
257
+ }
258
+ this._isResponseSent = true;
259
+ return this;
260
+ },
261
+ write(chunk) {
262
+ if (!this._responseBody) {
263
+ this._responseBody = '';
264
+ }
265
+ this._responseBody += chunk;
266
+ return true;
267
+ },
268
+ redirect(statusOrUrl, url) {
269
+ if (typeof statusOrUrl === 'string') {
270
+ this.statusCode = 302;
271
+ this._responseBody = statusOrUrl;
272
+ }
273
+ else {
274
+ this.statusCode = statusOrUrl;
275
+ this._responseBody = url;
276
+ }
277
+ this.setHeader('location', typeof statusOrUrl === 'string' ? statusOrUrl : url || '');
278
+ this._isResponseSent = true;
279
+ return this;
280
+ },
281
+ };
282
+ return res;
283
+ }
284
+ /**
285
+ * Parse cookies from cookie header string
286
+ */
287
+ function parseCookies(cookieHeader) {
288
+ const cookies = {};
289
+ if (!cookieHeader)
290
+ return cookies;
291
+ cookieHeader.split(';').forEach((cookie) => {
292
+ const [name, value] = cookie.trim().split('=');
293
+ if (name && value) {
294
+ cookies[name] = decodeURIComponent(value);
295
+ }
296
+ });
297
+ return cookies;
298
+ }
299
+ /**
300
+ * Convert Headers object to Record
301
+ */
302
+ function headersToRecord(headers) {
303
+ const record = {};
304
+ if (!headers)
305
+ return record;
306
+ headers.forEach((value, key) => {
307
+ const lowerKey = key.toLowerCase();
308
+ if (record[lowerKey]) {
309
+ if (Array.isArray(record[lowerKey])) {
310
+ record[lowerKey].push(value);
311
+ }
312
+ else {
313
+ record[lowerKey] = [record[lowerKey], value];
314
+ }
315
+ }
316
+ else {
317
+ record[lowerKey] = value;
318
+ }
319
+ });
320
+ return record;
321
+ }
322
+ /**
323
+ * Normalize headers to Response headers format
324
+ */
325
+ function normalizeHeaders(headers) {
326
+ const normalized = {};
327
+ Object.entries(headers).forEach(([key, value]) => {
328
+ if (Array.isArray(value)) {
329
+ normalized[key] = value.join(', ');
330
+ }
331
+ else {
332
+ normalized[key] = String(value);
333
+ }
334
+ });
335
+ return normalized;
336
+ }