@tapepack/core 0.0.0-stage → 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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +69 -2
  3. package/package.json +37 -4
  4. package/src/archive.mjs +38 -0
  5. package/src/assemble.mjs +366 -0
  6. package/src/chain-read.mjs +88 -0
  7. package/src/chains.mjs +52 -0
  8. package/src/cli.mjs +234 -0
  9. package/src/deploy-page.mjs +266 -0
  10. package/src/deploy-plan.mjs +105 -0
  11. package/src/deploy-run.mjs +200 -0
  12. package/src/estimate.mjs +209 -0
  13. package/src/external.mjs +30 -0
  14. package/src/fee.mjs +58 -0
  15. package/src/firstscreen.mjs +166 -0
  16. package/src/incremental.mjs +144 -0
  17. package/src/index.mjs +6 -0
  18. package/src/jpeg-meta.mjs +42 -0
  19. package/src/loader-template.html +922 -0
  20. package/src/onchain.mjs +678 -0
  21. package/src/optimize.mjs +224 -0
  22. package/src/pack.mjs +241 -0
  23. package/src/prices.mjs +39 -0
  24. package/src/prune.mjs +193 -0
  25. package/src/sri.mjs +31 -0
  26. package/src/strategy.mjs +119 -0
  27. package/src/text-min.mjs +33 -0
  28. package/src/tpk.mjs +97 -0
  29. package/src/webp-meta.mjs +22 -0
  30. package/vendor/tapekit/LICENSE +21 -0
  31. package/vendor/tapekit/abi.js +113 -0
  32. package/vendor/tapekit/cache.js +111 -0
  33. package/vendor/tapekit/config.js +190 -0
  34. package/vendor/tapekit/i18n.js +86 -0
  35. package/vendor/tapekit/identity.js +155 -0
  36. package/vendor/tapekit/index.js +13 -0
  37. package/vendor/tapekit/keccak.js +66 -0
  38. package/vendor/tapekit/kernel.js +480 -0
  39. package/vendor/tapekit/name.js +95 -0
  40. package/vendor/tapekit/path.js +39 -0
  41. package/vendor/tapekit/purity.js +76 -0
  42. package/vendor/tapekit/rpc.js +336 -0
  43. package/vendor/tapekit/selectors.js +58 -0
  44. package/vendor/tapekit/sha.js +13 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 thewho
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/README.md CHANGED
@@ -1,3 +1,70 @@
1
- # Temporary Holding Version
1
+ # @tapepack/core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 把静态站点压缩后部署到 DeWEB(TapeOut 生态)链上。网站逐字节存在链上,**压缩比 ≈ 省钱比**。产物在未修改的 TapeKit 查看器、网关下直接运行。
4
+
5
+ 支持 BNB Smart Chain、X Layer、Base。需要 Node ≥ 20。
6
+
7
+ ## 一条命令部署
8
+
9
+ ```bash
10
+ export TAPEPACK_PRIVATE_KEY=0x… # 容器持有人钱包的私钥,只从环境变量读
11
+ npx -p @tapepack/core tapepack deploy ./dist --site 1.2.344
12
+ ```
13
+
14
+ 流程依次是:
15
+
16
+ 1. **读链上现有档案作增量基准**:只把变化的文件打成增量卷。
17
+ 2. **与链上逐文件比对**:只传变化的文件。
18
+ 3. **报价**:列出链上费用、服务费和预估合计,确认后才执行。非交互环境加 `--yes`;只看报价用 `--dry-run`,不需要私钥。
19
+ 4. **操作员模式上传**,与网页版同一套:
20
+ - 主钱包只签两笔:给临时钱包充值(含服务费),授权它写入 2 小时。
21
+ - 临时钱包流水线写入,写完转服务费,剩余余额退回主钱包,最后读链复核。
22
+ - 按 Ctrl-C 会在当前这笔确认后停止,并照常退款。中途停止不收服务费。
23
+
24
+ | 选项 | 说明 |
25
+ |---|---|
26
+ | `--site <名字>` | 链上站点名,如 `1.2.344`;用容器地址时另加 `--chain bnb\|xlayer\|base` |
27
+ | `--dry-run` | 只报价 |
28
+ | `--yes` | 不再询问,直接执行 |
29
+ | `--full` | 不做增量,整体重打 |
30
+ | `--remove-leftovers` | 删除链上有、本地没有的文件(`.well-known/` 等点目录除外) |
31
+ | `--out <目录>` | 保留产物(缺省用临时目录) |
32
+ | `--preset fast\|balanced\|max`、`--strategy auto\|speed\|gas` | 压缩档位、加载策略 |
33
+ | `--rpc <url>` 或环境变量 `TAPEPACK_RPC` | 自备节点 |
34
+
35
+ 上次中断(进程被杀等)时,余额可能留在本机临时钱包(`~/.tapepack/`,权限 0600)。下次部署会接着用,也可以手动退回:
36
+
37
+ ```bash
38
+ npx -p @tapepack/core tapepack refund --chain xlayer
39
+ ```
40
+
41
+ ## 服务费
42
+
43
+ 只有部署时收:本次节省额(原样上传费用 − TapePack 上传费用)× 20%,封顶 $2。
44
+
45
+ - 节省额不到 $0.10、内容没变、取不到实时币价时都不收。
46
+ - 增量更新只按变化的文件计算。
47
+ - 收款地址:`0x6acf9e40122a37e8a43eb594efab2b1752158625`(三条链相同)。
48
+
49
+ 压缩、估算、盘点都免费。
50
+
51
+ `plan`、`diff`、`deploy-page` 产出可直接发送的 calldata。加 `--src <源站点目录>` 时会附上服务费金额和一笔 `serviceFeeCall`,自行发送时请在全部写完后一并发送。
52
+
53
+ ## 其他命令
54
+
55
+ ```bash
56
+ tapepack pack <站点目录> <产物目录> [--base 1.2.344 | --base-dir <上次产物>] # 只压缩
57
+ tapepack estimate <目录> --chain all # 三链费用对比
58
+ tapepack inspect <站点名|容器地址> # 盘点链上站点
59
+ tapepack diff <产物目录> --site <站点名> [--src <源站点目录>] # 增量更新计划
60
+ ```
61
+
62
+ ## 网络
63
+
64
+ 在需要代理的网络里,Node 内置的 fetch 默认不走 `HTTPS_PROXY`。读链和取币价都需要联网,请加:
65
+
66
+ ```bash
67
+ export NODE_USE_ENV_PROXY=1 # Node ≥ 22.21 / 24
68
+ ```
69
+
70
+ 取不到实时币价时,本次不收服务费,部署照常进行。
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "@tapepack/core",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "TapePack:把静态站点压缩后部署到 DeWEB(TapeOut)链上,上链 Gas 直降——压缩、估算、增量更新、一条命令部署",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "tapepack",
9
+ "deweb",
10
+ "tapeout",
11
+ "onchain",
12
+ "web3",
13
+ "deploy",
14
+ "compression"
15
+ ],
16
+ "bin": {
17
+ "tapepack": "src/cli.mjs"
18
+ },
19
+ "main": "src/index.mjs",
20
+ "exports": {
21
+ ".": "./src/index.mjs",
22
+ "./src/*": "./src/*",
23
+ "./vendor/*": "./vendor/*"
24
+ },
25
+ "files": [
26
+ "src",
27
+ "vendor",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "engines": {
32
+ "node": ">=20"
33
+ },
34
+ "dependencies": {
35
+ "esbuild": "^0.28.2",
36
+ "ethers": "^6.13.0",
37
+ "sharp": "^0.35.5"
38
+ }
39
+ }
@@ -0,0 +1,38 @@
1
+ // 档案装配编排(浏览器 / Node 共用,pack.mjs 与 web 的 pipeline.js 都走这里,保证产物一致):
2
+ // 首屏分类 → 整体分卷或增量分层 → 按加载策略决定是否内嵌首屏卷(strategy.mjs)。
3
+
4
+ import { buildOutputs, BOOT_PATH, G_LAZY, LAZY_RUNTIME_MARK } from './assemble.mjs';
5
+ import { classifyLazy } from './firstscreen.mjs';
6
+ import { planDelta } from './incremental.mjs';
7
+ import { planEmbed } from './strategy.mjs';
8
+
9
+ /**
10
+ * @param {Array<{ path: string, bytes: Uint8Array }>} splitFiles 已优化、已分片、含渲染段的站点文件
11
+ * @param {{ gzip, gunzip?, single?: boolean, base?: object | null, lazy?: boolean | Set<string>,
12
+ * embedMax?: number, costRatio?: number, pages?: boolean, lazyTextMin?: number, lazyStoredMin?: number }} opts
13
+ * single:整站内嵌进 index.html(不分延后卷、不做增量)
14
+ * base:现有档案(增量,见 incremental.planDelta);lazy:false 关闭首屏分卷,Set 指定延后路径(strategy.planLazy),缺省自动分类
15
+ * embedMax:入口内嵌首屏卷的总字节上限(0 = 不内嵌);costRatio:内嵌多花的 gas 占比上限(见 strategy.planEmbed)
16
+ * @returns {Promise<{ outputs: object[], groups: Array, embedded: object[], incremental: object | null, lazy: Set<string>,
17
+ * baseFile: Set<string> | undefined, embedPlan: object }>}
18
+ * outputs:要上链的卷(不含入口、不含已内嵌的卷);embedded:本次内嵌进入口的卷(校验用);
19
+ * baseFile:交给 renderEntries(多页站点 tpk-base.js 里放哪些卷)
20
+ */
21
+ export async function assembleArchive(splitFiles, opts) {
22
+ const { gzip, gunzip, single = false } = opts;
23
+ const lazy = single || opts.lazy === false ? new Set() : opts.lazy instanceof Set ? opts.lazy : classifyLazy(splitFiles);
24
+ const sizes = { lazy, lazyTextMin: opts.lazyTextMin, lazyStoredMin: opts.lazyStoredMin };
25
+ const incremental = opts.base && !single ? await planDelta(splitFiles, opts.base, { gzip, gunzip, ...sizes }) : null;
26
+ const layered = incremental && incremental.mode !== 'full';
27
+ const built = layered ? incremental : await buildOutputs(splitFiles, { gzip, ...sizes });
28
+ // 增量且基准的首屏卷已全部内嵌:沿用(否则更新后打开反而多读一次链)
29
+ const continuing = layered && opts.base.groups.every((g) => g[1] & G_LAZY || g[2]);
30
+ const r = planEmbed(built, { files: splitFiles, single, embedMax: opts.embedMax, costRatio: opts.costRatio, pages: opts.pages, continuing });
31
+ const baseFile = layered ? opts.base.baseFile || new Set() : r.baseFile;
32
+ // 有延后卷就必须配带延后支持的渲染段(否则延后卷里的文件永远等不到)
33
+ const boot = splitFiles.find((f) => f.path === BOOT_PATH);
34
+ if (boot && r.groups.some((g) => g[1] & G_LAZY && !g[2]) && !new TextDecoder().decode(boot.bytes).includes(LAZY_RUNTIME_MARK)) {
35
+ throw new Error('渲染段缺少延后卷支持:renderBootJs 需要 lazy: true(增量基准里有延后卷时)');
36
+ }
37
+ return { outputs: r.outputs, groups: r.groups, embedded: r.embedded, incremental, lazy, baseFile, embedPlan: r.plan };
38
+ }
@@ -0,0 +1,366 @@
1
+ // 分组装配(零 Node 依赖,浏览器/Node 双端):文本/存储分组、分卷、大文件分片、loader 渲染。
2
+ // 浏览器端(web)与 CLI/MCP(pack.mjs)共用,保证产物格式完全一致。
3
+
4
+ import { encodeTpk } from './tpk.mjs';
5
+ import { classifyLazy } from './firstscreen.mjs';
6
+
7
+ /** SiteRegistry 单文件硬上限:350 块 × 24,000B = 8.4MB */
8
+ export const MAX_CHAIN_FILE = 350 * 24_000;
9
+ export const SPLIT_SUFFIX = '.tpkpart'; // 大文件分片后缀:<name>.tpkpart0..N
10
+ export const BOOT_PATH = '.tpk-boot.js'; // loader 渲染段在档案内的路径(index.html 只留引导段)
11
+ // 存储卷索引(写在同一层的文本卷里):{ v:1, vols: { "g1.tpk": [[path, size, sha256 前 128 位 hex], …] } }
12
+ // 增量更新据此判断存储卷里的文件是否变化,不必下载图片 / wasm 等大卷(见 incremental.mjs / onchain.fetchBase)
13
+ export const INDEX_PATH = '.tpk-index.json';
14
+ export async function shortSha(u8) {
15
+ const d = new Uint8Array(await crypto.subtle.digest('SHA-256', u8));
16
+ return Array.from(d.subarray(0, 16), (b) => b.toString(16).padStart(2, '0')).join('');
17
+ }
18
+ const BOOT_RE = /<script type="tpk\/boot">\n([\s\S]*?)<\/script>\n?/;
19
+
20
+ /**
21
+ * 大文件分片:超过阈值的文件拆成若干 <name>.tpkpartN(loader 解压后自动拼回)。
22
+ * 平台单文件上限 8.4MB——esbuild.wasm 这类 12MB 资产必须分片才能上链。
23
+ */
24
+ export function splitBigFiles(files, threshold = 7_800_000) {
25
+ const out = [], splits = [];
26
+ for (const f of files) {
27
+ if (f.bytes.length <= threshold) { out.push(f); continue; }
28
+ const n = Math.ceil(f.bytes.length / threshold);
29
+ for (let i = 0; i < n; i++) {
30
+ out.push({ path: f.path + SPLIT_SUFFIX + i, bytes: f.bytes.subarray(i * threshold, (i + 1) * threshold) });
31
+ }
32
+ splits.push({ path: f.path, bytes: f.bytes.length, parts: n });
33
+ }
34
+ return { files: out, splits };
35
+ }
36
+
37
+ /** 卷表标志位(loader 卷表 G 的第 2 项):gzip 文本卷 / 延后卷(首屏渲染不等它,见 firstscreen.mjs) */
38
+ export const G_GZIP = 1;
39
+ export const G_LAZY = 2;
40
+ /** 延后文件太少时不单独成卷(多一个链上文件、多一次读取,不划算):延后文本 / 存储的原始字节下限 */
41
+ export const LAZY_TEXT_MIN = 16_000;
42
+ export const LAZY_STORED_MIN = 16_000;
43
+ /** 入口里内嵌的首屏卷(base64)总字节上限:小站省掉「入口 → 档案」这一次串行读链 */
44
+ export const EMBED_MAX = 16_000;
45
+ export const DEFAULT_NAMES = Object.freeze({ text: 'g0', stored: 'g1', lazyText: 'g2', lazyStored: 'g3' });
46
+
47
+ /**
48
+ * 分组 → 卷:文本组 gzip(可多卷)、存储组原样(可多卷);首屏与延后各自成卷。
49
+ * 每卷编码后必须 ≤ MAX_CHAIN_FILE;文本卷超限时把末尾文件挪到下一卷重编(gzip 体积无法精确预算)。
50
+ * 卷名:首屏文本 g0.tpk, g0b.tpk, g0c…;首屏存储 g1…;延后文本 g2…;延后存储 g3…。
51
+ * 有存储卷或延后卷时,首屏文本层首卷带卷索引(INDEX_PATH:这些卷各有哪些文件;只有存储文件时首卷只含索引)——
52
+ * loader 据此在延后卷到达前就知道哪些路径在路上,增量更新据此不必下载这些卷。
53
+ * @param {object[]} files 已分片、已优化的站点文件
54
+ * @param {{ gzip?: (u8: Uint8Array) => Promise<Uint8Array>, textBudget?: number, storedBudget?: number,
55
+ * names?: { text: string, stored: string, lazyText?: string, lazyStored?: string },
56
+ * lazy?: Set<string>, lazyTextMin?: number, lazyStoredMin?: number }} [opts]
57
+ * names:卷名前缀(缺省 DEFAULT_NAMES;增量卷用 d<N> / d<N>s / d<N>l / d<N>m,见 incremental.mjs)
58
+ * lazy:可延后的路径(classifyLazy);延后文件合计不足 lazyTextMin / lazyStoredMin 时并回首屏卷
59
+ * gzip 必须提供(Node:tpk.mjs 的 encodeTpkGzip;浏览器:CompressionStream 封装)
60
+ * @returns {Promise<{ outputs: Array<{ path, bytes, group: 'text'|'stored', lazy?: true }>, groups: Array<[string, number]> }>}
61
+ */
62
+ export async function buildOutputs(files, opts = {}) {
63
+ if (!opts.gzip) throw new Error('buildOutputs: opts.gzip required');
64
+ const lazySet = opts.lazy || new Set();
65
+ // 分片按原路径算(策略层对切片前的文件分类,集合里是原路径;分类器直接看分片时集合里是分片名)
66
+ const isLazy = (p) => lazySet.has(p) || lazySet.has(p.replace(/\.tpkpart\d+$/, ''));
67
+ const { text: allText, stored: allStored } = splitGroups(files);
68
+ const sum = (a) => a.reduce((s, f) => s + f.bytes.length, 0);
69
+ let lazyText = allText.filter((f) => isLazy(f.path)), lazyStored = allStored.filter((f) => isLazy(f.path));
70
+ if (sum(lazyText) < (opts.lazyTextMin ?? LAZY_TEXT_MIN)) lazyText = [];
71
+ if (sum(lazyStored) < (opts.lazyStoredMin ?? LAZY_STORED_MIN)) lazyStored = [];
72
+ const later = new Set([...lazyText, ...lazyStored]);
73
+ const text = allText.filter((f) => !later.has(f)), stored = allStored.filter((f) => !later.has(f));
74
+ const outputs = [], groups = [];
75
+
76
+ const volName = (base, i) => (i === 0 ? `${base}.tpk` : `${base}${String.fromCharCode(97 + i)}.tpk`);
77
+ const names = { ...DEFAULT_NAMES, ...opts.names };
78
+ if (!opts.names?.lazyText && opts.names) names.lazyText = names.text + 'l';
79
+ if (!opts.names?.lazyStored && opts.names) names.lazyStored = names.text + 'm';
80
+
81
+ // 存储组(首屏 / 延后)先分卷(按原始字节贪心装箱,编码近似恒等,预算可靠)
82
+ const pack = (list) => {
83
+ const vols = [], budget = opts.storedBudget ?? 8_000_000;
84
+ let vol = [], volBytes = 0;
85
+ for (const f of list) {
86
+ if (vol.length && volBytes + f.bytes.length > budget) { vols.push(vol); vol = []; volBytes = 0; }
87
+ vol.push(f); volBytes += f.bytes.length;
88
+ }
89
+ if (vol.length) vols.push(vol);
90
+ return vols;
91
+ };
92
+ const storedVols = pack(stored), lazyStoredVols = pack(lazyStored);
93
+ // 文本组按预算分桶,gzip 后超限把桶尾文件下沉到下一桶重编
94
+ // bulk:大块数据(wasm、切片)另起一卷——和懒加载的 JS / CSS 同卷的话,等一个小模块也得等几 MB
95
+ const textVols = async (list, base, head, bulk = () => false) => {
96
+ const budget = opts.textBudget ?? 9_000_000;
97
+ const buckets = [[]];
98
+ let acc = 0, prevBulk = false;
99
+ for (const f of list) {
100
+ const b = bulk(f);
101
+ if (buckets[buckets.length - 1].length && (acc + f.bytes.length > budget || b !== prevBulk)) { buckets.push([]); acc = 0; }
102
+ prevBulk = b;
103
+ buckets[buckets.length - 1].push(f); acc += f.bytes.length;
104
+ }
105
+ if (head) buckets[0].unshift(head); // 索引不计预算,固定在首卷
106
+ const out = [];
107
+ for (let bi = 0; bi < buckets.length; bi++) {
108
+ let bucket = buckets[bi];
109
+ let encoded = await opts.gzip(encodeTpk(bucket));
110
+ while (encoded.length > MAX_CHAIN_FILE && bucket.length > 1) {
111
+ const moved = bucket.pop();
112
+ if (!buckets[bi + 1]) buckets[bi + 1] = [];
113
+ buckets[bi + 1].unshift(moved);
114
+ encoded = await opts.gzip(encodeTpk(bucket));
115
+ }
116
+ if (encoded.length > MAX_CHAIN_FILE) throw new Error(`文本卷超限且无法再分(单文件压缩后仍 > 8.4MB)`);
117
+ out.push({ path: volName(base, bi), bytes: encoded, entries: bucket });
118
+ }
119
+ return out;
120
+ };
121
+ const isBulk = (f) => /\.wasm$|\.tpkpart\d+$/i.test(f.path);
122
+ const lazyTextVols = lazyText.length
123
+ ? await textVols([...lazyText.filter((f) => !isBulk(f)), ...lazyText.filter(isBulk)], names.lazyText, null, isBulk)
124
+ : [];
125
+
126
+ // 卷索引:存储卷与延后卷各有哪些文件([路径, 字节数, SHA-256 前 128 位])
127
+ const vols = {};
128
+ const listVol = async (name, entries) => { vols[name] = await Promise.all(entries.map(async (f) => [f.path, f.bytes.length, await shortSha(f.bytes)])); };
129
+ for (let vi = 0; vi < storedVols.length; vi++) await listVol(volName(names.stored, vi), storedVols[vi]);
130
+ for (const v of lazyTextVols) await listVol(v.path, v.entries);
131
+ for (let vi = 0; vi < lazyStoredVols.length; vi++) await listVol(volName(names.lazyStored, vi), lazyStoredVols[vi]);
132
+ const index = Object.keys(vols).length ? { path: INDEX_PATH, bytes: new TextEncoder().encode(JSON.stringify({ v: 1, vols })) } : null;
133
+
134
+ // 产物顺序:首屏文本(g0 起)→ 首屏存储 → 延后文本 → 延后存储(同一层内路径不重复,顺序只影响取卷先后)
135
+ if (text.length || index) {
136
+ for (const v of await textVols(text, names.text, index)) { outputs.push({ path: v.path, bytes: v.bytes, group: 'text' }); groups.push([v.path, G_GZIP]); }
137
+ }
138
+ const storedOut = (list, base, lazy) => {
139
+ for (let vi = 0; vi < list.length; vi++) {
140
+ const o = { path: volName(base, vi), bytes: encodeTpk(list[vi]), group: 'stored', ...(lazy ? { lazy: true } : {}) };
141
+ if (o.bytes.length > MAX_CHAIN_FILE) throw new Error(`存储卷超限 ${o.path}: ${o.bytes.length} > ${MAX_CHAIN_FILE}(单文件本身超限?)`);
142
+ outputs.push(o);
143
+ groups.push([o.path, lazy ? G_LAZY : 0]);
144
+ }
145
+ };
146
+ storedOut(storedVols, names.stored, false);
147
+ for (const v of lazyTextVols) { outputs.push({ path: v.path, bytes: v.bytes, group: 'text', lazy: true }); groups.push([v.path, G_GZIP | G_LAZY]); }
148
+ storedOut(lazyStoredVols, names.lazyStored, true);
149
+
150
+ return { outputs, groups };
151
+ }
152
+
153
+ /**
154
+ * 把小的首屏卷内嵌进入口(卷表项第 3 位放 base64):入口读到就能渲染,省掉「入口 → 档案」这次串行读链
155
+ * (每次读链是「文件信息 + 内容」两轮多节点核对,公共节点上约 1 秒)。base64 多 1/3 字节,
156
+ * 但少一个链上文件的固定开销(约 77K gas ≈ 360 字节)。
157
+ * 首屏卷是并行取的,只要还有一个要读链,内嵌其余的就不省时间——所以要么全部首屏卷(含增量时沿用的旧卷)
158
+ * 一起内嵌(总量 ≤ max),要么都不内嵌。(只内嵌个别极小卷看似省掉一个文件,但内嵌的字节随入口在每次增量更新时重传,
159
+ * 更新一次就亏回去了。)
160
+ * 延后卷不内嵌(--single 除外):它们本来就不挡首屏,内嵌只会把它们变回首屏数据。
161
+ * @param {Array<{ path, bytes, group, lazy?, keep? }>} outputs 卷产物(不含入口)
162
+ * @param {Array<[string, number] | [string, number, string]>} groups 卷表(已内嵌的旧卷第 3 位是 base64)
163
+ * @param {{ max?: number, all?: boolean }} [opts] all:全部内嵌(--single 单文件模式);max = 0 不内嵌
164
+ * @returns {{ outputs: object[], groups: Array, embedded: object[] }} outputs 去掉已内嵌的卷;embedded 为本次内嵌的卷
165
+ */
166
+ export function embedVolumes(outputs, groups, opts = {}) {
167
+ const max = opts.all ? Infinity : (opts.max ?? EMBED_MAX);
168
+ const byPath = new Map(outputs.map((o) => [o.path, o]));
169
+ const can = (g) => { const o = byPath.get(g[0]); return !g[2] && o && o.bytes && !o.keep ? o : null; };
170
+ const used = groups.reduce((s, g) => s + (g[2] ? Math.floor((g[2].length * 3) / 4) : 0), 0);
171
+ // 首屏卷全部内嵌是否可行:还要读链的首屏卷都能内嵌、且总量不超
172
+ const firstNeed = groups.filter((g) => !(g[1] & G_LAZY) && !g[2]);
173
+ const allFit = opts.all || (firstNeed.every(can) && used + firstNeed.reduce((s, g) => s + can(g).bytes.length, 0) <= max);
174
+ const take = new Set();
175
+ for (const g of groups) {
176
+ const o = can(g);
177
+ if (!o) continue;
178
+ if (opts.all || (allFit && !(g[1] & G_LAZY))) take.add(o);
179
+ }
180
+ const embedded = [...take];
181
+ return {
182
+ outputs: outputs.filter((o) => !take.has(o)),
183
+ groups: groups.map((g) => (take.has(byPath.get(g[0])) && !g[2] ? [g[0], g[1], toBase64(byPath.get(g[0]).bytes)] : g)),
184
+ embedded,
185
+ };
186
+ }
187
+
188
+ // 进 gzip 卷的扩展名:文本类,以及压缩率高的二进制 wasm(esbuild.wasm 14MB → gzip 3.8MB;
189
+ // 原样存储时链上多付约 3 倍费用、打开时多读约 3 倍数据)
190
+ export const TEXT_EXT = new Set(['html', 'htm', 'css', 'js', 'mjs', 'json', 'svg', 'txt', 'xml', 'map', 'webmanifest', 'csv', 'wasm']);
191
+
192
+ /** 内层结构分组的产物描述;真正编码见 tpk.mjs 的 encodeTpk / encodeTpkGzip。分片按原文件扩展名归组 */
193
+ export function splitGroups(files) {
194
+ const text = [], stored = [];
195
+ for (const f of files) {
196
+ const name = f.path.replace(/\.tpkpart\d+$/, '');
197
+ const ext = name.slice(name.lastIndexOf('.') + 1).toLowerCase();
198
+ (TEXT_EXT.has(ext) ? text : stored).push(f);
199
+ }
200
+ return { text, stored };
201
+ }
202
+
203
+ /**
204
+ * 渲染 loader index.html。
205
+ * @param {string} template loader-template.html 原文(Node 端 fs 读,web 端 ?raw 导入)
206
+ * @param {object[]} files 站点文件(取 index.html 的 <title>)
207
+ * @param {Array<[string, number] | [string, number, string]>} groups 卷表 [[档案名, 标志(G_GZIP|G_LAZY), 内嵌 base64?], ...](见 embedVolumes)
208
+ * @param {{ minify?: (code: string) => Promise<string>, title?: string }} [opts]
209
+ */
210
+ export function renderLoaderHtml(template, files, groups, opts = {}) {
211
+ let tpl = template;
212
+ const idx = files.find((f) => /^(index\.html|index\.htm)$/i.test(f.path));
213
+ const m = idx && new TextDecoder().decode(idx.bytes).match(/<title[^>]*>([\s\S]*?)<\/title>/i);
214
+ const title = opts.title || (m ? m[1].trim().slice(0, 120) : '');
215
+ tpl = tpl.replace('<title>...</title>', `<title>${title.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]))}</title>`);
216
+
217
+ tpl = tpl.replace(BOOT_RE, ''); // 渲染段不进 index.html,见 renderBootJs
218
+ // 引导段按卷表剥离:没有延后卷(不再等)就去掉延后处理,没有内嵌卷就去掉 base64 解码
219
+ tpl = stripSections(tpl, { LAZY: groups.some((g) => g[1] & G_LAZY && !g[2]), EMB: groups.some((g) => g[2]) });
220
+ tpl = tpl.replace(/__TPK_GROUPS__/g, () => JSON.stringify(groups.length ? groups : [['g0.tpk', G_GZIP]]));
221
+
222
+ if (!opts.minify) return tpl;
223
+ // 最小化:内联脚本过压缩器,HTML 骨架去过注释/空白
224
+ return opts.minify(tpl);
225
+ }
226
+
227
+ export const LOADER_JS = 'tpk-loader.js'; // 多页站点共享的引导脚本
228
+ export const BASE_JS = 'tpk-base.js'; // 多页站点:内嵌的首屏卷(base64),只在整体重打时变
229
+
230
+ /**
231
+ * 链上入口文件。单页站点:只有 index.html(内联引导段)。
232
+ * 多页站点:内核对有扩展名的路径不走 fallback(SPEC §6),档案里的 page2.html 直接访问会 404,
233
+ * 所以每个 HTML 页面在原路径放一个极小入口(~150B:本页标题 + TPK 路由提示 + 引用共享
234
+ * tpk-loader.js),引导段只上链一份。
235
+ * 多页站点内嵌的首屏卷放进 tpk-base.js(var TPKB = { 卷名: base64 },卷表里该项第 3 位记 1):
236
+ * 增量更新只改 tpk-loader.js 里的卷表,tpk-base.js 不变、不重传;查看器预取全部 .js、网关下浏览器并行取
237
+ * 两个脚本,所以不多一次串行读链。单页站点内嵌在 index.html 里(只有它这一个文件,别处放就得多读一次)。
238
+ * @param {{ pages?: boolean, baseFile?: Set<string> }} [opts] pages:强制多页/单页入口;缺省按 HTML 页数(>1 即多页)
239
+ * baseFile:放进 tpk-base.js 的内嵌卷(增量时沿用基准的,见 incremental.resolveBaseFile);缺省 = 全部内嵌卷(整体打包)
240
+ * 其余同 renderLoaderHtml
241
+ * @returns {Promise<Array<{ path: string, text: string }>>}
242
+ */
243
+ export async function renderEntries(template, files, groups, opts = {}) {
244
+ const pages = files.filter((f) => /\.html?$/i.test(f.path)).map((f) => f.path);
245
+ if (!(opts.pages ?? pages.length > 1)) return [{ path: 'index.html', text: await renderLoaderHtml(template, files, groups, opts) }];
246
+ for (const own of [LOADER_JS, BASE_JS]) if (files.some((f) => f.path === own)) throw new Error(`站点自带 ${own},与 TapePack 引导脚本重名`);
247
+ const inBase = opts.baseFile ?? new Set(groups.filter((g) => typeof g[2] === 'string').map((g) => g[0]));
248
+ const baseVols = groups.filter((g) => typeof g[2] === 'string' && inBase.has(g[0]));
249
+ const html = await renderLoaderHtml(template, files, groups.map((g) => (baseVols.includes(g) ? [g[0], g[1], 1] : g)), opts);
250
+ const m = html.match(/<script>([\s\S]*?)<\/script>/);
251
+ const td = new TextDecoder();
252
+ const esc = (t) => t.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
253
+ const out = [{ path: LOADER_JS, text: m[1].trim() + '\n' }];
254
+ if (baseVols.length) out.push({ path: BASE_JS, text: 'var TPKB=' + JSON.stringify(Object.fromEntries(baseVols.map((g) => [g[0], g[2]]))) + ';\n' });
255
+ for (const f of files) {
256
+ if (!pages.includes(f.path)) continue;
257
+ const root = '../'.repeat(f.path.split('/').length - 1);
258
+ const t = td.decode(f.bytes).match(/<title[^>]*>([\s\S]*?)<\/title>/i);
259
+ const tag = (baseVols.length ? '<script src="' + root + BASE_JS + '"></script>' : '')
260
+ + '<script>var TPK=' + JSON.stringify([f.path, root]) + '</script><script src="' + root + LOADER_JS + '"></script>';
261
+ out.push({ path: f.path, text: html.replace(m[0], () => tag).replace(/<title>[\s\S]*?<\/title>/, () => '<title>' + esc(t ? t[1].trim().slice(0, 120) : '') + '</title>') });
262
+ }
263
+ return out;
264
+ }
265
+
266
+ /**
267
+ * loader 渲染段(.tpk-boot.js):从模板摘出,按需剥离可选段。调用方最小化后作为
268
+ * { path: BOOT_PATH } 文件加入档案(文本组,享受 gzip),再 buildOutputs。
269
+ * 可选段(缺省按站点内容自动判断,漏判会坏站,所以判定从宽):
270
+ * worker —— Worker 拦截(站点脚本出现 Worker( 或 import.meta.url)
271
+ * modules —— ES 模块图:相对 import 改写 + import map + import.meta(站点有 ES 模块语法)
272
+ * runtime —— 运行时资源地址映射 setAttribute/属性/XHR(站点有任何脚本)
273
+ * lazy —— 延后卷:挂起还没到的文件、到了再补(会分出延后卷时,见 needsLazyRuntime;增量基准里已有延后卷时调用方强制保留)
274
+ * cssRuntime —— 运行时样式文本(JS 写入的 <style>、insertRule、FontFace)里的 url() 改写(脚本里出现 url( / FontFace;随 runtime 剥离)
275
+ * csp —— 站点自带 CSP(<meta http-equiv="Content-Security-Policy">):脚本重建完再放宽插回(HTML 里出现这个 meta)
276
+ * @param {{ worker?: boolean, modules?: boolean, runtime?: boolean, lazy?: boolean, cssRuntime?: boolean, csp?: boolean }} [opts] 强制保留/剥离
277
+ */
278
+ export function renderBootJs(template, files, opts = {}) {
279
+ const m = template.match(BOOT_RE);
280
+ if (!m) throw new Error('loader template missing tpk/boot section');
281
+ const keep = {
282
+ WORKER: opts.worker ?? usesWorker(files),
283
+ MOD: opts.modules ?? usesModules(files),
284
+ RT: opts.runtime ?? usesScripts(files),
285
+ LAZY: opts.lazy ?? needsLazyRuntime(files),
286
+ CSSRT: opts.cssRuntime ?? usesCssInScripts(files),
287
+ CSP: opts.csp ?? usesCsp(files),
288
+ };
289
+ return stripSections(m[1], keep);
290
+ }
291
+
292
+ /** 可选段:/*TPK_<名>{*\/ … /*}TPK_<名>*\/ 保留时只去掉标记,否则整段删除 */
293
+ function stripSections(js, keep) {
294
+ for (const [tag, on] of Object.entries(keep)) {
295
+ js = on
296
+ ? js.replace(new RegExp(`/\\*(TPK_${tag}\\{|\\}TPK_${tag})\\*/`, 'g'), '')
297
+ : js.replace(new RegExp(`/\\*TPK_${tag}\\{\\*/[\\s\\S]*?/\\*\\}TPK_${tag}\\*/`, 'g'), '');
298
+ }
299
+ return js;
300
+ }
301
+
302
+ /** 渲染段里延后卷支持的标志串(剥离后不再出现):装配时据此确认带延后卷的产物配的是带支持的渲染段 */
303
+ export const LAZY_RUNTIME_MARK = 'data-tpkp-';
304
+
305
+ /**
306
+ * 这批文件分卷后会不会出现延后卷(与 buildOutputs 同一套门槛)。站点文件的超集判断:分片前的大文件、
307
+ * 增量层只含部分文件时都只会更少,所以「不会」是可靠的,渲染段据此剥离延后支持。
308
+ * @param {{ lazy?: boolean | Set<string>, lazyTextMin?: number, lazyStoredMin?: number }} [opts]
309
+ */
310
+ export function needsLazyRuntime(files, opts = {}) {
311
+ if (opts.lazy === false) return false;
312
+ const lazy = opts.lazy instanceof Set ? opts.lazy : classifyLazy(files);
313
+ if (!lazy.size) return false;
314
+ const { text, stored } = splitGroups(files.filter((f) => lazy.has(f.path) || lazy.has(f.path.replace(/\.tpkpart\d+$/, ''))));
315
+ const sum = (a) => a.reduce((s, f) => s + f.bytes.length, 0);
316
+ return sum(text) >= (opts.lazyTextMin ?? LAZY_TEXT_MIN) || sum(stored) >= (opts.lazyStoredMin ?? LAZY_STORED_MIN);
317
+ }
318
+
319
+ const SCRIPT_FILE = /\.(m?js)$/i;
320
+ const HTML_FILE = /\.html?$/i;
321
+ /** 站点是否用 ES 模块(.mjs、<script type=module>、import/export 语法、import.meta) */
322
+ export function usesModules(files) {
323
+ const td = new TextDecoder();
324
+ return files.some((f) => {
325
+ if (/\.mjs$/i.test(f.path)) return true;
326
+ if (HTML_FILE.test(f.path)) return /<script\b[^>]*type\s*=\s*["']?module/i.test(td.decode(f.bytes));
327
+ if (!SCRIPT_FILE.test(f.path)) return false;
328
+ return /\bimport\s*[({"'`*\w]|\bexport\s*[{*\w]|\bimport\.meta\b/.test(td.decode(f.bytes));
329
+ });
330
+ }
331
+ /** 站点是否有任何脚本(JS 文件或 HTML 内联 / 外链 <script>) */
332
+ export function usesScripts(files) {
333
+ const td = new TextDecoder();
334
+ return files.some((f) => SCRIPT_FILE.test(f.path) || (HTML_FILE.test(f.path) && /<script\b/i.test(td.decode(f.bytes))));
335
+ }
336
+
337
+ /** 站点脚本是否在运行时写 CSS 资源地址(url( 或 FontFace;含 HTML 内联脚本,判定从宽) */
338
+ export function usesCssInScripts(files) {
339
+ const td = new TextDecoder();
340
+ return files.some((f) => {
341
+ if (SCRIPT_FILE.test(f.path)) return /url\(|FontFace/.test(td.decode(f.bytes));
342
+ if (!HTML_FILE.test(f.path)) return false;
343
+ const re = /<script\b[^>]*>([\s\S]*?)<\/script>/gi; let m; const h = td.decode(f.bytes);
344
+ while ((m = re.exec(h))) if (/url\(|FontFace/.test(m[1])) return true;
345
+ return false;
346
+ });
347
+ }
348
+
349
+ /** 站点页面是否自带 CSP(<meta http-equiv="Content-Security-Policy">;判定从宽:出现这个 http-equiv 值就算) */
350
+ export function usesCsp(files) {
351
+ const td = new TextDecoder();
352
+ return files.some((f) => HTML_FILE.test(f.path) && /http-equiv\s*=\s*["']?content-security-policy/i.test(td.decode(f.bytes)));
353
+ }
354
+
355
+ /** 站点脚本是否用到 Worker 或 import.meta.url(loader 需对应垫片) */
356
+ export function usesWorker(files) {
357
+ const td = new TextDecoder();
358
+ return files.some((f) => /\.(m?js|html?)$/i.test(f.path) && /\bimport\.meta\.url\b|\bWorker\s*\(/.test(td.decode(f.bytes)));
359
+ }
360
+
361
+ function toBase64(u8) {
362
+ if (typeof Buffer !== 'undefined') return Buffer.from(u8).toString('base64');
363
+ let s = '';
364
+ for (const b of u8) s += String.fromCharCode(b);
365
+ return btoa(s);
366
+ }
@@ -0,0 +1,88 @@
1
+ // 链上站点读取:基于 vendored TapeKit 内核(MIT,零依赖,见 vendor/tapekit/)。
2
+ // 用于 inspect(现状盘点)与 diff(增量更新计划)——只读,不签名不花钱。
3
+
4
+ import { createHash } from 'node:crypto';
5
+ import { createKernel } from '../vendor/tapekit/kernel.js';
6
+ import { NETWORKS, BSC_MAINNET } from '../vendor/tapekit/config.js';
7
+ import { buildDeployPlan } from './deploy-plan.mjs';
8
+ import { chainById } from './chains.mjs';
9
+ import { estimateGasForFiles } from './estimate.mjs';
10
+ import { isDotPath } from './external.mjs';
11
+
12
+ const sha256Hex = (u8) => '0x' + createHash('sha256').update(u8).digest('hex');
13
+
14
+ export function makeKernel() {
15
+ return createKernel(); // 多链内核:名字带区号自动选链,容器地址全链并查
16
+ }
17
+
18
+ /**
19
+ * 盘点一个链上站点:清单 + 每文件 size/sha/updatedAt + 成本估算。
20
+ * input 支持:链上名字(4246.0 或 1.2.344)/ 容器地址 / 处理器合约#ID(与内核 parseInput 一致)
21
+ */
22
+ export async function inspectSite(input, opts = {}) {
23
+ const kernel = opts.kernel || makeKernel();
24
+ const res = await kernel.resolve(input, { fresh: !!opts.fresh });
25
+ if (res.status !== 'ok') {
26
+ const e = new Error(`site status: ${res.status}`);
27
+ e.detail = res;
28
+ throw e;
29
+ }
30
+ const site = await kernel.openSite(res);
31
+ const man = site.manifest;
32
+ // 内核 site.info(p) 是单路径接口;并发逐查(内部各自合并成批请求)
33
+ const infos = await Promise.all(man.paths.map((p) => site.info(p)));
34
+ const files = man.paths.map((p, i) => {
35
+ const f = infos[i];
36
+ return { path: p, size: f?.size ?? 0, chunks: f?.chunkCount ?? 0, sha: f?.sha ?? null, updatedAt: f?.updatedAt ?? 0 };
37
+ });
38
+ const bytes = files.reduce((s, f) => s + f.size, 0);
39
+ return {
40
+ input, name: res.name, container: res.container, chainId: res.chainId,
41
+ fallback: man.fallback, files, fileCount: files.length, bytes,
42
+ gasEstimate: estimateGasForFiles(files.map((f) => ({ path: f.path, bytes: new Uint8Array(f.size) }))),
43
+ };
44
+ }
45
+
46
+ /**
47
+ * 增量更新计划:本地 TapePack 产物 vs 链上现状。
48
+ * 返回 { same, changed, added, removed, plan }——plan 只含需要上链的调用(putFile/appendChunk/removeFile)。
49
+ */
50
+ export async function diffSite(localOutputs, siteInput, opts = {}) {
51
+ const remote = await inspectSite(siteInput, opts);
52
+ const remoteBy = new Map(remote.files.map((f) => [f.path, f]));
53
+ const localBy = new Map(localOutputs.map((o) => [o.path, o]));
54
+
55
+ const same = [], changed = [], added = [];
56
+ for (const o of localOutputs) {
57
+ const r = remoteBy.get(o.path);
58
+ // 增量保留卷:链上已有即算相同(不上传、不删除);链上没有说明基准与链上不符
59
+ if (o.keep) {
60
+ if (!r || r.chunks === 0) throw new Error(`增量保留卷 ${o.path} 不在链上:基准与链上现状不符,请以链上为基准重新打包`);
61
+ same.push({ path: o.path, bytes: r.size, kept: true });
62
+ continue;
63
+ }
64
+ const localSha = sha256Hex(o.bytes);
65
+ if (!r || r.chunks === 0) added.push({ path: o.path, bytes: o.bytes.length });
66
+ else if (r.sha?.toLowerCase() === localSha.toLowerCase() && r.size === o.bytes.length) same.push({ path: o.path, bytes: o.bytes.length });
67
+ else changed.push({ path: o.path, bytes: o.bytes.length, wasBytes: r.size });
68
+ }
69
+ // 点开头的目录(.well-known/ 等)不进档案、别的程序直接读:不列入删除,单独报出
70
+ const extra = remote.files.filter((f) => f.chunks > 0 && !localBy.has(f.path));
71
+ const removed = extra.filter((f) => !isDotPath(f.path)).map((f) => ({ path: f.path, bytes: f.size }));
72
+ const keptDot = extra.filter((f) => isDotPath(f.path)).map((f) => f.path);
73
+
74
+ const needsUpload = localOutputs.filter((o) => changed.some((c) => c.path === o.path) || added.some((a) => a.path === o.path));
75
+ const plan = buildDeployPlan(needsUpload, remote.container, {
76
+ remove: removed.map((r) => r.path),
77
+ fallback: opts.fallback,
78
+ registry: chainById(remote.chainId)?.registry,
79
+ });
80
+ return {
81
+ site: { input: siteInput, name: remote.name, container: remote.container, chainId: remote.chainId },
82
+ same, changed, added, removed, keptDot,
83
+ updateBytes: needsUpload.reduce((s, o) => s + o.bytes.length, 0),
84
+ fullBytes: localOutputs.reduce((s, o) => s + o.bytes.length, 0),
85
+ plan,
86
+ planGas: estimateGasForFiles(needsUpload.map((o) => ({ path: o.path, bytes: o.bytes }))),
87
+ };
88
+ }