refactor: repartition server-side and client-side code

This commit is contained in:
LIlGG
2025-10-11 18:26:07 +08:00
parent 7acc4949fb
commit e9b573a276
309 changed files with 631 additions and 962 deletions

View File

@@ -0,0 +1,2 @@
export const MODEL_REGEX = /^\[Model: (.*?)\]\n\n/;
export const PROVIDER_REGEX = /\[Provider: (.*?)\]\n\n/;

View File

@@ -0,0 +1,13 @@
export function debounce<T extends (...args: any[]) => any>(func: T, wait: number): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout;
return function executedFunction(...args: Parameters<T>) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}

112
app/.client/utils/diff.ts Normal file
View File

@@ -0,0 +1,112 @@
import { createPatch } from 'diff';
import type { Section } from '~/types/actions';
import type { PageMap, SectionMap } from '~/types/pages';
interface ModifiedSection {
type: 'diff' | 'section';
content: string;
}
type SectionModifications = Record<string, ModifiedSection>;
export function computeSectionModifications(sections: SectionMap, modifiedSections: Map<string, Section>) {
const modifications: SectionModifications = {};
let hasModifiedPages = false;
for (const [sectionId, oldSection] of modifiedSections) {
const section = sections[sectionId];
if (!section) {
continue;
}
const originalContent = oldSection.content;
const unifiedDiff = diffPages(sectionId, originalContent, section.content);
if (!unifiedDiff) {
// files are identical
continue;
}
hasModifiedPages = true;
if (unifiedDiff.length > section.content.length) {
// if there are lots of changes we simply grab the current file content since it's smaller than the diff
modifications[sectionId] = { type: 'section', content: section.content };
} else {
// otherwise we use the diff since it's smaller
modifications[sectionId] = { type: 'diff', content: unifiedDiff };
}
}
if (!hasModifiedPages) {
return undefined;
}
return modifications;
}
interface ModifiedPage {
type: 'diff' | 'page';
content: string;
}
type PageModifications = Record<string, ModifiedPage>;
export function computePageModifications(pages: PageMap, modifiedFiles: Map<string, string>) {
const modifications: PageModifications = {};
let hasModifiedPages = false;
for (const [filePath, originalContent] of modifiedFiles) {
const page = pages[filePath];
if (!page) {
continue;
}
if (!page.content) {
continue;
}
const unifiedDiff = diffPages(filePath, originalContent, page.content);
if (!unifiedDiff) {
// files are identical
continue;
}
hasModifiedPages = true;
if (unifiedDiff.length > page.content.length) {
// if there are lots of changes we simply grab the current file content since it's smaller than the diff
modifications[filePath] = { type: 'page', content: page.content };
} else {
// otherwise we use the diff since it's smaller
modifications[filePath] = { type: 'diff', content: unifiedDiff };
}
}
if (!hasModifiedPages) {
return undefined;
}
return modifications;
}
/**
* Computes a diff in the unified format. The only difference is that the header is omitted
* because it will always assume that you're comparing two versions of the same file and
* it allows us to avoid the extra characters we send back to the llm.
*
* @see https://www.gnu.org/software/diffutils/manual/html_node/Unified-Format.html
*/
export function diffPages(pageName: string, oldFileContent: string, newFileContent: string) {
const unifiedDiff = createPatch(pageName, oldFileContent, newFileContent);
if (unifiedDiff === '') {
return undefined;
}
return unifiedDiff;
}

View File

@@ -0,0 +1,3 @@
import { cubicBezier } from 'framer-motion';
export const cubicEasingFn = cubicBezier(0.4, 0, 0.2, 1);

View File

