@sdkgen/node-runtime 0.0.0-dev.20230929190043 → 0.0.0-dev.20231002144112

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.
@@ -0,0 +1,1137 @@
1
+ import { randomBytes } from "crypto";
2
+ import { createReadStream, createWriteStream, unlink } from "fs";
3
+ import type { IncomingMessage, Server, ServerResponse } from "http";
4
+ import { createServer } from "http";
5
+ import type { AddressInfo } from "net";
6
+ import { hostname } from "os";
7
+ import { parse as parseQuerystring } from "querystring";
8
+ import { parse as parseUrl } from "url";
9
+ import { promisify } from "util";
10
+
11
+ import { generateCSharpServerSource } from "@sdkgen/csharp-generator";
12
+ import { generateDartClientSource } from "@sdkgen/dart-generator";
13
+ import { generateFSharpServerSource } from "@sdkgen/fsharp-generator";
14
+ import { generateAndroidClientSource } from "@sdkgen/kotlin-generator";
15
+ import type { AstRoot } from "@sdkgen/parser";
16
+ import {
17
+ StatusCodeAnnotation,
18
+ DecimalPrimitiveType,
19
+ Base64PrimitiveType,
20
+ BigIntPrimitiveType,
21
+ BoolPrimitiveType,
22
+ BytesPrimitiveType,
23
+ CnpjPrimitiveType,
24
+ CpfPrimitiveType,
25
+ DatePrimitiveType,
26
+ DateTimePrimitiveType,
27
+ FloatPrimitiveType,
28
+ HexPrimitiveType,
29
+ HtmlPrimitiveType,
30
+ IntPrimitiveType,
31
+ MoneyPrimitiveType,
32
+ OptionalType,
33
+ RestAnnotation,
34
+ StringPrimitiveType,
35
+ UIntPrimitiveType,
36
+ UuidPrimitiveType,
37
+ VoidPrimitiveType,
38
+ XmlPrimitiveType,
39
+ } from "@sdkgen/parser";
40
+ import { PLAYGROUND_PUBLIC_PATH } from "@sdkgen/playground";
41
+ import { generateSwiftClientSource } from "@sdkgen/swift-generator";
42
+ import { generateBrowserClientSource, generateNodeClientSource, generateNodeServerSource } from "@sdkgen/typescript-generator";
43
+ import Busboy from "busboy";
44
+ import FileType from "file-type";
45
+ import { getClientIp } from "request-ip";
46
+ import staticFilesHandler from "serve-handler";
47
+
48
+ import type { BaseApiConfig } from "./api-config";
49
+ import type { Context, ContextReply, ContextRequest } from "./context";
50
+ import { decode, encode } from "./encode-decode";
51
+ import { Fatal } from "./error";
52
+ import { executeRequest } from "./execute";
53
+ import { setupSwagger } from "./swagger";
54
+ import { has } from "./utils";
55
+
56
+ export class SdkgenHttpServer<ExtraContextT = unknown> {
57
+ public httpServer: Server;
58
+
59
+ private readonly headers = new Map<string, string>();
60
+
61
+ private readonly healthChecks: Array<() => Promise<boolean>> = [];
62
+
63
+ private handlers: Array<{
64
+ method: string;
65
+ matcher: string | RegExp;
66
+ handler(req: IncomingMessage, res: ServerResponse, body: Buffer): void;
67
+ }> = [];
68
+
69
+ public dynamicCorsOrigin = true;
70
+
71
+ public introspection = true;
72
+
73
+ public log = (message: string) => {
74
+ console.log(`${new Date().toISOString()} ${message}`);
75
+ };
76
+
77
+ private hasSwagger = false;
78
+
79
+ private ignoredUrlPrefix = "";
80
+
81
+ private extraContext: ExtraContextT;
82
+
83
+ constructor(
84
+ public apiConfig: BaseApiConfig<ExtraContextT>,
85
+ ...maybeExtraContext: {} extends ExtraContextT ? [{}?] : [ExtraContextT]
86
+ ) {
87
+ this.extraContext = (maybeExtraContext[0] ?? {}) as ExtraContextT;
88
+ this.httpServer = createServer(this.handleRequest.bind(this));
89
+ this.enableCors();
90
+ this.attachRestHandlers();
91
+
92
+ const targetTable = [
93
+ ["/targets/android/client.kt", (ast: AstRoot) => generateAndroidClientSource(ast, true)],
94
+ ["/targets/android/client_without_callbacks.kt", (ast: AstRoot) => generateAndroidClientSource(ast, false)],
95
+ ["/targets/dotnet/api.cs", generateCSharpServerSource],
96
+ ["/targets/dotnet/api.fs", generateFSharpServerSource],
97
+ ["/targets/flutter/client.dart", generateDartClientSource],
98
+ ["/targets/ios/client.swift", (ast: AstRoot) => generateSwiftClientSource(ast, false)],
99
+ ["/targets/ios/client-rx.swift", (ast: AstRoot) => generateSwiftClientSource(ast, true)],
100
+ ["/targets/node/api.ts", generateNodeServerSource],
101
+ ["/targets/node/client.ts", generateNodeClientSource],
102
+ ["/targets/web/client.ts", generateBrowserClientSource],
103
+ ] as const;
104
+
105
+ for (const [path, generateFn] of targetTable) {
106
+ this.addHttpHandler("GET", path, (_req, res) => {
107
+ if (!this.introspection) {
108
+ res.statusCode = 404;
109
+ res.end();
110
+ return;
111
+ }
112
+
113
+ try {
114
+ res.setHeader("Content-Type", "application/octet-stream");
115
+ res.write(generateFn(this.apiConfig.ast));
116
+ } catch (e) {
117
+ console.error(e);
118
+ res.statusCode = 500;
119
+ res.write(`${e}`);
120
+ }
121
+
122
+ res.end();
123
+ });
124
+ }
125
+
126
+ this.addHttpHandler("GET", "/ast.json", (_req, res) => {
127
+ if (!this.introspection) {
128
+ res.statusCode = 404;
129
+ res.end();
130
+ return;
131
+ }
132
+
133
+ res.setHeader("Content-Type", "application/json");
134
+ res.write(JSON.stringify(apiConfig.astJson));
135
+ res.end();
136
+ });
137
+
138
+ this.addHttpHandler("GET", /^\/playground.*/u, (req, res) => {
139
+ if (!this.introspection) {
140
+ res.statusCode = 404;
141
+ res.end();
142
+ return;
143
+ }
144
+
145
+ if (req.url) {
146
+ req.url = req.url.endsWith("/playground") ? req.url.replace(/\/playground/u, "/index.html") : req.url.replace(/\/playground/u, "");
147
+ }
148
+
149
+ staticFilesHandler(req, res, {
150
+ cleanUrls: false,
151
+ directoryListing: false,
152
+ etag: true,
153
+ public: PLAYGROUND_PUBLIC_PATH,
154
+ }).catch(e => {
155
+ console.error(e);
156
+ res.statusCode = 500;
157
+ res.write(`${e}`);
158
+ res.end();
159
+ });
160
+ });
161
+ }
162
+
163
+ registerHealthCheck(healthCheck: () => Promise<boolean>): void {
164
+ this.healthChecks.push(healthCheck);
165
+ }
166
+
167
+ ignoreUrlPrefix(urlPrefix: string): void {
168
+ this.ignoredUrlPrefix = urlPrefix;
169
+ }
170
+
171
+ async listen(port = 8000): Promise<void> {
172
+ return new Promise(resolve => {
173
+ this.httpServer.listen(port, () => {
174
+ const addr = this.httpServer.address() as AddressInfo;
175
+ let urlHost: string;
176
+
177
+ if (addr.address === "::") {
178
+ urlHost = `localhost:${addr.port}`;
179
+ } else if (addr.family === "ipv6") {
180
+ urlHost = `[${addr.address}]:${addr.port}`;
181
+ } else {
182
+ urlHost = `${addr.address}:${addr.port}`;
183
+ }
184
+
185
+ if (addr.address === "::" || addr.address === "0.0.0.0") {
186
+ console.log(`\nListening on port ${addr.port}`);
187
+ } else {
188
+ console.log(`\nListening on port ${addr.port} (${addr.address})`);
189
+ }
190
+
191
+ if (this.introspection) {
192
+ console.log(`Playground: http://${urlHost}/playground`);
193
+ }
194
+
195
+ if (this.hasSwagger) {
196
+ console.log(`Swagger UI: http://${urlHost}/swagger`);
197
+ }
198
+
199
+ console.log("");
200
+
201
+ resolve();
202
+ });
203
+ });
204
+ }
205
+
206
+ async close(): Promise<void> {
207
+ return promisify(this.httpServer.close.bind(this.httpServer))();
208
+ }
209
+
210
+ private enableCors() {
211
+ this.addHeader("Access-Control-Allow-Methods", "DELETE, HEAD, PUT, POST, PATCH, GET, OPTIONS");
212
+ this.addHeader("Access-Control-Allow-Headers", "Content-Type");
213
+ this.addHeader("Access-Control-Max-Age", "86400");
214
+ }
215
+
216
+ addHeader(header: string, value: string): void {
217
+ const cleanHeader = header.toLowerCase().trim();
218
+ const existing = this.headers.get(cleanHeader);
219
+
220
+ if (existing) {
221
+ if (!existing.includes(value)) {
222
+ this.headers.set(cleanHeader, `${existing}, ${value}`);
223
+ }
224
+ } else {
225
+ this.headers.set(cleanHeader, value);
226
+ }
227
+ }
228
+
229
+ addHttpHandler(method: string, matcher: string | RegExp, handler: (req: IncomingMessage, res: ServerResponse, body: Buffer) => void): void {
230
+ this.handlers.push({ handler, matcher, method });
231
+ }
232
+
233
+ private findBestHandler(path: string, req: IncomingMessage) {
234
+ const matchingHandlers = this.handlers
235
+ .filter(({ method }) => method === req.method)
236
+ .filter(({ matcher }) => {
237
+ if (typeof matcher === "string") {
238
+ return matcher === path;
239
+ }
240
+
241
+ return matcher.exec(path)?.[0] === path;
242
+ })
243
+ .sort(({ matcher: first }, { matcher: second }) => {
244
+ // Prefer string matches instead of Regexp matches
245
+ if (typeof first === "string" && typeof second === "string") {
246
+ return 0;
247
+ } else if (typeof first === "string") {
248
+ return -1;
249
+ } else if (typeof second === "string") {
250
+ return 1;
251
+ }
252
+
253
+ const firstMatch = first.exec(path);
254
+ const secondMatch = second.exec(path);
255
+
256
+ if (!firstMatch) {
257
+ return -1;
258
+ }
259
+
260
+ if (!secondMatch) {
261
+ return 1;
262
+ }
263
+
264
+ // Compute how many characters were NOT part of a capture group
265
+ const firstLength = firstMatch[0].length - firstMatch.slice(1).reduce((acc, cur) => acc + cur.length, 0);
266
+ const secondLength = secondMatch[0].length - secondMatch.slice(1).reduce((acc, cur) => acc + cur.length, 0);
267
+
268
+ // Prefer the maximum number of non-captured characters
269
+ return secondLength - firstLength;
270
+ });
271
+
272
+ return matchingHandlers.length ? matchingHandlers[0] : null;
273
+ }
274
+
275
+ private attachRestHandlers() {
276
+ function escapeRegExp(str: string) {
277
+ return str.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
278
+ }
279
+
280
+ for (const op of this.apiConfig.ast.operations) {
281
+ for (const ann of op.annotations) {
282
+ if (!(ann instanceof RestAnnotation)) {
283
+ continue;
284
+ }
285
+
286
+ if (!this.hasSwagger) {
287
+ setupSwagger(this);
288
+ this.hasSwagger = true;
289
+ }
290
+
291
+ const pathFragments = ann.path.split(/\{\w+\}/u);
292
+
293
+ let pathRegex = "^";
294
+
295
+ for (let i = 0; i < pathFragments.length; ++i) {
296
+ if (i > 0) {
297
+ pathRegex += "([^/]+?)";
298
+ }
299
+
300
+ pathRegex += escapeRegExp(pathFragments[i]);
301
+ }
302
+
303
+ pathRegex += "/?$";
304
+
305
+ for (const header of ann.headers.keys()) {
306
+ this.addHeader("Access-Control-Allow-Headers", header.toLowerCase());
307
+ }
308
+
309
+ // eslint-disable-next-line @typescript-eslint/no-misused-promises
310
+ this.addHttpHandler(ann.method, new RegExp(pathRegex, "u"), async (req, res, body) => {
311
+ try {
312
+ const args: Record<string, unknown> = {};
313
+ const files: ContextRequest["files"] = [];
314
+
315
+ const { pathname, query } = parseUrl(req.url ?? "");
316
+ const match = pathname?.match(pathRegex);
317
+
318
+ if (!match) {
319
+ res.statusCode = 404;
320
+ return;
321
+ }
322
+
323
+ const simpleArgs = new Map<string, string | null>();
324
+
325
+ for (let i = 0; i < ann.pathVariables.length; ++i) {
326
+ const argName = ann.pathVariables[i];
327
+ const argValue = match[i + 1];
328
+
329
+ simpleArgs.set(argName, argValue);
330
+ }
331
+
332
+ const parsedQuery = query ? parseQuerystring(query) : {};
333
+
334
+ for (const argName of ann.queryVariables) {
335
+ const argValue = parsedQuery[argName] ?? null;
336
+
337
+ if (argValue === null) {
338
+ continue;
339
+ }
340
+
341
+ simpleArgs.set(argName, Array.isArray(argValue) ? argValue.join("") : argValue);
342
+ }
343
+
344
+ for (const [headerName, argName] of ann.headers) {
345
+ const argValue = req.headers[headerName.toLowerCase()] ?? null;
346
+
347
+ if (argValue === null) {
348
+ continue;
349
+ }
350
+
351
+ simpleArgs.set(argName, Array.isArray(argValue) ? argValue.join("") : argValue);
352
+ }
353
+
354
+ if (!ann.bodyVariable && req.headers["content-type"]?.match(/^application\/x-www-form-urlencoded/iu)) {
355
+ const parsedBody = parseQuerystring(body.toString());
356
+
357
+ for (const argName of ann.queryVariables) {
358
+ const argValue = parsedBody[argName] ?? null;
359
+
360
+ if (argValue === null) {
361
+ continue;
362
+ }
363
+
364
+ simpleArgs.set(argName, Array.isArray(argValue) ? argValue.join("") : argValue);
365
+ }
366
+ } else if (!ann.bodyVariable && req.headers["content-type"]?.match(/^multipart\/form-data/iu)) {
367
+ const busboy = Busboy({ headers: req.headers });
368
+ const filePromises: Array<Promise<void>> = [];
369
+
370
+ busboy.on("field", (field, value) => {
371
+ if (ann.queryVariables.includes(field)) {
372
+ simpleArgs.set(field, `${value}`);
373
+ }
374
+ });
375
+
376
+ busboy.on("file", (_field, stream, info) => {
377
+ const tempName = randomBytes(32).toString("hex");
378
+ const writeStream = createWriteStream(tempName);
379
+
380
+ filePromises.push(
381
+ new Promise((resolve, reject) => {
382
+ writeStream.on("error", reject);
383
+
384
+ writeStream.on("close", () => {
385
+ const contents = createReadStream(tempName);
386
+
387
+ files.push({ contents, name: info.filename });
388
+
389
+ contents.on("open", () => {
390
+ unlink(tempName, err => {
391
+ if (err) {
392
+ reject(err);
393
+ } else {
394
+ resolve();
395
+ }
396
+ });
397
+ });
398
+ });
399
+
400
+ writeStream.on("open", () => {
401
+ stream.pipe(writeStream);
402
+ });
403
+ }),
404
+ );
405
+ });
406
+
407
+ await new Promise((resolve, reject) => {
408
+ busboy.on("finish", resolve);
409
+ busboy.on("error", reject);
410
+ busboy.write(body);
411
+ });
412
+
413
+ await Promise.all(filePromises);
414
+ } else if (ann.bodyVariable) {
415
+ const argName = ann.bodyVariable;
416
+ const arg = op.args.find(x => x.name === argName);
417
+
418
+ if (/application\/json/iu.test(req.headers["content-type"] ?? "")) {
419
+ args[argName] = JSON.parse(body.toString());
420
+ } else if (arg) {
421
+ let { type } = arg;
422
+ let solved = false;
423
+
424
+ if (type instanceof OptionalType) {
425
+ if (body.length === 0) {
426
+ args[argName] = null;
427
+ solved = true;
428
+ } else {
429
+ type = type.base;
430
+ }
431
+ }
432
+
433
+ if (!solved) {
434
+ if (
435
+ type instanceof BoolPrimitiveType ||
436
+ type instanceof IntPrimitiveType ||
437
+ type instanceof UIntPrimitiveType ||
438
+ type instanceof FloatPrimitiveType ||
439
+ type instanceof StringPrimitiveType ||
440
+ type instanceof DatePrimitiveType ||
441
+ type instanceof DateTimePrimitiveType ||
442
+ type instanceof MoneyPrimitiveType ||
443
+ type instanceof DecimalPrimitiveType ||
444
+ type instanceof BigIntPrimitiveType ||
445
+ type instanceof CpfPrimitiveType ||
446
+ type instanceof CnpjPrimitiveType ||
447
+ type instanceof UuidPrimitiveType ||
448
+ type instanceof HexPrimitiveType ||
449
+ type instanceof Base64PrimitiveType
450
+ ) {
451
+ simpleArgs.set(argName, body.toString());
452
+ } else if (type instanceof BytesPrimitiveType) {
453
+ args[argName] = body.toString("base64");
454
+ } else {
455
+ args[argName] = JSON.parse(body.toString());
456
+ }
457
+ }
458
+ }
459
+ }
460
+
461
+ for (const [argName, argValue] of simpleArgs) {
462
+ const arg = op.args.find(x => x.name === argName);
463
+
464
+ if (!arg) {
465
+ continue;
466
+ }
467
+
468
+ let { type } = arg;
469
+
470
+ if (type instanceof OptionalType) {
471
+ if (argValue === null) {
472
+ args[argName] = null;
473
+ continue;
474
+ } else {
475
+ type = type.base;
476
+ }
477
+ } else if (argValue === null) {
478
+ args[argName] = argValue;
479
+ continue;
480
+ }
481
+
482
+ if (type instanceof BoolPrimitiveType) {
483
+ if (argValue === "true") {
484
+ args[argName] = true;
485
+ } else if (argValue === "false") {
486
+ args[argName] = false;
487
+ } else {
488
+ args[argName] = argValue;
489
+ }
490
+ } else if (type instanceof UIntPrimitiveType || type instanceof IntPrimitiveType || type instanceof MoneyPrimitiveType) {
491
+ args[argName] = parseInt(argValue, 10);
492
+ } else if (type instanceof FloatPrimitiveType) {
493
+ args[argName] = parseFloat(argValue);
494
+ } else {
495
+ args[argName] = argValue;
496
+ }
497
+ }
498
+
499
+ const ip = getClientIp(req);
500
+
501
+ if (!ip) {
502
+ throw new Error("Couldn't determine client IP");
503
+ }
504
+
505
+ const request: ContextRequest = {
506
+ args,
507
+ deviceInfo: {
508
+ fingerprint: null,
509
+ id: randomBytes(16).toString("hex"),
510
+ language: null,
511
+ platform: null,
512
+ timezone: null,
513
+ type: "rest",
514
+ version: null,
515
+ },
516
+ extra: {},
517
+ files,
518
+ headers: req.headers,
519
+ id: randomBytes(16).toString("hex"),
520
+ ip,
521
+ name: op.name,
522
+ version: 3,
523
+ };
524
+
525
+ await this.executeRequest(request, (ctx, reply) => {
526
+ try {
527
+ if (ctx) {
528
+ for (const [headerKey, headerValue] of ctx.response.headers.entries()) {
529
+ res.setHeader(headerKey, headerValue);
530
+ }
531
+ }
532
+
533
+ if (ctx?.response.statusCode) {
534
+ res.statusCode = ctx.response.statusCode;
535
+ }
536
+
537
+ if (reply.error) {
538
+ const error = this.makeResponseError(reply.error);
539
+
540
+ if (!ctx?.response.statusCode) {
541
+ const errorNode = this.apiConfig.ast.errors.find(node => node.name === error.type);
542
+ const statusAnnotation = errorNode?.annotations.find(x => x instanceof StatusCodeAnnotation) as StatusCodeAnnotation | undefined;
543
+
544
+ res.statusCode = statusAnnotation ? statusAnnotation.statusCode : error.type === "Fatal" ? 500 : 400;
545
+ }
546
+
547
+ res.setHeader("content-type", "application/json");
548
+ res.write(JSON.stringify(error));
549
+ res.end();
550
+ return;
551
+ }
552
+
553
+ if (req.headers.accept === "application/json") {
554
+ res.setHeader("content-type", "application/json");
555
+ res.write(JSON.stringify(reply.result));
556
+ res.end();
557
+ } else {
558
+ let type = op.returnType;
559
+
560
+ if (type instanceof OptionalType) {
561
+ if (reply.result === null) {
562
+ if (!ctx?.response.statusCode) {
563
+ res.statusCode = ann.method === "GET" ? 404 : 204;
564
+ }
565
+
566
+ res.end();
567
+ return;
568
+ }
569
+
570
+ type = type.base;
571
+ }
572
+
573
+ if (
574
+ type instanceof BoolPrimitiveType ||
575
+ type instanceof IntPrimitiveType ||
576
+ type instanceof UIntPrimitiveType ||
577
+ type instanceof FloatPrimitiveType ||
578
+ type instanceof StringPrimitiveType ||
579
+ type instanceof DatePrimitiveType ||
580
+ type instanceof DateTimePrimitiveType ||
581
+ type instanceof MoneyPrimitiveType ||
582
+ type instanceof DecimalPrimitiveType ||
583
+ type instanceof BigIntPrimitiveType ||
584
+ type instanceof CpfPrimitiveType ||
585
+ type instanceof CnpjPrimitiveType ||
586
+ type instanceof UuidPrimitiveType ||
587
+ type instanceof HexPrimitiveType ||
588
+ type instanceof Base64PrimitiveType
589
+ ) {
590
+ res.setHeader("content-type", "text/plain");
591
+ res.write(`${reply.result}`);
592
+ res.end();
593
+ } else if (type instanceof HtmlPrimitiveType) {
594
+ res.setHeader("content-type", "text/html");
595
+ res.write(`${reply.result}`);
596
+ res.end();
597
+ } else if (type instanceof XmlPrimitiveType) {
598
+ res.setHeader("content-type", "text/xml");
599
+ res.write(`${reply.result}`);
600
+ res.end();
601
+ } else if (type instanceof BytesPrimitiveType) {
602
+ const buffer = Buffer.from(reply.result as string, "base64");
603
+
604
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
605
+ FileType.fromBuffer(buffer)
606
+ .then(fileType => {
607
+ res.setHeader("content-type", fileType?.mime ?? "application/octet-stream");
608
+ })
609
+ .catch(err => {
610
+ console.error(err);
611
+ res.setHeader("content-type", "application/octet-stream");
612
+ })
613
+ .then(() => {
614
+ res.write(buffer);
615
+ res.end();
616
+ })
617
+ .catch(() => {});
618
+ } else {
619
+ res.setHeader("content-type", "application/json");
620
+ res.write(JSON.stringify(reply.result));
621
+ res.end();
622
+ }
623
+ }
624
+ } catch (error) {
625
+ console.error(error);
626
+ if (!res.headersSent) {
627
+ res.statusCode = 500;
628
+ }
629
+
630
+ res.end();
631
+ }
632
+ });
633
+ } catch (error) {
634
+ console.error(error);
635
+ if (!res.headersSent) {
636
+ res.statusCode = 500;
637
+ }
638
+
639
+ res.end();
640
+ }
641
+ });
642
+ }
643
+ }
644
+ }
645
+
646
+ public handleRequest = (req: IncomingMessage, res: ServerResponse) => {
647
+ const hrStart = process.hrtime();
648
+
649
+ req.on("error", err => {
650
+ console.error(err);
651
+ res.end();
652
+ });
653
+
654
+ res.on("error", err => {
655
+ console.error(err);
656
+ res.end();
657
+ });
658
+
659
+ if (this.dynamicCorsOrigin && req.headers.origin) {
660
+ res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
661
+ res.setHeader("Vary", "Origin");
662
+ }
663
+
664
+ for (const [header, value] of this.headers) {
665
+ if (req.method === "OPTIONS" && !header.startsWith("access-control-")) {
666
+ continue;
667
+ }
668
+
669
+ res.setHeader(header, value);
670
+ }
671
+
672
+ if (req.method === "OPTIONS") {
673
+ res.writeHead(200);
674
+ res.end();
675
+ return;
676
+ }
677
+
678
+ const handleBody = (body: Buffer) => {
679
+ this.handleRequestWithBody(req, res, body, hrStart).catch((e: unknown) => this.writeReply(res, null, { error: e }, hrStart));
680
+ };
681
+
682
+ // Google Cloud Functions add a rawBody property to the request object
683
+ if (has(req, "rawBody") && req.rawBody instanceof Buffer) {
684
+ handleBody(req.rawBody);
685
+ } else {
686
+ const body: Buffer[] = [];
687
+
688
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
689
+ req.on("data", chunk => body.push(chunk));
690
+
691
+ req.on("end", () => {
692
+ handleBody(Buffer.concat(body));
693
+ });
694
+ }
695
+ };
696
+
697
+ private async handleRequestWithBody(req: IncomingMessage, res: ServerResponse, body: Buffer, hrStart: [number, number]) {
698
+ const { pathname, query } = parseUrl(req.url ?? "");
699
+ let path = pathname ?? "";
700
+
701
+ if (path.startsWith(this.ignoredUrlPrefix)) {
702
+ path = path.slice(this.ignoredUrlPrefix.length);
703
+ }
704
+
705
+ if (!req.headers["content-type"]?.match(/application\/sdkgen/iu)) {
706
+ const externalHandler = this.findBestHandler(path, req);
707
+
708
+ if (externalHandler) {
709
+ this.log(`HTTP ${req.method} ${path}${query ? `?${query}` : ""}`);
710
+ externalHandler.handler(req, res, body);
711
+ return;
712
+ }
713
+ }
714
+
715
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
716
+
717
+ if (req.method === "HEAD") {
718
+ res.writeHead(200);
719
+ res.end();
720
+ return;
721
+ }
722
+
723
+ if (req.method === "GET") {
724
+ if (path !== "/") {
725
+ res.writeHead(404);
726
+ res.end();
727
+ return;
728
+ }
729
+
730
+ let ok = true;
731
+
732
+ try {
733
+ for (const healthCheck of this.healthChecks) {
734
+ if (!ok) {
735
+ break;
736
+ }
737
+
738
+ ok = await healthCheck();
739
+ }
740
+ } catch (e) {
741
+ ok = false;
742
+ }
743
+
744
+ res.statusCode = ok ? 200 : 500;
745
+ res.write(JSON.stringify({ ok }));
746
+ res.end();
747
+ return;
748
+ }
749
+
750
+ if (req.method !== "POST") {
751
+ res.writeHead(400);
752
+ res.end();
753
+ return;
754
+ }
755
+
756
+ const clientIp = getClientIp(req);
757
+
758
+ if (!clientIp) {
759
+ this.writeReply(
760
+ res,
761
+ null,
762
+ {
763
+ error: new Fatal("Couldn't determine client IP"),
764
+ },
765
+ hrStart,
766
+ );
767
+ return;
768
+ }
769
+
770
+ const request = this.parseRequest(req, body.toString(), clientIp);
771
+
772
+ if (!request) {
773
+ this.writeReply(
774
+ res,
775
+ null,
776
+ {
777
+ error: new Fatal("Couldn't parse request"),
778
+ },
779
+ hrStart,
780
+ );
781
+ return;
782
+ }
783
+
784
+ await this.executeRequest(request, (ctx, reply) => this.writeReply(res, ctx, reply, hrStart));
785
+ }
786
+
787
+ private async executeRequest(request: ContextRequest, writeReply: (ctx: Context | null, reply: ContextReply) => void) {
788
+ const ctx: Context & ExtraContextT = {
789
+ ...this.extraContext,
790
+ request,
791
+ response: {
792
+ headers: new Map(),
793
+ },
794
+ };
795
+
796
+ writeReply(ctx, await executeRequest(ctx, this.apiConfig));
797
+ }
798
+
799
+ private parseRequest(req: IncomingMessage, body: string, ip: string): ContextRequest | null {
800
+ switch (this.identifyRequestVersion(req, body)) {
801
+ case 1:
802
+ return this.parseRequestV1(req, body, ip);
803
+ case 2:
804
+ return this.parseRequestV2(req, body, ip);
805
+ case 3:
806
+ return this.parseRequestV3(req, body, ip);
807
+ default:
808
+ throw new Error("Failed to understand request");
809
+ }
810
+ }
811
+
812
+ private identifyRequestVersion(_req: IncomingMessage, body: string): number {
813
+ const parsed = JSON.parse(body) as unknown;
814
+
815
+ if (typeof parsed === "object" && parsed && has(parsed, "version") && typeof parsed.version === "number") {
816
+ return parsed.version;
817
+ } else if (typeof parsed === "object" && parsed && has(parsed, "requestId")) {
818
+ return 2;
819
+ } else if (typeof parsed === "object" && parsed && has(parsed, "device")) {
820
+ return 1;
821
+ }
822
+
823
+ return 3;
824
+ }
825
+
826
+ // Old Sdkgen format
827
+ private parseRequestV1(req: IncomingMessage, body: string, ip: string): ContextRequest {
828
+ const parsed = decode(
829
+ {
830
+ Request: {
831
+ args: "json",
832
+ device: "RequestDevice",
833
+ id: "string",
834
+ name: "string",
835
+ },
836
+ RequestDevice: {
837
+ fingerprint: "string?",
838
+ id: "string?",
839
+ language: "string?",
840
+ platform: "json?",
841
+ timezone: "string?",
842
+ type: "string?",
843
+ version: "string?",
844
+ },
845
+ } as const,
846
+ "root",
847
+ "Request",
848
+ JSON.parse(body),
849
+ );
850
+
851
+ const deviceId = parsed.device.id ?? randomBytes(20).toString("hex");
852
+
853
+ if (!parsed.args || Array.isArray(parsed.args) || typeof parsed.args !== "object") {
854
+ throw new Error("Expected 'args' to be an object");
855
+ }
856
+
857
+ return {
858
+ args: parsed.args,
859
+ deviceInfo: {
860
+ fingerprint: parsed.device.fingerprint,
861
+ id: deviceId,
862
+ language: parsed.device.language,
863
+ platform: parsed.device.platform,
864
+ timezone: parsed.device.timezone,
865
+ type: parsed.device.type ?? (typeof parsed.device.platform === "string" ? parsed.device.platform : ""),
866
+ version: parsed.device.version,
867
+ },
868
+ extra: {},
869
+ files: [],
870
+ headers: req.headers,
871
+ id: `${deviceId}-${parsed.id}`,
872
+ ip,
873
+ name: parsed.name,
874
+ version: 1,
875
+ };
876
+ }
877
+
878
+ // Maxima sdkgen format
879
+ private parseRequestV2(req: IncomingMessage, body: string, ip: string): ContextRequest {
880
+ const parsed = decode(
881
+ {
882
+ Request: {
883
+ args: "json",
884
+ deviceFingerprint: "string?",
885
+ deviceId: "string",
886
+ info: "RequestInfo",
887
+ name: "string",
888
+ partnerId: "string?",
889
+ requestId: "string?",
890
+ sessionId: "string?",
891
+ },
892
+ RequestInfo: {
893
+ browserUserAgent: "string?",
894
+ language: "string",
895
+ type: "string",
896
+ },
897
+ } as const,
898
+ "root",
899
+ "Request",
900
+ JSON.parse(body),
901
+ );
902
+
903
+ if (!parsed.args || Array.isArray(parsed.args) || typeof parsed.args !== "object") {
904
+ throw new Error("Expected 'args' to be an object");
905
+ }
906
+
907
+ return {
908
+ args: parsed.args,
909
+ deviceInfo: {
910
+ fingerprint: parsed.deviceFingerprint,
911
+ id: parsed.deviceId,
912
+ language: parsed.info.language,
913
+ platform: {
914
+ browserUserAgent: parsed.info.browserUserAgent ?? null,
915
+ },
916
+ timezone: null,
917
+ type: parsed.info.type,
918
+ version: "",
919
+ },
920
+ extra: {
921
+ partnerId: parsed.partnerId,
922
+ sessionId: parsed.sessionId,
923
+ },
924
+ files: [],
925
+ headers: req.headers,
926
+ id: `${parsed.deviceId}-${parsed.requestId ?? randomBytes(16).toString("hex")}`,
927
+ ip,
928
+ name: parsed.name,
929
+ version: 2,
930
+ };
931
+ }
932
+
933
+ // New sdkgen format
934
+ private parseRequestV3(req: IncomingMessage, body: string, ip: string): ContextRequest {
935
+ const parsed = decode(
936
+ {
937
+ DeviceInfo: {
938
+ fingerprint: "string?",
939
+ id: "string?",
940
+ language: "string?",
941
+ platform: "json?",
942
+ timezone: "string?",
943
+ type: "string?",
944
+ version: "string?",
945
+ },
946
+ Request: {
947
+ args: "json",
948
+ deviceInfo: "DeviceInfo?",
949
+ extra: "json?",
950
+ name: "string",
951
+ requestId: "string?",
952
+ },
953
+ } as const,
954
+ "root",
955
+ "Request",
956
+ JSON.parse(body),
957
+ );
958
+
959
+ const deviceInfo = parsed.deviceInfo ?? {
960
+ fingerprint: null,
961
+ id: null,
962
+ language: null,
963
+ platform: null,
964
+ timezone: null,
965
+ type: null,
966
+ version: null,
967
+ };
968
+ const deviceId = deviceInfo.id ?? randomBytes(16).toString("hex");
969
+
970
+ if (!parsed.args || Array.isArray(parsed.args) || typeof parsed.args !== "object") {
971
+ throw new Error("Expected 'args' to be an object");
972
+ }
973
+
974
+ return {
975
+ args: parsed.args,
976
+ deviceInfo: {
977
+ fingerprint: deviceInfo.fingerprint,
978
+ id: deviceId,
979
+ language: deviceInfo.language,
980
+ platform: typeof deviceInfo.platform === "object" ? { ...deviceInfo.platform } : {},
981
+ timezone: deviceInfo.timezone,
982
+ type: deviceInfo.type ?? "api",
983
+ version: deviceInfo.version,
984
+ },
985
+ extra: typeof parsed.extra === "object" ? { ...parsed.extra } : {},
986
+ files: [],
987
+ headers: req.headers,
988
+ id: `${deviceId}-${parsed.requestId ?? randomBytes(16).toString("hex")}`,
989
+ ip,
990
+ name: parsed.name,
991
+ version: 3,
992
+ };
993
+ }
994
+
995
+ private makeResponseError(err: unknown): { message: string; type: string; data: unknown } {
996
+ let type = "Fatal";
997
+
998
+ if (typeof err === "object" && err !== null && has(err, "type") && typeof err.type === "string") {
999
+ ({ type } = err);
1000
+ }
1001
+
1002
+ let message: string;
1003
+
1004
+ if (typeof err === "object" && err !== null && has(err, "message") && typeof err.message === "string") {
1005
+ ({ message } = err);
1006
+ } else if (err instanceof Error) {
1007
+ message = err.toString();
1008
+ } else if (typeof err === "object") {
1009
+ message = JSON.stringify(err);
1010
+ } else {
1011
+ message = `${err}`;
1012
+ }
1013
+
1014
+ let data: unknown;
1015
+
1016
+ if (typeof err === "object" && err !== null && has(err, "data")) {
1017
+ ({ data } = err);
1018
+ }
1019
+
1020
+ const error = this.apiConfig.ast.errors.find(x => x.name === type);
1021
+
1022
+ if (error) {
1023
+ if (!(error.dataType instanceof VoidPrimitiveType)) {
1024
+ try {
1025
+ data = encode(this.apiConfig.astJson.typeTable, `error.${type}`, error.dataType.name, data);
1026
+ } catch (encodeError) {
1027
+ message = `Failed to encode error ${type} because: ${encodeError}. Original message: ${message}`;
1028
+ type = "Fatal";
1029
+ }
1030
+ }
1031
+ } else {
1032
+ type = "Fatal";
1033
+ }
1034
+
1035
+ return { data, message, type };
1036
+ }
1037
+
1038
+ private writeReply(res: ServerResponse, ctx: Context | null, reply: ContextReply, hrStart: [number, number]) {
1039
+ if (!ctx) {
1040
+ res.statusCode = 500;
1041
+ res.write(
1042
+ JSON.stringify({
1043
+ error: this.makeResponseError(reply.error ?? new Fatal("Response without context")),
1044
+ }),
1045
+ );
1046
+ res.end();
1047
+ return;
1048
+ }
1049
+
1050
+ const deltaTime = process.hrtime(hrStart);
1051
+ const duration = deltaTime[0] + deltaTime[1] * 1e-9;
1052
+
1053
+ if (reply.error) {
1054
+ console.error(reply.error);
1055
+ }
1056
+
1057
+ this.log(`${ctx.request.id} [${duration.toFixed(6)}s] ${ctx.request.name}() -> ${reply.error ? this.makeResponseError(reply.error).type : "OK"}`);
1058
+
1059
+ if (ctx.response.statusCode) {
1060
+ res.statusCode = ctx.response.statusCode;
1061
+ }
1062
+
1063
+ for (const [headerKey, headerValue] of ctx.response.headers.entries()) {
1064
+ res.setHeader(headerKey, headerValue);
1065
+ }
1066
+
1067
+ switch (ctx.request.version) {
1068
+ case 1: {
1069
+ const response = {
1070
+ deviceId: ctx.request.deviceInfo.id,
1071
+ duration,
1072
+ error: reply.error ? this.makeResponseError(reply.error) : null,
1073
+ host: hostname(),
1074
+ id: ctx.request.id,
1075
+ ok: !reply.error,
1076
+ result: reply.error ? null : reply.result,
1077
+ };
1078
+
1079
+ if (response.error && !ctx.response.statusCode) {
1080
+ res.statusCode = this.makeResponseError(response.error).type === "Fatal" ? 500 : 400;
1081
+ }
1082
+
1083
+ res.write(JSON.stringify(response));
1084
+ res.end();
1085
+ break;
1086
+ }
1087
+
1088
+ case 2: {
1089
+ const response = {
1090
+ deviceId: ctx.request.deviceInfo.id,
1091
+ error: reply.error ? this.makeResponseError(reply.error) : null,
1092
+ ok: !reply.error,
1093
+ requestId: ctx.request.id,
1094
+ result: reply.error ? null : reply.result,
1095
+ sessionId: ctx.request.extra.sessionId,
1096
+ };
1097
+
1098
+ if (response.error && !ctx.response.statusCode) {
1099
+ res.statusCode = this.makeResponseError(response.error).type === "Fatal" ? 500 : 400;
1100
+ }
1101
+
1102
+ res.write(JSON.stringify(response));
1103
+ res.end();
1104
+ break;
1105
+ }
1106
+
1107
+ case 3: {
1108
+ const response = {
1109
+ duration,
1110
+ error: reply.error ? this.makeResponseError(reply.error) : null,
1111
+ host: hostname(),
1112
+ result: reply.error ? null : reply.result,
1113
+ };
1114
+
1115
+ if (response.error && !ctx.response.statusCode) {
1116
+ res.statusCode = this.makeResponseError(response.error).type === "Fatal" ? 500 : 400;
1117
+ }
1118
+
1119
+ res.setHeader("x-request-id", ctx.request.id);
1120
+ res.write(JSON.stringify(response));
1121
+ res.end();
1122
+ break;
1123
+ }
1124
+
1125
+ default: {
1126
+ res.statusCode = 500;
1127
+ res.write(
1128
+ JSON.stringify({
1129
+ error: this.makeResponseError(reply.error ?? new Fatal("Unknown request version")),
1130
+ }),
1131
+ );
1132
+ res.end();
1133
+ return;
1134
+ }
1135
+ }
1136
+ }
1137
+ }