tosijs-ui 1.8.0 → 1.9.0-beta.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.
- package/CHANGELOG.md +54 -0
- package/dist/doc-browser.js +44 -0
- package/dist/doc-system/site/build-stamp.d.ts +23 -0
- package/dist/doc-system/site/build-stamp.js +65 -0
- package/dist/doc-system/site/dev-server.d.ts +18 -0
- package/dist/doc-system/site/dev-server.js +146 -9
- package/dist/doc-system/site/orchestrator.js +6 -0
- package/dist/doc-system/site/site-config.d.ts +25 -0
- package/dist/hydrate/hydrate.js +54 -53
- package/dist/hydrate/hydrate.js.map +4 -4
- package/dist/iife.js +72 -71
- package/dist/iife.js.map +4 -4
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/llms.txt +1 -1
- package/package.json +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,49 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.9.0-beta.1
|
|
4
|
+
|
|
5
|
+
> **Prerelease.** The preview-host system works end to end and is in daily use, but the
|
|
6
|
+
> plan it belongs to (`REMOTE-ACCESS-PLAN.md`) has two more phases. Published under the
|
|
7
|
+
> `beta` dist-tag — **install with `tosijs-ui@beta`**; `latest` stays on 1.8.0.
|
|
8
|
+
>
|
|
9
|
+
> It's a **minor**, not a patch, despite being unfinished: it adds public API
|
|
10
|
+
> (`SiteConfig.preview`), emits a new artifact into every adopter's output
|
|
11
|
+
> (`/version.json`), and changes what happens when a build fails. Semver tracks the
|
|
12
|
+
> shape of a change, not how done it feels.
|
|
13
|
+
|
|
14
|
+
- **New: preview hosting.** `bun run deploy` rsyncs the built site to a box you control,
|
|
15
|
+
so a phone, a client, or a reviewer can see it without your dev server — or your
|
|
16
|
+
laptop — being up. The whole artifact is a few MB, so this is a copy, not a pipeline.
|
|
17
|
+
- **`preview: { host, path?, url? }`** in the site config. Only `host` is required;
|
|
18
|
+
`path` defaults to `/srv/preview/<name>`. Flag > env > config, so a one-off push
|
|
19
|
+
elsewhere doesn't need a config edit.
|
|
20
|
+
- **Dry run by default.** It runs `rsync --delete` (the remote must mirror the build,
|
|
21
|
+
stale pages must not linger), so the destructive form has to be typed: `--go`. It
|
|
22
|
+
also refuses any target that isn't an absolute path at least two levels deep.
|
|
23
|
+
- **Projects self-register.** Each deploy writes its own Caddy fragment; the server
|
|
24
|
+
glob-imports them. Adding a project touches no shared config and needs no DNS
|
|
25
|
+
change. The deploy **validates before reloading** and refuses to reload on invalid
|
|
26
|
+
config, so one bad fragment can't take down every project on the box.
|
|
27
|
+
- `deploy/Caddyfile` and `deploy/build-index.sh` are committed — server config in git
|
|
28
|
+
beats server config in someone's shell history.
|
|
29
|
+
- **New: `/version.json`** in every build — `{ generator, site, commit, commitTime }`.
|
|
30
|
+
Nothing exposed which commit produced a given site before. It matters wherever a
|
|
31
|
+
deploy is a snapshot: a reviewer can otherwise report a bug you fixed this morning
|
|
32
|
+
with no way to tell from the page which of you is stale.
|
|
33
|
+
**Deliberately deterministic** — no build timestamp, no dirty flag — so a committed
|
|
34
|
+
output dir doesn't churn on every build. Git fields are omitted (never blank) when
|
|
35
|
+
git isn't available.
|
|
36
|
+
- **Fix: a failed rebuild no longer destroys the site.** `buildSite()` opens with
|
|
37
|
+
`rm -rf <outputDir>`, so a build failing after that left nothing to serve — save a
|
|
38
|
+
typo, and the next refresh was a dead site. The dev server now moves the working
|
|
39
|
+
build aside first and restores it on failure, **and says so on the page**: the
|
|
40
|
+
existing floating widget (which already means "the far end has something to tell
|
|
41
|
+
you") shows the failure until a good build clears it, with detail in the console.
|
|
42
|
+
Note it survives the "tests disabled" preference — otherwise the one person who
|
|
43
|
+
turned test indicators off is the one who never learns their build is broken.
|
|
44
|
+
- **New: `DEV_NO_WATCH=1`** serves the built site without watching, for automated
|
|
45
|
+
suites that want a server rather than a rebuilder.
|
|
46
|
+
|
|
3
47
|
## 1.8.0
|
|
4
48
|
|
|
5
49
|
> **Read this first if you use `tosijs-ui/site`.** This release adds a **dependency-audit
|
|
@@ -47,6 +91,16 @@
|
|
|
47
91
|
filename with a raw NUL byte, which makes macOS `grep` silently match **nothing** in that
|
|
48
92
|
file (exit 1, no message) — so searching it for any symbol came back empty and the sort bug
|
|
49
93
|
above was effectively unfindable. The comparator needs no delimiter; the NUL is gone.
|
|
94
|
+
- **Adopting the gate — budget for one upgrade, not one pin.** From the first real
|
|
95
|
+
adoption (`tosijs`, which went from 12 blocking advisories to zero): if the gate flags
|
|
96
|
+
**`brace-expansion`**, note that `GHSA-mh99-v99m-4gvg` marks *every* version below
|
|
97
|
+
**5.0.8** affected, so the override is mandatory — and `brace-expansion@5` breaks
|
|
98
|
+
**eslint 8**'s bundled `minimatch@3` with `expand is not a function`. In practice that
|
|
99
|
+
means **an eslint 8 → 10 migration** (flat config + typescript-eslint 8), not a one-line
|
|
100
|
+
pin. The upside is that it clears the whole `minimatch`/`js-yaml`/`flatted` cluster at
|
|
101
|
+
once — which is exactly what the advisories-per-package tally is telling you when it
|
|
102
|
+
fingers a stale toolchain. Expect "replace, don't patch" to be the right answer more
|
|
103
|
+
often than a targeted override.
|
|
50
104
|
- **New: `openBrowser`** site-config option — opens the doc site on dev-server start, reusing
|
|
51
105
|
an existing tab on macOS rather than piling up new ones.
|
|
52
106
|
- **New: `DEV_NO_WATCH=1`** serves the built site without watching. The E2E lane used to run
|
package/dist/doc-browser.js
CHANGED
|
@@ -191,6 +191,10 @@ const testIndicatorStyleSpec = {
|
|
|
191
191
|
'body:not(.tests-enabled) .doc-link::after, body:not(.tests-enabled) .test-widget': {
|
|
192
192
|
display: 'none !important',
|
|
193
193
|
},
|
|
194
|
+
// ...EXCEPT a far-end status (a failed build). That is not a test result, so the
|
|
195
|
+
// "show test indicators" preference must not suppress it — otherwise the one
|
|
196
|
+
// person who turned tests off is the one who never learns their build is broken.
|
|
197
|
+
'body .test-widget.-dev-status': { display: 'flex !important' },
|
|
194
198
|
// Nav link dot indicators
|
|
195
199
|
'.doc-link.-test-passed::after, .doc-link.-test-failed::after': {
|
|
196
200
|
content: "''",
|
|
@@ -1210,8 +1214,48 @@ export function createDocBrowser(options) {
|
|
|
1210
1214
|
onClick: showTestMenu,
|
|
1211
1215
|
}, span({ part: 'label' }, 'Tests'), span({ class: 'count', part: 'count' }, '0'));
|
|
1212
1216
|
container.appendChild(testWidget);
|
|
1217
|
+
/*
|
|
1218
|
+
The far end has something to tell you.
|
|
1219
|
+
|
|
1220
|
+
The dev server injects `window.__tosiDevStatus` at serve time when a rebuild has
|
|
1221
|
+
FAILED — see statusSnippet() in site/dev-server.ts. That is the same kind of news
|
|
1222
|
+
as a failing browser test (something broke; here is what), so it uses the same
|
|
1223
|
+
floating widget rather than a second overlay competing for the same corner.
|
|
1224
|
+
|
|
1225
|
+
Two differences from a test run, both deliberate:
|
|
1226
|
+
- it does NOT auto-fade. A passing test run is over; a broken build is a standing
|
|
1227
|
+
condition you have to act on, and it stays until a good build clears it.
|
|
1228
|
+
- it wins ties. If tests also failed, the build failure is the more useful thing
|
|
1229
|
+
to read first — the tests probably failed BECAUSE the build did.
|
|
1230
|
+
|
|
1231
|
+
Nothing is injected when the server is healthy, so this is inert in production and
|
|
1232
|
+
in a normal session.
|
|
1233
|
+
*/
|
|
1234
|
+
const devStatus = globalThis.__tosiDevStatus;
|
|
1235
|
+
let devStatusShown = false;
|
|
1236
|
+
if (devStatus && devStatus.ok === false) {
|
|
1237
|
+
devStatusShown = true;
|
|
1238
|
+
const labelEl = testWidget.querySelector('[part="label"]');
|
|
1239
|
+
const countEl = testWidget.querySelector('[part="count"]');
|
|
1240
|
+
if (labelEl)
|
|
1241
|
+
labelEl.textContent = devStatus.label || 'Build failed';
|
|
1242
|
+
if (countEl)
|
|
1243
|
+
countEl.hidden = true;
|
|
1244
|
+
testWidget.classList.add('-failed', '-dev-status');
|
|
1245
|
+
testWidget.hidden = false;
|
|
1246
|
+
testWidget.title = devStatus.detail || '';
|
|
1247
|
+
// The page you are reading is the LAST GOOD build — the dev server keeps serving
|
|
1248
|
+
// it rather than the wreckage of the failed one — so say so plainly in the
|
|
1249
|
+
// console, where the detail is readable and copyable.
|
|
1250
|
+
console.error(`[tosi] ${devStatus.label || 'Build failed'} — showing the last good build.\n` +
|
|
1251
|
+
(devStatus.detail || ''));
|
|
1252
|
+
}
|
|
1213
1253
|
let testsRunning = false;
|
|
1214
1254
|
function setTestWidgetRunning() {
|
|
1255
|
+
// A broken build outranks a test run: the tests very likely failed BECAUSE the
|
|
1256
|
+
// build did, so replacing "Build failed" with "Tests" would hide the cause.
|
|
1257
|
+
if (devStatusShown)
|
|
1258
|
+
return;
|
|
1215
1259
|
testsRunning = true;
|
|
1216
1260
|
testWidget.hidden = false;
|
|
1217
1261
|
testWidget.classList.remove('-passed', '-failed');
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface BuildStamp {
|
|
2
|
+
/** the tosijs-ui version that generated this site */
|
|
3
|
+
generator: string;
|
|
4
|
+
/** the project's name, from site config */
|
|
5
|
+
site?: string;
|
|
6
|
+
/** short commit hash the site was built from, when available */
|
|
7
|
+
commit?: string;
|
|
8
|
+
/** ISO-8601 commit timestamp, when available */
|
|
9
|
+
commitTime?: string;
|
|
10
|
+
}
|
|
11
|
+
/** Injectable git seam for tests. Returns '' when the command can't run. */
|
|
12
|
+
export type GitReader = (args: string[]) => Promise<string>;
|
|
13
|
+
/**
|
|
14
|
+
* Collect build identity. Git fields are omitted (not blank) when unavailable, so
|
|
15
|
+
* `version.json` never asserts something it doesn't know.
|
|
16
|
+
*/
|
|
17
|
+
export declare function gatherBuildStamp(opts?: {
|
|
18
|
+
generator: string;
|
|
19
|
+
site?: string;
|
|
20
|
+
git?: GitReader;
|
|
21
|
+
}): Promise<BuildStamp>;
|
|
22
|
+
/** Serialize for `/version.json` (trailing newline so the file is diff-friendly). */
|
|
23
|
+
export declare function serializeBuildStamp(stamp: BuildStamp): string;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Build identity for a generated site: `/version.json`.
|
|
3
|
+
|
|
4
|
+
Answers one question — **what am I looking at?** — for any deployed copy of the site.
|
|
5
|
+
Nothing in the build exposed this before: `src/version.ts` is the *library* version,
|
|
6
|
+
which says nothing about which commit of the CONSUMER's project produced a given
|
|
7
|
+
build.
|
|
8
|
+
|
|
9
|
+
That question is easy to shrug at locally and genuinely expensive elsewhere. A static
|
|
10
|
+
deploy is a snapshot, so a preview host serves whatever was last pushed to it; someone
|
|
11
|
+
reviews it, reports a bug you fixed this morning, and there is no way to tell from the
|
|
12
|
+
page which of you is out of date. Same for a published doc site after a partial deploy.
|
|
13
|
+
|
|
14
|
+
DELIBERATELY DETERMINISTIC — no build timestamp.
|
|
15
|
+
|
|
16
|
+
`docs/` is committed in this repo (and in the other tosijs-* projects), so anything
|
|
17
|
+
that changes on every build shows up as a diff on every commit. A wall-clock stamp
|
|
18
|
+
would churn the tree forever and train everyone to ignore it — the same nuisance the
|
|
19
|
+
non-reproducible ePub already causes. Identity comes from the COMMIT instead, so
|
|
20
|
+
rebuilding the same source twice produces byte-identical output. That is also the more
|
|
21
|
+
useful answer: you want to know which source this is, not when someone happened to run
|
|
22
|
+
a build.
|
|
23
|
+
|
|
24
|
+
There is no `dirty` flag for the same reason. A build made from a dirty tree reports
|
|
25
|
+
its last commit, which may not describe what was actually built — so the warning
|
|
26
|
+
belongs at DEPLOY time, where a human can act on it, not baked into a committed file
|
|
27
|
+
that would then be permanently wrong.
|
|
28
|
+
|
|
29
|
+
Best-effort by construction: a consumer of `tosijs-ui/site` may not be in a git repo
|
|
30
|
+
at all, and a build must never fail because it couldn't read git metadata.
|
|
31
|
+
*/
|
|
32
|
+
import { $ } from 'bun';
|
|
33
|
+
const defaultGit = async (args) => {
|
|
34
|
+
try {
|
|
35
|
+
const r = await $ `git ${args}`.nothrow().quiet();
|
|
36
|
+
return r.exitCode === 0 ? r.stdout.toString().trim() : '';
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return ''; // git absent, not a repo, whatever — never fatal
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Collect build identity. Git fields are omitted (not blank) when unavailable, so
|
|
44
|
+
* `version.json` never asserts something it doesn't know.
|
|
45
|
+
*/
|
|
46
|
+
export async function gatherBuildStamp(opts = {
|
|
47
|
+
generator: 'unknown',
|
|
48
|
+
}) {
|
|
49
|
+
const git = opts.git ?? defaultGit;
|
|
50
|
+
const stamp = { generator: opts.generator };
|
|
51
|
+
if (opts.site)
|
|
52
|
+
stamp.site = opts.site;
|
|
53
|
+
const commit = await git(['rev-parse', '--short', 'HEAD']);
|
|
54
|
+
if (commit)
|
|
55
|
+
stamp.commit = commit;
|
|
56
|
+
// %cI is the committer date, strict ISO-8601 — stable for a given commit.
|
|
57
|
+
const commitTime = await git(['log', '-1', '--format=%cI']);
|
|
58
|
+
if (commitTime)
|
|
59
|
+
stamp.commitTime = commitTime;
|
|
60
|
+
return stamp;
|
|
61
|
+
}
|
|
62
|
+
/** Serialize for `/version.json` (trailing newline so the file is diff-friendly). */
|
|
63
|
+
export function serializeBuildStamp(stamp) {
|
|
64
|
+
return JSON.stringify(stamp, null, 2) + '\n';
|
|
65
|
+
}
|
|
@@ -38,6 +38,24 @@ export declare function resolveLimitMb(configMb: number | undefined, envMb: stri
|
|
|
38
38
|
* once per test page (N redundant loads in throwaway frames). An agent only ever
|
|
39
39
|
* drives the top page, so nested frames never need the channel.
|
|
40
40
|
*/
|
|
41
|
+
/**
|
|
42
|
+
* Is this request coming from THIS machine?
|
|
43
|
+
*
|
|
44
|
+
* The dev server binds every interface (`Bun.serve` with no `hostname`), which is
|
|
45
|
+
* deliberate — the mkcert dev cert covers `<host>.local` precisely so you can open the
|
|
46
|
+
* site on a phone or a second laptop. But "anyone on this network may READ the preview"
|
|
47
|
+
* and "anyone on this network may REWRITE my source files" are wildly different
|
|
48
|
+
* propositions, and the source endpoints were getting the first one's treatment.
|
|
49
|
+
*
|
|
50
|
+
* On any shared network — a café, a conference, a hotel — an unauthenticated
|
|
51
|
+
* `POST /__docstore/source` is remote code execution: it writes a file in the repo, the
|
|
52
|
+
* watcher rebuilds, and the build runs what was written. So those endpoints are
|
|
53
|
+
* loopback-only, while the site itself stays reachable from your other devices.
|
|
54
|
+
*
|
|
55
|
+
* IPv4-mapped IPv6 (`::ffff:127.0.0.1`) counts — that is how a v4 client shows up on a
|
|
56
|
+
* dual-stack listener, and missing it would lock out the local machine on some setups.
|
|
57
|
+
*/
|
|
58
|
+
export declare function isLoopbackAddress(address: string | undefined | null): boolean;
|
|
41
59
|
export declare function haltijaLoaderSnippet(httpsPort: number): string;
|
|
42
60
|
export declare function devServer(config: SiteConfig, opts?: {
|
|
43
61
|
test?: boolean;
|
|
@@ -8,7 +8,7 @@ headless browser through the inline doc tests and exits with their pass/fail.
|
|
|
8
8
|
Build-time only (Bun APIs). Never import this from browser code.
|
|
9
9
|
*/
|
|
10
10
|
import * as path from 'path';
|
|
11
|
-
import { statSync } from 'fs';
|
|
11
|
+
import { statSync, existsSync } from 'fs';
|
|
12
12
|
import { watch } from 'chokidar';
|
|
13
13
|
import { $, spawn } from 'bun';
|
|
14
14
|
import { buildSite } from './orchestrator';
|
|
@@ -90,6 +90,33 @@ export function resolveLimitMb(configMb, envMb) {
|
|
|
90
90
|
* once per test page (N redundant loads in throwaway frames). An agent only ever
|
|
91
91
|
* drives the top page, so nested frames never need the channel.
|
|
92
92
|
*/
|
|
93
|
+
/**
|
|
94
|
+
* Is this request coming from THIS machine?
|
|
95
|
+
*
|
|
96
|
+
* The dev server binds every interface (`Bun.serve` with no `hostname`), which is
|
|
97
|
+
* deliberate — the mkcert dev cert covers `<host>.local` precisely so you can open the
|
|
98
|
+
* site on a phone or a second laptop. But "anyone on this network may READ the preview"
|
|
99
|
+
* and "anyone on this network may REWRITE my source files" are wildly different
|
|
100
|
+
* propositions, and the source endpoints were getting the first one's treatment.
|
|
101
|
+
*
|
|
102
|
+
* On any shared network — a café, a conference, a hotel — an unauthenticated
|
|
103
|
+
* `POST /__docstore/source` is remote code execution: it writes a file in the repo, the
|
|
104
|
+
* watcher rebuilds, and the build runs what was written. So those endpoints are
|
|
105
|
+
* loopback-only, while the site itself stays reachable from your other devices.
|
|
106
|
+
*
|
|
107
|
+
* IPv4-mapped IPv6 (`::ffff:127.0.0.1`) counts — that is how a v4 client shows up on a
|
|
108
|
+
* dual-stack listener, and missing it would lock out the local machine on some setups.
|
|
109
|
+
*/
|
|
110
|
+
export function isLoopbackAddress(address) {
|
|
111
|
+
if (!address)
|
|
112
|
+
return false;
|
|
113
|
+
const a = address.trim().toLowerCase().replace(/^\[|\]$/g, '');
|
|
114
|
+
if (a === '::1' || a === 'localhost')
|
|
115
|
+
return true;
|
|
116
|
+
// ::ffff:127.0.0.1 — IPv4-mapped IPv6
|
|
117
|
+
const mapped = a.startsWith('::ffff:') ? a.slice(7) : a;
|
|
118
|
+
return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(mapped);
|
|
119
|
+
}
|
|
93
120
|
export function haltijaLoaderSnippet(httpsPort) {
|
|
94
121
|
return (`<script>self===top&&/^localhost$|^127\\./.test(location.hostname)` +
|
|
95
122
|
`&&import('https://localhost:${httpsPort}/dev.js')</script>`);
|
|
@@ -276,6 +303,13 @@ export async function devServer(config, opts = {}) {
|
|
|
276
303
|
process.env.HALTIJA_DEV === '1' ||
|
|
277
304
|
process.env.HALTIJA_DEV === 'true');
|
|
278
305
|
const HALTIJA_SNIPPET = haltijaLoaderSnippet(HALTIJA_HTTPS_PORT);
|
|
306
|
+
/*
|
|
307
|
+
What the far end has to tell you. `ok: true` means nothing to report and nothing
|
|
308
|
+
is injected into served pages. A failed rebuild sets it; the next good build
|
|
309
|
+
clears it. Surfaced through the page's existing floating widget — see
|
|
310
|
+
statusSnippet() below and the `__tosiDevStatus` reader in doc-browser.ts.
|
|
311
|
+
*/
|
|
312
|
+
let buildStatus = { ok: true };
|
|
279
313
|
let testReportResolve;
|
|
280
314
|
// Source read/write for in-browser "edit page source" (config.editableSources).
|
|
281
315
|
// Local dev only — your machine, your files — so the lone guard is correctness:
|
|
@@ -342,15 +376,41 @@ export async function devServer(config, opts = {}) {
|
|
|
342
376
|
}
|
|
343
377
|
return null;
|
|
344
378
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
379
|
+
/*
|
|
380
|
+
Serve-time status for the page's floating widget.
|
|
381
|
+
|
|
382
|
+
The widget already exists and already means "the far end has something to tell
|
|
383
|
+
you" — it is how browser-test results surface. A failed rebuild is the same kind
|
|
384
|
+
of news, so it uses the same surface rather than inventing a second one.
|
|
385
|
+
|
|
386
|
+
Injected as a plain global rather than pushed over a socket: the case that matters
|
|
387
|
+
is "the build broke and I hit refresh", and a refresh re-reads this. (When Phase 2
|
|
388
|
+
of REMOTE-ACCESS-PLAN.md adds a socket, it can update the same global live.)
|
|
389
|
+
|
|
390
|
+
Only emitted when there IS something to say, so a healthy dev server injects
|
|
391
|
+
nothing at all.
|
|
392
|
+
*/
|
|
393
|
+
function statusSnippet() {
|
|
394
|
+
if (buildStatus.ok)
|
|
395
|
+
return '';
|
|
396
|
+
const payload = JSON.stringify({
|
|
397
|
+
ok: false,
|
|
398
|
+
label: buildStatus.label,
|
|
399
|
+
detail: buildStatus.detail,
|
|
400
|
+
at: buildStatus.at,
|
|
401
|
+
});
|
|
402
|
+
return `<script>window.__tosiDevStatus=${payload}</script>`;
|
|
403
|
+
}
|
|
404
|
+
// Serve a resolved file, injecting the haltija dev-channel loader and/or a build
|
|
405
|
+
// status into HTML pages. Serve-time only — neither touches the built output on
|
|
406
|
+
// disk, and non-HTML assets are streamed untouched.
|
|
348
407
|
async function respondFile(filePath) {
|
|
349
|
-
|
|
408
|
+
const extras = (haltijaDev ? HALTIJA_SNIPPET : '') + (testMode ? '' : statusSnippet());
|
|
409
|
+
if (extras && filePath.endsWith('.html')) {
|
|
350
410
|
const html = await Bun.file(filePath).text();
|
|
351
411
|
const injected = html.includes('</body>')
|
|
352
|
-
? html.replace('</body>', `${
|
|
353
|
-
: html +
|
|
412
|
+
? html.replace('</body>', `${extras}</body>`)
|
|
413
|
+
: html + extras;
|
|
354
414
|
return new Response(injected, {
|
|
355
415
|
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
356
416
|
});
|
|
@@ -499,6 +559,63 @@ export async function devServer(config, opts = {}) {
|
|
|
499
559
|
// network, and re-running it on every hot-reload save would add latency to the
|
|
500
560
|
// edit loop and break offline dev. The audit ran once at launch (below).
|
|
501
561
|
const runBuild = opts.build ?? (() => buildSite(config, { skipAudit: true }));
|
|
562
|
+
/*
|
|
563
|
+
A FAILED REBUILD MUST NOT DESTROY THE SITE YOU ARE LOOKING AT.
|
|
564
|
+
|
|
565
|
+
`buildSite()` begins with `rm -rf <outputDir>` — it has to, since it regenerates
|
|
566
|
+
the tree — so a build that fails anywhere after that leaves an empty or partial
|
|
567
|
+
output directory. Before this, the sequence was: save a typo, the rebuild throws,
|
|
568
|
+
the error scrolls past in a terminal you may not be watching, and the next refresh
|
|
569
|
+
serves nothing. The site was gone until you noticed and fixed it, and the page
|
|
570
|
+
gave you no clue why.
|
|
571
|
+
|
|
572
|
+
So: move the working site aside first (a rename — atomic and instant on the same
|
|
573
|
+
filesystem, and ~9MB of extra disk we will never notice), then build. On success,
|
|
574
|
+
drop the spare. On failure, put it back and record WHY, so the page keeps working
|
|
575
|
+
and the widget can say what broke.
|
|
576
|
+
|
|
577
|
+
Deliberately wraps `runBuild`, not `buildSite`: a consumer's custom `{ build }`
|
|
578
|
+
pipeline gets the same protection without knowing about it.
|
|
579
|
+
*/
|
|
580
|
+
const LAST_GOOD = `${PUBLIC}.last-good`;
|
|
581
|
+
const runBuildPreservingLastGood = async () => {
|
|
582
|
+
const hadSite = existsSync(PUBLIC);
|
|
583
|
+
if (hadSite) {
|
|
584
|
+
await $ `rm -rf ${LAST_GOOD}`.nothrow().quiet();
|
|
585
|
+
await $ `mv ${PUBLIC} ${LAST_GOOD}`.nothrow().quiet();
|
|
586
|
+
}
|
|
587
|
+
const restore = async () => {
|
|
588
|
+
if (!hadSite)
|
|
589
|
+
return;
|
|
590
|
+
await $ `rm -rf ${PUBLIC}`.nothrow().quiet();
|
|
591
|
+
await $ `mv ${LAST_GOOD} ${PUBLIC}`.nothrow().quiet();
|
|
592
|
+
};
|
|
593
|
+
try {
|
|
594
|
+
// buildSite REPORTS failure by returning false (a blocking advisory, an
|
|
595
|
+
// output-dir overlap) and THROWS for everything else, so treat both as
|
|
596
|
+
// failure — a falsy return that we ignored would silently ship a wiped site.
|
|
597
|
+
const result = await runBuild();
|
|
598
|
+
if (result === false)
|
|
599
|
+
throw new Error('build reported failure');
|
|
600
|
+
if (hadSite)
|
|
601
|
+
await $ `rm -rf ${LAST_GOOD}`.nothrow().quiet();
|
|
602
|
+
if (!buildStatus.ok)
|
|
603
|
+
console.log('✅ build recovered — serving fresh output');
|
|
604
|
+
buildStatus = { ok: true, at: Date.now() };
|
|
605
|
+
}
|
|
606
|
+
catch (error) {
|
|
607
|
+
await restore();
|
|
608
|
+
buildStatus = {
|
|
609
|
+
ok: false,
|
|
610
|
+
label: 'Build failed',
|
|
611
|
+
detail: String(error instanceof Error ? error.message : error).slice(0, 2000),
|
|
612
|
+
at: Date.now(),
|
|
613
|
+
};
|
|
614
|
+
console.error(`\n🛑 Rebuild failed — still serving the last good build.\n` +
|
|
615
|
+
` Fix the error above; the next successful build clears this.\n`);
|
|
616
|
+
throw error;
|
|
617
|
+
}
|
|
618
|
+
};
|
|
502
619
|
// Rebuild-storm detector.
|
|
503
620
|
//
|
|
504
621
|
// The other way this process eats the machine is not a leak but a LOOP: if the
|
|
@@ -547,7 +664,7 @@ export async function devServer(config, opts = {}) {
|
|
|
547
664
|
// Both look like: started again with no gap.
|
|
548
665
|
const immediate = lastBuildEnd > 0 && Date.now() - lastBuildEnd < IMMEDIATE_MS;
|
|
549
666
|
try {
|
|
550
|
-
await
|
|
667
|
+
await runBuildPreservingLastGood();
|
|
551
668
|
}
|
|
552
669
|
catch (error) {
|
|
553
670
|
console.error('rebuild failed:', error);
|
|
@@ -627,7 +744,7 @@ export async function devServer(config, opts = {}) {
|
|
|
627
744
|
key: Bun.file('./tls/key.pem'),
|
|
628
745
|
cert: Bun.file('./tls/certificate.pem'),
|
|
629
746
|
},
|
|
630
|
-
async fetch(request) {
|
|
747
|
+
async fetch(request, srv) {
|
|
631
748
|
touch();
|
|
632
749
|
let reqPath = new URL(request.url).pathname;
|
|
633
750
|
console.log(request.method, reqPath);
|
|
@@ -647,6 +764,26 @@ export async function devServer(config, opts = {}) {
|
|
|
647
764
|
if (!config.editableSources) {
|
|
648
765
|
return new Response('editableSources is not enabled in this doc-site config (set editableSources: true to edit/save source in dev)', { status: 501, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
|
|
649
766
|
}
|
|
767
|
+
// LOOPBACK ONLY. Reading exposes any file in the repo; writing is remote code
|
|
768
|
+
// execution (write a file, the watcher rebuilds, the build runs it). Checked on
|
|
769
|
+
// the PEER ADDRESS, never on Origin/Host, which are attacker-supplied.
|
|
770
|
+
//
|
|
771
|
+
// There is deliberately NO override. An env var that re-enables this would have
|
|
772
|
+
// no legitimate use today — nothing authenticates in front of this server — and
|
|
773
|
+
// it is exactly the shape of guard that gets set once in a shell profile and
|
|
774
|
+
// stays off forever. Remote editing is not a flag to flip; it needs real
|
|
775
|
+
// authentication, and that is Phase 3 of REMOTE-ACCESS-PLAN.md, where the
|
|
776
|
+
// schema is the authorization boundary and a session (not a location, and not
|
|
777
|
+
// an env var) decides who may write. Until then: view remotely, edit locally.
|
|
778
|
+
const peer = srv?.requestIP?.(request)?.address;
|
|
779
|
+
if (!isLoopbackAddress(peer)) {
|
|
780
|
+
console.warn(`⚠️ refused ${request.method} /__docstore/source from ${peer ?? 'unknown'} — ` +
|
|
781
|
+
`source editing is local-only.`);
|
|
782
|
+
return new Response('source editing is restricted to this machine', {
|
|
783
|
+
status: 403,
|
|
784
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
785
|
+
});
|
|
786
|
+
}
|
|
650
787
|
if (request.method === 'GET')
|
|
651
788
|
return handleReadSource(request);
|
|
652
789
|
if (request.method === 'POST')
|
|
@@ -23,6 +23,8 @@ import { generateSite } from './generate-site';
|
|
|
23
23
|
import { findOutputDirOverlap } from './output-guard';
|
|
24
24
|
import { preflight } from './preflight';
|
|
25
25
|
import { auditDependencies, reportAudit } from './audit-guard';
|
|
26
|
+
import { gatherBuildStamp, serializeBuildStamp } from './build-stamp';
|
|
27
|
+
import { version } from '../../version';
|
|
26
28
|
import { tjsEditorExternal, tjsEditorLeakedAsExternal, classicScriptSyntaxErrorInChild, } from './bundle-guard';
|
|
27
29
|
// Module specifiers contain regex metacharacters (`/`, `.`, `@`, …), so escape
|
|
28
30
|
// before interpolating into the require-shim detector below.
|
|
@@ -627,6 +629,10 @@ export async function buildSite(config, opts = {}) {
|
|
|
627
629
|
scriptUrl: config.scriptUrl,
|
|
628
630
|
basePath: config.basePath,
|
|
629
631
|
});
|
|
632
|
+
// Build identity at /version.json — "what am I looking at?" for any deployed copy.
|
|
633
|
+
// Deterministic (commit-derived, no wall clock) so a committed outputDir doesn't
|
|
634
|
+
// churn on every build. See build-stamp.ts.
|
|
635
|
+
await Bun.write(`${PUBLIC}/version.json`, serializeBuildStamp(await gatherBuildStamp({ generator: version, site: config.name })));
|
|
630
636
|
// Burn the theme into a static stylesheet (separate subprocess — see
|
|
631
637
|
// generate-css.ts). Resolve the sibling relative to THIS module so it works
|
|
632
638
|
// both in-repo (.ts) and when shipped (compiled .js).
|
|
@@ -291,6 +291,31 @@ export interface SiteConfig {
|
|
|
291
291
|
* permission prompt ("<runtime> wants to control <Browser>") — approve it once.
|
|
292
292
|
*/
|
|
293
293
|
openBrowser?: boolean | string;
|
|
294
|
+
/**
|
|
295
|
+
* Preview host for `bun bin/deploy-preview.ts` — rsync the built site to a box you
|
|
296
|
+
* control, so a phone, a client, or a reviewer can see it without your dev server
|
|
297
|
+
* (or your laptop) being up.
|
|
298
|
+
*
|
|
299
|
+
* Only `host` is required; everything else has a sensible default:
|
|
300
|
+
*
|
|
301
|
+
* ```ts
|
|
302
|
+
* preview: { host: 'root@203.0.113.10' }
|
|
303
|
+
* // → rsyncs <outputDir>/ to /srv/preview/<name>/ on that box
|
|
304
|
+
* ```
|
|
305
|
+
*
|
|
306
|
+
* The deploy is a plain `rsync --delete` of static files — the whole artifact is a
|
|
307
|
+
* few MB — so there is no pipeline, no build service, and (because static files
|
|
308
|
+
* have no write endpoint) no meaningful attack surface to design around. See
|
|
309
|
+
* REMOTE-ACCESS-PLAN.md.
|
|
310
|
+
*/
|
|
311
|
+
preview?: {
|
|
312
|
+
/** ssh target, e.g. `root@203.0.113.10` or `deploy@preview.example.com` */
|
|
313
|
+
host: string;
|
|
314
|
+
/** remote directory; defaults to `/srv/preview/<name>` */
|
|
315
|
+
path?: string;
|
|
316
|
+
/** public URL, printed after a successful deploy (e.g. `https://dev.example.com`) */
|
|
317
|
+
url?: string;
|
|
318
|
+
};
|
|
294
319
|
/**
|
|
295
320
|
* Enable the dev-server source read/write endpoints (`/__docstore/source`) that
|
|
296
321
|
* back in-browser "edit page source". Local dev only — the dev server runs on
|