@dsh-plus/web-shell-sw 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 agguy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/lib/client.js ADDED
@@ -0,0 +1,89 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "@dsh-plus/web-shell-sw",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ //#region src/decision.ts
8
+ /**
9
+ * 决定浏览器半启动动作。
10
+ * @param config - 注入的配置行(缺席 = 插件未装配,零行为)。
11
+ * @param env - 运行环境(安全上下文且支持 serviceWorker 才可操作)。
12
+ */
13
+ function decideClientAction(config, env) {
14
+ if (config === void 0) return "skip";
15
+ if (!env.isSecureContext || !env.hasServiceWorker) return "skip";
16
+ return config.enabled === true ? "register" : "cleanup";
17
+ }
18
+ //#endregion
19
+ //#region src/ns.ts
20
+ /** index-inject `kind:'global'` 注入的配置对象键。 */
21
+ const SW_GLOBAL_KEY = "__DSH_PLUS_WEB_SHELL_SW__";
22
+ /** sw.js 精确路由(注册与更新检查都打这里)。 */
23
+ const SW_SCRIPT_PATH = "/dsh-plus/shell-sw.js";
24
+ //#endregion
25
+ //#region src/client.ts
26
+ const name = "dsh-plus-web-shell-sw";
27
+ /** 收窄注入的 globalThis 值(形状不符一律视为缺席)。 */
28
+ function readConfig() {
29
+ const value = Reflect.get(globalThis, SW_GLOBAL_KEY);
30
+ if (typeof value !== "object" || value === null) return void 0;
31
+ return value;
32
+ }
33
+ /** 注册(幂等;失败仅告警——SW 缺席不影响页面任何功能)。 */
34
+ function registerSw() {
35
+ navigator.serviceWorker.register(SW_SCRIPT_PATH, { scope: "/" }).then(() => console.debug("[web-shell-sw] SW registered"), (error) => console.warn("[web-shell-sw] SW 注册失败(页面功能不受影响)", error));
36
+ }
37
+ /** 该注册是否属于本插件(三态 worker 任一匹配即可;scriptURL 在 worker 上)。 */
38
+ function isOurRegistration(registration) {
39
+ return [
40
+ registration.active,
41
+ registration.waiting,
42
+ registration.installing
43
+ ].some((worker) => worker?.scriptURL.endsWith(SW_SCRIPT_PATH) === true);
44
+ }
45
+ /** 禁用清理:注销本插件的 SW 注册 + 删除本插件前缀的全部缓存。 */
46
+ function cleanupSw() {
47
+ const run = async () => {
48
+ try {
49
+ const registrations = await navigator.serviceWorker.getRegistrations();
50
+ for (const registration of registrations) if (isOurRegistration(registration)) await registration.unregister();
51
+ const keys = await caches.keys();
52
+ for (const key of keys) if (key.startsWith("dsh-shell-")) await caches.delete(key);
53
+ console.info("[web-shell-sw] 已注销 SW 并清理缓存(恢复原生网络行为)");
54
+ } catch (error) {
55
+ console.warn("[web-shell-sw] 禁用清理失败(下次加载会重试)", error);
56
+ }
57
+ };
58
+ run();
59
+ }
60
+ function apply(ctx) {
61
+ const action = decideClientAction(readConfig(), {
62
+ isSecureContext: window.isSecureContext,
63
+ hasServiceWorker: "serviceWorker" in navigator
64
+ });
65
+ if (action === "skip") return;
66
+ const run = () => {
67
+ if (action === "register") registerSw();
68
+ else cleanupSw();
69
+ };
70
+ let timer;
71
+ let disposed = false;
72
+ const start = () => {
73
+ if (disposed) return;
74
+ timer = window.setTimeout(run, 0);
75
+ };
76
+ if (document.readyState === "complete") start();
77
+ else window.addEventListener("load", start, { once: true });
78
+ ctx.effect(() => () => {
79
+ disposed = true;
80
+ if (timer !== void 0) window.clearTimeout(timer);
81
+ window.removeEventListener("load", start);
82
+ }, "web-shell-sw: cancel pending register/cleanup");
83
+ }
84
+ //#endregion
85
+ exports.apply = apply;
86
+ exports.name = name;
87
+ return module.exports;
88
+ }
89
+ });
package/lib/index.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { UnwrapVolatile } from "@dsh-plus/shared";
2
+ import z from "@deepseek-ai/schemastery";
3
+ import { Context } from "@deepseek-ai/cordis";
4
+ //#region src/config.d.ts
5
+ declare const Config: z<Schemastery.ObjectS<NoInfer<{
6
+ enabled: z<boolean, boolean, "volatile-defined">;
7
+ }>>, Schemastery.ObjectT<NoInfer<{
8
+ enabled: z<boolean, boolean, "volatile-defined">;
9
+ }>>, "plain">;
10
+ /** 活动字段形态(0.1.7 loader 解析产物:volatile 字段为活动引用)。 */
11
+ type WebShellSwConfigFields = Schemastery.TypeT<typeof Config>;
12
+ /** 平面配置形态(消费面的读取形态,由活动引用解包得到)。 */
13
+ type WebShellSwConfig = UnwrapVolatile<WebShellSwConfigFields>;
14
+ //#endregion
15
+ //#region src/ns.d.ts
16
+ /**
17
+ * settings 命名空间 + 路由/缓存/全局键常量(纯常量,零依赖)。
18
+ * node 半(路由服务、注入行)与浏览器半(注册/注销、清理)共享同一来源,
19
+ * 避免两侧字面量漂移。
20
+ * @module @dsh-plus/web-shell-sw/ns
21
+ */
22
+ declare const SETTINGS_NS = "dsh-plus-web-shell-sw";
23
+ //#endregion
24
+ //#region src/index.d.ts
25
+ declare const name = "dsh-plus-web-shell-sw";
26
+ /**
27
+ * 装配路由与配置注入。配置行恒推(含 enabled=false——禁用需要浏览器半
28
+ * 执行注销,「行缺席」会使其无法区分禁用与未安装);路由恒注册(禁用时
29
+ * 无人注册新 SW,已注册的由配置行驱动注销,无需路由参与)。
30
+ * @param ctx - 宿主上下文(webServer 可选)。
31
+ * @param config - 活动字段形态的行级 config(测试可传平面值)。
32
+ */
33
+ declare function apply(ctx: Context, config: WebShellSwConfig | WebShellSwConfigFields): void;
34
+ /** 供测试断言 inject 形态:本插件不声明硬依赖。 */
35
+ declare const inject: readonly [];
36
+ //#endregion
37
+ export { Config, SETTINGS_NS, type WebShellSwConfig, apply, inject, name };
package/lib/index.js ADDED
@@ -0,0 +1,236 @@
1
+ import { unwrapVolatile } from "@dsh-plus/shared";
2
+ import z from "@deepseek-ai/schemastery";
3
+ //#region src/ns.ts
4
+ /**
5
+ * settings 命名空间 + 路由/缓存/全局键常量(纯常量,零依赖)。
6
+ * node 半(路由服务、注入行)与浏览器半(注册/注销、清理)共享同一来源,
7
+ * 避免两侧字面量漂移。
8
+ * @module @dsh-plus/web-shell-sw/ns
9
+ */
10
+ const SETTINGS_NS = "dsh-plus-web-shell-sw";
11
+ /** index-inject `kind:'global'` 注入的配置对象键。 */
12
+ const SW_GLOBAL_KEY = "__DSH_PLUS_WEB_SHELL_SW__";
13
+ /** sw.js 精确路由(注册与更新检查都打这里)。 */
14
+ const SW_SCRIPT_PATH = "/dsh-plus/shell-sw.js";
15
+ /**
16
+ * Cache Storage 缓存名(同时是版本号)。
17
+ * SW 脚本逻辑变更时手动 +1:activate 阶段删除前缀相同、名字不同的旧缓存。
18
+ */
19
+ const SW_CACHE_NAME = "dsh-shell-v1";
20
+ /** 缓存名前缀(禁用清理与版本升级都按它扫全量)。 */
21
+ const SW_CACHE_PREFIX = "dsh-shell-";
22
+ //#endregion
23
+ //#region src/decision.ts
24
+ /**
25
+ * 决定一个 fetch 事件的处置(自包含纯函数,会被 toString 内嵌进 sw.js)。
26
+ * @param method - HTTP 方法(大写)。
27
+ * @param pathname - 已剥查询串的路径。
28
+ * @param hasSearch - URL 是否带查询串(token 交换 `/?token=` 必须原生直通)。
29
+ * @param hasRange - 请求是否带 Range 头(分段请求不走整包缓存)。
30
+ */
31
+ function decideFetchAction(method, pathname, hasSearch, hasRange) {
32
+ if (method !== "GET") return "passthrough";
33
+ if (hasRange) return "passthrough";
34
+ if (pathname === "/" || pathname === "/index.html") return hasSearch ? "passthrough" : "network-first-shell";
35
+ if (pathname === "/plugins/events") return "passthrough";
36
+ if (pathname.startsWith("/assets/")) return "cache-first";
37
+ if (pathname.startsWith("/plugins/")) return "cache-first";
38
+ return "passthrough";
39
+ }
40
+ //#endregion
41
+ //#region src/sw-script.ts
42
+ /**
43
+ * sw.js 脚本生成:把安全决策(decision.ts 自包含纯函数)与缓存策略
44
+ * 渲染成一份独立的 Service Worker 脚本,由 node 半的精确路由对外服务。
45
+ *
46
+ * 设计要点(全部保守取向,宁可少缓存不可错缓存):
47
+ * - 不 skipWaiting、不 clients.claim:新版本等所有标签页关闭后才接管,
48
+ * 绝不在会话中途换实现;首次注册只从下一次导航开始接管。
49
+ * - passthrough 一大类(RPC、SSE、token 交换、跨源、Range、非 GET)
50
+ * 不调 respondWith = 浏览器原生行为,零干预。
51
+ * - 只有 status 200、type basic、且 cache-control 不含 no-store/no-cache
52
+ * 的响应才入库——token 页(no-store)与代理侧 no-store 的 index 天然
53
+ * 免疫,认证内容永不落 Cache Storage。
54
+ * - 入库前剥离 content-encoding/content-length/transfer-encoding/vary:
55
+ * fetch() 暴露的 body 已解码,复制编码头会造成「解码头 + 原始体」错配
56
+ * (经典 SW 缓存坑);存 identity 体,下次命中由 SW 直接原样返回。
57
+ * - 缓存名带版本(ns.ts 的 SW_CACHE_NAME),activate 清理同前缀旧版本;
58
+ * 源码逻辑变更时手动 +1 即可全量换血。
59
+ *
60
+ * 安全边界:`decideFetchAction` 无闭包依赖,toString() 内嵌后行为与
61
+ * node 侧单测完全一致(tests/sw-script.test.ts 同时断言编译与内嵌等价)。
62
+ * @module @dsh-plus/web-shell-sw/sw-script
63
+ */
64
+ /**
65
+ * 生成完整的 sw.js 脚本文本(纯函数,同配置恒等输出)。
66
+ * @returns 可直接作为 application/javascript 响应体的脚本源码。
67
+ */
68
+ function buildServiceWorkerScript() {
69
+ return `'use strict';
70
+ const CACHE_NAME = ${JSON.stringify(SW_CACHE_NAME)};
71
+ const CACHE_PREFIX = ${JSON.stringify(SW_CACHE_PREFIX)};
72
+ const decideFetch = ${decideFetchAction.toString()};
73
+
74
+ self.addEventListener('install', function () {
75
+ // 不 skipWaiting:新版本等待所有标签页关闭后接管,避免会话中途换实现。
76
+ });
77
+
78
+ self.addEventListener('activate', function (event) {
79
+ event.waitUntil(
80
+ caches.keys().then(function (keys) {
81
+ return Promise.all(
82
+ keys
83
+ .filter(function (key) { return key.indexOf(CACHE_PREFIX) === 0 && key !== CACHE_NAME; })
84
+ .map(function (key) { return caches.delete(key); })
85
+ );
86
+ })
87
+ );
88
+ });
89
+
90
+ self.addEventListener('fetch', function (event) {
91
+ const request = event.request;
92
+ let url;
93
+ try {
94
+ url = new URL(request.url);
95
+ } catch (error) {
96
+ return;
97
+ }
98
+ if (url.origin !== self.location.origin) return;
99
+ const action = decideFetch(
100
+ request.method,
101
+ url.pathname,
102
+ url.search !== '',
103
+ request.headers.has('range')
104
+ );
105
+ if (action === 'passthrough') return;
106
+ event.respondWith(
107
+ action === 'cache-first' ? cacheFirst(request) : shellNetworkFirst(request)
108
+ );
109
+ });
110
+
111
+ /** 剥离编码相关头后再入库:fetch 的 body 已解码,保留编码头会造成解码错配。 */
112
+ function normalizeForCache(response) {
113
+ const headers = new Headers(response.headers);
114
+ ['content-encoding', 'content-length', 'transfer-encoding', 'vary'].forEach(function (name) {
115
+ headers.delete(name);
116
+ });
117
+ return response.arrayBuffer().then(function (body) {
118
+ return new Response(body, { status: response.status, statusText: response.statusText, headers: headers });
119
+ });
120
+ }
121
+
122
+ /** 仅 200/basic/未标 no-store 且未标 no-cache 的响应可入库。 */
123
+ function isStorable(response) {
124
+ if (response.status !== 200 || response.type !== 'basic') return false;
125
+ const cacheControl = (response.headers.get('cache-control') || '').toLowerCase();
126
+ return cacheControl.indexOf('no-store') === -1 && cacheControl.indexOf('no-cache') === -1;
127
+ }
128
+
129
+ function putNormalized(cache, request, response) {
130
+ return normalizeForCache(response).then(
131
+ function (stored) { return cache.put(request, stored); },
132
+ function (error) { console.warn('[web-shell-sw] cache.put failed', error); }
133
+ );
134
+ }
135
+
136
+ /** 内容寻址资源:命中即回;未命中回源,可存则存(存失败不影响本次响应)。 */
137
+ function cacheFirst(request) {
138
+ return caches.open(CACHE_NAME).then(function (cache) {
139
+ return cache.match(request).then(function (hit) {
140
+ if (hit !== undefined) return hit;
141
+ return fetch(request).then(function (response) {
142
+ if (isStorable(response)) putNormalized(cache, request, response.clone());
143
+ return response;
144
+ });
145
+ });
146
+ });
147
+ }
148
+
149
+ /** index:网络优先(在线恒用最新外壳);仅网络失败时才用缓存兜底,无缓存则原生失败。 */
150
+ function shellNetworkFirst(request) {
151
+ return caches.open(CACHE_NAME).then(function (cache) {
152
+ return fetch(request).then(
153
+ function (response) {
154
+ if (isStorable(response)) putNormalized(cache, request, response.clone());
155
+ return response;
156
+ },
157
+ function (error) {
158
+ return cache.match(request).then(function (hit) {
159
+ if (hit !== undefined) return hit;
160
+ throw error;
161
+ });
162
+ }
163
+ );
164
+ });
165
+ }
166
+ `;
167
+ }
168
+ //#endregion
169
+ //#region src/route.ts
170
+ /**
171
+ * 精确路由 handler(webserver.register kind:'exact')。
172
+ * @param req - 入站请求。
173
+ * @param res - 出站响应(本 handler 拥有完整生命周期)。
174
+ */
175
+ function serveServiceWorker(req, res) {
176
+ if (req.method !== "GET" && req.method !== "HEAD") {
177
+ res.writeHead(405, {
178
+ "content-type": "text/plain; charset=utf-8",
179
+ allow: "GET, HEAD"
180
+ });
181
+ res.end("method not allowed");
182
+ return;
183
+ }
184
+ const body = buildServiceWorkerScript();
185
+ res.writeHead(200, {
186
+ "content-type": "text/javascript; charset=utf-8",
187
+ "cache-control": "no-cache",
188
+ "service-worker-allowed": "/",
189
+ "content-length": String(Buffer.byteLength(body))
190
+ });
191
+ res.end(body);
192
+ }
193
+ //#endregion
194
+ //#region src/config.ts
195
+ /**
196
+ * 插件配置(schemastery):可调参数一律外部化,不在逻辑里硬编码。
197
+ *
198
+ * 与仓库既有约定一致:Config 由 cordis 行级 config 解析,部署方可在
199
+ * profile 的 cordis.patch.yml 覆盖;同时经 settings namespace 暴露到
200
+ * $DSH_HOME/settings.yaml 用户层,修改热生效(无需 /reload)。
201
+ * @module @dsh-plus/web-shell-sw/config
202
+ */
203
+ const Config = z.object({ enabled: z.boolean().description("总开关(false = 浏览器半注销已注册的 Service Worker 并清空本插件缓存,恢复原生网络行为)").default(true).volatile() });
204
+ //#endregion
205
+ //#region src/index.ts
206
+ const name = "dsh-plus-web-shell-sw";
207
+ /**
208
+ * 装配路由与配置注入。配置行恒推(含 enabled=false——禁用需要浏览器半
209
+ * 执行注销,「行缺席」会使其无法区分禁用与未安装);路由恒注册(禁用时
210
+ * 无人注册新 SW,已注册的由配置行驱动注销,无需路由参与)。
211
+ * @param ctx - 宿主上下文(webServer 可选)。
212
+ * @param config - 活动字段形态的行级 config(测试可传平面值)。
213
+ */
214
+ function apply(ctx, config) {
215
+ const logger = ctx.logger("web-shell-sw");
216
+ const current = () => unwrapVolatile(config);
217
+ ctx.inject(["webServer"], (webCtx) => {
218
+ webCtx.effect(() => webCtx.on("webserver/index-inject", (table) => {
219
+ table.push({
220
+ kind: "global",
221
+ name: SW_GLOBAL_KEY,
222
+ value: { enabled: current().enabled }
223
+ });
224
+ }), "web-shell-sw: index injection");
225
+ webCtx.effect(() => webCtx.webServer.register({
226
+ kind: "exact",
227
+ path: SW_SCRIPT_PATH,
228
+ handler: serveServiceWorker
229
+ }), "web-shell-sw: sw.js route");
230
+ logger.info(`installed (script route ${SW_SCRIPT_PATH})`);
231
+ });
232
+ }
233
+ /** 供测试断言 inject 形态:本插件不声明硬依赖。 */
234
+ const inject = [];
235
+ //#endregion
236
+ export { Config, SETTINGS_NS, apply, inject, name };
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@dsh-plus/web-shell-sw",
3
+ "version": "0.1.0",
4
+ "description": "dsh-plus ui plugin: 外壳 Service Worker——内容寻址静态资源 cache-first、index network-first 离线兜底;绝不触碰 RPC/SSE/网关路由,token 页(no-store)永不入缓存,settings 一键注销",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ },
13
+ "./client": {
14
+ "default": "./lib/client.js"
15
+ },
16
+ "./src/*": "./src/*",
17
+ "./package.json": "./package.json"
18
+ },
19
+ "files": [
20
+ "lib",
21
+ "src"
22
+ ],
23
+ "dsh": {
24
+ "client": {
25
+ "inject": [],
26
+ "platform": "web"
27
+ }
28
+ },
29
+ "dependencies": {
30
+ "@dsh-plus/shared": "0.1.24"
31
+ },
32
+ "peerDependencies": {
33
+ "@deepseek-ai/cordis": "^4.0.4",
34
+ "@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.1",
35
+ "@deepseek-ai/dsh-settings": "^0.1.7-rc.1",
36
+ "@deepseek-ai/schemastery": "^3.18.4"
37
+ },
38
+ "devDependencies": {
39
+ "@deepseek-ai/cordis": "4.0.4",
40
+ "@deepseek-ai/dsh-host-webserver": "0.1.7-rc.1",
41
+ "@deepseek-ai/dsh-settings": "0.1.7-rc.1",
42
+ "@deepseek-ai/schemastery": "3.18.4"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "license": "MIT",
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/A-G-guy/dsh-plus.git",
51
+ "directory": "packages/web-shell-sw"
52
+ },
53
+ "scripts": {
54
+ "build": "tsdown"
55
+ }
56
+ }
package/src/client.ts ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * 浏览器半:Service Worker 的注册与注销(load 后执行,不与首屏抢带宽)。
3
+ *
4
+ * 启动链路:plugin apply → window load(已过则下一个宏任务)→
5
+ * decideClientAction 裁决:
6
+ * - register:幂等注册 `/dsh-plus/shell-sw.js`(scope `/`,由 node 半路由的
7
+ * Service-Worker-Allowed 头授权);新 SW 不 skipWaiting,本页不受影响,
8
+ * 从下一次导航开始接管。
9
+ * - cleanup(settings 禁用):注销 scriptURL 匹配的注册 + 删除本插件
10
+ * 前缀的全部缓存——一键恢复原生网络行为。
11
+ * - skip:无配置行(插件缺席)或非安全上下文/无 SW 支持(裸 http 局域网
12
+ * 直连)——零行为,其余插件不受影响。
13
+ *
14
+ * 所有异步失败仅带上下文 warn/debug,绝不抛入 boot 链路。ctx.effect 兜底
15
+ * 移除 load 监听与未触发的定时器(已注册的 SW 不随 HMR 注销——它是部署级
16
+ * 状态,不是会话级状态)。
17
+ * 构建产物须为 window.__ModuleLoader__.load({id, factory}) 形式
18
+ * (包装见 tsdown.config.ts 的 banner/footer)。
19
+ * @module @dsh-plus/web-shell-sw/client
20
+ */
21
+ import type { Context } from '@deepseek-ai/cordis'
22
+
23
+ import { decideClientAction } from './decision.ts'
24
+ import { SW_CACHE_PREFIX, SW_GLOBAL_KEY, SW_SCRIPT_PATH } from './ns.ts'
25
+
26
+ export const name = 'dsh-plus-web-shell-sw'
27
+
28
+ /** 注入的配置行形状(node 半保证 JSON 可序列化)。 */
29
+ interface SwGlobal {
30
+ readonly enabled?: boolean
31
+ }
32
+
33
+ /** 收窄注入的 globalThis 值(形状不符一律视为缺席)。 */
34
+ function readConfig(): SwGlobal | undefined {
35
+ const value: unknown = Reflect.get(globalThis, SW_GLOBAL_KEY)
36
+ if (typeof value !== 'object' || value === null) return undefined
37
+ return value as SwGlobal
38
+ }
39
+
40
+ /** 注册(幂等;失败仅告警——SW 缺席不影响页面任何功能)。 */
41
+ function registerSw(): void {
42
+ navigator.serviceWorker.register(SW_SCRIPT_PATH, { scope: '/' }).then(
43
+ () => console.debug('[web-shell-sw] SW registered'),
44
+ (error: unknown) => console.warn('[web-shell-sw] SW 注册失败(页面功能不受影响)', error),
45
+ )
46
+ }
47
+
48
+ /** 该注册是否属于本插件(三态 worker 任一匹配即可;scriptURL 在 worker 上)。 */
49
+ function isOurRegistration(registration: ServiceWorkerRegistration): boolean {
50
+ return [registration.active, registration.waiting, registration.installing].some(
51
+ (worker) => worker?.scriptURL.endsWith(SW_SCRIPT_PATH) === true,
52
+ )
53
+ }
54
+
55
+ /** 禁用清理:注销本插件的 SW 注册 + 删除本插件前缀的全部缓存。 */
56
+ function cleanupSw(): void {
57
+ const run = async (): Promise<void> => {
58
+ try {
59
+ const registrations = await navigator.serviceWorker.getRegistrations()
60
+ for (const registration of registrations) {
61
+ if (isOurRegistration(registration)) await registration.unregister()
62
+ }
63
+ const keys = await caches.keys()
64
+ for (const key of keys) {
65
+ if (key.startsWith(SW_CACHE_PREFIX)) await caches.delete(key)
66
+ }
67
+ console.info('[web-shell-sw] 已注销 SW 并清理缓存(恢复原生网络行为)')
68
+ } catch (error) {
69
+ console.warn('[web-shell-sw] 禁用清理失败(下次加载会重试)', error)
70
+ }
71
+ }
72
+ void run()
73
+ }
74
+
75
+ export function apply(ctx: Context): void {
76
+ const config = readConfig()
77
+ const action = decideClientAction(config, {
78
+ isSecureContext: window.isSecureContext,
79
+ hasServiceWorker: 'serviceWorker' in navigator,
80
+ })
81
+ if (action === 'skip') return
82
+
83
+ const run = (): void => {
84
+ if (action === 'register') registerSw()
85
+ else cleanupSw()
86
+ }
87
+
88
+ // load 后下一个宏任务执行:此时首屏资源已全部就绪,注册/注销零竞争。
89
+ let timer: number | undefined
90
+ let disposed = false
91
+ const start = (): void => {
92
+ if (disposed) return
93
+ timer = window.setTimeout(run, 0)
94
+ }
95
+ if (document.readyState === 'complete') start()
96
+ else window.addEventListener('load', start, { once: true })
97
+
98
+ ctx.effect(
99
+ () => () => {
100
+ disposed = true
101
+ if (timer !== undefined) window.clearTimeout(timer)
102
+ window.removeEventListener('load', start)
103
+ },
104
+ 'web-shell-sw: cancel pending register/cleanup',
105
+ )
106
+ }
package/src/config.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * 插件配置(schemastery):可调参数一律外部化,不在逻辑里硬编码。
3
+ *
4
+ * 与仓库既有约定一致:Config 由 cordis 行级 config 解析,部署方可在
5
+ * profile 的 cordis.patch.yml 覆盖;同时经 settings namespace 暴露到
6
+ * $DSH_HOME/settings.yaml 用户层,修改热生效(无需 /reload)。
7
+ * @module @dsh-plus/web-shell-sw/config
8
+ */
9
+ import z from '@deepseek-ai/schemastery'
10
+ import type { UnwrapVolatile } from '@dsh-plus/shared'
11
+
12
+ // 0.1.7:`.volatile()` 使条目进入 settings describe 视图(卡片可读写)并让
13
+ // loader 以活动引用原位提交热更新。
14
+ export const Config = z.object({
15
+ enabled: z
16
+ .boolean()
17
+ .description(
18
+ '总开关(false = 浏览器半注销已注册的 Service Worker 并清空本插件缓存,恢复原生网络行为)',
19
+ )
20
+ .default(true)
21
+ .volatile(),
22
+ })
23
+
24
+ /** 活动字段形态(0.1.7 loader 解析产物:volatile 字段为活动引用)。 */
25
+ export type WebShellSwConfigFields = Schemastery.TypeT<typeof Config>
26
+ /** 平面配置形态(消费面的读取形态,由活动引用解包得到)。 */
27
+ export type WebShellSwConfig = UnwrapVolatile<WebShellSwConfigFields>
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Service Worker 路由决策纯函数(node 与浏览器两侧共用同一份实现):
3
+ *
4
+ * - `decideFetchAction`:SW fetch 事件该不该 `respondWith`、用哪种策略。
5
+ * 安全底线——只有同源 GET、非 Range、内容寻址静态资源与无查询串的
6
+ * index 才进缓存路径;SSE(/plugins/events)、RPC、网关路由、token 交换
7
+ * (`/?token=` 带查询串)一律 passthrough(不调 respondWith = 浏览器
8
+ * 原生行为,零干预)。
9
+ * - `decideClientAction`:浏览器半启动时该注册、该注销还是跳过。
10
+ *
11
+ * 该模块无闭包依赖,函数体自包含——`decideFetchAction.toString()` 会被
12
+ * 内嵌进生成的 sw.js(见 sw-script.ts),测试同时验证内嵌前后行为一致。
13
+ * @module @dsh-plus/web-shell-sw/decision
14
+ */
15
+
16
+ /** 单个 fetch 事件的处置。 */
17
+ export type SwFetchAction =
18
+ | 'passthrough' /** 不 respondWith:浏览器原生处理(RPC/SSE/token/跨格式一律走此道) */
19
+ | 'cache-first' /** 内容寻址静态资源:缓存命中即回,未命中回源并按策略入库 */
20
+ | 'network-first-shell' /** 无查询串的 index:网络优先,仅网络失败时才用缓存兜底 */
21
+
22
+ /**
23
+ * 决定一个 fetch 事件的处置(自包含纯函数,会被 toString 内嵌进 sw.js)。
24
+ * @param method - HTTP 方法(大写)。
25
+ * @param pathname - 已剥查询串的路径。
26
+ * @param hasSearch - URL 是否带查询串(token 交换 `/?token=` 必须原生直通)。
27
+ * @param hasRange - 请求是否带 Range 头(分段请求不走整包缓存)。
28
+ */
29
+ export function decideFetchAction(
30
+ method: string,
31
+ pathname: string,
32
+ hasSearch: boolean,
33
+ hasRange: boolean,
34
+ ): SwFetchAction {
35
+ if (method !== 'GET') return 'passthrough'
36
+ if (hasRange) return 'passthrough'
37
+ if (pathname === '/' || pathname === '/index.html') {
38
+ return hasSearch ? 'passthrough' : 'network-first-shell'
39
+ }
40
+ if (pathname === '/plugins/events') return 'passthrough'
41
+ if (pathname.startsWith('/assets/')) return 'cache-first'
42
+ if (pathname.startsWith('/plugins/')) return 'cache-first'
43
+ return 'passthrough'
44
+ }
45
+
46
+ /** 浏览器半启动处置。 */
47
+ export type ClientAction =
48
+ | 'register' /** 注册(幂等;无注册则新建,有则触发更新检查) */
49
+ | 'cleanup' /** 注销已注册 SW 并清空本插件缓存(禁用恢复原生行为) */
50
+ | 'skip' /** 无配置行(插件缺席)或环境不支持(非安全上下文/无 SW) */
51
+
52
+ /**
53
+ * 决定浏览器半启动动作。
54
+ * @param config - 注入的配置行(缺席 = 插件未装配,零行为)。
55
+ * @param env - 运行环境(安全上下文且支持 serviceWorker 才可操作)。
56
+ */
57
+ export function decideClientAction(
58
+ config: { readonly enabled?: boolean } | undefined,
59
+ env: { readonly isSecureContext: boolean; readonly hasServiceWorker: boolean },
60
+ ): ClientAction {
61
+ if (config === undefined) return 'skip'
62
+ if (!env.isSecureContext || !env.hasServiceWorker) return 'skip'
63
+ return config.enabled === true ? 'register' : 'cleanup'
64
+ }
package/src/index.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * dsh 插件:外壳 Service Worker(web-shell-sw)。
3
+ *
4
+ * 问题:弱网/远程访问下每次冷启动都要重新拉 ~1.4MB 外壳 + ~4MB 插件组合包
5
+ * (HTTP 缓存头已由 web-cache-headers 与官方 immutable 覆盖,但浏览器缓存
6
+ * 可被淘汰、且 index 本身不可缓存);断网时完全打不开。Service Worker 提供
7
+ * 一层由本插件完全掌控的 Cache Storage:内容寻址资源 cache-first、index
8
+ * network-first 离线兜底。
9
+ *
10
+ * 三半协作,均为纯增量:
11
+ * - node 半:每次渲染 index 推 `kind:'global'` 配置行(含 enabled=false——
12
+ * 浏览器半据此注销,禁用即恢复原生行为);精确路由 GET/HEAD
13
+ * `/dsh-plus/shell-sw.js` 服务生成的 SW 脚本(Service-Worker-Allowed: /
14
+ * 使 scope 覆盖全站)。
15
+ * - 浏览器半(src/client.ts):load 后经 requestIdleCallback 注册(不与
16
+ * 首屏抢带宽);禁用时注销 SW 并清空本插件缓存。
17
+ * - SW 脚本(src/sw-script.ts 生成):见该模块注释——不 skipWaiting、
18
+ * 不碰 RPC/SSE/token、no-store 永不入库。
19
+ *
20
+ * 安全边界与已知约束:
21
+ * - 仅安全上下文(https / localhost)可用 Service Worker;裸 http 的
22
+ * 局域网直连降级为「无 SW」,其余插件不受影响。
23
+ * - access-gate 未豁免 sw.js:注册发生在已认证页面(cookie 随请求携带),
24
+ * 未认证时浏览器更新检查得 403 → 保留现有 SW,不影响围栏语义。
25
+ * - 插件整体卸载会留下孤儿 SW:docs 给出一行手动清理命令。
26
+ * @module @dsh-plus/web-shell-sw
27
+ */
28
+ import type { Context } from '@deepseek-ai/cordis'
29
+ import type {} from '@deepseek-ai/dsh-host-webserver'
30
+ import type {} from '@deepseek-ai/dsh-settings'
31
+ import { unwrapVolatile } from '@dsh-plus/shared'
32
+
33
+ import type { WebShellSwConfig, WebShellSwConfigFields } from './config.ts'
34
+ import { SW_GLOBAL_KEY, SW_SCRIPT_PATH } from './ns.ts'
35
+ import { serveServiceWorker } from './route.ts'
36
+
37
+ export const name = 'dsh-plus-web-shell-sw'
38
+
39
+ /** 无硬 inject:headless 等无 webServer 的 profile 里本插件空转(见 boot-retry 同款说明)。 */
40
+
41
+ export { Config } from './config.ts'
42
+ export { SETTINGS_NS } from './ns.ts'
43
+ export type { WebShellSwConfig }
44
+
45
+ /**
46
+ * 装配路由与配置注入。配置行恒推(含 enabled=false——禁用需要浏览器半
47
+ * 执行注销,「行缺席」会使其无法区分禁用与未安装);路由恒注册(禁用时
48
+ * 无人注册新 SW,已注册的由配置行驱动注销,无需路由参与)。
49
+ * @param ctx - 宿主上下文(webServer 可选)。
50
+ * @param config - 活动字段形态的行级 config(测试可传平面值)。
51
+ */
52
+ export function apply(ctx: Context, config: WebShellSwConfig | WebShellSwConfigFields): void {
53
+ const logger = ctx.logger('web-shell-sw')
54
+ // 活动引用原位提交:每次渲染现取快照,settings 翻转对下一次页面加载生效。
55
+ const current = (): WebShellSwConfig => unwrapVolatile(config)
56
+
57
+ ctx.inject(['webServer'], (webCtx) => {
58
+ webCtx.effect(
59
+ () =>
60
+ webCtx.on('webserver/index-inject', (table) => {
61
+ table.push({
62
+ kind: 'global',
63
+ name: SW_GLOBAL_KEY,
64
+ value: { enabled: current().enabled },
65
+ })
66
+ }),
67
+ 'web-shell-sw: index injection',
68
+ )
69
+ webCtx.effect(
70
+ () =>
71
+ webCtx.webServer.register({
72
+ kind: 'exact',
73
+ path: SW_SCRIPT_PATH,
74
+ handler: serveServiceWorker,
75
+ }),
76
+ 'web-shell-sw: sw.js route',
77
+ )
78
+ logger.info(`installed (script route ${SW_SCRIPT_PATH})`)
79
+ })
80
+ }
81
+
82
+ /** 供测试断言 inject 形态:本插件不声明硬依赖。 */
83
+ export const inject = [] as const
package/src/ns.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * settings 命名空间 + 路由/缓存/全局键常量(纯常量,零依赖)。
3
+ * node 半(路由服务、注入行)与浏览器半(注册/注销、清理)共享同一来源,
4
+ * 避免两侧字面量漂移。
5
+ * @module @dsh-plus/web-shell-sw/ns
6
+ */
7
+ export const SETTINGS_NS = 'dsh-plus-web-shell-sw'
8
+
9
+ /** index-inject `kind:'global'` 注入的配置对象键。 */
10
+ export const SW_GLOBAL_KEY = '__DSH_PLUS_WEB_SHELL_SW__'
11
+
12
+ /** sw.js 精确路由(注册与更新检查都打这里)。 */
13
+ export const SW_SCRIPT_PATH = '/dsh-plus/shell-sw.js'
14
+
15
+ /**
16
+ * Cache Storage 缓存名(同时是版本号)。
17
+ * SW 脚本逻辑变更时手动 +1:activate 阶段删除前缀相同、名字不同的旧缓存。
18
+ */
19
+ export const SW_CACHE_NAME = 'dsh-shell-v1'
20
+
21
+ /** 缓存名前缀(禁用清理与版本升级都按它扫全量)。 */
22
+ export const SW_CACHE_PREFIX = 'dsh-shell-'
package/src/route.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * sw.js 路由处理器:GET/HEAD 服务生成的 SW 脚本。
3
+ *
4
+ * 响应头三条缺一不可:
5
+ * - content-type 必须是 JavaScript MIME(否则注册被浏览器拒);
6
+ * - service-worker-allowed: / 使 scope 覆盖全站(脚本挂在 /dsh-plus/ 下,
7
+ * 默认 scope 只到 /dsh-plus/,管不到 /assets 与 /plugins);
8
+ * - cache-control: no-cache 让更新检查拿到最新脚本(现代浏览器主脚本本就
9
+ * 绕过 HTTP 缓存,此头是显式语义与旧实现的双保险)。
10
+ * @module @dsh-plus/web-shell-sw/route
11
+ */
12
+ import type { IncomingMessage, ServerResponse } from 'node:http'
13
+
14
+ import { buildServiceWorkerScript } from './sw-script.ts'
15
+
16
+ /**
17
+ * 精确路由 handler(webserver.register kind:'exact')。
18
+ * @param req - 入站请求。
19
+ * @param res - 出站响应(本 handler 拥有完整生命周期)。
20
+ */
21
+ export function serveServiceWorker(req: IncomingMessage, res: ServerResponse): void {
22
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
23
+ res.writeHead(405, {
24
+ 'content-type': 'text/plain; charset=utf-8',
25
+ allow: 'GET, HEAD',
26
+ })
27
+ res.end('method not allowed')
28
+ return
29
+ }
30
+ const body = buildServiceWorkerScript()
31
+ res.writeHead(200, {
32
+ 'content-type': 'text/javascript; charset=utf-8',
33
+ 'cache-control': 'no-cache',
34
+ 'service-worker-allowed': '/',
35
+ 'content-length': String(Buffer.byteLength(body)),
36
+ })
37
+ res.end(body)
38
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * sw.js 脚本生成:把安全决策(decision.ts 自包含纯函数)与缓存策略
3
+ * 渲染成一份独立的 Service Worker 脚本,由 node 半的精确路由对外服务。
4
+ *
5
+ * 设计要点(全部保守取向,宁可少缓存不可错缓存):
6
+ * - 不 skipWaiting、不 clients.claim:新版本等所有标签页关闭后才接管,
7
+ * 绝不在会话中途换实现;首次注册只从下一次导航开始接管。
8
+ * - passthrough 一大类(RPC、SSE、token 交换、跨源、Range、非 GET)
9
+ * 不调 respondWith = 浏览器原生行为,零干预。
10
+ * - 只有 status 200、type basic、且 cache-control 不含 no-store/no-cache
11
+ * 的响应才入库——token 页(no-store)与代理侧 no-store 的 index 天然
12
+ * 免疫,认证内容永不落 Cache Storage。
13
+ * - 入库前剥离 content-encoding/content-length/transfer-encoding/vary:
14
+ * fetch() 暴露的 body 已解码,复制编码头会造成「解码头 + 原始体」错配
15
+ * (经典 SW 缓存坑);存 identity 体,下次命中由 SW 直接原样返回。
16
+ * - 缓存名带版本(ns.ts 的 SW_CACHE_NAME),activate 清理同前缀旧版本;
17
+ * 源码逻辑变更时手动 +1 即可全量换血。
18
+ *
19
+ * 安全边界:`decideFetchAction` 无闭包依赖,toString() 内嵌后行为与
20
+ * node 侧单测完全一致(tests/sw-script.test.ts 同时断言编译与内嵌等价)。
21
+ * @module @dsh-plus/web-shell-sw/sw-script
22
+ */
23
+ import { decideFetchAction } from './decision.ts'
24
+ import { SW_CACHE_NAME, SW_CACHE_PREFIX } from './ns.ts'
25
+
26
+ /**
27
+ * 生成完整的 sw.js 脚本文本(纯函数,同配置恒等输出)。
28
+ * @returns 可直接作为 application/javascript 响应体的脚本源码。
29
+ */
30
+ export function buildServiceWorkerScript(): string {
31
+ return `'use strict';
32
+ const CACHE_NAME = ${JSON.stringify(SW_CACHE_NAME)};
33
+ const CACHE_PREFIX = ${JSON.stringify(SW_CACHE_PREFIX)};
34
+ const decideFetch = ${decideFetchAction.toString()};
35
+
36
+ self.addEventListener('install', function () {
37
+ // 不 skipWaiting:新版本等待所有标签页关闭后接管,避免会话中途换实现。
38
+ });
39
+
40
+ self.addEventListener('activate', function (event) {
41
+ event.waitUntil(
42
+ caches.keys().then(function (keys) {
43
+ return Promise.all(
44
+ keys
45
+ .filter(function (key) { return key.indexOf(CACHE_PREFIX) === 0 && key !== CACHE_NAME; })
46
+ .map(function (key) { return caches.delete(key); })
47
+ );
48
+ })
49
+ );
50
+ });
51
+
52
+ self.addEventListener('fetch', function (event) {
53
+ const request = event.request;
54
+ let url;
55
+ try {
56
+ url = new URL(request.url);
57
+ } catch (error) {
58
+ return;
59
+ }
60
+ if (url.origin !== self.location.origin) return;
61
+ const action = decideFetch(
62
+ request.method,
63
+ url.pathname,
64
+ url.search !== '',
65
+ request.headers.has('range')
66
+ );
67
+ if (action === 'passthrough') return;
68
+ event.respondWith(
69
+ action === 'cache-first' ? cacheFirst(request) : shellNetworkFirst(request)
70
+ );
71
+ });
72
+
73
+ /** 剥离编码相关头后再入库:fetch 的 body 已解码,保留编码头会造成解码错配。 */
74
+ function normalizeForCache(response) {
75
+ const headers = new Headers(response.headers);
76
+ ['content-encoding', 'content-length', 'transfer-encoding', 'vary'].forEach(function (name) {
77
+ headers.delete(name);
78
+ });
79
+ return response.arrayBuffer().then(function (body) {
80
+ return new Response(body, { status: response.status, statusText: response.statusText, headers: headers });
81
+ });
82
+ }
83
+
84
+ /** 仅 200/basic/未标 no-store 且未标 no-cache 的响应可入库。 */
85
+ function isStorable(response) {
86
+ if (response.status !== 200 || response.type !== 'basic') return false;
87
+ const cacheControl = (response.headers.get('cache-control') || '').toLowerCase();
88
+ return cacheControl.indexOf('no-store') === -1 && cacheControl.indexOf('no-cache') === -1;
89
+ }
90
+
91
+ function putNormalized(cache, request, response) {
92
+ return normalizeForCache(response).then(
93
+ function (stored) { return cache.put(request, stored); },
94
+ function (error) { console.warn('[web-shell-sw] cache.put failed', error); }
95
+ );
96
+ }
97
+
98
+ /** 内容寻址资源:命中即回;未命中回源,可存则存(存失败不影响本次响应)。 */
99
+ function cacheFirst(request) {
100
+ return caches.open(CACHE_NAME).then(function (cache) {
101
+ return cache.match(request).then(function (hit) {
102
+ if (hit !== undefined) return hit;
103
+ return fetch(request).then(function (response) {
104
+ if (isStorable(response)) putNormalized(cache, request, response.clone());
105
+ return response;
106
+ });
107
+ });
108
+ });
109
+ }
110
+
111
+ /** index:网络优先(在线恒用最新外壳);仅网络失败时才用缓存兜底,无缓存则原生失败。 */
112
+ function shellNetworkFirst(request) {
113
+ return caches.open(CACHE_NAME).then(function (cache) {
114
+ return fetch(request).then(
115
+ function (response) {
116
+ if (isStorable(response)) putNormalized(cache, request, response.clone());
117
+ return response;
118
+ },
119
+ function (error) {
120
+ return cache.match(request).then(function (hit) {
121
+ if (hit !== undefined) return hit;
122
+ throw error;
123
+ });
124
+ }
125
+ );
126
+ });
127
+ }
128
+ `
129
+ }