Compare commits

..
5 Commits
8 changed files with 334 additions and 1504 deletions
+3
View File
@@ -51,3 +51,6 @@ Thumbs.db
# Docs tooling # Docs tooling
site/ site/
site_test/ site_test/
# ВМ-деплой (не в git)
deploy-vm/
+81
View File
@@ -0,0 +1,81 @@
# tf_docs: переход на 302-редирект + раздача через ВМ
Дата: 2026-09-02
## Проблема
Отдача больших MkDocs-страниц (~133 KB) через под managed-кластера `nodejsk8s`
захлёбывалась: `273 B/s` через `tf_docs` (за 80 c скачано 21 KB). Входной шлюз
managed-кластера рвёт большие тела (как в drhider: шлюз режет >~64 KB, egress не ограничен).
## Диагностика (замеры)
| Путь → S3 | Скорость |
|---|---|
| `tf_docs` (кластер) | 273 B/s |
| локальная машина (Node.js) | 24.5 KiB/s |
| **ВМ `5.172.178.213`** | **5.6 MB/s** (133 KB за 0.023 с) |
Вывод: S3 быстрый, медленный — путь от managed-кластера/внешней сети. ВМ имеет
быстрый прямой канал до S3. Паттерн решения взят из drhider («ВМ-буфер», `~/nubes/HowTo/vm-file-upload-service.md`): большие данные — мимо шлюза кластера, через ВМ.
## Решение (финальная архитектура)
```
Браузер → tf_docs (кластер, только 302) → ВМ nginx → статика (зеркало S3)
```
- `tf_docs` — тонкий редирект-роутер: `/health` + `302` на ВМ. Без S3, без стримов.
- ВМ `5.172.178.213` — nginx раздаёт статику из локального зеркала S3.
- URL без версий: `/nubes-test/...` → S3 `docs/nubes-test/nubes/...` (перезапись).
## Изменения
### `tf_docs` (репо, запушено `5360893`, версия `0.0.3`)
- `server.js` — переписан на 302-роутер (сгенерирован DeepSeek Flash):
- `GET /health` → `200 ok`
- `GET /:stand/...` → `302 Location: ${VM_DOCS_BASE_URL}/:stand/...`
- убраны `@aws-sdk/client-s3`, S3-креды, стрим
- `package.json` — `0.0.3`, зависимостей нет (чистый `http`).
- `test/server.test.js` — 4 теста (health, redirect, root, 405) — PASS.
- `.gitignore` — добавлен `deploy-vm/`.
### `tf_docs/deploy-vm/` (локально, НЕ в git)
- `nginx-tf-docs.conf` — location `/(nubes-test|nubes-dev|nubes)/` → статика `/var/www/tf-docs/`.
- `mirror-s3.sh` — `mc mirror` из S3 `docs/<stand>/nubes/` → `/var/www/tf-docs/<stand>/`.
- `README.md` — инструкция деплоя на ВМ.
### `tf_provider` (репо, запушено `dc469c6`)
- `scripts/publish-docs.sh` — заливка **без версии** (`docs/<ns>/<name>/`), перезапись `mc mirror --overwrite --remove`.
- `TOOLS/scripts/04_build_and_publish_docs.sh` — `site_url: https://<host>/<namespace>/` (без name/version); `docs_dir` относительный (для docker-сборки); хост `tf-docs.nodejsk8s.dev.nubes.ru`.
## Env tf_docs (jsonEnv в UI)
```json
{
"VM_DOCS_BASE_URL": "http://5.172.178.213"
}
```
S3-креды `tf_docs` больше не нужны (не ходит в S3). Креды — только на ВМ в `mirror-s3.sh`.
## Порядок деплоя
1. **tf_docs**: redeploy в ЛК облака (образ из gitea `5360893`), env `VM_DOCS_BASE_URL`, health `/health`.
2. **ВМ**: скопировать `deploy-vm/`, `./mirror-s3.sh nubes-test`, вставить nginx-конфиг, `nginx -t && reload`.
3. **Перезалить документацию**: `04` + `publish-docs.sh` → `docs/nubes-test/nubes/` (без версии).
## Проверка
```bash
curl -I https://tf-docs.nodejsk8s.dev.nubes.ru/nubes-test/ # 302 → ВМ
curl -I http://5.172.178.213/nubes-test/ # 200 index.html
```
## Урок (не повторять)
- `kube5s.ru` — **legacy**, закрывается. Не использовать ни в коде, ни в доках.
- HowTo-документация местами устарела (описывает legacy-домен как рабочий) — сверять с актуальными источниками.
@@ -0,0 +1,53 @@
# 2026-09-02 — Анализ Соннета по tf_docs + критическое мнение
## Вопрос (что отдавали Соннету)
Анализ финальной архитектуры отдачи MkDocs-документации через «вечный» бесплатный
домен `tf-docs.nodejsk8s.dev.nubes.ru`: браузер → под nodejsk8s (reverse-proxy) →
ВМ 5.172.178.213 (nginx/статика). Просили: правильна ли схема, можно ли ускорить
международный маршрут без платных доменов, скрытые риски.
## Ответ Соннета (суть)
1. Архитектура верна, но есть **конфигурационный дрейф**: в репо
`deploy-vm/nginx-ip-5.172.178.213.conf` всё ещё `location / { proxy_pass
http://127.0.0.1:8888/; }` (на остановленный llmui), хотя на ВМ уже `return 301`.
2. Обрывы ~16–36 КБ — похожи на инцидент tf_registry (там виноват локальный
прокси), но у нас Германия без прокси тоже режет + ВМ напрямую режет → маршрут
в РФ фильтруется на границе. Предложил **включить gzip**, чтобы «сжать страницу
ниже порога обрыва».
3. Риски: SPOF на ВМ, ВМ не выделенный хост, `location /` открыт по IP, timeout
30с (idle), TLS только на ingress.
## Моё критическое мнение
**Главное: рекомендация gzip — ошибочна.** Порог обрыва — СЕТЕВОЙ (~16.7 КБ), а не
«по объёму контента». Доказательство из замеров этой же сессии:
- файл `.test_20k.bin` (ровно 20000 байт) обрывается на 16775 байт;
- файлы 50/100/200 КБ — тоже ровно на 16773–16775;
- favicon 1017 байт — проходит полностью.
То есть обрыв наступает на ~16.7 КБ **независимо от content-length**. Сжать HTML
133 КБ gzip до <16.7 КБ нереально (текст жмётся до ~25–40 КБ) — ответ всё равно
упрётся в сетевой порог. gzip меняет размер контента, но не лимит маршрута. Гипотеза
«сжать ниже порога» не сработает. Вывод: для страниц 133–200 КБ gzip проблему не
решает.
**Остальные пункты** — валидные, но в основном уже известные/принятые trade-off'ы:
- дрейф 8888 в репо — подтверждён, реальный риск при повторном применении файла из
git (файл стоит синхронизировать с ВМ);
- SPOF на ВМ, timeout 30с, TLS-только-ingress — верно, но «ВМ-буфер» и есть
осознанное решение (весь код на ВМ);
- «location / открыт по IP» — натянуто: ВМ и так публична по IP, новой дыры нет.
**Итог:** анализ полезен, но ключевая рекомендация (gzip) технически неверна;
реально ценное — пункт про дрейф 8888 (нужно синхронизировать файл в репо).
## Статус
- Дрейф `deploy-vm/nginx-ip-5.172.178.213.conf` (8888) — ИСПРАВЛЕН: файл синхронизирован
с ВМ (`return 301 /nubes-test/` + `absolute_redirect off`). Файл gitignored, в git не
входит — риск «применения из git» не подтвердился, это локальная справочная копия.
- gzip — НЕ делать (не решает).
- Range-проброс в `server.js` — НЕ делать (для MkDocs-статики не критично).
+2 -1312
View File
File diff suppressed because it is too large Load Diff
+2 -5
View File
@@ -1,7 +1,7 @@
{ {
"name": "tf-docs", "name": "tf-docs",
"version": "0.0.2", "version": "0.0.5",
"description": "Terraform docs service (S3 static /docs) — initial skeleton", "description": "Terraform docs reverse-proxy (stream from VM static server)",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",
@@ -10,8 +10,5 @@
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
},
"dependencies": {
"@aws-sdk/client-s3": "3.879.0"
} }
} }
+57
View File
@@ -0,0 +1,57 @@
Ты — инженер. Проанализируй задачу ниже и дай КРАТКИЙ, ясный ответ БЕЗ ВОДЫ: что правильно, что неправильно, что стоит поменять. Не пересказывай вводные.
---
## ЦЕЛЬ
Документация Terraform-провайдера Nubes (статический MkDocs-сайт, страницы 133–200 КБ, поисковый индекс ~1.3 МБ) должна открываться по БЕСПЛАТНОМУ «вечному» домену:
`https://tf-docs.nodejsk8s.dev.nubes.ru`
Без IP в адресной строке, без платных доменов (продление домена стоит денег — не вариант). Домен даёт облако Nubes бесплатно как managed-сервис.
## АРХИТЕКТУРА (текущая)
```
Браузер
→ tf-docs.nodejsk8s.dev.nubes.ru (managed-кластер nodejsk8s, ingress-шлюз 185.247.187.151)
→ под tf_docs (Node.js, reverse-proxy, 0.0.5)
→ ВМ 5.172.178.213 (nginx, статика из /var/www/tf-docs/)
→ MkDocs HTML
```
`tf_docs/server.js`: reverse-proxy. `/health` → 200. `/` → 301 на `/nubes-test/`. `/:stand/...` → `${VM_DOCS_BASE_URL}/:stand/...`. Форвардит content-type/content-length/cache-control/content-encoding/location. На ВМ nginx: `location /nubes-test/` отдаёт статику, `location /` = `return 301 /nubes-test/` + `absolute_redirect off`.
## ЧТО ПРОБОВАЛИ И ЧТО НЕ ПОЛУЧАЛОСЬ (хронология)
1. **Стриминг docs из S3 через под** → упиралось в ~273 Б/с (шлюз резал). Отказались.
2. **Redirect 302 с пода на ВМ** → браузер уходил на `http://5.172.178.213/...` — IP в адресе, домен терялся. Не подходит.
3. **Reverse-proxy (текущее)** → URL остаётся на домене. Принято как финальное.
4. **502 на корне `/`** → баг на ВМ: `location /` проксировал на `127.0.0.1:8888` (Docker `llmui`, остановлен по соображениям безопасности). Починено: `return 301 /nubes-test/`.
5. **301 терял заголовок `Location`** → в прокси не был в списке форвардящихся заголовков. Добавили `location`. Плюс `absolute_redirect off` на ВМ, чтобы Location был относительным (`/nubes-test/`), а не `http://5.172.178.213/...`.
## ГЛАВНАЯ ПРОБЛЕМА — СКОРОСТЬ/ОБРЫВЫ (диагностировано)
Симптом: большие ответы «замирали» ровно на ~16–20 КБ.
Замеры:
- ВМ напрямую из РФ: **5 МБ/с** (133 КБ за 0.026 с).
- Домен из РФ без прокси: **133 КБ за ~5 с, 1.3 МБ за ~5 с** — работает полностью.
- Домен через локальный v2ray-прокси (`HTTPS_PROXY=172.17.192.1:10808`): **стоп на 16788 байт**.
- Сервер в Германии (Vultr 95.179.252.111), чистый, без прокси:
- шлюз `185.247.187.151`: стоп на 16788 байт;
- ВМ `5.172.178.213` напрямую: стоп на 35912 байт (~1 КБ/с);
- контроль (google.com, не-РФ): 83 КБ за 0.48 с — канал Германии хороший.
- iotdash22.nodejsk8s.dev.nubes.ru (другой шлюз 91.214.116.216) отдаёт 17.7 КБ полностью.
Вывод, к которому пришли: режется **международный маршрут → российские IP**, а не наша архитектура. Из РФ напрямую всё быстро.
## ТЕКУЩЕЕ СОСТОЯНИЕ
Всё работает из РФ напрямую (браузер открывает страницы быстро, это подтверждено пользователем). Проверено в браузере: `/` → 301 → `/nubes-test/` (домен сохраняется), getting-started-страница (197 КБ) грузится полностью. ВМ отдаёт поду все байты (access log nginx: 133525, 200000 и т.д.).
## ЧТО ОТ ТЕБЯ НУЖНО
1. Правильна ли текущая архитектура для поставленной цели (бесплатный вечный домен + быстрая отдача из РФ)? Есть ли фундаментальные изъяны?
2. Есть ли способ ускорить/сделать надёжнее отдачу для трафика, который идёт через зарубежный маршрут (прокси/Германия), не вводя платных доменов? (или это физически нерешаемо на этом хостинге)
3. Скрытые риски/узкие места текущего решения (single point of failure на ВМ, TLS, кэш, безопасность `location /` и т.д.).
Ответ: кратко, по пунктам, без воды.
+99 -100
View File
@@ -1,125 +1,124 @@
'use strict'; 'use strict';
const http = require('http'); const http = require('http');
const path = require('path'); const https = require('https');
const { S3Client, GetObjectCommand, HeadBucketCommand } = require('@aws-sdk/client-s3');
const PORT = process.env.PORT || 3000; const PORT = Number(process.env.PORT) || 3000;
const VERSION = process.env.APP_VERSION || '0.0.2'; const PROXY_TIMEOUT_MS = Number(process.env.PROXY_TIMEOUT_MS) || 30000;
const SERVICE = 'tf-docs'; const VM_DOCS_BASE_URL = process.env.VM_DOCS_BASE_URL;
const S3_BUCKET = process.env.S3_BUCKET || '';
const DOCS_S3_PREFIX = (process.env.DOCS_S3_PREFIX || 'docs').replace(/^\/+|\/+$/g, '');
const S3_ENDPOINT = process.env.S3_ENDPOINT || '';
const CACHE_CONTROL = process.env.CACHE_CONTROL || 'public, max-age=60';
const s3 = new S3Client({ if (!VM_DOCS_BASE_URL) {
region: process.env.S3_REGION || 'us-east-1', throw new Error('VM_DOCS_BASE_URL environment variable is required');
endpoint: S3_ENDPOINT ? `https://${S3_ENDPOINT.replace(/^https?:\/\//, '')}` : undefined,
forcePathStyle: true,
credentials: process.env.S3_ACCESS_KEY && process.env.S3_SECRET_KEY
? { accessKeyId: process.env.S3_ACCESS_KEY, secretAccessKey: process.env.S3_SECRET_KEY }
: undefined
});
function getDocsCandidates(urlPath) {
const relativePath = decodeURIComponent(urlPath.replace(/^\/+/, ''));
const normalized = path.posix.normalize(`/${relativePath}`).replace(/^\/+|\/+$/g, '');
if (!normalized || normalized === '.' || normalized.startsWith('../') || normalized.includes('/../')) {
return null;
}
const base = `${DOCS_S3_PREFIX}/${normalized}`;
const candidates = [];
if (urlPath.endsWith('/')) {
candidates.push(`${base}/index.html`);
} else {
candidates.push(base);
if (!path.posix.extname(normalized)) candidates.push(`${base}/index.html`);
candidates.push(`${DOCS_S3_PREFIX}/${normalized.split('/').slice(0, 3).join('/')}/index.html`);
}
return [...new Set(candidates)];
} }
function contentTypeFor(key) { const UPSTREAM = new URL(VM_DOCS_BASE_URL);
const types = { if (UPSTREAM.protocol !== 'http:' && UPSTREAM.protocol !== 'https:') {
'.html': 'text/html; charset=utf-8', throw new Error('VM_DOCS_BASE_URL must start with http:// or https://');
'.css': 'text/css; charset=utf-8', }
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8', const UPSTREAM_MODULE = UPSTREAM.protocol === 'https:' ? https : http;
'.svg': 'image/svg+xml', const UPSTREAM_BASE_PATH = UPSTREAM.pathname.replace(/\/+$/, '');
'.png': 'image/png', const UPSTREAM_PORT = UPSTREAM.port || (UPSTREAM.protocol === 'https:' ? 443 : 80);
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg', const STAND_RE = /^[A-Za-z0-9._-]+$/;
'.webp': 'image/webp' const FORWARD_HEADERS = ['content-type', 'content-length', 'cache-control', 'content-encoding', 'location'];
function respond(res, status, body) {
if (!res.headersSent) {
res.writeHead(status, {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
});
}
res.end(body);
}
function proxyRequest(req, res, targetPath) {
const options = {
protocol: UPSTREAM.protocol,
hostname: UPSTREAM.hostname,
port: UPSTREAM_PORT,
method: req.method,
path: targetPath,
timeout: PROXY_TIMEOUT_MS,
}; };
return types[path.posix.extname(key).toLowerCase()] || 'application/octet-stream';
}
async function docsHandler(req, res) { const upstreamReq = UPSTREAM_MODULE.request(options, (upstreamRes) => {
const candidates = getDocsCandidates(req.url.split('?')[0]); const headers = {};
if (!candidates || !S3_BUCKET) { for (const name of FORWARD_HEADERS) {
res.statusCode = 400; if (upstreamRes.headers[name] !== undefined) {
res.end('Invalid documentation path or S3_BUCKET is not configured'); headers[name] = upstreamRes.headers[name];
return;
} }
}
res.writeHead(upstreamRes.statusCode || 502, headers);
upstreamRes.pipe(res);
});
for (const key of candidates) { upstreamReq.on('timeout', () => {
try { upstreamReq.destroy(new Error('Upstream timeout'));
const result = await s3.send(new GetObjectCommand({ Bucket: S3_BUCKET, Key: key })); });
res.statusCode = 200;
res.setHeader('Content-Type', result.ContentType || contentTypeFor(key));
res.setHeader('Cache-Control', CACHE_CONTROL);
if (result.ContentLength !== undefined) res.setHeader('Content-Length', result.ContentLength);
result.Body.pipe(res);
return;
} catch (error) {
if (error.name !== 'NoSuchKey' && error.$metadata?.httpStatusCode !== 404) {
res.statusCode = 502;
res.end('S3 request failed');
return;
}
}
}
res.statusCode = 404; upstreamReq.on('error', () => {
res.end('Documentation file not found'); if (res.headersSent) {
} res.destroy();
} else {
respond(res, 502, 'Bad Gateway');
}
});
async function health(_req, res) { req.on('aborted', () => upstreamReq.destroy());
if (!S3_BUCKET) {
res.statusCode = 503; upstreamReq.end();
res.end('s3 unreachable: S3_BUCKET not configured');
return;
}
try {
await s3.send(new HeadBucketCommand({ Bucket: S3_BUCKET }));
res.statusCode = 200;
res.end('ok');
} catch (error) {
res.statusCode = 503;
res.end('s3 unreachable: ' + (error && error.message ? error.message : error));
}
} }
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
if (req.url === '/health') { if (req.method !== 'GET' && req.method !== 'HEAD') {
health(req, res); respond(res, 405, 'Method Not Allowed');
return; return;
} }
docsHandler(req, res);
const rawUrl = req.url || '/';
const qIndex = rawUrl.indexOf('?');
const rawPath = qIndex === -1 ? rawUrl : rawUrl.slice(0, qIndex);
const rawQuery = qIndex === -1 ? '' : rawUrl.slice(qIndex);
if (rawPath === '/health') {
respond(res, 200, 'ok');
return;
}
let decoded;
try {
decoded = decodeURIComponent(rawPath);
} catch (_) {
respond(res, 400, 'Bad Request');
return;
}
if (decoded.split('/').some((seg) => seg === '..')) {
respond(res, 400, 'Bad Request');
return;
}
let targetPath;
if (decoded === '/') {
targetPath = UPSTREAM_BASE_PATH + '/' + rawQuery;
} else {
const stand = decoded.split('/').filter(Boolean)[0];
if (!stand || !STAND_RE.test(stand)) {
respond(res, 400, 'Bad Request');
return;
}
targetPath = UPSTREAM_BASE_PATH + rawPath + rawQuery;
}
proxyRequest(req, res, targetPath);
}); });
if (require.main === module) { if (require.main === module) {
server.listen(PORT, () => { server.listen(PORT, () => {
console.log(`[${SERVICE}] listening on :${PORT} (version ${VERSION})`); console.log(`[docs-proxy] listening on port ${PORT}, upstream: ${VM_DOCS_BASE_URL}`);
}); });
} }
module.exports = { module.exports = { server };
getDocsCandidates,
contentTypeFor,
health,
docsHandler,
server,
s3,
};
+37 -87
View File
@@ -1,117 +1,67 @@
'use strict'; 'use strict';
process.env.S3_BUCKET = process.env.S3_BUCKET || 'terraform-registry'; const http = require('node:http');
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert'); const assert = require('node:assert');
const { Readable } = require('node:stream');
const { getDocsCandidates, contentTypeFor, server, s3 } = require('../server.js'); let server;
const upstream = http.createServer((req, res) => {
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Content-Length': Buffer.byteLength('<html>ok</html>'),
});
res.end('<html>ok</html>');
});
test.before(async () => {
await new Promise((r) => upstream.listen(0, '127.0.0.1', r));
process.env.VM_DOCS_BASE_URL = `http://127.0.0.1:${upstream.address().port}`;
({ server } = require('../server.js'));
});
test.after(async () => {
await new Promise((r) => upstream.close(r));
});
async function withServer(fn) { async function withServer(fn) {
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); await new Promise((r) => server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${server.address().port}`; const base = `http://127.0.0.1:${server.address().port}`;
try { try {
await fn(base); await fn(base);
} finally { } finally {
await new Promise((resolve) => server.close(resolve)); await new Promise((r) => server.close(r));
} }
} }
// --- getDocsCandidates (URL -> S3 keys) --- test('GET /health -> 200 ok', async () => {
test('candidates: trailing slash -> index.html', () => {
assert.deepStrictEqual(
getDocsCandidates('/nubes/nubes/1.0.0/'),
['docs/nubes/nubes/1.0.0/index.html']
);
});
test('candidates: file with extension -> exact + root index fallback', () => {
assert.deepStrictEqual(
getDocsCandidates('/nubes/nubes/1.0.0/page.html'),
['docs/nubes/nubes/1.0.0/page.html', 'docs/nubes/nubes/1.0.0/index.html']
);
});
test('candidates: directory-style path -> exact + index.html + root index', () => {
assert.deepStrictEqual(
getDocsCandidates('/nubes/nubes/1.0.0/guides/getting-started'),
[
'docs/nubes/nubes/1.0.0/guides/getting-started',
'docs/nubes/nubes/1.0.0/guides/getting-started/index.html',
'docs/nubes/nubes/1.0.0/index.html',
]
);
});
// --- contentTypeFor ---
test('contentTypeFor: known and unknown extensions', () => {
assert.strictEqual(contentTypeFor('index.html'), 'text/html; charset=utf-8');
assert.strictEqual(contentTypeFor('style.css'), 'text/css; charset=utf-8');
assert.strictEqual(contentTypeFor('app.js'), 'application/javascript; charset=utf-8');
assert.strictEqual(contentTypeFor('logo.png'), 'image/png');
assert.strictEqual(contentTypeFor('data.json'), 'application/json; charset=utf-8');
assert.strictEqual(contentTypeFor('file.unknown'), 'application/octet-stream');
});
// --- /health ---
test('GET /health -> 200 ok when S3 available', async () => {
const originalSend = s3.send;
s3.send = async () => ({ $metadata: { httpStatusCode: 200 } });
await withServer(async (base) => { await withServer(async (base) => {
const res = await fetch(`${base}/health`); const res = await fetch(`${base}/health`);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(await res.text(), 'ok'); assert.strictEqual(await res.text(), 'ok');
}); });
s3.send = originalSend;
}); });
test('GET /health -> 503 when S3 unreachable', async () => { test('GET /nubes-test/ -> proxies to upstream', async () => {
const originalSend = s3.send;
s3.send = async () => { throw new Error('boom'); };
await withServer(async (base) => { await withServer(async (base) => {
const res = await fetch(`${base}/health`); const res = await fetch(`${base}/nubes-test/`);
assert.strictEqual(res.status, 503);
assert.match(await res.text(), /s3 unreachable/);
});
s3.send = originalSend;
});
// --- /docs ---
test('GET /docs/... -> 200 streams body with content-type', async () => {
const originalSend = s3.send;
s3.send = async (cmd) => {
if (cmd.input && cmd.input.Key) {
return { Body: Readable.from(['<html>ok</html>']), ContentType: 'text/html' };
}
return { $metadata: { httpStatusCode: 200 } };
};
await withServer(async (base) => {
const res = await fetch(`${base}/nubes/nubes/1.0.0/`);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.match(res.headers.get('content-type') || '', /text\/html/);
assert.strictEqual(await res.text(), '<html>ok</html>'); assert.strictEqual(await res.text(), '<html>ok</html>');
}); });
s3.send = originalSend;
}); });
test('GET /docs/... -> 404 when no key found', async () => { test('GET / -> proxies to upstream root', async () => {
const originalSend = s3.send;
s3.send = async (cmd) => {
if (cmd.input && cmd.input.Key) {
const err = new Error('Not Found');
err.name = 'NoSuchKey';
throw err;
}
return { $metadata: { httpStatusCode: 200 } };
};
await withServer(async (base) => { await withServer(async (base) => {
const res = await fetch(`${base}/nubes/nubes/1.0.0/missing`); const res = await fetch(`${base}/`);
assert.strictEqual(res.status, 404); assert.strictEqual(res.status, 200);
assert.strictEqual(await res.text(), '<html>ok</html>');
}); });
s3.send = originalSend;
}); });
test('POST -> 405', async () => {
await withServer(async (base) => {
const res = await fetch(`${base}/health`, { method: 'POST' });
assert.strictEqual(res.status, 405);
});
});