@ducking-mind/proxy-app-cli 0.1.0 → 0.1.1
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 +127 -62
- package/agent-contract.mjs +128 -0
- package/agent-runtime/package-guide.mjs +193 -0
- package/agent-runtime/protocol.mjs +325 -0
- package/agent-runtime/runtime-status.mjs +70 -0
- package/artifact-security.mjs +45 -0
- package/local-dev-credentials.mjs +187 -0
- package/local-dev-relay.mjs +367 -82
- package/package.json +23 -6
- package/prepare-agent-runtime.mjs +43 -0
- package/proxy-app.mjs +871 -190
- package/release-runtime/release-config.mjs +154 -0
- package/release-runtime/release-target.mjs +48 -0
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,134 @@ 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
|
+
// Do not silently reuse an already signed-in browser account when no
|
|
318
|
+
// developer credential is available. The login page will require the
|
|
319
|
+
// user to choose the creator account explicitly.
|
|
320
|
+
if (!developerToken && !explicitToken) requiresFreshLogin = true
|
|
321
|
+
checkActive()
|
|
322
|
+
if (developerToken) {
|
|
323
|
+
try {
|
|
324
|
+
await wait(registerWithDiagnostics(developerToken, controller.signal))
|
|
325
|
+
checkActive()
|
|
326
|
+
developmentReady = true
|
|
327
|
+
return
|
|
328
|
+
} catch (error) {
|
|
329
|
+
// A cached developer account can be valid for the platform while
|
|
330
|
+
// still belonging to a different app owner. Registration reports
|
|
331
|
+
// that case as 403/404, the same way it reports an expired
|
|
332
|
+
// developer session as 401. Drop only the cached credential and
|
|
333
|
+
// offer one fresh developer login; explicit management tokens are
|
|
334
|
+
// caller supplied and must remain an actionable hard failure.
|
|
335
|
+
const registrationNeedsFreshLogin = error?.code === 'APP_LOGIN_SESSION_EXPIRED'
|
|
336
|
+
|| (error?.stage === 'app_registration' && [401, 403, 404].includes(error.statusCode))
|
|
337
|
+
if (!registrationNeedsFreshLogin || explicitToken) throw error
|
|
338
|
+
await wait(Promise.resolve(developerCredentialStore?.clear()))
|
|
339
|
+
requiresFreshLogin = true
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
checkActive()
|
|
343
|
+
if (!onAuthorizationRequired) throw relayError('APP_DEVELOPER_LOGIN_REQUIRED', '请先通过 proxy-app dev 完成开发者授权。', 'development_preflight', 401)
|
|
344
|
+
timer = setTimeout(() => cancel(relayError('APP_DEVELOPER_LOGIN_TIMEOUT', '开发者授权等待超时,请重新运行 proxy-app dev。', 'development_preflight', 408)), Math.max(1, Math.min(timeoutMs, 10 * 60 * 1000)))
|
|
345
|
+
checkActive()
|
|
346
|
+
setupState = randomUUID()
|
|
347
|
+
setupIssuedAt = Date.now()
|
|
348
|
+
const receivedToken = new Promise((resolve) => { submitSetupToken = resolve })
|
|
349
|
+
const query = new URLSearchParams({ proxy_app_auth: '1', proxy_app_state: setupState, proxy_app_relay: relayURL })
|
|
350
|
+
if (requiresFreshLogin) query.set('proxy_app_force_login', '1')
|
|
351
|
+
const details = { login_url: portalOrigin + '/#/login?' + query, auth_state: setupState, relay_url: relayURL, force_login: query.has('proxy_app_force_login') }
|
|
352
|
+
await wait(Promise.resolve(onAuthorizationRequired(details)))
|
|
353
|
+
developerToken = await wait(receivedToken)
|
|
354
|
+
checkActive()
|
|
355
|
+
if (!developerToken) throw relayError('APP_LOGIN_TOKEN_INVALID', '平台未返回有效的开发者授权,请重新运行 proxy-app dev。', 'development_preflight', 400)
|
|
356
|
+
await wait(registerWithDiagnostics(developerToken, controller.signal))
|
|
357
|
+
checkActive()
|
|
358
|
+
// Runtime user login never reads or clears this store.
|
|
359
|
+
await wait(Promise.resolve(developerCredentialStore?.save(developerToken)).catch(() => undefined))
|
|
360
|
+
checkActive()
|
|
361
|
+
developmentReady = true
|
|
362
|
+
} catch (error) {
|
|
363
|
+
// Preserve structured diagnostic fields, but never print arbitrary
|
|
364
|
+
// upstream text or credentials in the terminal/authorization page.
|
|
365
|
+
if (error?.name === 'AbortError') throw error
|
|
366
|
+
throw Object.assign(new Error(visibleErrorMessage(error)), {
|
|
367
|
+
code: error?.code, stage: error?.stage, statusCode: error?.statusCode,
|
|
368
|
+
requestId: error?.requestId, upstreamCode: error?.upstreamCode,
|
|
369
|
+
currentAccount: error?.currentAccount, platformAPI: error?.platformAPI,
|
|
370
|
+
})
|
|
371
|
+
} finally {
|
|
372
|
+
clearTimeout(timer)
|
|
373
|
+
signal?.removeEventListener('abort', abort)
|
|
374
|
+
setupState = ''
|
|
375
|
+
setupIssuedAt = 0
|
|
376
|
+
submitSetupToken = null
|
|
377
|
+
cancelSetup = null
|
|
378
|
+
}
|
|
379
|
+
})()
|
|
380
|
+
developmentPromise = pending
|
|
381
|
+
pending.catch(() => { if (developmentPromise === pending) developmentPromise = null })
|
|
382
|
+
return pending
|
|
383
|
+
}
|
|
210
384
|
|
|
211
385
|
const refresh = async (force = false, generation = state.authGeneration) => {
|
|
212
386
|
assertAuthentication(generation)
|
|
@@ -216,7 +390,9 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
216
390
|
const pending = portalJSON('/portal/user/apps/' + encodeURIComponent(appID) + '/sessions', {
|
|
217
391
|
method: 'POST',
|
|
218
392
|
headers: { 'Content-Type': 'application/json' },
|
|
219
|
-
|
|
393
|
+
// App Sessions are always issued for the Gateway data plane. The old
|
|
394
|
+
// runtime_protocol=2 and Bridge transport are not valid app transports.
|
|
395
|
+
body: JSON.stringify({ version, runtime_protocol: 1 }),
|
|
220
396
|
}, 'app_session', refreshToken).then(async (raw) => {
|
|
221
397
|
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
398
|
const next = { ...raw, gateway_url: normalizeGatewayURL(raw.gateway_url) }
|
|
@@ -235,13 +411,46 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
235
411
|
return pending
|
|
236
412
|
}
|
|
237
413
|
|
|
414
|
+
const restoreAuthentication = async () => {
|
|
415
|
+
if (!developmentReady) throw developmentRequired()
|
|
416
|
+
if (state.login) return state.login
|
|
417
|
+
if (state.session) return state.session
|
|
418
|
+
if (state.restore) return state.restore
|
|
419
|
+
const generation = state.authGeneration
|
|
420
|
+
const pending = (async () => {
|
|
421
|
+
if (!state.cacheLoaded) {
|
|
422
|
+
const token = await credentialStore?.load()
|
|
423
|
+
assertAuthentication(generation)
|
|
424
|
+
state.cacheLoaded = true
|
|
425
|
+
state.portalToken = typeof token === 'string' ? token : ''
|
|
426
|
+
}
|
|
427
|
+
if (!state.portalToken) return null
|
|
428
|
+
// Preparation already registered this process's manifest and entry URL.
|
|
429
|
+
// Restoring a runtime user must never mutate the developer's app version.
|
|
430
|
+
return await refresh(false, generation)
|
|
431
|
+
})().catch(async (error) => {
|
|
432
|
+
if (state.authGeneration !== generation || state.closed) throw error
|
|
433
|
+
if (['APP_LOGIN_SESSION_EXPIRED', 'APP_LOGIN_NOT_ALLOWED'].includes(error?.code)) {
|
|
434
|
+
await clearAuthentication(true)
|
|
435
|
+
return null
|
|
436
|
+
}
|
|
437
|
+
// Network or session failures are retryable; retain the saved
|
|
438
|
+
// authorization instead of converting every failure into a login prompt.
|
|
439
|
+
throw error
|
|
440
|
+
}).finally(() => {
|
|
441
|
+
if (state.restore === pending) state.restore = null
|
|
442
|
+
})
|
|
443
|
+
state.restore = pending
|
|
444
|
+
return pending
|
|
445
|
+
}
|
|
446
|
+
|
|
238
447
|
const loginDetails = () => {
|
|
239
448
|
if (!state.authState || Date.now() - state.authStateIssuedAt > 10 * 60 * 1000) {
|
|
240
449
|
state.authState = randomUUID()
|
|
241
450
|
state.authStateIssuedAt = Date.now()
|
|
242
451
|
}
|
|
243
452
|
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 }
|
|
453
|
+
return { login_url: portalOrigin + '/#/login?' + query, auth_state: state.authState, relay_url: relayURL, force_login: state.forceLogin }
|
|
245
454
|
}
|
|
246
455
|
|
|
247
456
|
const requireLogin = (response, reason) => jsonResponse(response, 401, {
|
|
@@ -272,26 +481,55 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
272
481
|
},
|
|
273
482
|
}
|
|
274
483
|
const title = isSuccess ? '登录成功,正在返回应用...' : visibleErrorMessage(error)
|
|
275
|
-
|
|
484
|
+
// Leave a failed callback open so the SDK can reuse the user-opened popup
|
|
485
|
+
// for one fresh login. Closing it here would trigger popup blockers.
|
|
486
|
+
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
487
|
}
|
|
277
488
|
|
|
278
|
-
const
|
|
489
|
+
const developmentHTML = (error) => {
|
|
490
|
+
const title = error ? '开发环境检查失败' : '开发环境已就绪'
|
|
491
|
+
const description = error ? visibleErrorMessage(error) : '应用登记与开发权限检查已通过,终端正在启动本地应用。进入应用后可登录需要测试的用户账户。'
|
|
492
|
+
const diagnostic = error
|
|
493
|
+
? '<p>开发者登录页面:' + htmlEscape(portalOrigin) + '</p><p>平台 API 地址:' + htmlEscape(portal) + '</p><p>应用 slug:' + htmlEscape(appSlug) + '</p>' + (error.requestId ? '<p>请求编号:' + htmlEscape(error.requestId) + '</p>' : '') + '<p>请核对页面显示的完整账户;平台应用要求由创建者账户开发。</p>'
|
|
494
|
+
: '<a href="' + htmlEscape(entryURL) + '" rel="noreferrer">打开本地应用</a>'
|
|
495
|
+
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>'
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const forward = async (target, request, rawBody, token, appVersion, stage) => {
|
|
279
499
|
const headers = copyRequestHeaders(request)
|
|
280
500
|
if (token) headers.set('Authorization', 'Bearer ' + token)
|
|
281
501
|
if (appVersion) headers.set('X-App-Version', appVersion)
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
502
|
+
const readOnly = ['GET', 'HEAD'].includes(request.method || '')
|
|
503
|
+
try {
|
|
504
|
+
return await fetch(target, {
|
|
505
|
+
method: request.method,
|
|
506
|
+
headers,
|
|
507
|
+
body: readOnly ? undefined : rawBody,
|
|
508
|
+
redirect: 'manual',
|
|
509
|
+
})
|
|
510
|
+
} catch (cause) {
|
|
511
|
+
const gateway = stage === 'gateway'
|
|
512
|
+
const message = gateway
|
|
513
|
+
? '无法连接模型网关,请确认网关服务已启动,并检查平台下发的网关地址及网络连接。'
|
|
514
|
+
: '无法连接平台服务,请确认平台服务已启动,并检查平台 API 地址及网络连接。'
|
|
515
|
+
const failure = relayError(gateway ? 'APP_GATEWAY_UNAVAILABLE' : 'APP_PLATFORM_UNAVAILABLE',
|
|
516
|
+
message + (readOnly ? '恢复连接后可重试查询。' : '本次请求结果尚未确认,请先检查任务或操作状态,避免重复提交。'), stage, 502, cause)
|
|
517
|
+
// A transport failure may occur after an upstream mutation was accepted.
|
|
518
|
+
// Never retry here or claim that execution definitely did not start.
|
|
519
|
+
if (!readOnly) failure.executionUnknown = true
|
|
520
|
+
throw failure
|
|
521
|
+
}
|
|
288
522
|
}
|
|
289
523
|
|
|
290
524
|
const close = async () => {
|
|
291
525
|
if (state.closed) return
|
|
292
526
|
state.closed = true
|
|
527
|
+
cancelSetup?.()
|
|
528
|
+
await developmentPromise?.catch(() => undefined)
|
|
529
|
+
await state.restore?.catch(() => undefined)
|
|
293
530
|
await state.refresh?.catch(() => undefined)
|
|
294
531
|
await state.login?.catch(() => undefined)
|
|
532
|
+
await credentialQueue
|
|
295
533
|
const current = state.session
|
|
296
534
|
state.session = null
|
|
297
535
|
await revoke(current)
|
|
@@ -303,8 +541,11 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
303
541
|
try {
|
|
304
542
|
if (state.closed) throw new Error('local development relay is closed')
|
|
305
543
|
const parsed = new URL(request.url || '/', 'http://127.0.0.1')
|
|
306
|
-
|
|
307
|
-
|
|
544
|
+
// `/auth` is the relay's local control plane. It is exposed to app
|
|
545
|
+
// pages as `/__platform/auth` by the Vite proxy, while the relay
|
|
546
|
+
// itself never accepts an Admin App Runtime path.
|
|
547
|
+
const authPrefix = '/__platform/auth'
|
|
548
|
+
const authPath = parsed.pathname.startsWith(authPrefix + '/') ? parsed.pathname.slice(authPrefix.length) : parsed.pathname
|
|
308
549
|
const callback = authPath === '/auth/callback' && request.method === 'POST'
|
|
309
550
|
if (!callback && request.headers['x-proxy-app-relay'] !== relaySecret) {
|
|
310
551
|
jsonResponse(response, 403, { code: 'LOCAL_DEV_RELAY_UNAUTHORIZED', error: 'local development relay authentication failed' })
|
|
@@ -314,6 +555,25 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
314
555
|
if (callback) {
|
|
315
556
|
const form = new URLSearchParams(rawBody.toString('utf8'))
|
|
316
557
|
const receivedState = form.get('state') || ''
|
|
558
|
+
if (setupState && receivedState === setupState && Date.now() - setupIssuedAt <= 10 * 60 * 1000 && submitSetupToken) {
|
|
559
|
+
// This callback completes CLI preparation, not an app user login.
|
|
560
|
+
// Consume its state before awaiting I/O to prevent replay.
|
|
561
|
+
setupState = ''
|
|
562
|
+
const submit = submitSetupToken
|
|
563
|
+
submitSetupToken = null
|
|
564
|
+
response.setHeader('Content-Type', 'text/html; charset=utf-8')
|
|
565
|
+
response.setHeader('Cache-Control', 'no-store')
|
|
566
|
+
response.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'")
|
|
567
|
+
submit(String(form.get('access_token') || '').trim())
|
|
568
|
+
try {
|
|
569
|
+
await developmentPromise
|
|
570
|
+
response.end(developmentHTML())
|
|
571
|
+
} catch (error) {
|
|
572
|
+
response.statusCode = error?.statusCode || 503
|
|
573
|
+
response.end(developmentHTML(error))
|
|
574
|
+
}
|
|
575
|
+
return
|
|
576
|
+
}
|
|
317
577
|
const valid = receivedState && receivedState === state.authState && Date.now() - state.authStateIssuedAt <= 10 * 60 * 1000
|
|
318
578
|
response.setHeader('Content-Type', 'text/html; charset=utf-8')
|
|
319
579
|
response.setHeader('Cache-Control', 'no-store')
|
|
@@ -322,6 +582,7 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
322
582
|
response.end(callbackHTML(receivedState, 'login-failed', relayError('APP_LOGIN_STATE_INVALID', 'login request expired', 'state_validation', 400)))
|
|
323
583
|
return
|
|
324
584
|
}
|
|
585
|
+
if (!developmentReady) throw developmentRequired()
|
|
325
586
|
state.authState = ''
|
|
326
587
|
state.authStateIssuedAt = 0
|
|
327
588
|
const previous = state.session
|
|
@@ -331,13 +592,20 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
331
592
|
state.session = null
|
|
332
593
|
state.portalToken = token
|
|
333
594
|
state.refresh = null
|
|
595
|
+
state.restore = null
|
|
596
|
+
state.cacheLoaded = true
|
|
334
597
|
const pending = (async () => {
|
|
335
|
-
await revoke(previous, previousToken)
|
|
598
|
+
await Promise.all([revoke(previous, previousToken), updateCredentials(() => credentialStore?.clear())])
|
|
336
599
|
assertAuthentication(generation)
|
|
337
600
|
if (!token) throw relayError('APP_LOGIN_TOKEN_INVALID', 'platform returned an invalid user session token', 'token_validation', 400)
|
|
338
|
-
await
|
|
601
|
+
const session = await refresh(true, generation)
|
|
339
602
|
assertAuthentication(generation)
|
|
340
|
-
|
|
603
|
+
await updateCredentials(() => {
|
|
604
|
+
if (!state.closed && state.authGeneration === generation) return credentialStore?.save(token)
|
|
605
|
+
})
|
|
606
|
+
assertAuthentication(generation)
|
|
607
|
+
state.forceLogin = false
|
|
608
|
+
return session
|
|
341
609
|
})()
|
|
342
610
|
state.login = pending
|
|
343
611
|
try {
|
|
@@ -345,7 +613,7 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
345
613
|
assertAuthentication(generation)
|
|
346
614
|
response.end(callbackHTML(receivedState, 'login-complete'))
|
|
347
615
|
} catch (error) {
|
|
348
|
-
if (state.authGeneration === generation) await clearAuthentication()
|
|
616
|
+
if (!state.closed && state.authGeneration === generation) await clearAuthentication(['APP_LOGIN_SESSION_EXPIRED', 'APP_LOGIN_NOT_ALLOWED'].includes(error?.code))
|
|
349
617
|
response.statusCode = error?.statusCode || 502
|
|
350
618
|
response.end(callbackHTML(receivedState, 'login-failed', error))
|
|
351
619
|
} finally {
|
|
@@ -353,50 +621,62 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
353
621
|
}
|
|
354
622
|
return
|
|
355
623
|
}
|
|
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 === '/
|
|
624
|
+
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 }
|
|
625
|
+
if (!developmentReady) throw developmentRequired()
|
|
626
|
+
if (authPath === '/start' || authPath === '/auth/start') {
|
|
627
|
+
if (parsed.searchParams.get('force_login') === '1') await clearAuthentication(true)
|
|
628
|
+
else await restoreAuthentication()
|
|
629
|
+
jsonResponse(response, 200, { data: { authenticated: Boolean(state.session), ...loginDetails() } })
|
|
630
|
+
return
|
|
631
|
+
}
|
|
632
|
+
if ((authPath === '/logout' || authPath === '/auth/logout') && request.method === 'POST') {
|
|
359
633
|
await clearAuthentication()
|
|
360
634
|
jsonResponse(response, 200, { data: { success: true } })
|
|
361
635
|
return
|
|
362
636
|
}
|
|
363
|
-
if (authPath === '/
|
|
364
|
-
|
|
365
|
-
if (!
|
|
366
|
-
|
|
367
|
-
|
|
637
|
+
if (authPath === '/status' || authPath === '/auth/status') {
|
|
638
|
+
const current = await restoreAuthentication()
|
|
639
|
+
if (current && !validAccountScope(current.account_scope)) {
|
|
640
|
+
// Account scope is returned by the Gateway App Session contract.
|
|
641
|
+
// If an older cached session lacks it, obtain one replacement
|
|
642
|
+
// session through the authenticated control-plane session API;
|
|
643
|
+
// never query the retired Admin App Runtime endpoint.
|
|
644
|
+
const generation = state.authGeneration
|
|
645
|
+
await refresh(true, generation)
|
|
646
|
+
if (!validAccountScope(state.session?.account_scope)) throw accountScopeUnavailable()
|
|
368
647
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
648
|
+
jsonResponse(response, 200, { data: { authenticated: Boolean(state.session), app_id: appID, app_version: version, ...(state.session?.account_scope ? { account_scope: state.session.account_scope } : {}) } })
|
|
649
|
+
return
|
|
650
|
+
}
|
|
651
|
+
// The profile bridge is account metadata, not a Gateway model route.
|
|
652
|
+
// Resolve it with the same platform credential used to mint the App
|
|
653
|
+
// Session so local development shows the signed-in user consistently.
|
|
654
|
+
if (parsed.pathname === '/v1/profile' || parsed.pathname === '/auth/profile') {
|
|
655
|
+
const current = await restoreAuthentication()
|
|
656
|
+
if (!current) { requireLogin(response); return }
|
|
657
|
+
const profile = await portalJSON('/portal/user/profile', {}, 'account_scope', state.portalToken)
|
|
658
|
+
const safeProfile = profile && typeof profile === 'object' ? {
|
|
659
|
+
...(typeof profile.id === 'string' ? { id: profile.id } : {}),
|
|
660
|
+
...(typeof profile.email === 'string' ? { email: profile.email } : {}),
|
|
661
|
+
...(typeof profile.username === 'string' ? { username: profile.username } : {}),
|
|
662
|
+
...(typeof profile.name === 'string' ? { name: profile.name } : {}),
|
|
663
|
+
...(typeof profile.avatar_url === 'string' ? { avatar_url: profile.avatar_url } : {}),
|
|
664
|
+
} : {}
|
|
665
|
+
jsonResponse(response, 200, { data: safeProfile })
|
|
387
666
|
return
|
|
388
667
|
}
|
|
389
|
-
const runtimeRequest = parsed.pathname === runtimePrefix || parsed.pathname.startsWith(runtimePrefix + '/')
|
|
390
668
|
const gatewayRequest = parsed.pathname === '/v1' || parsed.pathname.startsWith('/v1/')
|
|
391
|
-
if (
|
|
669
|
+
if (gatewayRequest) {
|
|
670
|
+
await restoreAuthentication()
|
|
392
671
|
if (!state.session) { requireLogin(response); return }
|
|
393
|
-
const prefix =
|
|
672
|
+
const prefix = '/v1'
|
|
394
673
|
const suffix = parsed.pathname.slice(prefix.length) || '/'
|
|
395
|
-
const target = (session) =>
|
|
674
|
+
const target = (session) => session.gateway_url + suffix + parsed.search
|
|
675
|
+
const stage = 'gateway'
|
|
396
676
|
const generation = state.authGeneration
|
|
397
677
|
let current = state.session
|
|
398
|
-
let upstream = await forward(target(current), request, rawBody, current.access_token, current.app_version)
|
|
399
|
-
if (await retryableSessionExpiry(upstream
|
|
678
|
+
let upstream = await forward(target(current), request, rawBody, current.access_token, current.app_version, stage)
|
|
679
|
+
if (await retryableSessionExpiry(upstream)) {
|
|
400
680
|
await upstream.arrayBuffer().catch(() => undefined)
|
|
401
681
|
try {
|
|
402
682
|
assertAuthentication(generation)
|
|
@@ -404,14 +684,14 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
404
684
|
} catch (error) {
|
|
405
685
|
assertAuthentication(generation)
|
|
406
686
|
if (error?.code === 'APP_LOGIN_SESSION_EXPIRED' || error?.code === 'APP_LOGIN_NOT_ALLOWED') {
|
|
407
|
-
await clearAuthentication()
|
|
687
|
+
await clearAuthentication(true)
|
|
408
688
|
requireLogin(response, error)
|
|
409
689
|
return
|
|
410
690
|
}
|
|
411
691
|
throw error
|
|
412
692
|
}
|
|
413
693
|
assertAuthentication(generation)
|
|
414
|
-
upstream = await forward(target(current), request, rawBody, current.access_token, current.app_version)
|
|
694
|
+
upstream = await forward(target(current), request, rawBody, current.access_token, current.app_version, stage)
|
|
415
695
|
}
|
|
416
696
|
if (state.authGeneration !== generation) {
|
|
417
697
|
await upstream.body?.cancel().catch(() => undefined)
|
|
@@ -424,13 +704,18 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
424
704
|
}
|
|
425
705
|
jsonResponse(response, 404, { code: 'LOCAL_DEV_ROUTE_NOT_FOUND', error: 'local development route not found' })
|
|
426
706
|
} catch (error) {
|
|
427
|
-
|
|
707
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
708
|
+
jsonResponse(response, error?.statusCode || (error?.message === 'local development relay is closed' ? 503 : 502), {
|
|
709
|
+
code: error?.code || 'LOCAL_DEV_RELAY_ERROR', error: message, message,
|
|
710
|
+
stage: error?.stage,
|
|
711
|
+
execution_unknown: error?.executionUnknown || undefined,
|
|
712
|
+
})
|
|
428
713
|
}
|
|
429
714
|
})
|
|
430
715
|
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) })
|
|
431
716
|
const address = server.address()
|
|
432
717
|
relayURL = 'http://127.0.0.1:' + address.port
|
|
433
|
-
return { relayURL, close }
|
|
718
|
+
return { relayURL, prepareDevelopment, close }
|
|
434
719
|
} catch (error) {
|
|
435
720
|
await close()
|
|
436
721
|
throw error
|