Preview: Dev-Modus nur mit dev-Skript waehlbar
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a5cb668853
commit
04b45e4207
5 files changed
+159
-7
No files matched your search
@@ -9,12 +9,14 @@ import * as runnerClient from '../../services/previewRunnerClient.js'
|
|||||||
import { runDeploy, fetchPreviewConfig } from '../../services/previewDeploy.js'
|
import { runDeploy, fetchPreviewConfig } from '../../services/previewDeploy.js'
|
||||||
import { withLocks } from '../../services/keyedLock.js'
|
import { withLocks } from '../../services/keyedLock.js'
|
||||||
import { getRepoInfo } from '../../services/giteaAdmin.js'
|
import { getRepoInfo } from '../../services/giteaAdmin.js'
|
||||||
|
import { repoHasDevScript } from '../../services/previewDevScript.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
router.use(requireAppwriteStaff)
|
router.use(requireAppwriteStaff)
|
||||||
|
|
||||||
const BUSY = 'Server ausgelastet, versuche es gleich noch mal'
|
const BUSY = 'Server ausgelastet, versuche es gleich noch mal'
|
||||||
const MODES = ['auto', 'build', 'dev', 'off']
|
const MODES = ['auto', 'build', 'dev', 'off']
|
||||||
|
const NO_DEV = 'Dieses Projekt hat kein dev-Skript. Reines HTML braucht keinen Dev-Server – Änderungen erscheinen nach jedem Push automatisch.'
|
||||||
const SHA_RE = /^[0-9a-f]{7,40}$/
|
const SHA_RE = /^[0-9a-f]{7,40}$/
|
||||||
|
|
||||||
function fail(status, message) {
|
function fail(status, message) {
|
||||||
@@ -62,10 +64,14 @@ async function view(doc) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function previewBranchOf(doc) {
|
||||||
|
return doc.previewBranch || (await getRepoInfo(doc.repoFullName).catch(() => null))?.defaultBranch || 'main'
|
||||||
|
}
|
||||||
|
|
||||||
// Neubau anstossen (wie beim Bearbeiten/Reaktivieren eines Projekts)
|
// Neubau anstossen (wie beim Bearbeiten/Reaktivieren eines Projekts)
|
||||||
async function triggerRebuild(doc) {
|
async function triggerRebuild(doc) {
|
||||||
const repo = doc.repoFullName
|
const repo = doc.repoFullName
|
||||||
const branch = doc.previewBranch || (await getRepoInfo(repo).catch(() => null))?.defaultBranch || 'main'
|
const branch = await previewBranchOf(doc)
|
||||||
const found = await fetchPreviewConfig(repo, branch).catch(() => null)
|
const found = await fetchPreviewConfig(repo, branch).catch(() => null)
|
||||||
const cfg = {
|
const cfg = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -93,7 +99,11 @@ const wrap = (fn) => async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
router.get('/:id/preview', wrap(async (req, res) => {
|
router.get('/:id/preview', wrap(async (req, res) => {
|
||||||
res.json(await view(await loadProject(req.params.id)))
|
const doc = await loadProject(req.params.id)
|
||||||
|
const devAvailable = doc.repoFullName
|
||||||
|
? await repoHasDevScript(doc.repoFullName, await previewBranchOf(doc)).catch(() => null)
|
||||||
|
: null
|
||||||
|
res.json({ ...(await view(doc)), devAvailable })
|
||||||
}))
|
}))
|
||||||
|
|
||||||
router.patch('/:id/preview', wrap(async (req, res) => {
|
router.patch('/:id/preview', wrap(async (req, res) => {
|
||||||
@@ -110,6 +120,10 @@ router.patch('/:id/preview', wrap(async (req, res) => {
|
|||||||
}
|
}
|
||||||
return res.json(await view(await getDocument(col(), doc.$id)))
|
return res.json(await view(await getDocument(col(), doc.$id)))
|
||||||
}
|
}
|
||||||
|
if (mode === 'dev' && doc.repoFullName) {
|
||||||
|
const has = await repoHasDevScript(doc.repoFullName, await previewBranchOf(doc)).catch(() => null)
|
||||||
|
if (has === false) throw fail(400, NO_DEV)
|
||||||
|
}
|
||||||
const updated = await updateDocument(col(), doc.$id, { previewMode: mode })
|
const updated = await updateDocument(col(), doc.$id, { previewMode: mode })
|
||||||
await triggerRebuild(updated)
|
await triggerRebuild(updated)
|
||||||
res.json(await view(await getDocument(col(), doc.$id)))
|
res.json(await view(await getDocument(col(), doc.$id)))
|
||||||
|
|||||||
@@ -540,6 +540,7 @@ export async function syncPreviewTlsOnStartup({
|
|||||||
// ---- Preview-Runner (PREVIEW_RUNNER=on) ----
|
// ---- Preview-Runner (PREVIEW_RUNNER=on) ----
|
||||||
|
|
||||||
// Zeiten des Hintergrund-Verfolgers (fuer Tests ueberschreibbar)
|
// Zeiten des Hintergrund-Verfolgers (fuer Tests ueberschreibbar)
|
||||||
|
const NO_DEV_SCRIPT_ERROR = 'Kein dev-Skript in package.json'
|
||||||
export const runnerTiming = { pollMs: 2000, maxMs: 60 * 60 * 1000, unreachableMs: 5 * 60 * 1000 }
|
export const runnerTiming = { pollMs: 2000, maxMs: 60 * 60 * 1000, unreachableMs: 5 * 60 * 1000 }
|
||||||
// Neuester Lauf je Repo: aeltere Verfolger schreiben nichts mehr (R14)
|
// Neuester Lauf je Repo: aeltere Verfolger schreiben nichts mehr (R14)
|
||||||
const latestRun = new Map()
|
const latestRun = new Map()
|
||||||
@@ -679,7 +680,9 @@ async function followRun({ repoFullName, project, token, runId, subdomain, job,
|
|||||||
} else {
|
} else {
|
||||||
await patchProject(repoFullName, project, {
|
await patchProject(repoFullName, project, {
|
||||||
previewState: 'failed',
|
previewState: 'failed',
|
||||||
previewError: maskLog(failure || run?.error || 'Preview-Build fehlgeschlagen', env),
|
previewError: run?.error === NO_DEV_SCRIPT_ERROR && !failure
|
||||||
|
? 'Dev-Modus hier nicht möglich – statische Version ist online'
|
||||||
|
: maskLog(failure || run?.error || 'Preview-Build fehlgeschlagen', env),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!current()) return
|
if (!current()) return
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Prueft, ob ein Repo-Branch ein dev-Skript in package.json hat (Dev-Modus nur dann sinnvoll).
|
||||||
|
import { giteaFetch, splitRepo } from './giteaClient.js'
|
||||||
|
|
||||||
|
export const cacheTtlMs = 60 * 1000
|
||||||
|
const cache = new Map()
|
||||||
|
|
||||||
|
export function clearDevScriptCache() { cache.clear() }
|
||||||
|
|
||||||
|
/** true = dev-Skript vorhanden, false = kein package.json oder kein dev-Skript, null = unbekannt (Gitea-Fehler). */
|
||||||
|
export async function repoHasDevScript(repoFullName, branch) {
|
||||||
|
const key = `${repoFullName}@${branch}`
|
||||||
|
const hit = cache.get(key)
|
||||||
|
if (hit && Date.now() - hit.at < cacheTtlMs) return hit.value
|
||||||
|
const value = await lookup(repoFullName, branch)
|
||||||
|
cache.set(key, { at: Date.now(), value })
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lookup(repoFullName, branch) {
|
||||||
|
const [owner, repo] = splitRepo(repoFullName)
|
||||||
|
if (!owner || !repo) return null
|
||||||
|
let data
|
||||||
|
try {
|
||||||
|
data = await giteaFetch(`/repos/${owner}/${repo}/contents/package.json?ref=${encodeURIComponent(branch)}`)
|
||||||
|
} catch (err) {
|
||||||
|
return err?.status === 404 ? false : null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(Buffer.from(data?.content || '', 'base64').toString('utf8'))
|
||||||
|
const dev = pkg?.scripts?.dev
|
||||||
|
return typeof dev === 'string' && dev.trim() !== ''
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,10 +8,11 @@ import crypto from 'node:crypto'
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
|
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
|
||||||
|
|
||||||
let aw, runner, gitea, app, base, tmp, cfg, envLib, logs
|
let devScript, aw, runner, gitea, app, base, tmp, cfg, envLib, logs
|
||||||
const key = crypto.randomBytes(32)
|
const key = crypto.randomBytes(32)
|
||||||
const SHA = 'c'.repeat(40)
|
const SHA = 'c'.repeat(40)
|
||||||
const rs = { calls: [], busy: false, slow: false, result: 'static' }
|
const rs = { calls: [], busy: false, slow: false, result: 'static', failError: null }
|
||||||
|
const gs = { pkg: undefined, status: 200 } // pkg: undefined = Standardantwort, null = 404, Objekt = package.json
|
||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
const seed = [
|
const seed = [
|
||||||
@@ -34,6 +35,7 @@ before(async () => {
|
|||||||
rs.calls.push({ method: req.method, url: req.url, body: raw ? JSON.parse(raw) : null })
|
rs.calls.push({ method: req.method, url: req.url, body: raw ? JSON.parse(raw) : null })
|
||||||
res.setHeader('Content-Type', 'application/json')
|
res.setHeader('Content-Type', 'application/json')
|
||||||
if (req.method === 'POST' && req.url === '/jobs/build') return res.end(JSON.stringify({ runId: 'run-x' }))
|
if (req.method === 'POST' && req.url === '/jobs/build') return res.end(JSON.stringify({ runId: 'run-x' }))
|
||||||
|
if (req.method === 'GET' && req.url.startsWith('/jobs/') && rs.failError) return res.end(JSON.stringify({ state: 'failed', result: null, log: [], error: rs.failError }))
|
||||||
if (req.method === 'GET' && req.url.startsWith('/jobs/')) return res.end(JSON.stringify(rs.slow ? { state: 'running' } : { state: 'done', detected: { kind: 'static', pm: 'npm' }, sha: 'b'.repeat(40), log: ['ok'], error: null, result: rs.result }))
|
if (req.method === 'GET' && req.url.startsWith('/jobs/')) return res.end(JSON.stringify(rs.slow ? { state: 'running' } : { state: 'done', detected: { kind: 'static', pm: 'npm' }, sha: 'b'.repeat(40), log: ['ok'], error: null, result: rs.result }))
|
||||||
if (req.url.endsWith('/start')) return res.end(JSON.stringify({ state: 'starting' }))
|
if (req.url.endsWith('/start')) return res.end(JSON.stringify({ state: 'starting' }))
|
||||||
res.end(JSON.stringify({ state: 'sleeping', ok: true }))
|
res.end(JSON.stringify({ state: 'sleeping', ok: true }))
|
||||||
@@ -42,6 +44,11 @@ before(async () => {
|
|||||||
await new Promise((r) => runner.listen(0, '127.0.0.1', r))
|
await new Promise((r) => runner.listen(0, '127.0.0.1', r))
|
||||||
gitea = http.createServer((req, res) => {
|
gitea = http.createServer((req, res) => {
|
||||||
res.setHeader('Content-Type', 'application/json')
|
res.setHeader('Content-Type', 'application/json')
|
||||||
|
if (req.url.startsWith('/api/v1/repos/') && req.url.includes('/contents/package.json') && gs.pkg !== undefined) {
|
||||||
|
if (gs.status !== 200) { res.statusCode = gs.status; return res.end(JSON.stringify({ message: 'x' })) }
|
||||||
|
if (gs.pkg === null) { res.statusCode = 404; return res.end(JSON.stringify({ message: 'not found' })) }
|
||||||
|
return res.end(JSON.stringify({ content: Buffer.from(JSON.stringify(gs.pkg)).toString('base64') }))
|
||||||
|
}
|
||||||
res.end(JSON.stringify({ commit: { id: 'b'.repeat(40) }, default_branch: 'main' }))
|
res.end(JSON.stringify({ commit: { id: 'b'.repeat(40) }, default_branch: 'main' }))
|
||||||
})
|
})
|
||||||
await new Promise((r) => gitea.listen(0, '127.0.0.1', r))
|
await new Promise((r) => gitea.listen(0, '127.0.0.1', r))
|
||||||
@@ -62,6 +69,7 @@ before(async () => {
|
|||||||
cfg.previewRunner.logDir = path.join(tmp, 'logs')
|
cfg.previewRunner.logDir = path.join(tmp, 'logs')
|
||||||
envLib = await import('../server/services/previewEnv.js')
|
envLib = await import('../server/services/previewEnv.js')
|
||||||
logs = await import('../server/services/previewLogs.js')
|
logs = await import('../server/services/previewLogs.js')
|
||||||
|
devScript = await import('../server/services/previewDevScript.js')
|
||||||
const pd = await import('../server/services/previewDeploy.js')
|
const pd = await import('../server/services/previewDeploy.js')
|
||||||
pd.runnerTiming.pollMs = 10
|
pd.runnerTiming.pollMs = 10
|
||||||
const { default: preview } = await import('../server/routes/admin/preview.js')
|
const { default: preview } = await import('../server/routes/admin/preview.js')
|
||||||
@@ -88,7 +96,7 @@ after(async () => {
|
|||||||
await fs.rm(tmp, { recursive: true, force: true })
|
await fs.rm(tmp, { recursive: true, force: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
beforeEach(() => { rs.busy = false; rs.slow = false; rs.result = 'static'; rs.calls = []; cfg.previewRunner.enabled = true; process.env.PREVIEW_ENV_KEY = key.toString('base64') })
|
beforeEach(() => { gs.pkg = undefined; gs.status = 200; rs.failError = null; devScript.clearDevScriptCache(); rs.busy = false; rs.slow = false; rs.result = 'static'; rs.calls = []; cfg.previewRunner.enabled = true; process.env.PREVIEW_ENV_KEY = key.toString('base64') })
|
||||||
|
|
||||||
const call = (method, p, body, jwt = 'jwt-admin') =>
|
const call = (method, p, body, jwt = 'jwt-admin') =>
|
||||||
fetch(`${base}${p}`, {
|
fetch(`${base}${p}`, {
|
||||||
@@ -108,7 +116,7 @@ test('GET: Form, leerer Modus = auto, 404 und keine Subdomain', async () => {
|
|||||||
const r = await call('GET', '/pw/preview')
|
const r = await call('GET', '/pw/preview')
|
||||||
assert.equal(r.status, 200)
|
assert.equal(r.status, 200)
|
||||||
assert.deepEqual(await r.json(), {
|
assert.deepEqual(await r.json(), {
|
||||||
mode: 'auto', state: 'online', detected: 'static', builtSha: SHA, builtAt: '', error: '', branch: 'main', envNames: [], logs: [],
|
mode: 'auto', state: 'online', detected: 'static', builtSha: SHA, builtAt: '', error: '', branch: 'main', envNames: [], logs: [], devAvailable: null,
|
||||||
})
|
})
|
||||||
assert.equal((await call('GET', '/gibtsnicht/preview')).status, 404)
|
assert.equal((await call('GET', '/gibtsnicht/preview')).status, 404)
|
||||||
const r2 = await call('GET', '/pnosub/preview')
|
const r2 = await call('GET', '/pnosub/preview')
|
||||||
@@ -293,3 +301,44 @@ test('Runner-Schutzschalter 503: rebuild und dev/start melden Server ausgelastet
|
|||||||
assert.deepEqual(await r.json(), { error: 'Server ausgelastet, versuche es gleich noch mal' })
|
assert.deepEqual(await r.json(), { error: 'Server ausgelastet, versuche es gleich noch mal' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('GET: devAvailable true/false/null', async () => {
|
||||||
|
gs.pkg = { scripts: { dev: 'vite' } }
|
||||||
|
assert.equal((await (await call('GET', '/pw/preview')).json()).devAvailable, true)
|
||||||
|
devScript.clearDevScriptCache()
|
||||||
|
gs.pkg = null
|
||||||
|
assert.equal((await (await call('GET', '/pw/preview')).json()).devAvailable, false)
|
||||||
|
devScript.clearDevScriptCache()
|
||||||
|
gs.pkg = {}; gs.status = 500
|
||||||
|
assert.equal((await (await call('GET', '/pw/preview')).json()).devAvailable, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('PATCH dev ohne dev-Skript: 400, Modus bleibt, kein Neubau', async () => {
|
||||||
|
gs.pkg = null
|
||||||
|
const r = await call('PATCH', '/pr/preview', { mode: 'dev' })
|
||||||
|
assert.equal(r.status, 400)
|
||||||
|
assert.equal((await r.json()).error, 'Dieses Projekt hat kein dev-Skript. Reines HTML braucht keinen Dev-Server – Änderungen erscheinen nach jedem Push automatisch.')
|
||||||
|
assert.notEqual(aw.get('websiteProjects', 'pr').previewMode, 'dev')
|
||||||
|
assert.equal(rs.calls.filter((c) => c.url === '/jobs/build').length, 0)
|
||||||
|
devScript.clearDevScriptCache()
|
||||||
|
gs.pkg = { scripts: { build: 'x' } }
|
||||||
|
assert.equal((await call('PATCH', '/pr/preview', { mode: 'dev' })).status, 400)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('PATCH dev erlaubt bei dev-Skript und bei Gitea-Fehler (null)', async () => {
|
||||||
|
gs.pkg = { scripts: { dev: 'vite' } }
|
||||||
|
assert.equal((await call('PATCH', '/pe/preview', { mode: 'dev' })).status, 200)
|
||||||
|
assert.equal(aw.get('websiteProjects', 'pe').previewMode, 'dev')
|
||||||
|
devScript.clearDevScriptCache()
|
||||||
|
gs.pkg = {}; gs.status = 500
|
||||||
|
assert.equal((await call('PATCH', '/pw/preview', { mode: 'dev' })).status, 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Verfolger: Kein dev-Skript => freundliche Meldung, Zustand failed', async () => {
|
||||||
|
rs.failError = 'Kein dev-Skript in package.json'
|
||||||
|
assert.equal((await call('PATCH', '/pe/preview', { mode: 'build' })).status, 200)
|
||||||
|
for (let i = 0; i < 100 && aw.get('websiteProjects', 'pe').previewState !== 'failed'; i++) await new Promise((r) => setTimeout(r, 20))
|
||||||
|
const d = aw.get('websiteProjects', 'pe')
|
||||||
|
assert.equal(d.previewState, 'failed')
|
||||||
|
assert.equal(d.previewError, 'Dev-Modus hier nicht möglich – statische Version ist online')
|
||||||
|
})
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { test, before, after, beforeEach } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import http from 'node:http'
|
||||||
|
|
||||||
|
let srv, mod, hits
|
||||||
|
const g = { status: 200, body: null }
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
srv = http.createServer((req, res) => {
|
||||||
|
hits++
|
||||||
|
res.setHeader('Content-Type', 'application/json')
|
||||||
|
res.statusCode = g.status
|
||||||
|
res.end(JSON.stringify(g.body))
|
||||||
|
})
|
||||||
|
await new Promise((r) => srv.listen(0, '127.0.0.1', r))
|
||||||
|
process.env.GITEA_BASE_URL = `http://127.0.0.1:${srv.address().port}`
|
||||||
|
process.env.GITEA_API_TOKEN = 'tok'
|
||||||
|
mod = await import('../server/services/previewDevScript.js')
|
||||||
|
})
|
||||||
|
after(() => { srv.closeAllConnections(); srv.close() })
|
||||||
|
beforeEach(() => { hits = 0; g.status = 200; mod.clearDevScriptCache() })
|
||||||
|
|
||||||
|
const pkg = (o) => ({ content: Buffer.from(JSON.stringify(o)).toString('base64') })
|
||||||
|
|
||||||
|
test('dev-Skript vorhanden => true', async () => {
|
||||||
|
g.body = pkg({ scripts: { dev: 'vite' } })
|
||||||
|
assert.equal(await mod.repoHasDevScript('o/r', 'main'), true)
|
||||||
|
})
|
||||||
|
test('package.json fehlt (404) => false', async () => {
|
||||||
|
g.status = 404; g.body = { message: 'nf' }
|
||||||
|
assert.equal(await mod.repoHasDevScript('o/r', 'main'), false)
|
||||||
|
})
|
||||||
|
test('kein oder leeres dev-Skript => false', async () => {
|
||||||
|
g.body = pkg({ scripts: { build: 'x' } })
|
||||||
|
assert.equal(await mod.repoHasDevScript('o/r', 'main'), false)
|
||||||
|
mod.clearDevScriptCache()
|
||||||
|
g.body = pkg({ scripts: { dev: ' ' } })
|
||||||
|
assert.equal(await mod.repoHasDevScript('o/r', 'main'), false)
|
||||||
|
})
|
||||||
|
test('Gitea-Fehler => null', async () => {
|
||||||
|
g.status = 500; g.body = { message: 'boom' }
|
||||||
|
assert.equal(await mod.repoHasDevScript('o/r', 'main'), null)
|
||||||
|
})
|
||||||
|
test('Cache 60 s je Repo+Branch', async () => {
|
||||||
|
g.body = pkg({ scripts: { dev: 'vite' } })
|
||||||
|
await mod.repoHasDevScript('o/r', 'main')
|
||||||
|
await mod.repoHasDevScript('o/r', 'main')
|
||||||
|
assert.equal(hits, 1)
|
||||||
|
await mod.repoHasDevScript('o/r', 'other')
|
||||||
|
assert.equal(hits, 2)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user