e10-ebuilder-prototype 0.5.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 (54) hide show
  1. package/README.md +113 -0
  2. package/dist/api.d.ts +12 -0
  3. package/dist/api.js +125 -0
  4. package/dist/application.d.ts +7 -0
  5. package/dist/application.js +16 -0
  6. package/dist/archive.d.ts +130 -0
  7. package/dist/archive.js +151 -0
  8. package/dist/capture.d.ts +15 -0
  9. package/dist/capture.js +440 -0
  10. package/dist/common.d.ts +20 -0
  11. package/dist/common.js +87 -0
  12. package/dist/dom.d.mts +1 -0
  13. package/dist/dom.mjs +58 -0
  14. package/dist/form-context.d.ts +3 -0
  15. package/dist/form-context.js +58 -0
  16. package/dist/form-runtime.d.mts +2 -0
  17. package/dist/form-runtime.mjs +149 -0
  18. package/dist/forms.d.ts +51 -0
  19. package/dist/forms.js +603 -0
  20. package/dist/html.d.ts +22 -0
  21. package/dist/html.js +427 -0
  22. package/dist/index.d.ts +2 -0
  23. package/dist/index.js +370 -0
  24. package/dist/menus.d.ts +32 -0
  25. package/dist/menus.js +330 -0
  26. package/dist/model.d.ts +164 -0
  27. package/dist/model.js +8 -0
  28. package/dist/offline-store.d.mts +5 -0
  29. package/dist/offline-store.mjs +80 -0
  30. package/dist/platform.d.ts +10 -0
  31. package/dist/platform.js +123 -0
  32. package/dist/readiness.d.ts +124 -0
  33. package/dist/readiness.js +529 -0
  34. package/dist/runtime-support.d.mts +52 -0
  35. package/dist/runtime-support.mjs +279 -0
  36. package/dist/site.d.ts +34 -0
  37. package/dist/site.js +195 -0
  38. package/dist/store.d.ts +90 -0
  39. package/dist/store.js +296 -0
  40. package/dist/templates/form-guide.md +539 -0
  41. package/dist/templates/index.html +803 -0
  42. package/dist/templates/placeholder.html +143 -0
  43. package/dist/templates/workflow-guide.md +95 -0
  44. package/dist/templates/workflow-presets.json +89 -0
  45. package/dist/temporary-records.d.ts +15 -0
  46. package/dist/temporary-records.js +286 -0
  47. package/dist/vendor/environment-auth.d.ts +61 -0
  48. package/dist/vendor/environment-auth.js +455 -0
  49. package/dist/workflow-runtime.d.mts +2 -0
  50. package/dist/workflow-runtime.mjs +298 -0
  51. package/dist/workflows.d.ts +28 -0
  52. package/dist/workflows.js +90 -0
  53. package/docs/PROTOCOL.md +299 -0
  54. package/package.json +45 -0
