@ducking-mind/proxy-app-cli 0.1.0 → 0.1.2
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/README.md +128 -62
- package/agent-contract.mjs +125 -0
- package/agent-runtime/package-guide.mjs +144 -0
- package/agent-runtime/protocol.mjs +205 -0
- package/artifact-security.mjs +45 -0
- package/local-dev-credentials.mjs +187 -0
- package/local-dev-relay.mjs +368 -82
- package/package.json +23 -6
- package/platform-settings.mjs +19 -0
- package/prepare-agent-runtime.mjs +10 -0
- package/proxy-app.mjs +623 -190
package/local-dev-relay.mjs
CHANGED
|
@@ -5,6 +5,8 @@ import { Readable } from 'node:stream'
|
|
|
5
5
|
const HOP_BY_HOP_HEADERS = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade'])
|
|
6
6
|
const SENSITIVE_HEADERS = new Set(['host', 'authorization', 'cookie', 'origin', 'referer', 'content-length', 'x-proxy-app-relay'])
|
|
7
7
|
const PLATFORM_AUTH_TIMEOUT_MS = 30 * 1000
|
|
8
|
+
const validAccountScope = (value) => typeof value === 'string' && /^acs_[a-f0-9]{64}$/.test(value)
|
|
9
|
+
const accountScopeUnavailable = () => relayError('APP_ACCOUNT_SCOPE_UNAVAILABLE', '暂时无法确认账户信息,请重新登录后再试;如果仍无法进入,请联系平台管理员。', 'account_scope', 502)
|
|
8
10
|
|
|
9
11
|
function copyRequestHeaders(request) {
|
|
10
12
|
const headers = new Headers()
|
|
@@ -35,10 +37,16 @@ async function readBody(request, limit = 16 * 1024 * 1024) {
|
|
|
35
37
|
return Buffer.concat(chunks)
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
async function readJSON(response) {
|
|
39
|
-
const text = await response.text().catch(() => '')
|
|
40
|
-
if (!text.trim())
|
|
41
|
-
|
|
40
|
+
async function readJSON(response, strictBody = false) {
|
|
41
|
+
const text = strictBody ? await response.text() : await response.text().catch(() => '')
|
|
42
|
+
if (!text.trim()) {
|
|
43
|
+
if (strictBody) throw new SyntaxError('empty platform response')
|
|
44
|
+
return {}
|
|
45
|
+
}
|
|
46
|
+
try { return JSON.parse(text) } catch (error) {
|
|
47
|
+
if (strictBody) throw error
|
|
48
|
+
return { message: text }
|
|
49
|
+
}
|
|
42
50
|
}
|
|
43
51
|
|
|
44
52
|
function unwrap(payload) {
|
|
@@ -56,7 +64,7 @@ function errorCode(payload) {
|
|
|
56
64
|
return String(code || payload?.code || '').trim()
|
|
57
65
|
}
|
|
58
66
|
|
|
59
|
-
async function retryableSessionExpiry(response
|
|
67
|
+
async function retryableSessionExpiry(response) {
|
|
60
68
|
if (response?.status !== 401) return false
|
|
61
69
|
const payload = await readJSON(response.clone())
|
|
62
70
|
const error = payload?.error
|
|
@@ -64,7 +72,7 @@ async function retryableSessionExpiry(response, runtimeRequest) {
|
|
|
64
72
|
const executionStarted = typeof error === 'object' && error && Object.prototype.hasOwnProperty.call(error, 'execution_started')
|
|
65
73
|
? error.execution_started
|
|
66
74
|
: payload?.execution_started
|
|
67
|
-
return
|
|
75
|
+
return reason === 'APP_SESSION_EXPIRED' && executionStarted === false
|
|
68
76
|
}
|
|
69
77
|
|
|
70
78
|
function relayError(code, message, stage, statusCode = 502, cause) {
|
|
@@ -80,12 +88,21 @@ function visibleErrorMessage(error) {
|
|
|
80
88
|
switch (error?.code) {
|
|
81
89
|
case 'APP_PLATFORM_UNAVAILABLE': return '无法连接平台,请确认平台服务已启动或网络可用后重试。'
|
|
82
90
|
case 'APP_PLATFORM_TIMEOUT': return '平台响应超时,请稍后重试。'
|
|
91
|
+
case 'APP_PLATFORM_INVALID_RESPONSE': return '平台返回了无效响应,请检查平台 API 地址及服务配置。'
|
|
83
92
|
case 'APP_LOGIN_SESSION_EXPIRED': return '平台登录态已失效,请重新登录。'
|
|
84
93
|
case 'APP_LOGIN_NOT_ALLOWED': return '当前账户无权使用此应用,请切换账户后重试。'
|
|
85
94
|
case 'APP_APP_REGISTRATION_FAILED':
|
|
86
|
-
if (error?.upstreamCode === 'APP_LOCAL_MANIFEST_CONFLICT' || error?.message === 'local development version already exists with a different manifest; bump manifest.version') return '
|
|
87
|
-
if (error?.statusCode
|
|
88
|
-
|
|
95
|
+
if (error?.upstreamCode === 'APP_LOCAL_MANIFEST_CONFLICT' || error?.message === 'local development version already exists with a different manifest; bump manifest.version') return '本地 manifest 与已上传或发布的同版本内容不同。请更新 manifest.version 后重启 proxy-app dev。'
|
|
96
|
+
if ([403, 404].includes(error?.statusCode)) {
|
|
97
|
+
const account = error?.currentAccount ? ` 当前登录账户:${error.currentAccount}。` : ''
|
|
98
|
+
const platform = error?.platformAPI ? ` 平台 API 地址:${error.platformAPI}。` : ''
|
|
99
|
+
const reason = error.statusCode === 403 ? '当前账户无权开发此应用' : '该平台上没有当前账户拥有的同 slug 应用'
|
|
100
|
+
return `${reason}。${account}${platform}请使用应用创建时的完整账户登录,并确认 manifest.json 的 id 与平台应用 slug 完全一致。`
|
|
101
|
+
}
|
|
102
|
+
if (error?.statusCode === 400) {
|
|
103
|
+
const code = error?.upstreamCode ? `(${error.upstreamCode})` : ''
|
|
104
|
+
return '应用开发登记参数不符合平台要求(HTTP 400)' + code + '。请检查 manifest.json 的 id、version、entry、权限和能力声明。'
|
|
105
|
+
}
|
|
89
106
|
return '应用开发登记失败(HTTP ' + error?.statusCode + '),请根据请求编号检查平台日志。'
|
|
90
107
|
case 'APP_SESSION_CREATE_FAILED': return '用户 App Session 创建失败,请稍后重试。'
|
|
91
108
|
case 'APP_SESSION_INVALID': return '平台返回的用户 App Session 无效,请稍后重试。'
|
|
@@ -119,7 +136,18 @@ function safeURL(value, label) {
|
|
|
119
136
|
|
|
120
137
|
function normalizeGatewayURL(value) {
|
|
121
138
|
const normalized = safeURL(value, 'platform returned gateway URL')
|
|
122
|
-
|
|
139
|
+
const url = new URL(normalized)
|
|
140
|
+
const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '')
|
|
141
|
+
const loopback = hostname === 'localhost' || hostname === '::1' || /^127(?:\.\d{1,3}){3}$/.test(hostname)
|
|
142
|
+
const pathStart = value.indexOf('/', value.indexOf('://') + 3)
|
|
143
|
+
const rawPath = pathStart < 0 ? '/' : value.slice(pathStart)
|
|
144
|
+
if (value !== value.trim() || value.includes('\\') || rawPath !== new URL(value).pathname ||
|
|
145
|
+
(url.pathname !== '/' && url.pathname !== '/v1') ||
|
|
146
|
+
/(?:^|[.-])(?:admin|admin-api|admin-go|codex-admin-api|portal|www)(?:[.-]|$)/.test(hostname) ||
|
|
147
|
+
(url.protocol === 'http:' && !loopback)) {
|
|
148
|
+
throw relayError('APP_GATEWAY_URL_INVALID', 'App Session must use the Gateway /v1 URL', 'app_session')
|
|
149
|
+
}
|
|
150
|
+
return url.origin + '/v1'
|
|
123
151
|
}
|
|
124
152
|
|
|
125
153
|
function jsonResponse(response, status, payload) {
|
|
@@ -130,19 +158,30 @@ function jsonResponse(response, status, payload) {
|
|
|
130
158
|
}
|
|
131
159
|
|
|
132
160
|
/**
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
* opened the local app.
|
|
161
|
+
* Development preparation and runtime user login are separate operations.
|
|
162
|
+
* prepareDevelopment registers the manifest before the CLI starts the app.
|
|
163
|
+
* Only runtime login creates App Sessions; developer credentials never fund
|
|
164
|
+
* runtime requests or reach the app. Both credentials may use separate OS stores.
|
|
138
165
|
*/
|
|
139
|
-
export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', relaySecret, appID, appSlug, version, manifest, entryURL,
|
|
166
|
+
export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', relaySecret, appID, appSlug, version, manifest, entryURL, credentialStore = null, developerCredentialStore = null }) {
|
|
140
167
|
if (!baseURL || !relaySecret || !appID || !appSlug || !version || !entryURL) throw new Error('local development relay configuration is incomplete')
|
|
141
168
|
const portal = safeURL(baseURL, 'platform API URL')
|
|
142
169
|
const portalOrigin = safeURL(portalURL || portal, 'platform Portal URL')
|
|
143
|
-
const state = { portalToken: '', session: null, refresh: null, login: null, authGeneration: 0, authState: '', authStateIssuedAt: 0, closed: false, revoked: new Set() }
|
|
170
|
+
const state = { portalToken: '', session: null, refresh: null, login: null, restore: null, cacheLoaded: false, forceLogin: false, authGeneration: 0, authState: '', authStateIssuedAt: 0, closed: false, revoked: new Set() }
|
|
171
|
+
let credentialQueue = Promise.resolve()
|
|
172
|
+
const updateCredentials = (operation) => {
|
|
173
|
+
// Serialize writes so an in-flight save cannot resurrect a logged-out account.
|
|
174
|
+
credentialQueue = credentialQueue.then(operation).catch(() => undefined)
|
|
175
|
+
return credentialQueue
|
|
176
|
+
}
|
|
144
177
|
let server
|
|
145
178
|
let relayURL = ''
|
|
179
|
+
let developmentReady = false
|
|
180
|
+
let developmentPromise = null
|
|
181
|
+
let setupState = ''
|
|
182
|
+
let setupIssuedAt = 0
|
|
183
|
+
let submitSetupToken = null
|
|
184
|
+
let cancelSetup = null
|
|
146
185
|
|
|
147
186
|
const portalJSON = async (path, init = {}, stage = 'platform', authToken) => {
|
|
148
187
|
const token = authToken === undefined ? state.portalToken : authToken
|
|
@@ -152,22 +191,31 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
152
191
|
const controller = new AbortController()
|
|
153
192
|
const timeout = setTimeout(() => controller.abort(), PLATFORM_AUTH_TIMEOUT_MS)
|
|
154
193
|
let response
|
|
194
|
+
let payload
|
|
155
195
|
try {
|
|
156
196
|
response = await fetch(portal + path, {
|
|
157
197
|
...init,
|
|
158
|
-
signal: controller.signal,
|
|
198
|
+
signal: init.signal ? AbortSignal.any([controller.signal, init.signal]) : controller.signal,
|
|
159
199
|
headers: { Authorization: 'Bearer ' + token, ...(init.headers || {}) },
|
|
160
200
|
})
|
|
201
|
+
payload = await readJSON(response, true)
|
|
202
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new SyntaxError('platform response is not an object')
|
|
161
203
|
} catch (error) {
|
|
162
|
-
|
|
163
|
-
if (error?.name === 'AbortError') throw relayError('APP_PLATFORM_TIMEOUT', 'platform request timed out', stage, 504, error)
|
|
204
|
+
if (init.signal?.aborted) throw init.signal.reason
|
|
205
|
+
if (controller.signal.aborted || error?.name === 'AbortError') throw relayError('APP_PLATFORM_TIMEOUT', 'platform request timed out', stage, 504, error)
|
|
206
|
+
if (error instanceof SyntaxError) throw relayError('APP_PLATFORM_INVALID_RESPONSE', 'platform response is invalid', stage, 502, error)
|
|
164
207
|
throw relayError('APP_PLATFORM_UNAVAILABLE', 'platform request could not be reached', stage, 502, error)
|
|
208
|
+
} finally {
|
|
209
|
+
clearTimeout(timeout)
|
|
165
210
|
}
|
|
166
|
-
clearTimeout(timeout)
|
|
167
|
-
const payload = await readJSON(response)
|
|
168
211
|
if (!response.ok) {
|
|
212
|
+
if (stage === 'account_scope') {
|
|
213
|
+
const failure = relayError(errorCode(payload) || 'APP_PLATFORM_REQUEST_FAILED', errorMessage(payload, 'platform request failed: HTTP ' + response.status), stage, response.status)
|
|
214
|
+
failure.requestId = response.headers.get('x-request-id') || ''
|
|
215
|
+
throw failure
|
|
216
|
+
}
|
|
169
217
|
if (response.status === 401) throw relayError('APP_LOGIN_SESSION_EXPIRED', 'platform user session expired', stage, 401)
|
|
170
|
-
if (response.status === 403) throw relayError('APP_LOGIN_NOT_ALLOWED', 'platform user is not allowed to use this app', stage, 403)
|
|
218
|
+
if (response.status === 403 && stage !== 'app_registration') throw relayError('APP_LOGIN_NOT_ALLOWED', 'platform user is not allowed to use this app', stage, 403)
|
|
171
219
|
const code = stage === 'app_registration' ? 'APP_APP_REGISTRATION_FAILED' : stage === 'app_session' ? 'APP_SESSION_CREATE_FAILED' : 'APP_PLATFORM_REQUEST_FAILED'
|
|
172
220
|
const failure = relayError(code, errorMessage(payload, 'platform request failed: HTTP ' + response.status), stage, response.status)
|
|
173
221
|
failure.requestId = response.headers.get('x-request-id') || ''
|
|
@@ -184,7 +232,7 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
184
232
|
await portalJSON('/portal/user/apps/' + encodeURIComponent(appID) + '/sessions/' + encodeURIComponent(id), { method: 'DELETE' }, 'platform', authToken).catch(() => undefined)
|
|
185
233
|
}
|
|
186
234
|
|
|
187
|
-
const clearAuthentication = async () => {
|
|
235
|
+
const clearAuthentication = async (forceLogin = false) => {
|
|
188
236
|
const current = state.session
|
|
189
237
|
const token = state.portalToken
|
|
190
238
|
state.authGeneration += 1
|
|
@@ -192,9 +240,12 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
192
240
|
state.portalToken = ''
|
|
193
241
|
state.refresh = null
|
|
194
242
|
state.login = null
|
|
243
|
+
state.restore = null
|
|
244
|
+
state.cacheLoaded = true
|
|
245
|
+
state.forceLogin = forceLogin
|
|
195
246
|
state.authState = ''
|
|
196
247
|
state.authStateIssuedAt = 0
|
|
197
|
-
await revoke(current, token)
|
|
248
|
+
await Promise.all([revoke(current, token), updateCredentials(() => credentialStore?.clear())])
|
|
198
249
|
}
|
|
199
250
|
|
|
200
251
|
const assertAuthentication = (generation) => {
|
|
@@ -202,11 +253,135 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
202
253
|
if (generation !== state.authGeneration) throw relayError('APP_AUTH_CHANGED', 'local development authentication changed', 'app_session', 409)
|
|
203
254
|
}
|
|
204
255
|
|
|
205
|
-
const register = (token) =>
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
256
|
+
const register = async (token, signal) => {
|
|
257
|
+
const details = await portalJSON('/portal/user/app-workspace/by-slug/' + encodeURIComponent(appSlug) + '/local-development', {
|
|
258
|
+
method: 'POST',
|
|
259
|
+
signal,
|
|
260
|
+
headers: { 'Content-Type': 'application/json' },
|
|
261
|
+
body: JSON.stringify({ entry_url: entryURL, manifest }),
|
|
262
|
+
}, 'app_registration', token)
|
|
263
|
+
if (!details || typeof details.id !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(details.id)) {
|
|
264
|
+
throw relayError('APP_PLATFORM_INVALID_RESPONSE', 'platform did not return the registered application identity', 'app_registration', 502)
|
|
265
|
+
}
|
|
266
|
+
// The public slug locates the owner-authorized app. All subsequent
|
|
267
|
+
// session/revocation calls use the server-resolved durable identity, not
|
|
268
|
+
// the slug or an obsolete local sidecar. No repair step is required.
|
|
269
|
+
appID = details.id
|
|
270
|
+
return details
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const registerWithDiagnostics = async (token, signal) => {
|
|
274
|
+
try {
|
|
275
|
+
return await register(token, signal)
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (error?.stage === 'app_registration' && [403, 404].includes(error.statusCode)) {
|
|
278
|
+
error.platformAPI = portal
|
|
279
|
+
// The profile endpoint confirms which account the accepted token
|
|
280
|
+
// represents. Show only that account's own email to make duplicate
|
|
281
|
+
// usernames and near-identical addresses distinguishable.
|
|
282
|
+
const profile = await portalJSON('/portal/user/profile', {}, 'account_scope', token).catch(() => null)
|
|
283
|
+
const email = typeof profile?.email === 'string' ? profile.email.trim() : ''
|
|
284
|
+
if (email && email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) error.currentAccount = email
|
|
285
|
+
}
|
|
286
|
+
throw error
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const developmentRequired = () => relayError('APP_DEVELOPMENT_NOT_READY', '开发环境尚未就绪,请先在终端完成 proxy-app dev 的开发授权与应用登记。', 'development_preflight', 503)
|
|
291
|
+
const abortPreparation = () => Object.assign(new Error('开发环境初始化已取消。'), { name: 'AbortError' })
|
|
292
|
+
|
|
293
|
+
const prepareDevelopment = ({ token = '', onAuthorizationRequired, signal, forceLogin = false, timeoutMs = 10 * 60 * 1000 } = {}) => {
|
|
294
|
+
if (state.closed || signal?.aborted) return Promise.reject(abortPreparation())
|
|
295
|
+
if (developmentReady) return Promise.resolve()
|
|
296
|
+
if (developmentPromise) return developmentPromise
|
|
297
|
+
const pending = (async () => {
|
|
298
|
+
let timer
|
|
299
|
+
let rejectCancellation
|
|
300
|
+
const controller = new AbortController()
|
|
301
|
+
const cancellation = new Promise((_, reject) => { rejectCancellation = reject })
|
|
302
|
+
// Install the handler immediately, including while reading the OS store.
|
|
303
|
+
cancellation.catch(() => undefined)
|
|
304
|
+
const cancel = (error) => { controller.abort(error); rejectCancellation(error) }
|
|
305
|
+
const abort = () => cancel(abortPreparation())
|
|
306
|
+
cancelSetup = abort
|
|
307
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
308
|
+
const checkActive = () => {
|
|
309
|
+
if (state.closed || signal?.aborted) throw abortPreparation()
|
|
310
|
+
}
|
|
311
|
+
const wait = (operation) => Promise.race([operation, cancellation])
|
|
312
|
+
try {
|
|
313
|
+
const explicitToken = !forceLogin && typeof token === 'string' ? token.trim() : ''
|
|
314
|
+
let requiresFreshLogin = forceLogin
|
|
315
|
+
let developerToken = explicitToken
|
|
316
|
+
if (!developerToken && !forceLogin) developerToken = await wait(Promise.resolve(developerCredentialStore?.load()))
|
|
317
|
+
// A missing developer cache does not mean that the Portal session is
|
|
318
|
+
// invalid. Let the Portal reuse its current signed-in account; the
|
|
319
|
+
// registration endpoint still enforces that it is the app creator.
|
|
320
|
+
// Force account selection only when replacing a rejected credential.
|
|
321
|
+
if (!developerToken && !explicitToken) requiresFreshLogin = false
|
|
322
|
+
checkActive()
|
|
323
|
+
if (developerToken) {
|
|
324
|
+
try {
|
|
325
|
+
await wait(registerWithDiagnostics(developerToken, controller.signal))
|
|
326
|
+
checkActive()
|
|
327
|
+
developmentReady = true
|
|
328
|
+
return
|
|
329
|
+
} catch (error) {
|
|
330
|
+
// A cached developer account can be valid for the platform while
|
|
331
|
+
// still belonging to a different app owner. Registration reports
|
|
332
|
+
// that case as 403/404, the same way it reports an expired
|
|
333
|
+
// developer session as 401. Drop only the cached credential and
|
|
334
|
+
// offer one fresh developer login; explicit management tokens are
|
|
335
|
+
// caller supplied and must remain an actionable hard failure.
|
|
336
|
+
const registrationNeedsFreshLogin = error?.code === 'APP_LOGIN_SESSION_EXPIRED'
|
|
337
|
+
|| (error?.stage === 'app_registration' && [401, 403, 404].includes(error.statusCode))
|
|
338
|
+
if (!registrationNeedsFreshLogin || explicitToken) throw error
|
|
339
|
+
await wait(Promise.resolve(developerCredentialStore?.clear()))
|
|
340
|
+
requiresFreshLogin = true
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
checkActive()
|
|
344
|
+
if (!onAuthorizationRequired) throw relayError('APP_DEVELOPER_LOGIN_REQUIRED', '请先通过 proxy-app dev 完成开发者授权。', 'development_preflight', 401)
|
|
345
|
+
timer = setTimeout(() => cancel(relayError('APP_DEVELOPER_LOGIN_TIMEOUT', '开发者授权等待超时,请重新运行 proxy-app dev。', 'development_preflight', 408)), Math.max(1, Math.min(timeoutMs, 10 * 60 * 1000)))
|
|
346
|
+
checkActive()
|
|
347
|
+
setupState = randomUUID()
|
|
348
|
+
setupIssuedAt = Date.now()
|
|
349
|
+
const receivedToken = new Promise((resolve) => { submitSetupToken = resolve })
|
|
350
|
+
const query = new URLSearchParams({ proxy_app_auth: '1', proxy_app_state: setupState, proxy_app_relay: relayURL })
|
|
351
|
+
if (requiresFreshLogin) query.set('proxy_app_force_login', '1')
|
|
352
|
+
const details = { login_url: portalOrigin + '/#/login?' + query, auth_state: setupState, relay_url: relayURL, force_login: query.has('proxy_app_force_login') }
|
|
353
|
+
await wait(Promise.resolve(onAuthorizationRequired(details)))
|
|
354
|
+
developerToken = await wait(receivedToken)
|
|
355
|
+
checkActive()
|
|
356
|
+
if (!developerToken) throw relayError('APP_LOGIN_TOKEN_INVALID', '平台未返回有效的开发者授权,请重新运行 proxy-app dev。', 'development_preflight', 400)
|
|
357
|
+
await wait(registerWithDiagnostics(developerToken, controller.signal))
|
|
358
|
+
checkActive()
|
|
359
|
+
// Runtime user login never reads or clears this store.
|
|
360
|
+
await wait(Promise.resolve(developerCredentialStore?.save(developerToken)).catch(() => undefined))
|
|
361
|
+
checkActive()
|
|
362
|
+
developmentReady = true
|
|
363
|
+
} catch (error) {
|
|
364
|
+
// Preserve structured diagnostic fields, but never print arbitrary
|
|
365
|
+
// upstream text or credentials in the terminal/authorization page.
|
|
366
|
+
if (error?.name === 'AbortError') throw error
|
|
367
|
+
throw Object.assign(new Error(visibleErrorMessage(error)), {
|
|
368
|
+
code: error?.code, stage: error?.stage, statusCode: error?.statusCode,
|
|
369
|
+
requestId: error?.requestId, upstreamCode: error?.upstreamCode,
|
|
370
|
+
currentAccount: error?.currentAccount, platformAPI: error?.platformAPI,
|
|
371
|
+
})
|
|
372
|
+
} finally {
|
|
373
|
+
clearTimeout(timer)
|
|
374
|
+
signal?.removeEventListener('abort', abort)
|
|
375
|
+
setupState = ''
|
|
376
|
+
setupIssuedAt = 0
|
|
377
|
+
submitSetupToken = null
|
|
378
|
+
cancelSetup = null
|
|
379
|
+
}
|
|
380
|
+
})()
|
|
381
|
+
developmentPromise = pending
|
|
382
|
+
pending.catch(() => { if (developmentPromise === pending) developmentPromise = null })
|
|
383
|
+
return pending
|
|
384
|
+
}
|
|
210
385
|
|
|
211
386
|
const refresh = async (force = false, generation = state.authGeneration) => {
|
|
212
387
|
assertAuthentication(generation)
|
|
@@ -216,7 +391,9 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
216
391
|
const pending = portalJSON('/portal/user/apps/' + encodeURIComponent(appID) + '/sessions', {
|
|
217
392
|
method: 'POST',
|
|
218
393
|
headers: { 'Content-Type': 'application/json' },
|
|
219
|
-
|
|
394
|
+
// App Sessions are always issued for the Gateway data plane. The old
|
|
395
|
+
// runtime_protocol=2 and Bridge transport are not valid app transports.
|
|
396
|
+
body: JSON.stringify({ version, runtime_protocol: 1 }),
|
|
220
397
|
}, 'app_session', refreshToken).then(async (raw) => {
|
|
221
398
|
if (!raw || typeof raw.access_token !== 'string' || typeof raw.gateway_url !== 'string') throw relayError('APP_SESSION_INVALID', 'platform returned an invalid App Session', 'app_session', 502)
|
|
222
399
|
const next = { ...raw, gateway_url: normalizeGatewayURL(raw.gateway_url) }
|
|
@@ -235,13 +412,46 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
235
412
|
return pending
|
|
236
413
|
}
|
|
237
414
|
|
|
415
|
+
const restoreAuthentication = async () => {
|
|
416
|
+
if (!developmentReady) throw developmentRequired()
|
|
417
|
+
if (state.login) return state.login
|
|
418
|
+
if (state.session) return state.session
|
|
419
|
+
if (state.restore) return state.restore
|
|
420
|
+
const generation = state.authGeneration
|
|
421
|
+
const pending = (async () => {
|
|
422
|
+
if (!state.cacheLoaded) {
|
|
423
|
+
const token = await credentialStore?.load()
|
|
424
|
+
assertAuthentication(generation)
|
|
425
|
+
state.cacheLoaded = true
|
|
426
|
+
state.portalToken = typeof token === 'string' ? token : ''
|
|
427
|
+
}
|
|
428
|
+
if (!state.portalToken) return null
|
|
429
|
+
// Preparation already registered this process's manifest and entry URL.
|
|
430
|
+
// Restoring a runtime user must never mutate the developer's app version.
|
|
431
|
+
return await refresh(false, generation)
|
|
432
|
+
})().catch(async (error) => {
|
|
433
|
+
if (state.authGeneration !== generation || state.closed) throw error
|
|
434
|
+
if (['APP_LOGIN_SESSION_EXPIRED', 'APP_LOGIN_NOT_ALLOWED'].includes(error?.code)) {
|
|
435
|
+
await clearAuthentication(true)
|
|
436
|
+
return null
|
|
437
|
+
}
|
|
438
|
+
// Network or session failures are retryable; retain the saved
|
|
439
|
+
// authorization instead of converting every failure into a login prompt.
|
|
440
|
+
throw error
|
|
441
|
+
}).finally(() => {
|
|
442
|
+
if (state.restore === pending) state.restore = null
|
|
443
|
+
})
|
|
444
|
+
state.restore = pending
|
|
445
|
+
return pending
|
|
446
|
+
}
|
|
447
|
+
|
|
238
448
|
const loginDetails = () => {
|
|
239
449
|
if (!state.authState || Date.now() - state.authStateIssuedAt > 10 * 60 * 1000) {
|
|
240
450
|
state.authState = randomUUID()
|
|
241
451
|
state.authStateIssuedAt = Date.now()
|
|
242
452
|
}
|
|
243
453
|
const query = new URLSearchParams({ proxy_app_auth: '1', proxy_app_state: state.authState, proxy_app_relay: relayURL })
|
|
244
|
-
return { login_url: portalOrigin + '/#/login?' + query, auth_state: state.authState, relay_url: relayURL }
|
|
454
|
+
return { login_url: portalOrigin + '/#/login?' + query, auth_state: state.authState, relay_url: relayURL, force_login: state.forceLogin }
|
|
245
455
|
}
|
|
246
456
|
|
|
247
457
|
const requireLogin = (response, reason) => jsonResponse(response, 401, {
|
|
@@ -272,26 +482,55 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
272
482
|
},
|
|
273
483
|
}
|
|
274
484
|
const title = isSuccess ? '登录成功,正在返回应用...' : visibleErrorMessage(error)
|
|
275
|
-
|
|
485
|
+
// Leave a failed callback open so the SDK can reuse the user-opened popup
|
|
486
|
+
// for one fresh login. Closing it here would trigger popup blockers.
|
|
487
|
+
return '<!doctype html><meta charset="utf-8"><title>平台登录</title><meta name="viewport" content="width=device-width,initial-scale=1"><p>' + htmlEscape(title) + '</p><script>const m=' + safeScriptJSON(message) + ';if(window.opener){window.opener.postMessage(m,' + safeScriptJSON(origin) + ');' + (isSuccess ? 'setTimeout(()=>window.close(),120)' : '') + '}</script>'
|
|
276
488
|
}
|
|
277
489
|
|
|
278
|
-
const
|
|
490
|
+
const developmentHTML = (error) => {
|
|
491
|
+
const title = error ? '开发环境检查失败' : '开发环境已就绪'
|
|
492
|
+
const description = error ? visibleErrorMessage(error) : '应用登记与开发权限检查已通过,终端正在启动本地应用。进入应用后可登录需要测试的用户账户。'
|
|
493
|
+
const diagnostic = error
|
|
494
|
+
? '<p>开发者登录页面:' + htmlEscape(portalOrigin) + '</p><p>平台 API 地址:' + htmlEscape(portal) + '</p><p>应用 slug:' + htmlEscape(appSlug) + '</p>' + (error.requestId ? '<p>请求编号:' + htmlEscape(error.requestId) + '</p>' : '') + '<p>请核对页面显示的完整账户;平台应用要求由创建者账户开发。</p>'
|
|
495
|
+
: '<a href="' + htmlEscape(entryURL) + '" rel="noreferrer">打开本地应用</a>'
|
|
496
|
+
return '<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>' + title + '</title><style>html{color-scheme:dark}body{margin:0;min-height:100vh;display:grid;place-items:center;background:#111;color:#eee;font:15px/1.8 system-ui,sans-serif}main{box-sizing:border-box;width:min(440px,calc(100% - 40px));padding:32px;border:1px solid #383838;border-radius:18px;background:#222}small,p{color:#aaa}h1{font-size:24px;line-height:1.4}p{overflow-wrap:anywhere}a{display:block;margin-top:24px;padding:10px;text-align:center;background:#f1f1f1;color:#202020;border-radius:9px;text-decoration:none}a:focus-visible{outline:2px solid #fff;outline-offset:4px}</style><main><small>Proxy App · 开发环境初始化</small><h1>' + title + '</h1><p>' + htmlEscape(description) + '</p>' + diagnostic + '</main></html>'
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const forward = async (target, request, rawBody, token, appVersion, stage) => {
|
|
279
500
|
const headers = copyRequestHeaders(request)
|
|
280
501
|
if (token) headers.set('Authorization', 'Bearer ' + token)
|
|
281
502
|
if (appVersion) headers.set('X-App-Version', appVersion)
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
503
|
+
const readOnly = ['GET', 'HEAD'].includes(request.method || '')
|
|
504
|
+
try {
|
|
505
|
+
return await fetch(target, {
|
|
506
|
+
method: request.method,
|
|
507
|
+
headers,
|
|
508
|
+
body: readOnly ? undefined : rawBody,
|
|
509
|
+
redirect: 'manual',
|
|
510
|
+
})
|
|
511
|
+
} catch (cause) {
|
|
512
|
+
const gateway = stage === 'gateway'
|
|
513
|
+
const message = gateway
|
|
514
|
+
? '无法连接模型网关,请确认网关服务已启动,并检查平台下发的网关地址及网络连接。'
|
|
515
|
+
: '无法连接平台服务,请确认平台服务已启动,并检查平台 API 地址及网络连接。'
|
|
516
|
+
const failure = relayError(gateway ? 'APP_GATEWAY_UNAVAILABLE' : 'APP_PLATFORM_UNAVAILABLE',
|
|
517
|
+
message + (readOnly ? '恢复连接后可重试查询。' : '本次请求结果尚未确认,请先检查任务或操作状态,避免重复提交。'), stage, 502, cause)
|
|
518
|
+
// A transport failure may occur after an upstream mutation was accepted.
|
|
519
|
+
// Never retry here or claim that execution definitely did not start.
|
|
520
|
+
if (!readOnly) failure.executionUnknown = true
|
|
521
|
+
throw failure
|
|
522
|
+
}
|
|
288
523
|
}
|
|
289
524
|
|
|
290
525
|
const close = async () => {
|
|
291
526
|
if (state.closed) return
|
|
292
527
|
state.closed = true
|
|
528
|
+
cancelSetup?.()
|
|
529
|
+
await developmentPromise?.catch(() => undefined)
|
|
530
|
+
await state.restore?.catch(() => undefined)
|
|
293
531
|
await state.refresh?.catch(() => undefined)
|
|
294
532
|
await state.login?.catch(() => undefined)
|
|
533
|
+
await credentialQueue
|
|
295
534
|
const current = state.session
|
|
296
535
|
state.session = null
|
|
297
536
|
await revoke(current)
|
|
@@ -303,8 +542,11 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
303
542
|
try {
|
|
304
543
|
if (state.closed) throw new Error('local development relay is closed')
|
|
305
544
|
const parsed = new URL(request.url || '/', 'http://127.0.0.1')
|
|
306
|
-
|
|
307
|
-
|
|
545
|
+
// `/auth` is the relay's local control plane. It is exposed to app
|
|
546
|
+
// pages as `/__platform/auth` by the Vite proxy, while the relay
|
|
547
|
+
// itself never accepts an Admin App Runtime path.
|
|
548
|
+
const authPrefix = '/__platform/auth'
|
|
549
|
+
const authPath = parsed.pathname.startsWith(authPrefix + '/') ? parsed.pathname.slice(authPrefix.length) : parsed.pathname
|
|
308
550
|
const callback = authPath === '/auth/callback' && request.method === 'POST'
|
|
309
551
|
if (!callback && request.headers['x-proxy-app-relay'] !== relaySecret) {
|
|
310
552
|
jsonResponse(response, 403, { code: 'LOCAL_DEV_RELAY_UNAUTHORIZED', error: 'local development relay authentication failed' })
|
|
@@ -314,6 +556,25 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
314
556
|
if (callback) {
|
|
315
557
|
const form = new URLSearchParams(rawBody.toString('utf8'))
|
|
316
558
|
const receivedState = form.get('state') || ''
|
|
559
|
+
if (setupState && receivedState === setupState && Date.now() - setupIssuedAt <= 10 * 60 * 1000 && submitSetupToken) {
|
|
560
|
+
// This callback completes CLI preparation, not an app user login.
|
|
561
|
+
// Consume its state before awaiting I/O to prevent replay.
|
|
562
|
+
setupState = ''
|
|
563
|
+
const submit = submitSetupToken
|
|
564
|
+
submitSetupToken = null
|
|
565
|
+
response.setHeader('Content-Type', 'text/html; charset=utf-8')
|
|
566
|
+
response.setHeader('Cache-Control', 'no-store')
|
|
567
|
+
response.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'")
|
|
568
|
+
submit(String(form.get('access_token') || '').trim())
|
|
569
|
+
try {
|
|
570
|
+
await developmentPromise
|
|
571
|
+
response.end(developmentHTML())
|
|
572
|
+
} catch (error) {
|
|
573
|
+
response.statusCode = error?.statusCode || 503
|
|
574
|
+
response.end(developmentHTML(error))
|
|
575
|
+
}
|
|
576
|
+
return
|
|
577
|
+
}
|
|
317
578
|
const valid = receivedState && receivedState === state.authState && Date.now() - state.authStateIssuedAt <= 10 * 60 * 1000
|
|
318
579
|
response.setHeader('Content-Type', 'text/html; charset=utf-8')
|
|
319
580
|
response.setHeader('Cache-Control', 'no-store')
|
|
@@ -322,6 +583,7 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
322
583
|
response.end(callbackHTML(receivedState, 'login-failed', relayError('APP_LOGIN_STATE_INVALID', 'login request expired', 'state_validation', 400)))
|
|
323
584
|
return
|
|
324
585
|
}
|
|
586
|
+
if (!developmentReady) throw developmentRequired()
|
|
325
587
|
state.authState = ''
|
|
326
588
|
state.authStateIssuedAt = 0
|
|
327
589
|
const previous = state.session
|
|
@@ -331,13 +593,20 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
331
593
|
state.session = null
|
|
332
594
|
state.portalToken = token
|
|
333
595
|
state.refresh = null
|
|
596
|
+
state.restore = null
|
|
597
|
+
state.cacheLoaded = true
|
|
334
598
|
const pending = (async () => {
|
|
335
|
-
await revoke(previous, previousToken)
|
|
599
|
+
await Promise.all([revoke(previous, previousToken), updateCredentials(() => credentialStore?.clear())])
|
|
336
600
|
assertAuthentication(generation)
|
|
337
601
|
if (!token) throw relayError('APP_LOGIN_TOKEN_INVALID', 'platform returned an invalid user session token', 'token_validation', 400)
|
|
338
|
-
await
|
|
602
|
+
const session = await refresh(true, generation)
|
|
339
603
|
assertAuthentication(generation)
|
|
340
|
-
|
|
604
|
+
await updateCredentials(() => {
|
|
605
|
+
if (!state.closed && state.authGeneration === generation) return credentialStore?.save(token)
|
|
606
|
+
})
|
|
607
|
+
assertAuthentication(generation)
|
|
608
|
+
state.forceLogin = false
|
|
609
|
+
return session
|
|
341
610
|
})()
|
|
342
611
|
state.login = pending
|
|
343
612
|
try {
|
|
@@ -345,7 +614,7 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
345
614
|
assertAuthentication(generation)
|
|
346
615
|
response.end(callbackHTML(receivedState, 'login-complete'))
|
|
347
616
|
} catch (error) {
|
|
348
|
-
if (state.authGeneration === generation) await clearAuthentication()
|
|
617
|
+
if (!state.closed && state.authGeneration === generation) await clearAuthentication(['APP_LOGIN_SESSION_EXPIRED', 'APP_LOGIN_NOT_ALLOWED'].includes(error?.code))
|
|
349
618
|
response.statusCode = error?.statusCode || 502
|
|
350
619
|
response.end(callbackHTML(receivedState, 'login-failed', error))
|
|
351
620
|
} finally {
|
|
@@ -353,50 +622,62 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
353
622
|
}
|
|
354
623
|
return
|
|
355
624
|
}
|
|
356
|
-
if (parsed.pathname === '/health') { jsonResponse(response, 200, { ok: true, app_id: appID, app_slug: appSlug, app_version: version, runtime_protocol:
|
|
357
|
-
if (
|
|
358
|
-
if (authPath === '/
|
|
625
|
+
if (parsed.pathname === '/health') { jsonResponse(response, 200, { ok: true, development_ready: developmentReady, app_id: appID, app_slug: appSlug, app_version: version, runtime_protocol: 1, authenticated: Boolean(state.session) }); return }
|
|
626
|
+
if (!developmentReady) throw developmentRequired()
|
|
627
|
+
if (authPath === '/start' || authPath === '/auth/start') {
|
|
628
|
+
if (parsed.searchParams.get('force_login') === '1') await clearAuthentication(true)
|
|
629
|
+
else await restoreAuthentication()
|
|
630
|
+
jsonResponse(response, 200, { data: { authenticated: Boolean(state.session), ...loginDetails() } })
|
|
631
|
+
return
|
|
632
|
+
}
|
|
633
|
+
if ((authPath === '/logout' || authPath === '/auth/logout') && request.method === 'POST') {
|
|
359
634
|
await clearAuthentication()
|
|
360
635
|
jsonResponse(response, 200, { data: { success: true } })
|
|
361
636
|
return
|
|
362
637
|
}
|
|
363
|
-
if (authPath === '/
|
|
364
|
-
|
|
365
|
-
if (!
|
|
366
|
-
|
|
367
|
-
|
|
638
|
+
if (authPath === '/status' || authPath === '/auth/status') {
|
|
639
|
+
const current = await restoreAuthentication()
|
|
640
|
+
if (current && !validAccountScope(current.account_scope)) {
|
|
641
|
+
// Account scope is returned by the Gateway App Session contract.
|
|
642
|
+
// If an older cached session lacks it, obtain one replacement
|
|
643
|
+
// session through the authenticated control-plane session API;
|
|
644
|
+
// never query the retired Admin App Runtime endpoint.
|
|
645
|
+
const generation = state.authGeneration
|
|
646
|
+
await refresh(true, generation)
|
|
647
|
+
if (!validAccountScope(state.session?.account_scope)) throw accountScopeUnavailable()
|
|
368
648
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
649
|
+
jsonResponse(response, 200, { data: { authenticated: Boolean(state.session), app_id: appID, app_version: version, ...(state.session?.account_scope ? { account_scope: state.session.account_scope } : {}) } })
|
|
650
|
+
return
|
|
651
|
+
}
|
|
652
|
+
// The profile bridge is account metadata, not a Gateway model route.
|
|
653
|
+
// Resolve it with the same platform credential used to mint the App
|
|
654
|
+
// Session so local development shows the signed-in user consistently.
|
|
655
|
+
if (parsed.pathname === '/v1/profile' || parsed.pathname === '/auth/profile') {
|
|
656
|
+
const current = await restoreAuthentication()
|
|
657
|
+
if (!current) { requireLogin(response); return }
|
|
658
|
+
const profile = await portalJSON('/portal/user/profile', {}, 'account_scope', state.portalToken)
|
|
659
|
+
const safeProfile = profile && typeof profile === 'object' ? {
|
|
660
|
+
...(typeof profile.id === 'string' ? { id: profile.id } : {}),
|
|
661
|
+
...(typeof profile.email === 'string' ? { email: profile.email } : {}),
|
|
662
|
+
...(typeof profile.username === 'string' ? { username: profile.username } : {}),
|
|
663
|
+
...(typeof profile.name === 'string' ? { name: profile.name } : {}),
|
|
664
|
+
...(typeof profile.avatar_url === 'string' ? { avatar_url: profile.avatar_url } : {}),
|
|
665
|
+
} : {}
|
|
666
|
+
jsonResponse(response, 200, { data: safeProfile })
|
|
387
667
|
return
|
|
388
668
|
}
|
|
389
|
-
const runtimeRequest = parsed.pathname === runtimePrefix || parsed.pathname.startsWith(runtimePrefix + '/')
|
|
390
669
|
const gatewayRequest = parsed.pathname === '/v1' || parsed.pathname.startsWith('/v1/')
|
|
391
|
-
if (
|
|
670
|
+
if (gatewayRequest) {
|
|
671
|
+
await restoreAuthentication()
|
|
392
672
|
if (!state.session) { requireLogin(response); return }
|
|
393
|
-
const prefix =
|
|
673
|
+
const prefix = '/v1'
|
|
394
674
|
const suffix = parsed.pathname.slice(prefix.length) || '/'
|
|
395
|
-
const target = (session) =>
|
|
675
|
+
const target = (session) => session.gateway_url + suffix + parsed.search
|
|
676
|
+
const stage = 'gateway'
|
|
396
677
|
const generation = state.authGeneration
|
|
397
678
|
let current = state.session
|
|
398
|
-
let upstream = await forward(target(current), request, rawBody, current.access_token, current.app_version)
|
|
399
|
-
if (await retryableSessionExpiry(upstream
|
|
679
|
+
let upstream = await forward(target(current), request, rawBody, current.access_token, current.app_version, stage)
|
|
680
|
+
if (await retryableSessionExpiry(upstream)) {
|
|
400
681
|
await upstream.arrayBuffer().catch(() => undefined)
|
|
401
682
|
try {
|
|
402
683
|
assertAuthentication(generation)
|
|
@@ -404,14 +685,14 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
404
685
|
} catch (error) {
|
|
405
686
|
assertAuthentication(generation)
|
|
406
687
|
if (error?.code === 'APP_LOGIN_SESSION_EXPIRED' || error?.code === 'APP_LOGIN_NOT_ALLOWED') {
|
|
407
|
-
await clearAuthentication()
|
|
688
|
+
await clearAuthentication(true)
|
|
408
689
|
requireLogin(response, error)
|
|
409
690
|
return
|
|
410
691
|
}
|
|
411
692
|
throw error
|
|
412
693
|
}
|
|
413
694
|
assertAuthentication(generation)
|
|
414
|
-
upstream = await forward(target(current), request, rawBody, current.access_token, current.app_version)
|
|
695
|
+
upstream = await forward(target(current), request, rawBody, current.access_token, current.app_version, stage)
|
|
415
696
|
}
|
|
416
697
|
if (state.authGeneration !== generation) {
|
|
417
698
|
await upstream.body?.cancel().catch(() => undefined)
|
|
@@ -424,13 +705,18 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
424
705
|
}
|
|
425
706
|
jsonResponse(response, 404, { code: 'LOCAL_DEV_ROUTE_NOT_FOUND', error: 'local development route not found' })
|
|
426
707
|
} catch (error) {
|
|
427
|
-
|
|
708
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
709
|
+
jsonResponse(response, error?.statusCode || (error?.message === 'local development relay is closed' ? 503 : 502), {
|
|
710
|
+
code: error?.code || 'LOCAL_DEV_RELAY_ERROR', error: message, message,
|
|
711
|
+
stage: error?.stage,
|
|
712
|
+
execution_unknown: error?.executionUnknown || undefined,
|
|
713
|
+
})
|
|
428
714
|
}
|
|
429
715
|
})
|
|
430
716
|
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) })
|
|
431
717
|
const address = server.address()
|
|
432
718
|
relayURL = 'http://127.0.0.1:' + address.port
|
|
433
|
-
return { relayURL, close }
|
|
719
|
+
return { relayURL, prepareDevelopment, close }
|
|
434
720
|
} catch (error) {
|
|
435
721
|
await close()
|
|
436
722
|
throw error
|