Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 58 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,66 @@ All notable changes to this project will be documented in this file.
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).

## [Unreleased]
### Added
- **feat**: Route registration now supports setting up input validation schema.
- **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.
```typescript
$.storeSet(key, value, {ttl?: number /* in milliseconds */, persist?: boolean});
```

### Improved
- **deps**: Upgrade @valkyriestudios/utils to 12.47.0
- **deps**: Upgrade @cloudflare/workers-types to 4.20251014.0
- **deps**: Upgrade @types/node to 22.18.2
- **deps**: Upgrade @vitest/coverage-v8 to 4.0.4
- **deps**: Upgrade bun-types to 1.3.1
- **deps**: Upgrade eslint to 9.38.0
- **deps**: Upgrade @valkyriestudios/utils to 12.48.0
- **deps**: Upgrade @cloudflare/workers-types to 4.20260103.0
- **deps**: Upgrade @types/node to 22.19.3
- **deps**: Upgrade @vitest/coverage-v8 to 4.0.16
- **deps**: Upgrade bun-types to 1.3.5
- **deps**: Upgrade eslint to 9.39.2
- **deps**: Upgrade prettier to 3.7.4
- **deps**: Upgrade typescript to 5.9.3
- **deps**: Upgrade typescript-eslint to 8.46.2
- **deps**: Upgrade vitest to 4.0.4
- **deps**: Upgrade typescript-eslint to 8.51.0
- **deps**: Upgrade vitest to 4.0.16

### Fixed
- 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`.

---

### More about TTL expiry
Each key now emits:
- **$store:{KEY}**: On set or manual delete
- **$store:{KEY}:expired**: When its TTL elapses naturally

This makes the atomic store **time-aware and reactive**, enabling token renewal, cache invalidation, live dashboards, ... **without polling or background loops**.

Atomic now natively handles **self-expiring state**, fully deterministic and zero-idle.

**Additional notes**:
- The provided TTL is **in milliseconds**
- Like the `$store:{KEY}` events, the new `$store:{KEY}:expired` events are **fully typed**.
- On expiry, **only** `$store:{KEY}:expired` is emitted, this prevents unnecessary updates for consumers of `$store:{KEY}`, allowing refresh logic to remain isolated..

### Examples on TTL expiry
##### Auth token refresh
```typescript
// Expire after 1 hour
$.storeSet('token', 'abc123', { ttl: 3_600_000, persist: true });

// Subscribe within a VM
el.$subscribe('$store:token:expired', () => $.fetch('/auth/refresh'));
```
##### Dashboard auto-refresh
```typescript
async function load () {
// Fetch dashboard data (example)
const data = await $.fetch('/api/dashboard');

// Set and expire after 10 seconds
$.storeSet('dashboard_data', data, { ttl: 10_000 });
}

