@ducking-mind/proxy-app-cli 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,78 +1,36 @@
1
1
  import { createServer } from 'node:http'
2
2
  import { randomUUID } from 'node:crypto'
3
- import { Readable } from 'node:stream'
4
3
 
5
- const HOP_BY_HOP_HEADERS = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade'])
6
- const SENSITIVE_HEADERS = new Set(['host', 'authorization', 'cookie', 'origin', 'referer', 'content-length', 'x-proxy-app-relay'])
7
4
  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)
10
5
 
11
- function copyRequestHeaders(request) {
12
- const headers = new Headers()
13
- for (const [name, value] of Object.entries(request.headers)) {
14
- const lower = name.toLowerCase()
15
- if (HOP_BY_HOP_HEADERS.has(lower) || SENSITIVE_HEADERS.has(lower)) continue
16
- if (Array.isArray(value)) headers.set(name, value.join(', '))
17
- else if (value !== undefined) headers.set(name, value)
18
- }
19
- return headers
20
- }
21
-
22
- function copyResponseHeaders(response, target) {
23
- response.headers.forEach((value, name) => {
24
- const lower = name.toLowerCase()
25
- if (!HOP_BY_HOP_HEADERS.has(lower) && !['content-encoding', 'content-length', 'set-cookie'].includes(lower)) target.setHeader(name, value)
26
- })
27
- }
28
-
29
- async function readBody(request, limit = 16 * 1024 * 1024) {
6
+ async function readBody(request, limit = 16 * 1024) {
30
7
  const chunks = []
31
- let size = 0
8
+ let length = 0
32
9
  for await (const chunk of request) {
33
- size += chunk.length
34
- if (size > limit) throw new Error('local development request body is too large')
10
+ length += chunk.length
11
+ if (length > limit) throw Object.assign(new Error('request body is too large'), { statusCode: 413 })
35
12
  chunks.push(chunk)
36
13
  }
37
14
  return Buffer.concat(chunks)
38
15
  }
39
16
 
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 }
17
+ async function readResponse(response) {
18
+ const text = await response.text()
19
+ if (!text.trim()) return { text, payload: null, invalidJSON: true }
20
+ try {
21
+ const payload = JSON.parse(text)
22
+ return {
23
+ text,
24
+ payload,
25
+ invalidJSON: !payload || typeof payload !== 'object' || Array.isArray(payload),
26
+ }
27
+ } catch {
28
+ return { text, payload: null, invalidJSON: true }
49
29
  }
50
30
  }
51
31
 
