Completely refactored examples to run only once and have multiple endpoints

This commit is contained in:
Alexander Cerutti
2021-10-31 20:37:06 +01:00
parent 8552f217ee
commit 64ccacdc13
16 changed files with 389 additions and 105 deletions

View File

@@ -0,0 +1,35 @@
# Examples
This is examples folder. These examples are used to test new features and as sample showcases.
Each example owns an endpoint where a pass can be reached. This project is build upon Express.js, which is required to be installed.
Typescript compilation is done automatically through `ts-node`.
Assuming you already have cloned this repository, installed its dependencies through `npm install` and moved to `examples/self-hosted`, run these commands:
```sh
$ npm install;
$ npm run example;
```
Certificates paths in examples are linked to a folder `certificates` in the root of this project which is not provided.
To make them work, you'll have to edit both certificates and model path.
Every example runs on `0.0.0.0:8080`. Visit `http://localhost:8080/:example/:modelName`, by replacing `:example` with one of the following and `:modelName` with one inside models folder.
Please note that `field.js` example will force you to download `exampleBooking.pass`, no matter what.
| Example name | Endpoint name | Additional notes |
| -------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------- |
| localize | `/localize` | - |
| fields | `/fields` | - |
| expirationDate | `/expirationDate` | Accepts a required parameter in query string `fn`, which can be either `expiration` or `void`, to switch generated example. |
| scratch | `/scratch` | - |
| PKPass.from | pkpassfrom | - |
| barcodes | `/barcodes` | Using `?alt=true` query parameter, will lead to barcode string message usage instead of selected ones |
| pkpasses | `/pkpasses` | - |
---
Every contribution is really appreciated. ❤️ Thank you!

1590
examples/self-hosted/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
{
"name": "examples-self-hosted",
"version": "0.0.0",
"private": true,
"description": "Passkit-generator self-hosted examples",
"author": "Alexander P. Cerutti <cerutti.alexander@gmail.com>",
"license": "ISC",
"scripts": {
"preinstall": "npm run clear:deps && npm unlink --no-save passkit-generator",
"postinstall": "npm --prefix ../.. run build && npm --prefix ../.. link && npm link passkit-generator",
"example": "npx ts-node src/index.ts",
"example:debug": "node -r ts-node/register --inspect-brk src/index.ts",
"clear:deps": "rm -rf node_modules"
},
"peerDependencies": {
"passkit-generator": "latest"
},
"dependencies": {
"express": "^4.17.1",
"node-fetch": "^3.0.0",
"tslib": "^2.3.1"
},
"devDependencies": {
"@types/express": "4.17.8",
"ts-node": "^10.4.0",
"typescript": "^4.4.4"
}
}

View File

