mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
feat(electron): bundle worker assets and resolve worker path
Add webpack config and include it in the electron backend build so worker scripts are correctly bundled for packaged apps. Move worker files to be packaged as extraResources instead of asar unpack patterns so the packaged app can load worker JS from Resources/dist/... at runtime. Fix worker path resolution in epg.events: compute resourcesPath from app.getAppPath() and point to dist/apps/electron-backend/workers when app.isPackaged. Remove verbose app path logging and update comments to reflect the actual packaged layout. Why: ensure webworker entry points are included in the renderer/backend bundle and accessible when the Electron app is packaged, preventing worker loading failures in production builds.
This commit is contained in:
1 parent
b7109c16a2
commit
a948ce0265
7 files changed
+1636
-219
No files matched your search
@@ -5,13 +5,16 @@
|
|||||||
"WebSearch",
|
"WebSearch",
|
||||||
"WebFetch(domain:www.electronjs.org)",
|
"WebFetch(domain:www.electronjs.org)",
|
||||||
"Bash(npm run build:frontend:*)",
|
"Bash(npm run build:frontend:*)",
|
||||||
"Bash(cat:*)"
|
"Bash(cat:*)",
|
||||||
|
"Bash(npx asar:*)",
|
||||||
|
"Bash(npm run package:app:*)",
|
||||||
|
"Bash(npm run build:backend:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
},
|
},
|
||||||
|
"enableAllProjectMcpServers": true,
|
||||||
"enabledMcpjsonServers": [
|
"enabledMcpjsonServers": [
|
||||||
"nx-mcp"
|
"nx-mcp"
|
||||||
],
|
]
|
||||||
"enableAllProjectMcpServers": true
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
const esbuild = require('esbuild');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Node.js built-in modules that should be externalized
|
||||||
|
const nodeBuiltins = [
|
||||||
|
'assert', 'async_hooks', 'buffer', 'child_process', 'cluster',
|
||||||
|
'console', 'constants', 'crypto', 'dgram', 'dns', 'domain',
|
||||||
|
'events', 'fs', 'http', 'http2', 'https', 'inspector', 'module',
|
||||||
|
'net', 'os', 'path', 'perf_hooks', 'process', 'punycode',
|
||||||
|
'querystring', 'readline', 'repl', 'stream', 'string_decoder',
|
||||||
|
'sys', 'timers', 'tls', 'trace_events', 'tty', 'url', 'util',
|
||||||
|
'v8', 'vm', 'worker_threads', 'zlib'
|
||||||
|
];
|
||||||
|
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
|
async function buildWorker() {
|
||||||
|
try {
|
||||||
|
console.log(`Building EPG parser worker with esbuild (${isProduction ? 'production' : 'development'})...`);
|
||||||
|
|
||||||
|
await esbuild.build({
|
||||||
|
entryPoints: [path.join(__dirname, 'src/app/workers/epg-parser.worker.ts')],
|
||||||
|
bundle: true,
|
||||||
|
platform: 'node',
|
||||||
|
target: 'node18',
|
||||||
|
format: 'cjs',
|
||||||
|
outfile: path.join(__dirname, '../../dist/apps/electron-backend/workers/epg-parser.worker.js'),
|
||||||
|
external: nodeBuiltins.map(m => `node:${m}`).concat(nodeBuiltins),
|
||||||
|
sourcemap: !isProduction,
|
||||||
|
minify: isProduction,
|
||||||
|
// Resolve workspace libraries from tsconfig paths
|
||||||
|
alias: {
|
||||||
|
'shared-interfaces': path.join(__dirname, '../../libs/shared/interfaces/src/index.ts'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Worker built successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Worker build failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildWorker();
|
||||||
@@ -4,8 +4,16 @@
|
|||||||
"sourceRoot": "apps/electron-backend/src",
|
"sourceRoot": "apps/electron-backend/src",
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
"targets": {
|
"targets": {
|
||||||
|
"build-worker": {
|
||||||
|
"executor": "nx:run-commands",
|
||||||
|
"outputs": ["{workspaceRoot}/dist/apps/electron-backend/workers"],
|
||||||
|
"options": {
|
||||||
|
"command": "node apps/electron-backend/build-worker.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"dependsOn": [
|
"dependsOn": [
|
||||||
|
"electron-backend:build-worker",
|
||||||
{
|
{
|
||||||
"projects": ["web", "remote-control-web"],
|
"projects": ["web", "remote-control-web"],
|
||||||
"target": "build"
|
"target": "build"
|
||||||
@@ -17,20 +25,7 @@
|
|||||||
"outputPath": "dist/apps/electron-backend",
|
"outputPath": "dist/apps/electron-backend",
|
||||||
"main": "apps/electron-backend/src/main.ts",
|
"main": "apps/electron-backend/src/main.ts",
|
||||||
"tsConfig": "apps/electron-backend/tsconfig.app.json",
|
"tsConfig": "apps/electron-backend/tsconfig.app.json",
|
||||||
"assets": [
|
"assets": ["apps/electron-backend/src/assets"]
|
||||||
"apps/electron-backend/src/assets",
|
|
||||||
{
|
|
||||||
"input": "apps/electron-backend/src/app/workers",
|
|
||||||
"glob": "*.worker.ts",
|
|
||||||
"output": "workers"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"additionalEntryPoints": [
|
|
||||||
{
|
|
||||||
"entryName": "workers/epg-parser.worker",
|
|
||||||
"entryPath": "apps/electron-backend/src/app/workers/epg-parser.worker.ts"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
@@ -54,7 +49,6 @@
|
|||||||
"nx run electron-backend:serve-electron"
|
"nx run electron-backend:serve-electron"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
"serve-electron": {
|
"serve-electron": {
|
||||||
"executor": "nx-electron:execute",
|
"executor": "nx-electron:execute",
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ export default class EpgEvents {
|
|||||||
* Bootstrap EPG events
|
* Bootstrap EPG events
|
||||||
*/
|
*/
|
||||||
static bootstrapEpgEvents(): Electron.IpcMain {
|
static bootstrapEpgEvents(): Electron.IpcMain {
|
||||||
console.log('[EPG Events] Bootstrapping EPG events...');
|
|
||||||
|
|
||||||
// Fetch EPG from URLs
|
// Fetch EPG from URLs
|
||||||
ipcMain.handle('FETCH_EPG', async (event, args: { url: string[] }) => {
|
ipcMain.handle('FETCH_EPG', async (event, args: { url: string[] }) => {
|
||||||
return await this.handleFetchEpg(args.url);
|
return await this.handleFetchEpg(args.url);
|
||||||
@@ -79,7 +77,7 @@ export default class EpgEvents {
|
|||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[EPG Events] Error fetching EPG:', error);
|
console.error(this.loggerLabel, 'Error fetching EPG:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: error instanceof Error ? error.message : String(error),
|
message: error instanceof Error ? error.message : String(error),
|
||||||
@@ -93,21 +91,19 @@ export default class EpgEvents {
|
|||||||
private static async fetchEpgFromUrl(url: string): Promise<void> {
|
private static async fetchEpgFromUrl(url: string): Promise<void> {
|
||||||
// Skip if already fetched
|
// Skip if already fetched
|
||||||
if (this.fetchedUrls.has(url)) {
|
if (this.fetchedUrls.has(url)) {
|
||||||
console.log(this.loggerLabel, 'URL already fetched:', url);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// Get the proper worker path following Electron's recommended approach
|
|
||||||
// https://www.electronjs.org/docs/latest/tutorial/multithreading
|
|
||||||
let workerPath: string;
|
let workerPath: string;
|
||||||
|
|
||||||
if (app.isPackaged) {
|
if (app.isPackaged) {
|
||||||
// In packaged app: Resources/app.asar.unpacked/electron-backend/workers/epg-parser.worker.js
|
// In packaged app: Resources/dist/apps/electron-backend/workers/epg-parser.worker.js
|
||||||
const appPath = app.getAppPath();
|
const resourcesPath = path.dirname(app.getAppPath());
|
||||||
const unpackedPath = appPath.replace('app.asar', 'app.asar.unpacked');
|
|
||||||
workerPath = path.join(
|
workerPath = path.join(
|
||||||
unpackedPath,
|
resourcesPath,
|
||||||
|
'dist',
|
||||||
|
'apps',
|
||||||
'electron-backend',
|
'electron-backend',
|
||||||
'workers',
|
'workers',
|
||||||
'epg-parser.worker.js'
|
'epg-parser.worker.js'
|
||||||
@@ -117,17 +113,11 @@ export default class EpgEvents {
|
|||||||
workerPath = path.join(__dirname, 'workers', 'epg-parser.worker.js');
|
workerPath = path.join(__dirname, 'workers', 'epg-parser.worker.js');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(this.loggerLabel, 'Creating worker for:', url);
|
|
||||||
console.log(this.loggerLabel, 'Worker path:', workerPath);
|
|
||||||
console.log(this.loggerLabel, 'App path:', app.getAppPath());
|
|
||||||
console.log(this.loggerLabel, 'Is packaged:', app.isPackaged);
|
|
||||||
|
|
||||||
let worker: Worker;
|
let worker: Worker;
|
||||||
try {
|
try {
|
||||||
// Worker threads require file:// URLs wrapped in URL object for packaged apps
|
// Worker threads require file:// URLs wrapped in URL object for packaged apps
|
||||||
const workerURL = pathToFileURL(workerPath);
|
const workerURL = pathToFileURL(workerPath);
|
||||||
worker = new Worker(workerURL);
|
worker = new Worker(workerURL);
|
||||||
console.log(this.loggerLabel, 'Worker created successfully');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
this.loggerLabel,
|
this.loggerLabel,
|
||||||
@@ -149,16 +139,8 @@ export default class EpgEvents {
|
|||||||
url?: string;
|
url?: string;
|
||||||
}) => {
|
}) => {
|
||||||
if (message.type === 'READY') {
|
if (message.type === 'READY') {
|
||||||
console.log(
|
|
||||||
this.loggerLabel,
|
|
||||||
'Worker ready, sending fetch command'
|
|
||||||
);
|
|
||||||
worker.postMessage({ type: 'FETCH_EPG', url });
|
worker.postMessage({ type: 'FETCH_EPG', url });
|
||||||
} else if (message.type === 'EPG_PARSED') {
|
} else if (message.type === 'EPG_PARSED') {
|
||||||
console.log(
|
|
||||||
this.loggerLabel,
|
|
||||||
'EPG parsed in worker, merging data...'
|
|
||||||
);
|
|
||||||
if (message.data) {
|
if (message.data) {
|
||||||
this.mergeEpgData(message.data);
|
this.mergeEpgData(message.data);
|
||||||
this.fetchedUrls.add(url);
|
this.fetchedUrls.add(url);
|
||||||
@@ -201,11 +183,6 @@ export default class EpgEvents {
|
|||||||
* Merge EPG data (optimized for large datasets)
|
* Merge EPG data (optimized for large datasets)
|
||||||
*/
|
*/
|
||||||
private static mergeEpgData(newData: EpgData): void {
|
private static mergeEpgData(newData: EpgData): void {
|
||||||
console.log(
|
|
||||||
this.loggerLabel,
|
|
||||||
`Merging ${newData.channels.length} channels and ${newData.programs.length} programs...`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Merge channels (avoid duplicates)
|
// Merge channels (avoid duplicates)
|
||||||
const existingChannelIds = new Set(
|
const existingChannelIds = new Set(
|
||||||
this.epgData.channels.map((c) => c.id)
|
this.epgData.channels.map((c) => c.id)
|
||||||
@@ -226,11 +203,6 @@ export default class EpgEvents {
|
|||||||
|
|
||||||
// Rebuild merged data structure
|
// Rebuild merged data structure
|
||||||
this.rebuildMergedData();
|
this.rebuildMergedData();
|
||||||
|
|
||||||
console.log(
|
|
||||||
this.loggerLabel,
|
|
||||||
`Merged EPG data: ${this.epgData.channels.length} channels, ${this.epgData.programs.length} programs`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -238,7 +210,6 @@ export default class EpgEvents {
|
|||||||
* Optimized for large datasets
|
* Optimized for large datasets
|
||||||
*/
|
*/
|
||||||
private static rebuildMergedData(): void {
|
private static rebuildMergedData(): void {
|
||||||
console.log(this.loggerLabel, 'Rebuilding merged data structure...');
|
|
||||||
this.epgDataMerged.clear();
|
this.epgDataMerged.clear();
|
||||||
|
|
||||||
// Create channel lookup map for O(1) access
|
// Create channel lookup map for O(1) access
|
||||||
@@ -265,31 +236,17 @@ export default class EpgEvents {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
|
||||||
this.loggerLabel,
|
|
||||||
`Rebuild complete. ${this.epgDataMerged.size} channels with programs`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get programs for a specific channel
|
* Get programs for a specific channel
|
||||||
*/
|
*/
|
||||||
private static handleGetChannelPrograms(channelId: string): EpgProgram[] {
|
private static handleGetChannelPrograms(channelId: string): EpgProgram[] {
|
||||||
console.log('[EPG Events] Getting programs for channel:', channelId);
|
|
||||||
console.log(
|
|
||||||
'[EPG Events] Total merged channels:',
|
|
||||||
this.epgDataMerged.size
|
|
||||||
);
|
|
||||||
|
|
||||||
// First try exact ID match
|
// First try exact ID match
|
||||||
let channelData = this.epgDataMerged.get(channelId);
|
let channelData = this.epgDataMerged.get(channelId);
|
||||||
|
|
||||||
// If not found, try to find by display name
|
// If not found, try to find by display name
|
||||||
if (!channelData) {
|
if (!channelData) {
|
||||||
console.log(
|
|
||||||
'[EPG Events] No exact match, searching by display name...'
|
|
||||||
);
|
|
||||||
for (const [id, channel] of this.epgDataMerged.entries()) {
|
for (const [id, channel] of this.epgDataMerged.entries()) {
|
||||||
const displayNames = channel.displayName.map((d) =>
|
const displayNames = channel.displayName.map((d) =>
|
||||||
d.value.toLowerCase()
|
d.value.toLowerCase()
|
||||||
@@ -302,22 +259,12 @@ export default class EpgEvents {
|
|||||||
channelId.toLowerCase().includes(name)
|
channelId.toLowerCase().includes(name)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
console.log(
|
|
||||||
'[EPG Events] Found match by name:',
|
|
||||||
id,
|
|
||||||
'→',
|
|
||||||
channel.displayName[0]?.value
|
|
||||||
);
|
|
||||||
channelData = channel;
|
channelData = channel;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
|
||||||
'[EPG Events] Found programs:',
|
|
||||||
channelData?.programs?.length || 0
|
|
||||||
);
|
|
||||||
return channelData?.programs || [];
|
return channelData?.programs || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,7 +290,5 @@ export default class EpgEvents {
|
|||||||
// Terminate all workers
|
// Terminate all workers
|
||||||
this.workers.forEach((worker) => worker.terminate());
|
this.workers.forEach((worker) => worker.terminate());
|
||||||
this.workers.clear();
|
this.workers.clear();
|
||||||
|
|
||||||
console.log(this.loggerLabel, 'EPG data cleared');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import axios from 'axios';
|
|
||||||
import { parse as parseEpg } from 'epg-parser';
|
import { parse as parseEpg } from 'epg-parser';
|
||||||
import { EpgData, EpgProgram } from 'shared-interfaces';
|
import { EpgData, EpgProgram } from 'shared-interfaces';
|
||||||
import { parentPort } from 'worker_threads';
|
import { parentPort } from 'worker_threads';
|
||||||
@@ -29,32 +28,28 @@ const loggerLabel = '[EPG Worker]';
|
|||||||
*/
|
*/
|
||||||
async function fetchAndParseEpg(url: string): Promise<EpgData> {
|
async function fetchAndParseEpg(url: string): Promise<EpgData> {
|
||||||
try {
|
try {
|
||||||
console.log(loggerLabel, 'Fetching EPG from:', url);
|
|
||||||
|
|
||||||
const isGzipped = url.endsWith('.gz');
|
const isGzipped = url.endsWith('.gz');
|
||||||
const axiosConfig = isGzipped
|
|
||||||
? { responseType: 'arraybuffer' as const }
|
|
||||||
: {};
|
|
||||||
|
|
||||||
const response = await axios.get(url.trim(), axiosConfig);
|
// Use native fetch (available in Node.js 18+)
|
||||||
console.log(
|
const response = await fetch(url.trim());
|
||||||
loggerLabel,
|
|
||||||
'EPG data fetched, size:',
|
if (!response.ok) {
|
||||||
response.data.length
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
);
|
}
|
||||||
|
|
||||||
let xmlString: string;
|
let xmlString: string;
|
||||||
|
|
||||||
if (isGzipped) {
|
if (isGzipped) {
|
||||||
console.log(loggerLabel, 'Unzipping...');
|
// For gzipped files, get as ArrayBuffer
|
||||||
const buffer = Buffer.from(response.data);
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
const decompressed = gunzipSync(buffer);
|
const decompressed = gunzipSync(buffer);
|
||||||
xmlString = decompressed.toString('utf-8');
|
xmlString = decompressed.toString('utf-8');
|
||||||
} else {
|
} else {
|
||||||
xmlString = response.data;
|
// For regular files, get as text
|
||||||
|
xmlString = await response.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(loggerLabel, 'Parsing EPG XML...');
|
|
||||||
const parsed = parseEpg(xmlString);
|
const parsed = parseEpg(xmlString);
|
||||||
|
|
||||||
// Normalize data to match our interface
|
// Normalize data to match our interface
|
||||||
@@ -89,11 +84,6 @@ async function fetchAndParseEpg(url: string): Promise<EpgData> {
|
|||||||
) as EpgProgram[],
|
) as EpgProgram[],
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(
|
|
||||||
loggerLabel,
|
|
||||||
`Parsed ${normalized.channels.length} channels and ${normalized.programs.length} programs`
|
|
||||||
);
|
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -109,20 +99,12 @@ async function fetchAndParseEpg(url: string): Promise<EpgData> {
|
|||||||
* Worker message handler
|
* Worker message handler
|
||||||
*/
|
*/
|
||||||
if (parentPort) {
|
if (parentPort) {
|
||||||
console.log(loggerLabel, 'Worker initialized and ready');
|
|
||||||
|
|
||||||
parentPort.on('message', async (message: WorkerMessage) => {
|
parentPort.on('message', async (message: WorkerMessage) => {
|
||||||
console.log(loggerLabel, 'Received message:', message.type);
|
|
||||||
try {
|
try {
|
||||||
if (
|
if (
|
||||||
message.type === 'FETCH_EPG' ||
|
message.type === 'FETCH_EPG' ||
|
||||||
message.type === 'FORCE_FETCH'
|
message.type === 'FORCE_FETCH'
|
||||||
) {
|
) {
|
||||||
console.log(
|
|
||||||
loggerLabel,
|
|
||||||
'Starting EPG fetch for:',
|
|
||||||
message.url
|
|
||||||
);
|
|
||||||
const parsedData = await fetchAndParseEpg(message.url);
|
const parsedData = await fetchAndParseEpg(message.url);
|
||||||
|
|
||||||
const response: WorkerResponse = {
|
const response: WorkerResponse = {
|
||||||
@@ -131,10 +113,6 @@ if (parentPort) {
|
|||||||
url: message.url,
|
url: message.url,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(
|
|
||||||
loggerLabel,
|
|
||||||
'Sending parsed data back to main thread'
|
|
||||||
);
|
|
||||||
parentPort?.postMessage(response);
|
parentPort?.postMessage(response);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -150,7 +128,6 @@ if (parentPort) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Notify parent that worker is ready
|
// Notify parent that worker is ready
|
||||||
console.log(loggerLabel, 'Sending READY message');
|
|
||||||
parentPort.postMessage({ type: 'READY' });
|
parentPort.postMessage({ type: 'READY' });
|
||||||
} else {
|
} else {
|
||||||
console.error(loggerLabel, 'parentPort is not available!');
|
console.error(loggerLabel, 'parentPort is not available!');
|
||||||
|
|||||||
Generated
+1556
-103
File diff suppressed because it is too large.
Load diff
+3
-2
@@ -36,8 +36,8 @@
|
|||||||
"extraMetadata": {
|
"extraMetadata": {
|
||||||
"main": "electron-backend/main.js"
|
"main": "electron-backend/main.js"
|
||||||
},
|
},
|
||||||
"asarUnpack": [
|
"extraResources": [
|
||||||
"**/workers/**"
|
"dist/apps/electron-backend/workers"
|
||||||
],
|
],
|
||||||
"mac": {
|
"mac": {
|
||||||
"target": {
|
"target": {
|
||||||
@@ -201,6 +201,7 @@
|
|||||||
"drizzle-kit": "0.31.5",
|
"drizzle-kit": "0.31.5",
|
||||||
"electron": "^39.0.0",
|
"electron": "^39.0.0",
|
||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.0.12",
|
||||||
|
"esbuild": "0.27.0",
|
||||||
"eslint": "^9.8.0",
|
"eslint": "^9.8.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-import": "2.32.0",
|
"eslint-plugin-import": "2.32.0",
|
||||||
|
|||||||
Reference in new issue
Block a user