feat: Introduce fe modular build system

This commit is contained in:
divocat
2025-10-02 21:40:16 +03:00
parent 4ef15f7340
commit 294cb21e91
21 changed files with 2294 additions and 17 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
.idea .idea
fe-app-podkop/node_modules

View File

@@ -0,0 +1,8 @@
{
"printWidth": 80,
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": true
}

View File

@@ -0,0 +1,18 @@
// eslint.config.js
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';
export default [
js.configs.recommended,
...tseslint.configs.recommended,
{
ignores: ['dist', 'node_modules'],
},
{
rules: {
'no-console': 'warn',
},
},
prettier,
];

View File

@@ -0,0 +1,25 @@
{
"name": "fe-app-podkop",
"version": "1.0.0",
"license": "MIT",
"type": "module",
"scripts": {
"format": "prettier --write src",
"lint": "eslint src --ext .ts,.tsx",
"lint:fix": "eslint src --ext .ts,.tsx --fix",
"build": "tsup src/main.ts",
"dev": "tsup src/main.ts --watch",
"test": "vitest"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "8.45.0",
"@typescript-eslint/parser": "8.45.0",
"eslint": "9.36.0",
"eslint-config-prettier": "10.1.8",
"prettier": "3.6.2",
"tsup": "8.5.0",
"typescript": "5.9.3",
"typescript-eslint": "8.45.0",
"vitest": "3.2.4"
}
}

View File

@@ -0,0 +1,4 @@
'use strict';
'require baseclass';
export * from './validators';

View File

@@ -0,0 +1,4 @@
export * from './validateIp';
export * from './validateDomain';
export * from './validateDns';
export * from './validateUrl';

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { validateDNS } from '../validateDns.js';
import { invalidIPs, validIPs } from './validateIp.test';
import { invalidDomains, validDomains } from './validateDomain.test';
const validDns = [...validIPs, ...validDomains];
const invalidDns = [...invalidIPs, ...invalidDomains];
describe('validateDns', () => {
describe.each(validDns)('Valid dns: %s', (_desc, domain) => {
it(`returns valid=true for "${domain}"`, () => {
const res = validateDNS(domain);
expect(res.valid).toBe(true);
});
});
describe.each(invalidDns)('Invalid dns: %s', (_desc, domain) => {
it(`returns valid=false for "${domain}"`, () => {
const res = validateDNS(domain);
expect(res.valid).toBe(false);
});
});
});

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { validateDomain } from '../validateDomain';
export const validDomains = [
['Simple domain', 'example.com'],
['Subdomain', 'sub.example.com'],
['With dash', 'my-site.org'],
['With numbers', 'site123.net'],
['Deep subdomain', 'a.b.c.example.co.uk'],
['With path', 'example.com/path/to/resource'],
['Punycode RU', 'xn--d1acufc.xn--p1ai'],
['Adguard dns', 'dns.adguard-dns.com'],
['Nextdns dns', 'dns.nextdns.io/xxxxxxx'],
['Long domain (63 chars in label)', 'a'.repeat(63) + '.com'],
];
export const invalidDomains = [
['No TLD', 'localhost'],
['Only TLD', '.com'],
['Double dot', 'example..com'],
['Illegal chars', 'exa!mple.com'],
['Space inside', 'exa mple.com'],
['Ending with dash', 'example-.com'],
['Starting with dash', '-example.com'],
['Trailing dot', 'example.com.'],
['Too short TLD', 'example.c'],
['With protocol (not allowed)', 'http://example.com'],
['Too long label (>63 chars)', 'a'.repeat(64) + '.com'],
['Too long domain (>253 chars)', Array(40).fill('abcdef').join('.') + '.com'],
];
describe('validateDomain', () => {
describe.each(validDomains)('Valid domain: %s', (_desc, domain) => {
it(`returns valid=true for "${domain}"`, () => {
const res = validateDomain(domain);
expect(res.valid).toBe(true);
});
});
describe.each(invalidDomains)('Invalid domain: %s', (_desc, domain) => {
it(`returns valid=false for "${domain}"`, () => {
const res = validateDomain(domain);
expect(res.valid).toBe(false);
});
});
});

View File

@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { validateIPV4 } from '../validateIp';
export const validIPs = [
['Private LAN', '192.168.1.1'],
['All zeros', '0.0.0.0'],
['Broadcast', '255.255.255.255'],
['Simple', '1.2.3.4'],
['Loopback', '127.0.0.1'],
];
export const invalidIPs = [
['Octet too large', '256.0.0.1'],
['Too few octets', '192.168.1'],
['Too many octets', '1.2.3.4.5'],
['Leading zero (1st octet)', '01.2.3.4'],
['Leading zero (2nd octet)', '1.02.3.4'],
['Leading zero (3rd octet)', '1.2.003.4'],
['Leading zero (4th octet)', '1.2.3.004'],
['Four digits in octet', '1.2.3.0004'],
['Trailing dot', '1.2.3.'],
];
describe('validateIPV4', () => {
describe.each(validIPs)('Valid IP: %s', (_desc, ip) => {
it(`returns {valid:true for "${ip}"`, () => {
const res = validateIPV4(ip);
expect(res.valid).toBe(true);
});
});
describe.each(invalidIPs)('Invalid IP: %s', (_desc, ip) => {
it(`returns {valid:false for "${ip}"`, () => {
const res = validateIPV4(ip);
expect(res.valid).toBe(false);
});
});
});

