Initial commit: Web2App static site to Tauri platform
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
117
server/db/index.ts
Normal file
117
server/db/index.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import Database from "better-sqlite3";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export type BuildStatus =
|
||||
| "pending"
|
||||
| "queued"
|
||||
| "in_progress"
|
||||
| "completed"
|
||||
| "failed";
|
||||
|
||||
export interface BuildRecord {
|
||||
id: string;
|
||||
app_name: string;
|
||||
app_identifier: string;
|
||||
status: BuildStatus;
|
||||
workflow_run_id: number | null;
|
||||
windows_url: string | null;
|
||||
android_url: string | null;
|
||||
error: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
if (db) return db;
|
||||
|
||||
const dataDir = path.resolve(__dirname, "../../data");
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
const dbPath = path.join(dataDir, "web2app.db");
|
||||
db = new Database(dbPath);
|
||||
const schema = fs.readFileSync(path.join(__dirname, "schema.sql"), "utf8");
|
||||
db.exec(schema);
|
||||
return db;
|
||||
}
|
||||
|
||||
export function insertBuild(record: {
|
||||
id: string;
|
||||
appName: string;
|
||||
appIdentifier: string;
|
||||
}): BuildRecord {
|
||||
const database = getDb();
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO builds (id, app_name, app_identifier, status)
|
||||
VALUES (@id, @appName, @appIdentifier, 'pending')`,
|
||||
)
|
||||
.run(record);
|
||||
|
||||
return getBuild(record.id)!;
|
||||
}
|
||||
|
||||
export function getBuild(id: string): BuildRecord | null {
|
||||
const row = getDb()
|
||||
.prepare("SELECT * FROM builds WHERE id = ?")
|
||||
.get(id) as BuildRecord | undefined;
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export function listBuilds(limit = 20): BuildRecord[] {
|
||||
return getDb()
|
||||
.prepare("SELECT * FROM builds ORDER BY created_at DESC LIMIT ?")
|
||||
.all(limit) as BuildRecord[];
|
||||
}
|
||||
|
||||
export function updateBuild(
|
||||
id: string,
|
||||
patch: Partial<
|
||||
Pick<
|
||||
BuildRecord,
|
||||
| "status"
|
||||
| "workflow_run_id"
|
||||
| "windows_url"
|
||||
| "android_url"
|
||||
| "error"
|
||||
>
|
||||
>,
|
||||
): BuildRecord | null {
|
||||
const fields: string[] = [];
|
||||
const params: Record<string, unknown> = { id };
|
||||
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value !== undefined) {
|
||||
fields.push(`${key} = @${key}`);
|
||||
params[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.length === 0) return getBuild(id);
|
||||
|
||||
fields.push("updated_at = datetime('now')");
|
||||
getDb()
|
||||
.prepare(`UPDATE builds SET ${fields.join(", ")} WHERE id = @id`)
|
||||
.run(params);
|
||||
|
||||
return getBuild(id);
|
||||
}
|
||||
|
||||
export function toPublicBuild(record: BuildRecord) {
|
||||
return {
|
||||
id: record.id,
|
||||
appName: record.app_name,
|
||||
appIdentifier: record.app_identifier,
|
||||
status: record.status,
|
||||
workflowRunId: record.workflow_run_id,
|
||||
windowsUrl: record.windows_url,
|
||||
androidUrl: record.android_url,
|
||||
error: record.error,
|
||||
createdAt: record.created_at,
|
||||
updatedAt: record.updated_at,
|
||||
};
|
||||
}
|
||||
14
server/db/schema.sql
Normal file
14
server/db/schema.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE IF NOT EXISTS builds (
|
||||
id TEXT PRIMARY KEY,
|
||||
app_name TEXT NOT NULL,
|
||||
app_identifier TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
workflow_run_id INTEGER,
|
||||
windows_url TEXT,
|
||||
android_url TEXT,
|
||||
error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_builds_created_at ON builds(created_at DESC);
|
||||
36
server/index.ts
Normal file
36
server/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import cors from "cors";
|
||||
import dotenv from "dotenv";
|
||||
import express from "express";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { buildsRouter } from "./routes/builds.js";
|
||||
|
||||
dotenv.config({ path: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.env") });
|
||||
|
||||
const app = express();
|
||||
const port = Number(process.env.PORT ?? "3001");
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
app.get("/api/health", (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.use("/api/builds", buildsRouter);
|
||||
|
||||
const webDist = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../web/dist");
|
||||
app.use(express.static(webDist));
|
||||
app.get("*", (_req, res, next) => {
|
||||
if (_req.path.startsWith("/api")) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
res.sendFile(path.join(webDist, "index.html"), (err) => {
|
||||
if (err) next();
|
||||
});
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Web2App server listening on http://localhost:${port}`);
|
||||
});
|
||||
31
server/package.json
Normal file
31
server/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "web2app-server",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nanoid": "^5.1.0",
|
||||
"octokit": "^4.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.10.5",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "~5.6.2"
|
||||
}
|
||||
}
|
||||
170
server/routes/builds.ts
Normal file
170
server/routes/builds.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { Router } from "express";
|
||||
import multer from "multer";
|
||||
import { nanoid } from "nanoid";
|
||||
import {
|
||||
getBuild,
|
||||
insertBuild,
|
||||
listBuilds,
|
||||
toPublicBuild,
|
||||
updateBuild,
|
||||
type BuildStatus,
|
||||
} from "../db/index.js";
|
||||
import {
|
||||
getActionsRunUrl,
|
||||
getReleaseAssets,
|
||||
getWorkflowRun,
|
||||
triggerBuildWorkflow,
|
||||
uploadSiteZip,
|
||||
} from "../services/github.js";
|
||||
import { slugifyIdentifier, validateZipBuffer, ZipValidationError } from "../services/zip.js";
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 100 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
const maxUploadMb = Number(process.env.MAX_UPLOAD_MB ?? "50");
|
||||
const maxUploadBytes = maxUploadMb * 1024 * 1024;
|
||||
|
||||
export const buildsRouter = Router();
|
||||
|
||||
buildsRouter.get("/", (_req, res) => {
|
||||
const builds = listBuilds().map(toPublicBuild);
|
||||
res.json({ builds });
|
||||
});
|
||||
|
||||
buildsRouter.get("/:id", async (req, res) => {
|
||||
const record = getBuild(req.params.id);
|
||||
if (!record) {
|
||||
res.status(404).json({ error: "Build not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshed = await refreshBuildStatus(record);
|
||||
res.json({
|
||||
...toPublicBuild(refreshed),
|
||||
actionsUrl: getActionsRunUrl(refreshed.workflow_run_id),
|
||||
});
|
||||
});
|
||||
|
||||
buildsRouter.post("/", upload.single("file"), async (req, res) => {
|
||||
try {
|
||||
const appName = String(req.body.appName ?? "").trim();
|
||||
const identifierInput = String(req.body.identifier ?? "").trim();
|
||||
|
||||
if (!appName) {
|
||||
res.status(400).json({ error: "appName is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!req.file) {
|
||||
res.status(400).json({ error: "file is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!req.file.originalname.toLowerCase().endsWith(".zip")) {
|
||||
res.status(400).json({ error: "Only .zip files are supported" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { normalizedBuffer } = validateZipBuffer(req.file.buffer, maxUploadBytes);
|
||||
const jobId = nanoid(10);
|
||||
const appIdentifier =
|
||||
identifierInput ||
|
||||
`com.web2app.${slugifyIdentifier(appName) || jobId.toLowerCase()}`;
|
||||
|
||||
insertBuild({ id: jobId, appName, appIdentifier });
|
||||
|
||||
await uploadSiteZip(jobId, normalizedBuffer);
|
||||
updateBuild(jobId, { status: "queued" });
|
||||
|
||||
const workflowRunId = await triggerBuildWorkflow({
|
||||
jobId,
|
||||
appName,
|
||||
appIdentifier,
|
||||
});
|
||||
|
||||
updateBuild(jobId, {
|
||||
workflow_run_id: workflowRunId,
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
id: jobId,
|
||||
status: "in_progress",
|
||||
workflowRunId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ZipValidationError) {
|
||||
res.status(400).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : "Failed to create build",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function refreshBuildStatus(record: ReturnType<typeof getBuild> & object) {
|
||||
if (!record.workflow_run_id) return record;
|
||||
if (record.status === "completed" || record.status === "failed") {
|
||||
return record;
|
||||
}
|
||||
|
||||
try {
|
||||
const run = await getWorkflowRun(record.workflow_run_id);
|
||||
|
||||
if (run.status === "queued") {
|
||||
return updateBuild(record.id, { status: "queued" }) ?? record;
|
||||
}
|
||||
|
||||
if (run.status === "in_progress") {
|
||||
return updateBuild(record.id, { status: "in_progress" }) ?? record;
|
||||
}
|
||||
|
||||
if (run.status === "completed") {
|
||||
if (run.conclusion === "success") {
|
||||
const assets = await getReleaseAssets(record.id);
|
||||
return (
|
||||
updateBuild(record.id, {
|
||||
status: "completed",
|
||||
windows_url: assets.windowsUrl,
|
||||
android_url: assets.androidUrl,
|
||||
error:
|
||||
assets.windowsUrl && assets.androidUrl
|
||||
? null
|
||||
: "Build finished but release assets were not found yet",
|
||||
}) ?? record
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
updateBuild(record.id, {
|
||||
status: "failed",
|
||||
error: `Workflow failed with conclusion: ${run.conclusion ?? "unknown"}`,
|
||||
}) ?? record
|
||||
);
|
||||
}
|
||||
|
||||
return record;
|
||||
} catch (error) {
|
||||
return (
|
||||
updateBuild(record.id, {
|
||||
status: "failed",
|
||||
error: error instanceof Error ? error.message : "Status refresh failed",
|
||||
}) ?? record
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPublicMeta(record: ReturnType<typeof getBuild>) {
|
||||
if (!record) return null;
|
||||
return {
|
||||
...toPublicBuild(record),
|
||||
actionsUrl: getActionsRunUrl(record.workflow_run_id),
|
||||
};
|
||||
}
|
||||
|
||||
export type { BuildStatus };
|
||||
155
server/services/github.ts
Normal file
155
server/services/github.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { Octokit } from "octokit";
|
||||
|
||||
const owner = process.env.GITHUB_OWNER ?? "";
|
||||
const repo = process.env.GITHUB_REPO ?? "";
|
||||
const token = process.env.GITHUB_TOKEN ?? "";
|
||||
|
||||
function getOctokit(): Octokit {
|
||||
if (!owner || !repo || !token) {
|
||||
throw new Error(
|
||||
"Missing GITHUB_OWNER, GITHUB_REPO, or GITHUB_TOKEN environment variables",
|
||||
);
|
||||
}
|
||||
return new Octokit({ auth: token });
|
||||
}
|
||||
|
||||
export function getRepoInfo() {
|
||||
return { owner, repo };
|
||||
}
|
||||
|
||||
export async function uploadSiteZip(jobId: string, zipBuffer: Buffer): Promise<void> {
|
||||
const octokit = getOctokit();
|
||||
const path = `builds/${jobId}/site.zip`;
|
||||
const content = zipBuffer.toString("base64");
|
||||
|
||||
try {
|
||||
await octokit.rest.repos.createOrUpdateFileContents({
|
||||
owner,
|
||||
repo,
|
||||
path,
|
||||
message: `chore: upload site for build ${jobId}`,
|
||||
content,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const status = (error as { status?: number }).status;
|
||||
if (status !== 422) throw error;
|
||||
|
||||
const existing = await octokit.rest.repos.getContent({ owner, repo, path });
|
||||
if (Array.isArray(existing.data) || existing.data.type !== "file") {
|
||||
throw new Error(`Unexpected content at ${path}`);
|
||||
}
|
||||
|
||||
await octokit.rest.repos.createOrUpdateFileContents({
|
||||
owner,
|
||||
repo,
|
||||
path,
|
||||
message: `chore: update site for build ${jobId}`,
|
||||
content,
|
||||
sha: existing.data.sha,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function triggerBuildWorkflow(input: {
|
||||
jobId: string;
|
||||
appName: string;
|
||||
appIdentifier: string;
|
||||
}): Promise<number> {
|
||||
const octokit = getOctokit();
|
||||
|
||||
await octokit.rest.actions.createWorkflowDispatch({
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: "build-app.yml",
|
||||
ref: process.env.DEFAULT_BRANCH ?? "main",
|
||||
inputs: {
|
||||
job_id: input.jobId,
|
||||
app_name: input.appName,
|
||||
app_identifier: input.appIdentifier,
|
||||
},
|
||||
});
|
||||
|
||||
await sleep(3000);
|
||||
|
||||
const since = new Date(Date.now() - 120_000).toISOString();
|
||||
const runs = await octokit.rest.actions.listWorkflowRuns({
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: "build-app.yml",
|
||||
event: "workflow_dispatch",
|
||||
per_page: 10,
|
||||
});
|
||||
|
||||
const run = runs.data.workflow_runs
|
||||
.filter((item) => item.created_at >= since)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||
)[0];
|
||||
|
||||
if (!run) {
|
||||
throw new Error("Failed to locate workflow run after dispatch");
|
||||
}
|
||||
|
||||
return run.id;
|
||||
}
|
||||
|
||||
export async function getWorkflowRun(runId: number) {
|
||||
const octokit = getOctokit();
|
||||
const { data } = await octokit.rest.actions.getWorkflowRun({
|
||||
owner,
|
||||
repo,
|
||||
run_id: runId,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getReleaseAssets(jobId: string): Promise<{
|
||||
windowsUrl: string | null;
|
||||
androidUrl: string | null;
|
||||
}> {
|
||||
const octokit = getOctokit();
|
||||
const tag = `build-${jobId}`;
|
||||
|
||||
try {
|
||||
const { data: release } = await octokit.rest.repos.getReleaseByTag({
|
||||
owner,
|
||||
repo,
|
||||
tag,
|
||||
});
|
||||
|
||||
let windowsUrl: string | null = null;
|
||||
let androidUrl: string | null = null;
|
||||
|
||||
for (const asset of release.assets) {
|
||||
const name = asset.name.toLowerCase();
|
||||
if (
|
||||
!windowsUrl &&
|
||||
(name.endsWith(".exe") ||
|
||||
name.endsWith(".msi") ||
|
||||
name.includes("windows"))
|
||||
) {
|
||||
windowsUrl = asset.browser_download_url;
|
||||
}
|
||||
if (
|
||||
!androidUrl &&
|
||||
(name.endsWith(".apk") || name.includes("android"))
|
||||
) {
|
||||
androidUrl = asset.browser_download_url;
|
||||
}
|
||||
}
|
||||
|
||||
return { windowsUrl, androidUrl };
|
||||
} catch {
|
||||
return { windowsUrl: null, androidUrl: null };
|
||||
}
|
||||
}
|
||||
|
||||
export function getActionsRunUrl(runId: number | null): string | null {
|
||||
if (!runId || !owner || !repo) return null;
|
||||
return `https://github.com/${owner}/${repo}/actions/runs/${runId}`;
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
102
server/services/zip.ts
Normal file
102
server/services/zip.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import AdmZip from "adm-zip";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export class ZipValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ZipValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
export function validateZipBuffer(
|
||||
buffer: Buffer,
|
||||
maxBytes: number,
|
||||
): { normalizedBuffer: Buffer } {
|
||||
if (buffer.length > maxBytes) {
|
||||
throw new ZipValidationError(
|
||||
`Zip file exceeds ${Math.floor(maxBytes / (1024 * 1024))}MB limit`,
|
||||
);
|
||||
}
|
||||
|
||||
let zip: AdmZip;
|
||||
try {
|
||||
zip = new AdmZip(buffer);
|
||||
} catch {
|
||||
throw new ZipValidationError("Invalid zip archive");
|
||||
}
|
||||
|
||||
const entries = zip.getEntries().filter((entry) => !entry.isDirectory);
|
||||
if (entries.length === 0) {
|
||||
throw new ZipValidationError("Zip archive is empty");
|
||||
}
|
||||
|
||||
const indexEntry = entries.find((entry) => {
|
||||
const normalized = entry.entryName.replace(/\\/g, "/");
|
||||
if (normalized === "index.html") return true;
|
||||
const parts = normalized.split("/");
|
||||
return parts.length === 2 && parts[1] === "index.html";
|
||||
});
|
||||
|
||||
if (!indexEntry) {
|
||||
throw new ZipValidationError(
|
||||
"Zip must contain index.html at root or in a single top-level folder",
|
||||
);
|
||||
}
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "web2app-"));
|
||||
try {
|
||||
zip.extractAllTo(tmpDir, true);
|
||||
const rootIndex = path.join(tmpDir, "index.html");
|
||||
if (!fs.existsSync(rootIndex)) {
|
||||
const subdirs = fs
|
||||
.readdirSync(tmpDir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory());
|
||||
if (subdirs.length !== 1) {
|
||||
throw new ZipValidationError(
|
||||
"index.html must be at zip root or inside exactly one folder",
|
||||
);
|
||||
}
|
||||
const nestedRoot = path.join(tmpDir, subdirs[0].name);
|
||||
flattenDirectory(nestedRoot, tmpDir);
|
||||
}
|
||||
const normalizedZip = new AdmZip();
|
||||
addDirectoryToZip(normalizedZip, tmpDir, "");
|
||||
return { normalizedBuffer: normalizedZip.toBuffer() };
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function flattenDirectory(sourceDir: string, targetDir: string) {
|
||||
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
|
||||
const from = path.join(sourceDir, entry.name);
|
||||
const to = path.join(targetDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
fs.cpSync(from, to, { recursive: true });
|
||||
} else {
|
||||
fs.copyFileSync(from, to);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addDirectoryToZip(zip: AdmZip, dir: string, prefix: string) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
const entryName = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
addDirectoryToZip(zip, fullPath, entryName);
|
||||
} else {
|
||||
zip.addFile(entryName, fs.readFileSync(fullPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function slugifyIdentifier(input: string): string {
|
||||
return input
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ".")
|
||||
.replace(/^\.+|\.+$/g, "")
|
||||
.slice(0, 48);
|
||||
}
|
||||
15
server/tsconfig.json
Normal file
15
server/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user