@@ -0,0 +1,148 @@
/**
* PKPass.from static method example.
* Here it is showed manual model reading and
* creating through another PKPass because in the other
* examples, creation through templates is already shown
*/
import { app } from "./webserver";
import { getCertificates } from "./shared";
import path from "path";
import { promises as fs } from "fs";
import { PKPass } from "passkit-generator";
import * as Utils from "passkit-generator/lib/utils";
// ******************************************** //
// *** CODE FROM GET MODEL FOLDER INTERNALS *** //
// ******************************************** //
async function readFileOrDirectory(filePath: string) {
if ((await fs.lstat(filePath)).isDirectory()) {
return Promise.all(await readDirectory(filePath));
} else {
return fs
.readFile(filePath)
.then((content) => getObjectFromModelFile(filePath, content, 1));
}
}
/**
* Returns an object containing the parsed fileName
* from a path along with its content.
*
* @param filePath
* @param content
* @param depthFromEnd - used to preserve localization lproj content
* @returns
*/
function getObjectFromModelFile(
filePath: string,
content: Buffer,
depthFromEnd: number,
) {
const fileComponents = filePath.split(path.sep);
const fileName = fileComponents
.slice(fileComponents.length - depthFromEnd)
.join(path.sep);
return { [fileName]: content };
}
/**
* Reads a directory and returns all the files in it
* as an Array<Promise>
*
* @param filePath
* @returns
*/
async function readDirectory(filePath: string) {
const dirContent = await fs.readdir(filePath).then(Utils.removeHidden);
return dirContent.map(async (fileName) => {
const content = await fs.readFile(path.resolve(filePath, fileName));
return getObjectFromModelFile(
path.resolve(filePath, fileName),
content,
2,
);
});
}
// *************************** //
// *** EXAMPLE FROM NOW ON *** //
// *************************** //
const passTemplate = new Promise<PKPass>(async (resolve) => {
const modelPath = path.resolve(__dirname, `../../models/examplePass.pass`);
const [modelFilesList, certificates] = await Promise.all([
fs.readdir(modelPath),
getCertificates(),
]);
const modelRecords = (
await Promise.all(
/**
* Obtaining flattened array of buffer records
* containing file name and the buffer itself.
*
* This goes also to read every nested l10n
* subfolder.
*/
modelFilesList.map((fileOrDirectoryPath) => {
const fullPath = path.resolve(modelPath, fileOrDirectoryPath);
return readFileOrDirectory(fullPath);
}),
)
)
.flat(1)
.reduce((acc, current) => ({ ...acc, ...current }), {});
/** Creating a PKPass Template */
return resolve(
new PKPass(modelRecords, {
wwdr: certificates.wwdr,
signerCert: certificates.signerCert,
signerKey: certificates.signerKey,
signerKeyPassphrase: certificates.signerKeyPassphrase,
}),
);
});
app.route("/pkpassfrom/:modelName").get(async (request, response) => {
const passName =
request.params.modelName +
"_" +
new Date().toISOString().split("T")[0].replace(/-/gi, "");
const templatePass = await passTemplate;
try {
const pass = await PKPass.from(
templatePass,
request.body || request.params || request.query,
);
const stream = pass.getAsStream();
response.set({
"Content-type": pass.mimeType,
"Content-disposition": `attachment; filename=${passName}.pkpass`,
});
stream.pipe(response);
} catch (err) {
console.log(err);
response.set({
"Content-type": "text/html",
});
response.send(err.message);
}
});

View File

@@ -0,0 +1,151 @@
/**
* PKPasses generation through PKPass.pack static method
* example.
* Here it is showed manual model reading and
* creating through another PKPass because in the other
* examples, creation through templates is already shown
*
* PLEASE NOTE THAT, AT TIME OF WRITING, THIS EXAMPLE WORKS
* ONLY IF PASSES ARE DOWNLOADED FROM SAFARI, due to the
* support of PKPasses archives. To test this, you might
* need to open a tunnel through NGROK if you cannot access
* to your local machine (in my personal case, developing
* under WSL is a pretty big limitation sometimes).
*
* @TODO test again this example with next iOS 15 versions.
* Currently, pass viewer seems to be soooo bugged.
*
* https://imgur.com/bDTbcDg.jpg
* https://imgur.com/Y4GpuHT.jpg
* https://i.imgur.com/qbJMy1d.jpg
*
* Alberto, come to look at APPLE.
*
* MAMMA MIA!
*
* A feedback to Apple have been sent for this.
*/
import { app } from "./webserver";
import { getCertificates } from "./shared";
import { promises as fs } from "fs";
import path from "path";
import { PKPass } from "passkit-generator";
// *************************** //
// *** EXAMPLE FROM NOW ON *** //
// *************************** //
function getRandomColorPart() {
return Math.floor(Math.random() * 255);
}
async function generatePass(props: Object) {
const [iconFromModel, certificates] = await Promise.all([
fs.readFile(
path.resolve(
__dirname,
"../../models/exampleBooking.pass/icon.png",
),
),
getCertificates(),
]);
const pass = new PKPass(
{},
{
wwdr: certificates.wwdr,
signerCert: certificates.signerCert,
signerKey: certificates.signerKey,
signerKeyPassphrase: certificates.signerKeyPassphrase,
},
{
...props,
description: "Example Apple Wallet Pass",
passTypeIdentifier: "pass.com.passkitgenerator",
serialNumber: "nmyuxofgna",
organizationName: `Test Organization ${Math.random()}`,
teamIdentifier: "F53WB8AE67",
foregroundColor: `rgb(${getRandomColorPart()}, ${getRandomColorPart()}, ${getRandomColorPart()})`,
labelColor: `rgb(${getRandomColorPart()}, ${getRandomColorPart()}, ${getRandomColorPart()})`,
backgroundColor: `rgb(${getRandomColorPart()}, ${getRandomColorPart()}, ${getRandomColorPart()})`,
},
);
pass.type = "boardingPass";
pass.transitType = "PKTransitTypeAir";
pass.setBarcodes({
message: "123456789",
format: "PKBarcodeFormatQR",
});
pass.headerFields.push(
{
key: "header-field-test-1",
value: "Unknown",
},
{
key: "header-field-test-2",
value: "unknown",
},
);
pass.primaryFields.push(
{
key: "primaryField-1",
value: "NAP",
},
{
key: "primaryField-2",
value: "VCE",
},
);
/**
* Required by Apple. If one is not available, a
* pass might be openable on a Mac but not on a
* specific iPhone model
*/
pass.addBuffer("icon.png", iconFromModel);
pass.addBuffer("icon@2x.png", iconFromModel);
pass.addBuffer("icon@3x.png", iconFromModel);
return pass;
}
app.route("/pkpasses/:modelName").get(async (request, response) => {
const passName =
request.params.modelName +
"_" +
new Date().toISOString().split("T")[0].replace(/-/gi, "");
try {
const passes = await Promise.all([
generatePass(request.body || request.params || request.query),
generatePass(request.body || request.params || request.query),
generatePass(request.body || request.params || request.query),
generatePass(request.body || request.params || request.query),
]);
const pkpasses = PKPass.pack(...passes);
response.set({
"Content-type": pkpasses.mimeType,
"Content-disposition": `attachment; filename=${passName}.pkpasses`,
});
const stream = pkpasses.getAsStream();
stream.pipe(response);
} catch (err) {
console.log(err);
response.set({
"Content-type": "text/html",
});
response.send(err.message);
}
});

