feat: migrate to Cloudflare Workers with icon and version upload

Replace Express/SQLite and web/ with frontend/, worker/, and wrangler deploy. Add optional app icon upload, date-based app_version, and build-app workflow input.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 13:30:05 +08:00
parent 0921e5f7a1
commit b69dfc4813
55 changed files with 3285 additions and 3411 deletions

View File

@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS builds (
id TEXT PRIMARY KEY,
app_name TEXT NOT NULL,
app_name_en TEXT NOT NULL DEFAULT '',
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);

View File

@@ -0,0 +1 @@
ALTER TABLE builds ADD COLUMN app_version TEXT NOT NULL DEFAULT '1.0.0';

13
worker/package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "web2app-worker",
"private": true,
"version": "0.1.0",
"type": "module",
"dependencies": {
"fflate": "^0.8.2",
"nanoid": "^5.0.9"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20241127.0"
}
}

128
worker/src/db/builds.ts Normal file
View File

@@ -0,0 +1,128 @@
import type { Env } from "../env";
export type BuildStatus =
| "pending"
| "queued"
| "in_progress"
| "completed"
| "failed";
export interface BuildRecord {
id: string;
app_name: string;
app_name_en: string;
app_identifier: string;
app_version: 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;
}
export async function insertBuild(
env: Env,
record: {
id: string;
appName: string;
appNameEn: string;
appIdentifier: string;
appVersion: string;
},
): Promise<BuildRecord> {
await env.DB.prepare(
`INSERT INTO builds (id, app_name, app_name_en, app_identifier, app_version, status)
VALUES (?, ?, ?, ?, ?, 'pending')`,
)
.bind(
record.id,
record.appName,
record.appNameEn,
record.appIdentifier,
record.appVersion,
)
.run();
const row = await getBuild(env, record.id);
if (!row) throw new Error("Failed to insert build");
return row;
}
export async function getBuild(
env: Env,
id: string,
): Promise<BuildRecord | null> {
const row = await env.DB.prepare("SELECT * FROM builds WHERE id = ?")
.bind(id)
.first<BuildRecord>();
return row ?? null;
}
export async function listBuilds(
env: Env,
limit = 20,
): Promise<BuildRecord[]> {
const { results } = await env.DB.prepare(
"SELECT * FROM builds ORDER BY created_at DESC LIMIT ?",
)
.bind(limit)
.all<BuildRecord>();
return results ?? [];
}
export async function updateBuild(
env: Env,
id: string,
patch: Partial<
Pick<
BuildRecord,
| "status"
| "workflow_run_id"
| "windows_url"
| "android_url"
| "error"
>
>,
): Promise<BuildRecord | null> {
const fields: string[] = [];
const values: unknown[] = [];
for (const [key, value] of Object.entries(patch)) {
if (value !== undefined) {
fields.push(`${key} = ?`);
values.push(value);
}
}
if (fields.length === 0) return getBuild(env, id);
fields.push("updated_at = datetime('now')");
values.push(id);
await env.DB.prepare(
`UPDATE builds SET ${fields.join(", ")} WHERE id = ?`,
)
.bind(...values)
.run();
return getBuild(env, id);
}
export function toPublicBuild(record: BuildRecord) {
return {
id: record.id,
appName: record.app_name,
appNameEn: record.app_name_en || record.app_name,
appIdentifier: record.app_identifier,
appVersion: record.app_version || "1.0.0",
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,
};
}

9
worker/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
export interface Env {
DB: D1Database;
ASSETS: Fetcher;
GITHUB_TOKEN: string;
GITHUB_OWNER: string;
GITHUB_REPO: string;
DEFAULT_BRANCH?: string;
MAX_UPLOAD_MB?: string;
}

19
worker/src/index.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { Env } from "./env";
import { handleBuildsRequest } from "./routes/builds";
import { jsonResponse } from "./lib/response";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/api/health") {
return jsonResponse({ ok: true });
}
if (url.pathname.startsWith("/api/builds")) {
return handleBuildsRequest(request, env, url);
}
return env.ASSETS.fetch(request);
},
};

View File

@@ -0,0 +1,17 @@
export function jsonResponse(
data: unknown,
status = 200,
headers?: Record<string, string>,
): Response {
return new Response(JSON.stringify(data), {
status,
headers: {
"Content-Type": "application/json",
...headers,
},
});
}
export function errorResponse(message: string, status: number): Response {
return jsonResponse({ error: message }, status);
}