View File

@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { validateUrl } from '../validateUrl';
const validUrls = [
['Simple HTTP', 'http://example.com'],
['Simple HTTPS', 'https://example.com'],
['With path', 'https://example.com/path/to/page'],
['With query', 'https://example.com/?q=test'],
['With port', 'http://example.com:8080'],
['With subdomain', 'https://sub.example.com'],
];
const invalidUrls = [
['Invalid format', 'not a url'],
['Missing protocol', 'example.com'],
['Unsupported protocol (ftp)', 'ftp://example.com'],
['Unsupported protocol (ws)', 'ws://example.com'],
['Empty string', ''],
];
describe('validateUrl', () => {
describe.each(validUrls)('Valid URL: %s', (_desc, url) => {
it(`returns valid=true for "${url}"`, () => {
const res = validateUrl(url);
expect(res.valid).toBe(true);
});
});
describe.each(invalidUrls)('Invalid URL: %s', (_desc, url) => {
it(`returns valid=false for "${url}"`, () => {
const res = validateUrl(url);
expect(res.valid).toBe(false);
});
});
it('allows custom protocol list (ftp)', () => {
const res = validateUrl('ftp://example.com', ['ftp:']);
expect(res.valid).toBe(true);
});
});

View File

@@ -0,0 +1,4 @@
export interface ValidationResult {
valid: boolean;
message: string;
}

View File

@@ -0,0 +1,23 @@
import { validateDomain } from './validateDomain';
import { validateIPV4 } from './validateIp';
import { ValidationResult } from './types.js';
export function validateDNS(value: string): ValidationResult {
if (!value) {
return { valid: false, message: 'DNS server address cannot be empty' };
}
if (validateIPV4(value).valid) {
return { valid: true, message: 'Valid' };
}
if (validateDomain(value).valid) {
return { valid: true, message: 'Valid' };
}
return {
valid: false,
message:
'Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH',
};
}

View File

@@ -0,0 +1,21 @@
import { ValidationResult } from './types.js';
export function validateDomain(domain: string): ValidationResult {
const domainRegex =
/^(?=.{1,253}(?:\/|$))(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)\.)+(?:[a-zA-Z]{2,}|xn--[a-zA-Z0-9-]{1,59}[a-zA-Z0-9])(?:\/[^\s]*)?$/;
if (!domainRegex.test(domain)) {
return { valid: false, message: 'Invalid domain address' };
}
const hostname = domain.split('/')[0];
const parts = hostname.split('.');
const atLeastOneInvalidPart = parts.some((part) => part.length > 63);
if (atLeastOneInvalidPart) {
return { valid: false, message: 'Invalid domain address' };
}
return { valid: true, message: 'Valid' };
}

View File

@@ -0,0 +1,12 @@
import { ValidationResult } from './types.js';
export function validateIPV4(ip: string): ValidationResult {
const ipRegex =
/^(?:(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])$/;
if (ipRegex.test(ip)) {
return { valid: true, message: 'Valid' };
}
return { valid: false, message: 'Invalid IP address' };
}

View File

@@ -0,0 +1,20 @@
import { ValidationResult } from './types.js';
export function validateUrl(
url: string,
protocols: string[] = ['http:', 'https:'],
): ValidationResult {
try {
const parsedUrl = new URL(url);
if (!protocols.includes(parsedUrl.protocol)) {
return {
valid: false,
message: `URL must use one of the following protocols: ${protocols.join(', ')}`,
};
}
return { valid: true, message: 'Valid' };
} catch (e) {
return { valid: false, message: 'Invalid URL format' };
}
}

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"strict": true,
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist"
},
"include": ["src"]
}

View File

@@ -0,0 +1,35 @@
import { defineConfig } from 'tsup';
import fs from 'fs';
import path from 'path';
export default defineConfig({
entry: ['src/main.ts'],
format: ['esm'], // пусть tsup генерит export {...}
outDir: '../luci-app-podkop/htdocs/luci-static/resources/view/podkop',
outExtension: () => ({ js: '.js' }),
dts: false,
clean: false,
sourcemap: false,
banner: {
js: `// This file is autogenerated, please don't change manually \n"use strict";`,
},
esbuildOptions(options) {
options.legalComments = 'none';
},
onSuccess: () => {
const outDir =
'../luci-app-podkop/htdocs/luci-static/resources/view/podkop';
const file = path.join(outDir, 'main.js');
let code = fs.readFileSync(file, 'utf8');
code = code.replace(
/export\s*{([\s\S]*?)}/,
(match, group) => {
return `return baseclass.extend({${group}})`;
}
);
fs.writeFileSync(file, code, 'utf8');
console.log(`✅ Patched LuCI build: ${file}`);
},
});

