@uniweb/build 1.8.4 → 1.9.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.
- package/package.json +8 -8
- package/src/hosts/detect-ci-context.js +17 -1
- package/src/hosts/github-pages.js +32 -32
- package/src/i18n/audit.js +0 -14
- package/src/i18n/extract.js +4 -16
- package/src/i18n/freeform.js +1 -19
- package/src/i18n/merge.js +1 -12
- package/src/i18n/records.js +6 -10
- package/src/i18n/sync.js +14 -6
- package/src/prerender.js +30 -2
- package/src/site/build-site-data.js +16 -5
- package/src/site/config.js +10 -1
- package/src/site/content-collector.js +4 -20
- package/src/site/dev-server.js +263 -0
- package/src/site/index.js +4 -0
- package/src/site/plugin.js +90 -370
- package/src/site/query-processor.js +11 -27
- package/src/site/record-projections.js +77 -0
- package/src/site/seo-files.js +190 -0
- package/src/site/site-address.js +127 -0
- package/src/uwx/site.js +2 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHERE A SITE'S DEV SERVER IS — stated by the dev server `uniweb dev` starts, once it listens, and
|
|
3
|
+
* read by `uniweb dev`.
|
|
4
|
+
*
|
|
5
|
+
* Whoever starts a dev server needs its address, and one guessed from a port is worse than none:
|
|
6
|
+
* Vite's 5173 may be another project's server, and a review of the wrong site reports success. So
|
|
7
|
+
* the address is the server's to state and everyone else's to read, as a serve location is. The
|
|
8
|
+
* dev server writes the address it was given (Vite's `resolvedUrls`) to the site's `.uniweb/`,
|
|
9
|
+
* which the templates gitignore, and removes it when it closes:
|
|
10
|
+
*
|
|
11
|
+
* .uniweb/dev-server.json { url, port, pid, token, startedAt }
|
|
12
|
+
*
|
|
13
|
+
* `uniweb dev` reads it: to say where the server is (`--status`), to leave a running one alone
|
|
14
|
+
* rather than start a second on another port, and to stop it (`--stop`).
|
|
15
|
+
*
|
|
16
|
+
* ⭐ ONLY THE SERVER `uniweb dev` STARTS KEEPS A RECORD (`DEV_RECORD_ENV`). A dev server started for a
|
|
17
|
+
* tool's own use is the tool's: `uniweb snapshot --dev` runs one in its own process, and while every
|
|
18
|
+
* server kept a record, a snapshot's replaced the one people were using and removed it on closing,
|
|
19
|
+
* so `--status` said none was running while it still served (measured 2026-10-25). A server started
|
|
20
|
+
* with `vite` itself keeps none either; it prints its own address.
|
|
21
|
+
*
|
|
22
|
+
* ⭐ A RECORD IS A CLAIM, CHECKED WHEN READ (`runningDevServer`). A server that did not get to close
|
|
23
|
+
* — killed, its machine restarted — leaves its record behind, and its pid may since name another
|
|
24
|
+
* process. So the record counts only while the server at its address, asked with the record's
|
|
25
|
+
* token, answers with the record's pid. One whose process is gone is removed; one that does not
|
|
26
|
+
* answer so is not believed, and the next server to listen replaces it. Nothing stops a process
|
|
27
|
+
* this has not confirmed is the site's dev server.
|
|
28
|
+
*
|
|
29
|
+
* ⭐ A STOP IS ASKED FOR, AND THE SERVER EXITS 0 (`stopDevServer`). Vite exits a SIGTERM with 143,
|
|
30
|
+
* which the package manager running the site's dev script reports as a failed script, so a
|
|
31
|
+
* signalled stop read as a crash in the server's own output. The token that asks is the record's:
|
|
32
|
+
* sent to the server and never served by it, so a page that can reach the route — it is answered
|
|
33
|
+
* before Vite's CORS and host checks — learns nothing from it and cannot stop the server.
|
|
34
|
+
*
|
|
35
|
+
* ⭐ A PORT IS THE DEV SERVER'S TO CHOOSE: Vite takes the next free one when its default is busy.
|
|
36
|
+
* `uniweb dev --port <n>` asks for one, in `DEV_PORT_ENV`, and gets exactly that one or a refusal
|
|
37
|
+
* (`strictPort`), never another (`config.js`).
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
41
|
+
import { request as httpRequest } from 'node:http'
|
|
42
|
+
import { request as httpsRequest } from 'node:https'
|
|
43
|
+
import { randomUUID, timingSafeEqual } from 'node:crypto'
|
|
44
|
+
import { dirname, join } from 'node:path'
|
|
45
|
+
|
|
46
|
+
/** The dev server's record, under the site's `.uniweb/`. */
|
|
47
|
+
export const DEV_SERVER_RECORD = join('.uniweb', 'dev-server.json')
|
|
48
|
+
|
|
49
|
+
/** Where the dev server, asked with its record's token, says who it is (`GET`) and stops (`POST /stop`). */
|
|
50
|
+
export const DEV_SERVER_ROUTE = '/@uniweb/dev-server'
|
|
51
|
+
|
|
52
|
+
/** The request header that carries the record's token. */
|
|
53
|
+
const TOKEN_HEADER = 'x-uniweb-dev-token'
|
|
54
|
+
|
|
55
|
+
/** Set by `uniweb dev` in the environment of the dev server it starts — the one that keeps a record. */
|
|
56
|
+
export const DEV_RECORD_ENV = 'UNIWEB_DEV_RECORD'
|
|
57
|
+
|
|
58
|
+
/** The port `uniweb dev --port <n>` asks for, in the dev server's environment. */
|
|
59
|
+
export const DEV_PORT_ENV = 'UNIWEB_DEV_PORT'
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The port `DEV_PORT_ENV` asks for, or null when it asks for none.
|
|
63
|
+
*
|
|
64
|
+
* @param {Object} [env]
|
|
65
|
+
* @returns {number|null}
|
|
66
|
+
*/
|
|
67
|
+
export function requestedDevPort(env = process.env) {
|
|
68
|
+
const value = env[DEV_PORT_ENV]
|
|
69
|
+
if (value === undefined || value === '') return null
|
|
70
|
+
const port = Number(value)
|
|
71
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
72
|
+
throw new Error(`${DEV_PORT_ENV} is "${value}", which is not a port (1–65535).`)
|
|
73
|
+
}
|
|
74
|
+
return port
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The record at `siteRoot` as written, or null. Unchecked — `runningDevServer` checks it. */
|
|
78
|
+
function readRecord(siteRoot) {
|
|
79
|
+
try {
|
|
80
|
+
const record = JSON.parse(readFileSync(join(siteRoot, DEV_SERVER_RECORD), 'utf8'))
|
|
81
|
+
return record && typeof record.url === 'string' && Number.isInteger(record.pid) && typeof record.token === 'string'
|
|
82
|
+
? record
|
|
83
|
+
: null
|
|
84
|
+
} catch {
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function writeRecord(siteRoot, record) {
|
|
90
|
+
const path = join(siteRoot, DEV_SERVER_RECORD)
|
|
91
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
92
|
+
// Written whole, then moved into place, so a reader never sees half a record. Its owner's to
|
|
93
|
+
// read, since its token stops the server.
|
|
94
|
+
const temp = `${path}.${process.pid}.tmp`
|
|
95
|
+
writeFileSync(temp, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 })
|
|
96
|
+
renameSync(temp, path)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Remove the record, if it is still the one `pid` wrote — a newer server's is left alone.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} siteRoot
|
|
103
|
+
* @param {number} pid
|
|
104
|
+
*/
|
|
105
|
+
export function removeDevServerRecord(siteRoot, pid) {
|
|
106
|
+
if (readRecord(siteRoot)?.pid === pid) rmSync(join(siteRoot, DEV_SERVER_RECORD), { force: true })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Ask the server at the record's address, with the record's token: who it is (`GET`), or to stop
|
|
111
|
+
* (`POST /stop`). Its JSON answer, or null when nothing answers so.
|
|
112
|
+
*/
|
|
113
|
+
function callDevServer(record, { method = 'GET', path = '', timeout = 2000 } = {}) {
|
|
114
|
+
return new Promise((resolve) => {
|
|
115
|
+
let target
|
|
116
|
+
try {
|
|
117
|
+
target = new URL(DEV_SERVER_ROUTE + path, record.url)
|
|
118
|
+
} catch {
|
|
119
|
+
return resolve(null)
|
|
120
|
+
}
|
|
121
|
+
const request = target.protocol === 'https:' ? httpsRequest : httpRequest
|
|
122
|
+
// A dev server's certificate is its own, so it is not verified: the answer is the check. One
|
|
123
|
+
// request, on a connection of its own, so no kept-alive socket outlives the answer.
|
|
124
|
+
const call = request(
|
|
125
|
+
target,
|
|
126
|
+
{ method, timeout, headers: { [TOKEN_HEADER]: record.token }, rejectUnauthorized: false, agent: false },
|
|
127
|
+
(response) => {
|
|
128
|
+
let body = ''
|
|
129
|
+
response.setEncoding('utf8')
|
|
130
|
+
response.on('data', (chunk) => { body += chunk })
|
|
131
|
+
response.on('end', () => {
|
|
132
|
+
if (response.statusCode >= 300) return resolve(null)
|
|
133
|
+
try {
|
|
134
|
+
resolve(JSON.parse(body))
|
|
135
|
+
} catch {
|
|
136
|
+
resolve(null)
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
call.on('timeout', () => call.destroy())
|
|
142
|
+
call.on('error', () => resolve(null))
|
|
143
|
+
call.end()
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Whether a request carries `token`, compared as a secret is: in constant time. */
|
|
148
|
+
function carriesToken(req, token) {
|
|
149
|
+
const given = Buffer.from(String(req.headers[TOKEN_HEADER] ?? ''))
|
|
150
|
+
const expected = Buffer.from(token)
|
|
151
|
+
return given.length === expected.length && timingSafeEqual(given, expected)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isAlive(pid) {
|
|
155
|
+
try {
|
|
156
|
+
process.kill(pid, 0)
|
|
157
|
+
return true
|
|
158
|
+
} catch (err) {
|
|
159
|
+
return err?.code === 'EPERM' // alive, and someone else's
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The site's running dev server — its record — or null. Confirmed by asking the server at the
|
|
165
|
+
* record's address who it is. A record whose process is gone is removed; one no server confirms
|
|
166
|
+
* is left for the next server to replace, since a busy one may answer late.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} siteRoot
|
|
169
|
+
* @param {{ timeout?: number }} [options]
|
|
170
|
+
* @returns {Promise<{ url: string, port: number|null, pid: number, token: string, startedAt: string }|null>}
|
|
171
|
+
*/
|
|
172
|
+
export async function runningDevServer(siteRoot, { timeout = 2000 } = {}) {
|
|
173
|
+
const record = readRecord(siteRoot)
|
|
174
|
+
if (!record) return null
|
|
175
|
+
if (!isAlive(record.pid)) {
|
|
176
|
+
removeDevServerRecord(siteRoot, record.pid)
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
const answer = await callDevServer(record, { timeout })
|
|
180
|
+
return answer?.pid === record.pid ? record : null
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Stop the site's running dev server, once it has confirmed it is one (`runningDevServer`). Asked,
|
|
185
|
+
* it closes and exits 0; one that does not take the request is sent SIGTERM, which Vite exits
|
|
186
|
+
* with 143.
|
|
187
|
+
*
|
|
188
|
+
* @param {string} siteRoot
|
|
189
|
+
* @param {{ wait?: number }} [options] - how long to wait for it to exit, in ms
|
|
190
|
+
* @returns {Promise<{ stopped: Object }|{ stuck: Object }|{ none: true }>}
|
|
191
|
+
*/
|
|
192
|
+
export async function stopDevServer(siteRoot, { wait = 5000 } = {}) {
|
|
193
|
+
const running = await runningDevServer(siteRoot)
|
|
194
|
+
if (!running) return { none: true }
|
|
195
|
+
const asked = await callDevServer(running, { method: 'POST', path: '/stop' })
|
|
196
|
+
if (!asked?.stopping) {
|
|
197
|
+
try {
|
|
198
|
+
process.kill(running.pid, 'SIGTERM')
|
|
199
|
+
} catch {
|
|
200
|
+
// Gone between the check and the signal.
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
for (const until = Date.now() + wait; isAlive(running.pid) && Date.now() < until; ) {
|
|
204
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
205
|
+
}
|
|
206
|
+
if (isAlive(running.pid)) return { stuck: running }
|
|
207
|
+
removeDevServerRecord(siteRoot, running.pid)
|
|
208
|
+
return { stopped: running }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Close the server and exit, as Vite does on SIGTERM — with 0, since this stop was asked for. */
|
|
212
|
+
async function closeAndExit(server) {
|
|
213
|
+
server.config.logger.info('Stopped: `uniweb dev --stop` asked.')
|
|
214
|
+
try {
|
|
215
|
+
await server.close()
|
|
216
|
+
} finally {
|
|
217
|
+
process.exit(0)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The plugin that keeps the record, in the server `uniweb dev` starts (`DEV_RECORD_ENV`): it
|
|
223
|
+
* answers `DEV_SERVER_ROUTE`, and writes the record once the server listens — `listen` is what
|
|
224
|
+
* sets `resolvedUrls`, so the record is written when it resolves — and removes it when the server
|
|
225
|
+
* closes.
|
|
226
|
+
*
|
|
227
|
+
* @param {string} siteRoot
|
|
228
|
+
* @param {{ env?: Object }} [options] - the environment that says whether `uniweb dev` started it
|
|
229
|
+
* @returns {import('vite').Plugin}
|
|
230
|
+
*/
|
|
231
|
+
export function devServerRecordPlugin(siteRoot, { env = process.env } = {}) {
|
|
232
|
+
return {
|
|
233
|
+
name: 'uniweb:dev-server-record',
|
|
234
|
+
apply: (_config, { command }) => command === 'serve' && env[DEV_RECORD_ENV] === '1',
|
|
235
|
+
configureServer(server) {
|
|
236
|
+
const token = randomUUID()
|
|
237
|
+
server.middlewares.use(DEV_SERVER_ROUTE, (req, res) => {
|
|
238
|
+
res.setHeader('Content-Type', 'application/json')
|
|
239
|
+
if (!carriesToken(req, token)) {
|
|
240
|
+
res.statusCode = 403
|
|
241
|
+
return res.end('{}')
|
|
242
|
+
}
|
|
243
|
+
if (req.method === 'POST' && req.url === '/stop') {
|
|
244
|
+
res.statusCode = 202
|
|
245
|
+
// Answered before it closes, so the caller hears it is stopping.
|
|
246
|
+
return res.end(JSON.stringify({ stopping: true }), () => closeAndExit(server))
|
|
247
|
+
}
|
|
248
|
+
res.end(JSON.stringify({ pid: process.pid }))
|
|
249
|
+
})
|
|
250
|
+
const listen = server.listen.bind(server)
|
|
251
|
+
server.listen = async (...args) => {
|
|
252
|
+
const listening = await listen(...args)
|
|
253
|
+
const url = server.resolvedUrls?.local?.[0]
|
|
254
|
+
if (url) {
|
|
255
|
+
const port = server.httpServer?.address()?.port ?? null
|
|
256
|
+
writeRecord(siteRoot, { url, port, pid: process.pid, token, startedAt: new Date().toISOString() })
|
|
257
|
+
server.httpServer?.once('close', () => removeDevServerRecord(siteRoot, process.pid))
|
|
258
|
+
}
|
|
259
|
+
return listening
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
}
|
package/src/site/index.js
CHANGED
|
@@ -10,6 +10,10 @@ export { siteContentPlugin } from './plugin.js'
|
|
|
10
10
|
export { defineSiteConfig, readSiteConfig, default } from './config.js'
|
|
11
11
|
// What `uniweb dev` hands the dev server of a site a backend holds (`./preview.js`).
|
|
12
12
|
export { PREVIEW_ENV, encodePreview, readPreview } from './preview.js'
|
|
13
|
+
// Where a site's dev server listens, as it states it, for `uniweb dev` (`./dev-server.js`).
|
|
14
|
+
export { DEV_PORT_ENV, DEV_RECORD_ENV, runningDevServer, stopDevServer } from './dev-server.js'
|
|
15
|
+
// Where a statically built site lives, as its deploy target or author states it (`./site-address.js`).
|
|
16
|
+
export { ORIGIN_ENV, refuseUnreadableAddress } from './site-address.js'
|
|
13
17
|
export { collectSiteContent } from './content-collector.js'
|
|
14
18
|
// The `agents:` vocabulary lives in @uniweb/projections (which owns the block);
|
|
15
19
|
// re-exported so the CLI can validate an author's spelling, and read an
|