52
- function unwrap(payload) {
53
- return payload && Object.prototype.hasOwnProperty.call(payload, 'data') ? payload.data : payload
54
- }
55
-
56
- function errorMessage(payload, fallback) {
57
- const error = typeof payload?.error === 'string' ? payload.error : payload?.error?.message
58
- return error || payload?.message || fallback
59
- }
60
-
61
- function errorCode(payload) {
62
- const error = payload?.error
63
- const code = typeof error === 'object' && error ? error.code : undefined
64
- return String(code || payload?.code || '').trim()
65
- }
66
-
67
- async function retryableSessionExpiry(response) {
68
- if (response?.status !== 401) return false
69
- const payload = await readJSON(response.clone())
70
- const error = payload?.error
71
- const reason = typeof error === 'object' && error ? error.auth_reason : undefined
72
- const executionStarted = typeof error === 'object' && error && Object.prototype.hasOwnProperty.call(error, 'execution_started')
73
- ? error.execution_started
74
- : payload?.execution_started
75
- return reason === 'APP_SESSION_EXPIRED' && executionStarted === false
32
+ function unwrap(value) {
33
+ return value && Object.prototype.hasOwnProperty.call(value, 'data') ? value.data : value
76
34
  }
77
35
 
78
36
  function relayError(code, message, stage, statusCode = 502, cause) {
@@ -84,45 +42,37 @@ function relayError(code, message, stage, statusCode = 502, cause) {
84
42
  return error
85
43
  }
86
44
 
45
+ function errorCode(payload) {
46
+ const error = payload?.error
47
+ return String((error && typeof error === 'object' ? error.code : '') || payload?.code || '').trim()
48
+ }
49
+
50
+ function errorMessage(payload, fallback) {
51
+ const error = typeof payload?.error === 'string' ? payload.error : payload?.error?.message
52
+ return error || payload?.message || fallback
53
+ }
54
+
87
55
  function visibleErrorMessage(error) {
88
56
  switch (error?.code) {
89
57
  case 'APP_PLATFORM_UNAVAILABLE': return '无法连接平台,请确认平台服务已启动或网络可用后重试。'
90
58
  case 'APP_PLATFORM_TIMEOUT': return '平台响应超时,请稍后重试。'
91
- case 'APP_PLATFORM_INVALID_RESPONSE': return '平台返回了无效响应,请检查平台 API 地址及服务配置。'
92
- case 'APP_LOGIN_SESSION_EXPIRED': return '平台登录态已失效,请重新登录。'
93
- case 'APP_LOGIN_NOT_ALLOWED': return '当前账户无权使用此应用,请切换账户后重试。'
59
+ case 'APP_PLATFORM_INVALID_RESPONSE': return `平台返回了非 JSON 或格式无效的响应(HTTP ${error?.statusCode || 502}),请检查平台 API 地址、路由及服务配置。`
60
+ case 'APP_LOGIN_SESSION_EXPIRED': return '开发者授权已过期,请重新运行 proxy-app dev。'
94
61
  case 'APP_APP_REGISTRATION_FAILED':
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)) {
62
+ if ([401, 403, 404].includes(error?.statusCode)) {
97
63
  const account = error?.currentAccount ? ` 当前登录账户:${error.currentAccount}。` : ''
98
64
  const platform = error?.platformAPI ? ` 平台 API 地址:${error.platformAPI}。` : ''
99
- const reason = error.statusCode === 403 ? '当前账户无权开发此应用' : '该平台上没有当前账户拥有的同 slug 应用'
100
- return `${reason}。${account}${platform}请使用应用创建时的完整账户登录,并确认 manifest.json 的 id 与平台应用 slug 完全一致。`
65
+ const reason = error.statusCode === 403 ? '当前账户无权开发此应用' : error.statusCode === 404 ? '该平台上没有当前账户拥有的同 slug 应用' : '开发者授权已失效'
66
+ return `${reason}。${account}${platform}请使用应用创建者账户登录,并确认 manifest.json 的 id 与平台应用 slug 完全一致。`
101
67
  }
102
- if (error?.statusCode === 400) {
103
- const code = error?.upstreamCode ? `(${error.upstreamCode})` : ''
104
- return '应用开发登记参数不符合平台要求(HTTP 400)' + code + '。请检查 manifest.json 的 id、version、entry、权限和能力声明。'
105
- }
106
- return '应用开发登记失败(HTTP ' + error?.statusCode + '),请根据请求编号检查平台日志。'
107
- case 'APP_SESSION_CREATE_FAILED': return '用户 App Session 创建失败,请稍后重试。'
108
- case 'APP_SESSION_INVALID': return '平台返回的用户 App Session 无效,请稍后重试。'
109
- default: return String(error?.message || '平台登录失败,请重试。').slice(0, 240)
68
+ if (error?.statusCode === 400) return '应用开发登记参数不符合平台要求,请检查 manifest.json 的 id、version、权限和能力声明。'
69
+ return `应用开发登记失败(HTTP ${error?.statusCode || 502}),请根据请求编号检查平台日志。`
70
+ default: return String(error?.message || '平台开发授权失败,请重试。').slice(0, 240)
110
71
  }
111
72
  }
112
73
 
113
74
  function htmlEscape(value) {
114
- return String(value).replace(/[&<>"']/g, (character) => ({
115
- '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
116
- }[character]))
117
- }
118
-
119
- function safeScriptJSON(value) {
120
- return JSON.stringify(value)
121
- .replace(/</g, '\\u003c')
122
- .replace(/>/g, '\\u003e')
123
- .replace(/&/g, '\\u0026')
124
- .replace(/\u2028/g, '\\u2028')
125
- .replace(/\u2029/g, '\\u2029')
75
+ return String(value).replace(/[&<>"']/g, character => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character])
126
76
  }
127
77
 
128
78
  function safeURL(value, label) {
@@ -134,139 +84,87 @@ function safeURL(value, label) {
134
84
  return parsed.origin + parsed.pathname.replace(/\/$/, '')
135
85
  }
136
86
 
137
- function normalizeGatewayURL(value) {
138
- const normalized = safeURL(value, 'platform returned gateway URL')
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'
151
- }
152
-
153
- function jsonResponse(response, status, payload) {
87
+ function htmlResponse(response, status, title, description, link = '') {
154
88
  response.statusCode = status
155
- response.setHeader('Content-Type', 'application/json')
89
+ response.setHeader('Content-Type', 'text/html; charset=utf-8')
156
90
  response.setHeader('Cache-Control', 'no-store')
157
- response.end(JSON.stringify(payload))
91
+ response.setHeader('Referrer-Policy', 'no-referrer')
92
+ response.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'")
93
+ const anchor = link ? `<a href="${htmlEscape(link)}" rel="noreferrer">打开本地应用</a>` : ''
94
+ response.end(`<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${htmlEscape(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}</style><main><small>Proxy App · 开发环境初始化</small><h1>${htmlEscape(title)}</h1><p>${htmlEscape(description)}</p>${anchor}</main></html>`)
158
95
  }
159
96
 
160
97
  /**
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.
98
+ * Handles developer authorization and draft registration only. Runtime user
99
+ * sessions are created by the Portal in the browser and never enter this
100
+ * process or its credential store.
165
101
  */
166
- export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', relaySecret, appID, appSlug, version, manifest, entryURL, credentialStore = null, developerCredentialStore = null }) {
167
- if (!baseURL || !relaySecret || !appID || !appSlug || !version || !entryURL) throw new Error('local development relay configuration is incomplete')
168
- const portal = safeURL(baseURL, 'platform API URL')
169
- const portalOrigin = safeURL(portalURL || portal, 'platform Portal URL')
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
- }
102
+ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', appID, appSlug, version, manifest, functionFiles = [], entryURL, developerCredentialStore = null }) {
103
+ if (!baseURL || !appID || !appSlug || !version || !entryURL) throw new Error('local development relay configuration is incomplete')
104
+ const platformAPI = safeURL(baseURL, 'platform API URL')
105
+ const portalOrigin = safeURL(portalURL || platformAPI, 'platform Portal URL')
106
+ const state = { closed: false }
177
107
  let server
178
108
  let relayURL = ''
179
109
  let developmentReady = false
180
110
  let developmentPromise = null
111
+ let developmentSessionGrant = ''
181
112
  let setupState = ''
182
113
  let setupIssuedAt = 0
183
114
  let submitSetupToken = null
184
115
  let cancelSetup = null
185
116
 
186
- const portalJSON = async (path, init = {}, stage = 'platform', authToken) => {
187
- const token = authToken === undefined ? state.portalToken : authToken
188
- if (!token) {
189
- throw relayError('APP_LOGIN_REQUIRED', 'platform user login is required', stage, 401)
190
- }
117
+ const portalJSON = async (path, init, stage, token) => {
118
+ if (!token) throw relayError('APP_LOGIN_REQUIRED', 'developer login is required', stage, 401)
191
119
  const controller = new AbortController()
192
120
  const timeout = setTimeout(() => controller.abort(), PLATFORM_AUTH_TIMEOUT_MS)
193
121
  let response
194
- let payload
122
+ let result
195
123
  try {
196
- response = await fetch(portal + path, {
124
+ response = await fetch(platformAPI + path, {
197
125
  ...init,
198
- signal: init.signal ? AbortSignal.any([controller.signal, init.signal]) : controller.signal,
199
- headers: { Authorization: 'Bearer ' + token, ...(init.headers || {}) },
126
+ signal: init?.signal ? AbortSignal.any([controller.signal, init.signal]) : controller.signal,
127
+ headers: { Authorization: 'Bearer ' + token, ...(init?.headers || {}) },
200
128
  })
201
- payload = await readJSON(response, true)
202
- if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new SyntaxError('platform response is not an object')
129
+ result = await readResponse(response)
203
130
  } catch (error) {
204
- if (init.signal?.aborted) throw init.signal.reason
131
+ if (init?.signal?.aborted) throw init.signal.reason
205
132
  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)
207
133
  throw relayError('APP_PLATFORM_UNAVAILABLE', 'platform request could not be reached', stage, 502, error)
208
134
  } finally {
209
135
  clearTimeout(timeout)
210
136
  }
137
+ const payload = result.payload
138
+ const requestId = response.headers.get('x-request-id') || response.headers.get('x-trace-id') || ''
139
+ if (result.invalidJSON) {
140
+ const failure = relayError('APP_PLATFORM_INVALID_RESPONSE', `platform returned a non-JSON response (HTTP ${response.status})`, stage, response.status)
141
+ failure.requestId = requestId
142
+ throw failure
143
+ }
211
144
  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
- }
217
- if (response.status === 401) throw relayError('APP_LOGIN_SESSION_EXPIRED', 'platform user session expired', stage, 401)
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)
219
- const code = stage === 'app_registration' ? 'APP_APP_REGISTRATION_FAILED' : stage === 'app_session' ? 'APP_SESSION_CREATE_FAILED' : 'APP_PLATFORM_REQUEST_FAILED'
220
- const failure = relayError(code, errorMessage(payload, 'platform request failed: HTTP ' + response.status), stage, response.status)
221
- failure.requestId = response.headers.get('x-request-id') || ''
222
- failure.upstreamCode = errorCode(payload)
145
+ const code = errorCode(payload)
146
+ const failure = relayError(response.status === 401 ? 'APP_LOGIN_SESSION_EXPIRED' : stage === 'app_registration' ? 'APP_APP_REGISTRATION_FAILED' : code || 'APP_PLATFORM_REQUEST_FAILED', errorMessage(payload, 'platform request failed: HTTP ' + response.status), stage, response.status)
147
+ failure.requestId = requestId
148
+ failure.upstreamCode = code
223
149
  throw failure
224
150
  }
225
151
  return unwrap(payload)
226
152
  }
227
153
 
228
- const revoke = async (session, authToken) => {
229
- const id = session?.session_id
230
- if (!id || state.revoked.has(id) || !(authToken === undefined ? state.portalToken : authToken)) return
231
- state.revoked.add(id)
232
- await portalJSON('/portal/user/apps/' + encodeURIComponent(appID) + '/sessions/' + encodeURIComponent(id), { method: 'DELETE' }, 'platform', authToken).catch(() => undefined)
233
- }
234
-
235
- const clearAuthentication = async (forceLogin = false) => {
236
- const current = state.session
237
- const token = state.portalToken
238
- state.authGeneration += 1
239
- state.session = null
240
- state.portalToken = ''
241
- state.refresh = null
242
- state.login = null
243
- state.restore = null
244
- state.cacheLoaded = true
245
- state.forceLogin = forceLogin
246
- state.authState = ''
247
- state.authStateIssuedAt = 0
248
- await Promise.all([revoke(current, token), updateCredentials(() => credentialStore?.clear())])
249
- }
250
-
251
- const assertAuthentication = (generation) => {
252
- if (state.closed) throw new Error('local development relay is closed')
253
- if (generation !== state.authGeneration) throw relayError('APP_AUTH_CHANGED', 'local development authentication changed', 'app_session', 409)
254
- }
255
-
256
154
  const register = async (token, signal) => {
257
155
  const details = await portalJSON('/portal/user/app-workspace/by-slug/' + encodeURIComponent(appSlug) + '/local-development', {
258
- method: 'POST',
259
- signal,
156
+ method: 'POST', signal,
260
157
  headers: { 'Content-Type': 'application/json' },
261
- body: JSON.stringify({ entry_url: entryURL, manifest }),
158
+ body: JSON.stringify({ entry_url: entryURL, manifest, ...(functionFiles.length ? { function_files: functionFiles } : {}) }),
262
159
  }, 'app_registration', token)
263
160
  if (!details || typeof details.id !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(details.id)) {
264
161
  throw relayError('APP_PLATFORM_INVALID_RESPONSE', 'platform did not return the registered application identity', 'app_registration', 502)
265
162
  }
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.
163
+ if (typeof details.local_development_grant !== 'string' || details.local_development_grant.length < 32 || details.local_development_grant.length > 8192) {
164
+ throw relayError('APP_PLATFORM_INVALID_RESPONSE', 'platform did not return a local preview grant', 'app_registration', 502)
165
+ }
269
166
  appID = details.id
167
+ developmentSessionGrant = details.local_development_grant
270
168
  return details
271
169
  }
272
170
 
@@ -275,10 +173,7 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
275
173
  return await register(token, signal)
276
174
  } catch (error) {
277
175
  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.
176
+ error.platformAPI = platformAPI
282
177
  const profile = await portalJSON('/portal/user/profile', {}, 'account_scope', token).catch(() => null)
283
178
  const email = typeof profile?.email === 'string' ? profile.email.trim() : ''
284
179
  if (email && email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) error.currentAccount = email
@@ -287,11 +182,14 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
287
182
  }
288
183
  }
