@stacksjs/router 0.70.88 → 0.70.91

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.
@@ -0,0 +1,57 @@
1
+ import { createSessionStore } from '@stacksjs/bun-router';
2
+ import type { RedisClient, SessionConfig, SessionData, SessionStore } from '@stacksjs/bun-router';
3
+ // Re-export bun-router's session types so app code only has to
4
+ // import from `@stacksjs/router` (one less package boundary to
5
+ // learn). Drivers stay accessible by name for callers that want
6
+ // to assemble a custom store manually.
7
+ export type {
8
+ RedisClient,
9
+ SessionConfig,
10
+ SessionData,
11
+ SessionStore,
12
+ };
13
+ /**
14
+ * Build a session store from the Stacks config. Mirrors what
15
+ * `mail` / `Jobs` do for their driver registries — call once at
16
+ * boot, pass the result into the session middleware.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * // config/session.ts (typical)
21
+ * export const session = {
22
+ * driver: 'redis',
23
+ * ttl: 60 * 60 * 24, // 24h
24
+ * cookie: { name: 'sid', httpOnly: true, sameSite: 'lax' },
25
+ * redis: { client: useRedis() },
26
+ * } satisfies StacksSessionConfig
27
+ *
28
+ * // Then at boot:
29
+ * const store = createStacksSessionStore(session)
30
+ * app.use(sessionMiddleware({ store }))
31
+ * ```
32
+ */
33
+ export declare function createStacksSessionStore(config: StacksSessionConfig): SessionStore<SessionData>;
34
+ /**
35
+ * Stacks-level session configuration. Extends bun-router's
36
+ * {@link SessionConfig} with:
37
+ *
38
+ * - `encrypt`: opt-in/out of {@link EncryptedSessionStore}
39
+ * wrapping (default: `'auto'` — on in production, off in
40
+ * dev/test where readability matters more than encryption-
41
+ * at-rest)
42
+ * - `appKey`: override key for the encryption (defaults to
43
+ * `process.env.APP_KEY`)
44
+ *
45
+ * All other fields pass through to bun-router unchanged.
46
+ */
47
+ export declare interface StacksSessionConfig extends SessionConfig {
48
+ encrypt?: boolean | 'auto'
49
+ appKey?: string
50
+ }
51
+ export {
52
+ DatabaseSessionStore,
53
+ FileSessionStore,
54
+ MemorySessionStore,
55
+ RedisSessionStore,
56
+ createSessionStore,
57
+ } from '@stacksjs/bun-router';
@@ -0,0 +1,26 @@
1
+ import process from "node:process";
2
+ import { createSessionStore } from "@stacksjs/bun-router";
3
+ import { EncryptedSessionStore } from "./encrypted-session-store";
4
+ export function createStacksSessionStore(config) {
5
+ const base = createSessionStore(config);
6
+ if (!resolveEncryptionMode(config.encrypt))
7
+ return base;
8
+ const appKey = config.appKey ?? process.env.APP_KEY;
9
+ if (!appKey || appKey.length < 16)
10
+ throw Error("[session] createStacksSessionStore: encryption requested but APP_KEY is missing or too short " + "(need \u226516 chars). Either set APP_KEY in env, pass `appKey` in config, or set `encrypt: false` to opt out.");
11
+ return new EncryptedSessionStore(base, { appKey });
12
+ }
13
+ function resolveEncryptionMode(mode) {
14
+ if (mode === !0)
15
+ return !0;
16
+ if (mode === !1)
17
+ return !1;
18
+ return (process.env.APP_ENV ?? process.env.NODE_ENV ?? "").toLowerCase() === "production";
19
+ }
20
+ export {
21
+ DatabaseSessionStore,
22
+ FileSessionStore,
23
+ MemorySessionStore,
24
+ RedisSessionStore,
25
+ createSessionStore
26
+ } from "@stacksjs/bun-router";
@@ -0,0 +1,50 @@
1
+ import type { EnhancedRequest } from '@stacksjs/bun-router';
2
+ /**
3
+ * Sign an existing URL (full or path-only). Returns a new URL string
4
+ * with `expires` (optional) and `signature` query params appended.
5
+ *
6
+ * Path-only inputs (`/api/email/verify?user=42`) inherit `APP_URL` as
7
+ * the origin — same convention as {@link buildUrl}.
8
+ */
9
+ export declare function signUrl(input: string, options?: SignedUrlOptions): string;
10
+ /**
11
+ * Convenience wrapper that resolves a named route to a URL via {@link buildUrl}
12
+ * and then signs it. Mirrors Laravel's `URL::signedRoute()`.
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * route.get('/api/email/verify', VerifyEmailAction).name('email.verify')
17
+ * const link = signedUrl('email.verify', { user: 42 }, { ttl: 60 * 60 * 24 })
18
+ * // → https://app.example.com/api/email/verify?user=42&expires=1716470400&signature=…
19
+ * ```
20
+ */
21
+ export declare function signedUrl(routeName: string, params?: Record<string, string | number>, options?: SignedUrlOptions): string;
22
+ /**
23
+ * Verify the `signature` (and optional `expires`) on an incoming URL.
24
+ * Returns a discriminated result so callers can pick their own status
25
+ * code per failure mode.
26
+ */
27
+ export declare function verifySignedUrl(input: string | URL): SignedUrlVerifyResult;
28
+ /**
29
+ * Middleware shape for `route.middleware('signed')`. Verifies the
30
+ * incoming URL's signature and throws a `Response` (the router's
31
+ * short-circuit contract) when it fails. Drop in as a route-level
32
+ * middleware on any URL minted by {@link signedUrl}.
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * route.get('/email/verify', 'Actions/VerifyEmail').middleware('signed')
37
+ * ```
38
+ */
39
+ export declare function verifySignedUrlMiddleware(req: EnhancedRequest): Promise<void>;
40
+ export declare interface SignedUrlOptions {
41
+ expiresAt?: number
42
+ ttl?: number
43
+ }
44
+ /**
45
+ * Result of {@link verifySignedUrl}. The `reason` only fires when `valid`
46
+ * is `false` — gives callers (and middleware) enough to decide whether
47
+ * to log, return 401, or return 410 (expired vs. tampered).
48
+ */
49
+ export type SignedUrlVerifyResult = | { valid: true }
50
+ | { valid: false, reason: 'missing-signature' | 'expired' | 'invalid-signature' }
@@ -0,0 +1,76 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import { Buffer } from "node:buffer";
3
+ import process from "node:process";
4
+ import { url as buildUrl } from "./stacks-router";
5
+ const SIGNATURE_PARAM = "signature", EXPIRES_PARAM = "expires";
6
+ function getSigningSecret() {
7
+ const secret = process.env.APP_KEY || process.env.STACKS_SIGNED_URL_SECRET;
8
+ if (!secret || secret.length < 16)
9
+ throw Error("[router] signed URLs require APP_KEY (\u2265 16 chars) or STACKS_SIGNED_URL_SECRET. " + "Run `./buddy key:generate` or set the env var.");
10
+ return secret;
11
+ }
12
+ function hmacHex(payload, secret) {
13
+ return new Bun.CryptoHasher("sha256", secret).update(payload).digest("hex");
14
+ }
15
+ function safeEqualHex(a, b) {
16
+ const ba = Buffer.from(a.toLowerCase(), "hex"), bb = Buffer.from(b.toLowerCase(), "hex");
17
+ if (ba.length !== bb.length)
18
+ return !1;
19
+ return timingSafeEqual(ba, bb);
20
+ }
21
+ function stripSignatureParam(input) {
22
+ const u = new URL(input.toString());
23
+ u.searchParams.delete(SIGNATURE_PARAM);
24
+ return u;
25
+ }
26
+ function buildSignaturePayload(input) {
27
+ const stripped = stripSignatureParam(input);
28
+ stripped.searchParams.sort();
29
+ return stripped.toString();
30
+ }
31
+ export function signUrl(input, options = {}) {
32
+ const secret = getSigningSecret(), url = input.startsWith("http") ? new URL(input) : new URL(input.startsWith("/") ? input : `/${input}`, process.env.APP_URL || "https://localhost");
33
+ if (options.ttl !== void 0 && options.expiresAt !== void 0)
34
+ warnOnce("dual-expiry", "[router] signUrl: both `ttl` and `expiresAt` provided \u2014 using `ttl`.");
35
+ if (options.ttl !== void 0)
36
+ url.searchParams.set(EXPIRES_PARAM, String(Math.floor(Date.now() / 1000) + options.ttl));
37
+ else if (options.expiresAt !== void 0)
38
+ url.searchParams.set(EXPIRES_PARAM, String(Math.floor(options.expiresAt)));
39
+ const payload = buildSignaturePayload(url);
40
+ url.searchParams.set(SIGNATURE_PARAM, hmacHex(payload, secret));
41
+ return url.toString();
42
+ }
43
+ const _warnedKeys = new Set;
44
+ function warnOnce(key, message) {
45
+ if (_warnedKeys.has(key))
46
+ return;
47
+ _warnedKeys.add(key);
48
+ console.warn(message);
49
+ }
50
+ export function signedUrl(routeName, params = {}, options = {}) {
51
+ return signUrl(buildUrl(routeName, params), options);
52
+ }
53
+ export function verifySignedUrl(input) {
54
+ const url = typeof input === "string" ? new URL(input, process.env.APP_URL || "https://localhost") : input, presented = url.searchParams.get(SIGNATURE_PARAM);
55
+ if (!presented)
56
+ return { valid: !1, reason: "missing-signature" };
57
+ const expiresRaw = url.searchParams.get(EXPIRES_PARAM);
58
+ if (expiresRaw !== null) {
59
+ const expires = Number.parseInt(expiresRaw, 10);
60
+ if (!Number.isFinite(expires) || Date.now() / 1000 > expires)
61
+ return { valid: !1, reason: "expired" };
62
+ }
63
+ try {
64
+ const expected = hmacHex(buildSignaturePayload(url), getSigningSecret());
65
+ return safeEqualHex(presented, expected) ? { valid: !0 } : { valid: !1, reason: "invalid-signature" };
66
+ } catch {
67
+ return { valid: !1, reason: "invalid-signature" };
68
+ }
69
+ }
70
+ export async function verifySignedUrlMiddleware(req) {
71
+ const result = verifySignedUrl(req.url);
72
+ if (result.valid)
73
+ return;
74
+ const status = result.reason === "expired" ? 410 : 401;
75
+ throw Response.json({ error: result.reason }, { status });
76
+ }
@@ -0,0 +1,213 @@
1
+ import type { Server } from 'bun';
2
+ import './request-augmentation';
3
+ import { Router } from '@stacksjs/bun-router';
4
+ import type { ActionHandler, EnhancedRequest, Route, ServerOptions } from '@stacksjs/bun-router';
5
+ import type { ActionValidations, ValidationResult } from '@stacksjs/actions';
6
+ /**
7
+ * Warn (once per process) when more than one @stacksjs/router module has loaded
8
+ * (stacksjs/stacks#1975 / #1982). Routing still works — the route table and
9
+ * request context are process-global singletons — but a duplicated install is
10
+ * worth surfacing. Called at serve() boot. Returns whether a split was detected
11
+ * so callers/tests can assert on it without capturing logs.
12
+ */
13
+ export declare function warnOnMultipleRouterInstances(): boolean;
14
+ /**
15
+ * Generate a full URL for a named route, like Laravel's route() helper.
16
+ *
17
+ * Validates path parameters at call time so a typo'd argument
18
+ * (`url('user.post', { userId: 1 })` against `/users/{id}`) throws
19
+ * immediately with a list of expected names instead of silently
20
+ * producing a URL with `{id}` left literal in the path.
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * // Define a named route
25
+ * route.get('/api/email/unsubscribe', 'Actions/UnsubscribeAction').name('email.unsubscribe')
26
+ *
27
+ * // Generate URL
28
+ * url('email.unsubscribe', { token: 'abc-123' })
29
+ * // → https://stacksjs.com/api/email/unsubscribe?token=abc-123
30
+ *
31
+ * // With path parameters
32
+ * route.get('/users/{id}/posts/{postId}', handler).name('user.post')
33
+ * url('user.post', { id: 42, postId: 7 })
34
+ * // → https://stacksjs.com/users/42/posts/7
35
+ * ```
36
+ */
37
+ export declare function url(routeName: string, params?: Record<string, string | number>): string;
38
+ /**
39
+ * List the placeholder names a named route expects — handy for
40
+ * codegen/test cases and for detecting typos before runtime.
41
+ */
42
+ export declare function routeParams(routeName: string): string[];
43
+ /**
44
+ * Snapshot of the registered routes — `{ method, path, name? }` per
45
+ * route. Used by `buddy route:list` and the dev-server startup banner.
46
+ */
47
+ export declare function listRegisteredRoutes(): Array<{ method: string, path: string, name?: string }>;
48
+ /**
49
+ * Clear the middleware cache (useful for hot-reload in development).
50
+ *
51
+ * `installMiddlewareHotReload()` will wire this up automatically when
52
+ * called from the dev server — production should never invoke it.
53
+ */
54
+ export declare function clearMiddlewareCache(): void;
55
+ /**
56
+ * Watch `app/Middleware/` and `app/Middleware.ts` and invalidate the
57
+ * cached middleware modules whenever a file changes. Intended for the
58
+ * dev server only — calling this in production is a no-op (the
59
+ * watcher handle is created but never fires anything user code cares
60
+ * about). Returns a `disposer()` to stop watching.
61
+ *
62
+ * Without this hook, editing a middleware file in dev requires a
63
+ * full server restart to see the change — the import map caches the
64
+ * old version forever.
65
+ */
66
+ export declare function installMiddlewareHotReload(): () => void;
67
+ /**
68
+ * Resolve every middleware alias referenced by a registered route and
69
+ * report the ones that don't load. `csrf` is always checked too — it's
70
+ * auto-injected on unsafe methods even when no route lists it.
71
+ *
72
+ * Resolution is inherently lazy (`.middleware(name)` is a sync chainable
73
+ * that just records a string; the alias map and middleware modules load
74
+ * via async dynamic import), so a throw at literal registration time is
75
+ * impossible. Calling this after all routes are registered — the end of
76
+ * `importRoutes()` and the compiled-binary boot in core/server — IS
77
+ * effectively registration-time validation. See stacksjs/stacks#1957.
78
+ */
79
+ export declare function findUnresolvableRouteMiddleware(): Promise<Array<{ alias: string, routes: string[] }>>;
80
+ /**
81
+ * Throw when any registered route references a middleware alias that
82
+ * cannot be resolved. Fail-closed boot validation: a typo'd `auth` alias
83
+ * must abort startup loudly, not serve the route unprotected (the
84
+ * request-time guard in createMiddlewareHandler 500s as a backstop).
85
+ */
86
+ export declare function assertRouteMiddlewareResolvable(): Promise<void>;
87
+ /**
88
+ * Run an action's declarative `validations:` against the request.
89
+ *
90
+ * @internal Exported for regression coverage of path-param coercion
91
+ * (stacksjs/stacks#1865). Production callers should rely on the
92
+ * router's action-resolution path, which invokes this for you.
93
+ */
94
+ export declare function validateActionInput(req: EnhancedRequest, validations: ActionValidations): Promise<ValidationResult>;
95
+ export declare function stream(source: ReadableStream | AsyncIterable<string | Uint8Array>, options?: StreamOptions): Response;
96
+ // Decorate the incoming request with the helpers the framework's middleware
97
+ // and actions assume are always available. Names follow Laravel's convention
98
+ // because that's the API surface Stacks userland expects.
99
+ export declare function enhanceRequest(req: EnhancedRequest): EnhancedRequest;
100
+ /**
101
+ * Create a Stacks-enhanced router
102
+ */
103
+ export declare function createStacksRouter(config?: StacksRouterConfig): StacksRouterInstance;
104
+ /**
105
+ * Handle a server request through the router
106
+ * This is the main entry point for the Stacks server
107
+ */
108
+ export declare function serverResponse(request: Request, _body?: string): Promise<Response>;
109
+ // Export serve function that uses the default router
110
+ export declare function serve(options?: ServerOptions): Promise<Server<unknown>>;
111
+ export declare const route: StacksRouterInstance;
112
+ declare interface StacksRouterConfig {
113
+ verbose?: boolean
114
+ apiPrefix?: string
115
+ }
116
+ declare interface GroupOptions {
117
+ prefix?: string
118
+ middleware?: string | string[]
119
+ apiResponse?: boolean
120
+ }
121
+ declare interface ResourceRouteOptions {
122
+ only?: ResourceAction[]
123
+ except?: ResourceAction[]
124
+ middleware?: string | string[]
125
+ }
126
+ /**
127
+ * Chainable route interface for middleware and naming support
128
+ */
129
+ declare interface ChainableRoute {
130
+ middleware: (name: string) => ChainableRoute
131
+ name: (routeName: string) => ChainableRoute
132
+ skipCsrf: () => ChainableRoute
133
+ requireCsrf: () => ChainableRoute
134
+ rateLimit: (max: number, window: 'second' | 'minute' | 'hour' | 'day' | number) => ChainableRoute
135
+ }
136
+ /**
137
+ * Helper for streaming responses — wraps a `ReadableStream` or async
138
+ * generator with the right headers for the chosen content type.
139
+ *
140
+ * Common shapes:
141
+ *
142
+ * ```ts
143
+ * // Server-Sent Events
144
+ * return stream(async function* () {
145
+ * for await (const evt of source) yield `data: ${JSON.stringify(evt)}\n\n`
146
+ * }, { type: 'sse' })
147
+ *
148
+ * // Chunked JSON (NDJSON) — one JSON object per line
149
+ * return stream(async function* () {
150
+ * for await (const row of rows) yield `${JSON.stringify(row)}\n`
151
+ * }, { type: 'ndjson' })
152
+ *
153
+ * // Raw bytes — caller supplies a ReadableStream of Uint8Array chunks
154
+ * return stream(myReadable, { contentType: 'application/octet-stream' })
155
+ * ```
156
+ *
157
+ * The wrapper sets `Cache-Control: no-cache` and `Connection: keep-alive`
158
+ * for SSE — the two headers a sane proxy / browser pair won't ignore — and
159
+ * leaves backpressure / cancellation to the underlying stream.
160
+ *
161
+ * See stacksjs/stacks#1870 R-4.
162
+ */
163
+ export declare interface StreamOptions {
164
+ type?: 'sse' | 'ndjson'
165
+ contentType?: string
166
+ headers?: HeadersInit
167
+ status?: number
168
+ }
169
+ export declare interface StacksRouterInstance {
170
+ bunRouter: Router
171
+ routes: Route[]
172
+ get: (path: string, handler: StacksHandler) => ChainableRoute
173
+ post: (path: string, handler: StacksHandler) => ChainableRoute
174
+ put: (path: string, handler: StacksHandler) => ChainableRoute
175
+ patch: (path: string, handler: StacksHandler) => ChainableRoute
176
+ delete: (path: string, handler: StacksHandler) => ChainableRoute
177
+ options: (path: string, handler: StacksHandler) => ChainableRoute
178
+ group: (options: GroupOptions, callback: () => void | Promise<void>) => StacksRouterInstance | Promise<StacksRouterInstance>
179
+ resource: (name: string, handler: string, options?: ResourceRouteOptions) => StacksRouterInstance
180
+ match: (methods: string[], path: string, handler: StacksHandler) => ChainableRoute
181
+ health: () => StacksRouterInstance
182
+ use: (middleware: ActionHandler | ((req: EnhancedRequest, next: () => Promise<Response>) => Response | Promise<Response>)) => StacksRouterInstance
183
+ register: (routePath: string, options?: { prefix?: string, middleware?: string | string[] }) => Promise<StacksRouterInstance>
184
+ serve: (options?: ServerOptions) => Promise<Server<unknown>>
185
+ handleRequest: (req: Request) => Promise<Response>
186
+ getAllowedMethods: (pathname: string, domain?: string) => string[]
187
+ importRoutes: () => Promise<void>
188
+ loadDiscoveredRoutes: () => Promise<void>
189
+ }
190
+ declare type RouteHandlerFn = (_req: EnhancedRequest) => Response | Promise<Response>;
191
+ declare type StacksHandler = string | RouteHandlerFn;
192
+ declare type ResourceAction = 'index' | 'store' | 'show' | 'update' | 'destroy';
193
+ /**
194
+ * FIFO-bounded Map. Wraps `Map` with a hard size cap; on overflow,
195
+ * the oldest entry (Map insertion order) is evicted. Used for the
196
+ * router's small framework-internal caches whose size is normally
197
+ * bounded by action count, but which had no upper limit before —
198
+ * tests that instantiate many short-lived routers would leak entries
199
+ * across `createStacksRouter()` calls (stacksjs/stacks#1863 T-8).
200
+ *
201
+ * Insertion-order LRU is appropriate here because the access pattern
202
+ * is "set once at action-load time, then many reads" — refreshing on
203
+ * get would buy nothing since reads dominate.
204
+ */
205
+ declare class BoundedMap<K, V> {
206
+ constructor(max: number);
207
+ get(key: K): V | undefined;
208
+ has(key: K): boolean;
209
+ set(key: K, value: V): this;
210
+ delete(key: K): boolean;
211
+ clear(): void;
212
+ get size(): number;
213
+ }