// Subscribe within a VM
el.$subscribe('$store:dashboard_data:expired', load);
```

## [1.4.1] - 2025-09-14
### Fixed
Expand Down
48 changes: 42 additions & 6 deletions lib/App.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {isIntGt} from '@valkyriestudios/utils/number';
import {isObject} from '@valkyriestudios/utils/object';
import {hexId} from '@valkyriestudios/utils/hash';
import {type TriFrostCache} from './modules/Cache';
import {type TriFrostCookieOptions} from './modules/Cookies';
import {TriFrostRateLimit, type TriFrostRateLimitLimitFunction} from './modules/RateLimit/_RateLimit';
Expand All @@ -26,7 +27,7 @@ import {mount as mountCss} from './modules/JSX/style/mount';
import {mount as mountScript} from './modules/JSX/script/mount';
import {type CssGeneric, type CssInstance} from './modules/JSX/style/use';
import {activateCtx} from './utils/Als';
import {hexId} from './utils/Generic';
import {type TFValidator} from './types/validation';

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

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

/* If route has a validator, run it */
if (match.route.input) {
try {
const parsed = match.route.input.parse({
body: ctx.body,
query: ctx.query,
});
// overwrite ctx.body/query with parsed values (safe cast)
ctx.body = parsed.body;
ctx.query = parsed.query;
} catch (err) {
if (match.route.input.onInvalid) {
await match.route.input.onInvalid(ctx, err);
} else {
ctx.setStatus(400);
}
return await runTriage(path, ctx);
}
}

/* Run chain */
for (let i = 0; i < match.route.middleware.length; i++) {
const el = match.route.middleware[i];
Expand Down Expand Up @@ -439,39 +460,54 @@ class App<Env extends Record<string, any>, State extends Record<string, unknown>
/**
* Configure a HTTP Get route
*/
get<Path extends string = string>(path: Path, handler: TriFrostRouteHandler<Env, State & PathParam<Path>>) {
get<
Path extends string = string,
TV extends TFValidator<any, Env, State & PathParam<Path>> = TFValidator<any, Env, State & PathParam<Path>>,
>(path: Path, handler: TriFrostRouteHandler<Env, State & PathParam<Path>, TV>) {
super.get(path, handler);
return this;
}

/**
* Configure a HTTP Post route
*/
post<Path extends string = string>(path: Path, handler: TriFrostRouteHandler<Env, State & PathParam<Path>>) {
post<
Path extends string = string,
TV extends TFValidator<any, Env, State & PathParam<Path>> = TFValidator<any, Env, State & PathParam<Path>>,
>(path: Path, handler: TriFrostRouteHandler<Env, State & PathParam<Path>, TV>) {
super.post(path, handler);
return this;
}

/**
* Configure a HTTP Patch route
*/
patch<Path extends string = string>(path: Path, handler: TriFrostRouteHandler<Env, State & PathParam<Path>>) {
patch<
Path extends string = string,
TV extends TFValidator<any, Env, State & PathParam<Path>> = TFValidator<any, Env, State & PathParam<Path>>,
>(path: Path, handler: TriFrostRouteHandler<Env, State & PathParam<Path>, TV>) {
super.patch(path, handler);
return this;
}

/**
* Configure a HTTP Put route
*/
put<Path extends string = string>(path: Path, handler: TriFrostRouteHandler<Env, State & PathParam<Path>>) {
put<
Path extends string = string,
TV extends TFValidator<any, Env, State & PathParam<Path>> = TFValidator<any, Env, State & PathParam<Path>>,
>(path: Path, handler: TriFrostRouteHandler<Env, State & PathParam<Path>, TV>) {
super.put(path, handler);
return this;
}

/**
* Configure a HTTP Delete route
*/
del<Path extends string = string>(path: Path, handler: TriFrostRouteHandler<Env, State & PathParam<Path>>) {
del<
Path extends string = string,
TV extends TFValidator<any, Env, State & PathParam<Path>> = TFValidator<any, Env, State & PathParam<Path>>,
>(path: Path, handler: TriFrostRouteHandler<Env, State & PathParam<Path>, TV>) {
super.del(path, handler);
return this;
}
Expand Down
57 changes: 41 additions & 16 deletions lib/Context.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {isObject} from '@valkyriestudios/utils/object';
import {isNeObject, isObject} from '@valkyriestudios/utils/object';
import {isNeString} from '@valkyriestudios/utils/string';
import {hexId} from '@valkyriestudios/utils/hash';
import {type TriFrostCache} from './modules/Cache';
import {Cookies} from './modules/Cookies';
import {NONCE_WIN_SCRIPT, NONCEMARKER} from './modules/JSX/ctx/nonce';
Expand Down Expand Up @@ -28,8 +29,10 @@
type TriFrostContextRenderOptions,
} from './types/context';
import {encodeFilename, extractDomainFromHost} from './utils/Http';
import {determineHost, injectBefore, prependDocType, hexId} from './utils/Generic';
import {type TriFrostBodyParserOptions, type ParsedBody} from './utils/BodyParser/types';
import {determineHost, injectBefore, prependDocType} from './utils/Generic';
import {type TriFrostBodyParserOptions} from './utils/BodyParser/types';
import {type TFInput} from './types/validation';
import toObject from './utils/Query';

type RequestConfig = {
method: HttpMethod;
Expand Down Expand Up @@ -59,8 +62,12 @@
'x-appengine-user-ip',
];

// eslint-disable-next-line prettier/prettier
export abstract class Context<Env extends Record<string, any> = {}, State extends Record<string, unknown> = {}> implements TriFrostContext<Env, State> {
export abstract class Context<
Env extends Record<string, any> = {},
State extends Record<string, unknown> = {},
TInput extends TFInput = TFInput,
> implements TriFrostContext<Env, State, TInput>

Check failure on line 69 in lib/Context.ts

View workflow job for this annotation

GitHub Actions / Node (22)

Replace `⏎` with `·`

Check failure on line 69 in lib/Context.ts

View workflow job for this annotation

GitHub Actions / Node (20)

Replace `⏎` with `·`
{
/**
* MARK: Private
*/
Expand Down Expand Up @@ -90,7 +97,10 @@
#cache: TriFrostCache | null = null;

/* TriFrost Route Query. We compute this on an as-needed basis */
#query: URLSearchParams | null = null;
#query: TInput['query'] | null = null;

/* Whether or not a query exists */
#query_has: boolean = false;

/* TriFrost logger instance */
#logger: TriFrostLogger;
Expand Down Expand Up @@ -118,7 +128,7 @@
protected req_id: string | null = null;

/* TriFrost Request body */
protected req_body: Readonly<ParsedBody> | null = null;
protected req_body: Readonly<TInput['body']> | null = null;

/* Whether or not the context is initialized */
protected is_initialized: boolean = false;
Expand Down Expand Up @@ -162,6 +172,9 @@
}
if (!this.req_id) this.req_id = hexId(16);

/* Set this.#query_has */
this.#query_has = this.req_config.query.length > 0;

/* Instantiate logger */
this.#logger = logger.spawn({
traceId: this.req_id,
Expand Down Expand Up @@ -244,7 +257,7 @@
* Returns the host of the context.
*/
get host(): string {
if (this.#host) return this.#host;
if (this.#host !== null) return this.#host;
this.#host = this.getHostFromHeaders() ?? determineHost(this.ctx_config.env);
return this.#host;
}
Expand Down Expand Up @@ -282,11 +295,19 @@
/**
* Request Query parameters
*/
get query(): Readonly<URLSearchParams> {
if (!this.#query) this.#query = new URLSearchParams(this.req_config.query);
get query(): Readonly<TInput['query']> {
if (!this.#query) {
this.#query = toObject(this.req_config.query);
this.#query_has = isNeObject(this.#query);
}
return this.#query;
}

set query(val: TInput['query']) {
this.#query = val;
this.#query_has = isNeObject(val);
}

/**
* Cache Instance
*/
Expand Down Expand Up @@ -330,8 +351,12 @@
/**
* Request Body
*/
get body(): Readonly<NonNullable<ParsedBody>> {
return this.req_body || {};
get body(): Readonly<NonNullable<TInput['body']>> {
return (this.req_body || {}) as unknown as Readonly<NonNullable<TInput['body']>>;
}

set body(val: TInput['body']) {
this.req_body = val;
}

/**
Expand Down Expand Up @@ -532,7 +557,7 @@
/**
* Initializes the request body and parses it into Json or FormData depending on its type
*/
async init(match: TriFrostRouteMatch<Env>, handler?: (config: TriFrostBodyParserOptions | null) => Promise<ParsedBody | null>) {
async init(match: TriFrostRouteMatch<Env>, handler?: (config: TriFrostBodyParserOptions | null) => Promise<TInput['body'] | null>) {
try {
/* No need to do anything if already initialized */
if (this.is_initialized) return;
Expand All @@ -559,7 +584,7 @@
if (body === null) {
this.setStatus(413);
} else {
this.req_body = body;
this.req_body = body as TInput['body'];
}
break;
}
Expand Down Expand Up @@ -879,9 +904,9 @@
}

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

/* This is a redirect, as such a body should not be present */
Expand Down
2 changes: 1 addition & 1 deletion lib/middleware/Security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import {isBoolean} from '@valkyriestudios/utils/boolean';
import {isIntGt} from '@valkyriestudios/utils/number';
import {isObject} from '@valkyriestudios/utils/object';
import {isNeString} from '@valkyriestudios/utils/string';
import {hexId} from '@valkyriestudios/utils/hash';
import {Sym_TriFrostDescription, Sym_TriFrostFingerPrint, Sym_TriFrostName} from '../types/constants';
import {type TriFrostContext} from '../types/context';
import {hexId} from '../utils/Generic';

const RGX_NONCE = /'nonce'/g;

Expand Down
Loading
Loading