289
184
 
290
- const developmentRequired = () => relayError('APP_DEVELOPMENT_NOT_READY', '开发环境尚未就绪,请先在终端完成 proxy-app dev 的开发授权与应用登记。', 'development_preflight', 503)
291
- const abortPreparation = () => Object.assign(new Error('开发环境初始化已取消。'), { name: 'AbortError' })
185
+ const developmentHTML = error => {
186
+ const title = error ? '开发环境检查失败' : '开发环境已就绪'
187
+ const description = error ? visibleErrorMessage(error) : '应用登记与开发权限检查已通过,本地应用正在启动。'
188
+ return { title, description }
189
+ }
292
190
 
293
191
  const prepareDevelopment = ({ token = '', onAuthorizationRequired, signal, forceLogin = false, timeoutMs = 10 * 60 * 1000 } = {}) => {
294
- if (state.closed || signal?.aborted) return Promise.reject(abortPreparation())
192
+ if (state.closed || signal?.aborted) return Promise.reject(Object.assign(new Error('开发环境初始化已取消。'), { name: 'AbortError' }))
295
193
  if (developmentReady) return Promise.resolve()
296
194
  if (developmentPromise) return developmentPromise
297
195
  const pending = (async () => {
@@ -299,24 +197,22 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
299
197
  let rejectCancellation
300
198
  const controller = new AbortController()
301
199
  const cancellation = new Promise((_, reject) => { rejectCancellation = reject })
302
- // Install the handler immediately, including while reading the OS store.
303
200
  cancellation.catch(() => undefined)
304
- const cancel = (error) => { controller.abort(error); rejectCancellation(error) }
305
- const abort = () => cancel(abortPreparation())
201
+ const cancel = error => { controller.abort(error); rejectCancellation(error) }
202
+ const abort = () => cancel(Object.assign(new Error('开发环境初始化已取消。'), { name: 'AbortError' }))
306
203
  cancelSetup = abort
307
204
  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])
205
+ const checkActive = () => { if (state.closed || signal?.aborted) abort() }
206
+ const wait = operation => Promise.race([operation, cancellation])
312
207
  try {
313
208
  const explicitToken = !forceLogin && typeof token === 'string' ? token.trim() : ''
314
209
  let requiresFreshLogin = forceLogin
315
210
  let developerToken = explicitToken
316
211
  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.
212
+ // A Portal can already have a valid browser login for a different
213
+ // account than the developer. When the CLI has no explicit or cached
214
+ // credential, require the user to choose the developer account rather
215
+ // than silently accepting the Portal's persisted browser session.
320
216
  if (!developerToken && !explicitToken) requiresFreshLogin = true
321
217
  checkActive()
322
218
  if (developerToken) {
@@ -326,15 +222,8 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
326
222
  developmentReady = true
327
223
  return
328
224
  } 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
225
+ const retry = error?.code === 'APP_LOGIN_SESSION_EXPIRED' || error?.stage === 'app_registration' && [401, 403, 404].includes(error.statusCode)
226
+ if (!retry || explicitToken) throw error
338
227
  await wait(Promise.resolve(developerCredentialStore?.clear()))
339
228
  requiresFreshLogin = true
340
229
  }
@@ -342,26 +231,21 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
342
231
  checkActive()
343
232
  if (!onAuthorizationRequired) throw relayError('APP_DEVELOPER_LOGIN_REQUIRED', '请先通过 proxy-app dev 完成开发者授权。', 'development_preflight', 401)
344
233
  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
234
  setupState = randomUUID()
347
235
  setupIssuedAt = Date.now()
348
- const receivedToken = new Promise((resolve) => { submitSetupToken = resolve })
236
+ const receivedToken = new Promise(resolve => { submitSetupToken = resolve })
349
237
  const query = new URLSearchParams({ proxy_app_auth: '1', proxy_app_state: setupState, proxy_app_relay: relayURL })
350
238
  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)))