@@ -0,0 +1,33 @@
export const executeScripts = (element: Element) => {
if (!element) {
return;
}
const scripts = element.getElementsByTagName('script');
const scriptArray = Array.from(scripts);
for (let i = 0; i < scriptArray.length; i++) {
const oldScript = scriptArray[i];
executeScript(oldScript);
}
};
export const executeScript = (scriptElement: HTMLScriptElement) => {
const newScript = document.createElement('script');
Array.from(scriptElement.attributes).forEach((attr) => {
newScript.setAttribute(attr.name, attr.value);
});
if (scriptElement.src) {
newScript.src = scriptElement.src;
} else {
if (scriptElement.textContent?.trim()?.startsWith('(function() {')) {
newScript.textContent = scriptElement.textContent;
} else {
newScript.textContent = `(function() { ${scriptElement.textContent} })();`;
}
}
if (scriptElement.parentNode) {
scriptElement.parentNode.replaceChild(newScript, scriptElement);
}
};

View File

@@ -0,0 +1,12 @@
export function formatSize(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}

View File

@@ -0,0 +1,224 @@
import { describe, expect, it } from 'vitest';
import { isScriptContent, isValidContent } from './html-parse';
describe('html-parse', () => {
describe('isScriptContent', () => {
it('应该识别 script 标签内容', () => {
expect(isScriptContent('<script id="test">console.log("hello")</script>')).toBe(true);
expect(isScriptContent(' <script id="test">console.log("hello")</script>')).toBe(true);
});
it('应该不识别非 script 标签内容', () => {
expect(isScriptContent('<div id="test">hello</div>')).toBe(false);
expect(isScriptContent('<style id="test">body { color: red; }</style>')).toBe(false);
expect(isScriptContent('hello world')).toBe(false);
});
});
describe('isValidContent - 有效的 HTML 内容', () => {
it('应该接受根节点完整且有 id 的 HTML内部元素可不完整', () => {
expect(isValidContent('<section id="xxxx"><div>h')).toBe(true);
expect(isValidContent('<section id="xxxx"><div>hello')).toBe(true);
expect(isValidContent('<section id="xxxx"><div>hello world</div>')).toBe(true);
expect(isValidContent('<section id="xxxx">hello world')).toBe(true);
expect(isValidContent('<section id="xxxx">hello world</br>')).toBe(true);
});
it('应该接受完整的 HTML 元素', () => {
expect(isValidContent('<div id="root"></div>')).toBe(true);
expect(isValidContent('<div id="root">content</div>')).toBe(true);
expect(isValidContent('<section id="main"><h1>Title</h1></section>')).toBe(true);
});
it('应该接受带有多个属性的根元素', () => {
expect(isValidContent('<div id="root" class="container">content</div>')).toBe(true);
expect(isValidContent('<div class="container" id="root" data-test="value">content</div>')).toBe(true);
});
it('应该接受使用单引号的 id 属性', () => {
expect(isValidContent("<div id='root'>content</div>")).toBe(true);
expect(isValidContent("<div id='root' class='container'>content</div>")).toBe(true);
});
});
describe('isValidContent - 无效的 HTML 内容', () => {
it('应该拒绝根节点不完整的 HTML', () => {
expect(isValidContent('<div')).toBe(false);
expect(isValidContent('<div id="xx')).toBe(false);
expect(isValidContent('<div id="xxxx"><div>hello world</d')).toBe(false);
expect(isValidContent('<div id="xxxx"><div>hello world</div')).toBe(false);
});
it('应该拒绝缺少 id 属性的根元素', () => {
expect(isValidContent('<div>content</div>')).toBe(false);
expect(isValidContent('<div class="container">content</div>')).toBe(false);
});
it('应该拒绝 id 属性不完整的根元素', () => {
expect(isValidContent('<div id=')).toBe(false);
expect(isValidContent('<div id="')).toBe(false);
expect(isValidContent('<div id="root')).toBe(false);
});
it('应该拒绝末尾有不完整标签的内容', () => {
expect(isValidContent('<div id="root">content</')).toBe(false);
expect(isValidContent('<div id="root">content</d')).toBe(false);
expect(isValidContent('<div id="root">content</di')).toBe(false);
});
it('应该拒绝末尾有孤立 < 字符的内容', () => {
expect(isValidContent('<div id="xxxx"><div>hello world<')).toBe(false);
expect(isValidContent('<div id="root">content<')).toBe(false);
expect(isValidContent('<div id="test"><span>test<')).toBe(false);
});
it('应该接受 < 作为普通字符后跟完整闭合标签的内容', () => {
expect(isValidContent('<div id="xxxx"><div>hello world<</div>')).toBe(true);
expect(isValidContent('<div id="root">5 < 10</div>')).toBe(true);
expect(isValidContent('<div id="test"><span>a<b</span></div>')).toBe(true);
});
});
describe('isValidContent - 有效的 script 内容', () => {
it('应该接受完整的 script 标签', () => {
expect(isValidContent('<script id="test">console.log("hello");</script>')).toBe(true);
expect(isValidContent('<script id="test"></script>')).toBe(true);
expect(isValidContent('<script id="main" type="text/javascript">alert("test");</script>')).toBe(true);
});
it('应该接受使用单引号的 script 标签', () => {
expect(isValidContent("<script id='test'>console.log('hello');</script>")).toBe(true);
});
it('应该接受带有多行代码的 script 标签', () => {
const content = `<script id="test">
function hello() {
console.log("world");
}
hello();
</script>`;
expect(isValidContent(content)).toBe(true);
});
});
describe('isValidContent - 无效的 script 内容', () => {
it('应该拒绝没有 id 的 script 标签', () => {
expect(isValidContent('<script>console.log("hello");</script>')).toBe(false);
});
it('应该拒绝没有闭合标签的 script', () => {
expect(isValidContent('<script id="test">console.log("hello");')).toBe(false);
expect(isValidContent('<script id="test">console.log("hello");</scrip')).toBe(false);
expect(isValidContent('<script id="test">console.log("hello");</script')).toBe(false);
});
it('应该拒绝 script 开始标签不完整', () => {
expect(isValidContent('<script id="test"')).toBe(false);
expect(isValidContent('<script id="test')).toBe(false);
expect(isValidContent('<script id=')).toBe(false);
});
it('应该拒绝 id 属性不完整的 script 标签', () => {
expect(isValidContent('<script id="test>console.log("hello");</script>')).toBe(false);
expect(isValidContent('<script id=test>console.log("hello");</script>')).toBe(false);
});
it('应该拒绝末尾有孤立 < 字符的 script 内容', () => {
expect(isValidContent('<script id="test">console.log("hello")<')).toBe(false);
expect(isValidContent('<script id="test">var a = 5<')).toBe(false);
});
it('应该接受 script 中 < 作为普通字符后跟完整闭合标签', () => {
expect(isValidContent('<script id="test">if (5 < 10) { console.log("yes"); }</script>')).toBe(true);
expect(isValidContent('<script id="test">var a = b < c;</script>')).toBe(true);
});
});
describe('isValidContent - 有效的 style 内容', () => {
it('应该接受完整的 style 标签', () => {
expect(isValidContent('<style id="test">body { color: red; }</style>')).toBe(true);
expect(isValidContent('<style id="test"></style>')).toBe(true);
expect(isValidContent('<style id="main" type="text/css">.class { margin: 0; }</style>')).toBe(true);
});
it('应该接受使用单引号的 style 标签', () => {
expect(isValidContent("<style id='test'>body { color: red; }</style>")).toBe(true);
});
it('应该接受带有多行样式的 style 标签', () => {
const content = `<style id="test">
body {
margin: 0;
padding: 0;
}
.container {
width: 100%;
}
</style>`;
expect(isValidContent(content)).toBe(true);
});
});
describe('isValidContent - 无效的 style 内容', () => {
it('应该拒绝没有 id 的 style 标签', () => {
expect(isValidContent('<style>body { color: red; }</style>')).toBe(false);
});
it('应该拒绝没有闭合标签的 style', () => {
expect(isValidContent('<style id="test">body { color: red; }')).toBe(false);
expect(isValidContent('<style id="test">body { color: red; }</styl')).toBe(false);
expect(isValidContent('<style id="test">body { color: red; }</style')).toBe(false);
});
it('应该拒绝 style 开始标签不完整', () => {
expect(isValidContent('<style id="test"')).toBe(false);
expect(isValidContent('<style id="test')).toBe(false);
expect(isValidContent('<style id=')).toBe(false);
});
it('应该拒绝 id 属性不完整的 style 标签', () => {
expect(isValidContent('<style id="test>body { color: red; }</style>')).toBe(false);
expect(isValidContent('<style id=test>body { color: red; }</style>')).toBe(false);
});
it('应该拒绝末尾有孤立 < 字符的 style 内容', () => {
expect(isValidContent('<style id="test">body { color: red; }<')).toBe(false);
expect(isValidContent('<style id="test">.class { margin: 0; }<')).toBe(false);
});
it('应该接受 style 中 < 作为普通字符后跟完整闭合标签', () => {
expect(isValidContent('<style id="test">/* comment < test */ body { color: red; }</style>')).toBe(true);
});
});
describe('isValidContent - 边界情况', () => {
it('应该拒绝空字符串', () => {
expect(isValidContent('')).toBe(false);
});
it('应该拒绝只有空格的字符串', () => {
expect(isValidContent(' ')).toBe(false);
});
it('应该拒绝 null 和 undefined', () => {
expect(isValidContent(null as any)).toBe(false);
expect(isValidContent(undefined as any)).toBe(false);
});
it('应该拒绝非字符串类型', () => {
expect(isValidContent(123 as any)).toBe(false);
expect(isValidContent({} as any)).toBe(false);
expect(isValidContent([] as any)).toBe(false);
});
it('应该拒绝纯文本内容(不是标签)', () => {
expect(isValidContent('hello world')).toBe(false);
});
it('应该接受带有前导空格的有效内容', () => {
expect(isValidContent(' <div id="root">content</div>')).toBe(true);
expect(isValidContent(' <script id="test">console.log("hello");</script>')).toBe(true);
expect(isValidContent(' <style id="test">body { color: red; }</style>')).toBe(true);
});
});
});

