tensorgrid-ui 1.1.3 → 1.1.4
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 +108 -29
- package/lib/auth-bridge.js +5 -6
- package/lib/http-runtime.js +152 -0
- package/lib/index.js +50 -139
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
| Файл | Роль |
|
|
13
13
|
|---|---|
|
|
14
14
|
| `package.json` | секция `dsh.client` — по ней рантайм понимает, что у пакета есть браузерная половина |
|
|
15
|
-
| `lib/index.js` | host
|
|
15
|
+
| `lib/index.js` | стабильная host-оболочка: маршруты, иконки, манифест и получение Cordis-служб |
|
|
16
|
+
| `lib/http-runtime.js` | перечитываемые HTTP-обработчики входа и обновлений |
|
|
17
|
+
| `lib/auth-bridge.js` | состояние разговора входа |
|
|
18
|
+
| `lib/update-check.js` | проверка версий и установка из реестра |
|
|
16
19
|
| `lib/client.js` | браузерная половина: палитра, стили, компонент слоя |
|
|
17
20
|
|
|
18
21
|
`lib/client.js` написан руками в том же формате, что и поставочные
|
|
@@ -155,22 +158,26 @@ git push # 6. выпустить
|
|
|
155
158
|
выпуском. Она прогоняет всё в правильном порядке и **останавливается на
|
|
156
159
|
первом провале**: сверяет версию dsh со снимком, переснимает окружение,
|
|
157
160
|
обновляет эталон строк, проверяет связность перевода, пересобирает
|
|
158
|
-
словари, гоняет
|
|
161
|
+
словари, гоняет 172 проверки контракта и смотрит, всё ли зафиксировано.
|
|
159
162
|
|
|
160
163
|
Порядок шагов не случаен: снимок снимается **до** проверки контракта,
|
|
161
164
|
потому что тест сверяется именно с ним.
|
|
162
165
|
|
|
163
166
|
### Обновление без новой версии dsh
|
|
164
167
|
|
|
165
|
-
|
|
166
|
-
|
|
168
|
+
Продукт сам сверяет установленную версию с меткой `latest` в реестре и
|
|
169
|
+
показывает результат в настройках. Кнопка «Обновить» ставит новую версию,
|
|
170
|
+
после чего просит либо обновить страницу, либо перезапустить приложение —
|
|
171
|
+
решение принимается по отпечатку host-половины, а не наугад.
|
|
172
|
+
|
|
173
|
+
То же самое из консоли:
|
|
167
174
|
|
|
168
175
|
```powershell
|
|
169
|
-
|
|
170
|
-
.\install.ps1 # применить; профиль и настройки не трогаются
|
|
176
|
+
dsh plugin --profile tensorgrid add tensorgrid-ui@latest
|
|
171
177
|
```
|
|
172
178
|
|
|
173
|
-
|
|
179
|
+
**У самого dsh автообновления нет, и никто о новых версиях не уведомляет** —
|
|
180
|
+
это проверено, а не предположено. Обновлять его нужно вручную:
|
|
174
181
|
|
|
175
182
|
```powershell
|
|
176
183
|
npm install -g "@deepseek-ai/dsh@<версия>"
|
|
@@ -195,12 +202,19 @@ npm install -g "@deepseek-ai/dsh@<версия>"
|
|
|
195
202
|
dsh --profile tensorgrid --from-default-profile web --dump-config
|
|
196
203
|
```
|
|
197
204
|
|
|
198
|
-
2.
|
|
205
|
+
2. Поставить пакет из реестра штатной командой dsh — она пробрасывает
|
|
206
|
+
установку в pnpm внутри профиля:
|
|
199
207
|
|
|
200
|
-
```powershell
|
|
201
|
-
robocopy <источник> "$env:USERPROFILE\.dsh\profiles\tensorgrid\packages\dsh-ui-obsidian-ion" `
|
|
202
|
-
/MIR /XD locale test tools /NFL /NDL /NJH /NJS
|
|
203
208
|
```
|
|
209
|
+
dsh plugin --profile tensorgrid add tensorgrid-ui@<версия>
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Версию задаёт манифест репозитория, а не метка `latest`: у всех должно
|
|
213
|
+
встать ровно то, что проверено, а не то, что оказалось свежим в момент
|
|
214
|
+
установки.
|
|
215
|
+
|
|
216
|
+
Предупреждение «declares no `dsh.bundle`» здесь нормально: пакет не
|
|
217
|
+
бандл, а один плагин, который монтируется своей строкой ниже.
|
|
204
218
|
|
|
205
219
|
3. Положить патч-слой профиля — готовый файл лежит в репозитории:
|
|
206
220
|
|
|
@@ -212,15 +226,22 @@ npm install -g "@deepseek-ai/dsh@<версия>"
|
|
|
212
226
|
|
|
213
227
|
```yaml
|
|
214
228
|
- insert:
|
|
215
|
-
- id:
|
|
216
|
-
name: '
|
|
229
|
+
- id: authorization
|
|
230
|
+
name: '@deepseek-ai/dsh-authorization'
|
|
231
|
+
|
|
232
|
+
- id: ui-tensorgrid
|
|
233
|
+
name: 'tensorgrid-ui'
|
|
217
234
|
```
|
|
218
235
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
236
|
+
Пакет монтируется **по имени**: спецификатор разрешается штатным
|
|
237
|
+
ESM-резолвом из `node_modules` профиля, куда его кладёт `dsh plugin add`,
|
|
238
|
+
а манифест `dsh.client` находится подъёмом до ближайшего `package.json`.
|
|
239
|
+
Регистрировать пакет где-то ещё не нужно.
|
|
240
|
+
|
|
241
|
+
Строка `authorization` — стойка входа. Ни один поставочный профиль её не
|
|
242
|
+
монтирует, а без неё `llm-pi-ai` не регистрирует ни одного потока входа,
|
|
243
|
+
и в настройках остаются только ключи API. Подробнее — в разделе «Вход по
|
|
244
|
+
подписке».
|
|
224
245
|
|
|
225
246
|
4. Положить агент-пресет:
|
|
226
247
|
|
|
@@ -241,6 +262,48 @@ npm install -g "@deepseek-ai/dsh@<версия>"
|
|
|
241
262
|
Пресет выбирается в интерфейсе, в настройках агента: в списке появится
|
|
242
263
|
«TENSOR GRID» рядом с поставочными.
|
|
243
264
|
|
|
265
|
+
## Вход по подписке
|
|
266
|
+
|
|
267
|
+
Провайдеры моделей приходят из библиотеки `pi-ai`, и у части из них есть
|
|
268
|
+
вход по подписке, а не только ключ API. Потоки входа регистрирует
|
|
269
|
+
`llm-pi-ai`, но **только если смонтирована стойка `dsh-authorization`** —
|
|
270
|
+
ни один поставочный профиль её не монтирует, поэтому в стоковом интерфейсе
|
|
271
|
+
видны одни ключи. Одна строка в патч-слое включает все 39 потоков, шесть из
|
|
272
|
+
которых с подпиской:
|
|
273
|
+
|
|
274
|
+
| Провайдер | Ключ | Способы |
|
|
275
|
+
|---|---|---|
|
|
276
|
+
| Anthropic | `llm-pi-ai/anthropic` | Claude Pro/Max, ключ API |
|
|
277
|
+
| OpenAI Codex | `llm-pi-ai/openai-codex` | ChatGPT Plus/Pro |
|
|
278
|
+
| GitHub Copilot | `llm-pi-ai/github-copilot` | вход, токен |
|
|
279
|
+
| Kimi For Coding | `llm-pi-ai/kimi-coding` | вход, ключ API |
|
|
280
|
+
| OpenRouter | `llm-pi-ai/openrouter` | вход, ключ API |
|
|
281
|
+
| xAI | `llm-pi-ai/xai` | SuperGrok или X Premium, ключ API |
|
|
282
|
+
|
|
283
|
+
Учётные данные ложатся в `~/.dsh/.credentials.yaml` — **вне профилей**,
|
|
284
|
+
поэтому вход, сделанный однажды, работает во всех, а подписка у каждого
|
|
285
|
+
сотрудника своя.
|
|
286
|
+
|
|
287
|
+
Строка настроек ведёт разговор входа целиком: показывает ссылку и код,
|
|
288
|
+
задаёт вопросы с выбором и вводом, позволяет отменить попытку. Отмена не
|
|
289
|
+
украшение — стойка допускает одну попытку на ключ, и осиротевшая заявка
|
|
290
|
+
иначе держит ключ занятым до перезапуска.
|
|
291
|
+
|
|
292
|
+
### Три ошибки, на которых это строилось
|
|
293
|
+
|
|
294
|
+
Чтение службы из Cordis оказалось не тем, чем выглядит, и стоило трёх
|
|
295
|
+
выпусков подряд:
|
|
296
|
+
|
|
297
|
+
1. `ctx.authorization` как свойство — запрещено без объявления в `inject`;
|
|
298
|
+
2. `ctx.get('authorization')` возвращает службу, но **её методы всё равно
|
|
299
|
+
охраняются** контекстом, который службу не объявил;
|
|
300
|
+
3. объявить стойку в `inject` всего ряда нельзя — она необязательна, и
|
|
301
|
+
профиль без неё остался бы без палитры, айдентики и перевода.
|
|
302
|
+
|
|
303
|
+
Работает только четвёртый вариант: **вложенный `ctx.inject`**, который ждёт
|
|
304
|
+
службы, не задерживая остальное, и отдаёт ссылки, связанные с контекстом,
|
|
305
|
+
их объявившим. Правило закреплено проверками в дымовом тесте.
|
|
306
|
+
|
|
244
307
|
### Проверка установки
|
|
245
308
|
|
|
246
309
|
Профиль можно поднять на свободном порту, не мешая уже запущенному:
|
|
@@ -288,7 +351,7 @@ dsh --profile tensorgrid --port 3081 --no-open
|
|
|
288
351
|
5. `exports["./client"]` принимается и строкой, и объектом с `default`;
|
|
289
352
|
6. путь бандла = `dirname(package.json) + exports["./client"]`.
|
|
290
353
|
|
|
291
|
-
- **исполнение бандла** — `node test/smoke.mjs`,
|
|
354
|
+
- **исполнение бандла** — `node test/smoke.mjs`, 172 проверки. Секция про
|
|
292
355
|
манифест проверена негативным контролем: при `platform: "node"` и снятом
|
|
293
356
|
`exports["./client"]` тест валится на четырёх проверках.
|
|
294
357
|
|
|
@@ -390,23 +453,39 @@ cd C:\тест\dsh-ui-obsidian-ion
|
|
|
390
453
|
node tools/extract-locales.mjs # обновить эталон, увидеть новые ключи
|
|
391
454
|
node tools/audit-ru.mjs # связность перевода
|
|
392
455
|
node tools/build-ru.mjs # пересобрать словари
|
|
393
|
-
node test/smoke.mjs #
|
|
456
|
+
node test/smoke.mjs # 172 проверки контракта
|
|
394
457
|
```
|
|
395
458
|
|
|
396
459
|
Новые строки интерфейса, которых нет в русском словаре, покажутся
|
|
397
460
|
по-английски — это штатный фолбэк, а не поломка.
|
|
398
461
|
|
|
399
|
-
###
|
|
462
|
+
### Доставка изменений и перезапуск
|
|
400
463
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
464
|
+
Пакет приезжает из реестра; копирования файлов больше нет нигде.
|
|
465
|
+
|
|
466
|
+
Что чем подхватывается:
|
|
467
|
+
|
|
468
|
+
| Изменилось | Достаточно |
|
|
469
|
+
|---|---|
|
|
470
|
+
| `lib/client.js` | обновить страницу |
|
|
471
|
+
| `lib/http-runtime.js`, `auth-bridge.js`, `update-check.js` | ничего — следующий запрос возьмёт новую версию |
|
|
472
|
+
| `lib/index.js`, состав маршрутов, зависимости | перезапустить приложение |
|
|
473
|
+
|
|
474
|
+
Так стало с 1.1.4. До неё обработчики жили в `index.js`, а он читается
|
|
475
|
+
только при старте — поэтому каждый выпуск требовал перезапуска, и
|
|
476
|
+
пользователь трижды подряд на это натыкался.
|
|
477
|
+
|
|
478
|
+
**Переход на 1.1.4 сам требует одного перезапуска**: старая оболочка про
|
|
479
|
+
новый модуль ещё не знает.
|
|
480
|
+
|
|
481
|
+
Две честные границы. Это не горячая перезагрузка всего приложения — всё,
|
|
482
|
+
что регистрируется в Cordis, по-прежнему живёт до перезапуска. И обновление
|
|
483
|
+
во время идущего входа обрывает разговор: состояние попытки привязано к
|
|
484
|
+
экземпляру модуля, а замена файла создаёт новый.
|
|
406
485
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
486
|
+
Проверено запуском, а не рассуждением: правка ответа обработчика доехала
|
|
487
|
+
до следующего запроса, возврат исходного текста — тоже, PID процесса не
|
|
488
|
+
менялся.
|
|
410
489
|
|
|
411
490
|
## Цикл правок
|
|
412
491
|
|
package/lib/auth-bridge.js
CHANGED
|
@@ -13,12 +13,11 @@
|
|
|
13
13
|
* Импортируются только встроенные модули Node — строка композиции грузится
|
|
14
14
|
* раньше, чем о пакете что-либо известно.
|
|
15
15
|
*
|
|
16
|
-
* Функции принимают САМИ СЛУЖБЫ, а не контекст. В Cordis
|
|
17
|
-
* `ctx.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* и Cordis ответит «cannot get property "authorization" without inject».
|
|
16
|
+
* Функции принимают САМИ СЛУЖБЫ, а не контекст. В Cordis служба должна быть
|
|
17
|
+
* получена из вложенного `ctx.inject(...)`: чтение через `ctx.get(...)`
|
|
18
|
+
* возвращает значение, но вызов его методов всё равно охраняется контекстом,
|
|
19
|
+
* который службу не объявил. Передача самого контекста или результата `get`
|
|
20
|
+
* уже дала две разные ошибки «without inject» подряд.
|
|
22
21
|
*/
|
|
23
22
|
|
|
24
23
|
/** Текущая попытка. null, пока никто не входил. */
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Горячо перечитываемые HTTP-обработчики TENSOR GRID.
|
|
3
|
+
*
|
|
4
|
+
* `lib/index.js` регистрируется Cordis один раз при старте и после
|
|
5
|
+
* обновления остаётся в ESM-кеше. Если бизнес-логика живёт там, любая её
|
|
6
|
+
* правка требует перезапуска всего приложения — именно поэтому во время
|
|
7
|
+
* разработки входа пользователь перезапускал Host после каждого выпуска.
|
|
8
|
+
*
|
|
9
|
+
* Index теперь только держит стабильные маршруты и связанные Cordis-службы,
|
|
10
|
+
* а каждый маршрут передаёт запрос сюда. Модуль загружается с ключом по
|
|
11
|
+
* времени правки файла, поэтому новая версия начинает работать со
|
|
12
|
+
* следующего запроса. Перезапуск нужен лишь когда меняется сама оболочка:
|
|
13
|
+
* новый маршрут, новая служба или статический ресурс.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Отдать компактный JSON без кеширования. */
|
|
17
|
+
function reply(res, code, value) {
|
|
18
|
+
const body = JSON.stringify(value)
|
|
19
|
+
res.writeHead(code, {
|
|
20
|
+
'content-type': 'application/json; charset=utf-8',
|
|
21
|
+
'cache-control': 'no-store',
|
|
22
|
+
})
|
|
23
|
+
res.end(body)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Принять маленькое JSON-тело.
|
|
28
|
+
*
|
|
29
|
+
* Здесь всегда только действие, ключ провайдера или ответ на вопрос.
|
|
30
|
+
* Ограничение 16 КБ не даёт случайному клиенту держать в памяти тело без
|
|
31
|
+
* практической причины.
|
|
32
|
+
*/
|
|
33
|
+
function readJsonBody(req) {
|
|
34
|
+
return new Promise((resolve) => {
|
|
35
|
+
let text = ''
|
|
36
|
+
let tooBig = false
|
|
37
|
+
req.on('data', (chunk) => {
|
|
38
|
+
if (tooBig) return
|
|
39
|
+
text += chunk
|
|
40
|
+
if (text.length > 16384) {
|
|
41
|
+
tooBig = true
|
|
42
|
+
resolve(null)
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
req.on('end', () => {
|
|
46
|
+
if (tooBig) return
|
|
47
|
+
try {
|
|
48
|
+
resolve(JSON.parse(text === '' ? '{}' : text))
|
|
49
|
+
} catch {
|
|
50
|
+
resolve(null)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
req.on('error', () => resolve(null))
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** JSON-заголовок заставляет чужую страницу пройти CORS preflight. */
|
|
58
|
+
function acceptsJson(req) {
|
|
59
|
+
return String(req.headers['content-type'] ?? '').includes('application/json')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Обновить кеш состояния версий. Вызывается и фоновым таймером. */
|
|
63
|
+
export async function refreshUpdate(state, loadModule) {
|
|
64
|
+
const module = await loadModule('./update-check.js')
|
|
65
|
+
state.cached = await module.collectStatus()
|
|
66
|
+
return state.cached
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Обслужить `/api/tensorgrid.update`. */
|
|
70
|
+
export async function handleUpdate(req, res, state, loadModule) {
|
|
71
|
+
try {
|
|
72
|
+
if (req.method === 'GET') {
|
|
73
|
+
// force=1 обходит кеш: кнопка «Проверить» должна спросить реестр заново.
|
|
74
|
+
const fresh = String(req.url ?? '').includes('force=1')
|
|
75
|
+
const value = fresh || state.cached === null
|
|
76
|
+
? await refreshUpdate(state, loadModule)
|
|
77
|
+
: state.cached
|
|
78
|
+
reply(res, 200, value)
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (req.method === 'POST') {
|
|
83
|
+
if (!acceptsJson(req)) {
|
|
84
|
+
reply(res, 415, { ok: false, problem: 'ожидается content-type: application/json' })
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
const module = await loadModule('./update-check.js')
|
|
88
|
+
const result = await module.applyUpdate()
|
|
89
|
+
state.cached = result.status ?? state.cached
|
|
90
|
+
reply(res, result.ok ? 200 : 409, result)
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
reply(res, 405, { ok: false, problem: 'поддерживаются GET и POST' })
|
|
95
|
+
} catch (error) {
|
|
96
|
+
reply(res, 500, { ok: false, problem: String(error?.message ?? error) })
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Обслужить `/api/tensorgrid.auth`. */
|
|
101
|
+
export async function handleAuth(req, res, services, loadModule) {
|
|
102
|
+
try {
|
|
103
|
+
if (services.authorization === null) {
|
|
104
|
+
reply(res, 200, { available: false, problem: 'стойка входа не смонтирована' })
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const bridge = await loadModule('./auth-bridge.js')
|
|
109
|
+
|
|
110
|
+
if (req.method === 'GET') {
|
|
111
|
+
reply(res, 200, {
|
|
112
|
+
available: true,
|
|
113
|
+
...(await bridge.listProviders(services.authorization, services.credentials)),
|
|
114
|
+
})
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (req.method !== 'POST') {
|
|
119
|
+
reply(res, 405, { problem: 'поддерживаются GET и POST' })
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!acceptsJson(req)) {
|
|
124
|
+
reply(res, 415, { problem: 'ожидается content-type: application/json' })
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const body = await readJsonBody(req)
|
|
129
|
+
if (body === null) {
|
|
130
|
+
reply(res, 400, { problem: 'тело запроса не разобрано' })
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let result
|
|
135
|
+
if (body.action === 'begin') {
|
|
136
|
+
result = bridge.begin(services.authorization, String(body.key), body.method)
|
|
137
|
+
} else if (body.action === 'answer') {
|
|
138
|
+
result = bridge.answer(body.value)
|
|
139
|
+
} else if (body.action === 'cancel') {
|
|
140
|
+
result = bridge.cancel(services.authorization, body.key)
|
|
141
|
+
} else if (body.action === 'poll') {
|
|
142
|
+
result = bridge.poll()
|
|
143
|
+
} else {
|
|
144
|
+
reply(res, 400, { problem: `неизвестное действие: ${String(body.action)}` })
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
reply(res, 200, { available: true, ...result })
|
|
149
|
+
} catch (error) {
|
|
150
|
+
reply(res, 500, { problem: String(error?.message ?? error) })
|
|
151
|
+
}
|
|
152
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -149,17 +149,14 @@ export function apply(ctx) {
|
|
|
149
149
|
}),
|
|
150
150
|
)
|
|
151
151
|
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
// старой логике до перезапуска. Ключ меняется только когда файл
|
|
161
|
-
// действительно изменился, поэтому лишних копий в памяти не остаётся.
|
|
162
|
-
async function loadModule(name = './update-check.js') {
|
|
152
|
+
// Стабильная оболочка маршрутов. Вся бизнес-логика живёт в
|
|
153
|
+
// `http-runtime.js`, который перечитывается по времени правки файла.
|
|
154
|
+
// Поэтому исправление обработчика доезжает со следующим запросом — без
|
|
155
|
+
// перезапуска приложения. Перезапуск остаётся нужен только когда меняется
|
|
156
|
+
// сама оболочка: новый маршрут, новая Cordis-служба или статический ресурс.
|
|
157
|
+
const updateState = { cached: null }
|
|
158
|
+
|
|
159
|
+
async function loadModule(name) {
|
|
163
160
|
const path = new URL(name, import.meta.url)
|
|
164
161
|
let version = ''
|
|
165
162
|
try {
|
|
@@ -171,44 +168,48 @@ export function apply(ctx) {
|
|
|
171
168
|
return import(`${path.href}${version}`)
|
|
172
169
|
}
|
|
173
170
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
171
|
+
/** Минимальный запасной ответ, если горячий модуль не загрузился вовсе. */
|
|
172
|
+
function fail(res, error) {
|
|
173
|
+
const body = JSON.stringify({ problem: `обработчик не загрузился: ${String(error?.message ?? error)}` })
|
|
174
|
+
res.writeHead(500, {
|
|
175
|
+
'content-type': 'application/json; charset=utf-8',
|
|
176
|
+
'cache-control': 'no-store',
|
|
177
|
+
})
|
|
178
|
+
res.end(body)
|
|
178
179
|
}
|
|
179
180
|
|
|
180
|
-
// Проверка при старте — отложенная.
|
|
181
|
-
// задерживает, а к
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
})
|
|
181
|
+
// Проверка версий при старте — отложенная. Подъём приложения она не
|
|
182
|
+
// задерживает, а к открытию настроек ответ обычно уже готов.
|
|
183
|
+
const timer = setTimeout(async () => {
|
|
184
|
+
try {
|
|
185
|
+
const runtime = await loadModule('./http-runtime.js')
|
|
186
|
+
await runtime.refreshUpdate(updateState, loadModule)
|
|
187
|
+
} catch (error) {
|
|
188
|
+
updateState.cached = {
|
|
189
|
+
problem: `проверка не удалась: ${String(error?.message ?? error)}`,
|
|
190
|
+
checkedAt: new Date().toISOString(),
|
|
191
|
+
}
|
|
192
|
+
}
|
|
187
193
|
}, 8000)
|
|
188
194
|
web.effect(() => () => clearTimeout(timer))
|
|
189
195
|
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
// Прочитать её через ctx.get мало: возвращённая служба остаётся под
|
|
196
|
-
// охраной контекста, который её не объявил, и первое же обращение к
|
|
197
|
-
// методу отвечает «cannot get property "list" without inject». Объявить
|
|
198
|
-
// стойку в inject всего ряда тоже нельзя — тогда без неё не
|
|
199
|
-
// смонтируется ни палитра, ни перевод.
|
|
200
|
-
//
|
|
201
|
-
// Поэтому службы забираются ВЛОЖЕННЫМ рядом и запоминаются: он ждёт их
|
|
202
|
-
// появления, не задерживая остальное, и ссылки остаются пригодными,
|
|
203
|
-
// потому что связаны с контекстом, который их объявил. Маршрут при этом
|
|
204
|
-
// регистрируется всегда — профиль без стойки получает внятный ответ, а
|
|
205
|
-
// не молчание.
|
|
206
|
-
let authorization = null
|
|
207
|
-
let credentials = null
|
|
196
|
+
// Стойка входа и хранилище необязательны. Вложенный ряд ждёт их, не
|
|
197
|
+
// задерживая палитру, айдентику и перевод. Ссылки должны быть получены
|
|
198
|
+
// именно из контекста, который объявил службы: ctx.get возвращает
|
|
199
|
+
// значение, но его методы всё ещё охраняются Cordis.
|
|
200
|
+
const authServices = { authorization: null, credentials: null, generation: null }
|
|
208
201
|
web.inject(['authorization', 'credentials'], (bound) => {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
202
|
+
const generation = {}
|
|
203
|
+
authServices.authorization = bound.authorization
|
|
204
|
+
authServices.credentials = bound.credentials
|
|
205
|
+
authServices.generation = generation
|
|
206
|
+
bound.effect(() => () => {
|
|
207
|
+
// Старый Fiber не должен стереть уже активированную новую пару.
|
|
208
|
+
if (authServices.generation !== generation) return
|
|
209
|
+
authServices.authorization = null
|
|
210
|
+
authServices.credentials = null
|
|
211
|
+
authServices.generation = null
|
|
212
|
+
})
|
|
212
213
|
})
|
|
213
214
|
|
|
214
215
|
web.effect(() =>
|
|
@@ -217,115 +218,25 @@ export function apply(ctx) {
|
|
|
217
218
|
path: '/api/tensorgrid.auth',
|
|
218
219
|
async handler(req, res) {
|
|
219
220
|
try {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
return
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const bridge = await loadModule('./auth-bridge.js')
|
|
226
|
-
|
|
227
|
-
if (req.method === 'GET') {
|
|
228
|
-
reply(res, 200, { available: true, ...(await bridge.listProviders(authorization, credentials)) })
|
|
229
|
-
return
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
if (req.method !== 'POST') {
|
|
233
|
-
reply(res, 405, { problem: 'поддерживаются GET и POST' })
|
|
234
|
-
return
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// Тот же заслон, что у обновлений: браузер не отправит запрос с
|
|
238
|
-
// JSON-заголовком с чужой страницы без предварительной проверки.
|
|
239
|
-
if (!String(req.headers['content-type'] ?? '').includes('application/json')) {
|
|
240
|
-
reply(res, 415, { problem: 'ожидается content-type: application/json' })
|
|
241
|
-
return
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
const body = await readJsonBody(req)
|
|
245
|
-
if (body === null) {
|
|
246
|
-
reply(res, 400, { problem: 'тело запроса не разобрано' })
|
|
247
|
-
return
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
let result
|
|
251
|
-
if (body.action === 'begin') result = bridge.begin(authorization, String(body.key), body.method)
|
|
252
|
-
else if (body.action === 'answer') result = bridge.answer(body.value)
|
|
253
|
-
else if (body.action === 'cancel') result = bridge.cancel(authorization, body.key)
|
|
254
|
-
else if (body.action === 'poll') result = bridge.poll()
|
|
255
|
-
else { reply(res, 400, { problem: `неизвестное действие: ${String(body.action)}` }); return }
|
|
256
|
-
|
|
257
|
-
reply(res, 200, { available: true, ...result })
|
|
221
|
+
const runtime = await loadModule('./http-runtime.js')
|
|
222
|
+
await runtime.handleAuth(req, res, authServices, loadModule)
|
|
258
223
|
} catch (error) {
|
|
259
|
-
|
|
224
|
+
fail(res, error)
|
|
260
225
|
}
|
|
261
226
|
},
|
|
262
227
|
}),
|
|
263
228
|
)
|
|
264
229
|
|
|
265
|
-
function reply(res, code, value) {
|
|
266
|
-
const body = JSON.stringify(value)
|
|
267
|
-
res.writeHead(code, {
|
|
268
|
-
'content-type': 'application/json; charset=utf-8',
|
|
269
|
-
'cache-control': 'no-store',
|
|
270
|
-
})
|
|
271
|
-
res.end(body)
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/**
|
|
275
|
-
* Читает тело запроса как JSON. Размер ограничен: тело здесь всегда
|
|
276
|
-
* крошечное — действие, ключ, ответ на вопрос, — и принимать больше
|
|
277
|
-
* значило бы держать открытой дверь без нужды.
|
|
278
|
-
*/
|
|
279
|
-
function readJsonBody(req) {
|
|
280
|
-
return new Promise((resolve) => {
|
|
281
|
-
let text = ''
|
|
282
|
-
let tooBig = false
|
|
283
|
-
req.on('data', (chunk) => {
|
|
284
|
-
if (tooBig) return
|
|
285
|
-
text += chunk
|
|
286
|
-
if (text.length > 16384) { tooBig = true; resolve(null) }
|
|
287
|
-
})
|
|
288
|
-
req.on('end', () => {
|
|
289
|
-
if (tooBig) return
|
|
290
|
-
try { resolve(JSON.parse(text === '' ? '{}' : text)) } catch { resolve(null) }
|
|
291
|
-
})
|
|
292
|
-
req.on('error', () => resolve(null))
|
|
293
|
-
})
|
|
294
|
-
}
|
|
295
|
-
|
|
296
230
|
web.effect(() =>
|
|
297
231
|
web.webServer.register({
|
|
298
232
|
kind: 'exact',
|
|
299
233
|
path: '/api/tensorgrid.update',
|
|
300
234
|
async handler(req, res) {
|
|
301
235
|
try {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
const fresh = String(req.url ?? '').includes('force=1')
|
|
305
|
-
reply(res, 200, fresh || cached === null ? await refresh() : cached)
|
|
306
|
-
return
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
if (req.method === 'POST') {
|
|
310
|
-
// Запись требует JSON-заголовка: браузер не отправит такой
|
|
311
|
-
// запрос с чужой страницы без предварительной проверки CORS,
|
|
312
|
-
// а она на этот маршрут не настроена. Для инструмента,
|
|
313
|
-
// слушающего только петлевой интерфейс, этого достаточно.
|
|
314
|
-
const contentType = String(req.headers['content-type'] ?? '')
|
|
315
|
-
if (!contentType.includes('application/json')) {
|
|
316
|
-
reply(res, 415, { ok: false, problem: 'ожидается content-type: application/json' })
|
|
317
|
-
return
|
|
318
|
-
}
|
|
319
|
-
const module = await loadModule()
|
|
320
|
-
const result = await module.applyUpdate()
|
|
321
|
-
cached = result.status ?? cached
|
|
322
|
-
reply(res, result.ok ? 200 : 409, result)
|
|
323
|
-
return
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
reply(res, 405, { ok: false, problem: 'поддерживаются GET и POST' })
|
|
236
|
+
const runtime = await loadModule('./http-runtime.js')
|
|
237
|
+
await runtime.handleUpdate(req, res, updateState, loadModule)
|
|
327
238
|
} catch (error) {
|
|
328
|
-
|
|
239
|
+
fail(res, error)
|
|
329
240
|
}
|
|
330
241
|
},
|
|
331
242
|
}),
|
package/package.json
CHANGED