239
+ await wait(Promise.resolve(onAuthorizationRequired({ login_url: portalOrigin + '/#/login?' + query, auth_state: setupState, relay_url: relayURL, force_login: requiresFreshLogin })))
353
240
  developerToken = await wait(receivedToken)
354
241
  checkActive()
355
242
  if (!developerToken) throw relayError('APP_LOGIN_TOKEN_INVALID', '平台未返回有效的开发者授权,请重新运行 proxy-app dev。', 'development_preflight', 400)
356
243
  await wait(registerWithDiagnostics(developerToken, controller.signal))
357
244
  checkActive()
358
- // Runtime user login never reads or clears this store.
359
245
  await wait(Promise.resolve(developerCredentialStore?.save(developerToken)).catch(() => undefined))
360
246
  checkActive()
361
247
  developmentReady = true
362
248
  } catch (error) {
363
- // Preserve structured diagnostic fields, but never print arbitrary
364
- // upstream text or credentials in the terminal/authorization page.
365
249
  if (error?.name === 'AbortError') throw error
366
250
  throw Object.assign(new Error(visibleErrorMessage(error)), {
367
251
  code: error?.code, stage: error?.stage, statusCode: error?.statusCode,
@@ -382,340 +266,63 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
382
266
  return pending
383
267
  }
384
268
 
385
- const refresh = async (force = false, generation = state.authGeneration) => {
386
- assertAuthentication(generation)
387
- if (state.refresh) return state.refresh
388
- const refreshToken = state.portalToken
389
- const previous = force ? state.session : null
390
- const pending = portalJSON('/portal/user/apps/' + encodeURIComponent(appID) + '/sessions', {
391
- method: 'POST',
392
- headers: { 'Content-Type': 'application/json' },
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 }),
396
- }, 'app_session', refreshToken).then(async (raw) => {
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)
398
- const next = { ...raw, gateway_url: normalizeGatewayURL(raw.gateway_url) }
399
- if (state.closed || state.authGeneration !== generation || state.portalToken !== refreshToken) {
400
- await revoke(next, refreshToken)
401
- assertAuthentication(generation)
402
- throw relayError('APP_AUTH_CHANGED', 'local development authentication changed', 'app_session', 409)
403
- }
404
- state.session = next
405
- if (previous && previous.session_id !== next.session_id) await revoke(previous, refreshToken)
406
- return next
407
- }).finally(() => {
408
- if (state.refresh === pending) state.refresh = null
409
- })
410
- state.refresh = pending
411
- return pending
412
- }
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
-
447
- const loginDetails = () => {
448
- if (!state.authState || Date.now() - state.authStateIssuedAt > 10 * 60 * 1000) {
449
- state.authState = randomUUID()
450
- state.authStateIssuedAt = Date.now()
451
- }
452
- const query = new URLSearchParams({ proxy_app_auth: '1', proxy_app_state: state.authState, proxy_app_relay: relayURL })
453
- return { login_url: portalOrigin + '/#/login?' + query, auth_state: state.authState, relay_url: relayURL, force_login: state.forceLogin }
454
- }
455
-
456
- const requireLogin = (response, reason) => jsonResponse(response, 401, {
457
- code: 'APP_LOGIN_REQUIRED',
458
- error: {
459
- code: 'APP_LOGIN_REQUIRED',
460
- message: reason ? visibleErrorMessage(reason) + ' 请重新登录后继续。' : '请登录平台账户后继续',
461
- },
462
- ...loginDetails(),
463
- })
464
-
465
- const callbackHTML = (stateValue, type, error) => {
466
- const origin = new URL(entryURL).origin
467
- const isSuccess = type === 'login-complete'
468
- const message = isSuccess
469
- ? { protocol: 'proxy-app/auth/v1', type, state: stateValue }
470
- : {
471
- protocol: 'proxy-app/auth/v1',
472
- type: 'login-failed',
473
- state: stateValue,
474
- error: {
475
- code: error?.code || 'APP_LOGIN_FAILED',
476
- message: visibleErrorMessage(error),
477
- stage: error?.stage || 'login',
478
- status: error?.statusCode,
479
- request_id: error?.requestId || undefined,
480
- upstream_code: error?.upstreamCode || undefined,
481
- },
482
- }
483
- const title = isSuccess ? '登录成功,正在返回应用...' : visibleErrorMessage(error)
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>'
487
- }
488
-
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) => {
499
- const headers = copyRequestHeaders(request)
500
- if (token) headers.set('Authorization', 'Bearer ' + token)
501
- if (appVersion) headers.set('X-App-Version', appVersion)
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
- }
522
- }
523
-
524
269
  const close = async () => {
525
270
  if (state.closed) return
526
271
  state.closed = true
527
272
  cancelSetup?.()
528
273
  await developmentPromise?.catch(() => undefined)
529
- await state.restore?.catch(() => undefined)
530
- await state.refresh?.catch(() => undefined)
531
- await state.login?.catch(() => undefined)
532
- await credentialQueue
533
- const current = state.session
534
- state.session = null
535
- await revoke(current)
536
- if (server?.listening) await new Promise((resolve) => server.close(resolve))
274
+ if (server?.listening) await new Promise(resolve => server.close(resolve))
537
275
  }