View File

@@ -0,0 +1,199 @@
/**
* Fields pushing dimostration
* To see all the included Fields, just open the pass
* Refer to https://apple.co/2Nvshvn to see how passes
* have their fields disposed.
*
* In this example we are going to imitate an EasyJet boarding pass
*
* @Author: Alexander P. Cerutti
*/
import { app } from "./webserver";
import { getCertificates } from "./shared";
import path from "path";
import { PKPass } from "passkit-generator";
app.route("/fields/:modelName").get(async (request, response) => {
const passName =
"exampleBooking" +
"_" +
new Date().toISOString().split("T")[0].replace(/-/gi, "");
const certificates = await getCertificates();
try {
const pass = await PKPass.from(
{
model: path.resolve(__dirname, "../../models/exampleBooking"),
certificates: {
wwdr: certificates.wwdr,
signerCert: certificates.signerCert,
signerKey: certificates.signerKey,
signerKeyPassphrase: certificates.signerKeyPassphrase,
},
},
request.body || request.params || request.query,
);
pass.transitType = "PKTransitTypeAir";
pass.headerFields.push(
{
key: "header1",
label: "Data",
value: "25 mag",
textAlignment: "PKTextAlignmentCenter",
},
{
key: "header2",
label: "Volo",
value: "EZY997",
textAlignment: "PKTextAlignmentCenter",
},
);
pass.primaryFields.push(
{
key: "IATA-source",
value: "NAP",
label: "Napoli",
textAlignment: "PKTextAlignmentLeft",
},
{
key: "IATA-destination",
value: "VCE",
label: "Venezia Marco Polo",
textAlignment: "PKTextAlignmentRight",
},
);
pass.secondaryFields.push(
{
key: "secondary1",
label: "Imbarco chiuso",
value: "18:40",
textAlignment: "PKTextAlignmentCenter",
},
{
key: "sec2",
label: "Partenze",
value: "19:10",
textAlignment: "PKTextAlignmentCenter",
},
{
key: "sec3",
label: "SB",
value: "Sì",
textAlignment: "PKTextAlignmentCenter",
},
{
key: "sec4",
label: "Imbarco",
value: "Anteriore",
textAlignment: "PKTextAlignmentCenter",
},
);
pass.auxiliaryFields.push(
{
key: "aux1",
label: "Passeggero",
value: "MR. WHO KNOWS",
textAlignment: "PKTextAlignmentLeft",
},
{
key: "aux2",
label: "Posto",
value: "1A*",
textAlignment: "PKTextAlignmentCenter",
},
);
pass.backFields.push(
{
key: "document number",
label: "Numero documento:",
value: "- -",
textAlignment: "PKTextAlignmentLeft",
},
{
key: "You're checked in, what next",
label: "Hai effettuato il check-in, Quali sono le prospettive",
value: "",
textAlignment: "PKTextAlignmentLeft",
},
{
key: "Check In",
label: "1. check-in✓",
value: "",
textAlignment: "PKTextAlignmentLeft",
},
{
key: "checkIn",
label: "",
value: "Le uscite d'imbarco chiudono 30 minuti prima della partenza, quindi sii puntuale. In questo aeroporto puoi utilizzare la corsia Fast Track ai varchi di sicurezza.",
textAlignment: "PKTextAlignmentLeft",
},
{
key: "2. Bags",
label: "2. Bagaglio",
value: "",
textAlignment: "PKTextAlignmentLeft",
},
{
key: "Require special assistance",
label: "Assistenza speciale",
value: "Se hai richiesto assistenza speciale, presentati a un membro del personale nell'area di Consegna bagagli almeno 90 minuti prima del volo.",
textAlignment: "PKTextAlignmentLeft",
},
{
key: "3. Departures",
label: "3. Partenze",
value: "",
textAlignment: "PKTextAlignmentLeft",
},
{
key: "photoId",
label: "Un documento didentità corredato di fotografia",
value: "è obbligatorio su TUTTI i voli. Per un viaggio internazionale è necessario un passaporto valido o, dove consentita, una carta didentità.",
textAlignment: "PKTextAlignmentLeft",
},
{
key: "yourSeat",
label: "Il tuo posto:",
value: "verifica il tuo numero di posto nella parte superiore. Durante limbarco utilizza le scale anteriori e posteriori: per le file 1-10 imbarcati dalla parte anteriore; per le file 11-31 imbarcati dalla parte posteriore. Colloca le borse di dimensioni ridotte sotto il sedile davanti a te.",
textAlignment: "PKTextAlignmentLeft",
},
{
key: "Pack safely",
label: "Bagaglio sicuro",
value: "Fai clic http://easyjet.com/it/articoli-pericolosi per maggiori informazioni sulle merci pericolose oppure visita il sito CAA http://www.caa.co.uk/default.aspx?catid=2200",
textAlignment: "PKTextAlignmentLeft",
},
{
key: "Thank you for travelling easyJet",
label: "Grazie per aver viaggiato con easyJet",
value: "",
textAlignment: "PKTextAlignmentLeft",
},
);
const stream = pass.getAsStream();
response.set({
"Content-type": pass.mimeType,
"Content-disposition": `attachment; filename=${passName}.pkpass`,
});
stream.pipe(response);
} catch (err) {
console.log(err);
response.set({
"Content-type": "text/html",
});
response.send(err.message);
}
});