package/dist/store.js ADDED
@@ -0,0 +1,296 @@
1
+ import { reservedWindowsName } from './runtime-support.mjs';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { atomicJson, CaptureError, digest, fileDigest, mapLimit, id, readJson, safeName, VERSION, } from './common.js';
7
+ import { defaults, } from './model.js';
8
+ import { temporarySummary } from './temporary-records.js';
9
+ import { formResult, verifiedForm } from './forms.js';
10
+ import { navigationKey } from './menus.js';
11
+ export const htmlArtifact = (key, kind = 'page') => kind === 'form' ? `form-html/${navigationKey(key)}.html` : `${id(key)}.html`;
12
+ export class Store {
13
+ root;
14
+ meta;
15
+ artifacts;
16
+ constructor(dir) {
17
+ this.root = path.resolve(dir);
18
+ // Stable on-disk format keeps existing tasks resumable after the rename.
19
+ this.meta = path.join(this.root, '.e10-page-capture');
20
+ this.artifacts = path.join(this.root, 'artifacts');
21
+ }
22
+ async load() {
23
+ const s = await readJson(path.join(this.meta, 'task.json'));
24
+ if (s.schema !== 1 || !s.settings || !/^\d+$/.test(s.appId))
25
+ throw new CaptureError('STATE_INVALID', '任务状态格式无效');
26
+ return s;
27
+ }
28
+ async save(s) {
29
+ await atomicJson(path.join(this.meta, 'task.json'), s);
30
+ }
31
+ async init(appId, settings = {}) {
32
+ try {
33
+ const s = await this.load();
34
+ if (s.appId !== appId)
35
+ throw new CaptureError('TASK_MISMATCH', '目录已绑定另一个 appId');
36
+ if (Object.entries(settings).some(([k, v]) => s.settings[k] !== v))
37
+ throw new CaptureError('SETTINGS_MISMATCH', '已有任务参数不同,请使用新目录');
38
+ return s;
39
+ }
40
+ catch (e) {
41
+ if (e.code !== 'ENOENT')
42
+ throw e;
43
+ }
44
+ const s = {
45
+ schema: 1,
46
+ version: VERSION,
47
+ appId: id(appId, 'appId'),
48
+ createdAt: new Date().toISOString(),
49
+ htmlRequired: true,
50
+ siteRequired: true,
51
+ menuRequired: true,
52
+ settings: { ...defaults, ...settings },
53
+ };
54
+ await this.save(s);
55
+ return s;
56
+ }
57
+ bind(s, a) {
58
+ if (s.requestedOrigin && new URL(a.baseUrl).origin !== s.requestedOrigin)
59
+ throw new CaptureError('ENVIRONMENT_MISMATCH', '应用地址与当前登录环境不一致,请使用对应环境的登录 Profile');
60
+ const environment = {
61
+ origin: new URL(a.baseUrl).origin,
62
+ identityHash: digest(`${a.userId}\0${a.tenantKey}`),
63
+ };
64
+ if (s.environment && JSON.stringify(environment) !== JSON.stringify(s.environment))
65
+ throw new CaptureError('ENVIRONMENT_MISMATCH', '当前登录环境/用户/租户与任务不符,请切换原 Profile 或使用新目录');
66
+ s.environment = environment;
67
+ }
68
+ receiptPath(pageId) {
69
+ return path.join(this.meta, 'results', `${id(pageId)}.json`);
70
+ }
71
+ async result(pageId) {
72
+ try {
73
+ return await readJson(this.receiptPath(pageId));
74
+ }
75
+ catch (e) {
76
+ if (e.code === 'ENOENT')
77
+ return undefined;
78
+ throw e;
79
+ }
80
+ }
81
+ async saveResult(r) {
82
+ await atomicJson(this.receiptPath(r.id), r);
83
+ }
84
+ htmlReceiptPath(pageId, kind = 'page') {
85
+ return path.join(this.meta, kind === 'form' ? 'form-html-results' : 'html-results', `${kind === 'form' ? navigationKey(pageId) : id(pageId)}.json`);
86
+ }
87
+ async htmlResult(pageId, kind = 'page') {
88
+ try {
89
+ return await readJson(this.htmlReceiptPath(pageId, kind));
90
+ }
91
+ catch (e) {
92
+ if (e.code === 'ENOENT')
93
+ return undefined;
94
+ throw e;
95
+ }
96
+ }
97
+ async saveHtmlResult(r) {
98
+ await atomicJson(this.htmlReceiptPath(r.id, r.kind), r);
99
+ }
100
+ async verifiedHtml(r, png) {
101
+ if (r?.status !== 'succeeded' ||
102
+ !r.file ||
103
+ !r.sha256 ||
104
+ r.id !== png?.id ||
105
+ r.file !== htmlArtifact(png.id, r.kind) ||
106
+ r.sourceSha256 !== png?.sha256 ||
107
+ png?.status !== 'succeeded')
108
+ return false;
109
+ try {
110
+ return (await fileDigest(this.artifact(r.file))) === r.sha256;
111
+ }
112
+ catch {
113
+ return false;
114
+ }
115
+ }
116
+ artifact(relative) {
117
+ const p = path.resolve(this.artifacts, relative);
118
+ if (!p.startsWith(this.artifacts + path.sep))
119
+ throw new CaptureError('PATH_ESCAPE', '文件路径超出产物目录');
120
+ return p;
121
+ }
122
+ async verified(r) {
123
+ if (r?.status !== 'succeeded' || !r.file || !r.sha256)
124
+ return false;
125
+ try {
126
+ const filename = this.artifact(r.file), handle = await fs.open(filename, 'r');
127
+ let valid = false;
128
+ try {
129
+ const header = Buffer.alloc(8);
130
+ const result = await handle.read(header, 0, 8, 0);
131
+ valid =
132
+ (await handle.stat()).size === r.bytes &&
133
+ result.bytesRead === 8 &&
134
+ header.equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
135
+ }
136
+ finally {
137
+ await handle.close();
138
+ }
139
+ return valid && (await fileDigest(filename)) === r.sha256;
140
+ }
141
+ catch {
142
+ return false;
143
+ }
144
+ }
145
+ async status(s) {
146
+ const results = await mapLimit(s.pages || [], 4, (p) => this.result(p.id));
147
+ const valid = await mapLimit(results, 4, (r) => this.verified(r));
148
+ const succeeded = valid.filter(Boolean).length;
149
+ const pending = results.filter((r, i) => !r || r.status === 'running' || (r.status === 'succeeded' && !valid[i])).length;
150
+ const failed = results.filter((r) => r?.status === 'failed').length;
151
+ const htmlResults = await mapLimit(s.pages || [], 4, (p) => this.htmlResult(p.id));
152
+ const htmlValid = await mapLimit(htmlResults, 4, async (r) => {
153
+ const index = s.pages.findIndex((p) => p.id === r?.id);
154
+ return index >= 0 && valid[index] && (await this.verifiedHtml(r, results[index]));
155
+ });
156
+ const html = {
157
+ required: !!s.htmlRequired,
158
+ succeeded: htmlValid.filter(Boolean).length,
159
+ failed: htmlResults.filter((r, i) => valid[i] && r?.status === 'failed' && r.sourceSha256 === results[i]?.sha256).length,
160
+ running: htmlResults.filter((r, i) => valid[i] && r?.status === 'running' && r.sourceSha256 === results[i]?.sha256).length,
161
+ pending: 0,
162
+ };
163
+ html.pending = s.htmlRequired ? succeeded - html.succeeded - html.failed : 0;
164
+ const pageHtml = { ...html };
165
+ const formResults = await mapLimit(s.menuRequired ? s.formPages || [] : [], 4, (p) => formResult(this, p.id));
166
+ const formValid = await mapLimit(formResults, 4, (r) => verifiedForm(this, r));
167
+ const formHtmlResults = await mapLimit(s.menuRequired ? s.formPages || [] : [], 4, (p) => this.htmlResult(p.id, 'form'));
168
+ const formHtmlValid = await mapLimit(formHtmlResults, 4, (r) => {
169
+ const i = s.formPages.findIndex((p) => p.id === r?.id);
170
+ return i >= 0 && formValid[i] ? this.verifiedHtml(r, formResults[i]) : Promise.resolve(false);
171
+ });
172
+ const collection = {
173
+ succeeded: formValid.filter(Boolean).length,
174
+ failed: formResults.filter((r) => r?.status === 'failed').length,
175
+ pending: formResults.filter((r, i) => !r || r.status === 'running' || (r.status === 'succeeded' && !formValid[i])).length,
176
+ };
177
+ const formHtml = {
178
+ succeeded: formHtmlValid.filter(Boolean).length,
179
+ failed: formHtmlResults.filter((r, i) => formValid[i] && r?.status === 'failed' && r.sourceSha256 === formResults[i]?.sha256).length,
180
+ running: formHtmlResults.filter((r, i) => formValid[i] && r?.status === 'running' && r.sourceSha256 === formResults[i]?.sha256).length,
181
+ pending: 0,
182
+ };
183
+ formHtml.pending = collection.succeeded - formHtml.succeeded - formHtml.failed;
184
+ for (const key of ['succeeded', 'failed', 'running', 'pending'])
185
+ html[key] += formHtml[key];
186
+ let state = !s.pages ||
187
+ (s.siteRequired && (!s.forms || !s.appName)) ||
188
+ (s.menuRequired && (!s.menus || !s.formPages))
189
+ ? 'DISCOVER'
190
+ : collection.pending
191
+ ? 'COLLECT'
192
+ : pending
193
+ ? 'CAPTURE'
194
+ : html.pending
195
+ ? 'HTML'
196
+ : 'PACKAGE';
197
+ const temporaryRecords = await temporarySummary(this);
198
+ if (temporaryRecords.pending && state !== 'DISCOVER')
199
+ state = 'COLLECT';
200
+ const partial = !!(failed || collection.failed || (s.htmlRequired && html.failed));
201
+ if (s.archive && state === 'PACKAGE' && s.archive.partial === partial) {
202
+ try {
203
+ const siteValid = !s.siteRequired ||
204
+ (s.archive.site &&
205
+ (await mapLimit(s.archive.site.files, 4, async (f) => (await fileDigest(this.artifact(f.path))) === f.sha256)).every(Boolean));
206
+ if (siteValid && (await fileDigest(this.artifact(s.archive.path))) === s.archive.sha256)
207
+ state = partial ? 'PARTIAL' : 'DONE';
208
+ }
209
+ catch {
210
+ /* missing archive requires packaging */
211
+ }
212
+ }
213
+ return {
214
+ state,
215
+ total: s.pages?.length ?? 0,
216
+ succeeded,
217
+ failed,
218
+ pending,
219
+ results,
220
+ htmlResults,
221
+ formResults,
222
+ formHtmlResults,
223
+ pageHtml,
224
+ formHtml,
225
+ collection,
226
+ temporaryRecords,
227
+ html,
228
+ appName: s.appName,
229
+ forms: s.menuRequired
230
+ ? {
231
+ total: s.formPages?.length ?? 0,
232
+ integrated: formHtml.succeeded,
233
+ placeholders: collection.failed + formHtml.failed,
234
+ collection,
235
+ html: formHtml,
236
+ }
237
+ : { total: s.forms?.length ?? 0, integrated: 0, placeholders: s.forms?.length ?? 0 },
238
+ entry: (state === 'DONE' || state === 'PARTIAL') && s.archive?.site
239
+ ? this.artifact(s.archive.site.entry)
240
+ : undefined,
241
+ archive: state === 'DONE' || state === 'PARTIAL' ? this.artifact(s.archive.path) : undefined,
242
+ };
243
+ }
244
+ async lock(action) {
245
+ await fs.mkdir(this.meta, { recursive: true });
246
+ const filename = path.join(this.meta, 'lock.json');
247
+ const token = randomUUID();
248
+ for (let i = 0; i < 2; i++) {
249
+ try {
250
+ await fs.writeFile(filename, JSON.stringify({ pid: process.pid, host: os.hostname(), token }), { flag: 'wx', mode: 0o600 });
251
+ break;
252
+ }
253
+ catch (e) {
254
+ if (e.code !== 'EEXIST')
255
+ throw e;
256
+ const owner = await readJson(filename).catch(() => null);
257
+ let alive = true;
258
+ if (owner?.host === os.hostname()) {
259
+ try {
260
+ process.kill(owner.pid, 0);
261
+ }
262
+ catch (err) {
263
+ if (err.code === 'ESRCH')
264
+ alive = false;
265
+ }
266
+ }
267
+ if (alive || i === 1)
268
+ throw new CaptureError('TASK_LOCKED', '另一个进程持有任务锁;不要并行操作同一目录', owner);
269
+ await fs.rm(filename);
270
+ }
271
+ }
272
+ try {
273
+ return await action();
274
+ }
275
+ finally {
276
+ const owner = await readJson(filename).catch(() => null);
277
+ if (owner?.token === token)
278
+ await fs.rm(filename, { force: true });
279
+ }
280
+ }
281
+ }
282
+ export function filenames(pages) {
283
+ const names = pages.map((p) => {
284
+ const name = safeName(p.name);
285
+ return reservedWindowsName(name) ? `_${name}` : name;
286
+ });
287
+ const keys = names.map((n) => n.toLocaleLowerCase('en-US'));
288
+ const used = new Set();
289
+ return new Map(pages.map((p, i) => {
290
+ let n = keys.filter((v) => v === keys[i]).length > 1 ? `${names[i]}-${p.id}` : names[i];
291
+ while (used.has(n.toLowerCase()))
292
+ n += `-${p.id}`;
293
+ used.add(n.toLowerCase());
294
+ return [p.id, `${n}.png`];
295
+ }));
296
+ }