View File

@@ -0,0 +1,290 @@
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('htmlParse');
export function isScriptContent(content: string): boolean {
return content.trim().startsWith('<script');
}
/**
* 验证第一个标签(根节点)的完整性
* @param content 内容字符串
* @returns 验证结果,包括是否有效、根元素 id 和标签名
*/
function validateRootTagCompleteness(content: string): { valid: boolean; rootId?: string; tagName?: string } {
const trimmedContent = content.trim();
if (!trimmedContent.startsWith('<')) {
logger.warn('内容不以标签开始');
return { valid: false };
}
// 查找第一个完整的开始标签(找到第一个 >
const firstTagEndIndex = trimmedContent.indexOf('>');
if (firstTagEndIndex === -1) {
logger.warn('根标签不完整:未找到闭合的 >');
return { valid: false };
}
// 提取第一个完整的标签(包括 >
const firstTag = trimmedContent.substring(0, firstTagEndIndex + 1);
// 提取标签名称(支持 <tagName 或 <tagName空格 的形式)
const tagNameMatch = firstTag.match(/^<([a-zA-Z][a-zA-Z0-9]*)/);
if (!tagNameMatch) {
logger.warn('无法提取标签名称');
return { valid: false };
}
const tagName = tagNameMatch[1];
// 验证 id 属性是否存在且完整
// 支持 id="..." 或 id='...' 两种形式
const idPattern = /id=["']([^"']+)["']/;
const idMatch = firstTag.match(idPattern);
if (!idMatch) {
logger.warn('根标签缺少完整的 id 属性', { tagName });
return { valid: false };
}
const rootId = idMatch[1];
return { valid: true, rootId, tagName };
}
/**
* 验证指定标签的闭合标签是否完整
* @param content 内容字符串
* @param tagName 标签名称
* @returns 是否存在完整的闭合标签
*/
function validateClosingTag(content: string, tagName: string): boolean {
const closingTag = `</${tagName}>`;
return content.includes(closingTag);
}
/**
* 检查内容中是否存在明显不完整的标签
* @param content 内容字符串
* @returns 是否存在不完整的标签
*/
function hasIncompleteTag(content: string): boolean {
// 检查内容末尾是否有不完整的闭合标签
// 匹配 </、</d、</div 等(没有 > 的闭合标签)
const incompleteClosingTagPattern = /<\/([a-zA-Z][a-zA-Z0-9]*)?$/;
if (incompleteClosingTagPattern.test(content)) {
logger.warn('检测到不完整的闭合标签', { contentEnd: content.slice(-20) });
return true;
}
// 检查内容末尾是否有不完整的开始标签
// 匹配以 < 开头但没有对应 > 的情况
const incompleteOpeningTagPattern = /<[a-zA-Z][^>]*$/;
if (incompleteOpeningTagPattern.test(content)) {
logger.warn('检测到不完整的开始标签', { contentEnd: content.slice(-20) });
return true;
}
// 检查内容末尾是否有孤立的 <
// 匹配末尾单独的 < 字符(不属于任何标签)
const isolatedLessThanPattern = /<$/;
if (isolatedLessThanPattern.test(content)) {
logger.warn('检测到末尾孤立的 < 字符', { contentEnd: content.slice(-20) });
return true;
}
return false;
}
/**
* 验证内容是否有效
* - 检查是否包含完整的 id 属性
* - 检查是否符合内容类型要求HTML、JS、CSS
* @param content 内容字符串
* @returns {boolean} 内容是否有效
*/
export function isValidContent(content: string): boolean {
if (!content || typeof content !== 'string') {
return false;
}
// 检查是否存在明显不完整的标签
if (hasIncompleteTag(content)) {
logger.warn('内容包含不完整的标签');
return false;
}
// 验证根节点标签完整性
const rootValidation = validateRootTagCompleteness(content);
if (!rootValidation.valid) {
logger.warn('根节点标签验证失败');
return false;
}
const { rootId } = rootValidation;
try {
// 创建一个临时的 DOM 解析器
const parser = new DOMParser();
const doc = parser.parseFromString(content, 'text/html');
// 检查内容类型
if (content.trim().startsWith('<script')) {
// 对于 script验证闭合标签完整性
if (!validateClosingTag(content, 'script')) {
logger.warn('script 标签缺少完整的闭合标签 </script>');
return false;
}
// JavaScript 内容验证
const scriptElements = doc.getElementsByTagName('script');
if (scriptElements.length !== 1) {
logger.warn('JS content must have exactly one script element', {
contentLength: content.length,
elementCount: scriptElements.length,
});
return false;
}
const scriptElement = scriptElements[0];
// 检查脚本元素是否有 id 属性
if (!scriptElement.id) {
logger.warn('JS content must have an id attribute on the script element', { contentLength: content.length });
return false;
}
// 验证提取的 id 与 DOMParser 解析的 id 一致
if (scriptElement.id !== rootId) {
logger.warn('script 标签 id 不一致', {
extractedId: rootId,
parsedId: scriptElement.id,
});
return false;
}
return true;
}
if (content.trim().startsWith('<style')) {
// 对于 style验证闭合标签完整性
if (!validateClosingTag(content, 'style')) {
logger.warn('style 标签缺少完整的闭合标签 </style>');
return false;
}
// CSS 内容验证
const styleElements = doc.getElementsByTagName('style');
if (styleElements.length !== 1) {
logger.warn('CSS content must have exactly one style element', {
contentLength: content.length,
elementCount: styleElements.length,
});
return false;
}
const styleElement = styleElements[0];
// 检查样式元素是否有 id 属性
if (!styleElement.id) {
logger.warn('CSS content must have an id attribute on the style element', { contentLength: content.length });
return false;
}
// 验证提取的 id 与 DOMParser 解析的 id 一致
if (styleElement.id !== rootId) {
logger.warn('style 标签 id 不一致', {
extractedId: rootId,
parsedId: styleElement.id,
});
return false;
}
return true;
}
// HTML 内容验证
const bodyChildren = doc.body.children;
if (bodyChildren.length !== 1) {
logger.warn('HTML content must have exactly one root element', {
contentLength: content.length,
rootCount: bodyChildren.length,
});
return false;
}
const rootElement = bodyChildren[0];
// 检查根元素是否有 id 属性
if (!rootElement.id) {
logger.warn('HTML content must have an id attribute on the root element');
return false;
}
// 验证提取的 id 与 DOMParser 解析的 id 一致
if (rootElement.id !== rootId) {
logger.warn('HTML 根元素 id 不一致', {
extractedId: rootId,
parsedId: rootElement.id,
});
return false;
}
return true;
} catch (error) {
logger.error('Error validating content', error);
return false;
}
}
/**
* 处理可能存在的不完整内容
* 特别处理末尾可能存在的不完整标签如 </
* @param content 内容字符串
* @returns {string} 处理后的内容
*/
export function sanitizeHtmlContent(content: string): string {
if (!content) {
return content;
}
// 检查是否以不完整的标签结尾
const incompleteEndingRegex = /<\/?[a-zA-Z][a-zA-Z0-9]*$/;
if (incompleteEndingRegex.test(content)) {
// 移除不完整的结束标签
logger.warn(
'Incomplete tag detected at the end of content',
JSON.stringify({
contentEnd: content.slice(-10),
contentLength: content.length,
}),
);
return content.replace(incompleteEndingRegex, '');
}
// 检查是否有不匹配的标签 (简单检查)
const openTags = content.match(/<[a-zA-Z][^>]*>/g) || [];
const closeTags = content.match(/<\/[a-zA-Z][^>]*>/g) || [];
if (openTags.length !== closeTags.length) {
logger.warn(
'Potential unbalanced tags detected',
JSON.stringify({
openTagsCount: openTags.length,
closeTagsCount: closeTags.length,
}),
);
}
return content;
}