View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
});

1857
fe-app-podkop/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -4,19 +4,9 @@
'require ui'; 'require ui';
'require network'; 'require network';
'require view.podkop.constants as constants'; 'require view.podkop.constants as constants';
'require view.podkop.main as main';
'require tools.widgets as widgets'; 'require tools.widgets as widgets';
function validateUrl(url, protocols = ['http:', 'https:']) {
try {
const parsedUrl = new URL(url);
if (!protocols.includes(parsedUrl.protocol)) {
return _('URL must use one of the following protocols: ') + protocols.join(', ');
}
return true;
} catch (e) {
return _('Invalid URL format');
}
}
function createConfigSection(section, map, network) { function createConfigSection(section, map, network) {
const s = section; const s = section;
@@ -438,8 +428,18 @@ function createConfigSection(section, map, network) {
o.rmempty = false; o.rmempty = false;
o.ucisection = s.section; o.ucisection = s.section;
o.validate = function (section_id, value) { o.validate = function (section_id, value) {
if (!value || value.length === 0) return true; // Optional
return validateUrl(value); if (!value || value.length === 0) {
return true
}
const validation = main.validateUrl(url);
if (validation.valid) {
return true;
}
return _(validation.message)
}; };
o = s.taboption('basic', form.Flag, 'local_subnet_lists_enabled', _('Local Subnet Lists'), _('Use the list from the router filesystem')); o = s.taboption('basic', form.Flag, 'local_subnet_lists_enabled', _('Local Subnet Lists'), _('Use the list from the router filesystem'));
@@ -562,8 +562,18 @@ function createConfigSection(section, map, network) {
o.rmempty = false; o.rmempty = false;
o.ucisection = s.section; o.ucisection = s.section;
o.validate = function (section_id, value) { o.validate = function (section_id, value) {
if (!value || value.length === 0) return true; // Optional
return validateUrl(value); if (!value || value.length === 0) {
return true
}
const validation = main.validateUrl(url);
if (validation.valid) {
return true;
}
return _(validation.message)
}; };
o = s.taboption('basic', form.Flag, 'all_traffic_from_ip_enabled', _('IP for full redirection'), _('Specify local IP addresses whose traffic will always use the configured route')); o = s.taboption('basic', form.Flag, 'all_traffic_from_ip_enabled', _('IP for full redirection'), _('Specify local IP addresses whose traffic will always use the configured route'));

View File

@@ -0,0 +1,66 @@
// This file is autogenerated, please don't change manually
"use strict";
"require baseclass";
// src/validators/validateIp.ts
function validateIPV4(ip) {
const ipRegex = /^(?:(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])$/;
if (ipRegex.test(ip)) {
return { valid: true, message: "Valid" };
}
return { valid: false, message: "Invalid IP address" };
}
// src/validators/validateDomain.ts
function validateDomain(domain) {
const domainRegex = /^(?=.{1,253}(?:\/|$))(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)\.)+(?:[a-zA-Z]{2,}|xn--[a-zA-Z0-9-]{1,59}[a-zA-Z0-9])(?:\/[^\s]*)?$/;
if (!domainRegex.test(domain)) {
return { valid: false, message: "Invalid domain address" };
}
const hostname = domain.split("/")[0];
const parts = hostname.split(".");
const atLeastOneInvalidPart = parts.some((part) => part.length > 63);
if (atLeastOneInvalidPart) {
return { valid: false, message: "Invalid domain address" };
}
return { valid: true, message: "Valid" };
}
// src/validators/validateDns.ts
function validateDNS(value) {
if (!value) {
return { valid: false, message: "DNS server address cannot be empty" };
}
if (validateIPV4(value).valid) {
return { valid: true, message: "Valid" };
}
if (validateDomain(value).valid) {
return { valid: true, message: "Valid" };
}
return {
valid: false,
message: "Invalid DNS server format. Examples: 8.8.8.8 or dns.example.com or dns.example.com/nicedns for DoH"
};
}
// src/validators/validateUrl.ts
function validateUrl(url, protocols = ["http:", "https:"]) {
try {
const parsedUrl = new URL(url);
if (!protocols.includes(parsedUrl.protocol)) {
return {
valid: false,
message: `URL must use one of the following protocols: ${protocols.join(", ")}`
};
}
return { valid: true, message: "Valid" };
} catch (e) {
return { valid: false, message: "Invalid URL format" };
}
}
return baseclass.extend({
validateDNS,
validateDomain,
validateIPV4,
validateUrl
});