View File

@@ -0,0 +1,7 @@
import "./fields";
import "./localize";
import "./PKPass.from";
import "./PKPasses";
import "./scratch";
import "./setBarcodes";
import "./setExpirationDate";

View File

@@ -0,0 +1,76 @@
/**
* .localize() methods example
* To see all the included languages, you have to unzip the
* .pkpass file and check for .lproj folders
*/
import { app } from "./webserver";
import { getCertificates } from "./shared";
import path from "path";
import { PKPass } from "passkit-generator";
/** Symbols are exported just for tests and examples. Replicate only if really needed. */
import { localizationSymbol } from "passkit-generator/lib/PKPass";
app.route("/localize/:modelName").get(async (request, response) => {
const passName =
request.params.modelName +
"_" +
new Date().toISOString().split("T")[0].replace(/-/gi, "");
const certificates = await getCertificates();
try {
const pass = await PKPass.from(
{
model: path.resolve(
__dirname,
`../../models/${request.params.modelName}`,
),
certificates: {
wwdr: certificates.wwdr,
signerCert: certificates.signerCert,
signerKey: certificates.signerKey,
signerKeyPassphrase: certificates.signerKeyPassphrase,
},
},
request.body || request.params || request.query,
);
// Italian, already has an .lproj which gets included...
pass.localize("it", {
EVENT: "Evento",
LOCATION: "Dove",
});
// ...while German doesn't, so it gets created
pass.localize("de", {
EVENT: "Ereignis",
LOCATION: "Ort",
});
// This language does not exist but is still added as .lproj folder
pass.localize("zu", {});
console.log(
"Added languages",
Object.keys(pass[localizationSymbol]).join(", "),
);
const stream = pass.getAsStream();
response.set({
"Content-type": pass.mimeType,
"Content-disposition": `attachment; filename=${passName}.pkpass`,
});
stream.pipe(response);
} catch (err) {
console.log(err);
response.set({
"Content-type": "text/html",
});
response.send(err.message);
}
});

