@walkhi/code-relax 0.1.0-beta.30 → 0.1.0-beta.32

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 (187) hide show
  1. package/README.md +95 -89
  2. package/dist/bin/cleanup-accounts.mjs +63 -63
  3. package/dist/bin/codex-remote.mjs +374 -367
  4. package/dist/bin/manage-relay-admin.mjs +20 -20
  5. package/dist/bin/migrate-relay-db.mjs +109 -109
  6. package/dist/bin/publish-android-release.mjs +63 -58
  7. package/dist/bin/publish-harmony-release.mjs +46 -0
  8. package/dist/bin/release-upload-server.mjs +174 -0
  9. package/dist/bin/self-relay-admin.mjs +8 -8
  10. package/dist/bin/self-relay-demo.mjs +33 -33
  11. package/dist/bin/self-relay-server.mjs +66 -66
  12. package/dist/build-platform.json +1 -1
  13. package/dist/get-lan-networks.ps1 +40 -40
  14. package/dist/get-shared-service-url.ps1 +6 -6
  15. package/dist/shared/app-server-events.cjs +422 -412
  16. package/dist/shared/code-browser.cjs +159 -159
  17. package/dist/shared/file-changes.cjs +31 -31
  18. package/dist/shared/file-citations.cjs +117 -117
  19. package/dist/shared/file-mentions.cjs +92 -92
  20. package/dist/shared/message-delivery.cjs +54 -54
  21. package/dist/shared/p2p-data-channel.cjs +72 -72
  22. package/dist/shared/p2p-diagnostics.cjs +28 -28
  23. package/dist/shared/p2p-file-channel.cjs +78 -74
  24. package/dist/shared/plan-pricing.cjs +32 -32
  25. package/dist/shared/question-attention.cjs +69 -0
  26. package/dist/shared/server-requests.cjs +219 -219
  27. package/dist/shared/timeline-sync.cjs +173 -173
  28. package/dist/skills/code-relax/SKILL.md +22 -22
  29. package/dist/src/archived-threads.mjs +22 -0
  30. package/dist/src/background-process.mjs +18 -18
  31. package/dist/src/bounded-process-log.mjs +63 -63
  32. package/dist/src/celsius-client.mjs +107 -107
  33. package/dist/src/code-browser.mjs +100 -100
  34. package/dist/src/desktop-launcher.mjs +46 -46
  35. package/dist/src/dots-approval-timeline.mjs +99 -99
  36. package/dist/src/dots-avatar.mjs +42 -42
  37. package/dist/src/dots-chat-projection.mjs +50 -50
  38. package/dist/src/dots-chat-server.mjs +461 -461
  39. package/dist/src/dots-message-images.mjs +319 -319
  40. package/dist/src/dots-profile.mjs +77 -77
  41. package/dist/src/dots-room-activity.mjs +94 -94
  42. package/dist/src/dots-room-client.mjs +135 -135
  43. package/dist/src/dots-room-projection.mjs +94 -94
  44. package/dist/src/dots-schedules.mjs +66 -66
  45. package/dist/src/dots-summary-tasks.mjs +32 -32
  46. package/dist/src/dots-task-catalog.mjs +430 -430
  47. package/dist/src/dots-task-files.mjs +52 -52
  48. package/dist/src/execution-health.mjs +26 -26
  49. package/dist/src/file-transfer-range.mjs +22 -22
  50. package/dist/src/file-upload-session.mjs +130 -130
  51. package/dist/src/hosted-history-app-server.mjs +103 -103
  52. package/dist/src/hosted-history-cache.mjs +113 -113
  53. package/dist/src/lan-socket-server.mjs +265 -263
  54. package/dist/src/managed-app-server.mjs +244 -244
  55. package/dist/src/message-images.mjs +9 -9
  56. package/dist/src/message-upload-files.mjs +74 -74
  57. package/dist/src/onboarding.mjs +12 -12
  58. package/dist/src/plan-pricing.mjs +47 -47
  59. package/dist/src/platform/README.md +12 -12
  60. package/dist/src/platform/codex-protocol.mjs +46 -46
  61. package/dist/src/platform/codex-runtime.mjs +3 -3
  62. package/dist/src/platform/credential-store.mjs +3 -3
  63. package/dist/src/platform/desktop-host.mjs +3 -3
  64. package/dist/src/platform/desktop-project-registration.mjs +3 -3
  65. package/dist/src/platform/desktop-project-store.mjs +208 -149
  66. package/dist/src/platform/file-opener.mjs +9 -0
  67. package/dist/src/platform/linux/README.md +3 -3
  68. package/dist/src/platform/linux/codex-runtime.mjs +56 -0
  69. package/dist/src/platform/linux/credential-store.mjs +28 -0
  70. package/dist/src/platform/linux/desktop-host.mjs +83 -0
  71. package/dist/src/platform/linux/desktop-installation.mjs +36 -0
  72. package/dist/src/platform/linux/desktop-project-registration.mjs +11 -0
  73. package/dist/src/platform/linux/network-info.mjs +11 -0
  74. package/dist/src/platform/linux/process.mjs +42 -0
  75. package/dist/src/platform/linux/project-locations.mjs +17 -0
  76. package/dist/src/platform/linux/resident-startup.mjs +52 -0
  77. package/dist/src/platform/linux/retained-runtime.mjs +76 -0
  78. package/dist/src/platform/linux/service-host.mjs +18 -0
  79. package/dist/src/platform/linux/system.mjs +49 -0
  80. package/dist/src/platform/linux/user-environment.mjs +7 -0
  81. package/dist/src/platform/macos/README.md +3 -3
  82. package/dist/src/platform/macos/codex-runtime.mjs +125 -125
  83. package/dist/src/platform/macos/credential-store.mjs +10 -10
  84. package/dist/src/platform/macos/desktop-host.mjs +82 -82
  85. package/dist/src/platform/macos/desktop-project-registration.mjs +11 -10
  86. package/dist/src/platform/macos/network-info.mjs +8 -28
  87. package/dist/src/platform/macos/project-locations.mjs +8 -8
  88. package/dist/src/platform/macos/resident-startup.mjs +84 -84
  89. package/dist/src/platform/macos/retained-runtime.mjs +127 -127
  90. package/dist/src/platform/macos/service-host.mjs +18 -18
  91. package/dist/src/platform/macos/system.mjs +29 -29
  92. package/dist/src/platform/macos/user-environment.mjs +7 -7
  93. package/dist/src/platform/native-package.mjs +58 -46
  94. package/dist/src/platform/network-info.mjs +3 -3
  95. package/dist/src/platform/path-identity.mjs +5 -5
  96. package/dist/src/platform/posix/network-candidates.mjs +23 -0
  97. package/dist/src/platform/posix/runtime-paths.mjs +25 -25
  98. package/dist/src/platform/project-locations.mjs +3 -3
  99. package/dist/src/platform/runtime-paths.mjs +8 -8
  100. package/dist/src/platform/select.mjs +5 -5
  101. package/dist/src/platform/service-host.mjs +3 -3
  102. package/dist/src/platform/user-environment.mjs +3 -3
  103. package/dist/src/platform/windows/IsolatedProcess.cs +79 -79
  104. package/dist/src/platform/windows/activate-desktop.ps1 +48 -48
  105. package/dist/src/platform/windows/background-process.mjs +30 -30
  106. package/dist/src/platform/windows/codex-runtime.mjs +215 -215
  107. package/dist/src/platform/windows/credential-store.mjs +27 -27
  108. package/dist/src/platform/windows/credential-store.ps1 +49 -49
  109. package/dist/src/platform/windows/desktop-helper.mjs +179 -179
  110. package/dist/src/platform/windows/desktop-host.mjs +58 -58
  111. package/dist/src/platform/windows/desktop-info.ps1 +14 -14
  112. package/dist/src/platform/windows/desktop-project-registration.mjs +4 -4
  113. package/dist/src/platform/windows/desktop-runtime.ps1 +10 -10
  114. package/dist/src/platform/windows/desktop-shortcuts.ps1 +21 -21
  115. package/dist/src/platform/windows/dots-profile.mjs +49 -49
  116. package/dist/src/platform/windows/launch-worker.mjs +41 -41
  117. package/dist/src/platform/windows/network-info.mjs +34 -34
  118. package/dist/src/platform/windows/project-locations.mjs +16 -16
  119. package/dist/src/platform/windows/resident-start.js +20 -20
  120. package/dist/src/platform/windows/resident-start.ps1 +7 -7
  121. package/dist/src/platform/windows/resident-startup.ps1 +83 -83
  122. package/dist/src/platform/windows/resolve-state-root.ps1 +14 -14
  123. package/dist/src/platform/windows/runtime-paths.mjs +20 -20
  124. package/dist/src/platform/windows/rustdesk-video-probe.mjs +167 -167
  125. package/dist/src/platform/windows/service-host.mjs +31 -31
  126. package/dist/src/platform/windows/start-hidden-console.ps1 +49 -49
  127. package/dist/src/platform/windows/stop-desktop.ps1 +22 -22
  128. package/dist/src/platform/windows/user-environment.mjs +50 -50
  129. package/dist/src/platform/windows/user-environment.ps1 +41 -41
  130. package/dist/src/project-archive.mjs +30 -0
  131. package/dist/src/project-create.mjs +121 -119
  132. package/dist/src/project-management.mjs +59 -69
  133. package/dist/src/project-remove.mjs +55 -0
  134. package/dist/src/project-update.mjs +56 -0
  135. package/dist/src/runtime-discovery.mjs +47 -47
  136. package/dist/src/self-relay/account-cleanup.mjs +30 -30
  137. package/dist/src/self-relay/account-connections.mjs +52 -52
  138. package/dist/src/self-relay/admin-cloud.mjs +101 -101
  139. package/dist/src/self-relay/admin-page.mjs +80 -80
  140. package/dist/src/self-relay/admin-server.mjs +74 -74
  141. package/dist/src/self-relay/admin-state.mjs +127 -127
  142. package/dist/src/self-relay/admin-style.mjs +14 -14
  143. package/dist/src/self-relay/android-releases.mjs +61 -61
  144. package/dist/src/self-relay/client.mjs +61 -61
  145. package/dist/src/self-relay/connector.mjs +439 -438
  146. package/dist/src/self-relay/debug-allowlist.mjs +33 -33
  147. package/dist/src/self-relay/demo.mjs +37 -37
  148. package/dist/src/self-relay/device-info.mjs +27 -0
  149. package/dist/src/self-relay/device-store.mjs +92 -84
  150. package/dist/src/self-relay/diagnostics.mjs +54 -54
  151. package/dist/src/self-relay/email-accounts.mjs +183 -183
  152. package/dist/src/self-relay/file-wire.mjs +13 -13
  153. package/dist/src/self-relay/lan-authorizations.mjs +71 -42
  154. package/dist/src/self-relay/lifecycle.mjs +264 -250
  155. package/dist/src/self-relay/long-pairing.mjs +40 -40
  156. package/dist/src/self-relay/mysql-state.mjs +118 -105
  157. package/dist/src/self-relay/p2p-probe.mjs +159 -159
  158. package/dist/src/self-relay/privacy-page.mjs +26 -26
  159. package/dist/src/self-relay/public-releases.mjs +52 -0
  160. package/dist/src/self-relay/rtc-file-bridge.mjs +41 -41
  161. package/dist/src/self-relay/server.mjs +1006 -932
  162. package/dist/src/self-relay/usage-daily.mjs +55 -55
  163. package/dist/src/self-relay/usage-meter.mjs +141 -141
  164. package/dist/src/self-relay/windows-route-helper.mjs +75 -75
  165. package/dist/src/self-relay/wire.mjs +36 -36
  166. package/dist/src/server.mjs +1861 -1778
  167. package/dist/src/service-control.mjs +82 -82
  168. package/dist/src/service-doctor.mjs +110 -110
  169. package/dist/src/service-health.mjs +33 -33
  170. package/dist/src/service-install.mjs +91 -91
  171. package/dist/src/service-lifecycle.mjs +138 -138
  172. package/dist/src/service-restart.mjs +81 -81
  173. package/dist/src/shared-app-server.mjs +615 -611
  174. package/dist/src/shared-catalog.mjs +132 -132
  175. package/dist/src/shared-recovery.mjs +146 -146
  176. package/dist/src/shared-thread-stream.mjs +164 -164
  177. package/dist/src/skill-install.mjs +51 -51
  178. package/dist/src/thread-title-generation.mjs +150 -150
  179. package/dist/src/turn-model-history.mjs +47 -47
  180. package/dist/src/usage-reset.mjs +17 -17
  181. package/dist/src/workstation-downloads.mjs +120 -120
  182. package/dist/tools/rtc-route-helper.ps1 +99 -99
  183. package/dist/tools/start-rtc-route-helper.ps1 +15 -15
  184. package/dist/web/vendor/markdown-it.LICENSE.txt +22 -22
  185. package/dist/web/vendor/markdown-it.umd.min.js +13 -13
  186. package/package.json +7 -6
  187. package/tools/postinstall.mjs +127 -127
