@zerotal/core 1.0.4 → 1.3.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/CHANGELOG.md +14 -1
- package/package.json +1 -1
- package/src/command/builtin/TestCommand.ts +18 -3
- package/src/config/AppConfig.ts +31 -1
- package/src/config/index.ts +1 -0
- package/src/crypt/URLSigner.ts +9 -2
- package/src/dev/CssPlugins.ts +11 -0
- package/src/dev/DevOrchestrator.ts +129 -24
- package/src/http/index.ts +5 -0
- package/src/pipeline/HttpContext.ts +23 -0
- package/src/security/index.ts +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Changelog
|
|
1
|
+
# Changelog — @zerotal/core
|
|
2
2
|
|
|
3
3
|
All notable changes to this package are documented here. The format is
|
|
4
4
|
based on [Keep a Changelog](https://keepachangelog.com/); this package
|
|
@@ -8,6 +8,19 @@ follows the Zerotal monorepo's unified versioning.
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [1.1.0] — 2026-08-08
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **`bun zt test` no longer fails a cold suite on Bun's 5-second hook timeout.** A `beforeAll` that calls `createTestApp()` boots providers and runs migrations, which exceeds 5s on a cold start; `bunfig.toml`'s `[test] timeout` does not cover hooks, only the CLI flag does. The failure appeared on the first run and not the second, which reads as flakiness and sends you looking for a race that is not there. The default is now 30s, still overridable with `--timeout`.
|
|
16
|
+
- **`serve --dev` no longer loses its server to a restart race.** The debounce timer spaced out the _scheduling_ of a restart, but its callback cleared the timer and then awaited a rebuild that can take seconds — so a change arriving in that window scheduled a second callback which ran concurrently. Both reached the spawn, two servers raced for the port, and the loser died with "Failed to start server. Is port 3000 in use?". Dev mode was then left owning no server while the winner kept serving stale code, so every later save appeared to do nothing. Restarts are now serialized: a request arriving mid-restart queues exactly one follow-up instead of running in parallel. A failed bind also retries briefly, since the OS releases a listening socket asynchronously after the previous owner exits, and the initial spawn uses the same path — an orphan from a previous run is the commonest reason the first bind fails. An unexpected exit is reported against the child that actually exited rather than whichever child the field happened to hold.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- `ctx.param(name, fallback?)` — the single-value route-parameter accessor, matching `string()` and `header()` in shape. Only the `params` record existed, though every other single-value read on the object is a method. Unlike `string()` it does not fall back to the query string: a route parameter either matched or it did not.
|
|
21
|
+
- `URLSigner` is exported from `@zerotal/core/security`. It carried a full documented example but was reachable only through a deep internal path, which is a worse dependency to take than reimplementing it. (`Url` / `url` were already exported — from the **http** subpath, not security; the docblock now says so.)
|
|
22
|
+
- `app.assets.loader` — per-extension bundler loader overrides, e.g. `{ ".woff2": "file" }`. Bun inlines small `url()` assets as data URIs, which is right for an icon and wrong for a font: the bytes move into the render-blocking stylesheet, so nine woff2 subsets turned a 36 KB stylesheet into 260 KB that had to download before first paint. There was no configuration to turn it off.
|
|
23
|
+
|
|
11
24
|
## [1.0.3] — 2026-08-07
|
|
12
25
|
|
|
13
26
|
### Changed
|
package/package.json
CHANGED
|
@@ -28,6 +28,15 @@ export class TestCommand extends Command {
|
|
|
28
28
|
static description = "Run the test suite in test environment";
|
|
29
29
|
static needsApp = false;
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Default per-test and per-hook timeout, in milliseconds.
|
|
33
|
+
*
|
|
34
|
+
* Generous on purpose: the expensive thing in a Zerotal suite is booting the app in a
|
|
35
|
+
* `beforeAll`, which Bun's 5s default does not allow for. Raising it costs nothing on a
|
|
36
|
+
* passing suite and removes a failure mode that looks exactly like flakiness.
|
|
37
|
+
*/
|
|
38
|
+
static readonly DEFAULT_TIMEOUT_MS = 30_000;
|
|
39
|
+
|
|
31
40
|
static override args = [{ name: "pattern", required: false, default: "" }];
|
|
32
41
|
|
|
33
42
|
static override flags = [
|
|
@@ -47,7 +56,7 @@ export class TestCommand extends Command {
|
|
|
47
56
|
{
|
|
48
57
|
name: "timeout",
|
|
49
58
|
type: "number" as const,
|
|
50
|
-
description:
|
|
59
|
+
description: `Per-test and per-hook timeout in milliseconds (default ${TestCommand.DEFAULT_TIMEOUT_MS})`,
|
|
51
60
|
default: 0,
|
|
52
61
|
},
|
|
53
62
|
{
|
|
@@ -103,8 +112,14 @@ export class TestCommand extends Command {
|
|
|
103
112
|
if (this.flags["watch"]) bunArguments.push("--watch");
|
|
104
113
|
if (this.flags["bail"]) bunArguments.push("--bail");
|
|
105
114
|
|
|
106
|
-
|
|
107
|
-
|
|
115
|
+
// Bun's default is 5s and applies to hooks as well as tests — and `bunfig.toml`'s
|
|
116
|
+
// `[test] timeout` does not cover hooks, only this flag does. A `beforeAll` that calls
|
|
117
|
+
// `createTestApp()` boots providers and runs migrations, which on a cold start with a
|
|
118
|
+
// dozen providers exceeds 5s; the failure lands on the first run and not the second,
|
|
119
|
+
// so it reads as a flaky test and sends you hunting for a race that is not there.
|
|
120
|
+
// A framework's own test runner should account for that framework's boot cost.
|
|
121
|
+
const timeout = (this.flags["timeout"] as number | undefined) || TestCommand.DEFAULT_TIMEOUT_MS;
|
|
122
|
+
bunArguments.push(`--timeout=${timeout}`);
|
|
108
123
|
|
|
109
124
|
this.dim(`APP_ENV=test ZT_DB_URL=${dbUrl}`);
|
|
110
125
|
this.dim(`bun ${bunArguments.join(" ")}\n`);
|
package/src/config/AppConfig.ts
CHANGED
|
@@ -66,8 +66,29 @@ export interface AppAssetsConfig {
|
|
|
66
66
|
prefix: string;
|
|
67
67
|
/** Minify output. Default: true in production, false otherwise. */
|
|
68
68
|
minify: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Per-extension loader overrides handed to Bun's bundler, e.g.
|
|
71
|
+
* `{ ".woff2": "file" }`.
|
|
72
|
+
*
|
|
73
|
+
* Bun inlines small files a stylesheet `url()`s as data URIs. That is the right
|
|
74
|
+
* default for an icon, and the wrong one for a font: the bytes move into the
|
|
75
|
+
* render-blocking stylesheet, so nine woff2 subsets can turn a 36KB stylesheet into
|
|
76
|
+
* 260KB that must download before first paint — the opposite of what `font-display:
|
|
77
|
+
* swap` is for. `{ ".woff2": "file" }` emits them as separate files again.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* // config/app.ts
|
|
81
|
+
* assets: { entrypoint: 'resources/css/app.css', loader: { '.woff2': 'file', '.woff': 'file' } }
|
|
82
|
+
*/
|
|
83
|
+
loader?: Record<string, AssetLoaderKind>;
|
|
69
84
|
}
|
|
70
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Bun bundler loaders that are meaningful for an asset referenced from CSS/JS.
|
|
88
|
+
* Mirrors Bun's `Loader` union, minus the source-code loaders a `url()` cannot name.
|
|
89
|
+
*/
|
|
90
|
+
export type AssetLoaderKind = "file" | "dataurl" | "base64" | "text" | "json" | "toml";
|
|
91
|
+
|
|
71
92
|
/**
|
|
72
93
|
* Default request-body ceiling: 8 MiB.
|
|
73
94
|
*
|
|
@@ -214,7 +235,13 @@ export function AppConfig(options: {
|
|
|
214
235
|
cors?: Partial<AppCorsConfig>;
|
|
215
236
|
throttle?: Partial<AppThrottleConfig>;
|
|
216
237
|
secureHeaders?: Partial<AppSecureHeadersConfig>;
|
|
217
|
-
assets?: {
|
|
238
|
+
assets?: {
|
|
239
|
+
entrypoint: string | string[];
|
|
240
|
+
outDir?: string;
|
|
241
|
+
prefix?: string;
|
|
242
|
+
minify?: boolean;
|
|
243
|
+
loader?: Record<string, AssetLoaderKind>;
|
|
244
|
+
};
|
|
218
245
|
conventions?: { enabled?: boolean; paths?: Partial<ConventionsConfig["paths"]> };
|
|
219
246
|
}): AppConfigShape {
|
|
220
247
|
// Resolve env-derived defaults, then deep-merge the caller's overrides so partial nested
|
|
@@ -247,6 +274,9 @@ export function AppConfig(options: {
|
|
|
247
274
|
outDir: options.assets.outDir ?? "public",
|
|
248
275
|
prefix: options.assets.prefix ?? "/",
|
|
249
276
|
minify: options.assets.minify ?? isProduction,
|
|
277
|
+
// Only set when declared: an explicit `undefined` is a distinct value under
|
|
278
|
+
// exactOptionalPropertyTypes, and would override Bun's own defaults with nothing.
|
|
279
|
+
...(options.assets.loader ? { loader: options.assets.loader } : {}),
|
|
250
280
|
};
|
|
251
281
|
}
|
|
252
282
|
return resolved;
|
package/src/config/index.ts
CHANGED
package/src/crypt/URLSigner.ts
CHANGED
|
@@ -8,11 +8,18 @@ import { safeEqual, hmacHex } from "../support/crypto.ts";
|
|
|
8
8
|
* the full URL (without the signature param) sorted by key so that the
|
|
9
9
|
* order of other query parameters doesn't matter.
|
|
10
10
|
*
|
|
11
|
-
* For app-key-keyed signing without managing a secret, prefer the
|
|
12
|
-
*
|
|
11
|
+
* For app-key-keyed signing without managing a secret, prefer the `Url` facade
|
|
12
|
+
* (`Url.sign` / `Url.verify`), which derives the secret from `APP_KEY`. It is exported
|
|
13
|
+
* from the **http** subpath, not this one:
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* import { Url } from "zerotal/http"; // or "@zerotal/core/http"
|
|
17
|
+
* ```
|
|
13
18
|
*
|
|
14
19
|
* @example
|
|
15
20
|
* ```ts
|
|
21
|
+
* import { URLSigner } from "zerotal/security"; // or "@zerotal/core/security"
|
|
22
|
+
*
|
|
16
23
|
* const signer = new URLSigner(process.env.APP_KEY!);
|
|
17
24
|
*
|
|
18
25
|
* // Generate a link that expires in 15 minutes:
|
package/src/dev/CssPlugins.ts
CHANGED
|
@@ -45,6 +45,7 @@ export async function buildCssBundle(
|
|
|
45
45
|
input: string,
|
|
46
46
|
outdir: string,
|
|
47
47
|
minify = false,
|
|
48
|
+
loader?: Record<string, string>,
|
|
48
49
|
): Promise<{ success: boolean; logs: unknown[] }> {
|
|
49
50
|
const cwd = process.cwd();
|
|
50
51
|
const plugins = await detectCssPlugins(cwd);
|
|
@@ -57,6 +58,9 @@ export async function buildCssBundle(
|
|
|
57
58
|
target: "browser",
|
|
58
59
|
minify,
|
|
59
60
|
plugins,
|
|
61
|
+
...(loader
|
|
62
|
+
? { loader: loader as NonNullable<Parameters<typeof Bun.build>[0]["loader"]> }
|
|
63
|
+
: {}),
|
|
60
64
|
});
|
|
61
65
|
}
|
|
62
66
|
|
|
@@ -135,6 +139,8 @@ export interface AssetBuildConfig {
|
|
|
135
139
|
outDir: string;
|
|
136
140
|
prefix: string;
|
|
137
141
|
minify: boolean;
|
|
142
|
+
/** Per-extension loader overrides (e.g. `{ ".woff2": "file" }`). See AppAssetsConfig. */
|
|
143
|
+
loader?: Record<string, string>;
|
|
138
144
|
}
|
|
139
145
|
|
|
140
146
|
/**
|
|
@@ -174,6 +180,11 @@ export async function buildConfiguredAssets(
|
|
|
174
180
|
sourcemap: assets.minify ? "none" : "external",
|
|
175
181
|
minify: assets.minify,
|
|
176
182
|
plugins,
|
|
183
|
+
// `app.assets.loader` — e.g. `{ ".woff2": "file" }` to stop fonts being inlined
|
|
184
|
+
// as data URIs into the render-blocking stylesheet.
|
|
185
|
+
...(assets.loader
|
|
186
|
+
? { loader: assets.loader as NonNullable<Parameters<typeof Bun.build>[0]["loader"]> }
|
|
187
|
+
: {}),
|
|
177
188
|
});
|
|
178
189
|
|
|
179
190
|
if (result.success) await pruneBuildOutput(outdir, result.outputs);
|
|
@@ -45,6 +45,27 @@ export class DevOrchestrator {
|
|
|
45
45
|
private _child: ReturnType<typeof Bun.spawn> | null = null;
|
|
46
46
|
private _restartTimer: ReturnType<typeof setTimeout> | null = null;
|
|
47
47
|
private _buildTimer: ReturnType<typeof setTimeout> | null = null;
|
|
48
|
+
/**
|
|
49
|
+
* Serializes restarts. The debounce timer only spaces out the *scheduling* of a
|
|
50
|
+
* restart — once its callback starts it clears the timer and then awaits a rebuild
|
|
51
|
+
* that can take seconds, during which another change schedules a second callback.
|
|
52
|
+
* Both then reached `_spawnServer()`, so two servers raced for the port, one lost
|
|
53
|
+
* with "Failed to start server. Is port 3000 in use?", and the dev server was left
|
|
54
|
+
* dead while the winner kept serving. A restart requested while one is running now
|
|
55
|
+
* queues a single follow-up instead of running in parallel.
|
|
56
|
+
*/
|
|
57
|
+
private _restartInFlight: Promise<void> | null = null;
|
|
58
|
+
private _restartQueued = false;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Bounded respawn attempts. The race above is fixed at the source, but a port can
|
|
62
|
+
* still be briefly unavailable after the previous owner exits — the OS releases the
|
|
63
|
+
* listening socket asynchronously — so a lost bind retries rather than killing dev mode.
|
|
64
|
+
*/
|
|
65
|
+
private static readonly RESPAWN_ATTEMPTS = 3;
|
|
66
|
+
private static readonly RESPAWN_DELAY_MS = 300;
|
|
67
|
+
/** A server that binds stays up; one that loses the port exits almost immediately. */
|
|
68
|
+
private static readonly BIND_SETTLE_MS = 400;
|
|
48
69
|
/** Per-build asset-version token — bumped on each build, busts `asset()` `?v=` URLs. */
|
|
49
70
|
private _assetVersion = Date.now().toString(36);
|
|
50
71
|
|
|
@@ -61,7 +82,9 @@ export class DevOrchestrator {
|
|
|
61
82
|
console.warn(" [zerotal:dev] ⚠ initial build failed — starting server anyway");
|
|
62
83
|
}
|
|
63
84
|
|
|
64
|
-
|
|
85
|
+
// Same retry as a restart: the commonest reason the first bind fails is an orphaned
|
|
86
|
+
// server from a previous run that has not finished exiting.
|
|
87
|
+
await this._spawnServerWithRetry();
|
|
65
88
|
this._watch();
|
|
66
89
|
|
|
67
90
|
// Park the process — cleanup happens in signal handlers registered by _watch()
|
|
@@ -70,8 +93,8 @@ export class DevOrchestrator {
|
|
|
70
93
|
|
|
71
94
|
// ── Server management ──────────────────────────────────────────────────────
|
|
72
95
|
|
|
73
|
-
private _spawnServer():
|
|
74
|
-
|
|
96
|
+
private _spawnServer(): ReturnType<typeof Bun.spawn> {
|
|
97
|
+
const child = Bun.spawn(
|
|
75
98
|
["bun", Bun.main, "serve", "--port", String(this._port), "--dev-worker"],
|
|
76
99
|
{
|
|
77
100
|
stdin: "pipe",
|
|
@@ -91,40 +114,122 @@ export class DevOrchestrator {
|
|
|
91
114
|
},
|
|
92
115
|
);
|
|
93
116
|
|
|
94
|
-
this._child
|
|
95
|
-
|
|
96
|
-
|
|
117
|
+
this._child = child;
|
|
118
|
+
|
|
119
|
+
void child.exited.then((code) => {
|
|
120
|
+
// Report only the *current* server dying unexpectedly. Comparing against
|
|
121
|
+
// `this._child` identity matters: reading the field alone reported an exit that a
|
|
122
|
+
// restart had deliberately caused, because by then the field held the replacement.
|
|
123
|
+
// A restart in flight owns its own reporting.
|
|
124
|
+
if (this._child === child && this._restartInFlight === null && code !== 0) {
|
|
97
125
|
console.log(` [zerotal:dev] server exited with code ${code}`);
|
|
98
126
|
}
|
|
99
127
|
});
|
|
128
|
+
|
|
129
|
+
return child;
|
|
100
130
|
}
|
|
101
131
|
|
|
102
132
|
private _scheduleRestart(): void {
|
|
103
133
|
if (this._restartTimer) clearTimeout(this._restartTimer);
|
|
104
|
-
this._restartTimer = setTimeout(
|
|
134
|
+
this._restartTimer = setTimeout(() => {
|
|
105
135
|
this._restartTimer = null;
|
|
106
|
-
|
|
136
|
+
void this._requestRestart();
|
|
137
|
+
}, 150);
|
|
138
|
+
}
|
|
107
139
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
140
|
+
/**
|
|
141
|
+
* Run a restart, or fold this request into the one already running.
|
|
142
|
+
*
|
|
143
|
+
* Restarts must not overlap: each one kills the current server and binds a new one to
|
|
144
|
+
* the same port, so two in flight means two servers competing for it. A request that
|
|
145
|
+
* arrives mid-restart sets a flag instead, and exactly one more restart runs when the
|
|
146
|
+
* current one finishes — enough to pick up whatever changed, without a queue that
|
|
147
|
+
* grows one entry per keystroke.
|
|
148
|
+
*/
|
|
149
|
+
private _requestRestart(): Promise<void> {
|
|
150
|
+
if (this._restartInFlight) {
|
|
151
|
+
this._restartQueued = true;
|
|
152
|
+
return this._restartInFlight;
|
|
153
|
+
}
|
|
115
154
|
|
|
116
|
-
|
|
117
|
-
|
|
155
|
+
const run = async (): Promise<void> => {
|
|
156
|
+
try {
|
|
157
|
+
do {
|
|
158
|
+
this._restartQueued = false;
|
|
159
|
+
await this._restartOnce();
|
|
160
|
+
} while (this._restartQueued);
|
|
161
|
+
} finally {
|
|
162
|
+
this._restartInFlight = null;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
this._restartInFlight = run();
|
|
167
|
+
return this._restartInFlight;
|
|
168
|
+
}
|
|
118
169
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
170
|
+
private async _restartOnce(): Promise<void> {
|
|
171
|
+
console.log(" [zerotal:dev] ↻ backend change — rebuilding + restarting server...");
|
|
172
|
+
|
|
173
|
+
// Rebuild assets before respawning: server-rendered views (Flow pages in `app/`,
|
|
174
|
+
// controllers returning markup) contain Tailwind classes the stylesheet scans via
|
|
175
|
+
// `@source`, so a backend edit can introduce new classes. Without this, a new utility
|
|
176
|
+
// used in a page wouldn't appear until an unrelated `resources/` file changed. The
|
|
177
|
+
// fresh `_assetVersion` (bumped by _runBuild) is passed to the respawned worker, so the
|
|
178
|
+
// browser refetches the updated CSS.
|
|
179
|
+
await this._runBuild();
|
|
180
|
+
|
|
181
|
+
// Stop before spawning, always: the old server owns the port until it exits.
|
|
182
|
+
await this._stopChild();
|
|
183
|
+
await this._spawnServerWithRetry();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Stop the running server and wait for it to actually exit, releasing the port. */
|
|
187
|
+
private async _stopChild(): Promise<void> {
|
|
188
|
+
const child = this._child;
|
|
189
|
+
this._child = null;
|
|
190
|
+
if (!child) return;
|
|
191
|
+
|
|
192
|
+
child.kill("SIGTERM");
|
|
193
|
+
const forceKill = setTimeout(() => child.kill("SIGKILL"), 1_500);
|
|
194
|
+
try {
|
|
195
|
+
await child.exited;
|
|
196
|
+
} finally {
|
|
197
|
+
clearTimeout(forceKill);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Spawn the server, retrying briefly if it dies on startup.
|
|
203
|
+
*
|
|
204
|
+
* A bind failure is indistinguishable from an application crash by exit code alone, so
|
|
205
|
+
* this retries either — a crash simply fails the remaining attempts quickly and reports.
|
|
206
|
+
* Leaving dev mode with no server and no explanation is the worse outcome: the watcher
|
|
207
|
+
* stays alive, so the next save appears to do nothing while the browser talks to
|
|
208
|
+
* whatever is still listening.
|
|
209
|
+
*/
|
|
210
|
+
private async _spawnServerWithRetry(): Promise<void> {
|
|
211
|
+
for (let attempt = 1; attempt <= DevOrchestrator.RESPAWN_ATTEMPTS; attempt++) {
|
|
212
|
+
const child = this._spawnServer();
|
|
213
|
+
|
|
214
|
+
const settled = await Promise.race([
|
|
215
|
+
child.exited.then(() => true),
|
|
216
|
+
Bun.sleep(DevOrchestrator.BIND_SETTLE_MS).then(() => false),
|
|
217
|
+
]);
|
|
218
|
+
|
|
219
|
+
if (!settled) return; // still running → it bound the port
|
|
220
|
+
if (this._child !== child) return; // superseded by a newer restart
|
|
221
|
+
|
|
222
|
+
this._child = null;
|
|
223
|
+
if (attempt < DevOrchestrator.RESPAWN_ATTEMPTS) {
|
|
224
|
+
await Bun.sleep(DevOrchestrator.RESPAWN_DELAY_MS);
|
|
124
225
|
}
|
|
226
|
+
}
|
|
125
227
|
|
|
126
|
-
|
|
127
|
-
|
|
228
|
+
console.error(
|
|
229
|
+
` [zerotal:dev] ✗ server did not start after ${DevOrchestrator.RESPAWN_ATTEMPTS} attempts.\n` +
|
|
230
|
+
` Port ${this._port} may be held by another process — the error above says which.\n` +
|
|
231
|
+
` Dev mode is still watching; fix the cause and save to retry.`,
|
|
232
|
+
);
|
|
128
233
|
}
|
|
129
234
|
|
|
130
235
|
// ── Build management ───────────────────────────────────────────────────────
|
package/src/http/index.ts
CHANGED
|
@@ -30,6 +30,11 @@ export type { UrlGenerator } from "./url.ts";
|
|
|
30
30
|
export { Http } from "./Http.ts";
|
|
31
31
|
export { UploadedFile } from "./UploadedFile.ts";
|
|
32
32
|
export type { StorageDisk, FileValidationOptions } from "./UploadedFile.ts";
|
|
33
|
+
// Exported for code that must identify bytes it did not receive as an upload —
|
|
34
|
+
// a file fetched from a URL or read back off a disk still needs its type read
|
|
35
|
+
// from its own contents rather than from whatever claimed to describe it.
|
|
36
|
+
export { sniffContentType } from "./sniffContentType.ts";
|
|
37
|
+
export type { SniffedType } from "./sniffContentType.ts";
|
|
33
38
|
export { HttpClientResponse, HttpClientError, PendingRequest } from "./HttpClient.ts";
|
|
34
39
|
export type { FakeStub } from "./HttpClient.ts";
|
|
35
40
|
export { Resource, ResourceCollection } from "./Resource.ts";
|
|
@@ -550,6 +550,29 @@ export class HttpContext<TParams extends Record<string, unknown> = Record<string
|
|
|
550
550
|
return (this.params[key] as string | undefined) ?? this.url.searchParams.get(key) ?? fallback;
|
|
551
551
|
}
|
|
552
552
|
|
|
553
|
+
/**
|
|
554
|
+
* Read a single route parameter.
|
|
555
|
+
*
|
|
556
|
+
* `params` is the whole record; this is the one-value accessor that matches
|
|
557
|
+
* {@link HttpContext.string} and {@link HttpContext.header} in shape. Every other
|
|
558
|
+
* single-value read on this object is a method, so reaching for `param('token')` and
|
|
559
|
+
* finding only `params['token']` is a needless inconsistency — even caught at compile
|
|
560
|
+
* time, it is one more thing to remember for no benefit.
|
|
561
|
+
*
|
|
562
|
+
* Unlike {@link HttpContext.string} this does **not** fall back to the query string: a
|
|
563
|
+
* route parameter either matched or it did not, and quietly answering with a
|
|
564
|
+
* user-supplied query value would let `?id=1` stand in for a path segment.
|
|
565
|
+
*
|
|
566
|
+
* @example
|
|
567
|
+
* const token = ctx.param('token');
|
|
568
|
+
* const page = ctx.param('page', '1');
|
|
569
|
+
*
|
|
570
|
+
* @category Request data
|
|
571
|
+
*/
|
|
572
|
+
param(key: string, fallback?: string): string | undefined {
|
|
573
|
+
return (this.params[key] as string | undefined) ?? fallback;
|
|
574
|
+
}
|
|
575
|
+
|
|
553
576
|
/**
|
|
554
577
|
* Read a query param or route param coerced to a boolean.
|
|
555
578
|
* Truthy values: `'1'`, `'true'`, `'yes'`, `'on'` (case-insensitive).
|
package/src/security/index.ts
CHANGED
|
@@ -20,3 +20,9 @@
|
|
|
20
20
|
export { Crypt, CryptKeyMissingError, DecryptionError } from "../crypt/Crypt.ts";
|
|
21
21
|
export { Hash } from "../hash/Hash.ts";
|
|
22
22
|
export type { HashAlgorithm } from "../hash/Hash.ts";
|
|
23
|
+
// Signed URLs with a secret you manage yourself. Documented with a full example on the
|
|
24
|
+
// class, but previously reachable only through a deep internal path — which is a worse
|
|
25
|
+
// dependency to take than reimplementing it, so people reimplemented it. For app-key-keyed
|
|
26
|
+
// signing with no secret to manage, `Url.sign` / `Url.verify` on the http subpath is the
|
|
27
|
+
// higher-level option.
|
|
28
|
+
export { URLSigner } from "../crypt/URLSigner.ts";
|