538
276
 
539
277
  try {
540
278
  server = createServer(async (request, response) => {
541
279
  try {
542
- if (state.closed) throw new Error('local development relay is closed')
280
+ if (state.closed) throw Object.assign(new Error('local developer relay is closed'), { statusCode: 503 })
543
281
  const parsed = new URL(request.url || '/', 'http://127.0.0.1')
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
549
- const callback = authPath === '/auth/callback' && request.method === 'POST'
550
- if (!callback && request.headers['x-proxy-app-relay'] !== relaySecret) {
551
- jsonResponse(response, 403, { code: 'LOCAL_DEV_RELAY_UNAUTHORIZED', error: 'local development relay authentication failed' })
282
+ if (request.method === 'GET' && parsed.pathname === '/health') {
283
+ response.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
284
+ response.end(JSON.stringify({ ok: true, development_ready: developmentReady, app_id: appID, app_slug: appSlug, app_version: version }))
552
285
  return
553
286
  }
554
- const rawBody = await readBody(request)
555
- if (callback) {
556
- const form = new URLSearchParams(rawBody.toString('utf8'))
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
- }
577
- const valid = receivedState && receivedState === state.authState && Date.now() - state.authStateIssuedAt <= 10 * 60 * 1000
578
- response.setHeader('Content-Type', 'text/html; charset=utf-8')
579
- response.setHeader('Cache-Control', 'no-store')
580
- if (!valid) {
581
- response.statusCode = 400
582
- response.end(callbackHTML(receivedState, 'login-failed', relayError('APP_LOGIN_STATE_INVALID', 'login request expired', 'state_validation', 400)))
583
- return
584
- }
585
- if (!developmentReady) throw developmentRequired()
586
- state.authState = ''
587
- state.authStateIssuedAt = 0
588
- const previous = state.session
589
- const previousToken = state.portalToken
590
- const generation = ++state.authGeneration
591
- const token = String(form.get('access_token') || '').trim()
592
- state.session = null
593
- state.portalToken = token
594
- state.refresh = null
595
- state.restore = null
596
- state.cacheLoaded = true
597
- const pending = (async () => {
598
- await Promise.all([revoke(previous, previousToken), updateCredentials(() => credentialStore?.clear())])
599
- assertAuthentication(generation)
600
- if (!token) throw relayError('APP_LOGIN_TOKEN_INVALID', 'platform returned an invalid user session token', 'token_validation', 400)
601
- const session = await refresh(true, generation)
602
- assertAuthentication(generation)
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
609
- })()
610
- state.login = pending
611
- try {
612
- await pending
613
- assertAuthentication(generation)
614
- response.end(callbackHTML(receivedState, 'login-complete'))
615
- } catch (error) {
616
- if (!state.closed && state.authGeneration === generation) await clearAuthentication(['APP_LOGIN_SESSION_EXPIRED', 'APP_LOGIN_NOT_ALLOWED'].includes(error?.code))
617
- response.statusCode = error?.statusCode || 502
618
- response.end(callbackHTML(receivedState, 'login-failed', error))
619
- } finally {
620
- if (state.login === pending) state.login = null
621
- }
287
+ if (request.method !== 'POST' || parsed.pathname !== '/auth/callback' || request.socket.remoteAddress && !request.socket.remoteAddress.includes('127.0.0.1') && request.socket.remoteAddress !== '::1' && request.socket.remoteAddress !== '::ffff:127.0.0.1') {
288
+ response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' })
289
+ response.end('not found')
622
290
  return
623
291
  }
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() } })
292
+ const contentType = String(request.headers['content-type'] || '').split(';')[0].trim().toLowerCase()
293
+ if (contentType !== 'application/x-www-form-urlencoded') {
294
+ htmlResponse(response, 415, '授权失败', '开发者授权请求格式无效。')
630
295
  return
631
296
  }
632
- if ((authPath === '/logout' || authPath === '/auth/logout') && request.method === 'POST') {
633
- await clearAuthentication()
634
- jsonResponse(response, 200, { data: { success: true } })
297
+ const form = new URLSearchParams((await readBody(request)).toString('utf8'))
298
+ const receivedState = form.get('state') || ''
299
+ const token = form.get('access_token') || ''
300
+ const valid = setupState && receivedState === setupState && Date.now() - setupIssuedAt <= 10 * 60 * 1000 && submitSetupToken
301
+ if (!valid || !token || token.length > 8192 || form.getAll('state').length !== 1 || form.getAll('access_token').length !== 1) {
302
+ htmlResponse(response, 400, '授权失败', '开发者授权已失效,请重新运行 proxy-app dev。')
635
303
  return
636
304
  }
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()
647
- }
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 })
666
- return
667
- }
668
- const gatewayRequest = parsed.pathname === '/v1' || parsed.pathname.startsWith('/v1/')
669
- if (gatewayRequest) {
670
- await restoreAuthentication()
671
- if (!state.session) { requireLogin(response); return }
672
- const prefix = '/v1'
673
- const suffix = parsed.pathname.slice(prefix.length) || '/'
674
- const target = (session) => session.gateway_url + suffix + parsed.search
675
- const stage = 'gateway'
676
- const generation = state.authGeneration
677
- let current = state.session
678
- let upstream = await forward(target(current), request, rawBody, current.access_token, current.app_version, stage)
679
- if (await retryableSessionExpiry(upstream)) {
680
- await upstream.arrayBuffer().catch(() => undefined)
681
- try {
682
- assertAuthentication(generation)
683
- current = state.session && state.session.access_token !== current.access_token ? state.session : await refresh(true, generation)
684
- } catch (error) {
685
- assertAuthentication(generation)
686
- if (error?.code === 'APP_LOGIN_SESSION_EXPIRED' || error?.code === 'APP_LOGIN_NOT_ALLOWED') {
687
- await clearAuthentication(true)
688
- requireLogin(response, error)
689
- return
690
- }
691
- throw error
692
- }
693
- assertAuthentication(generation)
694
- upstream = await forward(target(current), request, rawBody, current.access_token, current.app_version, stage)
695
- }
696
- if (state.authGeneration !== generation) {
697
- await upstream.body?.cancel().catch(() => undefined)
698
- assertAuthentication(generation)
699
- }
700
- response.statusCode = upstream.status
701
- copyResponseHeaders(upstream, response)
702
- if (upstream.body) Readable.fromWeb(upstream.body).pipe(response); else response.end()
703
- return
305
+ setupState = ''
306
+ setupIssuedAt = 0
307
+ const submit = submitSetupToken
308
+ submitSetupToken = null
309
+ submit(token.trim())
310
+ try {
311
+ await developmentPromise
312
+ const result = developmentHTML()
313
+ htmlResponse(response, 200, result.title, result.description, entryURL)
314
+ } catch (error) {
315
+ const result = developmentHTML(error)
316
+ htmlResponse(response, error?.statusCode || 503, result.title, result.description)
704
317
  }
705
- jsonResponse(response, 404, { code: 'LOCAL_DEV_ROUTE_NOT_FOUND', error: 'local development route not found' })
706
318
  } catch (error) {
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
- })
319
+ const result = developmentHTML(error)
320
+ htmlResponse(response, error?.statusCode || 502, result.title, result.description)
713
321
  }
714
322
  })
715
323
  await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) })
716
- const address = server.address()
717
- relayURL = 'http://127.0.0.1:' + address.port
718
- return { relayURL, prepareDevelopment, close }
324
+ relayURL = 'http://127.0.0.1:' + server.address().port
325
+ return { relayURL, prepareDevelopment, getDevelopmentSessionGrant: () => developmentSessionGrant, close }
719
326
  } catch (error) {
720
327
  await close()
721
328
  throw error