@@ -1,52 +1,52 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { readMessageUploads } from './message-upload-files.mjs';
3
-
4
- // Desktop 26.1007.9446.0 Dll/Uhn/pA: reserve, upload bytes, confirm,
5
- // then include an Uploaded file sediment pointer in the user message.
6
- export async function uploadTaskFiles(files, { uploadsRoot, client, current, fetchImpl = fetch }) {
7
- const uploads = await readMessageUploads({ files }, uploadsRoot);
8
- const references = [];
9
- for (const file of uploads) {
10
- current();
11
- const reserved = await client.request('/files', { method: 'POST', body: {
12
- file_name: file.name, file_size: file.bytes.length, mime_type: file.mimeType,
13
- use_case: 'codex', store_in_library: false, timezone_offset_min: new Date().getTimezoneOffset(), reset_rate_limits: false,
14
- } });
15
- current();
16
- if (reserved?.status === 'error' || !/^[A-Za-z0-9_-]{1,256}$/.test(reserved?.file_id || '')
17
- || typeof reserved.upload_url !== 'string') throw new Error('云端文件上传地址未确认。');
18
- const url = new URL(reserved.upload_url, 'https://chatgpt.com');
19
- if (url.protocol !== 'https:' || url.username || url.password || url.port || url.hash) throw new Error('云端文件上传地址无效。');
20
- if (url.origin === 'https://chatgpt.com' && url.pathname === '/backend-api/estuary/upload_content_bytes') {
21
- const uploadUrl = url.searchParams.get('upload_url');
22
- if (!uploadUrl) throw new Error('云端文件上传地址缺少参数。');
23
- const boundary = `relax-${randomUUID()}`;
24
- const name = file.name.replaceAll('"', '%22').replaceAll('\\', '%5C');
25
- const body = Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: ${file.mimeType}\r\n\r\n`),
26
- file.bytes, Buffer.from(`\r\n--${boundary}\r\nContent-Disposition: form-data; name="upload_url"\r\n\r\n${uploadUrl}\r\n--${boundary}--\r\n`)]);
27
- await client.request(url.pathname.slice('/backend-api'.length) + url.search, {
28
- method: 'POST', body, contentType: `multipart/form-data; boundary=${boundary}`, rawResponse: true,
29
- });
30
- } else {
31
- if (!['.oaiusercontent.com', '.blob.core.windows.net', '.amazonaws.com'].some(suffix => url.hostname.endsWith(suffix))) {
32
- throw new Error('云端文件上传地址不属于文件服务。');
33
- }
34
- const aws = [...url.searchParams.keys()].some(key => key.toLowerCase() === 'x-amz-algorithm');
35
- const response = await fetchImpl(url, { method: 'PUT', body: file.bytes, redirect: 'error', signal: AbortSignal.timeout(60000),
36
- headers: { 'Content-Type': file.mimeType, ...aws ? {} : { 'x-ms-blob-type': 'BlockBlob',
37
- 'x-ms-version': '2020-04-08', 'x-ms-blob-content-type': file.mimeType } } });
38
- await response.body?.cancel();
39
- if (!response.ok) throw new Error(`云端文件上传失败(${response.status})。`);
40
- }
41
- current();
42
- await client.request(`/files/${encodeURIComponent(reserved.file_id)}/uploaded`, { method: 'POST', body: {} });
43
- current();
44
- references.push({ pointer: `sediment://${reserved.file_id}`, fileName: file.name });
45
- }
46
- return references;
47
- }
48
-
49
- export function taskFilePrompt(text, references) {
50
- if (!references.length) return text;
51
- return `# Files mentioned by the user:\n${references.map(file => `\nUploaded file: ${JSON.stringify(file).replaceAll('#', '\\u0023')}\n`).join('')}\n## My request:\n${text || '请查看上述文件。'}`;
52
- }
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readMessageUploads } from './message-upload-files.mjs';
3
+
4
+ // Desktop 26.1007.9446.0 Dll/Uhn/pA: reserve, upload bytes, confirm,
5
+ // then include an Uploaded file sediment pointer in the user message.
6
+ export async function uploadTaskFiles(files, { uploadsRoot, client, current, fetchImpl = fetch }) {
7
+ const uploads = await readMessageUploads({ files }, uploadsRoot);
8
+ const references = [];
9
+ for (const file of uploads) {
10
+ current();
11
+ const reserved = await client.request('/files', { method: 'POST', body: {
12
+ file_name: file.name, file_size: file.bytes.length, mime_type: file.mimeType,
13
+ use_case: 'codex', store_in_library: false, timezone_offset_min: new Date().getTimezoneOffset(), reset_rate_limits: false,
14
+ } });
15
+ current();
16
+ if (reserved?.status === 'error' || !/^[A-Za-z0-9_-]{1,256}$/.test(reserved?.file_id || '')
17
+ || typeof reserved.upload_url !== 'string') throw new Error('云端文件上传地址未确认。');
18
+ const url = new URL(reserved.upload_url, 'https://chatgpt.com');
19
+ if (url.protocol !== 'https:' || url.username || url.password || url.port || url.hash) throw new Error('云端文件上传地址无效。');
20
+ if (url.origin === 'https://chatgpt.com' && url.pathname === '/backend-api/estuary/upload_content_bytes') {
21
+ const uploadUrl = url.searchParams.get('upload_url');
22
+ if (!uploadUrl) throw new Error('云端文件上传地址缺少参数。');
23
+ const boundary = `relax-${randomUUID()}`;
24
+ const name = file.name.replaceAll('"', '%22').replaceAll('\\', '%5C');
25
+ const body = Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: ${file.mimeType}\r\n\r\n`),
26
+ file.bytes, Buffer.from(`\r\n--${boundary}\r\nContent-Disposition: form-data; name="upload_url"\r\n\r\n${uploadUrl}\r\n--${boundary}--\r\n`)]);
27
+ await client.request(url.pathname.slice('/backend-api'.length) + url.search, {
28
+ method: 'POST', body, contentType: `multipart/form-data; boundary=${boundary}`, rawResponse: true,
29
+ });
30
+ } else {
31
+ if (!['.oaiusercontent.com', '.blob.core.windows.net', '.amazonaws.com'].some(suffix => url.hostname.endsWith(suffix))) {
32
+ throw new Error('云端文件上传地址不属于文件服务。');
33
+ }
34
+ const aws = [...url.searchParams.keys()].some(key => key.toLowerCase() === 'x-amz-algorithm');
35
+ const response = await fetchImpl(url, { method: 'PUT', body: file.bytes, redirect: 'error', signal: AbortSignal.timeout(60000),
36
+ headers: { 'Content-Type': file.mimeType, ...aws ? {} : { 'x-ms-blob-type': 'BlockBlob',
37
+ 'x-ms-version': '2020-04-08', 'x-ms-blob-content-type': file.mimeType } } });
38
+ await response.body?.cancel();
39
+ if (!response.ok) throw new Error(`云端文件上传失败(${response.status})。`);
40
+ }
41
+ current();
42
+ await client.request(`/files/${encodeURIComponent(reserved.file_id)}/uploaded`, { method: 'POST', body: {} });
43
+ current();
44
+ references.push({ pointer: `sediment://${reserved.file_id}`, fileName: file.name });
45
+ }
46
+ return references;
47
+ }
48
+
49
+ export function taskFilePrompt(text, references) {
50
+ if (!references.length) return text;
51
+ return `# Files mentioned by the user:\n${references.map(file => `\nUploaded file: ${JSON.stringify(file).replaceAll('#', '\\u0023')}\n`).join('')}\n## My request:\n${text || '请查看上述文件。'}`;
52
+ }
@@ -1,26 +1,26 @@
1
- // Execution availability is sampled in the background. Health requests may
2
- // join the current sample so a reconnect cannot cache an initial false state.
3
- // Only connections are retried, not work.
4
- export class ExecutionHealth {
5
- constructor(read, interval = 3000) { this.read = read; this.interval = interval; this.state = { state: 'checking', ready: false }; }
6
- start() { this.stopped = false; void this.tick(); }
7
- stop() { this.stopped = true; clearTimeout(this.timer); }
8
- refresh() {
9
- if (this.work) return this.work;
10
- this.work = this.sample().finally(() => { this.work = null; });
11
- return this.work;
12
- }
13
- async sample() {
14
- try {
15
- const result = await this.read();
16
- this.state = result && typeof result === 'object'
17
- ? { ...result, ready: Boolean(result.ready), checkedAt: new Date().toISOString() }
18
- : { state: result ? 'ready' : 'unavailable', ready: Boolean(result), checkedAt: new Date().toISOString() };
19
- } catch (error) { this.state = { state: 'unavailable', ready: false, error: error.message, checkedAt: new Date().toISOString() }; }
20
- return this.state;
21
- }
22
- async tick() {
23
- await this.refresh();
24
- if (!this.stopped) { this.timer = setTimeout(() => this.tick(), this.interval); this.timer.unref?.(); }
25
- }
26
- }
1
+ // Execution availability is sampled in the background. Health requests may
2
+ // join the current sample so a reconnect cannot cache an initial false state.
3
+ // Only connections are retried, not work.
4
+ export class ExecutionHealth {
5
+ constructor(read, interval = 3000) { this.read = read; this.interval = interval; this.state = { state: 'checking', ready: false }; }
6
+ start() { this.stopped = false; void this.tick(); }
7
+ stop() { this.stopped = true; clearTimeout(this.timer); }
8
+ refresh() {
9
+ if (this.work) return this.work;
10
+ this.work = this.sample().finally(() => { this.work = null; });
11
+ return this.work;
12
+ }
13
+ async sample() {
14
+ try {
15
+ const result = await this.read();
16
+ this.state = result && typeof result === 'object'
17
+ ? { ...result, ready: Boolean(result.ready), checkedAt: new Date().toISOString() }
18
+ : { state: result ? 'ready' : 'unavailable', ready: Boolean(result), checkedAt: new Date().toISOString() };
19
+ } catch (error) { this.state = { state: 'unavailable', ready: false, error: error.message, checkedAt: new Date().toISOString() }; }
20
+ return this.state;
21
+ }
22
+ async tick() {
23
+ await this.refresh();
24
+ if (!this.stopped) { this.timer = setTimeout(() => this.tick(), this.interval); this.timer.unref?.(); }
25
+ }
26
+ }
@@ -1,22 +1,22 @@
1
- export const FILE_READ_LIMIT = 20 * 1024 * 1024;
2
- export const FILE_PART_BYTES = 256 * 1024;
3
- export class FileRangeError extends Error {
4
- constructor(statusCode, message) { super(message); this.statusCode = statusCode; }
5
- }
6
-
7
- export function fileRange(query, size, revision) {
8
- const offset = Number(query.get('offset') || 0);
9
- const length = Number(query.get('length') || FILE_PART_BYTES);
10
- if (!Number.isSafeInteger(offset) || offset < 0 || offset >= size ||
11
- !Number.isSafeInteger(length) || length < 1 || length > FILE_PART_BYTES) {
12
- throw new FileRangeError(400, '无效的文件读取范围。');
13
- }
14
- if ((offset && !query.get('revision')) || (query.has('revision') && query.get('revision') !== revision)) {
15
- throw new FileRangeError(409, '文件已变化,请重新打开。');
16
- }
17
- return { offset, length: Math.min(length, size - offset) };
18
- }
19
-
20
- export function fileRevision(stat) {
21
- return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`;
22
- }
1
+ export const FILE_READ_LIMIT = 20 * 1024 * 1024;
2
+ export const FILE_PART_BYTES = 256 * 1024;
3
+ export class FileRangeError extends Error {
4
+ constructor(statusCode, message) { super(message); this.statusCode = statusCode; }
5
+ }
6
+
7
+ export function fileRange(query, size, revision) {
8
+ const offset = Number(query.get('offset') || 0);
9
+ const length = Number(query.get('length') || FILE_PART_BYTES);
10
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset >= size ||
11
+ !Number.isSafeInteger(length) || length < 1 || length > FILE_PART_BYTES) {
12
+ throw new FileRangeError(400, '无效的文件读取范围。');
13
+ }
14
+ if ((offset && !query.get('revision')) || (query.has('revision') && query.get('revision') !== revision)) {
15
+ throw new FileRangeError(409, '文件已变化,请重新打开。');
16
+ }
17
+ return { offset, length: Math.min(length, size - offset) };
18
+ }
19
+
20
+ export function fileRevision(stat) {
21
+ return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`;
22
+ }
@@ -1,130 +1,130 @@
1
- import crypto from 'node:crypto';
2
- import fs from 'node:fs/promises';
3
- import path from 'node:path';
4
-
5
- export const MAX_FILE_BYTES = 20 * 1024 * 1024;
6
- export const MAX_FILE_COUNT = 4;
7
- export const MAX_TOTAL_BYTES = 40 * 1024 * 1024;
8
- export const FILE_CHUNK_BYTES = 256 * 1024;
9
-
10
- function validName(value) {
11
- return typeof value === 'string' && value.length > 0 && value.length <= 180 &&
12
- value !== '.' && value !== '..' && !/[\\/:*?"<>|\x00-\x1f]/.test(value) && !/[. ]$/.test(value) &&
13
- !/^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/i.test(value);
14
- }
15
-
16
- async function uniqueDestination(directory, name, partial) {
17
- const dot = name.lastIndexOf('.');
18
- const stem = dot > 0 ? name.slice(0, dot) : name;
19
- const extension = dot > 0 ? name.slice(dot) : '';
20
- for (let index = 0; ; index++) {
21
- const suffix = index ? ` (${index})` : '';
22
- const candidate = index ? `${stem.slice(0, 180 - extension.length - suffix.length)}${suffix}${extension}` : name;
23
- if (!candidate || candidate.length > 180) throw new Error('同名文件过多,文件名无法继续编号');
24
- const destination = path.join(directory, candidate);
25
- try {
26
- await fs.link(partial, destination);
27
- await fs.unlink(partial);
28
- return { name: candidate, path: destination };
29
- } catch (error) {
30
- if (error.code !== 'EEXIST') throw error;
31
- }
32
- }
33
- }
34
-
35
- export function createFileUploadSession(root) {
36
- let upload;
37
- const completed = new Map();
38
- let completedBytes = 0;
39
- const directory = path.join(root, 'incoming');
40
- const clearActive = async () => {
41
- const current = upload;
42
- upload = undefined;
43
- if (!current) return;
44
- await current.handle.close().catch(() => {});
45
- await fs.unlink(current.partial).catch(() => {});
46
- };
47
- const clear = async () => {
48
- await clearActive();
49
- for (const file of completed.values()) {
50
- await fs.unlink(file.path).catch(() => {});
51
- if (file.preview) await fs.unlink(file.preview.path).catch(() => {});
52
- }
53
- completed.clear(); completedBytes = 0;
54
- };
55
- return {
56
- async receive(message) {
57
- if (!message || typeof message !== 'object') throw new Error('无效的文件上传请求');
58
- if (message.type === 'cancel') { await clear(); return { type: 'cancelled' }; }
59
- if (message.type === 'commit') {
60
- if (upload) throw new Error('文件尚未上传完成');
61
- completed.clear(); completedBytes = 0;
62
- return { type: 'committed' };
63
- }
64
- if (message.type === 'remove') {
65
- if (upload) throw new Error('请等待当前文件上传完成');
66
- const file = completed.get(message.id);
67
- if (!file) throw new Error('文件上传会话已失效');
68
- await fs.unlink(file.path);
69
- if (file.preview) await fs.unlink(file.preview.path);
70
- completed.delete(message.id); completedBytes -= file.size;
71
- return { type: 'removed', id: message.id };
72
- }
73
- if (message.type === 'start') {
74
- if (upload) throw new Error('已有文件正在上传');
75
- if (!validName(message.name)) throw new Error('文件名无效');
76
- if (!Number.isSafeInteger(message.size) || message.size < 1 || message.size > MAX_FILE_BYTES) {
77
- throw new Error('单个文件不得超过 20 MiB');
78
- }
79
- const parent = message.previewOf ? completed.get(message.previewOf) : null;
80
- if (message.previewOf && (!parent || parent.preview || message.size > FILE_CHUNK_BYTES)) throw new Error('预览图关联无效或超过 256 KiB');
81
- if (!parent && (completed.size >= MAX_FILE_COUNT || completedBytes + message.size > MAX_TOTAL_BYTES)) {
82
- throw new Error('每条消息最多 4 个文件,合计不超过 40 MiB');
83
- }
84
- if (!/^[a-f0-9]{64}$/i.test(message.sha256 || '')) throw new Error('文件校验值无效');
85
- const id = crypto.randomUUID();
86
- await fs.mkdir(directory, { recursive: true });
87
- const partial = path.join(directory, `.upload-${id}`);
88
- const handle = await fs.open(partial, 'wx');
89
- upload = { id, partial, handle, name: parent ? `${id}.preview.jpg` : message.name, size: message.size, parent,
90
- expected: message.sha256.toLowerCase(), hash: crypto.createHash('sha256'), offset: 0 };
91
- return { type: 'started', id, offset: 0, chunkBytes: FILE_CHUNK_BYTES };
92
- }
93
- const current = upload;
94
- if (!current || message.id !== current.id) throw new Error('文件上传会话已失效');
95
- if (message.type === 'chunk') {
96
- if (!Number.isSafeInteger(message.offset) || message.offset !== current.offset) {
97
- return { type: 'offset', id: current.id, offset: current.offset };
98
- }
99
- if (!Buffer.isBuffer(message.data)) {
100
- throw new Error('文件分片格式无效');
101
- }
102
- const bytes = message.data;
103
- if (!bytes.length || bytes.length > FILE_CHUNK_BYTES || current.offset + bytes.length > current.size) {
104
- throw new Error('文件分片大小无效');
105
- }
106
- await current.handle.writeFile(bytes);
107
- current.hash.update(bytes);
108
- current.offset += bytes.length;
109
- return { type: 'offset', id: current.id, offset: current.offset };
110
- }
111
- if (message.type === 'finish') {
112
- if (current.offset !== current.size || current.hash.digest('hex') !== current.expected) {
113
- await clear(); throw new Error('文件大小或 SHA-256 校验失败');
114
- }
115
- await current.handle.sync();
116
- await current.handle.close();
117
- upload = undefined;
118
- let saved;
119
- try { saved = await uniqueDestination(directory, current.name, current.partial); }
120
- catch (error) { await fs.unlink(current.partial).catch(() => {}); throw error; }
121
- const result = { type: 'completed', id: current.id, ...saved, size: current.size, sha256: current.expected };
122
- if (current.parent) current.parent.preview = result;
123
- else { completed.set(current.id, result); completedBytes += current.size; }
124
- return result;
125
- }
126
- throw new Error('不支持的文件上传操作');
127
- },
128
- close: clear,
129
- };
130
- }
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ export const MAX_FILE_BYTES = 20 * 1024 * 1024;
6
+ export const MAX_FILE_COUNT = 4;
7
+ export const MAX_TOTAL_BYTES = 40 * 1024 * 1024;
8
+ export const FILE_CHUNK_BYTES = 256 * 1024;
9
+
10
+ function validName(value) {
11
+ return typeof value === 'string' && value.length > 0 && value.length <= 180 &&
12
+ value !== '.' && value !== '..' && !/[\\/:*?"<>|\x00-\x1f]/.test(value) && !/[. ]$/.test(value) &&
13
+ !/^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/i.test(value);
14
+ }
15
+
16
+ async function uniqueDestination(directory, name, partial) {
17
+ const dot = name.lastIndexOf('.');
18
+ const stem = dot > 0 ? name.slice(0, dot) : name;
19
+ const extension = dot > 0 ? name.slice(dot) : '';
20
+ for (let index = 0; ; index++) {
21
+ const suffix = index ? ` (${index})` : '';
22
+ const candidate = index ? `${stem.slice(0, 180 - extension.length - suffix.length)}${suffix}${extension}` : name;
23
+ if (!candidate || candidate.length > 180) throw new Error('同名文件过多,文件名无法继续编号');
24
+ const destination = path.join(directory, candidate);
25
+ try {
26
+ await fs.link(partial, destination);
27
+ await fs.unlink(partial);
28
+ return { name: candidate, path: destination };
29
+ } catch (error) {
30
+ if (error.code !== 'EEXIST') throw error;
31
+ }
32
+ }
33
+ }
34
+
35
+ export function createFileUploadSession(root) {
36
+ let upload;
37
+ const completed = new Map();
38
+ let completedBytes = 0;
39
+ const directory = path.join(root, 'incoming');
40
+ const clearActive = async () => {
41
+ const current = upload;
42
+ upload = undefined;
43
+ if (!current) return;
44
+ await current.handle.close().catch(() => {});
45
+ await fs.unlink(current.partial).catch(() => {});
46
+ };
47
+ const clear = async () => {
48
+ await clearActive();
49
+ for (const file of completed.values()) {
50
+ await fs.unlink(file.path).catch(() => {});
51
+ if (file.preview) await fs.unlink(file.preview.path).catch(() => {});
52
+ }
53
+ completed.clear(); completedBytes = 0;
54
+ };
55
+ return {
56
+ async receive(message) {
57
+ if (!message || typeof message !== 'object') throw new Error('无效的文件上传请求');
58
+ if (message.type === 'cancel') { await clear(); return { type: 'cancelled' }; }
59
+ if (message.type === 'commit') {
60
+ if (upload) throw new Error('文件尚未上传完成');
61
+ completed.clear(); completedBytes = 0;
62
+ return { type: 'committed' };
63
+ }
64
+ if (message.type === 'remove') {
65
+ if (upload) throw new Error('请等待当前文件上传完成');
66
+ const file = completed.get(message.id);
67
+ if (!file) throw new Error('文件上传会话已失效');
68
+ await fs.unlink(file.path);
69
+ if (file.preview) await fs.unlink(file.preview.path);
70
+ completed.delete(message.id); completedBytes -= file.size;
71
+ return { type: 'removed', id: message.id };
72
+ }
73
+ if (message.type === 'start') {
74
+ if (upload) throw new Error('已有文件正在上传');
75
+ if (!validName(message.name)) throw new Error('文件名无效');
76
+ if (!Number.isSafeInteger(message.size) || message.size < 1 || message.size > MAX_FILE_BYTES) {
77
+ throw new Error('单个文件不得超过 20 MiB');
78
+ }
79
+ const parent = message.previewOf ? completed.get(message.previewOf) : null;
80
+ if (message.previewOf && (!parent || parent.preview || message.size > FILE_CHUNK_BYTES)) throw new Error('预览图关联无效或超过 256 KiB');
81
+ if (!parent && (completed.size >= MAX_FILE_COUNT || completedBytes + message.size > MAX_TOTAL_BYTES)) {
82
+ throw new Error('每条消息最多 4 个文件,合计不超过 40 MiB');
83
+ }
84
+ if (!/^[a-f0-9]{64}$/i.test(message.sha256 || '')) throw new Error('文件校验值无效');
85
+ const id = crypto.randomUUID();
86
+ await fs.mkdir(directory, { recursive: true });
87
+ const partial = path.join(directory, `.upload-${id}`);
88
+ const handle = await fs.open(partial, 'wx');
89
+ upload = { id, partial, handle, name: parent ? `${id}.preview.jpg` : message.name, size: message.size, parent,
90
+ expected: message.sha256.toLowerCase(), hash: crypto.createHash('sha256'), offset: 0 };
91
+ return { type: 'started', id, offset: 0, chunkBytes: FILE_CHUNK_BYTES };
92
+ }
93
+ const current = upload;
94
+ if (!current || message.id !== current.id) throw new Error('文件上传会话已失效');
95
+ if (message.type === 'chunk') {
96
+ if (!Number.isSafeInteger(message.offset) || message.offset !== current.offset) {
97
+ return { type: 'offset', id: current.id, offset: current.offset };
98
+ }
99
+ if (!Buffer.isBuffer(message.data)) {
100
+ throw new Error('文件分片格式无效');
101
+ }
102
+ const bytes = message.data;
103
+ if (!bytes.length || bytes.length > FILE_CHUNK_BYTES || current.offset + bytes.length > current.size) {
104
+ throw new Error('文件分片大小无效');
105
+ }
106
+ await current.handle.writeFile(bytes);
107
+ current.hash.update(bytes);
108
+ current.offset += bytes.length;
109
+ return { type: 'offset', id: current.id, offset: current.offset };
110
+ }
111
+ if (message.type === 'finish') {
112
+ if (current.offset !== current.size || current.hash.digest('hex') !== current.expected) {
113
+ await clear(); throw new Error('文件大小或 SHA-256 校验失败');
114
+ }
115
+ await current.handle.sync();
116
+ await current.handle.close();
117
+ upload = undefined;
118
+ let saved;
119
+ try { saved = await uniqueDestination(directory, current.name, current.partial); }
120
+ catch (error) { await fs.unlink(current.partial).catch(() => {}); throw error; }
121
+ const result = { type: 'completed', id: current.id, ...saved, size: current.size, sha256: current.expected };
122
+ if (current.parent) current.parent.preview = result;
123
+ else { completed.set(current.id, result); completedBytes += current.size; }
124
+ return result;
125
+ }
126
+ throw new Error('不支持的文件上传操作');
127
+ },
128
+ close: clear,
129
+ };
130
+ }