226
worker/src/routes/builds.ts Normal file
View File

@@ -0,0 +1,226 @@
import { nanoid } from "nanoid";
import type { Env } from "../env";
import {
getBuild,
insertBuild,
listBuilds,
toPublicBuild,
updateBuild,
type BuildRecord,
} from "../db/builds";
import { errorResponse, jsonResponse } from "../lib/response";
import {
getActionsRunUrl,
getReleaseAssets,
getWorkflowRun,
triggerBuildWorkflow,
uploadBuildFile,
uploadSiteZip,
} from "../services/github";
import { IconValidationError, resolveIconUpload } from "../services/icon";
import {
validateAppVersion,
VersionValidationError,
} from "../services/version";
import {
normalizeAppIdentifier,
slugifyIdentifier,
validateChineseAppName,
validateEnglishAppName,
validateZipBuffer,
ZipValidationError,
} from "../services/zip";
function maxUploadBytes(env: Env): number {
const mb = Number(env.MAX_UPLOAD_MB ?? "50");
return mb * 1024 * 1024;
}
export async function handleBuildsRequest(
request: Request,
env: Env,
url: URL,
): Promise<Response> {
const path = url.pathname;
if (path === "/api/builds" && request.method === "GET") {
const builds = (await listBuilds(env)).map(toPublicBuild);
return jsonResponse({ builds });
}
const match = path.match(/^\/api\/builds\/([^/]+)$/);
if (match) {
const id = match[1];
if (request.method === "GET") {
const record = await getBuild(env, id);
if (!record) {
return errorResponse("Build not found", 404);
}
const refreshed = await refreshBuildStatus(env, record);
return jsonResponse({
...toPublicBuild(refreshed),
actionsUrl: getActionsRunUrl(env, refreshed.workflow_run_id),
});
}
return errorResponse("Method not allowed", 405);
}
if (path === "/api/builds" && request.method === "POST") {
return createBuild(request, env);
}
return errorResponse("Not found", 404);
}
async function createBuild(request: Request, env: Env): Promise<Response> {
try {
const formData = await request.formData();
const appNameZh = validateChineseAppName(
String(formData.get("appNameZh") ?? formData.get("appName") ?? ""),
);
const appNameEn = validateEnglishAppName(
String(formData.get("appNameEn") ?? ""),
);
const identifierInput = String(formData.get("identifier") ?? "").trim();
const appVersion = validateAppVersion(
String(formData.get("appVersion") ?? ""),
);
const file = formData.get("file");
if (!(file instanceof File)) {
return errorResponse("file is required", 400);
}
if (!file.name.toLowerCase().endsWith(".zip")) {
return errorResponse("Only .zip files are supported", 400);
}
const buffer = new Uint8Array(await file.arrayBuffer());
const { normalizedBuffer } = validateZipBuffer(buffer, maxUploadBytes(env));
const jobId = nanoid(10);
const slug = slugifyIdentifier(appNameEn) || jobId.toLowerCase();
const appIdentifier = normalizeAppIdentifier(
identifierInput || `com.web2app.${slug}`,
);
await insertBuild(env, {
id: jobId,
appName: appNameZh,
appNameEn,
appIdentifier,
appVersion,
});
await uploadSiteZip(env, jobId, normalizedBuffer);
await uploadBuildFile(
env,
jobId,
"version.txt",
new TextEncoder().encode(appVersion),
);
const iconFile = formData.get("icon");
if (iconFile instanceof File && iconFile.size > 0) {
const iconBuffer = new Uint8Array(await iconFile.arrayBuffer());
const { repoPath } = resolveIconUpload(iconFile, iconBuffer);
await uploadBuildFile(env, jobId, repoPath, iconBuffer);
}
await updateBuild(env, jobId, { status: "queued" });
const workflowRunId = await triggerBuildWorkflow(env, {
jobId,
appName: appNameZh,
appNameEn,
appIdentifier,
});
await updateBuild(env, jobId, {
workflow_run_id: workflowRunId,
status: "in_progress",
});
return jsonResponse(
{
id: jobId,
status: "in_progress",
workflowRunId,
},
201,
);
} catch (error) {
if (
error instanceof ZipValidationError ||
error instanceof VersionValidationError ||
error instanceof IconValidationError
) {
return errorResponse(error.message, 400);
}
console.error(error);
return errorResponse(
error instanceof Error ? error.message : "Failed to create build",
500,
);
}
}
async function refreshBuildStatus(
env: Env,
record: BuildRecord,
): Promise<BuildRecord> {
if (!record.workflow_run_id) return record;
if (record.status === "completed" || record.status === "failed") {
return record;
}
try {
const run = await getWorkflowRun(env, record.workflow_run_id);
if (run.status === "queued") {
return (await updateBuild(env, record.id, { status: "queued" })) ?? record;
}
if (run.status === "in_progress") {
return (
(await updateBuild(env, record.id, { status: "in_progress" })) ?? record
);
}
if (run.status === "completed") {
if (run.conclusion === "success") {
const assets = await getReleaseAssets(env, record.id);
return (
(await updateBuild(env, 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 (
(await updateBuild(env, record.id, {
status: "failed",
error: `Workflow failed with conclusion: ${run.conclusion ?? "unknown"}`,
})) ?? record
);
}
return record;
} catch (error) {
return (
(await updateBuild(env, record.id, {
status: "failed",
error: error instanceof Error ? error.message : "Status refresh failed",
})) ?? record
);
}
}

View File

@@ -0,0 +1,252 @@
import type { Env } from "../env";
function getGitHubConfig(env: Env) {
return {
owner: (env.GITHUB_OWNER ?? "").trim(),
repo: (env.GITHUB_REPO ?? "").trim(),
token: (env.GITHUB_TOKEN ?? "").trim(),
branch: (env.DEFAULT_BRANCH ?? "main").trim(),
};
}
function assertGitHubConfig(env: Env) {
const { owner, repo, token } = getGitHubConfig(env);
if (!owner || !repo || !token) {
throw new Error(
"Missing GITHUB_OWNER, GITHUB_REPO, or GITHUB_TOKEN configuration",
);
}
return { owner, repo, token };
}
async function githubFetch(
env: Env,
path: string,
init?: RequestInit,
): Promise<Response> {
const { token } = assertGitHubConfig(env);
return fetch(`https://api.github.com${path}`, {
...init,
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "web2app-worker",
...(init?.headers ?? {}),
},
});
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
}
return btoa(binary);
}
export async function uploadBuildFile(
env: Env,
jobId: string,
relativePath: string,
fileBuffer: Uint8Array,
): Promise<void> {
const { owner, repo } = assertGitHubConfig(env);
const path = `builds/${jobId}/${relativePath}`;
const content = bytesToBase64(fileBuffer);
const put = async (sha?: string) => {
const body: Record<string, string> = {
message: sha
? `chore: update ${relativePath} for build ${jobId}`
: `chore: upload ${relativePath} for build ${jobId}`,
content,
};
if (sha) body.sha = sha;
return githubFetch(env, `/repos/${owner}/${repo}/contents/${path}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
};
let response = await put();
if (response.status === 422) {
const getResponse = await githubFetch(
env,
`/repos/${owner}/${repo}/contents/${path}`,
);
if (!getResponse.ok) {
const text = await getResponse.text();
throw new Error(`GitHub get content failed (${getResponse.status}): ${text}`);
}
const existing = (await getResponse.json()) as {
sha?: string;
type?: string;
};
if (!existing.sha || existing.type !== "file") {
throw new Error(`Unexpected content at ${path}`);
}
response = await put(existing.sha);
}
if (!response.ok) {
const text = await response.text();
throw new Error(`GitHub upload failed (${response.status}): ${text}`);
}
}
export async function uploadSiteZip(
env: Env,
jobId: string,
zipBuffer: Uint8Array,
): Promise<void> {
return uploadBuildFile(env, jobId, "site.zip", zipBuffer);
}
export async function triggerBuildWorkflow(
env: Env,
input: {
jobId: string;
appName: string;
appNameEn: string;
appIdentifier: string;
},
): Promise<number> {
const { owner, repo, branch } = getGitHubConfig(env);
assertGitHubConfig(env);
const dispatchResponse = await githubFetch(
env,
`/repos/${owner}/${repo}/actions/workflows/build-app.yml/dispatches`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ref: branch,
inputs: {
job_id: input.jobId,
app_name: input.appName,
app_name_en: input.appNameEn,
app_identifier: input.appIdentifier,
},
}),
},
);
if (!dispatchResponse.ok) {
const text = await dispatchResponse.text();
throw new Error(
`Workflow dispatch failed (${dispatchResponse.status}): ${text}`,
);
}
await sleep(3000);
const since = new Date(Date.now() - 120_000).toISOString();
const runsResponse = await githubFetch(
env,
`/repos/${owner}/${repo}/actions/workflows/build-app.yml/runs?event=workflow_dispatch&per_page=10`,
);
if (!runsResponse.ok) {
const text = await runsResponse.text();
throw new Error(`Failed to list workflow runs: ${text}`);
}
const runsData = (await runsResponse.json()) as {
workflow_runs: Array<{ id: number; created_at: string }>;
};
const run = runsData.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(env: Env, runId: number) {
const { owner, repo } = assertGitHubConfig(env);
const response = await githubFetch(
env,
`/repos/${owner}/${repo}/actions/runs/${runId}`,
);
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to get workflow run: ${text}`);
}
return (await response.json()) as {
status: string;
conclusion: string | null;
};
}
export async function getReleaseAssets(
env: Env,
jobId: string,
): Promise<{
windowsUrl: string | null;
androidUrl: string | null;
}> {
const { owner, repo } = assertGitHubConfig(env);
const tag = `build-${jobId}`;
const response = await githubFetch(
env,
`/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`,
);
if (!response.ok) {
return { windowsUrl: null, androidUrl: null };
}
const release = (await response.json()) as {
assets: Array<{ name: string; browser_download_url: string }>;
};
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 };
}
export function getActionsRunUrl(env: Env, runId: number | null): string | null {
const { owner, repo } = getGitHubConfig(env);
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));
}

