@mokuyoaxis/dsh-iris 0.2.6 → 0.2.7
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/CHANGELOG.md +23 -1
- package/README.en.md +28 -19
- package/README.md +27 -18
- package/bin/dsh-iris.js +140 -26
- package/docs/AGENT_QUICKSTART.md +5 -5
- package/docs/ARCHITECTURE.md +9 -3
- package/docs/CLI_MANAGEMENT.md +1 -1
- package/docs/CORE_RUNTIME_CONTRACT.md +36 -5
- package/docs/CORE_SKILLS.md +62 -0
- package/docs/DSH_COMPATIBILITY.md +4 -4
- package/docs/DSH_CORE_MIGRATION.md +4 -2
- package/docs/DSH_PROVIDER_CONNECTIONS.md +1 -1
- package/docs/DSH_RUNTIME.md +36 -0
- package/docs/FUNCTION_MODEL_POOLS.md +1 -1
- package/docs/HEADLESS_CLI.md +11 -3
- package/docs/HOST_DOCTOR.md +13 -1
- package/docs/IMAGE_FLOW.md +71 -0
- package/docs/LOCAL_API.md +60 -0
- package/docs/LOCAL_API_CONTRACT.md +115 -0
- package/docs/LOCAL_CREATION.md +50 -0
- package/docs/PROVIDER_HEALTH.md +1 -1
- package/docs/ROADMAP.md +14 -2
- package/docs/SECURITY.md +9 -1
- package/docs/SYNC_DELIVERY_RECOVERY.md +2 -0
- package/docs/VISION_CLI.md +1 -1
- package/docs/WEB_PROTOTYPE.md +47 -0
- package/docs/releases/0.2.7.md +62 -0
- package/lib/actions.js +79 -68
- package/lib/api.js +38 -38
- package/lib/application-runtime.js +186 -0
- package/lib/client.js +89 -3
- package/lib/core-artifact-store.js +5 -0
- package/lib/core-maintenance.js +25 -2
- package/lib/core-runtime.js +19 -0
- package/lib/core-tasks.js +62 -8
- package/lib/creation-request-store.js +159 -0
- package/lib/doctor.js +12 -1
- package/lib/dsh-core-adapter.js +666 -572
- package/lib/dsh-host-adapter.js +4 -1
- package/lib/flow-store.js +195 -0
- package/lib/image-flow.js +188 -0
- package/lib/index.js +59 -53
- package/lib/local-api-projection.js +27 -0
- package/lib/local-api.js +381 -0
- package/lib/local-creation.js +85 -0
- package/lib/private-storage.js +12 -0
- package/lib/provider-task-runner.js +22 -2
- package/lib/render-address.js +35 -0
- package/lib/render.js +52 -13
- package/lib/skill-installation.js +199 -0
- package/lib/workbench-artifacts.js +1 -2
- package/lib/workbench-management.js +8 -9
- package/package.json +3 -2
- package/skills/iris-core/SKILL.md +40 -0
- package/skills/iris-core/agents/openai.yaml +4 -0
- package/skills/iris-core/references/cli.md +95 -0
- package/skills/iris-core/references/tasks.md +58 -0
- package/user_guide.md +14 -8
package/lib/local-api.js
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { createApplicationRuntime } from './application-runtime.js';
|
|
6
|
+
import { normalizeCoreOptions } from './core-contract.js';
|
|
7
|
+
import { createCoreRuntime } from './core-runtime.js';
|
|
8
|
+
import { inspectFlowRoot } from './flow-store.js';
|
|
9
|
+
import { inspectCoreArtifactMetadata, readCoreArtifactBytes } from './core-artifacts.js';
|
|
10
|
+
import { imageCandidatesFromCatalog, loadProviderCatalog, providerTaskBinding } from './provider-catalog.js';
|
|
11
|
+
import { supportsImageEdit } from './provider-protocol.js';
|
|
12
|
+
import { projectLocalApiFlow, projectLocalApiRunSummary } from './local-api-projection.js';
|
|
13
|
+
import { inspectCreationRoot } from './creation-request-store.js';
|
|
14
|
+
import { MAX_IMPORT_IMAGE_BYTES } from './image-import.js';
|
|
15
|
+
|
|
16
|
+
export const MAX_LOCAL_API_IMAGE_BYTES = 64 * 1024 * 1024;
|
|
17
|
+
const RUN = /^run_[a-f0-9]{24}$/;
|
|
18
|
+
const ARTIFACT = /^artifact_[a-f0-9]{24}$/;
|
|
19
|
+
const KEY = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
|
|
20
|
+
const IMAGE_TYPES = new Map([['image/png', 'png'], ['image/jpeg', 'jpg'], ['image/webp', 'webp']]);
|
|
21
|
+
const digest = value => crypto.createHash('sha256').update(value).digest();
|
|
22
|
+
const CSP = "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'";
|
|
23
|
+
|
|
24
|
+
export class LocalApiError extends Error {
|
|
25
|
+
constructor(code, message, status = 400) { super(message); this.name = 'LocalApiError'; this.code = code; this.status = status; }
|
|
26
|
+
}
|
|
27
|
+
function fail(code, message, status) { throw new LocalApiError(code, message, status); }
|
|
28
|
+
|
|
29
|
+
function httpError(error) {
|
|
30
|
+
if (error instanceof LocalApiError) return error;
|
|
31
|
+
if (error?.code === 'IRIS_CORE_DATA_ROOT_BUSY') return new LocalApiError('IRIS_API_ROOT_BUSY', '该数据根已有活动写者,请稍后显式重试', 409);
|
|
32
|
+
if (error?.code === 'IRIS_CREATION_RESULT_UNAVAILABLE') return new LocalApiError('IRIS_API_RESULT_UNAVAILABLE', '原创建结果已不可用;同 Key 不会重新创建或复活作品', 409);
|
|
33
|
+
if (error?.code === 'IRIS_CREATION_KEY_CONFLICT') return new LocalApiError('IRIS_API_KEY_CONFLICT', '该 Key 已绑定其他输入', 409);
|
|
34
|
+
if (['IRIS_CREATION_KEY_INVALID', 'IRIS_CREATION_INPUT_INVALID', 'IRIS_GENERATION_INPUT_INVALID',
|
|
35
|
+
'IRIS_IMAGE_IMPORT_INVALID', 'IRIS_IMAGE_EDIT_INPUT_INVALID', 'IRIS_FLOW_INPUT_INVALID'].includes(error?.code)) {
|
|
36
|
+
return new LocalApiError('IRIS_API_INPUT_INVALID', '创作输入或静态图片无效,请检查来源、模型和参数', 400);
|
|
37
|
+
}
|
|
38
|
+
if (error?.code === 'IRIS_CREATION_MEDIA_MISMATCH') return new LocalApiError('IRIS_API_MEDIA_UNSUPPORTED', '图片真实格式与 Content-Type 不一致', 415);
|
|
39
|
+
if (/^IRIS_CREATION_/.test(error?.code || '')) return new LocalApiError('IRIS_API_RECORD_CONFLICT', '创建请求证据不一致,请在本机检查原记录', 409);
|
|
40
|
+
if (['IRIS_FLOW_NOT_FOUND', 'IRIS_ARTIFACT_NOT_FOUND'].includes(error?.code)) {
|
|
41
|
+
return new LocalApiError('IRIS_API_NOT_FOUND', '该实例中没有此流程或图片', 404);
|
|
42
|
+
}
|
|
43
|
+
if (/^IRIS_PROVIDER_/.test(error?.code || '')) {
|
|
44
|
+
return new LocalApiError('IRIS_API_CONFIG_UNAVAILABLE', '模型配置不可用,请在本机检查显式配置', 500);
|
|
45
|
+
}
|
|
46
|
+
if (error?.code === 'IRIS_APP_CLOSED' || error?.name === 'AbortError' || /^IRIS_CORE_DATA_ROOT_/.test(error?.code || '')) {
|
|
47
|
+
return new LocalApiError('IRIS_API_UNAVAILABLE', '服务正在关闭或数据根不可用', 503);
|
|
48
|
+
}
|
|
49
|
+
if (/^IRIS_(?:FLOW|ARTIFACT|TASK)_/.test(error?.code || '')) {
|
|
50
|
+
return new LocalApiError('IRIS_API_RECORD_CONFLICT', '流程或作品证据不一致,请在本机核对原记录', 409);
|
|
51
|
+
}
|
|
52
|
+
return new LocalApiError('IRIS_API_INTERNAL', '读取失败,请使用关联 ID 核对本机诊断', 500);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function reportUnexpected(error, requestId) {
|
|
56
|
+
// Keep call sites for diagnosis, without the potentially private exception message or directory names.
|
|
57
|
+
const frames = String(error?.stack || '').split('\n').slice(1, 7)
|
|
58
|
+
.map(line => line.replace(/(?:file:\/\/)?(?:\/[^\s()]+)+(?=:\d+:\d+)/g, value => path.basename(value)));
|
|
59
|
+
console.error(JSON.stringify({ event: 'iris_local_api_error', requestId, frames }));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function headerCount(request, name) {
|
|
63
|
+
let count = 0;
|
|
64
|
+
for (let index = 0; index < request.rawHeaders.length; index += 2) {
|
|
65
|
+
if (request.rawHeaders[index].toLowerCase() === name) count++;
|
|
66
|
+
}
|
|
67
|
+
return count;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function query(url, allowed = []) {
|
|
71
|
+
for (const name of url.searchParams.keys()) {
|
|
72
|
+
if (!allowed.includes(name) || url.searchParams.getAll(name).length !== 1) {
|
|
73
|
+
fail('IRIS_API_INPUT_INVALID', '查询参数无效或重复');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function send(request, response, status, bytes, headers = {}) {
|
|
79
|
+
if (response.destroyed) return Promise.resolve();
|
|
80
|
+
return new Promise(resolve => {
|
|
81
|
+
let cleanupRequest = () => {};
|
|
82
|
+
const finish = () => { cleanupRequest(); response.off('finish', finish); response.off('close', finish); resolve(); };
|
|
83
|
+
response.once('finish', finish); response.once('close', finish);
|
|
84
|
+
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': bytes.length,
|
|
85
|
+
'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': CSP,
|
|
86
|
+
'Referrer-Policy': 'no-referrer', ...headers });
|
|
87
|
+
if (status >= 400 && !request.complete && !request.destroyed) {
|
|
88
|
+
// Deliver the complete error before closing an upload with unread bytes. Closing immediately
|
|
89
|
+
// can reset the socket on Windows before the client receives the response. Discard the rest
|
|
90
|
+
// without buffering; the server's request/idle timeouts and dispose still bound the connection.
|
|
91
|
+
const end = () => { cleanupRequest(); response.end(); };
|
|
92
|
+
cleanupRequest = () => { request.off('end', end); request.off('close', end); };
|
|
93
|
+
request.once('end', end); request.once('close', end);
|
|
94
|
+
if (request.method === 'HEAD') response.flushHeaders();
|
|
95
|
+
else response.write(bytes);
|
|
96
|
+
request.resume();
|
|
97
|
+
} else response.end(request.method === 'HEAD' ? undefined : bytes);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
const json = (request, response, value, status = 200) => send(request, response, status, Buffer.from(JSON.stringify(value)));
|
|
101
|
+
|
|
102
|
+
/** Internal Host, read-only by default. Construction performs no I/O and never binds a port. */
|
|
103
|
+
export function createLocalReadApi({ dataRoot, providerConfigPath, allowLocalWrites = false } = {}) {
|
|
104
|
+
if (typeof allowLocalWrites !== 'boolean') throw new TypeError('allowLocalWrites 必须是布尔值');
|
|
105
|
+
const suppliedRoot = normalizeCoreOptions({ dataRoot, mode: 'reader' }).dataRoot;
|
|
106
|
+
if (providerConfigPath !== undefined && (typeof providerConfigPath !== 'string' || !path.isAbsolute(providerConfigPath))) {
|
|
107
|
+
throw new LocalApiError('IRIS_API_CONFIG_INVALID', 'Provider 配置必须是显式绝对路径');
|
|
108
|
+
}
|
|
109
|
+
let state = 'new', origin = null, tokenDigest = null, application, server, physicalRoot;
|
|
110
|
+
let startPromise, disposal, closeRequested = false, activeReads = 0, activeUploads = 0, localWrites = 0;
|
|
111
|
+
const jobs = new Set(), instanceId = crypto.randomUUID();
|
|
112
|
+
let resolveClosed;
|
|
113
|
+
const closed = new Promise(resolve => { resolveClosed = resolve; });
|
|
114
|
+
const snapshot = () => Object.freeze({ contractVersion: 0, state, origin, readOnly: !allowLocalWrites, activeReads });
|
|
115
|
+
|
|
116
|
+
function authorize(request) {
|
|
117
|
+
const headers = request.headers;
|
|
118
|
+
if (headerCount(request, 'host') !== 1 || headers.host !== new URL(origin).host) {
|
|
119
|
+
fail('IRIS_API_HOST_DENIED', '请求地址不可信,请使用启动时给出的本机地址', 403);
|
|
120
|
+
}
|
|
121
|
+
if (headerCount(request, 'origin') > 1 || (headers.origin !== undefined && headers.origin !== origin)
|
|
122
|
+
|| (headers['sec-fetch-site'] !== undefined && !['same-origin', 'none'].includes(headers['sec-fetch-site']))) {
|
|
123
|
+
fail('IRIS_API_ORIGIN_DENIED', '请求来源不可信', 403);
|
|
124
|
+
}
|
|
125
|
+
const match = headerCount(request, 'authorization') === 1 && /^Bearer +([A-Za-z0-9_-]{43})$/i.exec(headers.authorization || '');
|
|
126
|
+
if (!match || !crypto.timingSafeEqual(digest(match[1]), tokenDigest)) {
|
|
127
|
+
fail('IRIS_API_AUTH_REQUIRED', '需要有效的本机会话 Token', 401);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function image(artifactId) {
|
|
132
|
+
// Use an operation-owned reader, with no writer lease or local export path.
|
|
133
|
+
const reader = createCoreRuntime({ dataRoot: physicalRoot, mode: 'reader' });
|
|
134
|
+
try {
|
|
135
|
+
reader.start();
|
|
136
|
+
return await reader.run('inspect', ({ dataRoot: physicalRoot }) => {
|
|
137
|
+
const metadata = inspectCoreArtifactMetadata(physicalRoot, artifactId);
|
|
138
|
+
if (!IMAGE_TYPES.has(metadata.mediaType)) fail('IRIS_API_MEDIA_UNSUPPORTED', '此接口只提供 PNG、JPEG 或 WebP 图片', 415);
|
|
139
|
+
if (metadata.size > MAX_LOCAL_API_IMAGE_BYTES) fail('IRIS_API_MEDIA_TOO_LARGE', '图片超过只读接口的 64 MiB 上限', 413);
|
|
140
|
+
const result = readCoreArtifactBytes(physicalRoot, artifactId);
|
|
141
|
+
if (result.bytes.length > MAX_LOCAL_API_IMAGE_BYTES || result.bytes.length !== result.artifact.size
|
|
142
|
+
|| digest(result.bytes).toString('hex') !== result.artifact.digest.value) {
|
|
143
|
+
fail('IRIS_API_RECORD_CONFLICT', '图片字节与已保存记录不一致', 409);
|
|
144
|
+
}
|
|
145
|
+
return result;
|
|
146
|
+
});
|
|
147
|
+
} finally { await reader.dispose(); }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function models() {
|
|
151
|
+
if (!providerConfigPath) return { contractVersion: 0, configured: false, models: [], total: 0 };
|
|
152
|
+
const catalog = loadProviderCatalog(providerConfigPath);
|
|
153
|
+
let candidates;
|
|
154
|
+
try { candidates = imageCandidatesFromCatalog(catalog); }
|
|
155
|
+
catch (error) { if (error.code !== 'IRIS_PROVIDER_MODEL_UNAVAILABLE') throw error; candidates = []; }
|
|
156
|
+
const entries = candidates.map(route => ({ modelRef: route.modelRef, displayName: route.model,
|
|
157
|
+
supportsImageEdit: supportsImageEdit(route.provider.mediaProtocol, route.model) }));
|
|
158
|
+
return { contractVersion: 0, configured: true, models: entries, total: entries.length };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function create(request, response, url) {
|
|
162
|
+
query(url);
|
|
163
|
+
if (!['/api/v0/images', '/api/v0/runs'].includes(url.pathname)) fail('IRIS_API_NOT_FOUND', '没有此本地创建接口', 404);
|
|
164
|
+
const isImage = url.pathname === '/api/v0/images';
|
|
165
|
+
if (headerCount(request, 'idempotency-key') !== 1 || !KEY.test(request.headers['idempotency-key'] || '')) {
|
|
166
|
+
fail('IRIS_API_INPUT_INVALID', 'Idempotency-Key 须为单个小写 UUIDv4');
|
|
167
|
+
}
|
|
168
|
+
if (headerCount(request, 'content-type') !== 1 || request.headers['content-encoding'] !== undefined) {
|
|
169
|
+
fail('IRIS_API_INPUT_INVALID', '正文需要单个 Content-Type,且不接受 Content-Encoding');
|
|
170
|
+
}
|
|
171
|
+
const mediaType = request.headers['content-type'];
|
|
172
|
+
if (isImage ? !IMAGE_TYPES.has(mediaType) : !/^application\/json(?:;\s*charset=utf-8)?$/i.test(mediaType)) {
|
|
173
|
+
fail('IRIS_API_MEDIA_UNSUPPORTED', '请提供静态 PNG/JPEG/WebP 原字节或 UTF-8 JSON', 415);
|
|
174
|
+
}
|
|
175
|
+
const limit = isImage ? MAX_IMPORT_IMAGE_BYTES : 64 * 1024;
|
|
176
|
+
if (request.headers['content-length'] !== undefined && Number(request.headers['content-length']) > limit) {
|
|
177
|
+
fail('IRIS_API_INPUT_TOO_LARGE', '正文超过允许的大小', 413);
|
|
178
|
+
}
|
|
179
|
+
if (isImage && activeUploads >= 1 || localWrites >= 9) fail('IRIS_API_BUSY', '本地创建繁忙,请使用原 Key 稍后重试', 429);
|
|
180
|
+
if (isImage) activeUploads++;
|
|
181
|
+
localWrites++;
|
|
182
|
+
const controller = new AbortController();
|
|
183
|
+
let prepared = false;
|
|
184
|
+
const disconnect = () => { if (!prepared) controller.abort(); };
|
|
185
|
+
response.once('close', disconnect);
|
|
186
|
+
try {
|
|
187
|
+
const chunks = [];
|
|
188
|
+
let size = 0;
|
|
189
|
+
for await (const chunk of request.iterator({ destroyOnReturn: false })) {
|
|
190
|
+
size += chunk.length;
|
|
191
|
+
if (size > limit) fail('IRIS_API_INPUT_TOO_LARGE', '正文超过允许的大小', 413);
|
|
192
|
+
chunks.push(chunk);
|
|
193
|
+
}
|
|
194
|
+
if (Object.keys(request.trailers).length || size === 0) fail('IRIS_API_INPUT_INVALID', '正文不能为空或包含尾部头字段');
|
|
195
|
+
if (state !== 'open') fail('IRIS_API_UNAVAILABLE', '服务正在关闭', 503);
|
|
196
|
+
const bytes = Buffer.concat(chunks, size);
|
|
197
|
+
let value;
|
|
198
|
+
if (!isImage) {
|
|
199
|
+
try { value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); }
|
|
200
|
+
catch (_) { fail('IRIS_API_INPUT_INVALID', '需要有效的 UTF-8 JSON'); }
|
|
201
|
+
}
|
|
202
|
+
const result = await application.execute(isImage ? 'creation.image' : 'creation.flow', {
|
|
203
|
+
key: request.headers['idempotency-key'], ...(isImage ? { bytes, mediaType } : { value })
|
|
204
|
+
}, { signal: controller.signal, onPrepared: () => { prepared = true; } });
|
|
205
|
+
const body = { contractVersion: 0, idempotentReplay: result.idempotentReplay,
|
|
206
|
+
...(isImage ? { artifact: { artifactId: result.artifact.id, mediaType: result.artifact.mediaType,
|
|
207
|
+
size: result.artifact.size, width: result.artifact.metadata.width, height: result.artifact.metadata.height } }
|
|
208
|
+
: { flow: projectLocalApiFlow(result.flow) }) };
|
|
209
|
+
return await json(request, response, body, result.idempotentReplay ? 200 : 201);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (request.aborted && !prepared) fail('IRIS_API_REQUEST_INTERRUPTED', '上传连接已中断,未接受本次创建');
|
|
212
|
+
throw error;
|
|
213
|
+
} finally {
|
|
214
|
+
response.off('close', disconnect); localWrites--;
|
|
215
|
+
if (isImage) activeUploads--;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function route(request, response) {
|
|
220
|
+
if (state !== 'open') fail('IRIS_API_UNAVAILABLE', '服务正在关闭', 503);
|
|
221
|
+
authorize(request);
|
|
222
|
+
if (!['GET', 'HEAD'].includes(request.method) && !(allowLocalWrites && request.method === 'POST')) {
|
|
223
|
+
fail('IRIS_API_READ_ONLY', allowLocalWrites ? '仅支持 GET/HEAD 与明确的本地创建 POST' : '当前接口仅支持只读 GET/HEAD', 405);
|
|
224
|
+
}
|
|
225
|
+
if (request.method !== 'POST' && (request.headers['transfer-encoding'] !== undefined || (request.headers['content-length'] !== undefined && request.headers['content-length'] !== '0'))) {
|
|
226
|
+
fail('IRIS_API_INPUT_INVALID', '只读请求不接受正文');
|
|
227
|
+
}
|
|
228
|
+
if (!request.url.startsWith('/') || request.url.startsWith('//')) fail('IRIS_API_INPUT_INVALID', '请求路径无效');
|
|
229
|
+
const url = new URL(request.url, origin);
|
|
230
|
+
if (url.pathname !== request.url.split('?')[0]) fail('IRIS_API_INPUT_INVALID', '请求路径无效');
|
|
231
|
+
if (request.method === 'POST') return create(request, response, url);
|
|
232
|
+
const matchRun = /^\/api\/v0\/runs\/([^/]+)(\/download)?$/.exec(url.pathname);
|
|
233
|
+
const matchImage = /^\/api\/v0\/artifacts\/([^/]+)\/content$/.exec(url.pathname);
|
|
234
|
+
query(url, url.pathname === '/api/v0/runs' ? ['limit', 'cursor'] : []);
|
|
235
|
+
if (matchRun && !RUN.test(matchRun[1]) || matchImage && !ARTIFACT.test(matchImage[1])) {
|
|
236
|
+
fail('IRIS_API_INPUT_INVALID', '流程或图片 ID 格式无效');
|
|
237
|
+
}
|
|
238
|
+
if (activeReads >= 2) fail('IRIS_API_BUSY', '读取繁忙,请稍后再试', 429);
|
|
239
|
+
activeReads++;
|
|
240
|
+
try {
|
|
241
|
+
if (url.pathname === '/api/v0/session') {
|
|
242
|
+
return await json(request, response, { contractVersion: 0, instanceId, readOnly: !allowLocalWrites,
|
|
243
|
+
operations: ['models.list', 'runs.list', 'runs.inspect', 'artifacts.content', 'runs.download',
|
|
244
|
+
...(allowLocalWrites ? ['images.create', 'runs.create'] : [])],
|
|
245
|
+
limits: { runPageSize: 30, imageBytes: MAX_LOCAL_API_IMAGE_BYTES, activeReads: 2,
|
|
246
|
+
...(allowLocalWrites ? { uploadBytes: MAX_IMPORT_IMAGE_BYTES, jsonBytes: 64 * 1024, activeUploads: 1, localWrites: 9 } : {}) } });
|
|
247
|
+
}
|
|
248
|
+
if (url.pathname === '/api/v0/models') return await json(request, response, models());
|
|
249
|
+
if (url.pathname === '/api/v0/runs') {
|
|
250
|
+
const limitText = url.searchParams.get('limit') ?? '20';
|
|
251
|
+
if (!/^(?:[1-9]|[12][0-9]|30)$/.test(limitText)) fail('IRIS_API_INPUT_INVALID', 'limit 必须是 1–30 的整数');
|
|
252
|
+
const cursor = url.searchParams.get('cursor');
|
|
253
|
+
if (cursor !== null && !RUN.test(cursor)) fail('IRIS_API_INPUT_INVALID', '游标无效');
|
|
254
|
+
const result = await application.execute('flow.list', {});
|
|
255
|
+
const ordered = [...result.runs].sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id));
|
|
256
|
+
const index = cursor === null ? 0 : ordered.findIndex(run => run.id === cursor) + 1;
|
|
257
|
+
if (cursor !== null && index === 0) fail('IRIS_API_INPUT_INVALID', '游标已失效,请重新读取列表');
|
|
258
|
+
const page = ordered.slice(index, index + Number(limitText));
|
|
259
|
+
return await json(request, response, { contractVersion: 0, total: ordered.length,
|
|
260
|
+
runs: page.map(projectLocalApiRunSummary), nextCursor: index + page.length < ordered.length ? page.at(-1).id : null });
|
|
261
|
+
}
|
|
262
|
+
let artifactId;
|
|
263
|
+
if (matchRun) {
|
|
264
|
+
const view = projectLocalApiFlow(await application.execute('flow.inspect', { run_id: matchRun[1] }));
|
|
265
|
+
if (!matchRun[2]) return await json(request, response, view);
|
|
266
|
+
if (!view.selection) fail('IRIS_API_SELECTION_REQUIRED', '请先明确选择要下载的作品', 409);
|
|
267
|
+
artifactId = view.selection.artifactId;
|
|
268
|
+
} else if (matchImage) artifactId = matchImage[1];
|
|
269
|
+
else fail('IRIS_API_NOT_FOUND', '没有此只读接口', 404);
|
|
270
|
+
const result = await image(artifactId);
|
|
271
|
+
return await send(request, response, 200, result.bytes, { 'Content-Type': result.artifact.mediaType,
|
|
272
|
+
...(matchRun ? { 'Content-Disposition': `attachment; filename="iris-${artifactId}.${IMAGE_TYPES.get(result.artifact.mediaType)}"` } : {}) });
|
|
273
|
+
} finally { activeReads--; }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function handle(request, response) {
|
|
277
|
+
const requestId = crypto.randomUUID();
|
|
278
|
+
try { await route(request, response); }
|
|
279
|
+
catch (error) {
|
|
280
|
+
const safe = httpError(error);
|
|
281
|
+
if (safe.code === 'IRIS_API_INTERNAL') reportUnexpected(error, requestId);
|
|
282
|
+
await send(request, response, safe.status, Buffer.from(JSON.stringify({ contractVersion: 0,
|
|
283
|
+
error: { code: safe.code, message: safe.message, requestId } })), {
|
|
284
|
+
Connection: 'close', ...(safe.status === 401 ? { 'WWW-Authenticate': 'Bearer realm="iris-local"' } : {}),
|
|
285
|
+
...(safe.status === 405 ? { Allow: allowLocalWrites ? 'GET, HEAD, POST' : 'GET, HEAD' } : {}) });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function closeServer() {
|
|
290
|
+
if (!server?.listening) return;
|
|
291
|
+
const completion = new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
|
|
292
|
+
server.closeAllConnections();
|
|
293
|
+
await completion;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function start({ port = 0 } = {}) {
|
|
297
|
+
if (state !== 'new') return Promise.reject(new LocalApiError('IRIS_API_LIFECYCLE', '服务只能显式启动一次', 409));
|
|
298
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) return Promise.reject(new LocalApiError('IRIS_API_INPUT_INVALID', '端口必须是 0–65535 的整数'));
|
|
299
|
+
state = 'starting';
|
|
300
|
+
const result = (async () => {
|
|
301
|
+
try {
|
|
302
|
+
const reader = createCoreRuntime({ dataRoot: suppliedRoot, mode: 'reader' });
|
|
303
|
+
try {
|
|
304
|
+
reader.start();
|
|
305
|
+
physicalRoot = await reader.run('inspect', ({ dataRoot: root }) => {
|
|
306
|
+
inspectFlowRoot(root);
|
|
307
|
+
if (allowLocalWrites) inspectCreationRoot(root);
|
|
308
|
+
return root;
|
|
309
|
+
});
|
|
310
|
+
} finally { await reader.dispose(); }
|
|
311
|
+
if (closeRequested) fail('IRIS_API_UNAVAILABLE', '启动已取消', 503);
|
|
312
|
+
application = createApplicationRuntime({ dataRoot: physicalRoot,
|
|
313
|
+
...(allowLocalWrites && providerConfigPath ? { resolveImageCandidates: ({ modelRef }) =>
|
|
314
|
+
imageCandidatesFromCatalog(loadProviderCatalog(providerConfigPath), modelRef).map(route => ({
|
|
315
|
+
adapter: { id: route.provider.id, protocol: route.provider.mediaProtocol }, model: route.modelRef,
|
|
316
|
+
providerBinding: providerTaskBinding(route.provider), selectionReason: 'explicit'
|
|
317
|
+
})) } : {}) });
|
|
318
|
+
server = http.createServer({ maxHeaderSize: 8192, headersTimeout: 10000, requestTimeout: 15000 }, (request, response) => {
|
|
319
|
+
const job = handle(request, response).catch(error => {
|
|
320
|
+
reportUnexpected(error, crypto.randomUUID()); response.destroy();
|
|
321
|
+
});
|
|
322
|
+
jobs.add(job); void job.then(() => jobs.delete(job));
|
|
323
|
+
});
|
|
324
|
+
server.keepAliveTimeout = 1000; server.maxConnections = 32;
|
|
325
|
+
server.setTimeout(15000, socket => socket.destroy());
|
|
326
|
+
server.on('upgrade', (_request, socket) => socket.destroy());
|
|
327
|
+
server.on('connect', (_request, socket) => socket.destroy());
|
|
328
|
+
await new Promise((resolve, reject) => {
|
|
329
|
+
const onError = error => { server.off('listening', onListening); reject(error); };
|
|
330
|
+
const onListening = () => { server.off('error', onError); resolve(); };
|
|
331
|
+
server.once('error', onError); server.once('listening', onListening); server.listen(port, '127.0.0.1');
|
|
332
|
+
});
|
|
333
|
+
origin = `http://127.0.0.1:${server.address().port}`;
|
|
334
|
+
const accessToken = crypto.randomBytes(32).toString('base64url');
|
|
335
|
+
tokenDigest = digest(accessToken);
|
|
336
|
+
if (closeRequested) fail('IRIS_API_UNAVAILABLE', '启动已取消', 503);
|
|
337
|
+
state = 'open';
|
|
338
|
+
return Object.freeze({ origin, accessToken });
|
|
339
|
+
} catch (error) {
|
|
340
|
+
await closeServer(); await application?.dispose();
|
|
341
|
+
state = closeRequested ? 'closing' : 'closed';
|
|
342
|
+
if (!closeRequested) resolveClosed(snapshot());
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
345
|
+
})();
|
|
346
|
+
// Retain only completion for lifecycle coordination; not the private start result containing the token.
|
|
347
|
+
startPromise = result.then(() => undefined, () => undefined);
|
|
348
|
+
return result;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function dispose() {
|
|
352
|
+
if (disposal) return disposal;
|
|
353
|
+
closeRequested = true; state = 'closing'; tokenDigest?.fill(0);
|
|
354
|
+
disposal = (async () => {
|
|
355
|
+
if (startPromise) await startPromise.catch(() => {}); // start's caller retains the original failure.
|
|
356
|
+
await closeServer(); await application?.dispose(); await Promise.all([...jobs]);
|
|
357
|
+
state = 'closed'; resolveClosed(snapshot());
|
|
358
|
+
return snapshot();
|
|
359
|
+
})();
|
|
360
|
+
return disposal;
|
|
361
|
+
}
|
|
362
|
+
return Object.freeze({ start, dispose, snapshot, closed });
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Explicit credential output, outside Core. Existing files and parent permissions are untouched. */
|
|
366
|
+
export function writeLocalApiTokenFile(file, token, dataRoot) {
|
|
367
|
+
if (typeof file !== 'string' || !path.isAbsolute(file)) fail('IRIS_API_TOKEN_FILE_INVALID', '会话 Token 文件必须是新的绝对路径');
|
|
368
|
+
let fd;
|
|
369
|
+
try {
|
|
370
|
+
const root = fs.realpathSync(dataRoot), parent = fs.realpathSync(path.dirname(file));
|
|
371
|
+
const destination = path.join(parent, path.basename(file)), relative = path.relative(root, destination);
|
|
372
|
+
if (!relative || !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative)) {
|
|
373
|
+
fail('IRIS_API_TOKEN_FILE_INVALID', '会话 Token 文件必须放在 Core 数据根之外');
|
|
374
|
+
}
|
|
375
|
+
fd = fs.openSync(destination, 'wx', 0o600);
|
|
376
|
+
fs.writeFileSync(fd, token + '\n', 'utf8');
|
|
377
|
+
} catch (error) {
|
|
378
|
+
if (error instanceof LocalApiError) throw error;
|
|
379
|
+
fail('IRIS_API_TOKEN_FILE_UNAVAILABLE', '会话 Token 文件不可写或已存在,请指定新的根外文件');
|
|
380
|
+
} finally { if (fd !== undefined) fs.closeSync(fd); }
|
|
381
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Internal, non-generating creation service. One supplied writer owns the complete intent/commit sequence.
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import { createCoreArtifact, readCoreArtifactBytes } from './core-artifacts.js';
|
|
4
|
+
import { createImageFlowService } from './image-flow.js';
|
|
5
|
+
import { readImportImage } from './image-import.js';
|
|
6
|
+
import { normalizeGenerationInput } from './generation-input.js';
|
|
7
|
+
import { initializeCreationRoot, inspectCreationRoot, creationRequestId, creationInputDigest,
|
|
8
|
+
readCreationRequest, publishCreationRequest, completeCreationRequest, creationFail } from './creation-request-store.js';
|
|
9
|
+
|
|
10
|
+
const sha = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
|
|
11
|
+
function flowInput(input) {
|
|
12
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)
|
|
13
|
+
|| Object.keys(input).some(key => !['sourceArtifactId', 'modelRef', 'prompt', 'size'].includes(key))
|
|
14
|
+
|| !['sourceArtifactId', 'modelRef', 'prompt'].every(key => typeof input[key] === 'string')
|
|
15
|
+
|| input.size !== undefined && typeof input.size !== 'string') {
|
|
16
|
+
creationFail('IRIS_CREATION_INPUT_INVALID', '流程创建只接受来源作品、明确模型、Prompt 和可选 size');
|
|
17
|
+
}
|
|
18
|
+
const normalized = normalizeGenerationInput('image', { source_artifact_id: input.sourceArtifactId,
|
|
19
|
+
model_ref: input.modelRef, prompt: input.prompt, ...(input.size !== undefined ? { size: input.size } : {}) });
|
|
20
|
+
return { source_artifact_id: normalized.sourceArtifactId, model_ref: normalized.modelRef,
|
|
21
|
+
...normalized.providerInput };
|
|
22
|
+
}
|
|
23
|
+
export function createLocalCreationService(runtime, { resolveImageCandidates, onPrepared } = {}) {
|
|
24
|
+
const flows = createImageFlowService(runtime, { resolveImageCandidates });
|
|
25
|
+
function execute(name, input) {
|
|
26
|
+
return runtime.run('execute', async ({ dataRoot, signal }) => {
|
|
27
|
+
if (name === 'creation.init') return { contractVersion: 0, root: initializeCreationRoot(dataRoot) };
|
|
28
|
+
if (!['creation.image', 'creation.flow'].includes(name)) throw new TypeError('未知本地创建操作');
|
|
29
|
+
const kind = name === 'creation.image' ? 'image-import' : 'flow-create';
|
|
30
|
+
const marker = inspectCreationRoot(dataRoot), id = creationRequestId(marker, kind, input.key);
|
|
31
|
+
let source, owned;
|
|
32
|
+
if (kind === 'image-import') {
|
|
33
|
+
source = await readImportImage({ imageBytes: input.bytes }, signal);
|
|
34
|
+
if (source.mediaType !== input.mediaType) creationFail('IRIS_CREATION_MEDIA_MISMATCH', '图片真实格式与 Content-Type 不一致');
|
|
35
|
+
owned = { mediaType: source.mediaType, size: source.bytes.length, sha256: sha(source.bytes) };
|
|
36
|
+
} else owned = flowInput(input.value);
|
|
37
|
+
const inputDigest = creationInputDigest(kind, owned);
|
|
38
|
+
let request = readCreationRequest(dataRoot, id, marker);
|
|
39
|
+
const replay = Boolean(request);
|
|
40
|
+
if (request && request.inputDigest !== inputDigest) creationFail('IRIS_CREATION_KEY_CONFLICT', '该 Key 已绑定其他输入,请保留原请求或为新动作使用新 Key');
|
|
41
|
+
if (!request) {
|
|
42
|
+
const intent = kind === 'image-import'
|
|
43
|
+
? { artifactId: 'artifact_' + crypto.randomBytes(12).toString('hex'), ...owned,
|
|
44
|
+
metadata: source.metadata, createdAt: new Date().toISOString() }
|
|
45
|
+
: await flows.prepareCreation(dataRoot, signal, owned);
|
|
46
|
+
signal.throwIfAborted();
|
|
47
|
+
request = publishCreationRequest(dataRoot, { schemaVersion: 0, id, rootId: marker.rootId, kind,
|
|
48
|
+
inputDigest, createdAt: intent.createdAt, intent, state: 'prepared' });
|
|
49
|
+
}
|
|
50
|
+
onPrepared?.(); // The Host now detaches HTTP disconnect cancellation; shutdown still reaches this runtime.
|
|
51
|
+
signal.throwIfAborted();
|
|
52
|
+
let result;
|
|
53
|
+
const intent = request.intent;
|
|
54
|
+
if (kind === 'image-import') {
|
|
55
|
+
if (request.state === 'prepared') {
|
|
56
|
+
result = createCoreArtifact(dataRoot, { artifactId: intent.artifactId, bytes: source.bytes,
|
|
57
|
+
mediaType: intent.mediaType, kind: 'imported-image', metadata: intent.metadata,
|
|
58
|
+
expectedDigest: intent.sha256, expectedSize: intent.size, createdAt: intent.createdAt });
|
|
59
|
+
} else {
|
|
60
|
+
try { result = readCoreArtifactBytes(dataRoot, intent.artifactId).artifact; }
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (error.code !== 'IRIS_ARTIFACT_NOT_FOUND') throw error;
|
|
63
|
+
creationFail('IRIS_CREATION_RESULT_UNAVAILABLE', '该请求的原结果已不可用;不会重新创建或恢复已删除作品');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (result.digest.value !== intent.sha256 || result.size !== intent.size || result.mediaType !== intent.mediaType
|
|
67
|
+
|| result.kind !== 'imported-image' || result.createdAt !== intent.createdAt
|
|
68
|
+
|| JSON.stringify(result.metadata) !== JSON.stringify(intent.metadata) || result.relations.length) {
|
|
69
|
+
creationFail('IRIS_CREATION_RESULT_CONFLICT', '原作品与请求意图不一致');
|
|
70
|
+
}
|
|
71
|
+
result = { artifact: result };
|
|
72
|
+
} else {
|
|
73
|
+
try { result = { flow: flows.inspectCreation(dataRoot, intent, { allowCreate: request.state === 'prepared' }) }; }
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (request.state !== 'succeeded' || !['IRIS_FLOW_NOT_FOUND', 'IRIS_ARTIFACT_NOT_FOUND'].includes(error.code)) throw error;
|
|
76
|
+
creationFail('IRIS_CREATION_RESULT_UNAVAILABLE', '该请求的原流程或来源已不可用,不重新创建');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
signal.throwIfAborted();
|
|
80
|
+
if (request.state === 'prepared') completeCreationRequest(dataRoot, request);
|
|
81
|
+
return { contractVersion: 0, idempotentReplay: replay, ...result };
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return Object.freeze({ execute });
|
|
85
|
+
}
|
package/lib/private-storage.js
CHANGED
|
@@ -51,6 +51,18 @@ export function atomicWritePrivate(file, data) {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/** 原子且排他地发布新记录;已存在的身份绝不被 rename 覆盖。 */
|
|
55
|
+
export function atomicCreatePrivate(file, data) {
|
|
56
|
+
ensurePrivateDir(path.dirname(file));
|
|
57
|
+
const tmp = privateSibling(file);
|
|
58
|
+
try {
|
|
59
|
+
fs.writeFileSync(tmp, data, { mode: 0o600, flag: 'wx' });
|
|
60
|
+
fs.linkSync(tmp, file);
|
|
61
|
+
} finally {
|
|
62
|
+
try { fs.rmSync(tmp, { force: true }); } catch (_) { /* 仅本次未发布的临时链接 */ }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
54
66
|
/** 收紧既有 Iris 树;不跟随符号链接,也不修改文件内容。 */
|
|
55
67
|
export function hardenPrivateTree(root) {
|
|
56
68
|
ensurePrivateDir(root);
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
beginCoreDelivery,
|
|
12
12
|
completeCoreDelivery,
|
|
13
13
|
createCoreTask,
|
|
14
|
+
claimFlowTask,
|
|
14
15
|
failCoreDelivery,
|
|
15
16
|
finalizeCoreNoAcceptance,
|
|
16
17
|
inspectCoreTask,
|
|
@@ -33,6 +34,8 @@ import { ProviderContractError, providerErrorRecord, submitWithAcceptanceBoundar
|
|
|
33
34
|
import { imageEditCandidates, readImageEditArtifact } from './image-edit-input.js';
|
|
34
35
|
import { createDeliveryCache, newDeliveryPayloadPath, readDeliveryCache,
|
|
35
36
|
readDeliveryPayload, recordDeliveryPayload } from './core-delivery-cache.js';
|
|
37
|
+
import { readFlowRun, flowFail } from './flow-store.js';
|
|
38
|
+
import { normalizeGenerationInput } from './generation-input.js';
|
|
36
39
|
|
|
37
40
|
export const PROVIDER_TASK_RUNNER_VERSION = 0;
|
|
38
41
|
|
|
@@ -193,8 +196,11 @@ async function observeWithinRoot(dataRoot, taskId, adapter, signal) {
|
|
|
193
196
|
} catch (error) {
|
|
194
197
|
return recordCoreWatchError(dataRoot, taskId, error);
|
|
195
198
|
}
|
|
196
|
-
const
|
|
199
|
+
const cachedFlowResult = Boolean(before.flowBinding && result.kind === 'succeeded');
|
|
200
|
+
if (cachedFlowResult) createDeliveryCache(dataRoot, before, result.artifacts);
|
|
201
|
+
const recorded = recordCorePollResult(dataRoot, taskId, result, { deliveryCache: cachedFlowResult });
|
|
197
202
|
if (result.kind !== 'succeeded') return recorded;
|
|
203
|
+
if (cachedFlowResult) return deliverCachedArtifacts(dataRoot, taskId, adapter, signal);
|
|
198
204
|
return deliverArtifacts(dataRoot, taskId, adapter, result.artifacts, signal);
|
|
199
205
|
}
|
|
200
206
|
|
|
@@ -288,13 +294,27 @@ export function createProviderTaskRunner(runtime) {
|
|
|
288
294
|
const capability = String(input.capability || '').trim();
|
|
289
295
|
let candidates = normalizedCandidates(capability, input.candidates);
|
|
290
296
|
let providerInput = input.providerInput ?? {};
|
|
297
|
+
let flow;
|
|
298
|
+
if (input.flowRunId !== undefined) {
|
|
299
|
+
flow = readFlowRun(dataRoot, input.flowRunId);
|
|
300
|
+
const expected = normalizeGenerationInput('image', flow.input).providerInput;
|
|
301
|
+
if (capability !== 'image' || !flow.edit.claimed || input.sourceArtifactId !== flow.source.artifactId
|
|
302
|
+
|| Object.keys(providerInput).length !== Object.keys(expected).length
|
|
303
|
+
|| Object.keys(expected).some(key => providerInput[key] !== expected[key])
|
|
304
|
+
|| candidates.length !== 1 || candidates[0].model !== flow.input.model_ref
|
|
305
|
+
|| candidates[0].providerBinding !== flow.providerBinding) {
|
|
306
|
+
flowFail('IRIS_FLOW_INPUT_CONFLICT', '提交与流程已保存的输入或模型绑定冲突');
|
|
307
|
+
}
|
|
308
|
+
}
|
|
291
309
|
if (input.sourceArtifactId !== undefined) {
|
|
292
310
|
if (capability !== 'image') throw new TypeError('sourceArtifactId 仅用于图片编辑');
|
|
293
311
|
candidates = imageEditCandidates(candidates);
|
|
294
312
|
const image = await readImageEditArtifact(dataRoot, input.sourceArtifactId, signal);
|
|
295
313
|
providerInput = { ...providerInput, image };
|
|
296
314
|
}
|
|
297
|
-
const task =
|
|
315
|
+
const task = flow ? claimFlowTask(dataRoot, flow.id)
|
|
316
|
+
: createCoreTask(dataRoot, { capability, retriedFrom: input.retriedFrom, sourceArtifactId: input.sourceArtifactId });
|
|
317
|
+
if (flow && task.attempts.length) flowFail('IRIS_FLOW_TASK_ALREADY_STARTED', '该流程 Task 已有提交意图,禁止重新 submit');
|
|
298
318
|
const completedResults = new Map();
|
|
299
319
|
const wrapped = candidates.map(({ adapter, model, providerModel, prepareInput }) => ({
|
|
300
320
|
id: adapter.id,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Local browser navigation address. Public media links keep their separate webBase() contract. */
|
|
2
|
+
const DEFAULT_BASE = 'http://127.0.0.1:3080';
|
|
3
|
+
|
|
4
|
+
function invalid(source, message) {
|
|
5
|
+
return Object.freeze({ source, status: 'invalid', code: 'IRIS_RENDER_ADDRESS_INVALID', message });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function checkedBase(value, source) {
|
|
9
|
+
let url;
|
|
10
|
+
try { url = new URL(value); }
|
|
11
|
+
catch (_) { return invalid(source, '截图地址无法解析;请设置有效的 DSH_WEB_BASE HTTP(S) 地址。'); }
|
|
12
|
+
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password || url.search || url.hash) {
|
|
13
|
+
return invalid(source, '截图地址必须为 HTTP(S),且不能包含登录凭据、查询参数或片段;请核对 DSH_WEB_BASE。');
|
|
14
|
+
}
|
|
15
|
+
return Object.freeze({ source, status: 'resolved', baseUrl: url.href.replace(/\/+$/, '') });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Only documented host/port/protocol properties are read; no request headers or private config. */
|
|
19
|
+
export function resolveRenderAddress(listener, override = process.env.DSH_WEB_BASE) {
|
|
20
|
+
if (override) return checkedBase(String(override).trim(), 'environment');
|
|
21
|
+
const port = listener?.port;
|
|
22
|
+
const host = listener?.host;
|
|
23
|
+
if (typeof host !== 'string' || !host || !Number.isSafeInteger(port) || port < 1 || port > 65535) {
|
|
24
|
+
return Object.freeze({ source: 'default', status: 'fallback', baseUrl: DEFAULT_BASE,
|
|
25
|
+
message: '宿主未提供实际监听地址,暂用 127.0.0.1:3080;非默认部署请设置 DSH_WEB_BASE。' });
|
|
26
|
+
}
|
|
27
|
+
// Older supported DSH listeners are HTTP-only and have no protocol property.
|
|
28
|
+
const protocol = listener.protocol ?? 'http:';
|
|
29
|
+
if (!['http:', 'https:'].includes(protocol) || /[\s/@?#\\%]/.test(host)) {
|
|
30
|
+
return invalid('host', '宿主监听地址不能直接用于截图;请通过 DSH_WEB_BASE 指定浏览器可访问的 HTTP(S) 地址。');
|
|
31
|
+
}
|
|
32
|
+
const localHost = host === '0.0.0.0' ? '127.0.0.1' : (host === '::' || host === '[::]' ? '::1' : host);
|
|
33
|
+
const urlHost = localHost.includes(':') && !localHost.startsWith('[') ? '[' + localHost + ']' : localHost;
|
|
34
|
+
return checkedBase(protocol + '//' + urlHost + ':' + port, 'host');
|
|
35
|
+
}
|