Skip to content

Commit aaa4804

Browse files
authored
Feat/inputvalidation (#4)
* [feat][wip] Input validation for backend routes * [feat][wip] Input validation for backend routes - Add app handling * [chore] Add test file * [feat][wip] Input validation * [chore] Upgrade dependencies * [feat] Add TTL Expiry in atomic store * [chore] Add additional note in changelog * [chore] Add clarifying note about ttl being in milliseconds * [chore] Further readme updates * [chore] Further readme updates * [chore] Further readme updates * [chore] Further readme updates * [chore] Further readme updates * [chore] Further readme updates * [feat] Update tests * [chore] Update package-lock * [chore] Finalize tests
1 parent 3f638ed commit aaa4804

File tree

29 files changed

+1078
-1619
lines changed

29 files changed

+1078
-1619
lines changed

CHANGELOG.md

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,66 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
8+
### Added
9+
- **feat**: Route registration now supports setting up input validation schema.
10+
- **feat**: Global atomic store (accessed through `$.storeGet/Set/Del`) now supports **per-key TTLs (time-to-live)** and **reactive expiry events**. Entries automatically expire after their TTL, emit a `$store:{KEY}:expired` event, and cleanly remove themselves from memory and local storage.
11+
```typescript
12+
$.storeSet(key, value, {ttl?: number /* in milliseconds */, persist?: boolean});
13+
```
14+
815
### Improved
9-
- **deps**: Upgrade @valkyriestudios/utils to 12.47.0
10-
- **deps**: Upgrade @cloudflare/workers-types to 4.20251014.0
11-
- **deps**: Upgrade @types/node to 22.18.2
12-
- **deps**: Upgrade @vitest/coverage-v8 to 4.0.4
13-
- **deps**: Upgrade bun-types to 1.3.1
14-
- **deps**: Upgrade eslint to 9.38.0
16+
- **deps**: Upgrade @valkyriestudios/utils to 12.48.0
17+
- **deps**: Upgrade @cloudflare/workers-types to 4.20260103.0
18+
- **deps**: Upgrade @types/node to 22.19.3
19+
- **deps**: Upgrade @vitest/coverage-v8 to 4.0.16
20+
- **deps**: Upgrade bun-types to 1.3.5
21+
- **deps**: Upgrade eslint to 9.39.2
22+
- **deps**: Upgrade prettier to 3.7.4
1523
- **deps**: Upgrade typescript to 5.9.3
16-
- **deps**: Upgrade typescript-eslint to 8.46.2
17-
- **deps**: Upgrade vitest to 4.0.4
24+
- **deps**: Upgrade typescript-eslint to 8.51.0
25+
- **deps**: Upgrade vitest to 4.0.16
26+
27+
### Fixed
28+
- Fixed an edge-case issue where if an entry to the atomic-store was previously set using `persist: true` and then set using `persist: false` it would still linger in local storage and only be removed during `storeDel`.
29+
30+
---
31+
32+
### More about TTL expiry
33+
Each key now emits:
34+
- **$store:{KEY}**: On set or manual delete
35+
- **$store:{KEY}:expired**: When its TTL elapses naturally
36+
37+
This makes the atomic store **time-aware and reactive**, enabling token renewal, cache invalidation, live dashboards, ... **without polling or background loops**.
38+
39+
Atomic now natively handles **self-expiring state**, fully deterministic and zero-idle.
40+
41+
**Additional notes**:
42+
- The provided TTL is **in milliseconds**
43+
- Like the `$store:{KEY}` events, the new `$store:{KEY}:expired` events are **fully typed**.
44+
- On expiry, **only** `$store:{KEY}:expired` is emitted, this prevents unnecessary updates for consumers of `$store:{KEY}`, allowing refresh logic to remain isolated..
45+
46+
### Examples on TTL expiry
47+
##### Auth token refresh
48+
```typescript
49+
// Expire after 1 hour
50+
$.storeSet('token', 'abc123', { ttl: 3_600_000, persist: true });
51+
52+
// Subscribe within a VM
53+
el.$subscribe('$store:token:expired', () => $.fetch('/auth/refresh'));
54+
```
55+
##### Dashboard auto-refresh
56+
```typescript
57+
async function load () {
58+
// Fetch dashboard data (example)
59+
const data = await $.fetch('/api/dashboard');
60+
61+
// Set and expire after 10 seconds
62+
$.storeSet('dashboard_data', data, { ttl: 10_000 });
63+
}
64+
65+
// Subscribe within a VM
66+
el.$subscribe('$store:dashboard_data:expired', load);
67+
```
1868

1969
## [1.4.1] - 2025-09-14
2070
### Fixed

lib/App.ts

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {isIntGt} from '@valkyriestudios/utils/number';
22
import {isObject} from '@valkyriestudios/utils/object';
3+
import {hexId} from '@valkyriestudios/utils/hash';
34
import {type TriFrostCache} from './modules/Cache';
45
import {type TriFrostCookieOptions} from './modules/Cookies';
56
import {TriFrostRateLimit, type TriFrostRateLimitLimitFunction} from './modules/RateLimit/_RateLimit';
@@ -26,7 +27,7 @@ import {mount as mountCss} from './modules/JSX/style/mount';
2627
import {mount as mountScript} from './modules/JSX/script/mount';
2728
import {type CssGeneric, type CssInstance} from './modules/JSX/style/use';
2829
import {activateCtx} from './utils/Als';
29-
import {hexId} from './utils/Generic';
30+
import {type TFValidator} from './types/validation';
3031

3132
const RGX_RID = /^[a-z0-9-]{8,64}$/i;
3233

@@ -300,6 +301,26 @@ class App<Env extends Record<string, any>, State extends Record<string, unknown>
300301
await ctx.init(match);
301302
if (ctx.statusCode >= 400) return await runTriage(path, ctx);
302303

304+
/* If route has a validator, run it */
305+
if (match.route.input) {
306+
try {
307+
const parsed = match.route.input.parse({
308+
body: ctx.body,
309+
query: ctx.query,
310+
});
311+
// overwrite ctx.body/query with parsed values (safe cast)
312+
ctx.body = parsed.body;
313+
ctx.query = parsed.query;
314+
} catch (err) {
315+
if (match.route.input.onInvalid) {
316+
await match.route.input.onInvalid(ctx, err);
317+
} else {
318+
ctx.setStatus(400);
319+
}
320+
return await runTriage(path, ctx);
321+
}
322+
}
323+
303324
/* Run chain */
304325
for (let i = 0; i < match.route.middleware.length; i++) {
305326
const el = match.route.middleware[i];
@@ -439,39 +460,54 @@ class App<Env extends Record<string, any>, State extends Record<string, unknown>
439460
/**
440461
* Configure a HTTP Get route
441462
*/
442-
get<Path extends string = string>(path: Path, handler: TriFrostRouteHandler<Env, State & PathParam<Path>>) {
463+
get<
464+
Path extends string = string,
465+
TV extends TFValidator<any, Env, State & PathParam<Path>> = TFValidator<any, Env, State & PathParam<Path>>,
466+
>(path: Path, handler: TriFrostRouteHandler<Env, State & PathParam<Path>, TV>) {
443467
super.get(path, handler);
444468
return this;
445469
}
446470

447471
/**
448472
* Configure a HTTP Post route
449473
*/
450-
post<Path extends string = string>(path: Path, handler: TriFrostRouteHandler<Env, State & PathParam<Path>>) {
474+
post<
475+
Path extends string = string,
476+
TV extends TFValidator<any, Env, State & PathParam<Path>> = TFValidator<any, Env, State & PathParam<Path>>,
477+
>(path: Path, handler: TriFrostRouteHandler<Env, State & PathParam<Path>, TV>) {
451478
super.post(path, handler);
452479
return this;
453480
}
454481

455482
/**
456483
* Configure a HTTP Patch route
457484
*/
458-
patch<Path extends string = string>(path: Path, handler: TriFrostRouteHandler<Env, State & PathParam<Path>>) {
485+
patch<
486+
Path extends string = string,
487+
TV extends TFValidator<any, Env, State & PathParam<Path>> = TFValidator<any, Env, State & PathParam<Path>>,
488+
>(path: Path, handler: TriFrostRouteHandler<Env, State & PathParam<Path>, TV>) {
459489
super.patch(path, handler);
460490
return this;
461491
}
462492

463493
/**
464494
* Configure a HTTP Put route
465495
*/
466-
put<Path extends string = string>(path: Path, handler: TriFrostRouteHandler<Env, State & PathParam<Path>>) {
496+
put<
497+
Path extends string = string,
498+
TV extends TFValidator<any, Env, State & PathParam<Path>> = TFValidator<any, Env, State & PathParam<Path>>,
499+
>(path: Path, handler: TriFrostRouteHandler<Env, State & PathParam<Path>, TV>) {
467500
super.put(path, handler);
468501
return this;
469502
}
470503

471504
/**
472505
* Configure a HTTP Delete route
473506
*/
474-
del<Path extends string = string>(path: Path, handler: TriFrostRouteHandler<Env, State & PathParam<Path>>) {
507+
del<
508+
Path extends string = string,
509+
TV extends TFValidator<any, Env, State & PathParam<Path>> = TFValidator<any, Env, State & PathParam<Path>>,
510+
>(path: Path, handler: TriFrostRouteHandler<Env, State & PathParam<Path>, TV>) {
475511
super.del(path, handler);
476512
return this;
477513
}

lib/Context.ts

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import {isObject} from '@valkyriestudios/utils/object';
1+
import {isNeObject, isObject} from '@valkyriestudios/utils/object';
22
import {isNeString} from '@valkyriestudios/utils/string';
3+
import {hexId} from '@valkyriestudios/utils/hash';
34
import {type TriFrostCache} from './modules/Cache';
45
import {Cookies} from './modules/Cookies';
56
import {NONCE_WIN_SCRIPT, NONCEMARKER} from './modules/JSX/ctx/nonce';
@@ -28,8 +29,10 @@ import {
2829
type TriFrostContextRenderOptions,
2930
} from './types/context';
3031
import {encodeFilename, extractDomainFromHost} from './utils/Http';
31-
import {determineHost, injectBefore, prependDocType, hexId} from './utils/Generic';
32-
import {type TriFrostBodyParserOptions, type ParsedBody} from './utils/BodyParser/types';
32+
import {determineHost, injectBefore, prependDocType} from './utils/Generic';
33+
import {type TriFrostBodyParserOptions} from './utils/BodyParser/types';
34+
import {type TFInput} from './types/validation';
35+
import toObject from './utils/Query';
3336

3437
type RequestConfig = {
3538
method: HttpMethod;
@@ -59,8 +62,12 @@ export const IP_HEADER_CANDIDATES: string[] = [
5962
'x-appengine-user-ip',
6063
];
6164

62-
// eslint-disable-next-line prettier/prettier
63-
export abstract class Context<Env extends Record<string, any> = {}, State extends Record<string, unknown> = {}> implements TriFrostContext<Env, State> {
65+
export abstract class Context<
66+
Env extends Record<string, any> = {},
67+
State extends Record<string, unknown> = {},
68+
TInput extends TFInput = TFInput,
69+
> implements TriFrostContext<Env, State, TInput>
70+
{
6471
/**
6572
* MARK: Private
6673
*/
@@ -90,7 +97,10 @@ export abstract class Context<Env extends Record<string, any> = {}, State extend
9097
#cache: TriFrostCache | null = null;
9198

9299
/* TriFrost Route Query. We compute this on an as-needed basis */
93-
#query: URLSearchParams | null = null;
100+
#query: TInput['query'] | null = null;
101+
102+
/* Whether or not a query exists */
103+
#query_has: boolean = false;
94104

95105
/* TriFrost logger instance */
96106
#logger: TriFrostLogger;
@@ -118,7 +128,7 @@ export abstract class Context<Env extends Record<string, any> = {}, State extend
118128
protected req_id: string | null = null;
119129

120130
/* TriFrost Request body */
121-
protected req_body: Readonly<ParsedBody> | null = null;
131+
protected req_body: Readonly<TInput['body']> | null = null;
122132

123133
/* Whether or not the context is initialized */
124134
protected is_initialized: boolean = false;
@@ -162,6 +172,9 @@ export abstract class Context<Env extends Record<string, any> = {}, State extend
162172
}
163173
if (!this.req_id) this.req_id = hexId(16);
164174

175+
/* Set this.#query_has */
176+
this.#query_has = this.req_config.query.length > 0;
177+
165178
/* Instantiate logger */
166179
this.#logger = logger.spawn({
167180
traceId: this.req_id,
@@ -244,7 +257,7 @@ export abstract class Context<Env extends Record<string, any> = {}, State extend
244257
* Returns the host of the context.
245258
*/
246259
get host(): string {
247-
if (this.#host) return this.#host;
260+
if (this.#host !== null) return this.#host;
248261
this.#host = this.getHostFromHeaders() ?? determineHost(this.ctx_config.env);
249262
return this.#host;
250263
}
@@ -282,11 +295,19 @@ export abstract class Context<Env extends Record<string, any> = {}, State extend
282295
/**
283296
* Request Query parameters
284297
*/
285-
get query(): Readonly<URLSearchParams> {
286-
if (!this.#query) this.#query = new URLSearchParams(this.req_config.query);
298+
get query(): Readonly<TInput['query']> {
299+
if (!this.#query) {
300+
this.#query = toObject(this.req_config.query);
301+
this.#query_has = isNeObject(this.#query);
302+
}
287303
return this.#query;
288304
}
289305

306+
set query(val: TInput['query']) {
307+
this.#query = val;
308+
this.#query_has = isNeObject(val);
309+
}
310+
290311
/**
291312
* Cache Instance
292313
*/
@@ -330,8 +351,12 @@ export abstract class Context<Env extends Record<string, any> = {}, State extend
330351
/**
331352
* Request Body
332353
*/
333-
get body(): Readonly<NonNullable<ParsedBody>> {
334-
return this.req_body || {};
354+
get body(): Readonly<NonNullable<TInput['body']>> {
355+
return (this.req_body || {}) as unknown as Readonly<NonNullable<TInput['body']>>;
356+
}
357+
358+
set body(val: TInput['body']) {
359+
this.req_body = val;
335360
}
336361

337362
/**
@@ -532,7 +557,7 @@ export abstract class Context<Env extends Record<string, any> = {}, State extend
532557
/**
533558
* Initializes the request body and parses it into Json or FormData depending on its type
534559
*/
535-
async init(match: TriFrostRouteMatch<Env>, handler?: (config: TriFrostBodyParserOptions | null) => Promise<ParsedBody | null>) {
560+
async init(match: TriFrostRouteMatch<Env>, handler?: (config: TriFrostBodyParserOptions | null) => Promise<TInput['body'] | null>) {
536561
try {
537562
/* No need to do anything if already initialized */
538563
if (this.is_initialized) return;
@@ -559,7 +584,7 @@ export abstract class Context<Env extends Record<string, any> = {}, State extend
559584
if (body === null) {
560585
this.setStatus(413);
561586
} else {
562-
this.req_body = body;
587+
this.req_body = body as TInput['body'];
563588
}
564589
break;
565590
}
@@ -879,9 +904,9 @@ export abstract class Context<Env extends Record<string, any> = {}, State extend
879904
}
880905

881906
/* If keep_query is passed as true and a query exists add it to normalized to */
882-
if (this.query.size && opts?.keep_query !== false) {
907+
if (this.#query_has && opts?.keep_query !== false) {
883908
const prefix = url.indexOf('?') >= 0 ? '&' : '?';
884-
url += prefix + this.query.toString();
909+
url += prefix + this.req_config.query;
885910
}
886911

887912
/* This is a redirect, as such a body should not be present */

lib/middleware/Security.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import {isBoolean} from '@valkyriestudios/utils/boolean';
33
import {isIntGt} from '@valkyriestudios/utils/number';
44
import {isObject} from '@valkyriestudios/utils/object';
55
import {isNeString} from '@valkyriestudios/utils/string';
6+
import {hexId} from '@valkyriestudios/utils/hash';
67
import {Sym_TriFrostDescription, Sym_TriFrostFingerPrint, Sym_TriFrostName} from '../types/constants';
78
import {type TriFrostContext} from '../types/context';
8-
import {hexId} from '../utils/Generic';
99

1010
const RGX_NONCE = /'nonce'/g;
1111

0 commit comments

Comments
 (0)