View File

@@ -0,0 +1,106 @@
/**
* This examples shows how you can create a PKPass from scratch,
* by adding files later and not adding pass.json
*/
import { app } from "./webserver";
import { getCertificates } from "./shared";
import path from "path";
import { promises as fs } from "fs";
import { PKPass } from "passkit-generator";
function getRandomColorPart() {
return Math.floor(Math.random() * 255);
}
app.route("/scratch/:modelName").get(async (request, response) => {
const passName =
request.params.modelName +
"_" +
new Date().toISOString().split("T")[0].replace(/-/gi, "");
const [iconFromModel, certificates] = await Promise.all([
fs.readFile(
path.resolve(
__dirname,
"../../models/exampleBooking.pass/icon.png",
),
),
await getCertificates(),
]);
try {
const pass = new PKPass(
{},
{
wwdr: certificates.wwdr,
signerCert: certificates.signerCert,
signerKey: certificates.signerKey,
signerKeyPassphrase: certificates.signerKeyPassphrase,
},
{
...(request.body || request.params || request.query),
description: "Example Apple Wallet Pass",
passTypeIdentifier: "pass.com.passkitgenerator",
serialNumber: "nmyuxofgna",
organizationName: `Test Organization ${Math.random()}`,
teamIdentifier: "F53WB8AE67",
foregroundColor: `rgb(${getRandomColorPart()}, ${getRandomColorPart()}, ${getRandomColorPart()})`,
labelColor: `rgb(${getRandomColorPart()}, ${getRandomColorPart()}, ${getRandomColorPart()})`,
backgroundColor: `rgb(${getRandomColorPart()}, ${getRandomColorPart()}, ${getRandomColorPart()})`,
},
);
pass.type = "boardingPass";
pass.transitType = "PKTransitTypeAir";
pass.headerFields.push(
{
key: "header-field-test-1",
value: "Unknown",
},
{
key: "header-field-test-2",
value: "unknown",
},
);
pass.primaryFields.push(
{
key: "primaryField-1",
value: "NAP",
},
{
key: "primaryField-2",
value: "VCE",
},
);
/**
* Required by Apple. If one is not available, a
* pass might be openable on a Mac but not on a
* specific iPhone model
*/
pass.addBuffer("icon.png", iconFromModel);
pass.addBuffer("icon@2x.png", iconFromModel);
pass.addBuffer("icon@3x.png", iconFromModel);
const stream = pass.getAsStream();
response.set({
"Content-type": pass.mimeType,
"Content-disposition": `attachment; filename=${passName}.pkpass`,
});
stream.pipe(response);
} catch (err) {
console.log(err);
response.set({
"Content-type": "text/html",
});
response.send(err.message);
}
});

View File

@@ -0,0 +1,82 @@
/**
* .barcodes() methods example
* Here we set the barcode. To see all the results, you can
* both unzip .pkpass file or check the properties before
* generating the whole bundle
*
* Pass ?alt=true as querystring to test a barcode generate
* by a string
*/
import { app } from "./webserver";
import { getCertificates } from "./shared";
import { PKPass } from "passkit-generator";
import path from "path";
app.route("/barcodes/:modelName").get(async (request, response) => {
const passName =
request.params.modelName +
"_" +
new Date().toISOString().split("T")[0].replace(/-/gi, "");
const certificates = await getCertificates();
try {
const pass = await PKPass.from(
{
model: path.resolve(
__dirname,
`../../models/${request.params.modelName}`,
),
certificates: {
wwdr: certificates.wwdr,
signerCert: certificates.signerCert,
signerKey: certificates.signerKey,
signerKeyPassphrase: certificates.signerKeyPassphrase,
},
},
request.body || request.params || request.query || {},
);
if (request.query.alt === "true") {
// After this, pass.props["barcodes"] will have support for all the formats
pass.setBarcodes("Thank you for using this package <3");
console.log(
"Barcodes support is autocompleted:",
pass.props["barcodes"],
);
} else {
// After this, pass.props["barcodes"] will have support for just two of three
// of the passed format (the valid ones);
pass.setBarcodes(
{
message: "Thank you for using this package <3",
format: "PKBarcodeFormatCode128",
},
{
message: "Thank you for using this package <3",
format: "PKBarcodeFormatPDF417",
},
);
}
const stream = pass.getAsStream();
response.set({
"Content-type": pass.mimeType,
"Content-disposition": `attachment; filename=${passName}.pkpass`,
});
stream.pipe(response);
} catch (err) {
console.log(err);
response.set({
"Content-type": "text/html",
});
response.send(err.message);
}
});