View File

@@ -0,0 +1,50 @@
export class IconValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "IconValidationError";
}
}
const MAX_ICON_BYTES = 2 * 1024 * 1024;
const ALLOWED_TYPES = new Set([
"image/png",
"image/jpeg",
"image/x-icon",
"image/vnd.microsoft.icon",
]);
const ALLOWED_EXT = new Set([".png", ".jpg", ".jpeg", ".ico"]);
export function resolveIconUpload(
file: File,
buffer: Uint8Array,
): { repoPath: string; fileName: string } {
if (buffer.length > MAX_ICON_BYTES) {
throw new IconValidationError("图标文件不能超过 2MB");
}
const ext = pathExt(file.name).toLowerCase();
if (!ALLOWED_EXT.has(ext)) {
throw new IconValidationError("图标仅支持 PNG、JPG、ICO 格式");
}
const type = file.type.toLowerCase();
if (type && !ALLOWED_TYPES.has(type) && type !== "application/octet-stream") {
throw new IconValidationError("不支持的图标 MIME 类型");
}
if (ext === ".ico") {
return { repoPath: "favicon.ico", fileName: "favicon.ico" };
}
if (ext === ".jpg" || ext === ".jpeg") {
return { repoPath: "logo.jpg", fileName: "logo.jpg" };
}
return { repoPath: "logo.png", fileName: "logo.png" };
}
function pathExt(name: string): string {
const i = name.lastIndexOf(".");
return i >= 0 ? name.slice(i) : "";
}

