@uniweb/build 1.8.5 → 1.9.1

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.
@@ -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