From ba712f66a8d9230cef4e9c16d814a4e4e5f670b9 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 3 Feb 2026 14:38:45 +0900 Subject: [PATCH 01/36] x --- src/app/service/service_worker/resource.ts | 142 +++++++++++---------- src/app/service/service_worker/utils.ts | 18 ++- 2 files changed, 87 insertions(+), 73 deletions(-) diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index f82bad975..48adaac73 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -7,10 +7,10 @@ import { type IMessageQueue } from "@Packages/message/message_queue"; import { type Group } from "@Packages/message/server"; import type { ResourceBackup } from "@App/pkg/backup/struct"; import { isText } from "@App/pkg/utils/istextorbinary"; -import { blobToBase64, randNum } from "@App/pkg/utils/utils"; +import { blobToBase64, randNum, sleep } from "@App/pkg/utils/utils"; import { type TDeleteScript } from "../queue"; import { calculateHashFromArrayBuffer } from "@App/pkg/utils/crypto"; -import { isBase64, parseUrlSRI } from "./utils"; +import { isBase64, parseUrlSRI, type TUrlSRIInfo } from "./utils"; import { stackAsyncTask } from "@App/pkg/utils/async_queue"; import { blobToUint8Array } from "@App/pkg/utils/datatype"; import { readBlobContent } from "@App/pkg/utils/encoding"; @@ -29,35 +29,32 @@ export class ResourceService { public async getResource( uuid: string, - url: string, + u: TUrlSRIInfo, type: ResourceType, - loadNow: boolean + loadNow: boolean, + oldResources: Resource | undefined ): Promise { - const res = await this.getResourceModel(url); - if (res) { + if (oldResources) { // 读取过但失败的资源加载也会被放在缓存,避免再加载资源 // 因此 getResource 时不会再加载资源,直接返回 undefined 表示没有资源 - if (!res.contentType) return undefined; - return res; + if (!oldResources.contentType) return undefined; + return oldResources; } - // 缓存中无资源加载纪录 - if (loadNow) { - // 立即尝试加载资源 - try { - return await this.updateResource(uuid, url, type); - } catch (e: any) { - this.logger.error("load resource error", { url }, Logger.E(e)); - } - } else { - // 等一下尝试加载资源 (在后台异步加载) - // 先返回 undefined 表示没有资源 + // 缓存中无资源加载纪录,需要取得资源 + const url = u.originalUrl; + if (!loadNow) { + // 等一下尝试加载资源(例入 import) // 避免所有资源立即同一时间加载, delay设为 1.2s ~ 2.4s - setTimeout( - () => { - this.updateResource(uuid, url, type); - }, - randNum(1200, 2400) - ); + const delay = randNum(1200, 2400); + await sleep(delay); + const updatedResource = await this.getResourceModel(u); + // 如果等候期间有其他程序已生成 resource, 则不用呼叫 updateResource + if (updatedResource?.contentType) return updatedResource; + } + try { + return await this.updateResource(uuid, u, type, undefined); + } catch (e: any) { + this.logger.error("load resource error", { url }, Logger.E(e)); } return undefined; } @@ -98,12 +95,14 @@ export class ResourceService { } } if (path) { + const u = parseUrlSRI(path); + const oldResources = await this.getResourceModel(u); if (uri.startsWith("file:///")) { // 如果是file://协议,则每次请求更新一下文件 - const res = await this.updateResource(script.uuid, path, type); + const res = await this.updateResource(script.uuid, u, type, oldResources); ret[resourceKey] = res; } else { - const res = await this.getResource(script.uuid, path, type, load); + const res = await this.getResource(script.uuid, u, type, load, oldResources); if (res) { ret[resourceKey] = res; } @@ -114,49 +113,53 @@ export class ResourceService { return ret; } - updateResourceByType(script: Script, type: ResourceType) { + // 只需要等待Promise返回,不理会返回值(失败也可以) + updateResourceByType(script: Script, type: ResourceType): Promise | void { + const uuid = script.uuid; const promises = script.metadata[type]?.map(async (u) => { + let url = ""; if (type === "resource") { const split = u.split(/\s+/); if (split.length === 2) { - return this.checkResource(script.uuid, split[1], "resource"); + url = split[1]; } } else { - return this.checkResource(script.uuid, u, type); + url = u; } - }); - return promises?.length && Promise.allSettled(promises); - } - - // 检查资源是否存在,如果不存在则重新加载 - async checkResource(uuid: string, url: string, type: ResourceType) { - let res = await this.getResourceModel(url); - const updateTime = res?.updatetime; - // 判断1天过期 - if (updateTime && updateTime > Date.now() - 1000 * 86400) { - return res; - } - try { - res = await this.updateResource(uuid, url, type); - if (res?.contentType) { - return res; + if (url) { + // 检查资源是否存在,如果不存在则重新加载 + // 如果有旧资源,而没有新资讯,则继续使用旧资源 + // 只需要等待Promise返回,不理会返回值(失败也可以) + const u = parseUrlSRI(url); + const oldResources = await this.getResourceModel(u); + const updateTime = oldResources?.updatetime; + // 资源最后更新是24小时内则不更新 + if (updateTime && updateTime > Date.now() - 86400_000) return; + // 旧资源或没有资源记录,尝试更新 + try { + await this.updateResource(uuid, u, type, oldResources); + } catch (e: any) { + this.logger.error("check resource failed", { uuid, url }, Logger.E(e)); + } } - } catch (e: any) { - // ignore - this.logger.error("check resource failed", { uuid, url }, Logger.E(e)); - } - return undefined; + }); + if (promises?.length) return Promise.allSettled(promises); } - async updateResource(uuid: string, url: string, type: ResourceType) { + async updateResource( + uuid: string, + u: TUrlSRIInfo, + type: ResourceType, + oldResources: Resource | null | undefined = null + ) { // 重新加载 - const u = parseUrlSRI(url); - let result = await this.getResourceModel(u.url); + if (oldResources === null) oldResources = await this.getResourceModel(u); + let result: Resource; try { const resource = await this.loadByUrl(u.url, type); const now = Date.now(); resource.updatetime = now; - if (!result || !result.contentType) { + if (!oldResources || !oldResources.contentType) { // 资源不存在,保存 resource.createtime = now; resource.link = { [uuid]: true }; @@ -164,19 +167,28 @@ export class ResourceService { result = resource; this.logger.info("reload new resource success", { url: u.url }); } else { - result.base64 = resource.base64; - result.content = resource.content; - result.contentType = resource.contentType; - result.hash = resource.hash; - result.updatetime = resource.updatetime; - result.link[uuid] = true; + result = { + ...oldResources, + base64: resource.base64, + content: resource.content, + contentType: resource.contentType, + hash: resource.hash, + updatetime: resource.updatetime, + link: { ...oldResources.link, [uuid]: true }, + }; await this.resourceDAO.update(result.url, result); this.logger.info("reload resource success", { url: u.url, }); } + return result; } catch (e) { - // 资源错误时保存一个空纪录以防止再度尝试加载 + // 如果有旧资源,则使用旧资源 + if (oldResources) { + this.logger.error("load resource error - fallback to old resource", { url: u.url }, Logger.E(e)); + return oldResources; + } + // 资源错误时(且没有旧资源)保存一个空纪录以防止再度尝试加载 // this.resourceDAO.save 自身出错的话忽略 await this.resourceDAO .save({ @@ -199,11 +211,9 @@ export class ResourceService { this.logger.error("load resource error", { url: u.url }, Logger.E(e)); throw e; } - return result; } - async getResourceModel(url: string) { - const u = parseUrlSRI(url); + async getResourceModel(u: TUrlSRIInfo) { const resource = await this.resourceDAO.get(u.url); if (resource) { // 校验hash @@ -229,7 +239,7 @@ export class ResourceService { } } if (!flag) { - resource.content = `console.warn("ScriptCat: couldn't load resource from URL ${url} due to a SRI error ");`; + resource.content = `console.warn("ScriptCat: couldn't load resource from URL ${u.originalUrl} due to a SRI error ");`; } } return resource; diff --git a/src/app/service/service_worker/utils.ts b/src/app/service/service_worker/utils.ts index 9e7adfeba..7f1bfccf8 100644 --- a/src/app/service/service_worker/utils.ts +++ b/src/app/service/service_worker/utils.ts @@ -66,22 +66,26 @@ export function isBase64(str: string): boolean { return false; } -// 解析URL SRI -export function parseUrlSRI(url: string): { +export type TUrlSRIInfo = { url: string; - hash?: { [key: string]: string }; -} { + hash: { [key: string]: string } | undefined; + originalUrl: string; +}; + +// 解析URL SRI +export function parseUrlSRI(url: string): TUrlSRIInfo { const urls = url.split("#"); if (urls.length < 2) { - return { url: urls[0], hash: undefined }; + return { url: urls[0], hash: undefined, originalUrl: url }; } const hashs = urls[1].split(/[,;]/); const hash: { [key: string]: string } = {}; + const pattern = /^([a-zA-Z0-9]+)[-=](.+)$/; for (const val of hashs) { // 接受以下格式 // sha256-abc123== 格式 // sha256=abc123== 格式 - const match = val.match(/^([a-zA-Z0-9]+)[-=](.+)$/); + const match = pattern.exec(val); if (match) { const [, key, value] = match; hash[key] = value; @@ -89,7 +93,7 @@ export function parseUrlSRI(url: string): { } // 即使没有解析到任何哈希值,也只会返回空对象而不是 undefined - return { url: urls[0], hash }; + return { url: urls[0], hash, originalUrl: url }; } export async function notificationsUpdate( From 29cc3c6b5f742ac43974423bc35eed652ae95808 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 3 Feb 2026 18:45:31 +0900 Subject: [PATCH 02/36] =?UTF-8?q?=E5=A4=84=E7=90=86=E5=B9=B6=E8=A1=8Cfetch?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/resource.ts | 104 +++++++++++++++++---- src/app/service/service_worker/runtime.ts | 2 +- src/app/service/service_worker/script.ts | 2 +- 3 files changed, 88 insertions(+), 20 deletions(-) diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index 48adaac73..8f15ff5b8 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -15,6 +15,64 @@ import { stackAsyncTask } from "@App/pkg/utils/async_queue"; import { blobToUint8Array } from "@App/pkg/utils/datatype"; import { readBlobContent } from "@App/pkg/utils/encoding"; +class Semaphore { + private running = 0; + private readonly queue: Array<() => void> = []; + + constructor(readonly limit: number) { + if (limit < 1) throw new Error("limit must be >= 1"); + } + + async acquire(): Promise { + if (this.running < this.limit) { + this.running++; + return; + } + await new Promise((resolve) => this.queue.push(resolve)); + this.running++; + } + + release(): void { + if (this.running <= 0) { + console.warn("Semaphore double release detected"); + return; + } + this.running--; + this.queue.shift()?.(); + } +} + +const fetchSemaphore = new Semaphore(5); + +type TWithTimeoutNotifyResult = { + timeouted: boolean; + result: T | undefined; + done: boolean; + err: undefined | Error; +}; +const withTimeoutNotify = (promise: Promise, time: number, fn: (res: TWithTimeoutNotifyResult) => any) => { + const res: TWithTimeoutNotifyResult = { timeouted: false, result: undefined, done: false, err: undefined }; + const cid = setTimeout(() => { + res.timeouted = true; + fn(res); + }, time); + return promise + .then((result: T) => { + clearTimeout(cid); + res.result = result; + res.done = true; + fn(res); + return res; + }) + .catch((e) => { + clearTimeout(cid); + res.err = e; + res.done = true; + fn(res); + return res; + }); +}; + export class ResourceService { logger: Logger; resourceDAO: ResourceDAO = new ResourceDAO(); @@ -31,7 +89,6 @@ export class ResourceService { uuid: string, u: TUrlSRIInfo, type: ResourceType, - loadNow: boolean, oldResources: Resource | undefined ): Promise { if (oldResources) { @@ -42,15 +99,6 @@ export class ResourceService { } // 缓存中无资源加载纪录,需要取得资源 const url = u.originalUrl; - if (!loadNow) { - // 等一下尝试加载资源(例入 import) - // 避免所有资源立即同一时间加载, delay设为 1.2s ~ 2.4s - const delay = randNum(1200, 2400); - await sleep(delay); - const updatedResource = await this.getResourceModel(u); - // 如果等候期间有其他程序已生成 resource, 则不用呼叫 updateResource - if (updatedResource?.contentType) return updatedResource; - } try { return await this.updateResource(uuid, u, type, undefined); } catch (e: any) { @@ -59,11 +107,11 @@ export class ResourceService { return undefined; } - public async getScriptResources(script: Script, load: boolean): Promise<{ [key: string]: Resource }> { + public async getScriptResources(script: Script): Promise<{ [key: string]: Resource }> { const [require, require_css, resource] = await Promise.all([ - this.getResourceByType(script, "require", load), - this.getResourceByType(script, "require-css", load), - this.getResourceByType(script, "resource", load), + this.getResourceByType(script, "require"), + this.getResourceByType(script, "require-css"), + this.getResourceByType(script, "resource"), ]); return { @@ -73,7 +121,7 @@ export class ResourceService { }; } - async getResourceByType(script: Script, type: ResourceType, load: boolean): Promise<{ [key: string]: Resource }> { + async getResourceByType(script: Script, type: ResourceType): Promise<{ [key: string]: Resource }> { if (!script.metadata[type]) { return {}; } @@ -102,7 +150,7 @@ export class ResourceService { const res = await this.updateResource(script.uuid, u, type, oldResources); ret[resourceKey] = res; } else { - const res = await this.getResource(script.uuid, u, type, load, oldResources); + const res = await this.getResource(script.uuid, u, type, oldResources); if (res) { ret[resourceKey] = res; } @@ -269,7 +317,27 @@ export class ResourceService { async loadByUrl(url: string, type: ResourceType): Promise { const u = parseUrlSRI(url); - const resp = await fetch(u.url); + + await fetchSemaphore.acquire(); + // Semaphore 锁 - 同期只有五个 fetch 一起执行 + const delay = randNum(100, 150); // 100~150ms delay before starting fetch + await sleep(delay); + // 执行 fetch, 若超过 800ms, 不会中止 fetch 但会启动下一个网络连接任务 + // 这只为了避免等候时间过长,同时又不会有过多网络任务同时发生,使Web伺服器返回错误 + const { result, err } = await withTimeoutNotify(fetch(u.url), 800, ({ done, timeouted, err }) => { + if (timeouted || done || err) { + // fetch 成功 或 发生错误 或 timeout 时解锁 + fetchSemaphore.release(); + } + }); + // Semaphore 锁已解锁。继续处理 fetch Response 的结果 + + if (err) { + throw new Error(`resource fetch failed: ${err.message || err}`); + } + + const resp = result! as Response; + if (resp.status !== 200) { throw new Error(`resource response status not 200: ${resp.status}`); } @@ -342,7 +410,7 @@ export class ResourceService { } requestGetScriptResources(script: Script): Promise<{ [key: string]: Resource }> { - return this.getScriptResources(script, false); + return this.getScriptResources(script); } init() { diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 4fa3acbbc..c35f0c4c0 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -1214,7 +1214,7 @@ export class RuntimeService { script.value = value; }), // 加载resource - resource.getScriptResources(script, false).then((resource) => { + resource.getScriptResources(script).then((resource) => { script.resource = resource; for (const name of Object.keys(resource)) { const res = script.resource[name]; diff --git a/src/app/service/service_worker/script.ts b/src/app/service/service_worker/script.ts index e7b4e3b75..b7a503127 100644 --- a/src/app/service/service_worker/script.ts +++ b/src/app/service/service_worker/script.ts @@ -649,7 +649,7 @@ export class ScriptService { const ret = buildScriptRunResourceBasic(script); return Promise.all([ this.valueService.getScriptValue(ret), - this.resourceService.getScriptResources(ret, true), + this.resourceService.getScriptResources(ret), this.scriptCodeDAO.get(script.uuid), ]).then(([value, resource, code]) => { if (!code) { From 79b68f761395efa0869a98320035711e02ac485e Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 3 Feb 2026 18:52:51 +0900 Subject: [PATCH 03/36] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20src/app/service/serv?= =?UTF-8?q?ice=5Fworker/runtime.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/runtime.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index c35f0c4c0..adcc664ea 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -14,6 +14,7 @@ import { buildScriptRunResourceBasic, compileInjectionCode, getUserScriptRegister, + parseUrlSRI, scriptURLPatternResults, } from "./utils"; import { @@ -679,7 +680,7 @@ export class RuntimeService { async buildAndSaveCompiledResourceFromScript(script: Script, withCode: boolean = false) { const scriptRes = withCode ? await this.script.buildScriptRunResource(script) : buildScriptRunResourceBasic(script); - const resources = withCode ? scriptRes.resource : await this.resource.getScriptResources(scriptRes, true); + const resources = withCode ? scriptRes.resource : await this.resource.getScriptResources(scriptRes); const resourceUrls = (script.metadata["require"] || []).map((res) => resources[res]?.url).filter((res) => res); const scriptMatchInfo = await this.applyScriptMatchInfo(scriptRes); if (!scriptMatchInfo) return undefined; @@ -1156,7 +1157,7 @@ export class RuntimeService { if (!enableScriptList.length) return null; const scriptCodes = {} as Record; - // 更新资源使用了file协议的脚本 + // 更新资源使用了file协议的脚本 ( 不能在其他地方更新嗎?? 見 Issue #918 ) const scriptsWithUpdatedResources = new Map(); for (const scriptRes of enableScriptList) { const uuid = scriptRes.uuid; @@ -1166,8 +1167,11 @@ export class RuntimeService { for (const [url, [sha512, type]] of Object.entries(resourceCheck)) { const resourceList = scriptRes.metadata[type]; if (!resourceList) continue; - const updatedResource = await this.resource.updateResource(scriptRes.uuid, url, type); + const u = parseUrlSRI(url); + const oldResources = await this.resource.getResourceModel(u); + const updatedResource = await this.resource.updateResource(scriptRes.uuid, u, type, oldResources); if (updatedResource.hash?.sha512 !== sha512) { + // ----- 感觉这里是跟 resource.updateResource 内容的更新重复了 ----- for (const uri of resourceList) { /** 资源键名 */ let resourceKey = uri; @@ -1197,6 +1201,7 @@ export class RuntimeService { } } } + // ----- 感觉这里是跟 resource.updateResource 内容的更新重复了 ----- } } if (resourceUpdated) { From e83aae2b04eca2ba6483caa4161a9938e35eb671 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 3 Feb 2026 18:58:50 +0900 Subject: [PATCH 04/36] =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=9B=B4=E6=96=B0=E9=80=BB=E8=BE=91=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/runtime.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index adcc664ea..7f1578a53 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -1168,8 +1168,17 @@ export class RuntimeService { const resourceList = scriptRes.metadata[type]; if (!resourceList) continue; const u = parseUrlSRI(url); - const oldResources = await this.resource.getResourceModel(u); + if (u.hash) { + // 如果有 校验hash 的话,根本不用更新本地资源呀! + continue; + } + // const oldResources = await this.resource.getResourceModel(u); + const oldResources = await this.resource.resourceDAO.get(u.url); const updatedResource = await this.resource.updateResource(scriptRes.uuid, u, type, oldResources); + if (updatedResource === oldResources) { + // 如果新旧一样就忽视吧 - 不用更新本地资源 + continue; + } if (updatedResource.hash?.sha512 !== sha512) { // ----- 感觉这里是跟 resource.updateResource 内容的更新重复了 ----- for (const uri of resourceList) { From 96f34be874f4c5e7f66bfde7208fbd41073b05cf Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 3 Feb 2026 19:02:15 +0900 Subject: [PATCH 05/36] Update script.ts --- src/app/service/service_worker/script.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/service/service_worker/script.ts b/src/app/service/service_worker/script.ts index b7a503127..43d2ad696 100644 --- a/src/app/service/service_worker/script.ts +++ b/src/app/service/service_worker/script.ts @@ -420,6 +420,7 @@ export class ScriptService { this.resourceService.updateResourceByType(script, "require-css"), this.resourceService.updateResourceByType(script, "resource"), ]); + // 如果资源不完整,还是要接受安装吗??? // 广播一下 // Runtime 會負責更新 CompiledResource From 983f89ea4220bbc997e3a5c39835095e5e51d7c7 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 3 Feb 2026 19:05:55 +0900 Subject: [PATCH 06/36] fix --- src/app/service/service_worker/synchronize.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index e1da378ed..ceb046eb7 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -118,9 +118,9 @@ export class SynchronizeService { } const lastModificationDate = script.updatetime || script.createtime || undefined; const [values, valueRet] = await this.value.getScriptValueDetails(script); - const requires = await this.resource.getResourceByType(script, "require", false); - const requiresCss = await this.resource.getResourceByType(script, "require-css", false); - const resources = await this.resource.getResourceByType(script, "resource", false); + const requires = await this.resource.getResourceByType(script, "require"); + const requiresCss = await this.resource.getResourceByType(script, "require-css"); + const resources = await this.resource.getResourceByType(script, "resource"); const storage: ValueStorage = { data: { ...values }, ts: valueRet?.updatetime || lastModificationDate || Date.now(), From bbe6c1d6678cb9808c07c98d68d5d547462943cf Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:23:13 +0900 Subject: [PATCH 07/36] getResourceByType -> getResourceByTypes --- src/app/service/service_worker/resource.ts | 82 +++++++++---------- src/app/service/service_worker/synchronize.ts | 6 +- 2 files changed, 43 insertions(+), 45 deletions(-) diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index 8f15ff5b8..a2b49a86c 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -108,11 +108,7 @@ export class ResourceService { } public async getScriptResources(script: Script): Promise<{ [key: string]: Resource }> { - const [require, require_css, resource] = await Promise.all([ - this.getResourceByType(script, "require"), - this.getResourceByType(script, "require-css"), - this.getResourceByType(script, "resource"), - ]); + const [require, require_css, resource] = await this.getResourceByTypes(script, ["require", "require-css", "resource"]); return { ...require, @@ -121,44 +117,46 @@ export class ResourceService { }; } - async getResourceByType(script: Script, type: ResourceType): Promise<{ [key: string]: Resource }> { - if (!script.metadata[type]) { - return {}; - } - const ret: { [key: string]: Resource } = {}; - await Promise.allSettled( - script.metadata[type].map(async (uri) => { - /** 资源键名 */ - let resourceKey = uri; - /** 文件路径 */ - let path: string | null = uri; - if (type === "resource") { - // @resource xxx https://... - const split = uri.split(/\s+/); - if (split.length === 2) { - resourceKey = split[0]; - path = split[1].trim(); - } else { - path = null; - } - } - if (path) { - const u = parseUrlSRI(path); - const oldResources = await this.getResourceModel(u); - if (uri.startsWith("file:///")) { - // 如果是file://协议,则每次请求更新一下文件 - const res = await this.updateResource(script.uuid, u, type, oldResources); - ret[resourceKey] = res; - } else { - const res = await this.getResource(script.uuid, u, type, oldResources); - if (res) { - ret[resourceKey] = res; + public getResourceByTypes(script: Script, types: ResourceType[]): Promise[]> { + const promises = types.map(async (type) => { + const ret: Record = {}; + if (script.metadata[type]) { + await Promise.allSettled( + script.metadata[type].map(async (uri) => { + /** 资源键名 */ + let resourceKey = uri; + /** 文件路径 */ + let path: string | null = uri; + if (type === "resource") { + // @resource xxx https://... + const split = uri.split(/\s+/); + if (split.length === 2) { + resourceKey = split[0]; + path = split[1].trim(); + } else { + path = null; + } } - } - } - }) - ); - return ret; + if (path) { + const u = parseUrlSRI(path); + const oldResources = await this.getResourceModel(u); + if (uri.startsWith("file:///")) { + // 如果是file://协议,则每次请求更新一下文件 + const res = await this.updateResource(script.uuid, u, type, oldResources); + ret[resourceKey] = res; + } else { + const res = await this.getResource(script.uuid, u, type, oldResources); + if (res) { + ret[resourceKey] = res; + } + } + } + }) + ); + } + return ret; + }); + return Promise.all(promises); } // 只需要等待Promise返回,不理会返回值(失败也可以) diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index ceb046eb7..1f71cd8bf 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -118,9 +118,9 @@ export class SynchronizeService { } const lastModificationDate = script.updatetime || script.createtime || undefined; const [values, valueRet] = await this.value.getScriptValueDetails(script); - const requires = await this.resource.getResourceByType(script, "require"); - const requiresCss = await this.resource.getResourceByType(script, "require-css"); - const resources = await this.resource.getResourceByType(script, "resource"); + const [requires, requiresCss, resources] = await this.resource.getResourceByTypes(script, [ + "require", "require-css", "resource" + ]); const storage: ValueStorage = { data: { ...values }, ts: valueRet?.updatetime || lastModificationDate || Date.now(), From 49579bf069478802fac42e99faee1828b5c4cf0d Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:28:56 +0900 Subject: [PATCH 08/36] =?UTF-8?q?=E5=8A=A0=E5=85=A5=E6=B3=A8=E6=84=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/resource.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index a2b49a86c..f9ea3bcd5 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -109,6 +109,17 @@ export class ResourceService { public async getScriptResources(script: Script): Promise<{ [key: string]: Resource }> { const [require, require_css, resource] = await this.getResourceByTypes(script, ["require", "require-css", "resource"]); + const ret = { + ...require, + ...require_css, + ...resource, + }; + + // 注意! 如果它们包含相同名字的Resource,会根据次序而覆盖 + const recordKeyLens = [ret, require, require_css, resource].map((record) => Object.keys(record).length); + if (recordKeyLens[0] !== recordKeyLens[1] + recordKeyLens[2] + recordKeyLens[3]) { + console.warn("One or more properties are merged in ResourceService.getScriptResources"); + } return { ...require, From 3e8f04e2375cfb84bd200636635c58aa9ace7ec2 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:30:38 +0900 Subject: [PATCH 09/36] getScriptResources -> getScriptResourceValue --- src/app/service/service_worker/resource.ts | 4 ++-- src/app/service/service_worker/runtime.ts | 4 ++-- src/app/service/service_worker/script.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index f9ea3bcd5..a7df401e2 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -107,7 +107,7 @@ export class ResourceService { return undefined; } - public async getScriptResources(script: Script): Promise<{ [key: string]: Resource }> { + public async getScriptResourceValue(script: Script): Promise<{ [key: string]: Resource }> { const [require, require_css, resource] = await this.getResourceByTypes(script, ["require", "require-css", "resource"]); const ret = { ...require, @@ -419,7 +419,7 @@ export class ResourceService { } requestGetScriptResources(script: Script): Promise<{ [key: string]: Resource }> { - return this.getScriptResources(script); + return this.getScriptResourceValue(script); } init() { diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 7f1578a53..e1ed2f4d7 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -680,7 +680,7 @@ export class RuntimeService { async buildAndSaveCompiledResourceFromScript(script: Script, withCode: boolean = false) { const scriptRes = withCode ? await this.script.buildScriptRunResource(script) : buildScriptRunResourceBasic(script); - const resources = withCode ? scriptRes.resource : await this.resource.getScriptResources(scriptRes); + const resources = withCode ? scriptRes.resource : await this.resource.getScriptResourceValue(scriptRes); const resourceUrls = (script.metadata["require"] || []).map((res) => resources[res]?.url).filter((res) => res); const scriptMatchInfo = await this.applyScriptMatchInfo(scriptRes); if (!scriptMatchInfo) return undefined; @@ -1228,7 +1228,7 @@ export class RuntimeService { script.value = value; }), // 加载resource - resource.getScriptResources(script).then((resource) => { + resource.getScriptResourceValue(script).then((resource) => { script.resource = resource; for (const name of Object.keys(resource)) { const res = script.resource[name]; diff --git a/src/app/service/service_worker/script.ts b/src/app/service/service_worker/script.ts index 43d2ad696..55806bce3 100644 --- a/src/app/service/service_worker/script.ts +++ b/src/app/service/service_worker/script.ts @@ -650,7 +650,7 @@ export class ScriptService { const ret = buildScriptRunResourceBasic(script); return Promise.all([ this.valueService.getScriptValue(ret), - this.resourceService.getScriptResources(ret), + this.resourceService.getScriptResourceValue(ret), this.scriptCodeDAO.get(script.uuid), ]).then(([value, resource, code]) => { if (!code) { From bee32f0b0aa44a2f02ab3375e08f38d0babdac2d Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:47:42 +0900 Subject: [PATCH 10/36] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/resource.ts | 27 +++++++++++----------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index a7df401e2..3e13835e5 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -133,25 +133,26 @@ export class ResourceService { const ret: Record = {}; if (script.metadata[type]) { await Promise.allSettled( - script.metadata[type].map(async (uri) => { + script.metadata[type].map(async (mdValue) => { /** 资源键名 */ - let resourceKey = uri; + let resourceKey; /** 文件路径 */ - let path: string | null = uri; + let resourcePath: string; if (type === "resource") { // @resource xxx https://... - const split = uri.split(/\s+/); - if (split.length === 2) { - resourceKey = split[0]; - path = split[1].trim(); - } else { - path = null; - } + const split = mdValue.split(/\s+/); + if (split.length !== 2) return; // @resource 必须有 key 和 path. "xxx yyy zzz" 也不符合格式要求 + resourceKey = split[0]; + resourcePath = split[1].trim(); + } else { + // require / require-css 的话,使用 url 作为 resourceKey + resourceKey = mdValue; + resourcePath = mdValue; } - if (path) { - const u = parseUrlSRI(path); + if (resourcePath) { + const u = parseUrlSRI(resourcePath); const oldResources = await this.getResourceModel(u); - if (uri.startsWith("file:///")) { + if (mdValue.startsWith("file:///")) { // 如果是file://协议,则每次请求更新一下文件 const res = await this.updateResource(script.uuid, u, type, oldResources); ret[resourceKey] = res; From 495a78299ab2418e187e558c712b8c2e5e63f5a6 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:48:26 +0900 Subject: [PATCH 11/36] `mdValue.startsWith("file:///")` -> `resourcePath.startsWith("file:///")` --- src/app/service/service_worker/resource.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index 3e13835e5..f465cc8a5 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -152,7 +152,7 @@ export class ResourceService { if (resourcePath) { const u = parseUrlSRI(resourcePath); const oldResources = await this.getResourceModel(u); - if (mdValue.startsWith("file:///")) { + if (resourcePath.startsWith("file:///")) { // 如果是file://协议,则每次请求更新一下文件 const res = await this.updateResource(script.uuid, u, type, oldResources); ret[resourceKey] = res; From 1a84afd89b322a9e9adf90d98cb1cf896427ea8c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:49:56 +0900 Subject: [PATCH 12/36] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/resource.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index f465cc8a5..0750c3db6 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -131,9 +131,11 @@ export class ResourceService { public getResourceByTypes(script: Script, types: ResourceType[]): Promise[]> { const promises = types.map(async (type) => { const ret: Record = {}; - if (script.metadata[type]) { + const metadataEntries = script.metadata[type]; + const uuid = script.uuid; + if (metadataEntries) { await Promise.allSettled( - script.metadata[type].map(async (mdValue) => { + metadataEntries.map(async (mdValue) => { /** 资源键名 */ let resourceKey; /** 文件路径 */ @@ -154,10 +156,10 @@ export class ResourceService { const oldResources = await this.getResourceModel(u); if (resourcePath.startsWith("file:///")) { // 如果是file://协议,则每次请求更新一下文件 - const res = await this.updateResource(script.uuid, u, type, oldResources); + const res = await this.updateResource(uuid, u, type, oldResources); ret[resourceKey] = res; } else { - const res = await this.getResource(script.uuid, u, type, oldResources); + const res = await this.getResource(uuid, u, type, oldResources); if (res) { ret[resourceKey] = res; } From 658b256bf491370030fcac606796e40a47b52813 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:58:02 +0900 Subject: [PATCH 13/36] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/resource.ts | 25 ++++++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index 0750c3db6..2d2c07d88 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -154,16 +154,27 @@ export class ResourceService { if (resourcePath) { const u = parseUrlSRI(resourcePath); const oldResources = await this.getResourceModel(u); - if (resourcePath.startsWith("file:///")) { - // 如果是file://协议,则每次请求更新一下文件 - const res = await this.updateResource(uuid, u, type, oldResources); - ret[resourceKey] = res; + let freshResource: Resource | undefined = undefined; + if (oldResources && !resourcePath.startsWith("file:///")) { + // 读取过但失败的资源加载也会被放在缓存,避免再加载资源 + // 因此 getResource 时不会再加载资源,直接返回 undefined 表示没有资源 + if (!oldResources.contentType) { + freshResource = undefined; + } else { + freshResource = oldResources; + } } else { - const res = await this.getResource(uuid, u, type, oldResources); - if (res) { - ret[resourceKey] = res; + // 1) 如果是file://协议,则每次请求更新一下文件 + // 2) 缓存中无资源加载纪录,需要取得资源 + try { + freshResource = await this.updateResource(uuid, u, type, oldResources); + } catch (e: any) { + this.logger.error("load resource error", { url: u.originalUrl }, Logger.E(e)); } } + if (freshResource) { + ret[resourceKey] = freshResource; + } } }) ); From 582385542c472f1c4392c4d597b8fa3ce294df89 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:58:45 +0900 Subject: [PATCH 14/36] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/resource.ts | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index 2d2c07d88..3b7e6fec6 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -85,28 +85,6 @@ export class ResourceService { this.resourceDAO.enableCache(); } - public async getResource( - uuid: string, - u: TUrlSRIInfo, - type: ResourceType, - oldResources: Resource | undefined - ): Promise { - if (oldResources) { - // 读取过但失败的资源加载也会被放在缓存,避免再加载资源 - // 因此 getResource 时不会再加载资源,直接返回 undefined 表示没有资源 - if (!oldResources.contentType) return undefined; - return oldResources; - } - // 缓存中无资源加载纪录,需要取得资源 - const url = u.originalUrl; - try { - return await this.updateResource(uuid, u, type, undefined); - } catch (e: any) { - this.logger.error("load resource error", { url }, Logger.E(e)); - } - return undefined; - } - public async getScriptResourceValue(script: Script): Promise<{ [key: string]: Resource }> { const [require, require_css, resource] = await this.getResourceByTypes(script, ["require", "require-css", "resource"]); const ret = { From c3d395c1a2129d53993070b90aae173c383bf4d1 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:07:15 +0900 Subject: [PATCH 15/36] `loadByUrl` -> `createResourceByUrlFetch` --- src/app/service/service_worker/resource.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index 3b7e6fec6..e506b52b8 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -205,7 +205,7 @@ export class ResourceService { if (oldResources === null) oldResources = await this.getResourceModel(u); let result: Resource; try { - const resource = await this.loadByUrl(u.url, type); + const resource = await this.createResourceByUrlFetch(u.url, type); const now = Date.now(); resource.updatetime = now; if (!oldResources || !oldResources.contentType) { @@ -316,7 +316,7 @@ export class ResourceService { }); } - async loadByUrl(url: string, type: ResourceType): Promise { + async createResourceByUrlFetch(url: string, type: ResourceType): Promise { const u = parseUrlSRI(url); await fetchSemaphore.acquire(); From 1a434b82e8be68b042836593b1a53c02883ed413 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:10:37 +0900 Subject: [PATCH 16/36] =?UTF-8?q?=E7=AE=80=E5=8C=96=20updateResource=20sig?= =?UTF-8?q?nature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/resource.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index e506b52b8..64c1b29b7 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -199,10 +199,8 @@ export class ResourceService { uuid: string, u: TUrlSRIInfo, type: ResourceType, - oldResources: Resource | null | undefined = null + oldResources: Resource | undefined ) { - // 重新加载 - if (oldResources === null) oldResources = await this.getResourceModel(u); let result: Resource; try { const resource = await this.createResourceByUrlFetch(u.url, type); From da70c268ea553baa8c195eb9c50b84b669c1f119 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:13:36 +0900 Subject: [PATCH 17/36] =?UTF-8?q?=E7=AE=80=E5=8C=96=20createResourceByUrlF?= =?UTF-8?q?etch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/resource.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index 64c1b29b7..7d56a59a7 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -203,7 +203,7 @@ export class ResourceService { ) { let result: Resource; try { - const resource = await this.createResourceByUrlFetch(u.url, type); + const resource = await this.createResourceByUrlFetch(u, type); const now = Date.now(); resource.updatetime = now; if (!oldResources || !oldResources.contentType) { @@ -314,8 +314,8 @@ export class ResourceService { }); } - async createResourceByUrlFetch(url: string, type: ResourceType): Promise { - const u = parseUrlSRI(url); + async createResourceByUrlFetch(u: TUrlSRIInfo, type: ResourceType): Promise { + const url = u.url; // 无 URI Integrity Hash await fetchSemaphore.acquire(); // Semaphore 锁 - 同期只有五个 fetch 一起执行 @@ -323,7 +323,7 @@ export class ResourceService { await sleep(delay); // 执行 fetch, 若超过 800ms, 不会中止 fetch 但会启动下一个网络连接任务 // 这只为了避免等候时间过长,同时又不会有过多网络任务同时发生,使Web伺服器返回错误 - const { result, err } = await withTimeoutNotify(fetch(u.url), 800, ({ done, timeouted, err }) => { + const { result, err } = await withTimeoutNotify(fetch(url), 800, ({ done, timeouted, err }) => { if (timeouted || done || err) { // fetch 成功 或 发生错误 或 timeout 时解锁 fetchSemaphore.release(); From 8b1fc2465e9c52dce55f643c7d8ab3db1ca7b2ff Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:24:00 +0900 Subject: [PATCH 18/36] lint --- src/app/service/service_worker/resource.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index 7d56a59a7..98d483187 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -86,7 +86,11 @@ export class ResourceService { } public async getScriptResourceValue(script: Script): Promise<{ [key: string]: Resource }> { - const [require, require_css, resource] = await this.getResourceByTypes(script, ["require", "require-css", "resource"]); + const [require, require_css, resource] = await this.getResourceByTypes(script, [ + "require", + "require-css", + "resource", + ]); const ret = { ...require, ...require_css, @@ -195,12 +199,7 @@ export class ResourceService { if (promises?.length) return Promise.allSettled(promises); } - async updateResource( - uuid: string, - u: TUrlSRIInfo, - type: ResourceType, - oldResources: Resource | undefined - ) { + async updateResource(uuid: string, u: TUrlSRIInfo, type: ResourceType, oldResources: Resource | undefined) { let result: Resource; try { const resource = await this.createResourceByUrlFetch(u, type); From 0c991f25956d40fc5d437c36266f5e23e2fb5b46 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:35:16 +0900 Subject: [PATCH 19/36] `updateResourceByType` -> `updateResourceByTypes` --- src/app/service/service_worker/resource.ts | 56 ++++++++++++---------- src/app/service/service_worker/script.ts | 4 +- 2 files changed, 31 insertions(+), 29 deletions(-) diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index 98d483187..b7a4a7139 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -167,36 +167,40 @@ export class ResourceService { } // 只需要等待Promise返回,不理会返回值(失败也可以) - updateResourceByType(script: Script, type: ResourceType): Promise | void { + updateResourceByTypes(script: Script, types: ResourceType[]): Promise { const uuid = script.uuid; - const promises = script.metadata[type]?.map(async (u) => { - let url = ""; - if (type === "resource") { - const split = u.split(/\s+/); - if (split.length === 2) { - url = split[1]; + const metadata = script.metadata; + const promises = types.map((type) => { + const promises = metadata[type]?.map(async (u) => { + let url = ""; + if (type === "resource") { + const split = u.split(/\s+/); + if (split.length === 2) { + url = split[1]; + } + } else { + url = u; } - } else { - url = u; - } - if (url) { - // 检查资源是否存在,如果不存在则重新加载 - // 如果有旧资源,而没有新资讯,则继续使用旧资源 - // 只需要等待Promise返回,不理会返回值(失败也可以) - const u = parseUrlSRI(url); - const oldResources = await this.getResourceModel(u); - const updateTime = oldResources?.updatetime; - // 资源最后更新是24小时内则不更新 - if (updateTime && updateTime > Date.now() - 86400_000) return; - // 旧资源或没有资源记录,尝试更新 - try { - await this.updateResource(uuid, u, type, oldResources); - } catch (e: any) { - this.logger.error("check resource failed", { uuid, url }, Logger.E(e)); + if (url) { + // 检查资源是否存在,如果不存在则重新加载 + // 如果有旧资源,而没有新资讯,则继续使用旧资源 + // 只需要等待Promise返回,不理会返回值(失败也可以) + const u = parseUrlSRI(url); + const oldResources = await this.getResourceModel(u); + const updateTime = oldResources?.updatetime; + // 资源最后更新是24小时内则不更新 + if (updateTime && updateTime > Date.now() - 86400_000) return; + // 旧资源或没有资源记录,尝试更新 + try { + await this.updateResource(uuid, u, type, oldResources); + } catch (e: any) { + this.logger.error("check resource failed", { uuid, url }, Logger.E(e)); + } } - } + }); + if (promises?.length) return Promise.allSettled(promises); }); - if (promises?.length) return Promise.allSettled(promises); + return Promise.all(promises); } async updateResource(uuid: string, u: TUrlSRIInfo, type: ResourceType, oldResources: Resource | undefined) { diff --git a/src/app/service/service_worker/script.ts b/src/app/service/service_worker/script.ts index 741306d67..63c2edbd9 100644 --- a/src/app/service/service_worker/script.ts +++ b/src/app/service/service_worker/script.ts @@ -415,9 +415,7 @@ export class ScriptService { // Cache更新 & 下载资源 await Promise.all([ compiledResourceUpdatePromise, - this.resourceService.updateResourceByType(script, "require"), - this.resourceService.updateResourceByType(script, "require-css"), - this.resourceService.updateResourceByType(script, "resource"), + this.resourceService.updateResourceByTypes(script, ["require", "require-css", "resource"]), ]); // 如果资源不完整,还是要接受安装吗??? From d6d2a29d52997c093d6c30f2e21844c579c24836 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:35:32 +0900 Subject: [PATCH 20/36] =?UTF-8?q?=E5=8A=A0=E6=B3=A8=E9=87=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/resource.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index b7a4a7139..281e525a3 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -189,6 +189,7 @@ export class ResourceService { const oldResources = await this.getResourceModel(u); const updateTime = oldResources?.updatetime; // 资源最后更新是24小时内则不更新 + // 这里是假设 resources 都是 static. 使用者应该加 ?d=xxxx 之类的方式提示SC要更新资源 if (updateTime && updateTime > Date.now() - 86400_000) return; // 旧资源或没有资源记录,尝试更新 try { From 8892d60d41a0b8106b5dafa6d0bada60f807f365 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:39:20 +0900 Subject: [PATCH 21/36] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E4=BC=98=E5=8C=96=20-?= =?UTF-8?q?=20=E8=B5=84=E6=BA=90=E6=9B=B4=E6=96=B0=E6=9D=A1=E4=BB=B6?= =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/resource.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index 281e525a3..1ea817a6f 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -187,10 +187,13 @@ export class ResourceService { // 只需要等待Promise返回,不理会返回值(失败也可以) const u = parseUrlSRI(url); const oldResources = await this.getResourceModel(u); - const updateTime = oldResources?.updatetime; - // 资源最后更新是24小时内则不更新 - // 这里是假设 resources 都是 static. 使用者应该加 ?d=xxxx 之类的方式提示SC要更新资源 - if (updateTime && updateTime > Date.now() - 86400_000) return; + // 非空值 url 且 url 不是本地档案 -> 检查最后更新时间 + if (u.url && !u.url.startsWith("file:///")) { + const updateTime = oldResources?.updatetime; + // 资源最后更新是24小时内则不更新 + // 这里是假设 resources 都是 static. 使用者应该加 ?d=xxxx 之类的方式提示SC要更新资源 + if (updateTime && updateTime > Date.now() - 86400_000) return; + } // 旧资源或没有资源记录,尝试更新 try { await this.updateResource(uuid, u, type, oldResources); From ab7a08215a3ae225a150a0713716dc1e90264109 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:40:10 +0900 Subject: [PATCH 22/36] =?UTF-8?q?=E6=B3=A8=E9=87=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/resource.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index 1ea817a6f..c8b545c0d 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -194,7 +194,7 @@ export class ResourceService { // 这里是假设 resources 都是 static. 使用者应该加 ?d=xxxx 之类的方式提示SC要更新资源 if (updateTime && updateTime > Date.now() - 86400_000) return; } - // 旧资源或没有资源记录,尝试更新 + // 旧资源或没有资源记录或本地档案,尝试更新 try { await this.updateResource(uuid, u, type, oldResources); } catch (e: any) { From 0dcd7efa1e5bac8dbf7087d7d00dfb25b466464e Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:44:50 +0900 Subject: [PATCH 23/36] lint --- src/app/service/service_worker/synchronize.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 993c76592..bb2d3b262 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -126,7 +126,9 @@ export class SynchronizeService { const lastModificationDate = script.updatetime || script.createtime || undefined; const [values, valueRet] = await this.value.getScriptValueDetails(script); const [requires, requiresCss, resources] = await this.resource.getResourceByTypes(script, [ - "require", "require-css", "resource" + "require", + "require-css", + "resource", ]); const storage: ValueStorage = { data: { ...values }, From 360f6d4a99dccbc2992fe5380c34993defa6d27a Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 4 Feb 2026 14:32:27 +0900 Subject: [PATCH 24/36] =?UTF-8?q?=E8=B0=83=E6=95=B4=E4=BB=A3=E7=A0=81=20-?= =?UTF-8?q?=20updateResource=20&=20createResourceByUrlFetch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/repo/resource.ts | 2 +- src/app/service/service_worker/resource.ts | 114 +++++++++++---------- src/app/service/service_worker/runtime.ts | 2 +- 3 files changed, 63 insertions(+), 55 deletions(-) diff --git a/src/app/repo/resource.ts b/src/app/repo/resource.ts index 5aa0889b2..c3dd2da1e 100644 --- a/src/app/repo/resource.ts +++ b/src/app/repo/resource.ts @@ -11,7 +11,7 @@ export interface Resource { hash: ResourceHash; type: ResourceType; link: { [key: string]: boolean }; // 关联的脚本 - contentType: string; + contentType: string; // 下载成功的话必定有 contentType. 下载失败的话则没有 (空Resource) createtime: number; updatetime?: number; } diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index c8b545c0d..176c51998 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -150,11 +150,13 @@ export class ResourceService { // 2) 缓存中无资源加载纪录,需要取得资源 try { freshResource = await this.updateResource(uuid, u, type, oldResources); + // 没有 oldResources 时,下载资源失败还是会生成一个空 Resource,避免重复尝试失败的下载 } catch (e: any) { this.logger.error("load resource error", { url: u.originalUrl }, Logger.E(e)); } } if (freshResource) { + // 空资源也储存一下,确保 resourceDAO 的记录和 script 的 resourceValue 记录一致 ret[resourceKey] = freshResource; } } @@ -187,9 +189,9 @@ export class ResourceService { // 只需要等待Promise返回,不理会返回值(失败也可以) const u = parseUrlSRI(url); const oldResources = await this.getResourceModel(u); - // 非空值 url 且 url 不是本地档案 -> 检查最后更新时间 - if (u.url && !u.url.startsWith("file:///")) { - const updateTime = oldResources?.updatetime; + // 非空值 url 且 url 不是本地档案 -> 检查最后更新时间 (空资源除外) + if (u.url && !u.url.startsWith("file:///") && oldResources?.contentType) { + const updateTime = oldResources.updatetime; // 资源最后更新是24小时内则不更新 // 这里是假设 resources 都是 static. 使用者应该加 ?d=xxxx 之类的方式提示SC要更新资源 if (updateTime && updateTime > Date.now() - 86400_000) return; @@ -209,43 +211,43 @@ export class ResourceService { async updateResource(uuid: string, u: TUrlSRIInfo, type: ResourceType, oldResources: Resource | undefined) { let result: Resource; + let resource: Resource | undefined; try { - const resource = await this.createResourceByUrlFetch(u, type); - const now = Date.now(); - resource.updatetime = now; - if (!oldResources || !oldResources.contentType) { - // 资源不存在,保存 - resource.createtime = now; - resource.link = { [uuid]: true }; - await this.resourceDAO.save(resource); - result = resource; - this.logger.info("reload new resource success", { url: u.url }); + resource = await this.createResourceByUrlFetch(u, type); + } catch (e) { + this.logger.error("fetch resource error", { url: u.url }, Logger.E(e)); + } + try { + if (resource) { + if (!oldResources || !oldResources.contentType) { + // 资源不存在,保存 + resource.link = { [uuid]: true }; + result = resource; + await this.resourceDAO.save(result).catch(console.warn); + this.logger.info("reload new resource success", { url: u.url }); + } else { + result = { + ...oldResources, + base64: resource.base64, + content: resource.content, + contentType: resource.contentType, + hash: resource.hash, + updatetime: resource.updatetime, + link: { ...oldResources.link, [uuid]: true }, + }; + await this.resourceDAO.save(result).catch(console.warn); + this.logger.info("reload resource success", { + url: u.url, + }); + } + return result; } else { + // 如果有旧资源,则使用旧资源 + if (oldResources) return oldResources; + // 资源错误时(且没有旧资源)保存一个空纪录以防止再度尝试加载 + // this.resourceDAO.save 自身出错的话忽略 + const now = Date.now(); result = { - ...oldResources, - base64: resource.base64, - content: resource.content, - contentType: resource.contentType, - hash: resource.hash, - updatetime: resource.updatetime, - link: { ...oldResources.link, [uuid]: true }, - }; - await this.resourceDAO.update(result.url, result); - this.logger.info("reload resource success", { - url: u.url, - }); - } - return result; - } catch (e) { - // 如果有旧资源,则使用旧资源 - if (oldResources) { - this.logger.error("load resource error - fallback to old resource", { url: u.url }, Logger.E(e)); - return oldResources; - } - // 资源错误时(且没有旧资源)保存一个空纪录以防止再度尝试加载 - // this.resourceDAO.save 自身出错的话忽略 - await this.resourceDAO - .save({ url: u.url, content: "", contentType: "", @@ -259,9 +261,13 @@ export class ResourceService { base64: "", link: { [uuid]: true }, type, - createtime: Date.now(), - }) - .catch(console.warn); + createtime: now, + updatetime: now, + }; + await this.resourceDAO.save(result).catch(console.warn); + return result; // 下载失败还是回传一下 result + } + } catch (e) { this.logger.error("load resource error", { url: u.url }, Logger.E(e)); throw e; } @@ -354,25 +360,27 @@ export class ResourceService { blobToBase64(data), ]); const contentType = resp.headers.get("content-type"); - const resource: Resource = { - url: u.url, - content: "", - contentType: (contentType || "application/octet-stream").split(";")[0], - hash: hash, - base64: "", - link: {}, - type, - createtime: Date.now(), - }; + let content: string = ""; const uint8Array = new Uint8Array(arrayBuffer); if (isText(uint8Array)) { if (type === "require" || type === "require-css") { - resource.content = await readBlobContent(data, contentType); // @require和@require-css 是会转换成代码运行的,可以进行解码 + content = await readBlobContent(data, contentType); // @require和@require-css 是会转换成代码运行的,可以进行解码 } else { - resource.content = await data.text(); // @resource 应该要保留原汁原味 + content = await data.text(); // @resource 应该要保留原汁原味 } } - resource.base64 = base64 || ""; + const now = Date.now(); + const resource: Resource = { + url: u.url, + content: content, + contentType: (contentType || "application/octet-stream").split(";")[0], // 保证下载成功时必定有 contentType + hash: hash, + base64: base64 || "", + link: {}, + type, + createtime: now, + updatetime: now, + }; return resource; } diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index e1ed2f4d7..54386806e 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -1157,7 +1157,7 @@ export class RuntimeService { if (!enableScriptList.length) return null; const scriptCodes = {} as Record; - // 更新资源使用了file协议的脚本 ( 不能在其他地方更新嗎?? 見 Issue #918 ) + // 更新资源使用了file协议的脚本 ( 不能在其他地方更新吗?? 见 Issue #918 ) const scriptsWithUpdatedResources = new Map(); for (const scriptRes of enableScriptList) { const uuid = scriptRes.uuid; From 89d7a0f56b56edbb7fe9620c9eb7962381b4f633 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 4 Feb 2026 14:41:46 +0900 Subject: [PATCH 25/36] =?UTF-8?q?=E7=BB=9F=E4=B8=80=20try=20catch=20?= =?UTF-8?q?=E5=9C=A8=20updateResource=20=E9=87=8C=E8=BF=9B=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/resource.ts | 21 +++++---------------- src/app/service/service_worker/runtime.ts | 5 +++-- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index 176c51998..b427e4fd3 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -148,12 +148,8 @@ export class ResourceService { } else { // 1) 如果是file://协议,则每次请求更新一下文件 // 2) 缓存中无资源加载纪录,需要取得资源 - try { - freshResource = await this.updateResource(uuid, u, type, oldResources); - // 没有 oldResources 时,下载资源失败还是会生成一个空 Resource,避免重复尝试失败的下载 - } catch (e: any) { - this.logger.error("load resource error", { url: u.originalUrl }, Logger.E(e)); - } + freshResource = await this.updateResource(uuid, u, type, oldResources); + // 没有 oldResources 时,下载资源失败还是会生成一个空 Resource,避免重复尝试失败的下载 } if (freshResource) { // 空资源也储存一下,确保 resourceDAO 的记录和 script 的 resourceValue 记录一致 @@ -197,11 +193,7 @@ export class ResourceService { if (updateTime && updateTime > Date.now() - 86400_000) return; } // 旧资源或没有资源记录或本地档案,尝试更新 - try { - await this.updateResource(uuid, u, type, oldResources); - } catch (e: any) { - this.logger.error("check resource failed", { uuid, url }, Logger.E(e)); - } + await this.updateResource(uuid, u, type, oldResources); } }); if (promises?.length) return Promise.allSettled(promises); @@ -236,9 +228,7 @@ export class ResourceService { link: { ...oldResources.link, [uuid]: true }, }; await this.resourceDAO.save(result).catch(console.warn); - this.logger.info("reload resource success", { - url: u.url, - }); + this.logger.info("reload resource success", { url: u.url }); } return result; } else { @@ -268,8 +258,7 @@ export class ResourceService { return result; // 下载失败还是回传一下 result } } catch (e) { - this.logger.error("load resource error", { url: u.url }, Logger.E(e)); - throw e; + this.logger.error("Unexpected error in updateResource", { url: u.url }, Logger.E(e)); } } diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 54386806e..fad5d238a 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -1175,8 +1175,9 @@ export class RuntimeService { // const oldResources = await this.resource.getResourceModel(u); const oldResources = await this.resource.resourceDAO.get(u.url); const updatedResource = await this.resource.updateResource(scriptRes.uuid, u, type, oldResources); - if (updatedResource === oldResources) { - // 如果新旧一样就忽视吧 - 不用更新本地资源 + if (!updatedResource || !updatedResource.contentType || updatedResource === oldResources) { + // updateResource 出错 或 下载失败则忽略 + // 如果新旧一样也忽视吧 - 不用更新本地资源 continue; } if (updatedResource.hash?.sha512 !== sha512) { From 05e54f7aeec5921cb081b410d736dfc409ea72e5 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:00:20 +0900 Subject: [PATCH 26/36] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20Semaphore=20?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/resource.ts | 28 ++++++++++++---------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index b427e4fd3..153f399f8 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -16,29 +16,27 @@ import { blobToUint8Array } from "@App/pkg/utils/datatype"; import { readBlobContent } from "@App/pkg/utils/encoding"; class Semaphore { - private running = 0; + private active = 0; private readonly queue: Array<() => void> = []; constructor(readonly limit: number) { if (limit < 1) throw new Error("limit must be >= 1"); } - async acquire(): Promise { - if (this.running < this.limit) { - this.running++; - return; + async acquire() { + if (this.active >= this.limit) { + await new Promise((resolve) => this.queue.push(resolve)); } - await new Promise((resolve) => this.queue.push(resolve)); - this.running++; + this.active++; } - release(): void { - if (this.running <= 0) { + release() { + if (this.active > 0) { + this.active--; + this.queue.shift()?.(); + } else { console.warn("Semaphore double release detected"); - return; } - this.running--; - this.queue.shift()?.(); } } @@ -319,6 +317,7 @@ export class ResourceService { async createResourceByUrlFetch(u: TUrlSRIInfo, type: ResourceType): Promise { const url = u.url; // 无 URI Integrity Hash + let released = false; await fetchSemaphore.acquire(); // Semaphore 锁 - 同期只有五个 fetch 一起执行 const delay = randNum(100, 150); // 100~150ms delay before starting fetch @@ -328,7 +327,10 @@ export class ResourceService { const { result, err } = await withTimeoutNotify(fetch(url), 800, ({ done, timeouted, err }) => { if (timeouted || done || err) { // fetch 成功 或 发生错误 或 timeout 时解锁 - fetchSemaphore.release(); + if (!released) { + released = true; + fetchSemaphore.release(); + } } }); // Semaphore 锁已解锁。继续处理 fetch Response 的结果 From b0f9318103084b1eaa57ab673d17c161621ca47b Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 4 Feb 2026 18:09:40 +0900 Subject: [PATCH 27/36] =?UTF-8?q?=E6=8A=8A=E5=B9=B6=E8=A1=8C=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E7=9A=84=E4=BB=A3=E7=A0=81=E7=A7=BB=E5=8A=A8=E8=87=B3?= =?UTF-8?q?=20concurrency-control.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/resource.ts | 55 +-------------------- src/pkg/utils/concurrency-control.ts | 57 ++++++++++++++++++++++ 2 files changed, 58 insertions(+), 54 deletions(-) create mode 100644 src/pkg/utils/concurrency-control.ts diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index 153f399f8..ef10e1eee 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -14,63 +14,10 @@ import { isBase64, parseUrlSRI, type TUrlSRIInfo } from "./utils"; import { stackAsyncTask } from "@App/pkg/utils/async_queue"; import { blobToUint8Array } from "@App/pkg/utils/datatype"; import { readBlobContent } from "@App/pkg/utils/encoding"; - -class Semaphore { - private active = 0; - private readonly queue: Array<() => void> = []; - - constructor(readonly limit: number) { - if (limit < 1) throw new Error("limit must be >= 1"); - } - - async acquire() { - if (this.active >= this.limit) { - await new Promise((resolve) => this.queue.push(resolve)); - } - this.active++; - } - - release() { - if (this.active > 0) { - this.active--; - this.queue.shift()?.(); - } else { - console.warn("Semaphore double release detected"); - } - } -} +import { Semaphore, withTimeoutNotify } from "@App/pkg/utils/concurrency-control"; const fetchSemaphore = new Semaphore(5); -type TWithTimeoutNotifyResult = { - timeouted: boolean; - result: T | undefined; - done: boolean; - err: undefined | Error; -}; -const withTimeoutNotify = (promise: Promise, time: number, fn: (res: TWithTimeoutNotifyResult) => any) => { - const res: TWithTimeoutNotifyResult = { timeouted: false, result: undefined, done: false, err: undefined }; - const cid = setTimeout(() => { - res.timeouted = true; - fn(res); - }, time); - return promise - .then((result: T) => { - clearTimeout(cid); - res.result = result; - res.done = true; - fn(res); - return res; - }) - .catch((e) => { - clearTimeout(cid); - res.err = e; - res.done = true; - fn(res); - return res; - }); -}; - export class ResourceService { logger: Logger; resourceDAO: ResourceDAO = new ResourceDAO(); diff --git a/src/pkg/utils/concurrency-control.ts b/src/pkg/utils/concurrency-control.ts new file mode 100644 index 000000000..f796e7034 --- /dev/null +++ b/src/pkg/utils/concurrency-control.ts @@ -0,0 +1,57 @@ +export class Semaphore { + private active = 0; + private readonly queue: Array<() => void> = []; + + constructor(readonly limit: number) { + if (limit < 1) throw new Error("limit must be >= 1"); + } + + async acquire() { + if (this.active >= this.limit) { + await new Promise((resolve) => this.queue.push(resolve)); + } + this.active++; + } + + release() { + if (this.active > 0) { + this.active--; + this.queue.shift()?.(); + } else { + console.warn("Semaphore double release detected"); + } + } +} + +type TWithTimeoutNotifyResult = { + timeouted: boolean; + result: T | undefined; + done: boolean; + err: undefined | Error; +}; +export const withTimeoutNotify = ( + promise: Promise, + time: number, + fn: (res: TWithTimeoutNotifyResult) => any +) => { + const res: TWithTimeoutNotifyResult = { timeouted: false, result: undefined, done: false, err: undefined }; + const cid = setTimeout(() => { + res.timeouted = true; + fn(res); + }, time); + return promise + .then((result: T) => { + clearTimeout(cid); + res.result = result; + res.done = true; + fn(res); + return res; + }) + .catch((e) => { + clearTimeout(cid); + res.err = e; + res.done = true; + fn(res); + return res; + }); +}; From 59dcad37927fcb6c83f898f8f05a91a96c16de7d Mon Sep 17 00:00:00 2001 From: wangyizhi Date: Sun, 29 Mar 2026 16:20:52 +0800 Subject: [PATCH 28/36] Update src/app/service/service_worker/resource.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/app/service/service_worker/resource.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index 065c3f09d..57c8d166c 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -45,7 +45,7 @@ export class ResourceService { // 注意! 如果它们包含相同名字的Resource,会根据次序而覆盖 const recordKeyLens = [ret, require, require_css, resource].map((record) => Object.keys(record).length); if (recordKeyLens[0] !== recordKeyLens[1] + recordKeyLens[2] + recordKeyLens[3]) { - console.warn("One or more properties are merged in ResourceService.getScriptResources"); + this.logger.warn("One or more properties are merged in ResourceService.getScriptResourceValue"); } return { From e49f730f58f9d5395d3249066653ba0febe41094 Mon Sep 17 00:00:00 2001 From: wangyizhi Date: Sun, 29 Mar 2026 16:22:01 +0800 Subject: [PATCH 29/36] Update src/app/service/service_worker/utils.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/app/service/service_worker/utils.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/service/service_worker/utils.ts b/src/app/service/service_worker/utils.ts index 3afad5cc6..69e3f044e 100644 --- a/src/app/service/service_worker/utils.ts +++ b/src/app/service/service_worker/utils.ts @@ -95,7 +95,10 @@ export function parseUrlSRI(url: string): TUrlSRIInfo { } } - // 即使没有解析到任何哈希值,也只会返回空对象而不是 undefined + // 如果没有解析到任何哈希值,则返回 undefined,与类型定义保持一致 + if (Object.keys(hash).length === 0) { + return { url: urls[0], hash: undefined, originalUrl: url }; + } return { url: urls[0], hash, originalUrl: url }; } From 0b1ded9b16e936f9ee45f73d26270abce3480071 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Sun, 29 Mar 2026 17:20:41 +0800 Subject: [PATCH 30/36] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E5=8A=A0=E8=BD=BD=E4=BB=A3=E7=A0=81=EF=BC=9A?= =?UTF-8?q?=E6=8F=90=E5=8F=96=E9=AD=94=E6=B3=95=E6=95=B0=E5=AD=97=E3=80=81?= =?UTF-8?q?=E6=B8=85=E7=90=86=E7=96=91=E9=97=AE=E6=B3=A8=E9=87=8A=E3=80=81?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=B9=B6=E5=8F=91=E6=8E=A7=E5=88=B6=E5=8D=95?= =?UTF-8?q?=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 提取魔法数字为命名常量(MAX_CONCURRENT_FETCHES, FETCH_DELAY, FETCH_SEMAPHORE_TIMEOUT, RESOURCE_CACHE_TTL) - 清理疑问注释,改为明确的设计决策说明 - 为 Semaphore 和 withTimeoutNotify 添加单元测试 --- src/app/service/service_worker/resource.ts | 37 +++-- src/app/service/service_worker/runtime.ts | 5 +- src/app/service/service_worker/script.ts | 2 +- src/pkg/utils/concurrency-control.test.ts | 185 +++++++++++++++++++++ 4 files changed, 213 insertions(+), 16 deletions(-) create mode 100644 src/pkg/utils/concurrency-control.test.ts diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index 57c8d166c..ee9737299 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -16,7 +16,17 @@ import { blobToUint8Array } from "@App/pkg/utils/datatype"; import { readBlobContent } from "@App/pkg/utils/encoding"; import { Semaphore, withTimeoutNotify } from "@App/pkg/utils/concurrency-control"; -const fetchSemaphore = new Semaphore(5); +/** 同时发起的最大 fetch 数量,避免大量请求冲击同一服务器 */ +const MAX_CONCURRENT_FETCHES = 5; +/** fetch 前的随机延迟范围(ms),分散请求时间 */ +const FETCH_DELAY_MIN_MS = 100; +const FETCH_DELAY_MAX_MS = 150; +/** fetch 超时后释放信号量的时间(ms),不会中止 fetch 本身 */ +const FETCH_SEMAPHORE_TIMEOUT_MS = 800; +/** 资源缓存过期时间(ms),24小时 */ +const RESOURCE_CACHE_TTL_MS = 86400_000; + +const fetchSemaphore = new Semaphore(MAX_CONCURRENT_FETCHES); export class ResourceService { logger: Logger; @@ -135,7 +145,7 @@ export class ResourceService { const updateTime = oldResources.updatetime; // 资源最后更新是24小时内则不更新 // 这里是假设 resources 都是 static. 使用者应该加 ?d=xxxx 之类的方式提示SC要更新资源 - if (updateTime && updateTime > Date.now() - 86400_000) return; + if (updateTime && updateTime > Date.now() - RESOURCE_CACHE_TTL_MS) return; } // 旧资源或没有资源记录或本地档案,尝试更新 await this.updateResource(uuid, u, type, oldResources); @@ -267,19 +277,22 @@ export class ResourceService { let released = false; await fetchSemaphore.acquire(); // Semaphore 锁 - 同期只有五个 fetch 一起执行 - const delay = randNum(100, 150); // 100~150ms delay before starting fetch + const delay = randNum(FETCH_DELAY_MIN_MS, FETCH_DELAY_MAX_MS); await sleep(delay); - // 执行 fetch, 若超过 800ms, 不会中止 fetch 但会启动下一个网络连接任务 - // 这只为了避免等候时间过长,同时又不会有过多网络任务同时发生,使Web伺服器返回错误 - const { result, err } = await withTimeoutNotify(fetch(url), 800, ({ done, timeouted, err }) => { - if (timeouted || done || err) { - // fetch 成功 或 发生错误 或 timeout 时解锁 - if (!released) { - released = true; - fetchSemaphore.release(); + // 执行 fetch, 若超时则不中止 fetch 但释放信号量,让下一个任务启动 + const { result, err } = await withTimeoutNotify( + fetch(url), + FETCH_SEMAPHORE_TIMEOUT_MS, + ({ done, timeouted, err }) => { + if (timeouted || done || err) { + // fetch 成功 或 发生错误 或 timeout 时解锁 + if (!released) { + released = true; + fetchSemaphore.release(); + } } } - }); + ); // Semaphore 锁已解锁。继续处理 fetch Response 的结果 if (err) { diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index adcf585a1..f99b96313 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -1211,7 +1211,7 @@ export class RuntimeService { // 如果有 校验hash 的话,根本不用更新本地资源呀! continue; } - // const oldResources = await this.resource.getResourceModel(u); + // 这里不用 getResourceModel 是因为上面已经跳过了有 hash 的 URL,无需 SRI 校验 const oldResources = await this.resource.resourceDAO.get(u.url); const updatedResource = await this.resource.updateResource(scriptRes.uuid, u, type, oldResources); if (!updatedResource || !updatedResource.contentType || updatedResource === oldResources) { @@ -1220,7 +1220,7 @@ export class RuntimeService { continue; } if (updatedResource.hash?.sha512 !== sha512) { - // ----- 感觉这里是跟 resource.updateResource 内容的更新重复了 ----- + // updateResource 更新的是数据库,这里更新的是内存中的 scriptRes.resource 对象 for (const uri of resourceList) { /** 资源键名 */ let resourceKey = uri; @@ -1250,7 +1250,6 @@ export class RuntimeService { } } } - // ----- 感觉这里是跟 resource.updateResource 内容的更新重复了 ----- } } if (resourceUpdated) { diff --git a/src/app/service/service_worker/script.ts b/src/app/service/service_worker/script.ts index 965d26960..2d5daeb6d 100644 --- a/src/app/service/service_worker/script.ts +++ b/src/app/service/service_worker/script.ts @@ -430,7 +430,7 @@ export class ScriptService { compiledResourceUpdatePromise, this.resourceService.updateResourceByTypes(script, ["require", "require-css", "resource"]), ]); - // 如果资源不完整,还是要接受安装吗??? + // 资源下载失败不阻止安装,失败不影响安装 // 广播一下 // Runtime 会负责更新 CompiledResource diff --git a/src/pkg/utils/concurrency-control.test.ts b/src/pkg/utils/concurrency-control.test.ts new file mode 100644 index 000000000..c9caf5df0 --- /dev/null +++ b/src/pkg/utils/concurrency-control.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it, vi } from "vitest"; +import { Semaphore, withTimeoutNotify } from "./concurrency-control"; + +describe("Semaphore", () => { + it.concurrent("limit 小于 1 时抛出错误", () => { + expect(() => new Semaphore(0)).toThrow("limit must be >= 1"); + expect(() => new Semaphore(-1)).toThrow("limit must be >= 1"); + }); + + it.concurrent("未达到限制时 acquire 立即返回", async () => { + const sem = new Semaphore(2); + await sem.acquire(); + await sem.acquire(); + // 两次 acquire 都不应阻塞 + sem.release(); + sem.release(); + }); + + it.concurrent("达到限制时 acquire 阻塞,release 后恢复", async () => { + const sem = new Semaphore(1); + const order: number[] = []; + + await sem.acquire(); + order.push(1); + + const blocked = sem.acquire().then(() => { + order.push(3); + }); + + // 等一个 microtask,确认 blocked 还没执行 + await Promise.resolve(); + order.push(2); + + sem.release(); + await blocked; + + expect(order).toEqual([1, 2, 3]); + sem.release(); + }); + + it.concurrent("并发数不超过限制", async () => { + const limit = 3; + const sem = new Semaphore(limit); + let concurrent = 0; + let maxConcurrent = 0; + + const task = async () => { + await sem.acquire(); + concurrent++; + maxConcurrent = Math.max(maxConcurrent, concurrent); + // 模拟异步操作 + await new Promise((r) => setTimeout(r, 10)); + concurrent--; + sem.release(); + }; + + await Promise.all(Array.from({ length: 10 }, () => task())); + + expect(maxConcurrent).toBe(limit); + }); + + it.concurrent("按 FIFO 顺序唤醒等待者", async () => { + const sem = new Semaphore(1); + const order: number[] = []; + + await sem.acquire(); + + const p1 = sem.acquire().then(() => { + order.push(1); + sem.release(); + }); + const p2 = sem.acquire().then(() => { + order.push(2); + sem.release(); + }); + const p3 = sem.acquire().then(() => { + order.push(3); + sem.release(); + }); + + sem.release(); + await Promise.all([p1, p2, p3]); + + expect(order).toEqual([1, 2, 3]); + }); + + it.concurrent("double release 输出警告", () => { + const sem = new Semaphore(1); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + sem.release(); + expect(warnSpy).toHaveBeenCalledWith("Semaphore double release detected"); + + warnSpy.mockRestore(); + }); +}); + +describe("withTimeoutNotify", () => { + it.concurrent("promise 在超时前完成时,回调收到 done=true", async () => { + const promise = Promise.resolve("ok"); + const calls: Array<{ done: boolean; timeouted: boolean }> = []; + + const res = await withTimeoutNotify(promise, 1000, (r) => { + calls.push({ done: r.done, timeouted: r.timeouted }); + }); + + expect(res.result).toBe("ok"); + expect(res.done).toBe(true); + expect(res.timeouted).toBe(false); + expect(res.err).toBeUndefined(); + // 只调用一次(done),不触发 timeout + expect(calls).toEqual([{ done: true, timeouted: false }]); + }); + + it.concurrent("promise 在超时前失败时,回调收到 err", async () => { + const error = new Error("fail"); + const promise = Promise.reject(error); + const calls: Array<{ done: boolean; err: Error | undefined }> = []; + + const res = await withTimeoutNotify(promise, 1000, (r) => { + calls.push({ done: r.done, err: r.err }); + }); + + expect(res.err).toBe(error); + expect(res.done).toBe(true); + expect(res.result).toBeUndefined(); + expect(calls).toEqual([{ done: true, err: error }]); + }); + + it.concurrent("超时后回调被调用,promise 完成后再次调用", async () => { + vi.useFakeTimers(); + let resolvePromise: (v: string) => void; + const promise = new Promise((r) => { + resolvePromise = r; + }); + const calls: Array<{ done: boolean; timeouted: boolean }> = []; + + const resultPromise = withTimeoutNotify(promise, 100, (r) => { + calls.push({ done: r.done, timeouted: r.timeouted }); + }); + + // 触发超时 + vi.advanceTimersByTime(100); + expect(calls).toEqual([{ done: false, timeouted: true }]); + + // promise 完成 + resolvePromise!("late"); + const res = await resultPromise; + + expect(res.result).toBe("late"); + expect(res.done).toBe(true); + expect(res.timeouted).toBe(true); + // 回调被调用两次:timeout + done + expect(calls).toHaveLength(2); + expect(calls[1]).toEqual({ done: true, timeouted: true }); + + vi.useRealTimers(); + }); + + it.concurrent("超时后 promise 失败,回调也被调用两次", async () => { + vi.useFakeTimers(); + let rejectPromise: (e: Error) => void; + const promise = new Promise((_, reject) => { + rejectPromise = reject; + }); + const calls: Array<{ timeouted: boolean; err: Error | undefined }> = []; + + const resultPromise = withTimeoutNotify(promise, 50, (r) => { + calls.push({ timeouted: r.timeouted, err: r.err }); + }); + + vi.advanceTimersByTime(50); + expect(calls).toHaveLength(1); + + const error = new Error("network error"); + rejectPromise!(error); + const res = await resultPromise; + + expect(res.err).toBe(error); + expect(calls).toHaveLength(2); + expect(calls[1]).toEqual({ timeouted: true, err: error }); + + vi.useRealTimers(); + }); +}); From 1fa3981b77828ffa363ccdd324e605e04d22fed8 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 30 Mar 2026 07:06:44 +0900 Subject: [PATCH 31/36] =?UTF-8?q?=E6=94=B9=E5=9B=9E=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/runtime.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index f99b96313..ae20119a3 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -1196,7 +1196,7 @@ export class RuntimeService { if (!enableScriptList.length) return null; const scriptCodes = {} as Record; - // 更新资源使用了file协议的脚本 ( 不能在其他地方更新吗?? 见 Issue #918 ) + // 更新资源使用了file协议的脚本 const scriptsWithUpdatedResources = new Map(); for (const scriptRes of enableScriptList) { const uuid = scriptRes.uuid; From 39b2e4a87cadd652379c56773ee0d707e6903ba8 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:22:10 +0900 Subject: [PATCH 32/36] =?UTF-8?q?=E6=8C=89AI=E6=8C=87=E7=A4=BA=E8=AF=AD?= =?UTF-8?q?=E6=84=8F=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/resource.ts | 83 ++++++++++++++-------- 1 file changed, 55 insertions(+), 28 deletions(-) diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index ee9737299..a7aaafae1 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -16,17 +16,40 @@ import { blobToUint8Array } from "@App/pkg/utils/datatype"; import { readBlobContent } from "@App/pkg/utils/encoding"; import { Semaphore, withTimeoutNotify } from "@App/pkg/utils/concurrency-control"; -/** 同时发起的最大 fetch 数量,避免大量请求冲击同一服务器 */ -const MAX_CONCURRENT_FETCHES = 5; -/** fetch 前的随机延迟范围(ms),分散请求时间 */ -const FETCH_DELAY_MIN_MS = 100; -const FETCH_DELAY_MAX_MS = 150; -/** fetch 超时后释放信号量的时间(ms),不会中止 fetch 本身 */ -const FETCH_SEMAPHORE_TIMEOUT_MS = 800; +/** + * 滑动窗口并发上限:同时"已启动、尚未归还槽位"的 fetch 数量。 + * 超过此数量的请求会排队等待槽位释放后再启动, + * 避免瞬间大量请求冲击同一 server(被误判为 DDoS)。 + */ +const MAX_ACTIVE_FETCHES = 5; + +/** fetch 启动前的随机抖动范围(ms),分散对同一 server 的请求时间 */ +const FETCH_JITTER_MIN_MS = 100; +const FETCH_JITTER_MAX_MS = 150; + +/** + * 滑动窗口超时(ms): + * fetch 启动后若超过此时间仍无响应,提前归还并发槽位, + * 允许队列中的下一个请求启动——但原 fetch 继续运行, + * 响应回来后仍会被正常处理。 + * + * 这是"槽位滑动"而非"取消请求": + * - 慢响应不会阻塞后续请求的启动(需求 3) + * - 慢响应最终到达时仍会被处理(需求 4) + * - 同时活跃的 fetch 数受 MAX_ACTIVE_FETCHES 控制(需求 1 & 2) + */ +const FETCH_SLOT_SLIDE_TIMEOUT_MS = 800; + /** 资源缓存过期时间(ms),24小时 */ -const RESOURCE_CACHE_TTL_MS = 86400_000; +const RESOURCE_CACHE_TTL_MS = 86_400_000; -const fetchSemaphore = new Semaphore(MAX_CONCURRENT_FETCHES); +/** + * 滑动窗口并发控制器(Sliding Window Semaphore)。 + * 持有槽位 = "已启动 fetch 且尚未超时或完成"。 + * 超时后槽位提前归还,让下一个 fetch 可以启动, + * 而超时的 fetch 本身继续跑直到响应或网络错误。 + */ +const concurrentFetchSlots = new Semaphore(MAX_ACTIVE_FETCHES); export class ResourceService { logger: Logger; @@ -274,26 +297,30 @@ export class ResourceService { async createResourceByUrlFetch(u: TUrlSRIInfo, type: ResourceType): Promise { const url = u.url; // 无 URI Integrity Hash - let released = false; - await fetchSemaphore.acquire(); - // Semaphore 锁 - 同期只有五个 fetch 一起执行 - const delay = randNum(FETCH_DELAY_MIN_MS, FETCH_DELAY_MAX_MS); - await sleep(delay); - // 执行 fetch, 若超时则不中止 fetch 但释放信号量,让下一个任务启动 - const { result, err } = await withTimeoutNotify( - fetch(url), - FETCH_SEMAPHORE_TIMEOUT_MS, - ({ done, timeouted, err }) => { - if (timeouted || done || err) { - // fetch 成功 或 发生错误 或 timeout 时解锁 - if (!released) { - released = true; - fetchSemaphore.release(); - } - } + // 等待并发槽位(滑动窗口入口) + await concurrentFetchSlots.acquire(); + + // releaseSlotOnce 保证槽位只归还一次,无论经由 timeout 路径还是正常完成路径 + let slotReleased = false; + const releaseSlotOnce = () => { + if (!slotReleased) { + slotReleased = true; + concurrentFetchSlots.release(); } - ); - // Semaphore 锁已解锁。继续处理 fetch Response 的结果 + }; + + // 随机抖动:分散对同一 server 的请求启动时间,降低被限速的概率 + await sleep(randNum(FETCH_JITTER_MIN_MS, FETCH_JITTER_MAX_MS)); + + // 滑动窗口语义: + // - fetch 超时 (timeouted=true) → 提前归还槽位,下一个请求可以启动 + // - fetch 完成/失败 (done=true) → 归还槽位(若 timeout 已归还则为 no-op) + // 原 fetch 在超时后仍继续运行,响应到达时照常处理(不会被取消) + const { result, err } = await withTimeoutNotify(fetch(url), FETCH_SLOT_SLIDE_TIMEOUT_MS, ({ done, timeouted }) => { + if (timeouted || done) { + releaseSlotOnce(); + } + }); if (err) { throw new Error(`resource fetch failed: ${err.message || err}`); From 5b81d891dfb9d675a3fed6151272bfe4b1f478e8 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:29:54 +0900 Subject: [PATCH 33/36] =?UTF-8?q?=E6=A0=B9=E6=8D=AEAI=E6=84=8F=E8=A7=81?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/resource.ts | 20 +++++++++------ src/pkg/utils/concurrency-control.test.ts | 30 +++++++++++----------- src/pkg/utils/concurrency-control.ts | 6 ++--- 3 files changed, 30 insertions(+), 26 deletions(-) diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index a7aaafae1..7977c7d33 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -297,6 +297,9 @@ export class ResourceService { async createResourceByUrlFetch(u: TUrlSRIInfo, type: ResourceType): Promise { const url = u.url; // 无 URI Integrity Hash + // 随机抖动:分散对同一 server 的请求启动时间,降低被限速的概率 + await sleep(randNum(FETCH_JITTER_MIN_MS, FETCH_JITTER_MAX_MS)); + // 等待并发槽位(滑动窗口入口) await concurrentFetchSlots.acquire(); @@ -309,18 +312,19 @@ export class ResourceService { } }; - // 随机抖动:分散对同一 server 的请求启动时间,降低被限速的概率 - await sleep(randNum(FETCH_JITTER_MIN_MS, FETCH_JITTER_MAX_MS)); - // 滑动窗口语义: // - fetch 超时 (timeouted=true) → 提前归还槽位,下一个请求可以启动 - // - fetch 完成/失败 (done=true) → 归还槽位(若 timeout 已归还则为 no-op) + // - fetch 完成/失败 (settled=true) → 归还槽位(若 timeout 已归还则为 no-op) // 原 fetch 在超时后仍继续运行,响应到达时照常处理(不会被取消) - const { result, err } = await withTimeoutNotify(fetch(url), FETCH_SLOT_SLIDE_TIMEOUT_MS, ({ done, timeouted }) => { - if (timeouted || done) { - releaseSlotOnce(); + const { result, err } = await withTimeoutNotify( + fetch(url), + FETCH_SLOT_SLIDE_TIMEOUT_MS, + ({ settled, timeouted }) => { + if (timeouted || settled) { + releaseSlotOnce(); + } } - }); + ); if (err) { throw new Error(`resource fetch failed: ${err.message || err}`); diff --git a/src/pkg/utils/concurrency-control.test.ts b/src/pkg/utils/concurrency-control.test.ts index c9caf5df0..286a26a0d 100644 --- a/src/pkg/utils/concurrency-control.test.ts +++ b/src/pkg/utils/concurrency-control.test.ts @@ -96,35 +96,35 @@ describe("Semaphore", () => { }); describe("withTimeoutNotify", () => { - it.concurrent("promise 在超时前完成时,回调收到 done=true", async () => { + it.concurrent("promise 在超时前完成时,回调收到 settled=true", async () => { const promise = Promise.resolve("ok"); - const calls: Array<{ done: boolean; timeouted: boolean }> = []; + const calls: Array<{ settled: boolean; timeouted: boolean }> = []; const res = await withTimeoutNotify(promise, 1000, (r) => { - calls.push({ done: r.done, timeouted: r.timeouted }); + calls.push({ settled: r.settled, timeouted: r.timeouted }); }); expect(res.result).toBe("ok"); - expect(res.done).toBe(true); + expect(res.settled).toBe(true); expect(res.timeouted).toBe(false); expect(res.err).toBeUndefined(); // 只调用一次(done),不触发 timeout - expect(calls).toEqual([{ done: true, timeouted: false }]); + expect(calls).toEqual([{ settled: true, timeouted: false }]); }); it.concurrent("promise 在超时前失败时,回调收到 err", async () => { const error = new Error("fail"); const promise = Promise.reject(error); - const calls: Array<{ done: boolean; err: Error | undefined }> = []; + const calls: Array<{ settled: boolean; err: Error | undefined }> = []; const res = await withTimeoutNotify(promise, 1000, (r) => { - calls.push({ done: r.done, err: r.err }); + calls.push({ settled: r.settled, err: r.err }); }); expect(res.err).toBe(error); - expect(res.done).toBe(true); + expect(res.settled).toBe(true); expect(res.result).toBeUndefined(); - expect(calls).toEqual([{ done: true, err: error }]); + expect(calls).toEqual([{ settled: true, err: error }]); }); it.concurrent("超时后回调被调用,promise 完成后再次调用", async () => { @@ -133,26 +133,26 @@ describe("withTimeoutNotify", () => { const promise = new Promise((r) => { resolvePromise = r; }); - const calls: Array<{ done: boolean; timeouted: boolean }> = []; + const calls: Array<{ settled: boolean; timeouted: boolean }> = []; const resultPromise = withTimeoutNotify(promise, 100, (r) => { - calls.push({ done: r.done, timeouted: r.timeouted }); + calls.push({ settled: r.settled, timeouted: r.timeouted }); }); // 触发超时 vi.advanceTimersByTime(100); - expect(calls).toEqual([{ done: false, timeouted: true }]); + expect(calls).toEqual([{ settled: false, timeouted: true }]); // promise 完成 resolvePromise!("late"); const res = await resultPromise; expect(res.result).toBe("late"); - expect(res.done).toBe(true); + expect(res.settled).toBe(true); expect(res.timeouted).toBe(true); - // 回调被调用两次:timeout + done + // 回调被调用两次:timeout + settled expect(calls).toHaveLength(2); - expect(calls[1]).toEqual({ done: true, timeouted: true }); + expect(calls[1]).toEqual({ settled: true, timeouted: true }); vi.useRealTimers(); }); diff --git a/src/pkg/utils/concurrency-control.ts b/src/pkg/utils/concurrency-control.ts index f796e7034..f2abac06e 100644 --- a/src/pkg/utils/concurrency-control.ts +++ b/src/pkg/utils/concurrency-control.ts @@ -26,7 +26,7 @@ export class Semaphore { type TWithTimeoutNotifyResult = { timeouted: boolean; result: T | undefined; - done: boolean; + settled: boolean; err: undefined | Error; }; export const withTimeoutNotify = ( @@ -43,14 +43,14 @@ export const withTimeoutNotify = ( .then((result: T) => { clearTimeout(cid); res.result = result; - res.done = true; + res.settled = true; fn(res); return res; }) .catch((e) => { clearTimeout(cid); res.err = e; - res.done = true; + res.settled = true; fn(res); return res; }); From 147e33a2898d1f451d27f7615dee84919a2fd460 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:31:43 +0900 Subject: [PATCH 34/36] fix --- src/pkg/utils/concurrency-control.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pkg/utils/concurrency-control.ts b/src/pkg/utils/concurrency-control.ts index f2abac06e..256eec952 100644 --- a/src/pkg/utils/concurrency-control.ts +++ b/src/pkg/utils/concurrency-control.ts @@ -34,7 +34,7 @@ export const withTimeoutNotify = ( time: number, fn: (res: TWithTimeoutNotifyResult) => any ) => { - const res: TWithTimeoutNotifyResult = { timeouted: false, result: undefined, done: false, err: undefined }; + const res: TWithTimeoutNotifyResult = { timeouted: false, result: undefined, settled: false, err: undefined }; const cid = setTimeout(() => { res.timeouted = true; fn(res); From 6c96ef7608008d7a6f8f2318366a8128b303d5f7 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:41:17 +0900 Subject: [PATCH 35/36] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20Semaphore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pkg/utils/concurrency-control.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pkg/utils/concurrency-control.ts b/src/pkg/utils/concurrency-control.ts index 256eec952..8f9d6822e 100644 --- a/src/pkg/utils/concurrency-control.ts +++ b/src/pkg/utils/concurrency-control.ts @@ -44,13 +44,13 @@ export const withTimeoutNotify = ( clearTimeout(cid); res.result = result; res.settled = true; - fn(res); - return res; }) .catch((e) => { clearTimeout(cid); res.err = e; res.settled = true; + }) + .then(() => { fn(res); return res; }); From a69f1ec62f01a1f2d1b523d8076952ce4ef8567a Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:44:30 +0900 Subject: [PATCH 36/36] fix unit test --- src/app/service/service_worker/utils.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/service/service_worker/utils.test.ts b/src/app/service/service_worker/utils.test.ts index 13fa27f27..7794dfd87 100644 --- a/src/app/service/service_worker/utils.test.ts +++ b/src/app/service/service_worker/utils.test.ts @@ -63,10 +63,10 @@ describe.concurrent("parseUrlSRI", () => { expect(result.hash).toBeUndefined(); }); it.concurrent("不规则的SRI", () => { - const url = "https://example.com/script.js#sha256"; + const url = "https://example.com/script.js#sha256"; // 格式错误不视为哈希值 const result = parseUrlSRI(url); expect(result.url).toEqual("https://example.com/script.js"); - expect(result.hash).toEqual({}); + expect(result.hash).toBeUndefined(); const url2 = "https://example.com/script.js#sha256=abc123==,md5"; const result2 = parseUrlSRI(url2); expect(result2.url).toEqual("https://example.com/script.js");