@rexeus/typeweaver-server 0.5.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/dist/index.cjs ADDED
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ var path = require('node:path');
4
+ var node_url = require('node:url');
5
+ var typeweaverGen = require('@rexeus/typeweaver-gen');
6
+ var typeweaverCore = require('@rexeus/typeweaver-core');
7
+ var Case = require('case');
8
+
9
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
10
+
11
+ var path__default = /*#__PURE__*/_interopDefault(path);
12
+ var Case__default = /*#__PURE__*/_interopDefault(Case);
13
+
14
+ // ../../node_modules/.pnpm/tsup@8.5.1_jiti@2.6.1_postcss@8.5.6_tsx@4.21.0_typescript@5.9.3/node_modules/tsup/assets/cjs_shims.js
15
+ var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.tagName.toUpperCase() === "SCRIPT" ? document.currentScript.src : new URL("main.js", document.baseURI).href;
16
+ var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
17
+ var RouterGenerator = class {
18
+ /**
19
+ * Generates router files for all resources in the given context.
20
+ *
21
+ * @param context - The generator context containing resources, templates, and output configuration
22
+ */
23
+ static generate(context) {
24
+ const moduleDir2 = path__default.default.dirname(node_url.fileURLToPath(importMetaUrl));
25
+ const templateFile = path__default.default.join(moduleDir2, "templates", "Router.ejs");
26
+ for (const [entityName, entityResource] of Object.entries(
27
+ context.resources.entityResources
28
+ )) {
29
+ this.writeRouter(
30
+ entityName,
31
+ templateFile,
32
+ entityResource.operations,
33
+ context
34
+ );
35
+ }
36
+ }
37
+ static writeRouter(entityName, templateFile, operationResources, context) {
38
+ const pascalCaseEntityName = Case__default.default.pascal(entityName);
39
+ const outputDir = path__default.default.join(context.outputDir, entityName);
40
+ const outputPath = path__default.default.join(outputDir, `${pascalCaseEntityName}Router.ts`);
41
+ const operations = operationResources.filter((resource) => resource.definition.method !== typeweaverCore.HttpMethod.HEAD).map((resource) => this.createOperationData(resource)).sort((a, b) => this.compareRoutes(a, b));
42
+ const content = context.renderTemplate(templateFile, {
43
+ coreDir: typeweaverGen.Path.relative(outputDir, context.outputDir),
44
+ entityName,
45
+ pascalCaseEntityName,
46
+ operations
47
+ });
48
+ const relativePath = path__default.default.relative(context.outputDir, outputPath);
49
+ context.writeFile(relativePath, content);
50
+ }
51
+ static createOperationData(resource) {
52
+ const className = Case__default.default.pascal(resource.definition.operationId);
53
+ return {
54
+ className,
55
+ handlerName: `handle${className}Request`,
56
+ method: resource.definition.method,
57
+ path: resource.definition.path
58
+ };
59
+ }
60
+ static compareRoutes(a, b) {
61
+ const aSegments = a.path.split("/").filter((s) => s);
62
+ const bSegments = b.path.split("/").filter((s) => s);
63
+ if (aSegments.length !== bSegments.length) {
64
+ return aSegments.length - bSegments.length;
65
+ }
66
+ for (let i = 0; i < aSegments.length; i++) {
67
+ const aSegment = aSegments[i];
68
+ const bSegment = bSegments[i];
69
+ const aIsParam = aSegment.startsWith(":");
70
+ const bIsParam = bSegment.startsWith(":");
71
+ if (aIsParam !== bIsParam) {
72
+ return aIsParam ? 1 : -1;
73
+ }
74
+ if (aSegment !== bSegment) {
75
+ return aSegment.localeCompare(bSegment);
76
+ }
77
+ }
78
+ return this.getMethodPriority(a.method) - this.getMethodPriority(b.method);
79
+ }
80
+ static METHOD_PRIORITY = {
81
+ GET: 1,
82
+ POST: 2,
83
+ PUT: 3,
84
+ PATCH: 4,
85
+ DELETE: 5,
86
+ OPTIONS: 6,
87
+ HEAD: 7
88
+ };
89
+ static getMethodPriority(method) {
90
+ return this.METHOD_PRIORITY[method] ?? 999;
91
+ }
92
+ };
93
+
94
+ // src/index.ts
95
+ var moduleDir = path__default.default.dirname(node_url.fileURLToPath(importMetaUrl));
96
+ var ServerPlugin = class extends typeweaverGen.BasePlugin {
97
+ name = "server";
98
+ /**
99
+ * Generates the server runtime and typed routers for all resources.
100
+ *
101
+ * @param context - The generator context
102
+ */
103
+ generate(context) {
104
+ const libSourceDir = path__default.default.join(moduleDir, "lib");
105
+ this.copyLibFiles(context, libSourceDir, this.name);
106
+ RouterGenerator.generate(context);
107
+ }
108
+ };
109
+
110
+ module.exports = ServerPlugin;
@@ -0,0 +1,20 @@
1
+ import { BasePlugin, GeneratorContext } from '@rexeus/typeweaver-gen';
2
+
3
+ /**
4
+ * Typeweaver plugin that generates a lightweight, dependency-free server
5
+ * with built-in routing and middleware support.
6
+ *
7
+ * Copies the runtime library files (`TypeweaverApp`, `TypeweaverRouter`, `Router`,
8
+ * `Middleware`, etc.) and generates typed router classes for each resource.
9
+ */
10
+ declare class ServerPlugin extends BasePlugin {
11
+ name: string;
12
+ /**
13
+ * Generates the server runtime and typed routers for all resources.
14
+ *
15
+ * @param context - The generator context
16
+ */
17
+ generate(context: GeneratorContext): void;
18
+ }
19
+
20
+ export { ServerPlugin as default };
@@ -0,0 +1,20 @@
1
+ import { BasePlugin, GeneratorContext } from '@rexeus/typeweaver-gen';
2
+
3
+ /**
4
+ * Typeweaver plugin that generates a lightweight, dependency-free server
5
+ * with built-in routing and middleware support.
6
+ *
7
+ * Copies the runtime library files (`TypeweaverApp`, `TypeweaverRouter`, `Router`,
8
+ * `Middleware`, etc.) and generates typed router classes for each resource.
9
+ */
10
+ declare class ServerPlugin extends BasePlugin {
11
+ name: string;
12
+ /**
13
+ * Generates the server runtime and typed routers for all resources.
14
+ *
15
+ * @param context - The generator context
16
+ */
17
+ generate(context: GeneratorContext): void;
18
+ }
19
+
20
+ export { ServerPlugin as default };
package/dist/index.js ADDED
@@ -0,0 +1,101 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { BasePlugin, Path } from '@rexeus/typeweaver-gen';
4
+ import { HttpMethod } from '@rexeus/typeweaver-core';
5
+ import Case from 'case';
6
+
7
+ // src/index.ts
8
+ var RouterGenerator = class {
9
+ /**
10
+ * Generates router files for all resources in the given context.
11
+ *
12
+ * @param context - The generator context containing resources, templates, and output configuration
13
+ */
14
+ static generate(context) {
15
+ const moduleDir2 = path.dirname(fileURLToPath(import.meta.url));
16
+ const templateFile = path.join(moduleDir2, "templates", "Router.ejs");
17
+ for (const [entityName, entityResource] of Object.entries(
18
+ context.resources.entityResources
19
+ )) {
20
+ this.writeRouter(
21
+ entityName,
22
+ templateFile,
23
+ entityResource.operations,
24
+ context
25
+ );
26
+ }
27
+ }
28
+ static writeRouter(entityName, templateFile, operationResources, context) {
29
+ const pascalCaseEntityName = Case.pascal(entityName);
30
+ const outputDir = path.join(context.outputDir, entityName);
31
+ const outputPath = path.join(outputDir, `${pascalCaseEntityName}Router.ts`);
32
+ const operations = operationResources.filter((resource) => resource.definition.method !== HttpMethod.HEAD).map((resource) => this.createOperationData(resource)).sort((a, b) => this.compareRoutes(a, b));
33
+ const content = context.renderTemplate(templateFile, {
34
+ coreDir: Path.relative(outputDir, context.outputDir),
35
+ entityName,
36
+ pascalCaseEntityName,
37
+ operations
38
+ });
39
+ const relativePath = path.relative(context.outputDir, outputPath);
40
+ context.writeFile(relativePath, content);
41
+ }
42
+ static createOperationData(resource) {
43
+ const className = Case.pascal(resource.definition.operationId);
44
+ return {
45
+ className,
46
+ handlerName: `handle${className}Request`,
47
+ method: resource.definition.method,
48
+ path: resource.definition.path
49
+ };
50
+ }
51
+ static compareRoutes(a, b) {
52
+ const aSegments = a.path.split("/").filter((s) => s);
53
+ const bSegments = b.path.split("/").filter((s) => s);
54
+ if (aSegments.length !== bSegments.length) {
55
+ return aSegments.length - bSegments.length;
56
+ }
57
+ for (let i = 0; i < aSegments.length; i++) {
58
+ const aSegment = aSegments[i];
59
+ const bSegment = bSegments[i];
60
+ const aIsParam = aSegment.startsWith(":");
61
+ const bIsParam = bSegment.startsWith(":");
62
+ if (aIsParam !== bIsParam) {
63
+ return aIsParam ? 1 : -1;
64
+ }
65
+ if (aSegment !== bSegment) {
66
+ return aSegment.localeCompare(bSegment);
67
+ }
68
+ }
69
+ return this.getMethodPriority(a.method) - this.getMethodPriority(b.method);
70
+ }
71
+ static METHOD_PRIORITY = {
72
+ GET: 1,
73
+ POST: 2,
74
+ PUT: 3,
75
+ PATCH: 4,
76
+ DELETE: 5,
77
+ OPTIONS: 6,
78
+ HEAD: 7
79
+ };
80
+ static getMethodPriority(method) {
81
+ return this.METHOD_PRIORITY[method] ?? 999;
82
+ }
83
+ };
84
+
85
+ // src/index.ts
86
+ var moduleDir = path.dirname(fileURLToPath(import.meta.url));
87
+ var ServerPlugin = class extends BasePlugin {
88
+ name = "server";
89
+ /**
90
+ * Generates the server runtime and typed routers for all resources.
91
+ *
92
+ * @param context - The generator context
93
+ */
94
+ generate(context) {
95
+ const libSourceDir = path.join(moduleDir, "lib");
96
+ this.copyLibFiles(context, libSourceDir, this.name);
97
+ RouterGenerator.generate(context);
98
+ }
99
+ };
100
+
101
+ export { ServerPlugin as default };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * This file was automatically generated by typeweaver.
3
+ * DO NOT EDIT. Instead, modify the source definition file and generate again.
4
+ *
5
+ * @generated by @rexeus/typeweaver
6
+ */
7
+
8
+ /**
9
+ * Error thrown when the request body cannot be parsed.
10
+ * Caught by TypeweaverApp to return a 400 Bad Request response.
11
+ */
12
+ export class BodyParseError extends Error {
13
+ public override readonly name = "BodyParseError";
14
+ }
15
+
16
+ /**
17
+ * Error thrown when the request body exceeds the configured size limit.
18
+ * Caught by TypeweaverApp to return a 413 Payload Too Large response.
19
+ */
20
+ export class PayloadTooLargeError extends Error {
21
+ public override readonly name = "PayloadTooLargeError";
22
+ public constructor(
23
+ public readonly contentLength: number,
24
+ public readonly maxBodySize: number
25
+ ) {
26
+ super(
27
+ `Request body too large: ${contentLength} bytes exceeds limit of ${maxBodySize} bytes`
28
+ );
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Error thrown when the response body cannot be serialized to JSON.
34
+ * Typically caused by circular references or non-serializable values.
35
+ */
36
+ export class ResponseSerializationError extends Error {
37
+ public override readonly name = "ResponseSerializationError";
38
+ }
@@ -0,0 +1,362 @@
1
+ /**
2
+ * This file was automatically generated by typeweaver.
3
+ * DO NOT EDIT. Instead, modify the source definition file and generate again.
4
+ *
5
+ * @generated by @rexeus/typeweaver
6
+ */
7
+
8
+ import type {
9
+ HttpMethod,
10
+ IHttpBody,
11
+ IHttpHeader,
12
+ IHttpQuery,
13
+ IHttpRequest,
14
+ IHttpResponse,
15
+ } from "@rexeus/typeweaver-core";
16
+ import {
17
+ BodyParseError,
18
+ PayloadTooLargeError,
19
+ ResponseSerializationError,
20
+ } from "./Errors";
21
+
22
+ export type FetchApiAdapterOptions = {
23
+ readonly maxBodySize?: number;
24
+ };
25
+
26
+ /**
27
+ * Converts between Fetch API `Request`/`Response` and typeweaver's
28
+ * `IHttpRequest`/`IHttpResponse` at the server boundary.
29
+ *
30
+ * This is the **only** place where framework-specific types exist.
31
+ * Everything inside the middleware pipeline and handlers works
32
+ * exclusively with typeweaver's native types.
33
+ *
34
+ * Works with all runtimes that support the Fetch API:
35
+ * Bun, Deno, Node.js (>=18), Cloudflare Workers.
36
+ */
37
+ export class FetchApiAdapter {
38
+ private static readonly DEFAULT_MAX_BODY_SIZE = 1_048_576; // 1 MB
39
+
40
+ private readonly maxBodySize: number;
41
+
42
+ public constructor(options?: FetchApiAdapterOptions) {
43
+ this.maxBodySize =
44
+ options?.maxBodySize ?? FetchApiAdapter.DEFAULT_MAX_BODY_SIZE;
45
+ }
46
+
47
+ /**
48
+ * Converts a Fetch API Request to an IHttpRequest.
49
+ *
50
+ * Accepts an optional pre-parsed URL to avoid redundant parsing.
51
+ *
52
+ * @param request - The Fetch API Request object
53
+ * @param url - Optional pre-parsed URL object to avoid double parsing
54
+ * @returns Promise resolving to an IHttpRequest
55
+ * @throws BodyParseError when the request body is malformed
56
+ */
57
+ public async toRequest(request: Request, url?: URL): Promise<IHttpRequest> {
58
+ const parsedUrl = url ?? new URL(request.url);
59
+
60
+ return {
61
+ method: request.method.toUpperCase() as HttpMethod,
62
+ path: parsedUrl.pathname,
63
+ header: FetchApiAdapter.extractHeaders(request.headers),
64
+ query: FetchApiAdapter.extractQueryParams(parsedUrl),
65
+ body: await this.parseRequestBody(request),
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Converts an IHttpResponse to a Fetch API Response.
71
+ *
72
+ * @param response - The IHttpResponse to convert
73
+ * @returns A Fetch API Response object
74
+ */
75
+ public toResponse(response: IHttpResponse): Response {
76
+ const { statusCode, body, header } = response;
77
+
78
+ return new Response(FetchApiAdapter.serializeResponseBody(body), {
79
+ status: statusCode,
80
+ headers: FetchApiAdapter.buildResponseHeaders(header, body),
81
+ });
82
+ }
83
+
84
+ private static extractMediaType(contentType: string | null): string | null {
85
+ if (!contentType) return null;
86
+ return contentType.split(";")[0]!.trim().toLowerCase();
87
+ }
88
+
89
+ private static isJsonContentType(contentType: string | null): boolean {
90
+ const mediaType = FetchApiAdapter.extractMediaType(contentType);
91
+ if (!mediaType) return false;
92
+ return mediaType === "application/json" || mediaType.endsWith("+json");
93
+ }
94
+
95
+ private static isTextContentType(contentType: string | null): boolean {
96
+ const mediaType = FetchApiAdapter.extractMediaType(contentType);
97
+ if (!mediaType) return false;
98
+ return mediaType.startsWith("text/");
99
+ }
100
+
101
+ private static isFormUrlencodedContentType(
102
+ contentType: string | null
103
+ ): boolean {
104
+ return (
105
+ FetchApiAdapter.extractMediaType(contentType) ===
106
+ "application/x-www-form-urlencoded"
107
+ );
108
+ }
109
+
110
+ private static isMultipartFormDataContentType(
111
+ contentType: string | null
112
+ ): boolean {
113
+ return (
114
+ FetchApiAdapter.extractMediaType(contentType) === "multipart/form-data"
115
+ );
116
+ }
117
+
118
+ private static extractHeaders(headers: Headers): IHttpHeader {
119
+ const result: Record<string, string | string[]> = Object.create(null);
120
+ headers.forEach((value, key) => {
121
+ FetchApiAdapter.addMultiValue(result, key, value);
122
+ });
123
+ return Object.keys(result).length > 0 ? result : undefined;
124
+ }
125
+
126
+ private static extractQueryParams(url: URL): IHttpQuery {
127
+ const result: Record<string, string | string[]> = Object.create(null);
128
+ url.searchParams.forEach((value, key) => {
129
+ FetchApiAdapter.addMultiValue(result, key, value);
130
+ });
131
+ return Object.keys(result).length > 0 ? result : undefined;
132
+ }
133
+
134
+ private async parseRequestBody(request: Request): Promise<IHttpBody> {
135
+ if (!request.body) return undefined;
136
+
137
+ const checkedRequest = await this.enforceBodySizeLimit(request);
138
+ const contentType = checkedRequest.headers.get("content-type");
139
+
140
+ if (FetchApiAdapter.isJsonContentType(contentType)) {
141
+ return FetchApiAdapter.parseJsonBody(checkedRequest);
142
+ }
143
+ if (FetchApiAdapter.isTextContentType(contentType)) {
144
+ return FetchApiAdapter.parseTextBody(checkedRequest);
145
+ }
146
+ if (FetchApiAdapter.isFormUrlencodedContentType(contentType)) {
147
+ return FetchApiAdapter.parseFormUrlencodedBody(checkedRequest);
148
+ }
149
+ if (FetchApiAdapter.isMultipartFormDataContentType(contentType)) {
150
+ return FetchApiAdapter.parseMultipartBody(checkedRequest);
151
+ }
152
+ return FetchApiAdapter.parseRawBody(checkedRequest);
153
+ }
154
+
155
+ private static async parseJsonBody(request: Request): Promise<IHttpBody> {
156
+ try {
157
+ const text = await request.text();
158
+ return JSON.parse(text, (key, value) => {
159
+ if (key === "__proto__") return undefined;
160
+ return value;
161
+ });
162
+ } catch (error) {
163
+ throw new BodyParseError("Invalid JSON in request body", {
164
+ cause: error,
165
+ });
166
+ }
167
+ }
168
+
169
+ private static async parseTextBody(request: Request): Promise<IHttpBody> {
170
+ try {
171
+ return await request.text();
172
+ } catch (error) {
173
+ throw new BodyParseError("Failed to read text request body", {
174
+ cause: error,
175
+ });
176
+ }
177
+ }
178
+
179
+ private static async parseFormUrlencodedBody(
180
+ request: Request
181
+ ): Promise<IHttpBody> {
182
+ let text: string;
183
+ try {
184
+ text = await request.text();
185
+ } catch (error) {
186
+ throw new BodyParseError("Failed to read form-urlencoded request body", {
187
+ cause: error,
188
+ });
189
+ }
190
+
191
+ const result: Record<string, string | string[]> = Object.create(null);
192
+ new URLSearchParams(text).forEach((value, key) => {
193
+ FetchApiAdapter.addMultiValue(result, key, value);
194
+ });
195
+ return result;
196
+ }
197
+
198
+ private static async parseMultipartBody(
199
+ request: Request
200
+ ): Promise<IHttpBody> {
201
+ let formData: FormData;
202
+ try {
203
+ formData = await request.formData();
204
+ } catch (error) {
205
+ throw new BodyParseError("Invalid multipart/form-data in request body", {
206
+ cause: error,
207
+ });
208
+ }
209
+
210
+ const result: Record<string, string | File | (string | File)[]> =
211
+ Object.create(null);
212
+ formData.forEach((value, key) => {
213
+ const existing = result[key];
214
+ if (existing === undefined) {
215
+ result[key] = value;
216
+ } else if (Array.isArray(existing)) {
217
+ existing.push(value);
218
+ } else {
219
+ result[key] = [existing, value];
220
+ }
221
+ });
222
+ return result;
223
+ }
224
+
225
+ private static async parseRawBody(request: Request): Promise<IHttpBody> {
226
+ try {
227
+ const text = await request.text();
228
+ return text || undefined;
229
+ } catch (error) {
230
+ throw new BodyParseError("Failed to read request body", {
231
+ cause: error,
232
+ });
233
+ }
234
+ }
235
+
236
+ private async enforceBodySizeLimit(request: Request): Promise<Request> {
237
+ const contentLengthHeader = request.headers.get("content-length");
238
+ if (contentLengthHeader === null) {
239
+ return this.readBodyWithLimit(request);
240
+ }
241
+
242
+ const contentLength = Number(contentLengthHeader);
243
+ if (!Number.isFinite(contentLength) || contentLength < 0) {
244
+ return this.readBodyWithLimit(request);
245
+ }
246
+
247
+ if (contentLength > this.maxBodySize) {
248
+ throw new PayloadTooLargeError(contentLength, this.maxBodySize);
249
+ }
250
+
251
+ return request;
252
+ }
253
+
254
+ private async readBodyWithLimit(request: Request): Promise<Request> {
255
+ if (!request.body) return request;
256
+
257
+ const reader = request.body.getReader();
258
+ const chunks: Uint8Array[] = [];
259
+ let totalBytes = 0;
260
+
261
+ try {
262
+ for (;;) {
263
+ const { done, value } = await reader.read();
264
+ if (done) break;
265
+
266
+ totalBytes += value.byteLength;
267
+ if (totalBytes > this.maxBodySize) {
268
+ await reader.cancel();
269
+ throw new PayloadTooLargeError(totalBytes, this.maxBodySize);
270
+ }
271
+ chunks.push(value);
272
+ }
273
+ } finally {
274
+ reader.releaseLock();
275
+ }
276
+
277
+ return new Request(request.url, {
278
+ method: request.method,
279
+ headers: request.headers,
280
+ body: FetchApiAdapter.concatChunks(chunks, totalBytes),
281
+ });
282
+ }
283
+
284
+ private static concatChunks(
285
+ chunks: Uint8Array[],
286
+ totalBytes: number
287
+ ): Uint8Array {
288
+ const buffer = new Uint8Array(totalBytes);
289
+ let offset = 0;
290
+ for (const chunk of chunks) {
291
+ buffer.set(chunk, offset);
292
+ offset += chunk.byteLength;
293
+ }
294
+ return buffer;
295
+ }
296
+
297
+ private static serializeResponseBody(
298
+ body: any
299
+ ): string | ArrayBuffer | Blob | null {
300
+ if (body === undefined || body === null) return null;
301
+ if (typeof body === "string") return body;
302
+ if (body instanceof Blob || body instanceof ArrayBuffer) return body;
303
+
304
+ try {
305
+ return JSON.stringify(body);
306
+ } catch (error) {
307
+ throw new ResponseSerializationError(
308
+ "Failed to serialize response body to JSON",
309
+ { cause: error }
310
+ );
311
+ }
312
+ }
313
+
314
+ private static buildResponseHeaders(
315
+ header?: IHttpHeader,
316
+ body?: any
317
+ ): Headers {
318
+ const headers = new Headers();
319
+
320
+ if (header) {
321
+ for (const [key, value] of Object.entries(header)) {
322
+ if (value === undefined) continue;
323
+ if (Array.isArray(value)) {
324
+ for (const v of value) headers.append(key, v);
325
+ } else {
326
+ headers.set(key, String(value));
327
+ }
328
+ }
329
+ }
330
+
331
+ if (!headers.has("content-type") && FetchApiAdapter.isJsonBody(body)) {
332
+ headers.set("content-type", "application/json");
333
+ }
334
+
335
+ return headers;
336
+ }
337
+
338
+ private static isJsonBody(body: any): boolean {
339
+ return (
340
+ body !== undefined &&
341
+ body !== null &&
342
+ typeof body !== "string" &&
343
+ !(body instanceof Blob) &&
344
+ !(body instanceof ArrayBuffer)
345
+ );
346
+ }
347
+
348
+ private static addMultiValue(
349
+ record: Record<string, string | string[]>,
350
+ key: string,
351
+ value: string
352
+ ): void {
353
+ const existing = record[key];
354
+ if (existing === undefined) {
355
+ record[key] = value;
356
+ } else if (Array.isArray(existing)) {
357
+ existing.push(value);
358
+ } else {
359
+ record[key] = [existing, value];
360
+ }
361
+ }
362
+ }