View File

@@ -0,0 +1,4 @@
export function isMobile() {
// we use sm: as the breakpoint for mobile. It's currently set to 640px
return globalThis.innerWidth < 640;
}

4
app/.client/utils/os.ts Normal file
View File

@@ -0,0 +1,4 @@
// Helper to detect OS
export const isMac = typeof navigator !== 'undefined' ? navigator.platform.toLowerCase().includes('mac') : false;
export const isWindows = typeof navigator !== 'undefined' ? navigator.platform.toLowerCase().includes('win') : false;
export const isLinux = typeof navigator !== 'undefined' ? navigator.platform.toLowerCase().includes('linux') : false;

35
app/.client/utils/page.ts Normal file
View File

@@ -0,0 +1,35 @@
import type { MapStore } from 'nanostores';
import type { Page } from '~/types/actions';
import type { SectionMap } from '~/types/pages';
export const pagesToArtifacts = (pages: { [pageName: string]: Page }, sections: MapStore<SectionMap>): string => {
return Object.keys(pages)
.map((pageName) => {
const page = pages[pageName];
const sectionId = page.actionIds;
if (sectionId.length === 0) {
return '';
}
return `
<uPageArtifact id="${Date.now() + pageName}" name="${pageName}" title="${page.title}">
${sectionId.map((sectionId) => {
const section = sections.get()[sectionId];
if (section == null) {
return '';
}
return `
<uPageAction id="${Date.now()}" pageName="${pageName}" action="${section.action}" domId="${section.domId}" sort="${section.sort}">
${section.content}
</uPageAction>
`;
})}
</uPageArtifact>
`;
})
.filter(Boolean)
.join('\n');
};