View File

@@ -0,0 +1,25 @@
export class VersionValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "VersionValidationError";
}
}
/** 当前日期版本,格式如 2026.5.29(月、日不补零) */
export function getDefaultAppVersion(date = new Date()): string {
return `${date.getFullYear()}.${date.getMonth() + 1}.${date.getDate()}`;
}
export function validateAppVersion(input: string): string {
const value = input.trim() || getDefaultAppVersion();
if (!/^\d{4}\.\d{1,2}\.\d{1,2}$/.test(value)) {
throw new VersionValidationError(
"版本号格式应为 YYYY.M.D例如 2026.5.29",
);
}
const [, month, day] = value.split(".").map(Number);
if (month < 1 || month > 12 || day < 1 || day > 31) {
throw new VersionValidationError("版本号中的月或日无效");
}
return value;
}

218
worker/src/services/zip.ts Normal file
View File

@@ -0,0 +1,218 @@
import { unzipSync, zipSync } from "fflate";
export class ZipValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "ZipValidationError";
}
}
export function validateZipBuffer(
buffer: Uint8Array,
maxBytes: number,
): { normalizedBuffer: Uint8Array } {
if (buffer.length > maxBytes) {
throw new ZipValidationError(
`Zip file exceeds ${Math.floor(maxBytes / (1024 * 1024))}MB limit`,
);
}
let files: Record<string, Uint8Array>;
try {
files = unzipSync(buffer);
} catch {
throw new ZipValidationError("Invalid zip archive");
}
const entries = Object.keys(files).filter(
(key) => !key.endsWith("/") && files[key].length > 0,
);
if (entries.length === 0) {
throw new ZipValidationError("Zip archive is empty");
}
const indexEntry = entries.find((entry) => {
const normalized = entry.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 normalized = normalizeZipFiles(files);
return { normalizedBuffer: zipSync(normalized) };
}
function normalizeZipFiles(
files: Record<string, Uint8Array>,
): Record<string, Uint8Array> {
const map: Record<string, Uint8Array> = {};
for (const [key, data] of Object.entries(files)) {
const path = key.replace(/\\/g, "/").replace(/^\.\//, "");
if (path.endsWith("/")) continue;
map[path] = data;
}
if (map["index.html"]) {
return map;
}
const keys = Object.keys(map);
const indexKey = keys.find((k) => {
const parts = k.split("/");
return parts.length === 2 && parts[1] === "index.html";
});
if (!indexKey) {
throw new ZipValidationError(
"index.html must be at zip root or inside exactly one folder",
);
}
const folder = indexKey.split("/")[0];
const topDirs = new Set(
keys.map((k) => k.split("/")[0]).filter(Boolean),
);
if (topDirs.size !== 1 || !topDirs.has(folder)) {
throw new ZipValidationError(
"index.html must be at zip root or inside exactly one folder",
);
}
const flat: Record<string, Uint8Array> = {};
const prefix = `${folder}/`;
for (const [key, data] of Object.entries(map)) {
if (!key.startsWith(prefix)) {
throw new ZipValidationError(
"index.html must be at zip root or inside exactly one folder",
);
}
const rel = key.slice(prefix.length);
if (!rel) continue;
flat[rel] = data;
}
if (!flat["index.html"]) {
throw new ZipValidationError(
"index.html must be at zip root or inside exactly one folder",
);
}
return flat;
}
const JAVA_RESERVED_SEGMENTS = new Set([
"abstract",
"assert",
"boolean",
"break",
"byte",
"case",
"catch",
"char",
"class",
"const",
"continue",
"default",
"do",
"double",
"else",
"enum",
"extends",
"final",
"finally",
"float",
"for",
"goto",
"if",
"implements",
"import",
"instanceof",
"int",
"interface",
"long",
"native",
"new",
"package",
"private",
"protected",
"public",
"return",
"short",
"static",
"strictfp",
"super",
"switch",
"synchronized",
"this",
"throw",
"throws",
"transient",
"try",
"void",
"volatile",
"while",
]);
function sanitizeSegment(segment: string): string {
let value = segment.toLowerCase().replace(/[^a-z0-9_]/g, "");
if (!value) return "app";
if (/^\d/.test(value)) value = `app${value}`;
if (JAVA_RESERVED_SEGMENTS.has(value)) value = `${value}app`;
return value.slice(0, 48);
}
export function validateEnglishAppName(input: string): string {
const value = input.trim();
if (!value) {
throw new ZipValidationError("应用英文名不能为空");
}
if (!/^[a-zA-Z][a-zA-Z0-9 _.-]*$/.test(value)) {
throw new ZipValidationError(
"应用英文名需以字母开头,仅支持英文字母、数字、空格、下划线和连字符",
);
}
return value;
}
export function validateChineseAppName(input: string): string {
const value = input.trim();
if (!value) {
throw new ZipValidationError("应用中文名不能为空");
}
return value;
}
export function slugifyIdentifier(input: string): string {
return input
.toLowerCase()
.replace(/[^a-z0-9]+/g, ".")
.replace(/^\.+|\.+$/g, "")
.split(".")
.map(sanitizeSegment)
.filter(Boolean)
.join(".")
.slice(0, 120);
}
export function normalizeAppIdentifier(identifier: string): string {
const parts = identifier
.trim()
.split(".")
.map(sanitizeSegment)
.filter(Boolean);
if (parts.length < 2) {
throw new ZipValidationError(
"Bundle ID 至少需要两段,例如 com.example.myapp",
);
}
return parts.join(".");
}

14
worker/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true
},
"include": ["src/**/*.ts"]
}