@zerotal/core 1.13.1 → 1.13.3
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 +64 -0
- package/api-surface.md +181 -0
- package/package.json +3 -1
- package/src/application/Application.ts +15 -2
- package/src/command/CommandRunner.ts +13 -0
- package/src/command/builtin/GateCommands.ts +171 -0
- package/src/command/builtin/index.ts +1 -0
- package/src/doctor/AppDoctor.ts +2 -0
- package/src/gate/Gate.ts +130 -0
- package/src/gate/GateMiddleware.ts +260 -0
- package/src/gate/doctor.ts +72 -0
- package/src/gate/index.ts +13 -0
- package/src/gate/state.ts +127 -0
- package/src/heartbeat/Heartbeat.ts +192 -0
- package/src/heartbeat/doctor.ts +94 -0
- package/src/heartbeat/index.ts +11 -0
- package/src/router/Router.ts +29 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,70 @@ follows the Zerotal monorepo's unified versioning.
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [1.13.3] — 2026-08-31
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`Heartbeat` — which background processes are actually running.** An app could say what
|
|
16
|
+
it _registered_ and nothing could say whether any of it ever _ran_.
|
|
17
|
+
|
|
18
|
+
The failure this exists for was reported from production: an app shipped with no worker
|
|
19
|
+
process, and every scheduled task silently did not execute for weeks. No hold was
|
|
20
|
+
released, no reminder was sent, and nothing logged, because from the web process's point
|
|
21
|
+
of view nothing was wrong. They found it by going looking.
|
|
22
|
+
|
|
23
|
+
`@zerotal/core/heartbeat` — `beat()`, `start()`, `lastSeen()`, and `workerLivenessCheck()`
|
|
24
|
+
for building the doctor check.
|
|
25
|
+
|
|
26
|
+
The beat lives in the **cache**, because the reader is a different process from the
|
|
27
|
+
writer and often a different machine, and the app has already chosen where its shared
|
|
28
|
+
state goes. `sqlite` (the default) is shared across processes on one box, `redis` across
|
|
29
|
+
machines, `memory` with nobody — which is why `lastSeen()` returns a distinct
|
|
30
|
+
`"unknown"` rather than `"never"`. A check that reported a missing worker on every app
|
|
31
|
+
using the memory driver would be wrong more often than right, and a check that is
|
|
32
|
+
usually wrong is one people learn to skip.
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- **A site gate — maintenance, and private preview.** From a proposal by the Trekly team,
|
|
37
|
+
who were running a hand-edited `basic_auth` block in a reverse proxy, deliberately kept
|
|
38
|
+
out of version control so it could not be deployed and forgotten into a live shop.
|
|
39
|
+
|
|
40
|
+
Two states that look alike and are not. **Maintenance** means the site is down —
|
|
41
|
+
everyone refused, staff included, because the usual reason a site is down is that its
|
|
42
|
+
database is being changed underneath it. **Private preview** means the site is up and
|
|
43
|
+
working, for the people invited to it, for weeks.
|
|
44
|
+
|
|
45
|
+
`zt down`, `zt preview`, `zt up`, `zt gate:status`, and a `Gate` API at
|
|
46
|
+
`@zerotal/core/gate` so an app can build its own switch. See
|
|
47
|
+
[the guide](https://zerotal.dev/docs/site-gate).
|
|
48
|
+
|
|
49
|
+
The details that are the reason this belongs in the framework rather than in each app:
|
|
50
|
+
|
|
51
|
+
- **Maintenance is always `503` with `Retry-After`, and is not configurable.** A
|
|
52
|
+
maintenance page at `200` tells a search engine the apology is your homepage.
|
|
53
|
+
- **A preview token is stripped from the URL on first use**, via a redirect to a cookie.
|
|
54
|
+
Left in the address bar it travels into `Referer`, analytics and screenshots.
|
|
55
|
+
- **Webhook paths must be declared** in `gate.allow`. A payment provider posting a
|
|
56
|
+
settlement into a maintenance window otherwise gets a 503, and that is a retry, a
|
|
57
|
+
dropped callback, or a payment your books never learn about.
|
|
58
|
+
- **The state is a file, and the token is stored hashed.** A flag in the database is
|
|
59
|
+
unreadable exactly when the database is what you are working on; a token in a file is
|
|
60
|
+
a credential in something every backup copies.
|
|
61
|
+
- **It covers `Router.raw()` routes.** Found while testing: this framework's own docs
|
|
62
|
+
site serves every `/docs/*` page from a raw route, so an early build gated the front
|
|
63
|
+
page — which is what a person checks — and left all the content public.
|
|
64
|
+
- **`zt doctor` reports a gate left on**, and a preview whose `until` has passed.
|
|
65
|
+
- **The state file is gitignored** by the scaffold, which is the whole point.
|
|
66
|
+
|
|
67
|
+
### Fixed
|
|
68
|
+
|
|
69
|
+
- **`secureHeaders: false` no longer empties the kernel.** It set the kernel middleware to
|
|
70
|
+
`[]`, which was the same thing as removing the headers until the gate joined that layer —
|
|
71
|
+
at which point opting out of security headers would silently have taken the site gate
|
|
72
|
+
with it. One feature's opt-out disabling another's is the failure the gate is otherwise
|
|
73
|
+
about.
|
|
74
|
+
|
|
11
75
|
## [1.13.0] — 2026-08-31
|
|
12
76
|
|
|
13
77
|
### Changed — **BREAKING**
|
package/api-surface.md
CHANGED
|
@@ -1448,6 +1448,62 @@ class DoctorCommand = {
|
|
|
1448
1448
|
write: (msg: string) => void
|
|
1449
1449
|
}
|
|
1450
1450
|
|
|
1451
|
+
class DownCommand = {
|
|
1452
|
+
new (): DownCommand
|
|
1453
|
+
static args: never[]
|
|
1454
|
+
static commandName: string
|
|
1455
|
+
static description: string
|
|
1456
|
+
static flags: ({ name: string; type: 'number'; description: string; default: number;} | { name: string; type: 'string'; description: string; default?: never;})[]
|
|
1457
|
+
static needsApp: boolean
|
|
1458
|
+
_readLine: () => Promise<string>
|
|
1459
|
+
_writer: OutputWriter
|
|
1460
|
+
app: unknown
|
|
1461
|
+
args: Record<string, string>
|
|
1462
|
+
ask: (question: string, defaultValue?: string) => Promise<string>
|
|
1463
|
+
choice: (question: string, options: string[]) => Promise<string>
|
|
1464
|
+
confirm: (question: string, defaultValue?: boolean) => Promise<boolean>
|
|
1465
|
+
dim: (msg: string) => void
|
|
1466
|
+
error: (msg: string) => void
|
|
1467
|
+
flags: Record<string, string | number | boolean>
|
|
1468
|
+
info: (msg: string) => void
|
|
1469
|
+
line: (msg: string) => void
|
|
1470
|
+
newLine: () => void
|
|
1471
|
+
run: () => Promise<void>
|
|
1472
|
+
secret: (question: string) => Promise<string>
|
|
1473
|
+
section: (title: string) => void
|
|
1474
|
+
table: (rows: [string, string][], indent?: number) => void
|
|
1475
|
+
warn: (msg: string) => void
|
|
1476
|
+
write: (msg: string) => void
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
class GateStatusCommand = {
|
|
1480
|
+
new (): GateStatusCommand
|
|
1481
|
+
static args: never[]
|
|
1482
|
+
static commandName: string
|
|
1483
|
+
static description: string
|
|
1484
|
+
static flags: { name: string; type: 'boolean'; description: string; default: boolean;}[]
|
|
1485
|
+
static needsApp: boolean
|
|
1486
|
+
_readLine: () => Promise<string>
|
|
1487
|
+
_writer: OutputWriter
|
|
1488
|
+
app: unknown
|
|
1489
|
+
args: Record<string, string>
|
|
1490
|
+
ask: (question: string, defaultValue?: string) => Promise<string>
|
|
1491
|
+
choice: (question: string, options: string[]) => Promise<string>
|
|
1492
|
+
confirm: (question: string, defaultValue?: boolean) => Promise<boolean>
|
|
1493
|
+
dim: (msg: string) => void
|
|
1494
|
+
error: (msg: string) => void
|
|
1495
|
+
flags: Record<string, string | number | boolean>
|
|
1496
|
+
info: (msg: string) => void
|
|
1497
|
+
line: (msg: string) => void
|
|
1498
|
+
newLine: () => void
|
|
1499
|
+
run: () => Promise<void>
|
|
1500
|
+
secret: (question: string) => Promise<string>
|
|
1501
|
+
section: (title: string) => void
|
|
1502
|
+
table: (rows: [string, string][], indent?: number) => void
|
|
1503
|
+
warn: (msg: string) => void
|
|
1504
|
+
write: (msg: string) => void
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1451
1507
|
class KeyGenerateCommand = {
|
|
1452
1508
|
new (): KeyGenerateCommand
|
|
1453
1509
|
static args: never[]
|
|
@@ -1896,6 +1952,34 @@ class MakeTestCommand = {
|
|
|
1896
1952
|
write: (msg: string) => void
|
|
1897
1953
|
}
|
|
1898
1954
|
|
|
1955
|
+
class PreviewCommand = {
|
|
1956
|
+
new (): PreviewCommand
|
|
1957
|
+
static args: never[]
|
|
1958
|
+
static commandName: string
|
|
1959
|
+
static description: string
|
|
1960
|
+
static flags: { name: string; type: 'string'; description: string;}[]
|
|
1961
|
+
static needsApp: boolean
|
|
1962
|
+
_readLine: () => Promise<string>
|
|
1963
|
+
_writer: OutputWriter
|
|
1964
|
+
app: unknown
|
|
1965
|
+
args: Record<string, string>
|
|
1966
|
+
ask: (question: string, defaultValue?: string) => Promise<string>
|
|
1967
|
+
choice: (question: string, options: string[]) => Promise<string>
|
|
1968
|
+
confirm: (question: string, defaultValue?: boolean) => Promise<boolean>
|
|
1969
|
+
dim: (msg: string) => void
|
|
1970
|
+
error: (msg: string) => void
|
|
1971
|
+
flags: Record<string, string | number | boolean>
|
|
1972
|
+
info: (msg: string) => void
|
|
1973
|
+
line: (msg: string) => void
|
|
1974
|
+
newLine: () => void
|
|
1975
|
+
run: () => Promise<void>
|
|
1976
|
+
secret: (question: string) => Promise<string>
|
|
1977
|
+
section: (title: string) => void
|
|
1978
|
+
table: (rows: [string, string][], indent?: number) => void
|
|
1979
|
+
warn: (msg: string) => void
|
|
1980
|
+
write: (msg: string) => void
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1899
1983
|
class ReloadCommand = {
|
|
1900
1984
|
new (): ReloadCommand
|
|
1901
1985
|
static args: never[]
|
|
@@ -2123,6 +2207,34 @@ class TestCommand = {
|
|
|
2123
2207
|
write: (msg: string) => void
|
|
2124
2208
|
}
|
|
2125
2209
|
|
|
2210
|
+
class UpCommand = {
|
|
2211
|
+
new (): UpCommand
|
|
2212
|
+
static args: never[]
|
|
2213
|
+
static commandName: string
|
|
2214
|
+
static description: string
|
|
2215
|
+
static flags: never[]
|
|
2216
|
+
static needsApp: boolean
|
|
2217
|
+
_readLine: () => Promise<string>
|
|
2218
|
+
_writer: OutputWriter
|
|
2219
|
+
app: unknown
|
|
2220
|
+
args: Record<string, string>
|
|
2221
|
+
ask: (question: string, defaultValue?: string) => Promise<string>
|
|
2222
|
+
choice: (question: string, options: string[]) => Promise<string>
|
|
2223
|
+
confirm: (question: string, defaultValue?: boolean) => Promise<boolean>
|
|
2224
|
+
dim: (msg: string) => void
|
|
2225
|
+
error: (msg: string) => void
|
|
2226
|
+
flags: Record<string, string | number | boolean>
|
|
2227
|
+
info: (msg: string) => void
|
|
2228
|
+
line: (msg: string) => void
|
|
2229
|
+
newLine: () => void
|
|
2230
|
+
run: () => Promise<void>
|
|
2231
|
+
secret: (question: string) => Promise<string>
|
|
2232
|
+
section: (title: string) => void
|
|
2233
|
+
table: (rows: [string, string][], indent?: number) => void
|
|
2234
|
+
warn: (msg: string) => void
|
|
2235
|
+
write: (msg: string) => void
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2126
2238
|
class UpgradeCommand = {
|
|
2127
2239
|
new (): UpgradeCommand
|
|
2128
2240
|
static args: ArgDef[]
|
|
@@ -2625,6 +2737,59 @@ interface ArtisanResult = {
|
|
|
2625
2737
|
output: string
|
|
2626
2738
|
}
|
|
2627
2739
|
|
|
2740
|
+
## ./gate `(./src/gate/index.ts)`
|
|
2741
|
+
|
|
2742
|
+
class GateMiddleware = {
|
|
2743
|
+
new (): GateMiddleware
|
|
2744
|
+
static with: <T extends new (...args: any[]) => BaseMiddleware<any>, Opts = T extends new (...args: any[]) => BaseMiddleware<infer U> ? U : object>(this: T, options: DeepPartial<NoInfer<Opts>>) => new () => InstanceType<T>
|
|
2745
|
+
afterResponse?: (ctx: HttpContext) => Promise<void>
|
|
2746
|
+
handle: (ctx: HttpContext, next: NextFn) => Promise<Response | void>
|
|
2747
|
+
onError?: (ctx: HttpContext, error: Error) => Promise<void>
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
const Gate = { maintenance(options?: MaintenanceOptions): Promise<void>; preview(options: PreviewOptions): Promise<void>; open(): Promise<void>; status(): GateStatus;}
|
|
2751
|
+
|
|
2752
|
+
const GATE_COOKIE = 'zerotal_preview'
|
|
2753
|
+
|
|
2754
|
+
const GATE_FILE = string
|
|
2755
|
+
|
|
2756
|
+
const GATE_QUERY = 'preview'
|
|
2757
|
+
|
|
2758
|
+
function gateExpired = (state: GateState, now?: Date) => boolean
|
|
2759
|
+
|
|
2760
|
+
function readGate = (root?: string) => GateState | null
|
|
2761
|
+
|
|
2762
|
+
interface GateState = {
|
|
2763
|
+
by?: string | undefined
|
|
2764
|
+
mode: GateMode
|
|
2765
|
+
retryAfter?: number | undefined
|
|
2766
|
+
since: string
|
|
2767
|
+
tokenHash?: string | undefined
|
|
2768
|
+
until?: string | undefined
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
interface GateStatus = {
|
|
2772
|
+
by?: string | undefined
|
|
2773
|
+
expired?: boolean | undefined
|
|
2774
|
+
mode: 'open' | GateMode
|
|
2775
|
+
retryAfter?: number | undefined
|
|
2776
|
+
since?: string | undefined
|
|
2777
|
+
until?: string | undefined
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
interface MaintenanceOptions = {
|
|
2781
|
+
by?: string | undefined
|
|
2782
|
+
retryAfter?: number | undefined
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
interface PreviewOptions = {
|
|
2786
|
+
by?: string | undefined
|
|
2787
|
+
token: string
|
|
2788
|
+
until?: string | undefined
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
type GateMode = 'maintenance' | 'preview'
|
|
2792
|
+
|
|
2628
2793
|
## ./health `(./src/health/index.ts)`
|
|
2629
2794
|
|
|
2630
2795
|
const Health = HealthRegistry
|
|
@@ -2669,6 +2834,22 @@ type HealthCheckFn = () => Promise<HealthResult | void> | HealthResult | void
|
|
|
2669
2834
|
|
|
2670
2835
|
type HealthStatus = 'ok' | 'degraded' | 'down'
|
|
2671
2836
|
|
|
2837
|
+
## ./heartbeat `(./src/heartbeat/index.ts)`
|
|
2838
|
+
|
|
2839
|
+
const Heartbeat = { beat(name: string, extra?: Omit<Beat, 'at'>): Promise<void>; start(name: string, extra?: Omit<Beat, 'at'>, everyMs?: number): () => void; lastSeen(name: string): Promise<BeatLookup>;}
|
|
2840
|
+
|
|
2841
|
+
function describeBeat = (lookup: BeatLookup) => string
|
|
2842
|
+
|
|
2843
|
+
function workerLivenessCheck = (options: WorkerLivenessOptions) => DoctorCheck
|
|
2844
|
+
|
|
2845
|
+
interface Beat = {
|
|
2846
|
+
at: string
|
|
2847
|
+
detail?: string | undefined
|
|
2848
|
+
pid?: number | undefined
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
type BeatLookup = { status: 'seen'; beat: Beat; ageSeconds: number } | { status: 'never' } | { status: 'unknown'; reason: string }
|
|
2852
|
+
|
|
2672
2853
|
## ./helpers `(./src/helpers/index.ts)`
|
|
2673
2854
|
|
|
2674
2855
|
const Str = { camelCase(value: string): string; snakeCase(value: string): string; slugify(value: string): string; titleCase(value: string): string; pascalCase(value: string): string; capitalize(value: string): string; lcfirst(value: string): string; truncate(value: string, maxLength: number, suffix?: string): string; isAlphanumeric(value: string): boolean; padLeft(value: string, length: number, char?: string): string; kebab(value: string): string; squish(value: string): string; finish(value: string, cap: string): string; start(value: string, prefix: string): string; after(value: string, needle: string): string; before(value: string, needle: string): string; afterLast(value: string, needle: string): string; beforeLast(value: string, needle: string): string; contains(value: string, substring: string): boolean; random(length?: number): string; replaceFirst(value: string, search: string, replace: string): string; replaceLast(value: string, search: string, replace: string): string; reverse(value: string): string; words(value: string, maxWords: number, suffix?: string): string; macro(name: string, fn: (...args: unknown[]) => unknown): void;}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/core",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.3",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"maturity": "stable",
|
|
6
6
|
"private": false,
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
"./commands": "./src/command/builtin/index.ts",
|
|
18
18
|
"./helpers": "./src/helpers/index.ts",
|
|
19
19
|
"./runtime": "./src/support/runtime.ts",
|
|
20
|
+
"./gate": "./src/gate/index.ts",
|
|
21
|
+
"./heartbeat": "./src/heartbeat/index.ts",
|
|
20
22
|
"./shared": "./src/shared/index.ts",
|
|
21
23
|
"./facades": "./src/facade/facades/index.ts",
|
|
22
24
|
"./carbon": "./src/datetime/index.ts",
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
SecureHeadersMiddleware,
|
|
33
33
|
staticSecurityHeaders,
|
|
34
34
|
} from "../middleware/SecureHeadersMiddleware.ts";
|
|
35
|
+
import { GateMiddleware } from "../gate/GateMiddleware.ts";
|
|
35
36
|
import { isAllowedOrigin, allowedOriginsFrom } from "../http/originGuard.ts";
|
|
36
37
|
import { rescueSync } from "../helpers/index.ts";
|
|
37
38
|
import {
|
|
@@ -525,7 +526,15 @@ export class Application {
|
|
|
525
526
|
|
|
526
527
|
const app = new Application();
|
|
527
528
|
app._env = resolvedEnv;
|
|
528
|
-
|
|
529
|
+
// Remove only the headers, not the whole kernel. Emptying it used to be the
|
|
530
|
+
// same thing; once the gate joined the kernel it stopped being, and
|
|
531
|
+
// `secureHeaders: false` would have silently taken a site's maintenance mode
|
|
532
|
+
// with it — an opt-out for one feature disabling an unrelated one.
|
|
533
|
+
if (options.secureHeaders === false) {
|
|
534
|
+
app._kernelMiddleware = app._kernelMiddleware.filter(
|
|
535
|
+
(pipe) => pipe !== SecureHeadersMiddleware,
|
|
536
|
+
);
|
|
537
|
+
}
|
|
529
538
|
if (options.providers) for (const provider of options.providers) app._addProvider(provider);
|
|
530
539
|
if (options.config) {
|
|
531
540
|
app._configMap =
|
|
@@ -899,7 +908,11 @@ export class Application {
|
|
|
899
908
|
* documenting it and hoping — shipped every scaffold with no clickjacking protection and
|
|
900
909
|
* no MIME-sniffing protection. Cleared by `Application.create({ secureHeaders: false })`.
|
|
901
910
|
*/
|
|
902
|
-
|
|
911
|
+
// GateMiddleware first, and before SecureHeaders: a closed site should answer
|
|
912
|
+
// from the gate without anything else running, including the session and
|
|
913
|
+
// anything that reaches the database — which in maintenance mode may be the
|
|
914
|
+
// thing that is unavailable.
|
|
915
|
+
private _kernelMiddleware: PipeClass[] = [GateMiddleware, SecureHeadersMiddleware];
|
|
903
916
|
|
|
904
917
|
/** Resolved middleware pipeline: kernel → provider auto → explicit .use() */
|
|
905
918
|
private get _pipeline(): PipeClass[] {
|
|
@@ -344,6 +344,10 @@ export class CommandRunner {
|
|
|
344
344
|
ReloadCommand,
|
|
345
345
|
StatusCommand,
|
|
346
346
|
VersionCommand,
|
|
347
|
+
DownCommand,
|
|
348
|
+
PreviewCommand,
|
|
349
|
+
UpCommand,
|
|
350
|
+
GateStatusCommand,
|
|
347
351
|
MakeControllerCommand,
|
|
348
352
|
MakeMiddlewareCommand,
|
|
349
353
|
MakeCommandCommand,
|
|
@@ -386,6 +390,15 @@ export class CommandRunner {
|
|
|
386
390
|
// "which version is this" must not depend on which environment is asking.
|
|
387
391
|
this.register(VersionCommand);
|
|
388
392
|
|
|
393
|
+
// The gate is changed at the moments things are going wrong — before a
|
|
394
|
+
// migration, during an incident — so these are registered in every
|
|
395
|
+
// environment and need no application. A maintenance command that only
|
|
396
|
+
// works when the app boots is one you cannot reach when you need it.
|
|
397
|
+
this.register(DownCommand);
|
|
398
|
+
this.register(PreviewCommand);
|
|
399
|
+
this.register(UpCommand);
|
|
400
|
+
this.register(GateStatusCommand);
|
|
401
|
+
|
|
389
402
|
// route:list and doctor are inspection commands — always available regardless of mode.
|
|
390
403
|
this.register(RouteListCommand);
|
|
391
404
|
this.register(DoctorCommand);
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { Command } from "../Command.ts";
|
|
2
|
+
import { Gate } from "../../gate/Gate.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `bun zt down` — take the site down for maintenance.
|
|
6
|
+
*
|
|
7
|
+
* Every request is answered `503` with `Retry-After`, staff included, because
|
|
8
|
+
* the usual reason a site is down is that its database is being changed
|
|
9
|
+
* underneath it and letting one person in is letting them into that.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```bash
|
|
13
|
+
* bun zt down --retry=120
|
|
14
|
+
* bun zt up
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* @category Serving
|
|
18
|
+
*/
|
|
19
|
+
export class DownCommand extends Command {
|
|
20
|
+
static override commandName = "down";
|
|
21
|
+
static override description = "Take the site down for maintenance (503 + Retry-After)";
|
|
22
|
+
static override needsApp = false;
|
|
23
|
+
static override args = [];
|
|
24
|
+
static override flags = [
|
|
25
|
+
{
|
|
26
|
+
name: "retry",
|
|
27
|
+
type: "number" as const,
|
|
28
|
+
description: "Seconds to advertise in Retry-After",
|
|
29
|
+
default: 60,
|
|
30
|
+
},
|
|
31
|
+
{ name: "by", type: "string" as const, description: "Who is taking it down" },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
async run(): Promise<void> {
|
|
35
|
+
const retryAfter = this.flags["retry"] as number;
|
|
36
|
+
const by = this.flags["by"] as string | undefined;
|
|
37
|
+
|
|
38
|
+
await Gate.maintenance({ retryAfter, ...(by ? { by } : {}) });
|
|
39
|
+
|
|
40
|
+
this.info(`Site is down. Answering 503 with Retry-After: ${retryAfter}.`);
|
|
41
|
+
this.dim("Health checks and static assets stay reachable. `bun zt up` to restore.");
|
|
42
|
+
this.dim("Webhook paths do NOT — declare them in config/gate.ts `allow` first.");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `bun zt preview` — put the site behind a private preview.
|
|
48
|
+
*
|
|
49
|
+
* The site works normally for anyone holding the token; the public gets a
|
|
50
|
+
* holding page. Unlike `down`, this is not an outage — invited people can
|
|
51
|
+
* transact.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```bash
|
|
55
|
+
* bun zt preview # generates a token and prints the link
|
|
56
|
+
* bun zt preview --until=2026-09-30
|
|
57
|
+
* ```
|
|
58
|
+
*
|
|
59
|
+
* @category Serving
|
|
60
|
+
*/
|
|
61
|
+
export class PreviewCommand extends Command {
|
|
62
|
+
static override commandName = "preview";
|
|
63
|
+
static override description = "Put the site behind a private preview link";
|
|
64
|
+
static override needsApp = false;
|
|
65
|
+
static override args = [];
|
|
66
|
+
static override flags = [
|
|
67
|
+
{
|
|
68
|
+
name: "token",
|
|
69
|
+
type: "string" as const,
|
|
70
|
+
description: "Token to use. Generated when omitted; pass a new one to rotate",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: "until",
|
|
74
|
+
type: "string" as const,
|
|
75
|
+
description: "ISO date the preview lifts, e.g. 2026-09-30",
|
|
76
|
+
},
|
|
77
|
+
{ name: "by", type: "string" as const, description: "Who is raising it" },
|
|
78
|
+
{ name: "url", type: "string" as const, description: "Base URL to print the link against" },
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
async run(): Promise<void> {
|
|
82
|
+
// Generated rather than asked for by default. A token typed by a person is
|
|
83
|
+
// the one thing standing between the public and an unlaunched site, and
|
|
84
|
+
// nothing rate-limits guesses at it.
|
|
85
|
+
const token =
|
|
86
|
+
(this.flags["token"] as string | undefined) ?? crypto.randomUUID().replace(/-/g, "");
|
|
87
|
+
const until = this.flags["until"] as string | undefined;
|
|
88
|
+
const by = this.flags["by"] as string | undefined;
|
|
89
|
+
const base = (this.flags["url"] as string | undefined) ?? "https://your-site";
|
|
90
|
+
|
|
91
|
+
const rotating = Gate.status().mode === "preview";
|
|
92
|
+
await Gate.preview({ token, ...(until ? { until } : {}), ...(by ? { by } : {}) });
|
|
93
|
+
|
|
94
|
+
this.info("Site is in private preview.");
|
|
95
|
+
this.newLine();
|
|
96
|
+
this.line(` ${base}/?preview=${token}`);
|
|
97
|
+
this.newLine();
|
|
98
|
+
this.dim("Keep that link — the token is stored as a hash and cannot be read back.");
|
|
99
|
+
if (rotating) this.warn("Rotated: every previously issued preview cookie has stopped working.");
|
|
100
|
+
if (until) this.dim(`Lifts itself after ${until}.`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* `bun zt up` — open the site to the public again.
|
|
106
|
+
*
|
|
107
|
+
* @category Serving
|
|
108
|
+
*/
|
|
109
|
+
export class UpCommand extends Command {
|
|
110
|
+
static override commandName = "up";
|
|
111
|
+
static override description = "Open the site — clears maintenance or preview";
|
|
112
|
+
static override needsApp = false;
|
|
113
|
+
static override args = [];
|
|
114
|
+
static override flags = [];
|
|
115
|
+
|
|
116
|
+
async run(): Promise<void> {
|
|
117
|
+
const before = Gate.status();
|
|
118
|
+
await Gate.open();
|
|
119
|
+
|
|
120
|
+
if (before.mode === "open") {
|
|
121
|
+
this.info("Site was already open. Nothing to do.");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
this.info(`Site is open. Was in ${before.mode} since ${before.since ?? "unknown"}.`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* `bun zt gate:status` — what the gate is doing.
|
|
130
|
+
*
|
|
131
|
+
* @category Diagnostics
|
|
132
|
+
*/
|
|
133
|
+
export class GateStatusCommand extends Command {
|
|
134
|
+
static override commandName = "gate:status";
|
|
135
|
+
static override description = "Show whether the site is open, in maintenance, or in preview";
|
|
136
|
+
static override needsApp = false;
|
|
137
|
+
static override args = [];
|
|
138
|
+
static override flags = [
|
|
139
|
+
{ name: "json", type: "boolean" as const, description: "Print as JSON", default: false },
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
async run(): Promise<void> {
|
|
143
|
+
const status = Gate.status();
|
|
144
|
+
|
|
145
|
+
if (this.flags["json"] === true) {
|
|
146
|
+
// `write`, not `line`: the other helpers colour, and a colour code is not
|
|
147
|
+
// JSON.
|
|
148
|
+
this.write(`${JSON.stringify(status, null, 2)}\n`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (status.mode === "open") {
|
|
153
|
+
this.info("Site is open.");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
this.table([
|
|
158
|
+
["Mode", status.mode],
|
|
159
|
+
["Since", status.since ?? "—"],
|
|
160
|
+
["Until", status.until ?? "—"],
|
|
161
|
+
["By", status.by ?? "—"],
|
|
162
|
+
...(status.mode === "maintenance"
|
|
163
|
+
? ([["Retry-After", String(status.retryAfter ?? 60)]] as [string, string][])
|
|
164
|
+
: []),
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
if (status.expired) {
|
|
168
|
+
this.warn("This preview's window has passed — it is no longer gating anything.");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -34,6 +34,7 @@ export { KeyGenerateCommand } from "./KeyGenerateCommand.ts";
|
|
|
34
34
|
export { ReloadCommand } from "./ReloadCommand.ts";
|
|
35
35
|
export { StatusCommand } from "./StatusCommand.ts";
|
|
36
36
|
export { VersionCommand } from "./VersionCommand.ts";
|
|
37
|
+
export { DownCommand, PreviewCommand, UpCommand, GateStatusCommand } from "./GateCommands.ts";
|
|
37
38
|
export { MakeControllerCommand } from "./MakeControllerCommand.ts";
|
|
38
39
|
export { MakeMiddlewareCommand } from "./MakeMiddlewareCommand.ts";
|
|
39
40
|
export { MakeCommandCommand } from "./MakeCommandCommand.ts";
|
package/src/doctor/AppDoctor.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* scheduler's static-config check is the model); the built-ins cover core.
|
|
13
13
|
*/
|
|
14
14
|
import { readdirSync } from "node:fs";
|
|
15
|
+
import { siteGateCheck } from "../gate/doctor.ts";
|
|
15
16
|
import type { Application } from "../application/Application.ts";
|
|
16
17
|
import { appKeyStrengthWarning } from "../support/appKey.ts";
|
|
17
18
|
import { unroutedRoutesWarning } from "../support/unroutedRoutes.ts";
|
|
@@ -409,6 +410,7 @@ const throttleIdentityCheck: DoctorCheck = {
|
|
|
409
410
|
*/
|
|
410
411
|
export const builtinDoctorChecks: DoctorCheck[] = [
|
|
411
412
|
appKeyCheck,
|
|
413
|
+
siteGateCheck,
|
|
412
414
|
allowedOriginsCheck,
|
|
413
415
|
corsWildcardCheck,
|
|
414
416
|
secureHeadersCheck,
|