View File

@@ -0,0 +1,52 @@
import prettier, { type BuiltInParserName, type Options } from 'prettier';
import * as html from 'prettier/plugins/html';
import * as css from 'prettier/plugins/postcss';
import * as javascript from 'prettier/plugins/typescript';
import { path } from '../../utils/path';
const ignoreFiles = ['tailwindcss.js', 'iconify-icon.min.js'];
export function formatCode(code: string, options?: Options) {
return prettier.format(code, {
...options,
parser: 'html',
plugins: [html, css, javascript],
});
}
export function formatFile(filePath: string, code: string, options?: Options) {
if (ignoreFiles.includes(filePath)) {
return code;
}
const parser = getParser(filePath);
if (!parser) {
return code;
}
return prettier.format(code, {
...options,
parser,
plugins: [html, css, javascript],
});
}
export function getParser(filePath: string): BuiltInParserName | undefined {
const ext = path.extname(filePath);
switch (ext) {
case '.html':
return 'html';
case '.css':
return 'css';
case '.js':
return 'typescript';
case '.ts':
return 'typescript';
case '.md':
return 'markdown';
case '.vue':
return 'vue';
case '.json':
return 'json';
default:
return undefined;
}
}

View File

@@ -0,0 +1,6 @@
import { memo } from 'react';
export const genericMemo: <T extends keyof React.JSX.IntrinsicElements | React.JSXElementConstructor<any>>(
component: T,
propsAreEqual?: (prevProps: React.ComponentProps<T>, nextProps: React.ComponentProps<T>) => boolean,
) => T & { displayName?: string } = memo;

