приложение собрано каркасом и вшито в бинарник

- заведён каталог web/ — Vue 3, роутер пятой версии, сборка Vite; собранное
  вшивается через go:embed и раздаётся корневым маршрутом: разметка на
  неизвестном пути вне корней сервиса, отказ контракта внутри корня
- перечень корней сервиса стал единой точкой и порождает регистрацию маршрутов,
  а не описывает её; журнал раздачи пишет исход и длину пути, но не сам путь
- шаг front зовёт Node контейнером docker — Biome, юнит-тесты Vue и сборка
  входят в гейт, а в Dockerfile появилась ступень приложения
This commit is contained in:
av
2026-08-15 18:51:05 +03:00
parent c6ffda9aac
commit 663021f712
46 changed files with 5958 additions and 43 deletions
+24
View File
@@ -0,0 +1,24 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.8/schema.json",
"files": {
"includes": ["src/**", "*.ts", "*.json"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"linter": {
"enabled": true,
"rules": {
"preset": "recommended"
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "asNeeded"
}
}
}
+44
View File
@@ -0,0 +1,44 @@
// Package web несёт собранное приложение внутри бинарника.
//
// Каталога рядом с бинарником не требуется, и внешнего веб-сервера под раздачу
// не заводится: сервис едет на сервер одним образом, и второй разворачиваемый
// артефакт рядом с ним завёл бы вторую точку, где выкладка расходится с
// собранным.
package web
import (
"embed"
"io/fs"
)
// Вшивается каталог `embed`, а не `embed/dist`: собранное приложение лежит
// этажом ниже метки `embed/.gitkeep`, и очистка выходного каталога перед каждой
// сборкой метки не касается. Без метки каталога не было бы в git вовсе, и
// `go build ./...` отказывал бы у всякого, кто приложение ни разу не собирал.
//
// Приставка `all:` нужна ради самой метки: без неё файлы, чьё имя начинается с
// точки, не вшиваются.
//
//go:embed all:embed
var embedded embed.FS
// distDir — где внутри вшитого лежит собранное приложение.
const distDir = "embed/dist"
// Dist отдаёт собранное приложение файловой системой.
//
// Второе значение говорит, собрано ли оно вообще: пустой каталог — законное
// состояние сборки, прошедшей мимо набора проверок, и сервис обязан сказать о
// нём вслух, а не отдавать пустую страницу.
func Dist() (fs.FS, bool) {
dist, err := fs.Sub(embedded, distDir)
if err != nil {
return nil, false
}
if _, err := fs.Stat(dist, "index.html"); err != nil {
return dist, false
}
return dist, true
}
View File
+46
View File
@@ -0,0 +1,46 @@
package web
import (
"io/fs"
"testing"
)
// Стык вшивания с раздачей проверяется здесь, и только здесь: проверки самой
// раздачи получают файловую систему параметром и подставляют свою, поэтому
// расхождение объявленной раскладки с настоящей им недоступно по построению.
//
// Раскладка объявлена дважды — выходным каталогом сборщика и `distDir` здесь, —
// и связаны они только совпадением строк. Разойдясь, они дают `503` на каждом
// пути показа при зелёном наборе проверок.
func TestDistMatchesEmbeddedLayout(t *testing.T) {
dist, built := Dist()
if dist == nil {
t.Fatal("вшитое поддерево недоступно: distDir разошёлся с директивой go:embed")
}
_, err := fs.Stat(dist, "index.html")
// Оба исхода законны и различаются одним: собрано приложение или нет.
// Незаконно третье — «собрано, но `Dist` этого не видит».
if built && err != nil {
t.Fatalf("приложение объявлено собранным, а разметки в поддереве нет: %v", err)
}
if !built && err == nil {
t.Fatal("разметка есть, а приложение объявлено несобранным")
}
if !built {
t.Skip("приложение не собрано: прогон без шага сборки, проверять нечего")
}
}
// Метка пустого каталога вшивается вместе с прочим: без неё каталога не было бы
// в git, и сборка Go отказывала бы у всякого, кто приложение ни разу не собирал.
// Приставка `all:` заведена ровно ради неё.
func TestMarkerIsEmbedded(t *testing.T) {
if _, err := fs.Stat(embedded, "embed/.gitkeep"); err != nil {
t.Fatalf("метка пустого каталога не вшита: %v", err)
}
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Расшифровка записей</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2967
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "transcriber-web",
"private": true,
"type": "module",
"scripts": {
"build": "vue-tsc -b && vite build",
"check": "biome check .",
"test": "vitest run"
},
"dependencies": {
"vue": "^3.5.41",
"vue-router": "^5.2.0"
},
"devDependencies": {
"@biomejs/biome": "^2.5.8",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/test-utils": "^2.4.11",
"happy-dom": "^20.11.2",
"typescript": "^5.9.3",
"vite": "^8.2.1",
"vitest": "^4.1.10",
"vue-tsc": "^3.3.10"
}
}
+3
View File
@@ -0,0 +1,3 @@
<template>
<RouterView />
</template>
+77
View File
@@ -0,0 +1,77 @@
// Единственное место, где приложение читает код ответа и разбирает тело отказа.
// Экран получает готовый текст, а не `Response`: второй разборщик кодов на
// клиенте разошёлся бы с первым.
const appRoot = '/app'
/** Ответ сервиса на вопрос «кто вошёл». */
export interface Me {
id: string
name: string
}
/** Единая форма тела отказа на адресах приложения. */
interface ErrorBody {
error_code?: string
message?: string
}
/**
* Исход запроса. Отсутствие сессии — свой исход, а не разновидность неудачи:
* экран уводит ко входу только на нём. Всякий прочий отказ ко входу не ведёт,
* иначе отказ сервиса или ограничителя частоты замкнул бы круг «приложение →
* вход → приложение».
*/
export type ApiResult<T> =
| { status: 'ok'; value: T }
| { status: 'unauthorized' }
| { status: 'failed'; message: string }
const noConnectionMessage = 'Связи нет'
const unknownFailureMessage = 'Сервис не отвечает как надо'
async function failureMessage(response: Response): Promise<string> {
try {
const body = (await response.json()) as ErrorBody
if (body.message) {
return body.message
}
} catch {
// Тело не разобралось — своего текста под код ответа не сочиняем.
}
return unknownFailureMessage
}
async function request<T>(path: string): Promise<ApiResult<T>> {
let response: Response
try {
response = await fetch(appRoot + path, {
headers: { Accept: 'application/json' },
})
} catch {
// Сорванный запрос — состояние, а не ошибка: показываем строкой.
return { status: 'failed', message: noConnectionMessage }
}
if (response.status === 401) {
return { status: 'unauthorized' }
}
if (!response.ok) {
return { status: 'failed', message: await failureMessage(response) }
}
try {
return { status: 'ok', value: (await response.json()) as T }
} catch {
return { status: 'failed', message: unknownFailureMessage }
}
}
/** Спрашивает сервис, кто вошёл. Куку сессии браузер шлёт сам. */
export function fetchMe(): Promise<ApiResult<Me>> {
return request<Me>('/me')
}
/** Адрес, которым сервис уводит человека ко входу. */
export const loginPath = '/auth/login'
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import { router } from './router'
createApp(App).use(router).mount('#app')
+12
View File
@@ -0,0 +1,12 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeScreen from './screens/HomeScreen.vue'
// Одна таблица маршрутов. Маршруты по файлам не включаем: сборочная надстройка
// роутера на нашем числе экранов не окупается.
//
// Адреса обычные, а не после решётки. Отсюда требование к серверу: неизвестный
// путь вне корней сервиса отдаёт разметку, а не отказ.
export const router = createRouter({
history: createWebHistory(),
routes: [{ path: '/', name: 'home', component: HomeScreen }],
})
+86
View File
@@ -0,0 +1,86 @@
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import HomeScreen from './HomeScreen.vue'
// Экран судится по тому, что видит человек, а не по внутреннему состоянию:
// переписанная реализация обязана остаться зелёной.
const assign = vi.fn()
vi.stubGlobal('window', { location: { assign } })
function answerWith(response: Partial<Response> & { json?: () => Promise<unknown> }) {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response))
}
function failWith(error: Error) {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(error))
}
afterEach(() => {
vi.restoreAllMocks()
assign.mockReset()
})
describe('экран показывает вошедшего', () => {
it('показывает имя, когда сессия есть', async () => {
answerWith({
ok: true,
status: 200,
json: () => Promise.resolve({ id: 'u1', name: 'Антон' }),
})
const screen = mount(HomeScreen)
await flushPromises()
expect(screen.text()).toContain('Антон')
expect(assign).not.toHaveBeenCalled()
})
it('ведёт ко входу, когда сессии нет', async () => {
answerWith({ ok: false, status: 401, json: () => Promise.resolve({}) })
mount(HomeScreen)
await flushPromises()
expect(assign).toHaveBeenCalledWith('/auth/login')
})
it('на прочий отказ показывает строку и ко входу не ведёт', async () => {
answerWith({
ok: false,
status: 500,
json: () => Promise.resolve({ error_code: 'internal', message: 'Внутренняя ошибка сервиса' }),
})
const screen = mount(HomeScreen)
await flushPromises()
expect(screen.text()).toContain('Внутренняя ошибка сервиса')
expect(assign).not.toHaveBeenCalled()
})
it('на сорванный запрос показывает, что связи нет', async () => {
failWith(new Error('network down'))
const screen = mount(HomeScreen)
await flushPromises()
expect(screen.text()).toContain('Связи нет')
expect(assign).not.toHaveBeenCalled()
})
it('учётная запись без имени: вход выполнен, почты на экране нет', async () => {
answerWith({
ok: true,
status: 200,
json: () => Promise.resolve({ id: 'u1', name: '' }),
})
const screen = mount(HomeScreen)
await flushPromises()
expect(screen.text()).toContain('Вы вошли')
expect(screen.text()).not.toContain('@')
})
})
+46
View File
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchMe, loginPath } from '../api'
// Состояние экрана живёт в экране: общего между экранами пока нет.
const state = ref<'loading' | 'signed-in' | 'failed'>('loading')
const name = ref('')
const failure = ref('')
async function load() {
const result = await fetchMe()
if (result.status === 'unauthorized') {
// Единственная ветка, уводящая ко входу. Прочие отказы ведут сюда же
// только через круг «приложение → вход → приложение», поэтому их здесь нет.
window.location.assign(loginPath)
return
}
if (result.status === 'failed') {
failure.value = result.message
state.value = 'failed'
return
}
name.value = result.value.name
state.value = 'signed-in'
}
onMounted(load)
</script>
<template>
<main>
<h1>Расшифровка записей</h1>
<p v-if="state === 'loading'">Загружается</p>
<p v-else-if="state === 'failed'">{{ failure }}</p>
<!-- Имени у учётной записи может не быть вовсе: тогда говорим, что вход
выполнен, и не подставляем вместо имени адрес почты его в ответе нет. -->
<p v-else-if="name">Вы вошли как {{ name }}</p>
<p v-else>Вы вошли</p>
</main>
</template>
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"noEmit": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
}
+23
View File
@@ -0,0 +1,23 @@
import vue from '@vitejs/plugin-vue'
// Настройка сборки и настройка проверок живут одним файлом: второй разошёлся бы
// с первым в путях и в наборе подключаемого.
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [vue()],
build: {
// Выходной каталог лежит этажом ниже метки `embed/.gitkeep`: очистка перед
// сборкой метки не касается, и `go build ./...` не отказывает у того, кто
// приложение ни разу не собирал.
outDir: 'embed/dist',
emptyOutDir: true,
// Имя каталога задано явно, а не оставлено умолчанию: на него опираются два
// правила раздачи — отказ вместо разметки на несовпавшем ресурсе и долгий
// срок хранения, — и смена умолчания сборщика выключила бы оба молча.
// Второй дом имени — `controller/http.assetsDir`.
assetsDir: 'assets',
},
test: {
environment: 'happy-dom',
},
})