View File

@@ -0,0 +1,82 @@
/**
* .expiration() method and voided prop example
* To check if a ticket is void, look at the barcode;
* If it is grayed, the ticket is voided. May not be showed on macOS.
*
* To check if a ticket has an expiration date, you'll
* have to wait two minutes.
*/
import { app } from "./webserver";
import { getCertificates } from "./shared";
import path from "path";
import { PKPass } from "passkit-generator";
app.route("/expirationDate/:modelName").get(async (request, response) => {
if (!request.query.fn) {
response.send(
"<a href='?fn=void'>Generate a voided pass.</a><br><a href='?fn=expiration'>Generate a pass with expiration date</a>",
);
return;
}
const certificates = await getCertificates();
const passName =
request.params.modelName +
"_" +
new Date().toISOString().split("T")[0].replace(/-/gi, "");
try {
const pass = await PKPass.from(
{
model: path.resolve(
__dirname,
`../../models/${request.params.modelName}`,
),
certificates: {
wwdr: certificates.wwdr,
signerCert: certificates.signerCert,
signerKey: certificates.signerKey,
signerKeyPassphrase: certificates.signerKeyPassphrase,
},
},
Object.assign(
{
voided: request.query.fn === "void",
},
{ ...(request.body || request.params || request.query || {}) },
),
);
if (request.query.fn === "expiration") {
// 2 minutes later...
const d = new Date();
d.setMinutes(d.getMinutes() + 2);
// setting the expiration
pass.setExpirationDate(d);
console.log(
"EXPIRATION DATE EXPECTED:",
pass.props["expirationDate"],
);
}
const stream = pass.getAsStream();
response.set({
"Content-type": pass.mimeType,
"Content-disposition": `attachment; filename=${passName}.pkpass`,
});
stream.pipe(response);
} catch (err) {
console.log(err);
response.set({
"Content-type": "text/html",
});
response.send(err.message);
}
});

View File

@@ -0,0 +1,41 @@
import { promises as fs } from "fs";
import path from "path";
const certificatesCache: Partial<{
signerCert: Buffer;
signerKey: Buffer;
wwdr: Buffer;
signerKeyPassphrase: string;
}> = {};
export async function getCertificates(): Promise<typeof certificatesCache> {
if (Object.keys(certificatesCache).length) {
return certificatesCache;
}
const [signerCert, signerKey, wwdr, signerKeyPassphrase] =
await Promise.all([
fs.readFile(
path.resolve(__dirname, "../../../certificates/signerCert.pem"),
"utf-8",
),
fs.readFile(
path.resolve(__dirname, "../../../certificates/signerKey.pem"),
"utf-8",
),
fs.readFile(
path.resolve(__dirname, "../../../certificates/WWDR.pem"),
"utf-8",
),
Promise.resolve("123456"),
]);
Object.assign(certificatesCache, {
signerCert,
signerKey,
wwdr,
signerKeyPassphrase,
});
return certificatesCache;
}

View File

@@ -0,0 +1,14 @@
/*
* Generic webserver instance for the examples
* @Author Alexander P. Cerutti
* Requires express to run
*/
import express from "express";
export const app = express();
app.use(express.json());
app.listen(8080, "0.0.0.0", () => {
console.log("Webserver started.");
});

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"module": "CommonJS",
"outDir": "build",
"moduleResolution": "node",
"sourceMap": true
},
"exclude": ["node_modules"]
}