View File

@@ -0,0 +1,154 @@
/**
* Creates a function that samples calls at regular intervals and captures trailing calls.
* - Drops calls that occur between sampling intervals
* - Takes one call per sampling interval if available
* - Captures the last call if no call was made during the interval
*
* @param fn The function to sample
* @param sampleInterval How often to sample calls (in ms)
* @returns The sampled function
*/
export function createSampler<T extends (...args: any[]) => any>(fn: T, sampleInterval: number): T {
let lastArgs: Parameters<T> | null = null;
let lastTime = 0;
let timeout: NodeJS.Timeout | null = null;
// Create a function with the same type as the input function
const sampled = function (this: any, ...args: Parameters<T>) {
const now = Date.now();
lastArgs = args;
// If we're within the sample interval, just store the args
if (now - lastTime < sampleInterval) {
// Set up trailing call if not already set
if (!timeout) {
timeout = setTimeout(
() => {
timeout = null;
lastTime = Date.now();
if (lastArgs) {
fn.apply(this, lastArgs);
lastArgs = null;
}
},
sampleInterval - (now - lastTime),
);
}
return undefined as unknown as ReturnType<T>;
}
// If we're outside the interval, execute immediately
lastTime = now;
const result = fn.apply(this, args);
lastArgs = null;
return result;
} as unknown as T;
return sampled;
}
/**
* 创建一个采样异步函数,在指定的时间间隔内只执行一次,并捕获尾随调用。
* - 在采样间隔内的调用会被丢弃,但会保存最后一次调用的参数
* - 每个采样间隔只执行一次调用
* - 如果在间隔内没有调用,则捕获最后一次调用
* - 始终返回一个 Promise
* - 添加了执行状态管理,防止在高负载情况下连续多次执行
*
* @param fn 要采样的异步函数
* @param sampleInterval 采样间隔(毫秒)
* @param options 配置选项
* @returns 采样后的异步函数
*/
export function createAsyncSampler<T extends (...args: any[]) => Promise<any>>(fn: T, sampleInterval: number): T {
// 初始化状态变量,确保有合理的默认值
const now = Date.now();
let lastArgs: Parameters<T> | null = null;
let timeout: NodeJS.Timeout | null = null;
let lastThis: any = null;
let isExecuting = false; // 执行状态标志
let nextExecutionTime = now + sampleInterval; // 初始化为当前时间 + 采样间隔
let pendingPromise: Promise<any> | null = null; // 当前正在执行的 Promise
// 清除所有定时器
const clearAllTimeouts = () => {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
};
// 安全地重置执行状态
const resetExecutionState = () => {
isExecuting = false;
pendingPromise = null;
};
// 创建与输入函数类型相同的函数
const sampled = function (this: any, ...args: Parameters<T>) {
const now = Date.now();
lastArgs = args;
lastThis = this;
// 检查是否可以执行
// 1. 当前没有正在执行的任务
// 2. 当前时间已经超过了下次允许执行的时间
// 3. 没有待处理的 Promise
const canExecuteNow = !isExecuting && now >= nextExecutionTime && !pendingPromise;
if (!canExecuteNow) {
const waitTime = Math.max(0, nextExecutionTime - now);
// 清除之前的定时器,确保只有一个定时器在运行
clearAllTimeouts();
// 设置新的定时器,延迟执行
// 使用 Math.max 确保至少等待 100ms避免过于频繁的检查
const delayTime = Math.max(100, Math.min(sampleInterval, waitTime));
timeout = setTimeout(() => {
const currentTime = Date.now();
// 再次检查是否可以执行
if (!isExecuting && currentTime >= nextExecutionTime && !pendingPromise) {
clearAllTimeouts();
return executeTask();
}
}, delayTime);
// 返回一个空的 Promise以支持链式调用
return Promise.resolve() as any as ReturnType<T>;
}
return executeTask();
};
async function executeTask(): Promise<void> {
isExecuting = true;
try {
if (!lastArgs) {
resetExecutionState();
return Promise.resolve();
}
const result = fn.apply(lastThis, lastArgs);
lastArgs = null;
pendingPromise = result;
return result.finally(() => {
// 更新下一次执行时间
nextExecutionTime = Date.now() + sampleInterval;
resetExecutionState();
});
} catch (error) {
// 即使发生错误也更新下一次执行时间
nextExecutionTime = Date.now() + sampleInterval;
resetExecutionState();
return Promise.reject(error);
}
}
return sampled as unknown as T;
}

View File

@@ -0,0 +1,64 @@
export function throttle<T extends (...args: any[]) => any>(func: T, wait: number): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;
let lastArgs: Parameters<T> | null = null;
let lastCallTime = 0;
return function executedFunction(...args: Parameters<T>) {
const now = Date.now();
const remaining = wait - (now - lastCallTime);
lastArgs = args;
if (remaining <= 0) {
lastCallTime = now;
func(...args);
lastArgs = null;
} else if (!timeout) {
timeout = setTimeout(() => {
lastCallTime = Date.now();
timeout = null;
if (lastArgs) {
func(...lastArgs);
lastArgs = null;
}
}, remaining);
}
};
}
export function throttleWithTrailing<T extends (...args: any[]) => any>(
func: T,
wait: number,
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;
let lastArgs: Parameters<T> | null = null;
let lastCallTime = 0;
return function executedFunction(...args: Parameters<T>) {
const now = Date.now();
const remaining = wait - (now - lastCallTime);
lastArgs = args;
if (remaining <= 0) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
lastCallTime = now;
func(...args);
} else if (!timeout) {
timeout = setTimeout(() => {
lastCallTime = Date.now();
timeout = null;
if (lastArgs) {
func(...lastArgs);
lastArgs = null;
}
}, remaining);
}
};
}

View File

@@ -0,0 +1,3 @@
export function unreachable(message: string): never {
throw new Error(`Unreachable: ${message}`);
}