also looking at this
chore: wip stx improvements
#1649
241 files
+19022
-1323
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | - [x] Implement `.env` file loader using `Bun.env` |
| 10 | 10 | - [x] Implement `validateEnv()` with startup error reporting |
| 11 | 11 | - [x] Migrate `env.ts` helpers (`isProduction`, `isDevelopment`) into this package |
| 12 | - [ ] Add re-export shim in core `stx` package for backwards compat | |
| 12 | - [x] Add re-export shim in core `stx` package for backwards compat | |
| 13 | 13 | - [x] Add `@stacksjs/config` to root `tsconfig.json` paths |
| 14 | 14 | - [x] Add to workspace in root `package.json` |
| 15 | 15 | - [x] Write tests for all env types, defaults, required fields, validation |
| @@ -24,8 +24,8 @@ | ||
| 24 | 24 | - [x] Extract SPA client script from `stx/src/client/index.ts` |
| 25 | 25 | - [x] Implement nested layout resolution (`_layout.stx` chain) |
| 26 | 26 | - [x] Implement route type generation (`.stx/route-types.d.ts`) |
| 27 | - [ ] Update `dev-server.ts` and `bun-plugin/src/serve.ts` to import from `@stacksjs/router` | |
| 28 | - [ ] Add re-export shims in core `stx` for backwards compat | |
| 27 | - [x] Update `dev-server.ts` and `bun-plugin/src/serve.ts` to import from `@stacksjs/router` | |
| 28 | - [x] Add re-export shims in core `stx` for backwards compat | |
| 29 | 29 | - [x] Write tests for: file scanning, pattern matching, dynamic params, middleware chain, layout nesting, named routes |
| 30 | 30 | - [x] Add to `tsconfig.json` paths and workspace |
| 31 | 31 | |
| @@ -37,8 +37,8 @@ | ||
| 37 | 37 | - [x] Implement `useAsyncData()` and `useFetch()` composables |
| 38 | 38 | - [x] Implement loader result caching with stale-while-revalidate |
| 39 | 39 | - [x] Implement safe serialization (handle Dates, BigInt, etc.) |
| 40 | - [ ] Integrate loader execution into dev-server request pipeline | |
| 41 | - [ ] Integrate actions with `@form` directive | |
| 40 | - [x] Integrate loader execution into dev-server request pipeline | |
| 41 | - [x] Integrate actions with `@form` directive | |
| 42 | 42 | - [x] Write tests for loaders, actions, composables, caching, serialization |
| 43 | 43 | - [x] Add to `tsconfig.json` paths and workspace |
| 44 | 44 | |
| @@ -49,8 +49,8 @@ | ||
| 49 | 49 | - [x] Implement `bunServerAdapter` with compression, static file serving, TLS |
| 50 | 50 | - [x] Implement `staticAdapter` wrapping existing SSG |
| 51 | 51 | - [x] Extract platform detection from `edge-runtime.ts` |
| 52 | - [ ] Integrate with production build pipeline | |
| 53 | - [ ] Add `stx deploy` CLI command | |
| 52 | - [x] Integrate with production build pipeline | |
| 53 | - [x] Add `stx deploy` CLI command | |
| 54 | 54 | - [x] Write tests for each adapter |
| 55 | 55 | - [x] Add to `tsconfig.json` paths and workspace |
| 56 | 56 | |
| @@ -0,0 +1,14 @@ | ||
| 1 | import plugin from 'bun-plugin-dtsx' | |
| 2 | ||
| 3 | await Bun.build({ | |
| 4 | entrypoints: ['./src/index.ts'], | |
| 5 | outdir: './dist', | |
| 6 | target: 'bun', | |
| 7 | format: 'esm', | |
| 8 | splitting: true, | |
| 9 | sourcemap: 'external', | |
| 10 | minify: false, | |
| 11 | plugins: [plugin()], | |
| 12 | }) | |
| 13 | ||
| 14 | console.log('Build complete!') | |
| @@ -0,0 +1,49 @@ | ||
| 1 | { | |
| 2 | "name": "@stacksjs/config", | |
| 3 | "type": "module", | |
| 4 | "version": "0.2.6", | |
| 5 | "description": "Type-safe environment variables with build-time validation for STX applications.", | |
| 6 | "author": "Chris Breuer <chris@stacksjs.org>", | |
| 7 | "license": "MIT", | |
| 8 | "homepage": "https://github.com/stacksjs/stx#readme", | |
| 9 | "repository": { | |
| 10 | "type": "git", | |
| 11 | "url": "git+https://github.com/stacksjs/stx.git" | |
| 12 | }, | |
| 13 | "bugs": { | |
| 14 | "url": "https://github.com/stacksjs/stx/issues" | |
| 15 | }, | |
| 16 | "keywords": [ | |
| 17 | "config", | |
| 18 | "env", | |
| 19 | "environment", | |
| 20 | "type-safe", | |
| 21 | "validation", | |
| 22 | "bun", | |
| 23 | "stx" | |
| 24 | ], | |
| 25 | "exports": { | |
| 26 | ".": { | |
| 27 | "types": "./dist/index.d.ts", | |
| 28 | "import": "./dist/index.js" | |
| 29 | }, | |
| 30 | "./*": { | |
| 31 | "import": "./dist/*" | |
| 32 | } | |
| 33 | }, | |
| 34 | "module": "./dist/index.js", | |
| 35 | "types": "./dist/index.d.ts", | |
| 36 | "files": [ | |
| 37 | "LICENSE.md", | |
| 38 | "README.md", | |
| 39 | "dist" | |
| 40 | ], | |
| 41 | "scripts": { | |
| 42 | "build": "bun --bun build.ts", | |
| 43 | "prepublishOnly": "bun run build", | |
| 44 | "test": "bun test", | |
| 45 | "test:watch": "bun test --watch", | |
| 46 | "test:coverage": "bun test --coverage", | |
| 47 | "typecheck": "bun tsc --noEmit" | |
| 48 | } | |
| 49 | } | |
| @@ -0,0 +1,41 @@ | ||
| 1 | import process from 'node:process' | |
| 2 | import type { EnvVarDef, TypedEnv } from './types' | |
| 3 | import { getEnvValue, loadEnvFile } from './loader' | |
| 4 | import { validateEnv } from './validation' | |
| 5 | ||
| 6 | export function defineEnv<T extends Record<string, EnvVarDef>>(schema: T): TypedEnv<T> { | |
| 7 | // Load .env file into process.env (if not already set) | |
| 8 | const fileVars = loadEnvFile() | |
| 9 | for (const [key, value] of Object.entries(fileVars)) { | |
| 10 | if (process.env[key] === undefined) { | |
| 11 | process.env[key] = value | |
| 12 | } | |
| 13 | } | |
| 14 | ||
| 15 | // Collect raw values from environment | |
| 16 | const rawValues: Record<string, string | undefined> = {} | |
| 17 | for (const key of Object.keys(schema)) { | |
| 18 | rawValues[key] = getEnvValue(key) | |
| 19 | } | |
| 20 | ||
| 21 | const { values, errors } = validateEnv(schema, rawValues) | |
| 22 | ||
| 23 | if (errors.length > 0) { | |
| 24 | const messages = errors.map(e => ` - ${e.message}`).join('\n') | |
| 25 | throw new Error(`Environment validation failed:\n${messages}`) | |
| 26 | } | |
| 27 | ||
| 28 | return values as TypedEnv<T> | |
| 29 | } | |
| 30 | ||
| 31 | export function isProduction(): boolean { | |
| 32 | return process.env.NODE_ENV === 'production' | |
| 33 | } | |
| 34 | ||
| 35 | export function isDevelopment(): boolean { | |
| 36 | return !isProduction() || process.env.STX_DEBUG === 'true' | |
| 37 | } | |
| 38 | ||
| 39 | export function isTest(): boolean { | |
| 40 | return process.env.NODE_ENV === 'test' | |
| 41 | } | |
| @@ -0,0 +1,4 @@ | ||
| 1 | export { defineEnv, isDevelopment, isProduction, isTest } from './env' | |
| 2 | export { getEnvValue, loadEnvFile } from './loader' | |
| 3 | export { validateEnv } from './validation' | |
| 4 | export type { EnvAccessor, EnvType, EnvValidationError, EnvVarDef, TypedEnv } from './types' | |
| @@ -0,0 +1,64 @@ | ||
| 1 | import process from 'node:process' | |
| 2 | import { existsSync, readFileSync } from 'node:fs' | |
| 3 | import { resolve } from 'node:path' | |
| 4 | ||
| 5 | export function loadEnvFile(filePath?: string): Record<string, string> { | |
| 6 | const envPath = filePath ?? resolve(process.cwd(), '.env') | |
| 7 | ||
| 8 | if (!existsSync(envPath)) { | |
| 9 | return {} | |
| 10 | } | |
| 11 | ||
| 12 | const content = readFileSync(envPath, 'utf-8') | |
| 13 | const vars: Record<string, string> = {} | |
| 14 | ||
| 15 | for (const line of content.split('\n')) { | |
| 16 | const trimmed = line.trim() | |
| 17 | ||
| 18 | if (!trimmed || trimmed.startsWith('#')) { | |
| 19 | continue | |
| 20 | } | |
| 21 | ||
| 22 | const eqIndex = trimmed.indexOf('=') | |
| 23 | if (eqIndex === -1) continue | |
| 24 | ||
| 25 | const key = trimmed.slice(0, eqIndex).trim() | |
| 26 | let value = trimmed.slice(eqIndex + 1).trim() | |
| 27 | ||
| 28 | // Strip surrounding quotes | |
| 29 | if ((value.startsWith('"') && value.endsWith('"')) | |
| 30 | || (value.startsWith('\'') && value.endsWith('\''))) { | |
| 31 | value = value.slice(1, -1) | |
| 32 | } | |
| 33 | ||
| 34 | // Strip inline comments (only outside quotes) | |
| 35 | const commentIndex = value.indexOf(' #') | |
| 36 | if (commentIndex !== -1) { | |
| 37 | value = value.slice(0, commentIndex).trim() | |
| 38 | } | |
| 39 | ||
| 40 | vars[key] = value | |
| 41 | } | |
| 42 | ||
| 43 | return vars | |
| 44 | } | |
| 45 | ||
| 46 | export function getEnvValue(key: string): string | undefined { | |
| 47 | // Bun.env is preferred, falls back to process.env | |
| 48 | if (typeof globalThis !== 'undefined' && 'Bun' in globalThis) { | |
| 49 | return (globalThis as any).Bun.env[key] | |
| 50 | } | |
| 51 | ||
| 52 | // Deno | |
| 53 | if (typeof globalThis !== 'undefined' && 'Deno' in globalThis) { | |
| 54 | try { | |
| 55 | return (globalThis as any).Deno.env.get(key) | |
| 56 | } | |
| 57 | catch { | |
| 58 | return undefined | |
| 59 | } | |
| 60 | } | |
| 61 | ||
| 62 | // Node.js / fallback | |
| 63 | return process.env[key] | |
| 64 | } | |
| @@ -0,0 +1,29 @@ | ||
| 1 | export type EnvType = 'string' | 'number' | 'boolean' | 'url' | 'email' | 'port' | |
| 2 | ||
| 3 | export interface EnvVarDef<T = unknown> { | |
| 4 | type: EnvType | |
| 5 | required?: boolean | |
| 6 | default?: T | |
| 7 | description?: string | |
| 8 | validate?: (value: T) => boolean | |
| 9 | choices?: T[] | |
| 10 | } | |
| 11 | ||
| 12 | export type TypedEnv<T extends Record<string, EnvVarDef>> = { | |
| 13 | [K in keyof T]: T[K] extends { type: 'number' } ? number | |
| 14 | : T[K] extends { type: 'port' } ? number | |
| 15 | : T[K] extends { type: 'boolean' } ? boolean | |
| 16 | : string | |
| 17 | } | |
| 18 | ||
| 19 | export interface EnvValidationError { | |
| 20 | key: string | |
| 21 | message: string | |
| 22 | } | |
| 23 | ||
| 24 | export interface EnvAccessor { | |
| 25 | get: (key: string) => string | undefined | |
| 26 | getOrThrow: (key: string) => string | |
| 27 | has: (key: string) => boolean | |
| 28 | all: () => Record<string, string> | |
| 29 | } | |
| @@ -0,0 +1,84 @@ | ||
| 1 | import type { EnvType, EnvValidationError, EnvVarDef } from './types' | |
| 2 | ||
| 3 | const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ | |
| 4 | const URL_RE = /^https?:\/\/.+/ | |
| 5 | ||
| 6 | function coerceValue(raw: string, type: EnvType): unknown { | |
| 7 | switch (type) { | |
| 8 | case 'number': { | |
| 9 | const n = Number(raw) | |
| 10 | if (Number.isNaN(n)) throw new Error(`Expected a number, got "${raw}"`) | |
| 11 | return n | |
| 12 | } | |
| 13 | case 'port': { | |
| 14 | const p = Number(raw) | |
| 15 | if (Number.isNaN(p) || !Number.isInteger(p) || p < 0 || p > 65535) | |
| 16 | throw new Error(`Expected a valid port (0-65535), got "${raw}"`) | |
| 17 | return p | |
| 18 | } | |
| 19 | case 'boolean': { | |
| 20 | const lower = raw.toLowerCase() | |
| 21 | if (lower === 'true' || lower === '1' || lower === 'yes') return true | |
| 22 | if (lower === 'false' || lower === '0' || lower === 'no') return false | |
| 23 | throw new Error(`Expected a boolean, got "${raw}"`) | |
| 24 | } | |
| 25 | case 'url': { | |
| 26 | if (!URL_RE.test(raw)) throw new Error(`Expected a URL, got "${raw}"`) | |
| 27 | return raw | |
| 28 | } | |
| 29 | case 'email': { | |
| 30 | if (!EMAIL_RE.test(raw)) throw new Error(`Expected an email, got "${raw}"`) | |
| 31 | return raw | |
| 32 | } | |
| 33 | case 'string': | |
| 34 | default: | |
| 35 | return raw | |
| 36 | } | |
| 37 | } | |
| 38 | ||
| 39 | export function validateEnv<T extends Record<string, EnvVarDef>>( | |
| 40 | schema: T, | |
| 41 | rawValues: Record<string, string | undefined>, | |
| 42 | ): { values: Record<string, unknown>, errors: EnvValidationError[] } { | |
| 43 | const values: Record<string, unknown> = {} | |
| 44 | const errors: EnvValidationError[] = [] | |
| 45 | ||
| 46 | for (const [key, def] of Object.entries(schema)) { | |
| 47 | const raw = rawValues[key] | |
| 48 | const isRequired = def.required !== false | |
| 49 | ||
| 50 | if (raw === undefined || raw === '') { | |
| 51 | if (def.default !== undefined) { | |
| 52 | values[key] = def.default | |
| 53 | continue | |
| 54 | } | |
| 55 | if (isRequired) { | |
| 56 | errors.push({ key, message: `Missing required environment variable: ${key}` }) | |
| 57 | continue | |
| 58 | } | |
| 59 | values[key] = undefined | |
| 60 | continue | |
| 61 | } | |
| 62 | ||
| 63 | try { | |
| 64 | const coerced = coerceValue(raw, def.type) | |
| 65 | ||
| 66 | if (def.choices && !def.choices.includes(coerced as any)) { | |
| 67 | errors.push({ key, message: `${key} must be one of [${def.choices.join(', ')}], got "${coerced}"` }) | |
| 68 | continue | |
| 69 | } | |
| 70 | ||
| 71 | if (def.validate && !def.validate(coerced as any)) { | |
| 72 | errors.push({ key, message: `${key} failed custom validation` }) | |
| 73 | continue | |
| 74 | } | |
| 75 | ||
| 76 | values[key] = coerced | |
| 77 | } | |
| 78 | catch (err: any) { | |
| 79 | errors.push({ key, message: `${key}: ${err.message}` }) | |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | return { values, errors } | |
| 84 | } | |
| @@ -0,0 +1,172 @@ | ||
| 1 | import { afterEach, beforeEach, describe, expect, test } from 'bun:test' | |
| 2 | import { defineEnv, isDevelopment, isProduction, isTest } from '../src/env' | |
| 3 | ||
| 4 | describe('defineEnv', () => { | |
| 5 | const originalEnv = { ...process.env } | |
| 6 | ||
| 7 | beforeEach(() => { | |
| 8 | // Reset env | |
| 9 | for (const key of Object.keys(process.env)) { | |
| 10 | if (key.startsWith('TEST_')) delete process.env[key] | |
| 11 | } | |
| 12 | }) | |
| 13 | ||
| 14 | afterEach(() => { | |
| 15 | process.env.NODE_ENV = originalEnv.NODE_ENV | |
| 16 | }) | |
| 17 | ||
| 18 | test('parses string env vars', () => { | |
| 19 | process.env.TEST_NAME = 'hello' | |
| 20 | const env = defineEnv({ | |
| 21 | TEST_NAME: { type: 'string' }, | |
| 22 | }) | |
| 23 | expect(env.TEST_NAME).toBe('hello') | |
| 24 | }) | |
| 25 | ||
| 26 | test('parses number env vars', () => { | |
| 27 | process.env.TEST_COUNT = '42' | |
| 28 | const env = defineEnv({ | |
| 29 | TEST_COUNT: { type: 'number' }, | |
| 30 | }) | |
| 31 | expect(env.TEST_COUNT).toBe(42) | |
| 32 | }) | |
| 33 | ||
| 34 | test('parses boolean env vars', () => { | |
| 35 | process.env.TEST_ENABLED = 'true' | |
| 36 | const env = defineEnv({ | |
| 37 | TEST_ENABLED: { type: 'boolean' }, | |
| 38 | }) | |
| 39 | expect(env.TEST_ENABLED).toBe(true) | |
| 40 | }) | |
| 41 | ||
| 42 | test('parses port env vars', () => { | |
| 43 | process.env.TEST_PORT = '3000' | |
| 44 | const env = defineEnv({ | |
| 45 | TEST_PORT: { type: 'port' }, | |
| 46 | }) | |
| 47 | expect(env.TEST_PORT).toBe(3000) | |
| 48 | }) | |
| 49 | ||
| 50 | test('applies defaults when env var is missing', () => { | |
| 51 | const env = defineEnv({ | |
| 52 | TEST_MISSING: { type: 'string', default: 'fallback' }, | |
| 53 | }) | |
| 54 | expect(env.TEST_MISSING).toBe('fallback') | |
| 55 | }) | |
| 56 | ||
| 57 | test('throws when required var is missing', () => { | |
| 58 | expect(() => { | |
| 59 | defineEnv({ | |
| 60 | TEST_REQUIRED: { type: 'string', required: true }, | |
| 61 | }) | |
| 62 | }).toThrow('Environment validation failed') | |
| 63 | }) | |
| 64 | ||
| 65 | test('does not throw when optional var is missing', () => { | |
| 66 | const env = defineEnv({ | |
| 67 | TEST_OPTIONAL: { type: 'string', required: false }, | |
| 68 | }) | |
| 69 | expect(env.TEST_OPTIONAL).toBeUndefined() | |
| 70 | }) | |
| 71 | ||
| 72 | test('validates url type', () => { | |
| 73 | process.env.TEST_URL = 'https://example.com' | |
| 74 | const env = defineEnv({ | |
| 75 | TEST_URL: { type: 'url' }, | |
| 76 | }) | |
| 77 | expect(env.TEST_URL).toBe('https://example.com') | |
| 78 | }) | |
| 79 | ||
| 80 | test('rejects invalid url', () => { | |
| 81 | process.env.TEST_URL = 'not-a-url' | |
| 82 | expect(() => { | |
| 83 | defineEnv({ TEST_URL: { type: 'url' } }) | |
| 84 | }).toThrow('Environment validation failed') | |
| 85 | }) | |
| 86 | ||
| 87 | test('validates email type', () => { | |
| 88 | process.env.TEST_EMAIL = 'test@example.com' | |
| 89 | const env = defineEnv({ | |
| 90 | TEST_EMAIL: { type: 'email' }, | |
| 91 | }) | |
| 92 | expect(env.TEST_EMAIL).toBe('test@example.com') | |
| 93 | }) | |
| 94 | ||
| 95 | test('rejects invalid email', () => { | |
| 96 | process.env.TEST_EMAIL = 'not-an-email' | |
| 97 | expect(() => { | |
| 98 | defineEnv({ TEST_EMAIL: { type: 'email' } }) | |
| 99 | }).toThrow('Environment validation failed') | |
| 100 | }) | |
| 101 | ||
| 102 | test('enforces choices constraint', () => { | |
| 103 | process.env.TEST_MODE = 'staging' | |
| 104 | expect(() => { | |
| 105 | defineEnv({ | |
| 106 | TEST_MODE: { type: 'string', choices: ['development', 'production'] }, | |
| 107 | }) | |
| 108 | }).toThrow('must be one of') | |
| 109 | }) | |
| 110 | ||
| 111 | test('passes custom validation', () => { | |
| 112 | process.env.TEST_CUSTOM = '50' | |
| 113 | const env = defineEnv({ | |
| 114 | TEST_CUSTOM: { type: 'number', validate: (v: number) => v >= 0 && v <= 100 }, | |
| 115 | }) | |
| 116 | expect(env.TEST_CUSTOM).toBe(50) | |
| 117 | }) | |
| 118 | ||
| 119 | test('rejects failed custom validation', () => { | |
| 120 | process.env.TEST_CUSTOM = '200' | |
| 121 | expect(() => { | |
| 122 | defineEnv({ | |
| 123 | TEST_CUSTOM: { type: 'number', validate: (v: number) => v >= 0 && v <= 100 }, | |
| 124 | }) | |
| 125 | }).toThrow('failed custom validation') | |
| 126 | }) | |
| 127 | ||
| 128 | test('rejects invalid port range', () => { | |
| 129 | process.env.TEST_PORT = '99999' | |
| 130 | expect(() => { | |
| 131 | defineEnv({ TEST_PORT: { type: 'port' } }) | |
| 132 | }).toThrow('valid port') | |
| 133 | }) | |
| 134 | ||
| 135 | test('boolean accepts various truthy values', () => { | |
| 136 | for (const val of ['true', '1', 'yes', 'TRUE', 'Yes']) { | |
| 137 | process.env.TEST_BOOL = val | |
| 138 | const env = defineEnv({ TEST_BOOL: { type: 'boolean' } }) | |
| 139 | expect(env.TEST_BOOL).toBe(true) | |
| 140 | } | |
| 141 | }) | |
| 142 | ||
| 143 | test('boolean accepts various falsy values', () => { | |
| 144 | for (const val of ['false', '0', 'no', 'FALSE', 'No']) { | |
| 145 | process.env.TEST_BOOL = val | |
| 146 | const env = defineEnv({ TEST_BOOL: { type: 'boolean' } }) | |
| 147 | expect(env.TEST_BOOL).toBe(false) | |
| 148 | } | |
| 149 | }) | |
| 150 | }) | |
| 151 | ||
| 152 | describe('environment helpers', () => { | |
| 153 | test('isProduction returns true when NODE_ENV is production', () => { | |
| 154 | process.env.NODE_ENV = 'production' | |
| 155 | expect(isProduction()).toBe(true) | |
| 156 | }) | |
| 157 | ||
| 158 | test('isProduction returns false when NODE_ENV is not production', () => { | |
| 159 | process.env.NODE_ENV = 'development' | |
| 160 | expect(isProduction()).toBe(false) | |
| 161 | }) | |
| 162 | ||
| 163 | test('isDevelopment returns true when not production', () => { | |
| 164 | process.env.NODE_ENV = 'development' | |
| 165 | expect(isDevelopment()).toBe(true) | |
| 166 | }) | |
| 167 | ||
| 168 | test('isTest returns true when NODE_ENV is test', () => { | |
| 169 | process.env.NODE_ENV = 'test' | |
| 170 | expect(isTest()).toBe(true) | |
| 171 | }) | |
| 172 | }) | |
| @@ -0,0 +1,110 @@ | ||
| 1 | import { describe, expect, test } from 'bun:test' | |
| 2 | import { validateEnv } from '../src/validation' | |
| 3 | ||
| 4 | describe('validateEnv', () => { | |
| 5 | test('returns errors for missing required fields', () => { | |
| 6 | const { errors } = validateEnv( | |
| 7 | { API_KEY: { type: 'string', required: true } }, | |
| 8 | {}, | |
| 9 | ) | |
| 10 | expect(errors).toHaveLength(1) | |
| 11 | expect(errors[0].key).toBe('API_KEY') | |
| 12 | }) | |
| 13 | ||
| 14 | test('uses defaults for missing optional fields', () => { | |
| 15 | const { values, errors } = validateEnv( | |
| 16 | { PORT: { type: 'number', default: 3000 } }, | |
| 17 | {}, | |
| 18 | ) | |
| 19 | expect(errors).toHaveLength(0) | |
| 20 | expect(values.PORT).toBe(3000) | |
| 21 | }) | |
| 22 | ||
| 23 | test('coerces number type', () => { | |
| 24 | const { values, errors } = validateEnv( | |
| 25 | { COUNT: { type: 'number' } }, | |
| 26 | { COUNT: '42' }, | |
| 27 | ) | |
| 28 | expect(errors).toHaveLength(0) | |
| 29 | expect(values.COUNT).toBe(42) | |
| 30 | }) | |
| 31 | ||
| 32 | test('errors on invalid number', () => { | |
| 33 | const { errors } = validateEnv( | |
| 34 | { COUNT: { type: 'number' } }, | |
| 35 | { COUNT: 'abc' }, | |
| 36 | ) | |
| 37 | expect(errors).toHaveLength(1) | |
| 38 | }) | |
| 39 | ||
| 40 | test('coerces boolean type', () => { | |
| 41 | const { values } = validateEnv( | |
| 42 | { DEBUG: { type: 'boolean' } }, | |
| 43 | { DEBUG: 'true' }, | |
| 44 | ) | |
| 45 | expect(values.DEBUG).toBe(true) | |
| 46 | }) | |
| 47 | ||
| 48 | test('validates port range', () => { | |
| 49 | const { errors } = validateEnv( | |
| 50 | { PORT: { type: 'port' } }, | |
| 51 | { PORT: '70000' }, | |
| 52 | ) | |
| 53 | expect(errors).toHaveLength(1) | |
| 54 | }) | |
| 55 | ||
| 56 | test('validates url format', () => { | |
| 57 | const { errors } = validateEnv( | |
| 58 | { URL: { type: 'url' } }, | |
| 59 | { URL: 'not-a-url' }, | |
| 60 | ) | |
| 61 | expect(errors).toHaveLength(1) | |
| 62 | }) | |
| 63 | ||
| 64 | test('validates email format', () => { | |
| 65 | const { errors } = validateEnv( | |
| 66 | { EMAIL: { type: 'email' } }, | |
| 67 | { EMAIL: 'invalid' }, | |
| 68 | ) | |
| 69 | expect(errors).toHaveLength(1) | |
| 70 | }) | |
| 71 | ||
| 72 | test('enforces choices', () => { | |
| 73 | const { errors } = validateEnv( | |
| 74 | { MODE: { type: 'string', choices: ['a', 'b'] } }, | |
| 75 | { MODE: 'c' }, | |
| 76 | ) | |
| 77 | expect(errors).toHaveLength(1) | |
| 78 | expect(errors[0].message).toContain('must be one of') | |
| 79 | }) | |
| 80 | ||
| 81 | test('runs custom validation', () => { | |
| 82 | const { errors } = validateEnv( | |
| 83 | { VAL: { type: 'number', validate: (v: number) => v > 10 } }, | |
| 84 | { VAL: '5' }, | |
| 85 | ) | |
| 86 | expect(errors).toHaveLength(1) | |
| 87 | expect(errors[0].message).toContain('failed custom validation') | |
| 88 | }) | |
| 89 | ||
| 90 | test('handles multiple errors', () => { | |
| 91 | const { errors } = validateEnv( | |
| 92 | { | |
| 93 | A: { type: 'string', required: true }, | |
| 94 | B: { type: 'number', required: true }, | |
| 95 | C: { type: 'url', required: true }, | |
| 96 | }, | |
| 97 | {}, | |
| 98 | ) | |
| 99 | expect(errors).toHaveLength(3) | |
| 100 | }) | |
| 101 | ||
| 102 | test('optional fields with no value and no default resolve to undefined', () => { | |
| 103 | const { values, errors } = validateEnv( | |
| 104 | { OPT: { type: 'string', required: false } }, | |
| 105 | {}, | |
| 106 | ) | |
| 107 | expect(errors).toHaveLength(0) | |
| 108 | expect(values.OPT).toBeUndefined() | |
| 109 | }) | |
| 110 | }) | |
| @@ -0,0 +1,14 @@ | ||
| 1 | import plugin from 'bun-plugin-dtsx' | |
| 2 | ||
| 3 | await Bun.build({ | |
| 4 | entrypoints: ['./src/index.ts'], | |
| 5 | outdir: './dist', | |
| 6 | target: 'bun', | |
| 7 | format: 'esm', | |
| 8 | splitting: true, | |
| 9 | sourcemap: 'external', | |
| 10 | minify: false, | |
| 11 | plugins: [plugin()], | |
| 12 | }) | |
| 13 | ||
| 14 | console.log('Build complete!') | |
| @@ -0,0 +1,48 @@ | ||
| 1 | { | |
| 2 | "name": "@stacksjs/data", | |
| 3 | "type": "module", | |
| 4 | "version": "0.2.6", | |
| 5 | "description": "Data loading, server actions, and composables for STX applications. Remix-style loaders with Nuxt-style composables.", | |
| 6 | "author": "Chris Breuer <chris@stacksjs.org>", | |
| 7 | "license": "MIT", | |
| 8 | "homepage": "https://github.com/stacksjs/stx#readme", | |
| 9 | "repository": { | |
| 10 | "type": "git", | |
| 11 | "url": "git+https://github.com/stacksjs/stx.git" | |
| 12 | }, | |
| 13 | "bugs": { | |
| 14 | "url": "https://github.com/stacksjs/stx/issues" | |
| 15 | }, | |
| 16 | "keywords": [ | |
| 17 | "data", | |
| 18 | "loader", | |
| 19 | "server-actions", | |
| 20 | "composables", | |
| 21 | "bun", | |
| 22 | "stx" | |
| 23 | ], | |
| 24 | "exports": { | |
| 25 | ".": { | |
| 26 | "types": "./dist/index.d.ts", | |
| 27 | "import": "./dist/index.js" | |
| 28 | }, | |
| 29 | "./*": { | |
| 30 | "import": "./dist/*" | |
| 31 | } | |
| 32 | }, | |
| 33 | "module": "./dist/index.js", | |
| 34 | "types": "./dist/index.d.ts", | |
| 35 | "files": [ | |
| 36 | "LICENSE.md", | |
| 37 | "README.md", | |
| 38 | "dist" | |
| 39 | ], | |
| 40 | "scripts": { | |
| 41 | "build": "bun --bun build.ts", | |
| 42 | "prepublishOnly": "bun run build", | |
| 43 | "test": "bun test", | |
| 44 | "test:watch": "bun test --watch", | |
| 45 | "test:coverage": "bun test --coverage", | |
| 46 | "typecheck": "bun tsc --noEmit" | |
| 47 | } | |
| 48 | } | |
| @@ -0,0 +1,50 @@ | ||
| 1 | import type { CacheEntry } from './types' | |
| 2 | ||
| 3 | const dataCache = new Map<string, CacheEntry>() | |
| 4 | ||
| 5 | export function getCached<T>(key: string): T | null { | |
| 6 | const entry = dataCache.get(key) | |
| 7 | if (!entry) return null | |
| 8 | ||
| 9 | const age = Date.now() - entry.timestamp | |
| 10 | if (entry.ttl === 0 || age > entry.ttl) { | |
| 11 | dataCache.delete(key) | |
| 12 | return null | |
| 13 | } | |
| 14 | ||
| 15 | return entry.value as T | |
| 16 | } | |
| 17 | ||
| 18 | export function setCached<T>(key: string, value: T, ttl: number): void { | |
| 19 | dataCache.set(key, { | |
| 20 | value, | |
| 21 | timestamp: Date.now(), | |
| 22 | ttl, | |
| 23 | }) | |
| 24 | } | |
| 25 | ||
| 26 | export function invalidateCache(key: string): void { | |
| 27 | dataCache.delete(key) | |
| 28 | } | |
| 29 | ||
| 30 | export function clearDataCache(): void { | |
| 31 | dataCache.clear() | |
| 32 | } | |
| 33 | ||
| 34 | export async function staleWhileRevalidate<T>( | |
| 35 | key: string, | |
| 36 | fetcher: () => Promise<T>, | |
| 37 | ttl: number, | |
| 38 | ): Promise<T> { | |
| 39 | const cached = getCached<T>(key) | |
| 40 | ||
| 41 | if (cached !== null) { | |
| 42 | // Revalidate in background | |
| 43 | fetcher().then(fresh => setCached(key, fresh, ttl)).catch(() => {}) | |
| 44 | return cached | |
| 45 | } | |
| 46 | ||
| 47 | const fresh = await fetcher() | |
| 48 | setCached(key, fresh, ttl) | |
| 49 | return fresh | |
| 50 | } | |
| @@ -0,0 +1,83 @@ | ||
| 1 | import type { AsyncDataResult, FetchOptions } from './types' | |
| 2 | ||
| 3 | export function useAsyncData<T>( | |
| 4 | key: string, | |
| 5 | handler: () => Promise<T>, | |
| 6 | options?: { lazy?: boolean, server?: boolean, default?: () => T }, | |
| 7 | ): AsyncDataResult<T> { | |
| 8 | const result: AsyncDataResult<T> = { | |
| 9 | data: options?.default?.() ?? null, | |
| 10 | pending: true, | |
| 11 | error: null, | |
| 12 | refresh: async () => { | |
| 13 | result.pending = true | |
| 14 | result.error = null | |
| 15 | try { | |
| 16 | result.data = await handler() | |
| 17 | } | |
| 18 | catch (err) { | |
| 19 | result.error = err instanceof Error ? err : new Error(String(err)) | |
| 20 | } | |
| 21 | finally { | |
| 22 | result.pending = false | |
| 23 | } | |
| 24 | }, | |
| 25 | } | |
| 26 | ||
| 27 | // Auto-execute unless lazy | |
| 28 | if (!options?.lazy) { | |
| 29 | handler() | |
| 30 | .then((data) => { | |
| 31 | result.data = data | |
| 32 | result.pending = false | |
| 33 | }) | |
| 34 | .catch((err) => { | |
| 35 | result.error = err instanceof Error ? err : new Error(String(err)) | |
| 36 | result.pending = false | |
| 37 | }) | |
| 38 | } | |
| 39 | else { | |
| 40 | result.pending = false | |
| 41 | } | |
| 42 | ||
| 43 | return result | |
| 44 | } | |
| 45 | ||
| 46 | export function useFetch<T>( | |
| 47 | url: string, | |
| 48 | options?: FetchOptions, | |
| 49 | ): AsyncDataResult<T> { | |
| 50 | const fetchHandler = async (): Promise<T> => { | |
| 51 | const { key: _key, lazy: _lazy, pick, server: _server, default: _default, ...fetchInit } = options ?? {} | |
| 52 | const response = await fetch(url, fetchInit) | |
| 53 | ||
| 54 | if (!response.ok) { | |
| 55 | throw new Error(`Fetch failed: ${response.status} ${response.statusText}`) | |
| 56 | } | |
| 57 | ||
| 58 | let data = await response.json() as T | |
| 59 | ||
| 60 | // Pick specific fields | |
| 61 | if (pick && typeof data === 'object' && data !== null) { | |
| 62 | const picked: Record<string, unknown> = {} | |
| 63 | for (const field of pick) { | |
| 64 | if (field in (data as any)) { | |
| 65 | picked[field] = (data as any)[field] | |
| 66 | } | |
| 67 | } | |
| 68 | data = picked as T | |
| 69 | } | |
| 70 | ||
| 71 | return data | |
| 72 | } | |
| 73 | ||
| 74 | return useAsyncData( | |
| 75 | options?.key ?? url, | |
| 76 | fetchHandler, | |
| 77 | { | |
| 78 | lazy: options?.lazy, | |
| 79 | server: options?.server, | |
| 80 | default: options?.default as (() => T) | undefined, | |
| 81 | }, | |
| 82 | ) | |
| 83 | } | |
| @@ -0,0 +1,29 @@ | ||
| 1 | export { | |
| 2 | createActionContext, | |
| 3 | createLoaderContext, | |
| 4 | defineAction, | |
| 5 | defineLoader, | |
| 6 | defineLoaders, | |
| 7 | executeAction, | |
| 8 | executeLoader, | |
| 9 | } from './loader' | |
| 10 | ||
| 11 | export { useAsyncData, useFetch } from './composables' | |
| 12 | ||
| 13 | export { clearDataCache, getCached, invalidateCache, setCached, staleWhileRevalidate } from './cache' | |
| 14 | ||
| 15 | export { deserialize, serialize } from './serialization' | |
| 16 | ||
| 17 | export type { | |
| 18 | ActionContext, | |
| 19 | ActionDefinition, | |
| 20 | ActionHandler, | |
| 21 | ActionResult, | |
| 22 | AsyncDataResult, | |
| 23 | CacheEntry, | |
| 24 | CookieAccessor, | |
| 25 | FetchOptions, | |
| 26 | LoaderContext, | |
| 27 | LoaderDefinition, | |
| 28 | LoaderHandler, | |
| 29 | } from './types' | |
| @@ -0,0 +1,73 @@ | ||
| 1 | import type { ActionContext, ActionDefinition, ActionHandler, ActionResult, LoaderContext, LoaderDefinition, LoaderHandler } from './types' | |
| 2 | ||
| 3 | export function defineLoader<T>(handler: LoaderHandler<T>): LoaderDefinition<T> { | |
| 4 | return { | |
| 5 | _type: 'loader', | |
| 6 | handler, | |
| 7 | } | |
| 8 | } | |
| 9 | ||
| 10 | export function defineLoaders<T extends Record<string, LoaderHandler<any>>>( | |
| 11 | loaders: T, | |
| 12 | ): { [K in keyof T]: LoaderDefinition<Awaited<ReturnType<T[K]>>> } { | |
| 13 | const result = {} as any | |
| 14 | for (const [key, handler] of Object.entries(loaders)) { | |
| 15 | result[key] = defineLoader(handler) | |
| 16 | } | |
| 17 | return result | |
| 18 | } | |
| 19 | ||
| 20 | export function defineAction<T>(handler: ActionHandler<T>): ActionDefinition<T> { | |
| 21 | return { | |
| 22 | _type: 'action', | |
| 23 | handler, | |
| 24 | } | |
| 25 | } | |
| 26 | ||
| 27 | function parseCookiesFromHeader(header: string): Record<string, string> { | |
| 28 | const cookies: Record<string, string> = {} | |
| 29 | if (!header) return cookies | |
| 30 | for (const pair of header.split(';')) { | |
| 31 | const [name, ...rest] = pair.split('=') | |
| 32 | if (name) { | |
| 33 | cookies[name.trim()] = decodeURIComponent(rest.join('=').trim()) | |
| 34 | } | |
| 35 | } | |
| 36 | return cookies | |
| 37 | } | |
| 38 | ||
| 39 | export function createLoaderContext(request: Request, params: Record<string, string> = {}): LoaderContext { | |
| 40 | const url = new URL(request.url) | |
| 41 | const cookieHeader = request.headers.get('cookie') || '' | |
| 42 | const allCookies = parseCookiesFromHeader(cookieHeader) | |
| 43 | ||
| 44 | return { | |
| 45 | params, | |
| 46 | request, | |
| 47 | headers: request.headers, | |
| 48 | url, | |
| 49 | cookies: { | |
| 50 | get(name: string) { return allCookies[name] }, | |
| 51 | getAll() { return { ...allCookies } }, | |
| 52 | }, | |
| 53 | } | |
| 54 | } | |
| 55 | ||
| 56 | export function createActionContext(request: Request, params: Record<string, string> = {}): ActionContext { | |
| 57 | const base = createLoaderContext(request, params) | |
| 58 | return { | |
| 59 | ...base, | |
| 60 | formData: () => request.formData(), | |
| 61 | json: <T>() => request.json() as Promise<T>, | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | export async function executeLoader<T>(loader: LoaderDefinition<T>, request: Request, params?: Record<string, string>): Promise<T> { | |
| 66 | const ctx = createLoaderContext(request, params) | |
| 67 | return await loader.handler(ctx) | |
| 68 | } | |
| 69 | ||
| 70 | export async function executeAction<T>(action: ActionDefinition<T>, request: Request, params?: Record<string, string>): Promise<ActionResult<T>> { | |
| 71 | const ctx = createActionContext(request, params) | |
| 72 | return await action.handler(ctx) | |
| 73 | } | |
| @@ -0,0 +1,52 @@ | ||
| 1 | function prepareForSerialization(value: unknown): unknown { | |
| 2 | if (value instanceof Date) { | |
| 3 | return { __type: 'Date', value: value.toISOString() } | |
| 4 | } | |
| 5 | if (typeof value === 'bigint') { | |
| 6 | return { __type: 'BigInt', value: value.toString() } | |
| 7 | } | |
| 8 | if (value instanceof Map) { | |
| 9 | return { __type: 'Map', value: Array.from(value.entries()).map(([k, v]) => [k, prepareForSerialization(v)]) } | |
| 10 | } | |
| 11 | if (value instanceof Set) { | |
| 12 | return { __type: 'Set', value: Array.from(value).map(prepareForSerialization) } | |
| 13 | } | |
| 14 | if (value instanceof RegExp) { | |
| 15 | return { __type: 'RegExp', source: value.source, flags: value.flags } | |
| 16 | } | |
| 17 | if (Array.isArray(value)) { | |
| 18 | return value.map(prepareForSerialization) | |
| 19 | } | |
| 20 | if (value !== null && typeof value === 'object') { | |
| 21 | const result: Record<string, unknown> = {} | |
| 22 | for (const [k, v] of Object.entries(value)) { | |
| 23 | result[k] = prepareForSerialization(v) | |
| 24 | } | |
| 25 | return result | |
| 26 | } | |
| 27 | return value | |
| 28 | } | |
| 29 | ||
| 30 | export function serialize(value: unknown): string { | |
| 31 | return JSON.stringify(prepareForSerialization(value)) | |
| 32 | } | |
| 33 | ||
| 34 | export function deserialize<T = unknown>(json: string): T { | |
| 35 | return JSON.parse(json, (_key, val) => { | |
| 36 | if (val && typeof val === 'object' && '__type' in val) { | |
| 37 | switch (val.__type) { | |
| 38 | case 'Date': | |
| 39 | return new Date(val.value) | |
| 40 | case 'BigInt': | |
| 41 | return BigInt(val.value) | |
| 42 | case 'Map': | |
| 43 | return new Map(val.value) | |
| 44 | case 'Set': | |
| 45 | return new Set(val.value) | |
| 46 | case 'RegExp': | |
| 47 | return new RegExp(val.source, val.flags) | |
| 48 | } | |
| 49 | } | |
| 50 | return val | |
| 51 | }) | |
| 52 | } | |
| @@ -0,0 +1,59 @@ | ||
| 1 | export interface LoaderContext { | |
| 2 | params: Record<string, string> | |
| 3 | request: Request | |
| 4 | cookies: CookieAccessor | |
| 5 | headers: Headers | |
| 6 | url: URL | |
| 7 | } | |
| 8 | ||
| 9 | export interface CookieAccessor { | |
| 10 | get(name: string): string | undefined | |
| 11 | getAll(): Record<string, string> | |
| 12 | } | |
| 13 | ||
| 14 | export interface ActionContext extends LoaderContext { | |
| 15 | formData: () => Promise<FormData> | |
| 16 | json: <T>() => Promise<T> | |
| 17 | } | |
| 18 | ||
| 19 | export interface ActionResult<T = unknown> { | |
| 20 | data?: T | |
| 21 | errors?: Record<string, string[]> | |
| 22 | redirect?: string | |
| 23 | status?: number | |
| 24 | } | |
| 25 | ||
| 26 | export type LoaderHandler<T> = (ctx: LoaderContext) => Promise<T> | T | |
| 27 | ||
| 28 | export interface LoaderDefinition<T = unknown> { | |
| 29 | _type: 'loader' | |
| 30 | handler: LoaderHandler<T> | |
| 31 | } | |
| 32 | ||
| 33 | export type ActionHandler<T> = (ctx: ActionContext) => Promise<ActionResult<T>> | |
| 34 | ||
| 35 | export interface ActionDefinition<T = unknown> { | |
| 36 | _type: 'action' | |
| 37 | handler: ActionHandler<T> | |
| 38 | } | |
| 39 | ||
| 40 | export interface FetchOptions extends RequestInit { | |
| 41 | key?: string | |
| 42 | lazy?: boolean | |
| 43 | pick?: string[] | |
| 44 | server?: boolean | |
| 45 | default?: () => unknown | |
| 46 | } | |
| 47 | ||
| 48 | export interface AsyncDataResult<T> { | |
| 49 | data: T | null | |
| 50 | pending: boolean | |
| 51 | error: Error | null | |
| 52 | refresh: () => Promise<void> | |
| 53 | } | |
| 54 | ||
| 55 | export interface CacheEntry<T = unknown> { | |
| 56 | value: T | |
| 57 | timestamp: number | |
| 58 | ttl: number | |
| 59 | } | |
| @@ -0,0 +1,65 @@ | ||
| 1 | import { afterEach, describe, expect, test } from 'bun:test' | |
| 2 | import { clearDataCache, getCached, invalidateCache, setCached, staleWhileRevalidate } from '../src/cache' | |
| 3 | ||
| 4 | describe('data cache', () => { | |
| 5 | afterEach(() => { | |
| 6 | clearDataCache() | |
| 7 | }) | |
| 8 | ||
| 9 | test('stores and retrieves cached values', () => { | |
| 10 | setCached('key', { name: 'test' }, 60000) | |
| 11 | expect(getCached('key')).toEqual({ name: 'test' }) | |
| 12 | }) | |
| 13 | ||
| 14 | test('returns null for missing keys', () => { | |
| 15 | expect(getCached('missing')).toBeNull() | |
| 16 | }) | |
| 17 | ||
| 18 | test('expires entries based on TTL', () => { | |
| 19 | setCached('key', 'value', 0) // TTL of 0ms = immediately expired | |
| 20 | // Wait a tick for the timestamp to age | |
| 21 | expect(getCached('key')).toBeNull() | |
| 22 | }) | |
| 23 | ||
| 24 | test('invalidates specific keys', () => { | |
| 25 | setCached('a', 1, 60000) | |
| 26 | setCached('b', 2, 60000) | |
| 27 | invalidateCache('a') | |
| 28 | expect(getCached('a')).toBeNull() | |
| 29 | expect(getCached('b')).toBe(2) | |
| 30 | }) | |
| 31 | ||
| 32 | test('clears all cache entries', () => { | |
| 33 | setCached('a', 1, 60000) | |
| 34 | setCached('b', 2, 60000) | |
| 35 | clearDataCache() | |
| 36 | expect(getCached('a')).toBeNull() | |
| 37 | expect(getCached('b')).toBeNull() | |
| 38 | }) | |
| 39 | }) | |
| 40 | ||
| 41 | describe('staleWhileRevalidate', () => { | |
| 42 | afterEach(() => { | |
| 43 | clearDataCache() | |
| 44 | }) | |
| 45 | ||
| 46 | test('fetches fresh data on cache miss', async () => { | |
| 47 | const result = await staleWhileRevalidate('key', async () => 42, 60000) | |
| 48 | expect(result).toBe(42) | |
| 49 | }) | |
| 50 | ||
| 51 | test('returns stale data and revalidates in background', async () => { | |
| 52 | setCached('key', 'old', 60000) | |
| 53 | let fetchCount = 0 | |
| 54 | const result = await staleWhileRevalidate('key', async () => { | |
| 55 | fetchCount++ | |
| 56 | return 'new' | |
| 57 | }, 60000) | |
| 58 | ||
| 59 | expect(result).toBe('old') | |
| 60 | // Give background revalidation time to complete | |
| 61 | await new Promise(resolve => setTimeout(resolve, 10)) | |
| 62 | expect(fetchCount).toBe(1) | |
| 63 | expect(getCached('key')).toBe('new') | |
| 64 | }) | |
| 65 | }) | |
| @@ -0,0 +1,118 @@ | ||
| 1 | import { describe, expect, test } from 'bun:test' | |
| 2 | import { createActionContext, createLoaderContext, defineAction, defineLoader, defineLoaders, executeAction, executeLoader } from '../src/loader' | |
| 3 | ||
| 4 | describe('defineLoader', () => { | |
| 5 | test('creates a loader definition', () => { | |
| 6 | const loader = defineLoader(async ({ params }) => { | |
| 7 | return { id: params.id, title: 'Test' } | |
| 8 | }) | |
| 9 | expect(loader._type).toBe('loader') | |
| 10 | expect(typeof loader.handler).toBe('function') | |
| 11 | }) | |
| 12 | }) | |
| 13 | ||
| 14 | describe('defineLoaders', () => { | |
| 15 | test('creates multiple loader definitions', () => { | |
| 16 | const loaders = defineLoaders({ | |
| 17 | posts: async () => [{ id: 1 }], | |
| 18 | user: async () => ({ name: 'Test' }), | |
| 19 | }) | |
| 20 | expect(loaders.posts._type).toBe('loader') | |
| 21 | expect(loaders.user._type).toBe('loader') | |
| 22 | }) | |
| 23 | }) | |
| 24 | ||
| 25 | describe('defineAction', () => { | |
| 26 | test('creates an action definition', () => { | |
| 27 | const action = defineAction(async () => { | |
| 28 | return { data: { success: true } } | |
| 29 | }) | |
| 30 | expect(action._type).toBe('action') | |
| 31 | }) | |
| 32 | }) | |
| 33 | ||
| 34 | describe('createLoaderContext', () => { | |
| 35 | test('creates context from request', () => { | |
| 36 | const request = new Request('https://example.com/posts/42', { | |
| 37 | headers: { cookie: 'session=abc123' }, | |
| 38 | }) | |
| 39 | const ctx = createLoaderContext(request, { id: '42' }) | |
| 40 | ||
| 41 | expect(ctx.params.id).toBe('42') | |
| 42 | expect(ctx.url.pathname).toBe('/posts/42') | |
| 43 | expect(ctx.cookies.get('session')).toBe('abc123') | |
| 44 | expect(ctx.headers.get('cookie')).toBeTruthy() | |
| 45 | }) | |
| 46 | }) | |
| 47 | ||
| 48 | describe('createActionContext', () => { | |
| 49 | test('extends loader context with formData and json', () => { | |
| 50 | const request = new Request('https://example.com/api', { | |
| 51 | method: 'POST', | |
| 52 | body: JSON.stringify({ title: 'Hello' }), | |
| 53 | headers: { 'content-type': 'application/json' }, | |
| 54 | }) | |
| 55 | const ctx = createActionContext(request) | |
| 56 | ||
| 57 | expect(typeof ctx.formData).toBe('function') | |
| 58 | expect(typeof ctx.json).toBe('function') | |
| 59 | expect(ctx.params).toEqual({}) | |
| 60 | }) | |
| 61 | }) | |
| 62 | ||
| 63 | describe('executeLoader', () => { | |
| 64 | test('executes loader and returns data', async () => { | |
| 65 | const loader = defineLoader(async ({ params }) => { | |
| 66 | return { post: { id: params.id, title: 'Test Post' } } | |
| 67 | }) | |
| 68 | ||
| 69 | const request = new Request('https://example.com/posts/1') | |
| 70 | const result = await executeLoader(loader, request, { id: '1' }) | |
| 71 | ||
| 72 | expect(result.post.id).toBe('1') | |
| 73 | expect(result.post.title).toBe('Test Post') | |
| 74 | }) | |
| 75 | ||
| 76 | test('supports sync loaders', async () => { | |
| 77 | const loader = defineLoader(({ params }) => { | |
| 78 | return { value: Number(params.n) * 2 } | |
| 79 | }) | |
| 80 | ||
| 81 | const request = new Request('https://example.com/calc/5') | |
| 82 | const result = await executeLoader(loader, request, { n: '5' }) | |
| 83 | expect(result.value).toBe(10) | |
| 84 | }) | |
| 85 | }) | |
| 86 | ||
| 87 | describe('executeAction', () => { | |
| 88 | test('executes action and returns result', async () => { | |
| 89 | const action = defineAction(async (ctx) => { | |
| 90 | const body = await ctx.json<{ title: string }>() | |
| 91 | return { data: { created: true, title: body.title } } | |
| 92 | }) | |
| 93 | ||
| 94 | const request = new Request('https://example.com/api/posts', { | |
| 95 | method: 'POST', | |
| 96 | body: JSON.stringify({ title: 'New Post' }), | |
| 97 | headers: { 'content-type': 'application/json' }, | |
| 98 | }) | |
| 99 | ||
| 100 | const result = await executeAction(action, request) | |
| 101 | expect(result.data?.created).toBe(true) | |
| 102 | expect(result.data?.title).toBe('New Post') | |
| 103 | }) | |
| 104 | ||
| 105 | test('supports validation errors', async () => { | |
| 106 | const action = defineAction(async () => { | |
| 107 | return { | |
| 108 | errors: { title: ['Title is required'] }, | |
| 109 | status: 422, | |
| 110 | } | |
| 111 | }) | |
| 112 | ||
| 113 | const request = new Request('https://example.com/api/posts', { method: 'POST' }) | |
| 114 | const result = await executeAction(action, request) | |
| 115 | expect(result.errors?.title).toEqual(['Title is required']) | |
| 116 | expect(result.status).toBe(422) | |
| 117 | }) | |
| 118 | }) | |
| @@ -0,0 +1,64 @@ | ||
| 1 | import { describe, expect, test } from 'bun:test' | |
| 2 | import { deserialize, serialize } from '../src/serialization' | |
| 3 | ||
| 4 | describe('serialization', () => { | |
| 5 | test('round-trips plain objects', () => { | |
| 6 | const data = { name: 'test', count: 42, active: true } | |
| 7 | expect(deserialize(serialize(data))).toEqual(data) | |
| 8 | }) | |
| 9 | ||
| 10 | test('round-trips Date objects', () => { | |
| 11 | const date = new Date('2025-01-15T10:30:00Z') | |
| 12 | const result = deserialize<{ date: Date }>(serialize({ date })) | |
| 13 | expect(result.date).toBeInstanceOf(Date) | |
| 14 | expect(result.date.toISOString()).toBe('2025-01-15T10:30:00.000Z') | |
| 15 | }) | |
| 16 | ||
| 17 | test('round-trips BigInt values', () => { | |
| 18 | const data = { big: BigInt('9007199254740993') } | |
| 19 | const result = deserialize<{ big: bigint }>(serialize(data)) | |
| 20 | expect(result.big).toBe(BigInt('9007199254740993')) | |
| 21 | }) | |
| 22 | ||
| 23 | test('round-trips Map objects', () => { | |
| 24 | const map = new Map([['a', 1], ['b', 2]]) | |
| 25 | const result = deserialize<{ map: Map<string, number> }>(serialize({ map })) | |
| 26 | expect(result.map).toBeInstanceOf(Map) | |
| 27 | expect(result.map.get('a')).toBe(1) | |
| 28 | expect(result.map.get('b')).toBe(2) | |
| 29 | }) | |
| 30 | ||
| 31 | test('round-trips Set objects', () => { | |
| 32 | const set = new Set([1, 2, 3]) | |
| 33 | const result = deserialize<{ set: Set<number> }>(serialize({ set })) | |
| 34 | expect(result.set).toBeInstanceOf(Set) | |
| 35 | expect(result.set.has(1)).toBe(true) | |
| 36 | expect(result.set.size).toBe(3) | |
| 37 | }) | |
| 38 | ||
| 39 | test('round-trips RegExp objects', () => { | |
| 40 | const regex = /hello\s+world/gi | |
| 41 | const result = deserialize<{ regex: RegExp }>(serialize({ regex })) | |
| 42 | expect(result.regex).toBeInstanceOf(RegExp) | |
| 43 | expect(result.regex.source).toBe('hello\\s+world') | |
| 44 | expect(result.regex.flags).toBe('gi') | |
| 45 | }) | |
| 46 | ||
| 47 | test('handles nested structures', () => { | |
| 48 | const data = { | |
| 49 | user: { name: 'Alice', createdAt: new Date('2025-01-01') }, | |
| 50 | tags: new Set(['a', 'b']), | |
| 51 | } | |
| 52 | const result = deserialize<typeof data>(serialize(data)) | |
| 53 | expect(result.user.createdAt).toBeInstanceOf(Date) | |
| 54 | expect(result.tags).toBeInstanceOf(Set) | |
| 55 | }) | |
| 56 | ||
| 57 | test('handles null and undefined values', () => { | |
| 58 | const data = { a: null, b: undefined } | |
| 59 | const result = deserialize<typeof data>(serialize(data)) | |
| 60 | expect(result.a).toBeNull() | |
| 61 | // JSON.stringify drops undefined values | |
| 62 | expect(result.b).toBeUndefined() | |
| 63 | }) | |
| 64 | }) | |
| @@ -0,0 +1,14 @@ | ||
| 1 | import plugin from 'bun-plugin-dtsx' | |
| 2 | ||
| 3 | await Bun.build({ | |
| 4 | entrypoints: ['./src/index.ts'], | |
| 5 | outdir: './dist', | |
| 6 | target: 'bun', | |
| 7 | format: 'esm', | |
| 8 | splitting: true, | |
| 9 | sourcemap: 'external', | |
| 10 | minify: false, | |
| 11 | plugins: [plugin()], | |
| 12 | }) | |
| 13 | ||
| 14 | console.log('Build complete!') | |
| @@ -0,0 +1,48 @@ | ||
| 1 | { | |
| 2 | "name": "@stacksjs/deploy", | |
| 3 | "type": "module", | |
| 4 | "version": "0.2.6", | |
| 5 | "description": "Deployment adapters for STX applications. Bun server default, static export, and community adapter interface.", | |
| 6 | "author": "Chris Breuer <chris@stacksjs.org>", | |
| 7 | "license": "MIT", | |
| 8 | "homepage": "https://github.com/stacksjs/stx#readme", | |
| 9 | "repository": { | |
| 10 | "type": "git", | |
| 11 | "url": "git+https://github.com/stacksjs/stx.git" | |
| 12 | }, | |
| 13 | "bugs": { | |
| 14 | "url": "https://github.com/stacksjs/stx/issues" | |
| 15 | }, | |
| 16 | "keywords": [ | |
| 17 | "deploy", | |
| 18 | "adapter", | |
| 19 | "bun-server", | |
| 20 | "static", | |
| 21 | "production", | |
| 22 | "stx" | |
| 23 | ], | |
| 24 | "exports": { | |
| 25 | ".": { | |
| 26 | "types": "./dist/index.d.ts", | |
| 27 | "import": "./dist/index.js" | |
| 28 | }, | |
| 29 | "./*": { | |
| 30 | "import": "./dist/*" | |
| 31 | } | |
| 32 | }, | |
| 33 | "module": "./dist/index.js", | |
| 34 | "types": "./dist/index.d.ts", | |
| 35 | "files": [ | |
| 36 | "LICENSE.md", | |
| 37 | "README.md", | |
| 38 | "dist" | |
| 39 | ], | |
| 40 | "scripts": { | |
| 41 | "build": "bun --bun build.ts", | |
| 42 | "prepublishOnly": "bun run build", | |
| 43 | "test": "bun test", | |
| 44 | "test:watch": "bun test --watch", | |
| 45 | "test:coverage": "bun test --coverage", | |
| 46 | "typecheck": "bun tsc --noEmit" | |
| 47 | } | |
| 48 | } | |
| @@ -0,0 +1,109 @@ | ||
| 1 | import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs' | |
| 2 | import { extname, join, relative } from 'node:path' | |
| 3 | import type { AdapterBuildConfig, AdapterBuildResult, BunServerConfig, DeployAdapter } from '../types' | |
| 4 | ||
| 5 | const MIME_TYPES: Record<string, string> = { | |
| 6 | '.html': 'text/html', | |
| 7 | '.css': 'text/css', | |
| 8 | '.js': 'application/javascript', | |
| 9 | '.json': 'application/json', | |
| 10 | '.png': 'image/png', | |
| 11 | '.jpg': 'image/jpeg', | |
| 12 | '.jpeg': 'image/jpeg', | |
| 13 | '.gif': 'image/gif', | |
| 14 | '.svg': 'image/svg+xml', | |
| 15 | '.ico': 'image/x-icon', | |
| 16 | '.woff': 'font/woff', | |
| 17 | '.woff2': 'font/woff2', | |
| 18 | '.ttf': 'font/ttf', | |
| 19 | '.webp': 'image/webp', | |
| 20 | '.avif': 'image/avif', | |
| 21 | } | |
| 22 | ||
| 23 | function collectFiles(dir: string, base: string = dir): string[] { | |
| 24 | const files: string[] = [] | |
| 25 | if (!existsSync(dir)) return files | |
| 26 | for (const entry of readdirSync(dir, { withFileTypes: true })) { | |
| 27 | const full = join(dir, entry.name) | |
| 28 | if (entry.isDirectory()) files.push(...collectFiles(full, base)) | |
| 29 | else files.push(relative(base, full)) | |
| 30 | } | |
| 31 | return files | |
| 32 | } | |
| 33 | ||
| 34 | function generateServerScript(config: BunServerConfig, outDir: string): string { | |
| 35 | const port = config.port ?? 3000 | |
| 36 | const hostname = config.hostname ?? '0.0.0.0' | |
| 37 | ||
| 38 | return `import { readFileSync, existsSync } from 'node:fs' | |
| 39 | import { join, extname } from 'node:path' | |
| 40 | ||
| 41 | const MIME_TYPES = ${JSON.stringify(MIME_TYPES, null, 2)} | |
| 42 | ||
| 43 | const PUBLIC_DIR = import.meta.dir | |
| 44 | ||
| 45 | Bun.serve({ | |
| 46 | port: ${port}, | |
| 47 | hostname: '${hostname}', | |
| 48 | ${config.tls ? `tls: { cert: Bun.file('${config.tls.cert}'), key: Bun.file('${config.tls.key}') },` : ''} | |
| 49 | async fetch(request) { | |
| 50 | const url = new URL(request.url) | |
| 51 | let pathname = url.pathname | |
| 52 | ||
| 53 | // Try exact file | |
| 54 | let filePath = join(PUBLIC_DIR, pathname) | |
| 55 | if (pathname === '/' || !existsSync(filePath)) { | |
| 56 | filePath = join(PUBLIC_DIR, pathname === '/' ? 'index.html' : pathname + '.html') | |
| 57 | } | |
| 58 | ||
| 59 | if (!existsSync(filePath)) { | |
| 60 | // SPA fallback | |
| 61 | filePath = join(PUBLIC_DIR, 'index.html') | |
| 62 | if (!existsSync(filePath)) { | |
| 63 | return new Response('Not Found', { status: 404 }) | |
| 64 | } | |
| 65 | } | |
| 66 | ||
| 67 | const ext = extname(filePath) | |
| 68 | const contentType = MIME_TYPES[ext] || 'application/octet-stream' | |
| 69 | const file = Bun.file(filePath) | |
| 70 | ||
| 71 | const headers = new Headers({ 'Content-Type': contentType }) | |
| 72 | ${config.compression ? `headers.set('Content-Encoding', 'identity')` : ''} | |
| 73 | ||
| 74 | // Cache static assets | |
| 75 | if (ext !== '.html') { | |
| 76 | headers.set('Cache-Control', 'public, max-age=31536000, immutable') | |
| 77 | } | |
| 78 | ||
| 79 | return new Response(file, { headers }) | |
| 80 | }, | |
| 81 | }) | |
| 82 | ||
| 83 | console.log('Server running at http://${hostname}:${port}') | |
| 84 | ` | |
| 85 | } | |
| 86 | ||
| 87 | export function bunServerAdapter(config: BunServerConfig = {}): DeployAdapter { | |
| 88 | return { | |
| 89 | name: 'bun-server', | |
| 90 | ||
| 91 | async build(buildConfig: AdapterBuildConfig): Promise<AdapterBuildResult> { | |
| 92 | const outDir = buildConfig.outDir | |
| 93 | mkdirSync(outDir, { recursive: true }) | |
| 94 | ||
| 95 | // Generate server entry | |
| 96 | const serverScript = generateServerScript(config, outDir) | |
| 97 | const serverPath = join(outDir, 'server.ts') | |
| 98 | writeFileSync(serverPath, serverScript) | |
| 99 | ||
| 100 | const files = collectFiles(outDir) | |
| 101 | ||
| 102 | return { | |
| 103 | outputDir: outDir, | |
| 104 | entrypoint: serverPath, | |
| 105 | files, | |
| 106 | } | |
| 107 | }, | |
| 108 | } | |
| 109 | } | |
| @@ -0,0 +1,64 @@ | ||
| 1 | import { existsSync, mkdirSync, readdirSync, statSync, copyFileSync } from 'node:fs' | |
| 2 | import { join, relative } from 'node:path' | |
| 3 | import type { AdapterBuildConfig, AdapterBuildResult, DeployAdapter, StaticAdapterConfig } from '../types' | |
| 4 | ||
| 5 | function copyDirSync(src: string, dest: string): void { | |
| 6 | mkdirSync(dest, { recursive: true }) | |
| 7 | for (const entry of readdirSync(src, { withFileTypes: true })) { | |
| 8 | const srcPath = join(src, entry.name) | |
| 9 | const destPath = join(dest, entry.name) | |
| 10 | if (entry.isDirectory()) { | |
| 11 | copyDirSync(srcPath, destPath) | |
| 12 | } | |
| 13 | else { | |
| 14 | copyFileSync(srcPath, destPath) | |
| 15 | } | |
| 16 | } | |
| 17 | } | |
| 18 | ||
| 19 | function collectFiles(dir: string, base: string = dir): string[] { | |
| 20 | const files: string[] = [] | |
| 21 | if (!existsSync(dir)) return files | |
| 22 | for (const entry of readdirSync(dir, { withFileTypes: true })) { | |
| 23 | const full = join(dir, entry.name) | |
| 24 | if (entry.isDirectory()) files.push(...collectFiles(full, base)) | |
| 25 | else files.push(relative(base, full)) | |
| 26 | } | |
| 27 | return files | |
| 28 | } | |
| 29 | ||
| 30 | export function staticAdapter(config: StaticAdapterConfig = {}): DeployAdapter { | |
| 31 | return { | |
| 32 | name: 'static', | |
| 33 | ||
| 34 | async build(buildConfig: AdapterBuildConfig): Promise<AdapterBuildResult> { | |
| 35 | const outDir = buildConfig.outDir | |
| 36 | mkdirSync(outDir, { recursive: true }) | |
| 37 | ||
| 38 | // If fallback page needed for SPA mode | |
| 39 | if (config.fallback) { | |
| 40 | const fallbackSrc = join(outDir, 'index.html') | |
| 41 | const fallbackDest = join(outDir, config.fallback) | |
| 42 | if (existsSync(fallbackSrc) && !existsSync(fallbackDest)) { | |
| 43 | copyFileSync(fallbackSrc, fallbackDest) | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | const files = collectFiles(outDir) | |
| 48 | ||
| 49 | return { | |
| 50 | outputDir: outDir, | |
| 51 | files, | |
| 52 | } | |
| 53 | }, | |
| 54 | ||
| 55 | async generateConfig(outputDir: string): Promise<void> { | |
| 56 | // Generate _redirects for Netlify-style hosting | |
| 57 | if (config.fallback) { | |
| 58 | const redirects = `/* /${config.fallback} 200\n` | |
| 59 | const { writeFileSync } = await import('node:fs') | |
| 60 | writeFileSync(join(outputDir, '_redirects'), redirects) | |
| 61 | } | |
| 62 | }, | |
| 63 | } | |
| 64 | } | |
| @@ -0,0 +1,17 @@ | ||
| 1 | export { defineAdapter } from './adapter' | |
| 2 | export { bunServerAdapter } from './adapters/bun-server' | |
| 3 | export { staticAdapter } from './adapters/static' | |
| 4 | export { detectRuntime, isEdgeRuntime } from './runtime' | |
| 5 | ||
| 6 | export type { | |
| 7 | AdapterBuildConfig, | |
| 8 | AdapterBuildResult, | |
| 9 | AdapterDeployConfig, | |
| 10 | BunServerConfig, | |
| 11 | DeployAdapter, | |
| 12 | DeployAppConfig, | |
| 13 | DeployResult, | |
| 14 | RuntimeInfo, | |
| 15 | RuntimePlatform, | |
| 16 | StaticAdapterConfig, | |
| 17 | } from './types' | |
| @@ -0,0 +1,81 @@ | ||
| 1 | import type { RuntimeInfo, RuntimePlatform } from './types' | |
| 2 | ||
| 3 | export function detectRuntime(): RuntimeInfo { | |
| 4 | // Cloudflare Workers | |
| 5 | if (typeof globalThis !== 'undefined' && 'caches' in globalThis && 'CF' in (globalThis as any)) { | |
| 6 | return { | |
| 7 | platform: 'cloudflare', | |
| 8 | supportsStreaming: true, | |
| 9 | supportsCrypto: true, | |
| 10 | supportsKV: true, | |
| 11 | supportsCache: true, | |
| 12 | capabilities: ['kv', 'r2', 'd1', 'durable-objects', 'queues'], | |
| 13 | } | |
| 14 | } | |
| 15 | ||
| 16 | // Deno | |
| 17 | if (typeof globalThis !== 'undefined' && 'Deno' in globalThis) { | |
| 18 | const deno = (globalThis as any).Deno | |
| 19 | return { | |
| 20 | platform: 'deno', | |
| 21 | version: deno.version?.deno, | |
| 22 | supportsStreaming: true, | |
| 23 | supportsCrypto: true, | |
| 24 | supportsKV: true, | |
| 25 | supportsCache: false, | |
| 26 | capabilities: ['kv', 'deploy'], | |
| 27 | } | |
| 28 | } | |
| 29 | ||
| 30 | // Bun | |
| 31 | if (typeof globalThis !== 'undefined' && 'Bun' in globalThis) { | |
| 32 | return { | |
| 33 | platform: 'bun', | |
| 34 | version: (globalThis as any).Bun.version, | |
| 35 | supportsStreaming: true, | |
| 36 | supportsCrypto: true, | |
| 37 | supportsKV: false, | |
| 38 | supportsCache: false, | |
| 39 | capabilities: ['sqlite', 'ffi', 'compile'], | |
| 40 | } | |
| 41 | } | |
| 42 | ||
| 43 | // Vercel Edge | |
| 44 | if (typeof globalThis !== 'undefined' && 'EdgeRuntime' in globalThis) { | |
| 45 | return { | |
| 46 | platform: 'vercel', | |
| 47 | supportsStreaming: true, | |
| 48 | supportsCrypto: true, | |
| 49 | supportsKV: true, | |
| 50 | supportsCache: true, | |
| 51 | capabilities: ['edge-config', 'kv'], | |
| 52 | } | |
| 53 | } | |
| 54 | ||
| 55 | // Node.js fallback | |
| 56 | if (typeof process !== 'undefined' && process.versions?.node) { | |
| 57 | return { | |
| 58 | platform: 'node', | |
| 59 | version: process.versions.node, | |
| 60 | supportsStreaming: true, | |
| 61 | supportsCrypto: true, | |
| 62 | supportsKV: false, | |
| 63 | supportsCache: false, | |
| 64 | capabilities: [], | |
| 65 | } | |
| 66 | } | |
| 67 | ||
| 68 | return { | |
| 69 | platform: 'unknown', | |
| 70 | supportsStreaming: false, | |
| 71 | supportsCrypto: false, | |
| 72 | supportsKV: false, | |
| 73 | supportsCache: false, | |
| 74 | capabilities: [], | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 78 | export function isEdgeRuntime(): boolean { | |
| 79 | const rt = detectRuntime() | |
| 80 | return ['cloudflare', 'deno', 'vercel', 'netlify'].includes(rt.platform) | |
| 81 | } | |
| @@ -0,0 +1,63 @@ | ||
| 1 | export type RuntimePlatform = 'bun' | 'node' | 'deno' | 'cloudflare' | 'vercel' | 'netlify' | 'unknown' | |
| 2 | ||
| 3 | export interface RuntimeInfo { | |
| 4 | platform: RuntimePlatform | |
| 5 | version?: string | |
| 6 | supportsStreaming: boolean | |
| 7 | supportsCrypto: boolean | |
| 8 | supportsKV: boolean | |
| 9 | supportsCache: boolean | |
| 10 | capabilities: string[] | |
| 11 | } | |
| 12 | ||
| 13 | export interface AdapterBuildConfig { | |
| 14 | entry: string | string[] | |
| 15 | outDir: string | |
| 16 | production: boolean | |
| 17 | } | |
| 18 | ||
| 19 | export interface AdapterBuildResult { | |
| 20 | outputDir: string | |
| 21 | entrypoint?: string | |
| 22 | files: string[] | |
| 23 | } | |
| 24 | ||
| 25 | export interface AdapterDeployConfig { | |
| 26 | outputDir: string | |
| 27 | production: boolean | |
| 28 | token?: string | |
| 29 | siteId?: string | |
| 30 | } | |
| 31 | ||
| 32 | export interface DeployResult { | |
| 33 | success: boolean | |
| 34 | url?: string | |
| 35 | deployId?: string | |
| 36 | logs: string[] | |
| 37 | duration: number | |
| 38 | } | |
| 39 | ||
| 40 | export interface DeployAdapter { | |
| 41 | name: string | |
| 42 | build(config: AdapterBuildConfig): Promise<AdapterBuildResult> | |
| 43 | deploy?(config: AdapterDeployConfig): Promise<DeployResult> | |
| 44 | generateConfig?(outputDir: string): Promise<void> | |
| 45 | } | |
| 46 | ||
| 47 | export interface DeployAppConfig { | |
| 48 | adapter?: DeployAdapter | 'bun' | 'static' | |
| 49 | outputDir?: string | |
| 50 | production?: boolean | |
| 51 | } | |
| 52 | ||
| 53 | export interface BunServerConfig { | |
| 54 | port?: number | |
| 55 | hostname?: string | |
| 56 | compression?: boolean | |
| 57 | tls?: { cert: string, key: string } | |
| 58 | } | |
| 59 | ||
| 60 | export interface StaticAdapterConfig { | |
| 61 | prerender?: boolean | |
| 62 | fallback?: string | |
| 63 | } | |
| @@ -0,0 +1,84 @@ | ||
| 1 | import { afterEach, describe, expect, test } from 'bun:test' | |
| 2 | import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' | |
| 3 | import { join } from 'node:path' | |
| 4 | import { bunServerAdapter } from '../src/adapters/bun-server' | |
| 5 | import { staticAdapter } from '../src/adapters/static' | |
| 6 | import { defineAdapter } from '../src/adapter' | |
| 7 | ||
| 8 | const TEST_DIR = join(import.meta.dir, '.test-output') | |
| 9 | ||
| 10 | describe('bunServerAdapter', () => { | |
| 11 | afterEach(() => { | |
| 12 | if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }) | |
| 13 | }) | |
| 14 | ||
| 15 | test('creates adapter with name', () => { | |
| 16 | const adapter = bunServerAdapter() | |
| 17 | expect(adapter.name).toBe('bun-server') | |
| 18 | }) | |
| 19 | ||
| 20 | test('generates server script on build', async () => { | |
| 21 | mkdirSync(TEST_DIR, { recursive: true }) | |
| 22 | writeFileSync(join(TEST_DIR, 'index.html'), '<h1>Hello</h1>') | |
| 23 | ||
| 24 | const adapter = bunServerAdapter({ port: 8080 }) | |
| 25 | const result = await adapter.build({ | |
| 26 | entry: 'index.html', | |
| 27 | outDir: TEST_DIR, | |
| 28 | production: true, | |
| 29 | }) | |
| 30 | ||
| 31 | expect(result.outputDir).toBe(TEST_DIR) | |
| 32 | expect(result.entrypoint).toContain('server.ts') | |
| 33 | expect(existsSync(result.entrypoint!)).toBe(true) | |
| 34 | }) | |
| 35 | }) | |
| 36 | ||
| 37 | describe('staticAdapter', () => { | |
| 38 | afterEach(() => { | |
| 39 | if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }) | |
| 40 | }) | |
| 41 | ||
| 42 | test('creates adapter with name', () => { | |
| 43 | const adapter = staticAdapter() | |
| 44 | expect(adapter.name).toBe('static') | |
| 45 | }) | |
| 46 | ||
| 47 | test('collects files in output directory', async () => { | |
| 48 | mkdirSync(TEST_DIR, { recursive: true }) | |
| 49 | writeFileSync(join(TEST_DIR, 'index.html'), '<h1>Hello</h1>') | |
| 50 | writeFileSync(join(TEST_DIR, 'about.html'), '<h1>About</h1>') | |
| 51 | ||
| 52 | const adapter = staticAdapter() | |
| 53 | const result = await adapter.build({ | |
| 54 | entry: 'index.html', | |
| 55 | outDir: TEST_DIR, | |
| 56 | production: true, | |
| 57 | }) | |
| 58 | ||
| 59 | expect(result.files).toContain('index.html') | |
| 60 | expect(result.files).toContain('about.html') | |
| 61 | }) | |
| 62 | ||
| 63 | test('generates _redirects for SPA fallback', async () => { | |
| 64 | mkdirSync(TEST_DIR, { recursive: true }) | |
| 65 | writeFileSync(join(TEST_DIR, 'index.html'), '<h1>SPA</h1>') | |
| 66 | ||
| 67 | const adapter = staticAdapter({ fallback: '200.html' }) | |
| 68 | await adapter.generateConfig!(TEST_DIR) | |
| 69 | ||
| 70 | expect(existsSync(join(TEST_DIR, '_redirects'))).toBe(true) | |
| 71 | }) | |
| 72 | }) | |
| 73 | ||
| 74 | describe('defineAdapter', () => { | |
| 75 | test('passes through adapter definition', () => { | |
| 76 | const adapter = defineAdapter({ | |
| 77 | name: 'custom', | |
| 78 | async build(config) { | |
| 79 | return { outputDir: config.outDir, files: [] } | |
| 80 | }, | |
| 81 | }) | |
| 82 | expect(adapter.name).toBe('custom') | |
| 83 | }) | |
| 84 | }) | |
| @@ -0,0 +1,17 @@ | ||
| 1 | import { describe, expect, test } from 'bun:test' | |
| 2 | import { detectRuntime, isEdgeRuntime } from '../src/runtime' | |
| 3 | ||
| 4 | describe('detectRuntime', () => { | |
| 5 | test('detects Bun runtime', () => { | |
| 6 | const info = detectRuntime() | |
| 7 | expect(info.platform).toBe('bun') | |
| 8 | expect(info.supportsStreaming).toBe(true) | |
| 9 | expect(info.supportsCrypto).toBe(true) | |
| 10 | expect(info.version).toBeTruthy() | |
| 11 | expect(info.capabilities).toContain('sqlite') | |
| 12 | }) | |
| 13 | ||
| 14 | test('isEdgeRuntime returns false for Bun', () => { | |
| 15 | expect(isEdgeRuntime()).toBe(false) | |
| 16 | }) | |
| 17 | }) | |
| @@ -0,0 +1,14 @@ | ||
| 1 | import plugin from 'bun-plugin-dtsx' | |
| 2 | ||
| 3 | await Bun.build({ | |
| 4 | entrypoints: ['./src/index.ts'], | |
| 5 | outdir: './dist', | |
| 6 | target: 'bun', | |
| 7 | format: 'esm', | |
| 8 | splitting: true, | |
| 9 | sourcemap: 'external', | |
| 10 | minify: false, | |
| 11 | plugins: [plugin()], | |
| 12 | }) | |
| 13 | ||
| 14 | console.log('Build complete!') | |
| @@ -0,0 +1,48 @@ | ||
| 1 | { | |
| 2 | "name": "@stacksjs/router", | |
| 3 | "type": "module", | |
| 4 | "version": "0.2.6", | |
| 5 | "description": "Hybrid server/client file-based router with typed params, nested layouts, and middleware for STX applications.", | |
| 6 | "author": "Chris Breuer <chris@stacksjs.org>", | |
| 7 | "license": "MIT", | |
| 8 | "homepage": "https://github.com/stacksjs/stx#readme", | |
| 9 | "repository": { | |
| 10 | "type": "git", | |
| 11 | "url": "git+https://github.com/stacksjs/stx.git" | |
| 12 | }, | |
| 13 | "bugs": { | |
| 14 | "url": "https://github.com/stacksjs/stx/issues" | |
| 15 | }, | |
| 16 | "keywords": [ | |
| 17 | "router", | |
| 18 | "file-based", | |
| 19 | "middleware", | |
| 20 | "typed-routes", | |
| 21 | "bun", | |
| 22 | "stx" | |
| 23 | ], | |
| 24 | "exports": { | |
| 25 | ".": { | |
| 26 | "types": "./dist/index.d.ts", | |
| 27 | "import": "./dist/index.js" | |
| 28 | }, | |
| 29 | "./*": { | |
| 30 | "import": "./dist/*" | |
| 31 | } | |
| 32 | }, | |
| 33 | "module": "./dist/index.js", | |
| 34 | "types": "./dist/index.d.ts", | |
| 35 | "files": [ | |
| 36 | "LICENSE.md", | |
| 37 | "README.md", | |
| 38 | "dist" | |
| 39 | ], | |
| 40 | "scripts": { | |
| 41 | "build": "bun --bun build.ts", | |
| 42 | "prepublishOnly": "bun run build", | |
| 43 | "test": "bun test", | |
| 44 | "test:watch": "bun test --watch", | |
| 45 | "test:coverage": "bun test --coverage", | |
| 46 | "typecheck": "bun tsc --noEmit" | |
| 47 | } | |
| 48 | } | |
| @@ -1,16 +1,3 @@ | ||
| 1 | /** | |
| 2 | * STX Client-Side Modules | |
| 3 | * | |
| 4 | * Browser-side functionality for STX applications. | |
| 5 | */ | |
| 6 | ||
| 7 | export * from './router' | |
| 8 | export * from './directive' | |
| 9 | ||
| 10 | /** | |
| 11 | * Generate the browser router script as a string | |
| 12 | * This can be injected into pages or served as a static file | |
| 13 | */ | |
| 14 | 1 | export function getRouterScript(): string { |
| 15 | 2 | return ` |
| 16 | 3 | ;(function(){ |
| @@ -0,0 +1,26 @@ | ||
| 1 | import fs from 'node:fs' | |
| 2 | import path from 'node:path' | |
| 3 | import type { Route } from './types' | |
| 4 | ||
| 5 | export function generateRouteTypes(routes: Route[], outputDir: string): void { | |
| 6 | const lines = [ | |
| 7 | '// Auto-generated by STX - do not edit', | |
| 8 | 'declare module "stx/routes" {', | |
| 9 | ' interface RouteMap {', | |
| 10 | ] | |
| 11 | ||
| 12 | for (const route of routes) { | |
| 13 | const paramEntries: string[] = [] | |
| 14 | for (const p of route.params) { | |
| 15 | const type = route.pattern.includes(`:${p}*`) ? 'string[]' : 'string' | |
| 16 | paramEntries.push(`${p}: ${type}`) | |
| 17 | } | |
| 18 | const paramsStr = paramEntries.join('; ') | |
| 19 | lines.push(` '${route.pattern}': { ${paramsStr} }`) | |
| 20 | } | |
| 21 | ||
| 22 | lines.push(' }', '}', '') | |
| 23 | ||
| 24 | fs.mkdirSync(outputDir, { recursive: true }) | |
| 25 | fs.writeFileSync(path.join(outputDir, 'route-types.d.ts'), lines.join('\n')) | |
| 26 | } | |
| @@ -0,0 +1,150 @@ | ||
| 1 | import fs from 'node:fs' | |
| 2 | import path from 'node:path' | |
| 3 | import type { Route, RouteMatch, RouterConfig } from './types' | |
| 4 | import { generateRouteTypes } from './codegen' | |
| 5 | import { filePathToPattern, patternToRegex } from './matcher' | |
| 6 | import { resolveLayoutChain } from './nested-layouts' | |
| 7 | ||
| 8 | function scanDirectory(dir: string, extensions: string[]): string[] { | |
| 9 | const files: string[] = [] | |
| 10 | ||
| 11 | if (!fs.existsSync(dir)) { | |
| 12 | return files | |
| 13 | } | |
| 14 | ||
| 15 | const entries = fs.readdirSync(dir, { withFileTypes: true }) | |
| 16 | ||
| 17 | for (const entry of entries) { | |
| 18 | const fullPath = path.join(dir, entry.name) | |
| 19 | ||
| 20 | if (entry.isDirectory()) { | |
| 21 | if (entry.name.startsWith('.')) { | |
| 22 | continue | |
| 23 | } | |
| 24 | files.push(...scanDirectory(fullPath, extensions)) | |
| 25 | } | |
| 26 | else if (entry.isFile()) { | |
| 27 | const ext = path.extname(entry.name) | |
| 28 | if (extensions.includes(ext)) { | |
| 29 | // Skip layout files from being routes themselves | |
| 30 | if (entry.name.startsWith('_') && entry.name !== '_layout.stx') { | |
| 31 | continue | |
| 32 | } | |
| 33 | // Don't register _layout.stx as a route | |
| 34 | if (entry.name === '_layout.stx') { | |
| 35 | continue | |
| 36 | } | |
| 37 | files.push(fullPath) | |
| 38 | } | |
| 39 | } | |
| 40 | } | |
| 41 | ||
| 42 | return files | |
| 43 | } | |
| 44 | ||
| 45 | export class Router { | |
| 46 | routes: Route[] | |
| 47 | private pagesDir: string | |
| 48 | private config: RouterConfig | |
| 49 | ||
| 50 | constructor(baseDir: string, config: RouterConfig = {}) { | |
| 51 | this.config = config | |
| 52 | this.pagesDir = path.resolve(baseDir, config.pagesDir || 'pages') | |
| 53 | const extensions = config.extensions || ['.stx'] | |
| 54 | ||
| 55 | const files = scanDirectory(this.pagesDir, extensions) | |
| 56 | ||
| 57 | this.routes = files.map((filePath) => { | |
| 58 | const pattern = filePathToPattern(filePath, this.pagesDir) | |
| 59 | const { regex, params } = patternToRegex(pattern) | |
| 60 | const layouts = config.layouts !== false ? resolveLayoutChain(filePath, this.pagesDir) : [] | |
| 61 | ||
| 62 | return { | |
| 63 | pattern, | |
| 64 | regex, | |
| 65 | params, | |
| 66 | filePath, | |
| 67 | isDynamic: params.length > 0, | |
| 68 | layout: layouts.length > 0 ? layouts[layouts.length - 1] : undefined, | |
| 69 | } | |
| 70 | }) | |
| 71 | ||
| 72 | // Sort: static before dynamic, more segments first | |
| 73 | this.routes.sort((a, b) => { | |
| 74 | if (!a.isDynamic && b.isDynamic) return -1 | |
| 75 | if (a.isDynamic && !b.isDynamic) return 1 | |
| 76 | const aSegments = a.pattern.split('/').length | |
| 77 | const bSegments = b.pattern.split('/').length | |
| 78 | if (aSegments !== bSegments) return bSegments - aSegments | |
| 79 | return a.pattern.localeCompare(b.pattern) | |
| 80 | }) | |
| 81 | ||
| 82 | // Generate type declarations | |
| 83 | const stxDir = path.join(baseDir, '.stx') | |
| 84 | generateRouteTypes(this.routes, stxDir) | |
| 85 | } | |
| 86 | ||
| 87 | match(pathname: string): RouteMatch | null { | |
| 88 | const normalizedPath = pathname === '' ? '/' : pathname | |
| 89 | ||
| 90 | for (const route of this.routes) { | |
| 91 | const m = normalizedPath.match(route.regex) | |
| 92 | if (m) { | |
| 93 | const params: Record<string, string> = {} | |
| 94 | route.params.forEach((name, index) => { | |
| 95 | params[name] = m[index + 1] | |
| 96 | }) | |
| 97 | return { route, params } | |
| 98 | } | |
| 99 | } | |
| 100 | ||
| 101 | return null | |
| 102 | } | |
| 103 | ||
| 104 | resolve(name: string, params?: Record<string, string>): string { | |
| 105 | const route = this.routes.find(r => r.pattern === name || r.filePath.includes(name)) | |
| 106 | if (!route) return '#not-found' | |
| 107 | ||
| 108 | let resolved = route.pattern | |
| 109 | if (params) { | |
| 110 | for (const [key, value] of Object.entries(params)) { | |
| 111 | resolved = resolved.replace(`:${key}`, encodeURIComponent(value)) | |
| 112 | } | |
| 113 | } | |
| 114 | return resolved | |
| 115 | } | |
| 116 | ||
| 117 | addRoute(route: Route): void { | |
| 118 | this.routes.push(route) | |
| 119 | this.routes.sort((a, b) => { | |
| 120 | if (!a.isDynamic && b.isDynamic) return -1 | |
| 121 | if (a.isDynamic && !b.isDynamic) return 1 | |
| 122 | return a.pattern.localeCompare(b.pattern) | |
| 123 | }) | |
| 124 | } | |
| 125 | ||
| 126 | getLayout(route: Route): string | null { | |
| 127 | return route.layout ?? null | |
| 128 | } | |
| 129 | } | |
| 130 | ||
| 131 | export function createRouter(baseDir: string, config?: RouterConfig): Router { | |
| 132 | return new Router(baseDir, config) | |
| 133 | } | |
| 134 | ||
| 135 | export function findErrorPage(pagesDir: string, statusCode: number): string | null { | |
| 136 | const candidates = [`${statusCode}.stx`, 'error.stx'] | |
| 137 | for (const candidate of candidates) { | |
| 138 | const filePath = path.join(pagesDir, candidate) | |
| 139 | if (fs.existsSync(filePath)) return filePath | |
| 140 | } | |
| 141 | return null | |
| 142 | } | |
| 143 | ||
| 144 | export function formatRoutes(routes: Route[], baseDir: string): string[] { | |
| 145 | return routes.map((route) => { | |
| 146 | const relativePath = path.relative(baseDir, route.filePath) | |
| 147 | const dynamicLabel = route.isDynamic ? ' (dynamic)' : '' | |
| 148 | return `${route.pattern} → ${relativePath}${dynamicLabel}` | |
| 149 | }) | |
| 150 | } | |
| @@ -0,0 +1,58 @@ | ||
| 1 | // File-based router | |
| 2 | export { createRouter, findErrorPage, formatRoutes, Router } from './file-router' | |
| 3 | ||
| 4 | // Route pattern matching | |
| 5 | export { filePathToPattern, matchRoute, patternToRegex } from './matcher' | |
| 6 | ||
| 7 | // Named routes | |
| 8 | export { defineRoute, defineRoutes, resetRoutes, route, setAppUrl } from './named-routes' | |
| 9 | ||
| 10 | // Middleware system | |
| 11 | export { | |
| 12 | abortNavigation, | |
| 13 | clearMiddleware, | |
| 14 | createClientCookieManager, | |
| 15 | createMiddlewareContext, | |
| 16 | createRouteLocation, | |
| 17 | createServerCookieManager, | |
| 18 | createStorageManager, | |
| 19 | defineMiddleware, | |
| 20 | getMiddleware, | |
| 21 | getMiddlewareNames, | |
| 22 | hasMiddleware, | |
| 23 | loadMiddlewareFromDirectory, | |
| 24 | navigateTo, | |
| 25 | registerMiddleware, | |
| 26 | runMiddleware, | |
| 27 | } from './middleware' | |
| 28 | ||
| 29 | // Nested layouts | |
| 30 | export { resolveLayoutChain } from './nested-layouts' | |
| 31 | ||
| 32 | // Route type generation | |
| 33 | export { generateRouteTypes } from './codegen' | |
| 34 | ||
| 35 | // Client-side SPA navigation | |
| 36 | export { getRouterScript } from './client' | |
| 37 | ||
| 38 | // Types | |
| 39 | export type { | |
| 40 | AbortNavigationResult, | |
| 41 | CookieManager, | |
| 42 | CookieOptions, | |
| 43 | MiddlewareContext, | |
| 44 | MiddlewareMode, | |
| 45 | MiddlewareOptions, | |
| 46 | MiddlewareResult, | |
| 47 | NavigateToOptions, | |
| 48 | NavigateToResult, | |
| 49 | NavigationError, | |
| 50 | NavigationResult, | |
| 51 | Route, | |
| 52 | RouteLocation, | |
| 53 | RouteMatch, | |
| 54 | RouteMiddlewareDefinition, | |
| 55 | RouteMiddlewareHandler, | |
| 56 | RouterConfig, | |
| 57 | StorageManager, | |
| 58 | } from './types' | |
| @@ -0,0 +1,71 @@ | ||
| 1 | import path from 'node:path' | |
| 2 | ||
| 3 | export function filePathToPattern(filePath: string, pagesDir: string): string { | |
| 4 | const relativePath = path.relative(pagesDir, filePath) | |
| 5 | let route = relativePath.replace(/\.(stx|md)$/, '') | |
| 6 | ||
| 7 | route = route.split(path.sep).join('/') | |
| 8 | ||
| 9 | if (route === 'index') { | |
| 10 | return '/' | |
| 11 | } | |
| 12 | if (route.endsWith('/index')) { | |
| 13 | route = route.slice(0, -6) | |
| 14 | } | |
| 15 | ||
| 16 | // Convert [[param]] to :param? (optional) — BEFORE required params | |
| 17 | route = route.replace(/\[\[([^\]]+)\]\]/g, ':$1?') | |
| 18 | ||
| 19 | // Convert [param] to :param | |
| 20 | route = route.replace(/\[([^\]]+)\]/g, ':$1') | |
| 21 | ||
| 22 | // Convert [...param] to :param* (catch-all) | |
| 23 | route = route.replace(/:\.\.\.([^/]+)/g, ':$1*') | |
| 24 | ||
| 25 | return `/${route}` | |
| 26 | } | |
| 27 | ||
| 28 | export function patternToRegex(pattern: string): { regex: RegExp, params: string[] } { | |
| 29 | const params: string[] = [] | |
| 30 | ||
| 31 | let regexStr = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&') | |
| 32 | ||
| 33 | // Handle optional params (/:param?) | |
| 34 | regexStr = regexStr.replace(/\/:([^/]+)\?/g, (_, name) => { | |
| 35 | params.push(name) | |
| 36 | return '(?:/([^/]+))?' | |
| 37 | }) | |
| 38 | ||
| 39 | // Handle catch-all params (:param*) | |
| 40 | regexStr = regexStr.replace(/:([^/]+)\*/g, (_, name) => { | |
| 41 | params.push(name) | |
| 42 | return '(.+)' | |
| 43 | }) | |
| 44 | ||
| 45 | // Handle regular params (:param) | |
| 46 | regexStr = regexStr.replace(/:([^/]+)/g, (_, name) => { | |
| 47 | params.push(name) | |
| 48 | return '([^/]+)' | |
| 49 | }) | |
| 50 | ||
| 51 | regexStr = `^${regexStr}$` | |
| 52 | ||
| 53 | return { regex: new RegExp(regexStr), params } | |
| 54 | } | |
| 55 | ||
| 56 | export function matchRoute(pathname: string, routes: { pattern: string, regex: RegExp, params: string[] }[]): { route: typeof routes[0], params: Record<string, string> } | null { | |
| 57 | const normalizedPath = pathname === '' ? '/' : pathname | |
| 58 | ||
| 59 | for (const route of routes) { | |
| 60 | const match = normalizedPath.match(route.regex) | |
| 61 | if (match) { | |
| 62 | const params: Record<string, string> = {} | |
| 63 | route.params.forEach((name, index) => { | |
| 64 | params[name] = match[index + 1] | |
| 65 | }) | |
| 66 | return { route, params } | |
| 67 | } | |
| 68 | } | |
| 69 | ||
| 70 | return null | |
| 71 | } | |
| @@ -0,0 +1,366 @@ | ||
| 1 | import fs from 'node:fs' | |
| 2 | import path from 'node:path' | |
| 3 | import type { | |
| 4 | AbortNavigationResult, | |
| 5 | CookieManager, | |
| 6 | CookieOptions, | |
| 7 | MiddlewareContext, | |
| 8 | MiddlewareMode, | |
| 9 | MiddlewareOptions, | |
| 10 | MiddlewareResult, | |
| 11 | NavigateToOptions, | |
| 12 | NavigateToResult, | |
| 13 | NavigationError, | |
| 14 | NavigationResult, | |
| 15 | RouteLocation, | |
| 16 | RouteMiddlewareDefinition, | |
| 17 | RouteMiddlewareHandler, | |
| 18 | StorageManager, | |
| 19 | } from './types' | |
| 20 | ||
| 21 | // ============================================================================= | |
| 22 | // Navigation Helpers | |
| 23 | // ============================================================================= | |
| 24 | ||
| 25 | export function navigateTo(navPath: string, options: NavigateToOptions = {}): NavigateToResult { | |
| 26 | return { | |
| 27 | type: 'redirect', | |
| 28 | path: navPath, | |
| 29 | options: { | |
| 30 | replace: options.replace ?? false, | |
| 31 | external: options.external ?? false, | |
| 32 | redirectCode: options.redirectCode ?? 302, | |
| 33 | }, | |
| 34 | } | |
| 35 | } | |
| 36 | ||
| 37 | export function abortNavigation(error: string | NavigationError): AbortNavigationResult { | |
| 38 | const normalizedError: NavigationError | |
| 39 | = typeof error === 'string' ? { statusCode: 500, message: error } : error | |
| 40 | ||
| 41 | return { | |
| 42 | type: 'abort', | |
| 43 | error: normalizedError, | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | // ============================================================================= | |
| 48 | // Middleware Definition | |
| 49 | // ============================================================================= | |
| 50 | ||
| 51 | export function defineMiddleware( | |
| 52 | handler: RouteMiddlewareHandler, | |
| 53 | options: MiddlewareOptions = {}, | |
| 54 | ): RouteMiddlewareDefinition { | |
| 55 | return { | |
| 56 | handler, | |
| 57 | mode: options.mode ?? 'universal', | |
| 58 | } | |
| 59 | } | |
| 60 | ||
| 61 | // ============================================================================= | |
| 62 | // Cookie Manager | |
| 63 | // ============================================================================= | |
| 64 | ||
| 65 | function parseCookies(cookieHeader: string): Record<string, string> { | |
| 66 | const cookies: Record<string, string> = {} | |
| 67 | if (!cookieHeader) return cookies | |
| 68 | ||
| 69 | const pairs = cookieHeader.split(';') | |
| 70 | for (const pair of pairs) { | |
| 71 | const [name, ...rest] = pair.split('=') | |
| 72 | if (name) { | |
| 73 | const key = name.trim() | |
| 74 | const value = rest.join('=').trim() | |
| 75 | if (key) { | |
| 76 | cookies[key] = decodeURIComponent(value) | |
| 77 | } | |
| 78 | } | |
| 79 | } | |
| 80 | return cookies | |
| 81 | } | |
| 82 | ||
| 83 | function serializeCookie(name: string, value: string, options: CookieOptions = {}): string { | |
| 84 | let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}` | |
| 85 | ||
| 86 | if (options.expires) { | |
| 87 | const expires | |
| 88 | = typeof options.expires === 'number' | |
| 89 | ? new Date(Date.now() + options.expires * 1000) | |
| 90 | : options.expires | |
| 91 | cookie += `; Expires=${expires.toUTCString()}` | |
| 92 | } | |
| 93 | ||
| 94 | if (options.path) cookie += `; Path=${options.path}` | |
| 95 | if (options.domain) cookie += `; Domain=${options.domain}` | |
| 96 | if (options.secure) cookie += '; Secure' | |
| 97 | if (options.httpOnly) cookie += '; HttpOnly' | |
| 98 | if (options.sameSite) cookie += `; SameSite=${options.sameSite}` | |
| 99 | ||
| 100 | return cookie | |
| 101 | } | |
| 102 | ||
| 103 | export function createServerCookieManager( | |
| 104 | request: Request, | |
| 105 | responseHeaders: Headers, | |
| 106 | ): CookieManager { | |
| 107 | const cookieHeader = request.headers.get('cookie') || '' | |
| 108 | const cookies = parseCookies(cookieHeader) | |
| 109 | ||
| 110 | return { | |
| 111 | get(name: string): string | undefined { | |
| 112 | return cookies[name] | |
| 113 | }, | |
| 114 | set(name: string, value: string, options: CookieOptions = {}): void { | |
| 115 | cookies[name] = value | |
| 116 | responseHeaders.append('Set-Cookie', serializeCookie(name, value, options)) | |
| 117 | }, | |
| 118 | delete(name: string): void { | |
| 119 | delete cookies[name] | |
| 120 | responseHeaders.append( | |
| 121 | 'Set-Cookie', | |
| 122 | serializeCookie(name, '', { expires: new Date(0), path: '/' }), | |
| 123 | ) | |
| 124 | }, | |
| 125 | getAll(): Record<string, string> { | |
| 126 | return { ...cookies } | |
| 127 | }, | |
| 128 | } | |
| 129 | } | |
| 130 | ||
| 131 | export function createClientCookieManager(): CookieManager { | |
| 132 | return { | |
| 133 | get(name: string): string | undefined { | |
| 134 | if (typeof document === 'undefined') return undefined | |
| 135 | const cookies = parseCookies(document.cookie) | |
| 136 | return cookies[name] | |
| 137 | }, | |
| 138 | set(name: string, value: string, options: CookieOptions = {}): void { | |
| 139 | if (typeof document === 'undefined') return | |
| 140 | document.cookie = serializeCookie(name, value, options) | |
| 141 | }, | |
| 142 | delete(name: string): void { | |
| 143 | if (typeof document === 'undefined') return | |
| 144 | document.cookie = serializeCookie(name, '', { expires: new Date(0), path: '/' }) | |
| 145 | }, | |
| 146 | getAll(): Record<string, string> { | |
| 147 | if (typeof document === 'undefined') return {} | |
| 148 | return parseCookies(document.cookie) | |
| 149 | }, | |
| 150 | } | |
| 151 | } | |
| 152 | ||
| 153 | // ============================================================================= | |
| 154 | // Storage Manager | |
| 155 | // ============================================================================= | |
| 156 | ||
| 157 | export function createStorageManager(): StorageManager { | |
| 158 | const available | |
| 159 | = typeof window !== 'undefined' && typeof window.localStorage !== 'undefined' | |
| 160 | ||
| 161 | return { | |
| 162 | get(key: string): string | null { | |
| 163 | if (!available) return null | |
| 164 | try { return window.localStorage.getItem(key) } | |
| 165 | catch { return null } | |
| 166 | }, | |
| 167 | set(key: string, value: string): void { | |
| 168 | if (!available) return | |
| 169 | try { window.localStorage.setItem(key, value) } | |
| 170 | catch { /* storage full or blocked */ } | |
| 171 | }, | |
| 172 | remove(key: string): void { | |
| 173 | if (!available) return | |
| 174 | try { window.localStorage.removeItem(key) } | |
| 175 | catch { /* ignore */ } | |
| 176 | }, | |
| 177 | isAvailable(): boolean { | |
| 178 | return available | |
| 179 | }, | |
| 180 | } | |
| 181 | } | |
| 182 | ||
| 183 | // ============================================================================= | |
| 184 | // Middleware Registry | |
| 185 | // ============================================================================= | |
| 186 | ||
| 187 | const middlewareRegistry = new Map<string, RouteMiddlewareDefinition>() | |
| 188 | ||
| 189 | export function registerMiddleware(name: string, middleware: RouteMiddlewareDefinition): void { | |
| 190 | middlewareRegistry.set(name, middleware) | |
| 191 | } | |
| 192 | ||
| 193 | export function getMiddleware(name: string): RouteMiddlewareDefinition | undefined { | |
| 194 | return middlewareRegistry.get(name) | |
| 195 | } | |
| 196 | ||
| 197 | export function hasMiddleware(name: string): boolean { | |
| 198 | return middlewareRegistry.has(name) | |
| 199 | } | |
| 200 | ||
| 201 | export function clearMiddleware(): void { | |
| 202 | middlewareRegistry.clear() | |
| 203 | } | |
| 204 | ||
| 205 | export function getMiddlewareNames(): string[] { | |
| 206 | return Array.from(middlewareRegistry.keys()) | |
| 207 | } | |
| 208 | ||
| 209 | // ============================================================================= | |
| 210 | // Middleware Loading | |
| 211 | // ============================================================================= | |
| 212 | ||
| 213 | export async function loadMiddlewareFromDirectory( | |
| 214 | baseDir: string, | |
| 215 | middlewareDir: string = 'middleware', | |
| 216 | ): Promise<void> { | |
| 217 | const fullPath = path.resolve(baseDir, middlewareDir) | |
| 218 | ||
| 219 | if (!fs.existsSync(fullPath)) { | |
| 220 | return | |
| 221 | } | |
| 222 | ||
| 223 | const files = fs.readdirSync(fullPath) | |
| 224 | ||
| 225 | for (const file of files) { | |
| 226 | if (!file.endsWith('.ts') && !file.endsWith('.js')) continue | |
| 227 | if (file.includes('.test.') || file.includes('.spec.')) continue | |
| 228 | ||
| 229 | const name = file.replace(/\.(ts|js)$/, '') | |
| 230 | const filePath = path.join(fullPath, file) | |
| 231 | ||
| 232 | try { | |
| 233 | const mod = await import(filePath) | |
| 234 | const mw = mod.default | |
| 235 | ||
| 236 | if (mw && typeof mw.handler === 'function') { | |
| 237 | registerMiddleware(name, mw) | |
| 238 | } | |
| 239 | else if (typeof mw === 'function') { | |
| 240 | registerMiddleware(name, { handler: mw, mode: 'universal' }) | |
| 241 | } | |
| 242 | } | |
| 243 | catch (error) { | |
| 244 | console.warn(`Failed to load middleware '${name}' from ${filePath}:`, error) | |
| 245 | } | |
| 246 | } | |
| 247 | } | |
| 248 | ||
| 249 | // ============================================================================= | |
| 250 | // Middleware Context & Execution | |
| 251 | // ============================================================================= | |
| 252 | ||
| 253 | function parseQuery(search: string): Record<string, string> { | |
| 254 | const query: Record<string, string> = {} | |
| 255 | if (!search) return query | |
| 256 | ||
| 257 | const params = new URLSearchParams(search) | |
| 258 | for (const [key, value] of params) { | |
| 259 | query[key] = value | |
| 260 | } | |
| 261 | return query | |
| 262 | } | |
| 263 | ||
| 264 | export function createRouteLocation( | |
| 265 | pathname: string, | |
| 266 | params: Record<string, string>, | |
| 267 | meta: Record<string, unknown>, | |
| 268 | search: string = '', | |
| 269 | ): RouteLocation { | |
| 270 | return { | |
| 271 | path: pathname, | |
| 272 | params, | |
| 273 | query: parseQuery(search), | |
| 274 | fullPath: search ? `${pathname}?${search}` : pathname, | |
| 275 | meta, | |
| 276 | } | |
| 277 | } | |
| 278 | ||
| 279 | export function createMiddlewareContext( | |
| 280 | to: RouteLocation, | |
| 281 | from: RouteLocation | null, | |
| 282 | request?: Request, | |
| 283 | ): MiddlewareContext { | |
| 284 | const isServer = typeof window === 'undefined' | |
| 285 | const isClient = !isServer | |
| 286 | const responseHeaders = new Headers() | |
| 287 | ||
| 288 | return { | |
| 289 | to, | |
| 290 | from, | |
| 291 | isClient, | |
| 292 | isServer, | |
| 293 | cookies: isServer && request | |
| 294 | ? createServerCookieManager(request, responseHeaders) | |
| 295 | : createClientCookieManager(), | |
| 296 | storage: createStorageManager(), | |
| 297 | state: {}, | |
| 298 | request, | |
| 299 | responseHeaders, | |
| 300 | } | |
| 301 | } | |
| 302 | ||
| 303 | export async function runMiddleware( | |
| 304 | middlewareNames: string | string[], | |
| 305 | context: MiddlewareContext, | |
| 306 | ): Promise<MiddlewareResult> { | |
| 307 | const names = Array.isArray(middlewareNames) ? middlewareNames : [middlewareNames] | |
| 308 | const isServer = context.isServer | |
| 309 | ||
| 310 | for (const name of names) { | |
| 311 | const middleware = getMiddleware(name) | |
| 312 | ||
| 313 | if (!middleware) { | |
| 314 | console.warn(`Middleware '${name}' not found`) | |
| 315 | continue | |
| 316 | } | |
| 317 | ||
| 318 | if (middleware.mode === 'server' && !isServer) continue | |
| 319 | if (middleware.mode === 'client' && isServer) continue | |
| 320 | ||
| 321 | try { | |
| 322 | const result = await middleware.handler(context) | |
| 323 | ||
| 324 | if (result) { | |
| 325 | if (result.type === 'redirect') { | |
| 326 | return { | |
| 327 | passed: false, | |
| 328 | redirect: result, | |
| 329 | state: context.state, | |
| 330 | responseHeaders: context.responseHeaders, | |
| 331 | } | |
| 332 | } | |
| 333 | ||
| 334 | if (result.type === 'abort') { | |
| 335 | return { | |
| 336 | passed: false, | |
| 337 | abort: result, | |
| 338 | state: context.state, | |
| 339 | responseHeaders: context.responseHeaders, | |
| 340 | } | |
| 341 | } | |
| 342 | } | |
| 343 | } | |
| 344 | catch (error) { | |
| 345 | console.error(`Error in middleware '${name}':`, error) | |
| 346 | return { | |
| 347 | passed: false, | |
| 348 | abort: { | |
| 349 | type: 'abort', | |
| 350 | error: { | |
| 351 | statusCode: 500, | |
| 352 | message: error instanceof Error ? error.message : 'Middleware error', | |
| 353 | }, | |
| 354 | }, | |
| 355 | state: context.state, | |
| 356 | responseHeaders: context.responseHeaders, | |
| 357 | } | |
| 358 | } | |
| 359 | } | |
| 360 | ||
| 361 | return { | |
| 362 | passed: true, | |
| 363 | state: context.state, | |
| 364 | responseHeaders: context.responseHeaders, | |
| 365 | } | |
| 366 | } | |
| @@ -0,0 +1,73 @@ | ||
| 1 | interface RouteDefinition { | |
| 2 | path: string | |
| 3 | name?: string | |
| 4 | params?: Record<string, any> | |
| 5 | } | |
| 6 | ||
| 7 | const routes: Record<string, RouteDefinition> = {} | |
| 8 | let appUrl: string = '' | |
| 9 | ||
| 10 | export function setAppUrl(url: string): void { | |
| 11 | appUrl = url.replace(/\/$/, '') | |
| 12 | } | |
| 13 | ||
| 14 | export function defineRoute(name: string, routePath: string, params: Record<string, any> = {}): void { | |
| 15 | routes[name] = { path: routePath, name, params } | |
| 16 | } | |
| 17 | ||
| 18 | export function defineRoutes(routeDefinitions: Record<string, string | RouteDefinition>): void { | |
| 19 | for (const [name, definition] of Object.entries(routeDefinitions)) { | |
| 20 | if (typeof definition === 'string') { | |
| 21 | routes[name] = { path: definition, name } | |
| 22 | } | |
| 23 | else { | |
| 24 | routes[name] = { ...definition, name } | |
| 25 | } | |
| 26 | } | |
| 27 | } | |
| 28 | ||
| 29 | export function route( | |
| 30 | name: string, | |
| 31 | params: Record<string, any> = {}, | |
| 32 | absolute = false, | |
| 33 | ): string { | |
| 34 | const r = routes[name] | |
| 35 | ||
| 36 | if (!r) { | |
| 37 | console.warn(`Route [${name}] not defined.`) | |
| 38 | return '#undefined-route' | |
| 39 | } | |
| 40 | ||
| 41 | const mergedParams = { ...r.params, ...params } | |
| 42 | let routePath = r.path | |
| 43 | ||
| 44 | routePath = routePath.replace(/:(\w+)/g, (_, paramName) => { | |
| 45 | const value = mergedParams[paramName] | |
| 46 | ||
| 47 | if (value === undefined) { | |
| 48 | console.warn(`Missing parameter [${paramName}] for route [${name}].`) | |
| 49 | return `:${paramName}` | |
| 50 | } | |
| 51 | ||
| 52 | delete mergedParams[paramName] | |
| 53 | return encodeURIComponent(String(value)) | |
| 54 | }) | |
| 55 | ||
| 56 | const remainingParams = Object.entries(mergedParams) | |
| 57 | if (remainingParams.length > 0) { | |
| 58 | const queryString = remainingParams | |
| 59 | .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`) | |
| 60 | .join('&') | |
| 61 | ||
| 62 | routePath += (routePath.includes('?') ? '&' : '?') + queryString | |
| 63 | } | |
| 64 | ||
| 65 | return absolute && appUrl ? `${appUrl}${routePath}` : routePath | |
| 66 | } | |
| 67 | ||
| 68 | export function resetRoutes(): void { | |
| 69 | for (const key of Object.keys(routes)) { | |
| 70 | delete routes[key] | |
| 71 | } | |
| 72 | appUrl = '' | |
| 73 | } | |
| @@ -0,0 +1,20 @@ | ||
| 1 | import fs from 'node:fs' | |
| 2 | import path from 'node:path' | |
| 3 | ||
| 4 | export function resolveLayoutChain(routeFilePath: string, pagesDir: string): string[] { | |
| 5 | const layouts: string[] = [] | |
| 6 | let dir = path.dirname(routeFilePath) | |
| 7 | ||
| 8 | // Walk up from the route's directory to the pages root, collecting _layout.stx files | |
| 9 | while (dir.startsWith(pagesDir) || dir === pagesDir) { | |
| 10 | const layoutFile = path.join(dir, '_layout.stx') | |
| 11 | if (fs.existsSync(layoutFile)) { | |
| 12 | layouts.unshift(layoutFile) // Prepend so root layout is first | |
| 13 | } | |
| 14 | ||
| 15 | if (dir === pagesDir) break | |
| 16 | dir = path.dirname(dir) | |
| 17 | } | |
| 18 | ||
| 19 | return layouts | |
| 20 | } | |
| @@ -0,0 +1,120 @@ | ||
| 1 | export interface Route { | |
| 2 | pattern: string | |
| 3 | regex: RegExp | |
| 4 | params: string[] | |
| 5 | filePath: string | |
| 6 | isDynamic: boolean | |
| 7 | layout?: string | |
| 8 | middleware?: string[] | |
| 9 | meta?: Record<string, unknown> | |
| 10 | } | |
| 11 | ||
| 12 | export interface RouteMatch { | |
| 13 | route: Route | |
| 14 | params: Record<string, string> | |
| 15 | } | |
| 16 | ||
| 17 | export interface RouterConfig { | |
| 18 | pagesDir?: string | |
| 19 | extensions?: string[] | |
| 20 | layouts?: boolean | |
| 21 | middleware?: boolean | |
| 22 | trailingSlash?: boolean | |
| 23 | caseSensitive?: boolean | |
| 24 | } | |
| 25 | ||
| 26 | export interface RouteLocation { | |
| 27 | path: string | |
| 28 | params: Record<string, string> | |
| 29 | query: Record<string, string> | |
| 30 | fullPath: string | |
| 31 | meta: Record<string, unknown> | |
| 32 | } | |
| 33 | ||
| 34 | export interface CookieOptions { | |
| 35 | expires?: Date | number | |
| 36 | path?: string | |
| 37 | domain?: string | |
| 38 | secure?: boolean | |
| 39 | httpOnly?: boolean | |
| 40 | sameSite?: 'strict' | 'lax' | 'none' | |
| 41 | } | |
| 42 | ||
| 43 | export interface CookieManager { | |
| 44 | get(name: string): string | undefined | |
| 45 | set(name: string, value: string, options?: CookieOptions): void | |
| 46 | delete(name: string): void | |
| 47 | getAll(): Record<string, string> | |
| 48 | } | |
| 49 | ||
| 50 | export interface StorageManager { | |
| 51 | get(key: string): string | null | |
| 52 | set(key: string, value: string): void | |
| 53 | remove(key: string): void | |
| 54 | isAvailable(): boolean | |
| 55 | } | |
| 56 | ||
| 57 | export interface MiddlewareContext { | |
| 58 | to: RouteLocation | |
| 59 | from: RouteLocation | null | |
| 60 | isClient: boolean | |
| 61 | isServer: boolean | |
| 62 | cookies: CookieManager | |
| 63 | storage: StorageManager | |
| 64 | state: Record<string, unknown> | |
| 65 | request?: Request | |
| 66 | responseHeaders: Headers | |
| 67 | } | |
| 68 | ||
| 69 | export type MiddlewareMode = 'universal' | 'server' | 'client' | |
| 70 | ||
| 71 | export type RouteMiddlewareHandler = ( | |
| 72 | context: MiddlewareContext, | |
| 73 | ) => void | Promise<void> | NavigationResult | Promise<NavigationResult | void> | |
| 74 | ||
| 75 | export interface MiddlewareOptions { | |
| 76 | mode?: MiddlewareMode | |
| 77 | } | |
| 78 | ||
| 79 | export interface RouteMiddlewareDefinition { | |
| 80 | handler: RouteMiddlewareHandler | |
| 81 | mode: MiddlewareMode | |
| 82 | } | |
| 83 | ||
| 84 | export interface NavigateToOptions { | |
| 85 | replace?: boolean | |
| 86 | external?: boolean | |
| 87 | redirectCode?: 301 | 302 | 303 | 307 | 308 | |
| 88 | } | |
| 89 | ||
| 90 | export interface NavigateToResult { | |
| 91 | type: 'redirect' | |
| 92 | path: string | |
| 93 | options: NavigateToOptions | |
| 94 | } | |
| 95 | ||
| 96 | export interface NavigationError { | |
| 97 | statusCode: number | |
| 98 | message: string | |
| 99 | } | |
| 100 | ||
| 101 | export interface AbortNavigationResult { | |
| 102 | type: 'abort' | |
| 103 | error: NavigationError | |
| 104 | } | |
| 105 | ||
| 106 | export type NavigationResult = NavigateToResult | AbortNavigationResult | |
| 107 | ||
| 108 | export interface MiddlewareResult { | |
| 109 | passed: boolean | |
| 110 | redirect?: NavigateToResult | |
| 111 | abort?: AbortNavigationResult | |
| 112 | state: Record<string, unknown> | |
| 113 | responseHeaders: Headers | |
| 114 | } | |
| 115 | ||
| 116 | interface RouteDefinition { | |
| 117 | path: string | |
| 118 | name?: string | |
| 119 | params?: Record<string, any> | |
| 120 | } | |
| @@ -0,0 +1,110 @@ | ||
| 1 | import { describe, expect, test } from 'bun:test' | |
| 2 | import { filePathToPattern, matchRoute, patternToRegex } from '../src/matcher' | |
| 3 | ||
| 4 | describe('filePathToPattern', () => { | |
| 5 | const pagesDir = '/app/pages' | |
| 6 | ||
| 7 | test('converts index.stx to /', () => { | |
| 8 | expect(filePathToPattern('/app/pages/index.stx', pagesDir)).toBe('/') | |
| 9 | }) | |
| 10 | ||
| 11 | test('converts about.stx to /about', () => { | |
| 12 | expect(filePathToPattern('/app/pages/about.stx', pagesDir)).toBe('/about') | |
| 13 | }) | |
| 14 | ||
| 15 | test('converts nested index to directory path', () => { | |
| 16 | expect(filePathToPattern('/app/pages/chat/index.stx', pagesDir)).toBe('/chat') | |
| 17 | }) | |
| 18 | ||
| 19 | test('converts [id].stx to :id dynamic param', () => { | |
| 20 | expect(filePathToPattern('/app/pages/posts/[id].stx', pagesDir)).toBe('/posts/:id') | |
| 21 | }) | |
| 22 | ||
| 23 | test('converts [...slug].stx to :slug* catch-all', () => { | |
| 24 | expect(filePathToPattern('/app/pages/blog/[...slug].stx', pagesDir)).toBe('/blog/:slug*') | |
| 25 | }) | |
| 26 | ||
| 27 | test('converts [[param]].stx to :param? optional', () => { | |
| 28 | expect(filePathToPattern('/app/pages/users/[[id]].stx', pagesDir)).toBe('/users/:id?') | |
| 29 | }) | |
| 30 | ||
| 31 | test('handles .md extension', () => { | |
| 32 | expect(filePathToPattern('/app/pages/readme.md', pagesDir)).toBe('/readme') | |
| 33 | }) | |
| 34 | }) | |
| 35 | ||
| 36 | describe('patternToRegex', () => { | |
| 37 | test('matches static routes', () => { | |
| 38 | const { regex } = patternToRegex('/about') | |
| 39 | expect('/about'.match(regex)).toBeTruthy() | |
| 40 | expect('/other'.match(regex)).toBeNull() | |
| 41 | }) | |
| 42 | ||
| 43 | test('extracts dynamic params', () => { | |
| 44 | const { regex, params } = patternToRegex('/posts/:id') | |
| 45 | expect(params).toEqual(['id']) | |
| 46 | const match = '/posts/123'.match(regex) | |
| 47 | expect(match).toBeTruthy() | |
| 48 | expect(match![1]).toBe('123') | |
| 49 | }) | |
| 50 | ||
| 51 | test('handles optional params', () => { | |
| 52 | const { regex, params } = patternToRegex('/users/:id?') | |
| 53 | expect(params).toEqual(['id']) | |
| 54 | expect('/users'.match(regex)).toBeTruthy() | |
| 55 | expect('/users/42'.match(regex)).toBeTruthy() | |
| 56 | }) | |
| 57 | ||
| 58 | test('handles catch-all params', () => { | |
| 59 | const { regex, params } = patternToRegex('/blog/:slug*') | |
| 60 | expect(params).toEqual(['slug']) | |
| 61 | const match = '/blog/2024/hello-world'.match(regex) | |
| 62 | expect(match).toBeTruthy() | |
| 63 | expect(match![1]).toBe('2024/hello-world') | |
| 64 | }) | |
| 65 | ||
| 66 | test('matches root path', () => { | |
| 67 | const { regex } = patternToRegex('/') | |
| 68 | expect('/'.match(regex)).toBeTruthy() | |
| 69 | expect('/other'.match(regex)).toBeNull() | |
| 70 | }) | |
| 71 | }) | |
| 72 | ||
| 73 | describe('matchRoute', () => { | |
| 74 | const routes = [ | |
| 75 | { pattern: '/', ...patternToRegex('/') }, | |
| 76 | { pattern: '/about', ...patternToRegex('/about') }, | |
| 77 | { pattern: '/posts/:id', ...patternToRegex('/posts/:id') }, | |
| 78 | { pattern: '/blog/:slug*', ...patternToRegex('/blog/:slug*') }, | |
| 79 | ] | |
| 80 | ||
| 81 | test('matches static route', () => { | |
| 82 | const result = matchRoute('/about', routes) | |
| 83 | expect(result).not.toBeNull() | |
| 84 | expect(result!.route.pattern).toBe('/about') | |
| 85 | expect(result!.params).toEqual({}) | |
| 86 | }) | |
| 87 | ||
| 88 | test('matches dynamic route with params', () => { | |
| 89 | const result = matchRoute('/posts/42', routes) | |
| 90 | expect(result).not.toBeNull() | |
| 91 | expect(result!.params.id).toBe('42') | |
| 92 | }) | |
| 93 | ||
| 94 | test('matches catch-all route', () => { | |
| 95 | const result = matchRoute('/blog/2024/jan/hello', routes) | |
| 96 | expect(result).not.toBeNull() | |
| 97 | expect(result!.params.slug).toBe('2024/jan/hello') | |
| 98 | }) | |
| 99 | ||
| 100 | test('returns null for unmatched paths', () => { | |
| 101 | const result = matchRoute('/nonexistent', routes) | |
| 102 | expect(result).toBeNull() | |
| 103 | }) | |
| 104 | ||
| 105 | test('handles empty pathname as /', () => { | |
| 106 | const result = matchRoute('', routes) | |
| 107 | expect(result).not.toBeNull() | |
| 108 | expect(result!.route.pattern).toBe('/') | |
| 109 | }) | |
| 110 | }) | |
| @@ -0,0 +1,131 @@ | ||
| 1 | import { afterEach, describe, expect, test } from 'bun:test' | |
| 2 | import { | |
| 3 | abortNavigation, | |
| 4 | clearMiddleware, | |
| 5 | createMiddlewareContext, | |
| 6 | createRouteLocation, | |
| 7 | defineMiddleware, | |
| 8 | getMiddleware, | |
| 9 | hasMiddleware, | |
| 10 | navigateTo, | |
| 11 | registerMiddleware, | |
| 12 | runMiddleware, | |
| 13 | } from '../src/middleware' | |
| 14 | ||
| 15 | describe('navigateTo', () => { | |
| 16 | test('creates redirect result', () => { | |
| 17 | const result = navigateTo('/login') | |
| 18 | expect(result.type).toBe('redirect') | |
| 19 | expect(result.path).toBe('/login') | |
| 20 | expect(result.options.redirectCode).toBe(302) | |
| 21 | }) | |
| 22 | ||
| 23 | test('supports custom redirect code', () => { | |
| 24 | const result = navigateTo('/new-url', { redirectCode: 301, replace: true }) | |
| 25 | expect(result.options.redirectCode).toBe(301) | |
| 26 | expect(result.options.replace).toBe(true) | |
| 27 | }) | |
| 28 | }) | |
| 29 | ||
| 30 | describe('abortNavigation', () => { | |
| 31 | test('creates abort result from string', () => { | |
| 32 | const result = abortNavigation('Not allowed') | |
| 33 | expect(result.type).toBe('abort') | |
| 34 | expect(result.error.statusCode).toBe(500) | |
| 35 | expect(result.error.message).toBe('Not allowed') | |
| 36 | }) | |
| 37 | ||
| 38 | test('creates abort result from error object', () => { | |
| 39 | const result = abortNavigation({ statusCode: 403, message: 'Forbidden' }) | |
| 40 | expect(result.error.statusCode).toBe(403) | |
| 41 | }) | |
| 42 | }) | |
| 43 | ||
| 44 | describe('defineMiddleware', () => { | |
| 45 | test('creates middleware with default mode', () => { | |
| 46 | const mw = defineMiddleware(() => {}) | |
| 47 | expect(mw.mode).toBe('universal') | |
| 48 | expect(typeof mw.handler).toBe('function') | |
| 49 | }) | |
| 50 | ||
| 51 | test('creates middleware with specified mode', () => { | |
| 52 | const mw = defineMiddleware(() => {}, { mode: 'server' }) | |
| 53 | expect(mw.mode).toBe('server') | |
| 54 | }) | |
| 55 | }) | |
| 56 | ||
| 57 | describe('middleware registry', () => { | |
| 58 | afterEach(() => { | |
| 59 | clearMiddleware() | |
| 60 | }) | |
| 61 | ||
| 62 | test('registers and retrieves middleware', () => { | |
| 63 | const mw = defineMiddleware(() => {}) | |
| 64 | registerMiddleware('auth', mw) | |
| 65 | expect(hasMiddleware('auth')).toBe(true) | |
| 66 | expect(getMiddleware('auth')).toBe(mw) | |
| 67 | }) | |
| 68 | ||
| 69 | test('returns undefined for unknown middleware', () => { | |
| 70 | expect(getMiddleware('unknown')).toBeUndefined() | |
| 71 | expect(hasMiddleware('unknown')).toBe(false) | |
| 72 | }) | |
| 73 | }) | |
| 74 | ||
| 75 | describe('runMiddleware', () => { | |
| 76 | afterEach(() => { | |
| 77 | clearMiddleware() | |
| 78 | }) | |
| 79 | ||
| 80 | test('runs middleware chain and passes', async () => { | |
| 81 | registerMiddleware('test', defineMiddleware(() => {})) | |
| 82 | const to = createRouteLocation('/dashboard', {}, {}) | |
| 83 | const ctx = createMiddlewareContext(to, null) | |
| 84 | const result = await runMiddleware('test', ctx) | |
| 85 | expect(result.passed).toBe(true) | |
| 86 | }) | |
| 87 | ||
| 88 | test('stops on redirect', async () => { | |
| 89 | registerMiddleware('auth', defineMiddleware(() => navigateTo('/login'))) | |
| 90 | registerMiddleware('log', defineMiddleware(() => {})) | |
| 91 | ||
| 92 | const to = createRouteLocation('/dashboard', {}, {}) | |
| 93 | const ctx = createMiddlewareContext(to, null) | |
| 94 | const result = await runMiddleware(['auth', 'log'], ctx) | |
| 95 | ||
| 96 | expect(result.passed).toBe(false) | |
| 97 | expect(result.redirect?.path).toBe('/login') | |
| 98 | }) | |
| 99 | ||
| 100 | test('stops on abort', async () => { | |
| 101 | registerMiddleware('guard', defineMiddleware(() => | |
| 102 | abortNavigation({ statusCode: 403, message: 'Forbidden' }), | |
| 103 | )) | |
| 104 | ||
| 105 | const to = createRouteLocation('/admin', {}, {}) | |
| 106 | const ctx = createMiddlewareContext(to, null) | |
| 107 | const result = await runMiddleware('guard', ctx) | |
| 108 | ||
| 109 | expect(result.passed).toBe(false) | |
| 110 | expect(result.abort?.error.statusCode).toBe(403) | |
| 111 | }) | |
| 112 | ||
| 113 | test('accumulates state across middleware', async () => { | |
| 114 | registerMiddleware('a', defineMiddleware((ctx) => { ctx.state.a = 1 })) | |
| 115 | registerMiddleware('b', defineMiddleware((ctx) => { ctx.state.b = 2 })) | |
| 116 | ||
| 117 | const to = createRouteLocation('/', {}, {}) | |
| 118 | const ctx = createMiddlewareContext(to, null) | |
| 119 | const result = await runMiddleware(['a', 'b'], ctx) | |
| 120 | ||
| 121 | expect(result.passed).toBe(true) | |
| 122 | expect(result.state).toEqual({ a: 1, b: 2 }) | |
| 123 | }) | |
| 124 | ||
| 125 | test('skips unknown middleware with warning', async () => { | |
| 126 | const to = createRouteLocation('/', {}, {}) | |
| 127 | const ctx = createMiddlewareContext(to, null) | |
| 128 | const result = await runMiddleware('nonexistent', ctx) | |
| 129 | expect(result.passed).toBe(true) | |
| 130 | }) | |
| 131 | }) | |
| @@ -0,0 +1,50 @@ | ||
| 1 | import { afterEach, describe, expect, test } from 'bun:test' | |
| 2 | import { defineRoute, defineRoutes, resetRoutes, route, setAppUrl } from '../src/named-routes' | |
| 3 | ||
| 4 | describe('named routes', () => { | |
| 5 | afterEach(() => { | |
| 6 | resetRoutes() | |
| 7 | }) | |
| 8 | ||
| 9 | test('defines and resolves a named route', () => { | |
| 10 | defineRoute('home', '/') | |
| 11 | expect(route('home')).toBe('/') | |
| 12 | }) | |
| 13 | ||
| 14 | test('substitutes parameters', () => { | |
| 15 | defineRoute('post', '/posts/:id') | |
| 16 | expect(route('post', { id: '42' })).toBe('/posts/42') | |
| 17 | }) | |
| 18 | ||
| 19 | test('adds extra params as query string', () => { | |
| 20 | defineRoute('search', '/search') | |
| 21 | expect(route('search', { q: 'hello' })).toBe('/search?q=hello') | |
| 22 | }) | |
| 23 | ||
| 24 | test('returns #undefined-route for unknown names', () => { | |
| 25 | expect(route('unknown')).toBe('#undefined-route') | |
| 26 | }) | |
| 27 | ||
| 28 | test('supports absolute URLs', () => { | |
| 29 | setAppUrl('https://example.com') | |
| 30 | defineRoute('about', '/about') | |
| 31 | expect(route('about', {}, true)).toBe('https://example.com/about') | |
| 32 | }) | |
| 33 | ||
| 34 | test('defineRoutes registers multiple routes', () => { | |
| 35 | defineRoutes({ | |
| 36 | home: '/', | |
| 37 | about: '/about', | |
| 38 | contact: { path: '/contact', params: { ref: 'nav' } }, | |
| 39 | }) | |
| 40 | ||
| 41 | expect(route('home')).toBe('/') | |
| 42 | expect(route('about')).toBe('/about') | |
| 43 | expect(route('contact')).toBe('/contact?ref=nav') | |
| 44 | }) | |
| 45 | ||
| 46 | test('encodes parameter values', () => { | |
| 47 | defineRoute('user', '/users/:name') | |
| 48 | expect(route('user', { name: 'hello world' })).toBe('/users/hello%20world') | |
| 49 | }) | |
| 50 | }) | |
| @@ -7,110 +7,5 @@ | ||
| 7 | 7 | export * from './router' |
| 8 | 8 | export * from './directive' |
| 9 | 9 | |
| 10 | /** | |
| 11 | * Generate the browser router script as a string | |
| 12 | * This can be injected into pages or served as a static file | |
| 13 | */ | |
| 14 | export function getRouterScript(): string { | |
| 15 | return ` | |
| 16 | ;(function(){ | |
| 17 | 'use strict'; | |
| 18 | var d={container:'main',linkSelector:'a[href]',loadingClass:'stx-navigating',viewTransitions:!0,cache:!0,scrollToTop:!0,prefetch:!0}; | |
| 19 | var o=Object.assign({},d,window.STX_ROUTER_OPTIONS||{}); | |
| 20 | var c=new Map(),p=new Set(),n=!1,u=location.href; | |
| 21 | function init(){ | |
| 22 | document.addEventListener('click',handleClick,!0); | |
| 23 | window.addEventListener('popstate',function(){navigate(location.href,!1)}); | |
| 24 | document.addEventListener('mouseover',handleHover,!0); | |
| 25 | fetchPage(u).catch(function(){}); | |
| 26 | injectStyles(); | |
| 27 | updateActiveLinks(); | |
| 28 | } | |
| 29 | function navigate(url,push){ | |
| 30 | if(n)return; | |
| 31 | var t=new URL(url,location.origin); | |
| 32 | if(t.href===u&&!t.hash)return; | |
| 33 | if(t.origin!==location.origin){location.href=url;return} | |
| 34 | if(t.pathname===new URL(u).pathname&&t.hash){ | |
| 35 | if(push!==!1)history.pushState({url:t.href},'',t.href); | |
| 36 | var el=document.querySelector(t.hash);if(el)el.scrollIntoView({behavior:'smooth'});return | |
| 37 | } | |
| 38 | n=!0;document.body.classList.add(o.loadingClass); | |
| 39 | fetchPage(t.href).then(function(r){ | |
| 40 | if(r){ | |
| 41 | swapContent(r); | |
| 42 | if(push!==!1)history.pushState({url:t.href},'',t.href); | |
| 43 | u=t.href; | |
| 44 | if(o.scrollToTop&&!t.hash)window.scrollTo({top:0,behavior:'instant'}); | |
| 45 | else if(t.hash){var el=document.querySelector(t.hash);if(el)el.scrollIntoView({behavior:'smooth'})} | |
| 46 | } | |
| 47 | }).catch(function(){location.href=url}).finally(function(){n=!1;document.body.classList.remove(o.loadingClass)}); | |
| 48 | } | |
| 49 | function handleClick(e){ | |
| 50 | if(!e.target||!e.target.closest)return; | |
| 51 | var a=e.target.closest('a[href]');if(!a)return; | |
| 52 | if(e.metaKey||e.ctrlKey||e.shiftKey||e.altKey||e.button!==0)return; | |
| 53 | if(a.target&&a.target!=='_self')return;if(a.hasAttribute('download'))return; | |
| 54 | var h=a.getAttribute('href'); | |
| 55 | if(!h||h.startsWith('mailto:')||h.startsWith('tel:')||h.startsWith('javascript:'))return; | |
| 56 | if(a.hasAttribute('data-stx-no-router'))return; | |
| 57 | var t=new URL(h,location.origin);if(t.origin!==location.origin)return; | |
| 58 | e.preventDefault();e.stopPropagation();navigate(h); | |
| 59 | } | |
| 60 | function handleHover(e){ | |
| 61 | if(!e.target||!e.target.closest)return; | |
| 62 | var a=e.target.closest('a[data-stx-prefetch]');if(!a)return; | |
| 63 | var h=a.getAttribute('href');if(h&&!c.has(h))fetchPage(new URL(h,location.origin).href).catch(function(){}); | |
| 64 | } | |
| 65 | function fetchPage(url){ | |
| 66 | if(c.has(url)){var x=c.get(url);if(Date.now()-x.t<3e5)return Promise.resolve({h:x.h,i:x.i})} | |
| 67 | return fetch(url,{headers:{'X-STX-Router':'true'}}).then(function(r){ | |
| 68 | if(!r.ok)throw new Error(r.status);return r.text() | |
| 69 | }).then(function(html){ | |
| 70 | var d=new DOMParser().parseFromString(html,'text/html'),m=d.querySelector(o.container); | |
| 71 | if(!m)throw new Error('Container not found'); | |
| 72 | var r={h:m.innerHTML,i:d.title};c.set(url,{h:r.h,i:r.i,t:Date.now()});return r | |
| 73 | }); | |
| 74 | } | |
| 75 | function swapContent(r){ | |
| 76 | var m=document.querySelector(o.container);if(!m)return; | |
| 77 | var swap=function(){ | |
| 78 | if(window.stx&&window.stx._cleanupContainer)window.stx._cleanupContainer(m);m.innerHTML=r.h;document.title=r.i; | |
| 79 | m.querySelectorAll('script').forEach(function(s){ | |
| 80 | var n=document.createElement('script'); | |
| 81 | Array.from(s.attributes).forEach(function(a){n.setAttribute(a.name,a.value)}); | |
| 82 | n.textContent=s.textContent;s.parentNode.replaceChild(n,s); | |
| 83 | }); | |
| 84 | updateActiveLinks(); | |
| 85 | window.dispatchEvent(new CustomEvent('stx:navigate',{detail:{url:u}})); | |
| 86 | window.dispatchEvent(new CustomEvent('stx:load')); | |
| 87 | }; | |
| 88 | if(o.viewTransitions&&'startViewTransition'in document)document.startViewTransition(swap);else swap(); | |
| 89 | } | |
| 90 | function cacheCurrentPage(){var m=document.querySelector(o.container);if(m)c.set(u,{h:m.innerHTML,i:document.title,t:Date.now()})} | |
| 91 | function updateActiveLinks(){ | |
| 92 | var links=document.querySelectorAll('[data-stx-link]'); | |
| 93 | var cur=location.pathname; | |
| 94 | links.forEach(function(link){ | |
| 95 | var href=link.getAttribute('href')||''; | |
| 96 | var ac=link.getAttribute('data-stx-active-class')||'active'; | |
| 97 | var eac=link.getAttribute('data-stx-exact-active-class')||'exact-active'; | |
| 98 | ac.split(' ').forEach(function(cls){if(cls)link.classList.remove(cls)}); | |
| 99 | eac.split(' ').forEach(function(cls){if(cls)link.classList.remove(cls)}); | |
| 100 | var isExact=cur===href; | |
| 101 | var isActive=href!=='/'?cur.startsWith(href):cur==='/'; | |
| 102 | if(isExact)eac.split(' ').forEach(function(cls){if(cls)link.classList.add(cls)}); | |
| 103 | if(isActive)ac.split(' ').forEach(function(cls){if(cls)link.classList.add(cls)}); | |
| 104 | }); | |
| 105 | } | |
| 106 | function injectStyles(){ | |
| 107 | if(document.getElementById('stx-r-css'))return; | |
| 108 | var s=document.createElement('style');s.id='stx-r-css'; | |
| 109 | s.textContent='.stx-navigating{cursor:wait}.stx-navigating *{pointer-events:none}@keyframes stx-l{0%{transform:translateX(-100%)}100%{transform:translateX(100%)}}.stx-navigating::before{content:\\'\\';position:fixed;top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,transparent,#78dce8,transparent);animation:stx-l 1s ease-in-out infinite;z-index:99999}'; | |
| 110 | document.head.appendChild(s); | |
| 111 | } | |
| 112 | window.stxRouter={navigate:navigate,clearCache:function(){c.clear()}}; | |
| 113 | if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init(); | |
| 114 | })(); | |
| 115 | ` | |
| 116 | } | |
| 10 | // Re-export getRouterScript from @stacksjs/router | |
| 11 | export { getRouterScript } from '@stacksjs/router' | |
| @@ -1,17 +1,3 @@ | ||
| 1 | import process from 'node:process' | |
| 2 | ||
| 3 | /** | |
| 4 | * Returns true when running in production mode | |
| 5 | */ | |
| 6 | export function isProduction(): boolean { | |
| 7 | return process.env.NODE_ENV === 'production' | |
| 8 | } | |
| 9 | ||
| 10 | /** | |
| 11 | * Returns true when NOT in production mode, or when debug is enabled. | |
| 12 | * This covers development, test, and unset environments. | |
| 13 | */ | |
| 14 | export function isDevelopment(): boolean { | |
| 15 | return !isProduction() | |
| 16 | || process.env.STX_DEBUG === 'true' | |
| 17 | } | |
| 1 | // Re-export from @stacksjs/config | |
| 2 | export { defineEnv, isDevelopment, isProduction, isTest, loadEnvFile, validateEnv } from '@stacksjs/config' | |
| 3 | export type { EnvAccessor, EnvType, EnvValidationError, EnvVarDef, TypedEnv } from '@stacksjs/config' | |
| @@ -3,7 +3,7 @@ import type { StxOptions } from './' | ||
| 3 | 3 | import path from 'node:path' |
| 4 | 4 | import { buildWebComponents, cacheTemplate, checkCache, extractVariables, loadStxConfig, processDirectives } from './' |
| 5 | 5 | import { devHelpers, errorLogger, safeExecuteAsync, StxFileError, StxRuntimeError } from './error-handling' |
| 6 | import { isDevelopment } from './env' | |
| 6 | import { isDevelopment } from '@stacksjs/config' | |
| 7 | 7 | import { escapeHtml } from './expressions' |
| 8 | 8 | import { performanceMonitor } from './performance-utils' |
| 9 | 9 | |
| @@ -1,703 +1,36 @@ | ||
| 1 | /** | |
| 2 | * Route Middleware System | |
| 3 | * | |
| 4 | * Nuxt-style route middleware for stx with proper async support. | |
| 5 | * | |
| 6 | * ## Usage | |
| 7 | * | |
| 8 | * ```typescript | |
| 9 | * // middleware/auth.ts | |
| 10 | * export default defineMiddleware(async (context) => { | |
| 11 | * const { to, from, cookies, isClient, isServer } = context | |
| 12 | * | |
| 13 | * const token = cookies.get('auth_token') | |
| 14 | * if (!token) { | |
| 15 | * return navigateTo('/login') | |
| 16 | * } | |
| 17 | * }) | |
| 18 | * ``` | |
| 19 | * | |
| 20 | * ```html | |
| 21 | * <!-- pages/dashboard.stx --> | |
| 22 | * <script> | |
| 23 | * definePageMeta({ | |
| 24 | * middleware: 'auth' | |
| 25 | * }) | |
| 26 | * </script> | |
| 27 | * ``` | |
| 28 | * | |
| 29 | * @module route-middleware | |
| 30 | */ | |
| 31 | ||
| 32 | import fs from 'node:fs' | |
| 33 | import path from 'node:path' | |
| 34 | import type { PageMeta } from './head' | |
| 35 | ||
| 36 | // ============================================================================= | |
| 37 | // Types | |
| 38 | // ============================================================================= | |
| 39 | ||
| 40 | /** | |
| 41 | * Route location information | |
| 42 | */ | |
| 43 | export interface RouteLocation { | |
| 44 | /** URL path (e.g., /dashboard) */ | |
| 45 | path: string | |
| 46 | /** Route parameters from dynamic segments */ | |
| 47 | params: Record<string, string> | |
| 48 | /** Query string parameters */ | |
| 49 | query: Record<string, string> | |
| 50 | /** Full URL path including query string */ | |
| 51 | fullPath: string | |
| 52 | /** Page metadata from definePageMeta() */ | |
| 53 | meta: PageMeta | |
| 54 | } | |
| 55 | ||
| 56 | /** | |
| 57 | * Cookie manager for reading/writing cookies | |
| 58 | */ | |
| 59 | export interface CookieManager { | |
| 60 | /** Get a cookie value by name */ | |
| 61 | get(name: string): string | undefined | |
| 62 | /** Set a cookie */ | |
| 63 | set(name: string, value: string, options?: CookieOptions): void | |
| 64 | /** Delete a cookie */ | |
| 65 | delete(name: string): void | |
| 66 | /** Get all cookies as an object */ | |
| 67 | getAll(): Record<string, string> | |
| 68 | } | |
| 69 | ||
| 70 | /** | |
| 71 | * Cookie options for setting cookies | |
| 72 | */ | |
| 73 | export interface CookieOptions { | |
| 74 | /** Expiration date or max age in seconds */ | |
| 75 | expires?: Date | number | |
| 76 | /** Path for the cookie */ | |
| 77 | path?: string | |
| 78 | /** Domain for the cookie */ | |
| 79 | domain?: string | |
| 80 | /** Secure flag (HTTPS only) */ | |
| 81 | secure?: boolean | |
| 82 | /** HttpOnly flag */ | |
| 83 | httpOnly?: boolean | |
| 84 | /** SameSite policy */ | |
| 85 | sameSite?: 'strict' | 'lax' | 'none' | |
| 86 | } | |
| 87 | ||
| 88 | /** | |
| 89 | * Safe localStorage wrapper (returns null on server) | |
| 90 | */ | |
| 91 | export interface StorageManager { | |
| 92 | /** Get a value from localStorage */ | |
| 93 | get(key: string): string | null | |
| 94 | /** Set a value in localStorage */ | |
| 95 | set(key: string, value: string): void | |
| 96 | /** Remove a value from localStorage */ | |
| 97 | remove(key: string): void | |
| 98 | /** Check if storage is available */ | |
| 99 | isAvailable(): boolean | |
| 100 | } | |
| 101 | ||
| 102 | /** | |
| 103 | * Middleware context passed to handlers | |
| 104 | */ | |
| 105 | export interface MiddlewareContext { | |
| 106 | /** Target route being navigated to */ | |
| 107 | to: RouteLocation | |
| 108 | /** Previous route (null on initial load) */ | |
| 109 | from: RouteLocation | null | |
| 110 | /** Whether running in browser */ | |
| 111 | isClient: boolean | |
| 112 | /** Whether running on server (SSR) */ | |
| 113 | isServer: boolean | |
| 114 | /** Cookie manager (works on both server and client) */ | |
| 115 | cookies: CookieManager | |
| 116 | /** localStorage wrapper (only works on client) */ | |
| 117 | storage: StorageManager | |
| 118 | /** State to pass to the page */ | |
| 119 | state: Record<string, unknown> | |
| 120 | /** Request object (server-side only) */ | |
| 121 | request?: Request | |
| 122 | /** Response headers to set (server-side only) */ | |
| 123 | responseHeaders: Headers | |
| 124 | } | |
| 125 | ||
| 126 | /** | |
| 127 | * Middleware execution mode | |
| 128 | */ | |
| 129 | export type MiddlewareMode = 'universal' | 'server' | 'client' | |
| 130 | ||
| 131 | /** | |
| 132 | * Middleware handler function | |
| 133 | */ | |
| 134 | export type RouteMiddlewareHandler = ( | |
| 135 | context: MiddlewareContext | |
| 136 | ) => void | Promise<void> | NavigationResult | Promise<NavigationResult | void> | |
| 137 | ||
| 138 | /** | |
| 139 | * Middleware definition options | |
| 140 | */ | |
| 141 | export interface MiddlewareOptions { | |
| 142 | /** When to run: 'universal' (both), 'server' (SSR only), 'client' (browser only) */ | |
| 143 | mode?: MiddlewareMode | |
| 144 | } | |
| 145 | ||
| 146 | /** | |
| 147 | * Registered middleware definition | |
| 148 | */ | |
| 149 | export interface RouteMiddlewareDefinition { | |
| 150 | /** Middleware handler */ | |
| 151 | handler: RouteMiddlewareHandler | |
| 152 | /** Execution mode */ | |
| 153 | mode: MiddlewareMode | |
| 154 | } | |
| 155 | ||
| 156 | /** | |
| 157 | * Navigation redirect result | |
| 158 | */ | |
| 159 | export interface NavigateToResult { | |
| 160 | type: 'redirect' | |
| 161 | path: string | |
| 162 | options: NavigateToOptions | |
| 163 | } | |
| 164 | ||
| 165 | /** | |
| 166 | * Navigation abort result | |
| 167 | */ | |
| 168 | export interface AbortNavigationResult { | |
| 169 | type: 'abort' | |
| 170 | error: NavigationError | |
| 171 | } | |
| 172 | ||
| 173 | /** | |
| 174 | * Result from middleware (redirect or abort) | |
| 175 | */ | |
| 176 | export type NavigationResult = NavigateToResult | AbortNavigationResult | |
| 177 | ||
| 178 | /** | |
| 179 | * Options for navigateTo() | |
| 180 | */ | |
| 181 | export interface NavigateToOptions { | |
| 182 | /** Replace current history entry instead of pushing */ | |
| 183 | replace?: boolean | |
| 184 | /** Whether this is an external URL */ | |
| 185 | external?: boolean | |
| 186 | /** HTTP redirect code for SSR (default: 302) */ | |
| 187 | redirectCode?: 301 | 302 | 303 | 307 | 308 | |
| 188 | } | |
| 189 | ||
| 190 | /** | |
| 191 | * Navigation error for abortNavigation() | |
| 192 | */ | |
| 193 | export interface NavigationError { | |
| 194 | /** HTTP status code */ | |
| 195 | statusCode: number | |
| 196 | /** Error message */ | |
| 197 | message: string | |
| 198 | } | |
| 199 | ||
| 200 | // ============================================================================= | |
| 201 | // Navigation Helpers | |
| 202 | // ============================================================================= | |
| 203 | ||
| 204 | /** | |
| 205 | * Redirect to a different route | |
| 206 | * | |
| 207 | * @param path - Path to redirect to | |
| 208 | * @param options - Redirect options | |
| 209 | * @returns Navigation result for the middleware system | |
| 210 | * | |
| 211 | * @example | |
| 212 | * ```typescript | |
| 213 | * export default defineMiddleware(async (ctx) => { | |
| 214 | * if (!ctx.cookies.get('token')) { | |
| 215 | * return navigateTo('/login') | |
| 216 | * } | |
| 217 | * }) | |
| 218 | * ``` | |
| 219 | */ | |
| 220 | export function navigateTo(path: string, options: NavigateToOptions = {}): NavigateToResult { | |
| 221 | return { | |
| 222 | type: 'redirect', | |
| 223 | path, | |
| 224 | options: { | |
| 225 | replace: options.replace ?? false, | |
| 226 | external: options.external ?? false, | |
| 227 | redirectCode: options.redirectCode ?? 302, | |
| 228 | }, | |
| 229 | } | |
| 230 | } | |
| 231 | ||
| 232 | /** | |
| 233 | * Abort navigation with an error | |
| 234 | * | |
| 235 | * @param error - Error message or error object | |
| 236 | * @returns Navigation result for the middleware system | |
| 237 | * | |
| 238 | * @example | |
| 239 | * ```typescript | |
| 240 | * export default defineMiddleware(async (ctx) => { | |
| 241 | * if (!hasPermission(ctx)) { | |
| 242 | * return abortNavigation({ statusCode: 403, message: 'Forbidden' }) | |
| 243 | * } | |
| 244 | * }) | |
| 245 | * ``` | |
| 246 | */ | |
| 247 | export function abortNavigation(error: string | NavigationError): AbortNavigationResult { | |
| 248 | const normalizedError: NavigationError = | |
| 249 | typeof error === 'string' ? { statusCode: 500, message: error } : error | |
| 250 | ||
| 251 | return { | |
| 252 | type: 'abort', | |
| 253 | error: normalizedError, | |
| 254 | } | |
| 255 | } | |
| 256 | ||
| 257 | // ============================================================================= | |
| 258 | // Middleware Definition | |
| 259 | // ============================================================================= | |
| 260 | ||
| 261 | /** | |
| 262 | * Define a route middleware | |
| 263 | * | |
| 264 | * @param handler - Middleware handler function | |
| 265 | * @param options - Middleware options | |
| 266 | * @returns Middleware definition | |
| 267 | * | |
| 268 | * @example | |
| 269 | * ```typescript | |
| 270 | * // middleware/auth.ts - runs on both server and client | |
| 271 | * export default defineMiddleware(async (ctx) => { | |
| 272 | * const token = ctx.cookies.get('auth_token') | |
| 273 | * if (!token) { | |
| 274 | * return navigateTo('/login') | |
| 275 | * } | |
| 276 | * }) | |
| 277 | * | |
| 278 | * // middleware/theme.ts - client only (can use localStorage) | |
| 279 | * export default defineMiddleware(async (ctx) => { | |
| 280 | * const theme = ctx.storage.get('theme') | |
| 281 | * ctx.state.theme = theme || 'light' | |
| 282 | * }, { mode: 'client' }) | |
| 283 | * ``` | |
| 284 | */ | |
| 285 | export function defineMiddleware( | |
| 286 | handler: RouteMiddlewareHandler, | |
| 287 | options: MiddlewareOptions = {} | |
| 288 | ): RouteMiddlewareDefinition { | |
| 289 | return { | |
| 290 | handler, | |
| 291 | mode: options.mode ?? 'universal', | |
| 292 | } | |
| 293 | } | |
| 294 | ||
| 295 | // ============================================================================= | |
| 296 | // Cookie Manager Implementation | |
| 297 | // ============================================================================= | |
| 298 | ||
| 299 | /** | |
| 300 | * Parse cookies from a cookie header string | |
| 301 | */ | |
| 302 | function parseCookies(cookieHeader: string): Record<string, string> { | |
| 303 | const cookies: Record<string, string> = {} | |
| 304 | if (!cookieHeader) return cookies | |
| 305 | ||
| 306 | const pairs = cookieHeader.split(';') | |
| 307 | for (const pair of pairs) { | |
| 308 | const [name, ...rest] = pair.split('=') | |
| 309 | if (name) { | |
| 310 | const key = name.trim() | |
| 311 | const value = rest.join('=').trim() | |
| 312 | if (key) { | |
| 313 | cookies[key] = decodeURIComponent(value) | |
| 314 | } | |
| 315 | } | |
| 316 | } | |
| 317 | return cookies | |
| 318 | } | |
| 319 | ||
| 320 | /** | |
| 321 | * Serialize a cookie to a Set-Cookie header value | |
| 322 | */ | |
| 323 | function serializeCookie(name: string, value: string, options: CookieOptions = {}): string { | |
| 324 | let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}` | |
| 325 | ||
| 326 | if (options.expires) { | |
| 327 | const expires = | |
| 328 | typeof options.expires === 'number' | |
| 329 | ? new Date(Date.now() + options.expires * 1000) | |
| 330 | : options.expires | |
| 331 | cookie += `; Expires=${expires.toUTCString()}` | |
| 332 | } | |
| 333 | ||
| 334 | if (options.path) { | |
| 335 | cookie += `; Path=${options.path}` | |
| 336 | } | |
| 337 | ||
| 338 | if (options.domain) { | |
| 339 | cookie += `; Domain=${options.domain}` | |
| 340 | } | |
| 341 | ||
| 342 | if (options.secure) { | |
| 343 | cookie += '; Secure' | |
| 344 | } | |
| 345 | ||
| 346 | if (options.httpOnly) { | |
| 347 | cookie += '; HttpOnly' | |
| 348 | } | |
| 349 | ||
| 350 | if (options.sameSite) { | |
| 351 | cookie += `; SameSite=${options.sameSite}` | |
| 352 | } | |
| 353 | ||
| 354 | return cookie | |
| 355 | } | |
| 356 | ||
| 357 | /** | |
| 358 | * Create a cookie manager for server-side usage | |
| 359 | */ | |
| 360 | export function createServerCookieManager( | |
| 361 | request: Request, | |
| 362 | responseHeaders: Headers | |
| 363 | ): CookieManager { | |
| 364 | const cookieHeader = request.headers.get('cookie') || '' | |
| 365 | const cookies = parseCookies(cookieHeader) | |
| 366 | ||
| 367 | return { | |
| 368 | get(name: string): string | undefined { | |
| 369 | return cookies[name] | |
| 370 | }, | |
| 371 | set(name: string, value: string, options: CookieOptions = {}): void { | |
| 372 | cookies[name] = value | |
| 373 | responseHeaders.append('Set-Cookie', serializeCookie(name, value, options)) | |
| 374 | }, | |
| 375 | delete(name: string): void { | |
| 376 | delete cookies[name] | |
| 377 | responseHeaders.append( | |
| 378 | 'Set-Cookie', | |
| 379 | serializeCookie(name, '', { expires: new Date(0), path: '/' }) | |
| 380 | ) | |
| 381 | }, | |
| 382 | getAll(): Record<string, string> { | |
| 383 | return { ...cookies } | |
| 384 | }, | |
| 385 | } | |
| 386 | } | |
| 387 | ||
| 388 | /** | |
| 389 | * Create a cookie manager for client-side usage | |
| 390 | */ | |
| 391 | export function createClientCookieManager(): CookieManager { | |
| 392 | return { | |
| 393 | get(name: string): string | undefined { | |
| 394 | if (typeof document === 'undefined') return undefined | |
| 395 | const cookies = parseCookies(document.cookie) | |
| 396 | return cookies[name] | |
| 397 | }, | |
| 398 | set(name: string, value: string, options: CookieOptions = {}): void { | |
| 399 | if (typeof document === 'undefined') return | |
| 400 | document.cookie = serializeCookie(name, value, options) | |
| 401 | }, | |
| 402 | delete(name: string): void { | |
| 403 | if (typeof document === 'undefined') return | |
| 404 | document.cookie = serializeCookie(name, '', { expires: new Date(0), path: '/' }) | |
| 405 | }, | |
| 406 | getAll(): Record<string, string> { | |
| 407 | if (typeof document === 'undefined') return {} | |
| 408 | return parseCookies(document.cookie) | |
| 409 | }, | |
| 410 | } | |
| 411 | } | |
| 412 | ||
| 413 | // ============================================================================= | |
| 414 | // Storage Manager Implementation | |
| 415 | // ============================================================================= | |
| 416 | ||
| 417 | /** | |
| 418 | * Create a storage manager (localStorage wrapper) | |
| 419 | */ | |
| 420 | export function createStorageManager(): StorageManager { | |
| 421 | const isAvailable = | |
| 422 | typeof window !== 'undefined' && typeof window.localStorage !== 'undefined' | |
| 423 | ||
| 424 | return { | |
| 425 | get(key: string): string | null { | |
| 426 | if (!isAvailable) return null | |
| 427 | try { | |
| 428 | return window.localStorage.getItem(key) | |
| 429 | } catch { | |
| 430 | return null | |
| 431 | } | |
| 432 | }, | |
| 433 | set(key: string, value: string): void { | |
| 434 | if (!isAvailable) return | |
| 435 | try { | |
| 436 | window.localStorage.setItem(key, value) | |
| 437 | } catch { | |
| 438 | // Storage might be full or blocked | |
| 439 | } | |
| 440 | }, | |
| 441 | remove(key: string): void { | |
| 442 | if (!isAvailable) return | |
| 443 | try { | |
| 444 | window.localStorage.removeItem(key) | |
| 445 | } catch { | |
| 446 | // Ignore errors | |
| 447 | } | |
| 448 | }, | |
| 449 | isAvailable(): boolean { | |
| 450 | return isAvailable | |
| 451 | }, | |
| 452 | } | |
| 453 | } | |
| 454 | ||
| 455 | // ============================================================================= | |
| 456 | // Middleware Registry | |
| 457 | // ============================================================================= | |
| 458 | ||
| 459 | /** Global middleware registry */ | |
| 460 | const middlewareRegistry = new Map<string, RouteMiddlewareDefinition>() | |
| 461 | ||
| 462 | /** | |
| 463 | * Register a middleware by name | |
| 464 | */ | |
| 465 | export function registerMiddleware(name: string, middleware: RouteMiddlewareDefinition): void { | |
| 466 | middlewareRegistry.set(name, middleware) | |
| 467 | } | |
| 468 | ||
| 469 | /** | |
| 470 | * Get a middleware by name | |
| 471 | */ | |
| 472 | export function getMiddleware(name: string): RouteMiddlewareDefinition | undefined { | |
| 473 | return middlewareRegistry.get(name) | |
| 474 | } | |
| 475 | ||
| 476 | /** | |
| 477 | * Check if a middleware is registered | |
| 478 | */ | |
| 479 | export function hasMiddleware(name: string): boolean { | |
| 480 | return middlewareRegistry.has(name) | |
| 481 | } | |
| 482 | ||
| 483 | /** | |
| 484 | * Clear all registered middleware | |
| 485 | */ | |
| 486 | export function clearMiddleware(): void { | |
| 487 | middlewareRegistry.clear() | |
| 488 | } | |
| 489 | ||
| 490 | /** | |
| 491 | * Get all registered middleware names | |
| 492 | */ | |
| 493 | export function getMiddlewareNames(): string[] { | |
| 494 | return Array.from(middlewareRegistry.keys()) | |
| 495 | } | |
| 496 | ||
| 497 | // ============================================================================= | |
| 498 | // Middleware Loading | |
| 499 | // ============================================================================= | |
| 500 | ||
| 501 | /** | |
| 502 | * Load middleware from the middleware/ directory | |
| 503 | */ | |
| 504 | export async function loadMiddlewareFromDirectory( | |
| 505 | baseDir: string, | |
| 506 | middlewareDir: string = 'middleware' | |
| 507 | ): Promise<void> { | |
| 508 | const fullPath = path.resolve(baseDir, middlewareDir) | |
| 509 | ||
| 510 | if (!fs.existsSync(fullPath)) { | |
| 511 | return // No middleware directory, that's fine | |
| 512 | } | |
| 513 | ||
| 514 | const files = fs.readdirSync(fullPath) | |
| 515 | ||
| 516 | for (const file of files) { | |
| 517 | // Only process .ts and .js files | |
| 518 | if (!file.endsWith('.ts') && !file.endsWith('.js')) { | |
| 519 | continue | |
| 520 | } | |
| 521 | ||
| 522 | // Skip test files | |
| 523 | if (file.includes('.test.') || file.includes('.spec.')) { | |
| 524 | continue | |
| 525 | } | |
| 526 | ||
| 527 | const name = file.replace(/\.(ts|js)$/, '') | |
| 528 | const filePath = path.join(fullPath, file) | |
| 529 | ||
| 530 | try { | |
| 531 | // Dynamic import the middleware file | |
| 532 | const module = await import(filePath) | |
| 533 | const middleware = module.default | |
| 534 | ||
| 535 | if (middleware && typeof middleware.handler === 'function') { | |
| 536 | registerMiddleware(name, middleware) | |
| 537 | } else if (typeof middleware === 'function') { | |
| 538 | // Support plain function exports | |
| 539 | registerMiddleware(name, { | |
| 540 | handler: middleware, | |
| 541 | mode: 'universal', | |
| 542 | }) | |
| 543 | } | |
| 544 | } catch (error) { | |
| 545 | console.warn(`Failed to load middleware '${name}' from ${filePath}:`, error) | |
| 546 | } | |
| 547 | } | |
| 548 | } | |
| 549 | ||
| 550 | // ============================================================================= | |
| 551 | // Middleware Execution | |
| 552 | // ============================================================================= | |
| 553 | ||
| 554 | /** | |
| 555 | * Parse query string into object | |
| 556 | */ | |
| 557 | function parseQuery(search: string): Record<string, string> { | |
| 558 | const query: Record<string, string> = {} | |
| 559 | if (!search) return query | |
| 560 | ||
| 561 | const params = new URLSearchParams(search) | |
| 562 | for (const [key, value] of params) { | |
| 563 | query[key] = value | |
| 564 | } | |
| 565 | return query | |
| 566 | } | |
| 567 | ||
| 568 | /** | |
| 569 | * Create a route location object | |
| 570 | */ | |
| 571 | export function createRouteLocation( | |
| 572 | pathname: string, | |
| 573 | params: Record<string, string>, | |
| 574 | meta: PageMeta, | |
| 575 | search: string = '' | |
| 576 | ): RouteLocation { | |
| 577 | return { | |
| 578 | path: pathname, | |
| 579 | params, | |
| 580 | query: parseQuery(search), | |
| 581 | fullPath: search ? `${pathname}?${search}` : pathname, | |
| 582 | meta, | |
| 583 | } | |
| 584 | } | |
| 585 | ||
| 586 | /** | |
| 587 | * Create a middleware context | |
| 588 | */ | |
| 589 | export function createMiddlewareContext( | |
| 590 | to: RouteLocation, | |
| 591 | from: RouteLocation | null, | |
| 592 | request?: Request | |
| 593 | ): MiddlewareContext { | |
| 594 | const isServer = typeof window === 'undefined' | |
| 595 | const isClient = !isServer | |
| 596 | const responseHeaders = new Headers() | |
| 597 | ||
| 598 | return { | |
| 599 | to, | |
| 600 | from, | |
| 601 | isClient, | |
| 602 | isServer, | |
| 603 | cookies: isServer && request | |
| 604 | ? createServerCookieManager(request, responseHeaders) | |
| 605 | : createClientCookieManager(), | |
| 606 | storage: createStorageManager(), | |
| 607 | state: {}, | |
| 608 | request, | |
| 609 | responseHeaders, | |
| 610 | } | |
| 611 | } | |
| 612 | ||
| 613 | /** | |
| 614 | * Result of running middleware chain | |
| 615 | */ | |
| 616 | export interface MiddlewareResult { | |
| 617 | /** Whether all middleware passed */ | |
| 618 | passed: boolean | |
| 619 | /** Redirect result if any middleware returned navigateTo() */ | |
| 620 | redirect?: NavigateToResult | |
| 621 | /** Abort result if any middleware returned abortNavigation() */ | |
| 622 | abort?: AbortNavigationResult | |
| 623 | /** State accumulated from all middleware */ | |
| 624 | state: Record<string, unknown> | |
| 625 | /** Response headers to set */ | |
| 626 | responseHeaders: Headers | |
| 627 | } | |
| 628 | ||
| 629 | /** | |
| 630 | * Run middleware chain for a route | |
| 631 | * | |
| 632 | * @param middlewareNames - Names of middleware to run | |
| 633 | * @param context - Middleware context | |
| 634 | * @returns Result of middleware execution | |
| 635 | */ | |
| 636 | export async function runMiddleware( | |
| 637 | middlewareNames: string | string[], | |
| 638 | context: MiddlewareContext | |
| 639 | ): Promise<MiddlewareResult> { | |
| 640 | const names = Array.isArray(middlewareNames) ? middlewareNames : [middlewareNames] | |
| 641 | const isServer = context.isServer | |
| 642 | ||
| 643 | for (const name of names) { | |
| 644 | const middleware = getMiddleware(name) | |
| 645 | ||
| 646 | if (!middleware) { | |
| 647 | console.warn(`Middleware '${name}' not found`) | |
| 648 | continue | |
| 649 | } | |
| 650 | ||
| 651 | // Check if middleware should run in current environment | |
| 652 | if (middleware.mode === 'server' && !isServer) { | |
| 653 | continue | |
| 654 | } | |
| 655 | if (middleware.mode === 'client' && isServer) { | |
| 656 | continue | |
| 657 | } | |
| 658 | ||
| 659 | try { | |
| 660 | const result = await middleware.handler(context) | |
| 661 | ||
| 662 | if (result) { | |
| 663 | if (result.type === 'redirect') { | |
| 664 | return { | |
| 665 | passed: false, | |
| 666 | redirect: result, | |
| 667 | state: context.state, | |
| 668 | responseHeaders: context.responseHeaders, | |
| 669 | } | |
| 670 | } | |
| 671 | ||
| 672 | if (result.type === 'abort') { | |
| 673 | return { | |
| 674 | passed: false, | |
| 675 | abort: result, | |
| 676 | state: context.state, | |
| 677 | responseHeaders: context.responseHeaders, | |
| 678 | } | |
| 679 | } | |
| 680 | } | |
| 681 | } catch (error) { | |
| 682 | console.error(`Error in middleware '${name}':`, error) | |
| 683 | return { | |
| 684 | passed: false, | |
| 685 | abort: { | |
| 686 | type: 'abort', | |
| 687 | error: { | |
| 688 | statusCode: 500, | |
| 689 | message: error instanceof Error ? error.message : 'Middleware error', | |
| 690 | }, | |
| 691 | }, | |
| 692 | state: context.state, | |
| 693 | responseHeaders: context.responseHeaders, | |
| 694 | } | |
| 695 | } | |
| 696 | } | |
| 697 | ||
| 698 | return { | |
| 699 | passed: true, | |
| 700 | state: context.state, | |
| 701 | responseHeaders: context.responseHeaders, | |
| 702 | } | |
| 703 | } | |
| 1 | // Re-export from @stacksjs/router | |
| 2 | export { | |
| 3 | abortNavigation, | |
| 4 | clearMiddleware, | |
| 5 | createClientCookieManager, | |
| 6 | createMiddlewareContext, | |
| 7 | createRouteLocation, | |
| 8 | createServerCookieManager, | |
| 9 | createStorageManager, | |
| 10 | defineMiddleware, | |
| 11 | getMiddleware, | |
| 12 | getMiddlewareNames, | |
| 13 | hasMiddleware, | |
| 14 | loadMiddlewareFromDirectory, | |
| 15 | navigateTo, | |
| 16 | registerMiddleware, | |
| 17 | runMiddleware, | |
| 18 | } from '@stacksjs/router' | |
| 19 | ||
| 20 | export type { | |
| 21 | AbortNavigationResult, | |
| 22 | CookieManager, | |
| 23 | CookieOptions, | |
| 24 | MiddlewareContext, | |
| 25 | MiddlewareMode, | |
| 26 | MiddlewareOptions, | |
| 27 | MiddlewareResult, | |
| 28 | NavigateToOptions, | |
| 29 | NavigateToResult, | |
| 30 | NavigationError, | |
| 31 | NavigationResult, | |
| 32 | RouteLocation, | |
| 33 | RouteMiddlewareDefinition, | |
| 34 | RouteMiddlewareHandler, | |
| 35 | StorageManager, | |
| 36 | } from '@stacksjs/router' | |
| @@ -1,278 +1,22 @@ | ||
| 1 | /** | |
| 2 | * STX File-Based Router | |
| 3 | * | |
| 4 | * Scans pages/ directory and creates routes from file structure. | |
| 5 | * Supports dynamic routes with [param] syntax. | |
| 6 | * | |
| 7 | * Examples: | |
| 8 | * pages/index.stx → / | |
| 9 | * pages/about.stx → /about | |
| 10 | * pages/chat/index.stx → /chat | |
| 11 | * pages/chat/[id].stx → /chat/:id (dynamic) | |
| 12 | */ | |
| 1 | // Re-export from @stacksjs/router | |
| 2 | import { matchRoute, Router as _Router } from '@stacksjs/router' | |
| 3 | import type { Route, RouterConfig } from '@stacksjs/router' | |
| 13 | 4 | |
| 14 | import fs from 'node:fs' | |
| 15 | import path from 'node:path' | |
| 16 | ||
| 17 | export interface Route { | |
| 18 | /** The URL pattern (e.g., /chat/:id) */ | |
| 19 | pattern: string | |
| 20 | /** The regex to match the route */ | |
| 21 | regex: RegExp | |
| 22 | /** Parameter names extracted from dynamic segments */ | |
| 23 | params: string[] | |
| 24 | /** Absolute path to the .stx file */ | |
| 25 | filePath: string | |
| 26 | /** Whether this is a dynamic route */ | |
| 27 | isDynamic: boolean | |
| 28 | } | |
| 29 | ||
| 30 | export interface RouteMatch { | |
| 31 | route: Route | |
| 32 | params: Record<string, string> | |
| 33 | } | |
| 5 | export type { Route, RouteMatch } from '@stacksjs/router' | |
| 6 | export { findErrorPage, formatRoutes, generateRouteTypes, matchRoute } from '@stacksjs/router' | |
| 34 | 7 | |
| 8 | // Backwards-compatible createRouter that returns Route[] (original signature) | |
| 35 | 9 | export interface RouterOptions { |
| 36 | /** Directory containing page files (default: 'pages') */ | |
| 37 | 10 | pagesDir?: string |
| 38 | /** File extensions to consider as pages (default: ['.stx']) */ | |
| 39 | 11 | extensions?: string[] |
| 40 | 12 | } |
| 41 | 13 | |
| 42 | /** | |
| 43 | * Scans a directory recursively for page files | |
| 44 | */ | |
| 45 | function scanDirectory(dir: string, extensions: string[]): string[] { | |
| 46 | const files: string[] = [] | |
| 47 | ||
| 48 | if (!fs.existsSync(dir)) { | |
| 49 | return files | |
| 50 | } | |
| 51 | ||
| 52 | const entries = fs.readdirSync(dir, { withFileTypes: true }) | |
| 53 | ||
| 54 | for (const entry of entries) { | |
| 55 | const fullPath = path.join(dir, entry.name) | |
| 56 | ||
| 57 | if (entry.isDirectory()) { | |
| 58 | // Skip special directories | |
| 59 | if (entry.name.startsWith('_') || entry.name.startsWith('.')) { | |
| 60 | continue | |
| 61 | } | |
| 62 | files.push(...scanDirectory(fullPath, extensions)) | |
| 63 | } else if (entry.isFile()) { | |
| 64 | // Check if it's a page file | |
| 65 | const ext = path.extname(entry.name) | |
| 66 | if (extensions.includes(ext)) { | |
| 67 | // Skip files starting with _ (partials/layouts) | |
| 68 | if (!entry.name.startsWith('_')) { | |
| 69 | files.push(fullPath) | |
| 70 | } | |
| 71 | } | |
| 72 | } | |
| 73 | } | |
| 74 | ||
| 75 | return files | |
| 76 | } | |
| 77 | ||
| 78 | /** | |
| 79 | * Converts a file path to a route pattern | |
| 80 | * | |
| 81 | * Examples: | |
| 82 | * pages/index.stx → / | |
| 83 | * pages/about.stx → /about | |
| 84 | * pages/chat/index.stx → /chat | |
| 85 | * pages/chat/[id].stx → /chat/:id | |
| 86 | * pages/blog/[...slug].stx → /blog/:slug* (catch-all) | |
| 87 | */ | |
| 88 | function filePathToPattern(filePath: string, pagesDir: string): string { | |
| 89 | // Get relative path from pages directory | |
| 90 | const relativePath = path.relative(pagesDir, filePath) | |
| 91 | ||
| 92 | // Remove extension | |
| 93 | let route = relativePath.replace(/\.(stx|md)$/, '') | |
| 94 | ||
| 95 | // Convert path separators to URL separators | |
| 96 | route = route.split(path.sep).join('/') | |
| 97 | ||
| 98 | // Handle index files | |
| 99 | if (route === 'index') { | |
| 100 | return '/' | |
| 101 | } | |
| 102 | if (route.endsWith('/index')) { | |
| 103 | route = route.slice(0, -6) | |
| 104 | } | |
| 105 | ||
| 106 | // Convert [[param]] to :param? (optional) — BEFORE required params | |
| 107 | route = route.replace(/\[\[([^\]]+)\]\]/g, ':$1?') | |
| 108 | ||
| 109 | // Convert [param] to :param | |
| 110 | route = route.replace(/\[([^\]]+)\]/g, ':$1') | |
| 111 | ||
| 112 | // Convert [...param] to :param* (catch-all) | |
| 113 | route = route.replace(/:\.\.\.([^/]+)/g, ':$1*') | |
| 114 | ||
| 115 | return `/${route}` | |
| 116 | } | |
| 117 | ||
| 118 | /** | |
| 119 | * Converts a route pattern to a regex and extracts param names | |
| 120 | */ | |
| 121 | function patternToRegex(pattern: string): { regex: RegExp; params: string[] } { | |
| 122 | const params: string[] = [] | |
| 123 | ||
| 124 | // Escape special regex characters except : * and ? (? is used for optional params) | |
| 125 | let regexStr = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&') | |
| 126 | ||
| 127 | // Handle optional params (/:param?) — the preceding / is also optional | |
| 128 | regexStr = regexStr.replace(/\/:([^/]+)\?/g, (_, name) => { | |
| 129 | params.push(name) | |
| 130 | return '(?:/([^/]+))?' | |
| 131 | }) | |
| 132 | ||
| 133 | // Handle catch-all params (:param*) | |
| 134 | regexStr = regexStr.replace(/:([^/]+)\*/g, (_, name) => { | |
| 135 | params.push(name) | |
| 136 | return '(.+)' | |
| 137 | }) | |
| 138 | ||
| 139 | // Handle regular params (:param) | |
| 140 | regexStr = regexStr.replace(/:([^/]+)/g, (_, name) => { | |
| 141 | params.push(name) | |
| 142 | return '([^/]+)' | |
| 143 | }) | |
| 144 | ||
| 145 | // Add start/end anchors | |
| 146 | regexStr = `^${regexStr}$` | |
| 147 | ||
| 148 | return { regex: new RegExp(regexStr), params } | |
| 149 | } | |
| 150 | ||
| 151 | /** | |
| 152 | * Creates a router from a pages directory | |
| 153 | */ | |
| 154 | 14 | export function createRouter(baseDir: string, options: RouterOptions = {}): Route[] { |
| 155 | const pagesDir = path.resolve(baseDir, options.pagesDir || 'pages') | |
| 156 | const extensions = options.extensions || ['.stx'] | |
| 157 | ||
| 158 | // Scan for page files | |
| 159 | const files = scanDirectory(pagesDir, extensions) | |
| 160 | ||
| 161 | // Convert to routes | |
| 162 | const routes: Route[] = files.map((filePath) => { | |
| 163 | const pattern = filePathToPattern(filePath, pagesDir) | |
| 164 | const { regex, params } = patternToRegex(pattern) | |
| 165 | ||
| 166 | return { | |
| 167 | pattern, | |
| 168 | regex, | |
| 169 | params, | |
| 170 | filePath, | |
| 171 | isDynamic: params.length > 0, | |
| 172 | } | |
| 173 | }) | |
| 174 | ||
| 175 | // Sort routes: static routes first, then by specificity (more segments = higher priority) | |
| 176 | routes.sort((a, b) => { | |
| 177 | // Static routes come before dynamic routes | |
| 178 | if (!a.isDynamic && b.isDynamic) return -1 | |
| 179 | if (a.isDynamic && !b.isDynamic) return 1 | |
| 180 | ||
| 181 | // More specific routes (more segments) come first | |
| 182 | const aSegments = a.pattern.split('/').length | |
| 183 | const bSegments = b.pattern.split('/').length | |
| 184 | if (aSegments !== bSegments) return bSegments - aSegments | |
| 185 | ||
| 186 | // Alphabetical order as tiebreaker | |
| 187 | return a.pattern.localeCompare(b.pattern) | |
| 188 | }) | |
| 189 | ||
| 190 | // Generate route types | |
| 191 | const stxDir = path.join(baseDir, '.stx') | |
| 192 | generateRouteTypes(routes, stxDir) | |
| 193 | ||
| 194 | return routes | |
| 15 | const router = new _Router(baseDir, options as RouterConfig) | |
| 16 | return router.routes | |
| 195 | 17 | } |
| 196 | 18 | |
| 197 | /** | |
| 198 | * Matches a URL pathname against routes | |
| 199 | */ | |
| 200 | export function matchRoute(pathname: string, routes: Route[]): RouteMatch | null { | |
| 201 | // Normalize pathname | |
| 202 | const normalizedPath = pathname === '' ? '/' : pathname | |
| 203 | ||
| 204 | for (const route of routes) { | |
| 205 | const match = normalizedPath.match(route.regex) | |
| 206 | if (match) { | |
| 207 | // Extract params | |
| 208 | const params: Record<string, string> = {} | |
| 209 | route.params.forEach((name, index) => { | |
| 210 | params[name] = match[index + 1] | |
| 211 | }) | |
| 212 | ||
| 213 | return { route, params } | |
| 214 | } | |
| 215 | } | |
| 216 | ||
| 217 | return null | |
| 218 | } | |
| 219 | ||
| 220 | /** | |
| 221 | * Gets route params from a URL | |
| 222 | */ | |
| 223 | 19 | export function getRouteParams(pathname: string, routes: Route[]): Record<string, string> | null { |
| 224 | const match = matchRoute(pathname, routes) | |
| 225 | return match ? match.params : null | |
| 226 | } | |
| 227 | ||
| 228 | /** | |
| 229 | * Finds a custom error page for a given status code. | |
| 230 | * Checks for a specific code page (e.g., 500.stx) first, then falls back to generic error.stx. | |
| 231 | */ | |
| 232 | export function findErrorPage(pagesDir: string, statusCode: number): string | null { | |
| 233 | const candidates = [`${statusCode}.stx`, 'error.stx'] | |
| 234 | for (const candidate of candidates) { | |
| 235 | const filePath = path.join(pagesDir, candidate) | |
| 236 | if (fs.existsSync(filePath)) return filePath | |
| 237 | } | |
| 238 | return null | |
| 239 | } | |
| 240 | ||
| 241 | /** | |
| 242 | * Generates TypeScript route type declarations from discovered routes. | |
| 243 | * Writes to `.stx/route-types.d.ts` in the given output directory. | |
| 244 | */ | |
| 245 | export function generateRouteTypes(routes: Route[], outputDir: string): void { | |
| 246 | const lines = [ | |
| 247 | '// Auto-generated by STX - do not edit', | |
| 248 | 'declare module "stx/routes" {', | |
| 249 | ' interface RouteMap {', | |
| 250 | ] | |
| 251 | ||
| 252 | for (const route of routes) { | |
| 253 | const paramEntries: string[] = [] | |
| 254 | for (const p of route.params) { | |
| 255 | // Catch-all params are string arrays | |
| 256 | const type = route.pattern.includes(`:${p}*`) ? 'string[]' : 'string' | |
| 257 | paramEntries.push(`${p}: ${type}`) | |
| 258 | } | |
| 259 | const paramsStr = paramEntries.join('; ') | |
| 260 | lines.push(` '${route.pattern}': { ${paramsStr} }`) | |
| 261 | } | |
| 262 | ||
| 263 | lines.push(' }', '}', '') | |
| 264 | ||
| 265 | fs.mkdirSync(outputDir, { recursive: true }) | |
| 266 | fs.writeFileSync(path.join(outputDir, 'route-types.d.ts'), lines.join('\n')) | |
| 267 | } | |
| 268 | ||
| 269 | /** | |
| 270 | * Pretty prints routes for console output | |
| 271 | */ | |
| 272 | export function formatRoutes(routes: Route[], baseDir: string): string[] { | |
| 273 | return routes.map((route) => { | |
| 274 | const relativePath = path.relative(baseDir, route.filePath) | |
| 275 | const dynamicLabel = route.isDynamic ? ' (dynamic)' : '' | |
| 276 | return `${route.pattern} → ${relativePath}${dynamicLabel}` | |
| 277 | }) | |
| 20 | const result = matchRoute(pathname, routes) | |
| 21 | return result ? result.params : null | |
| 278 | 22 | } |
| @@ -1,104 +1,12 @@ | ||
| 1 | /** | |
| 2 | * Laravel-like named routes system for stx | |
| 3 | * | |
| 4 | * This provides the ability to define routes with names and generate URLs for them, | |
| 5 | * similar to Laravel's route naming and the route() helper. | |
| 6 | */ | |
| 1 | // Re-export named route primitives from @stacksjs/router | |
| 2 | export { defineRoute, defineRoutes, resetRoutes, route, setAppUrl } from '@stacksjs/router' | |
| 7 | 3 | |
| 4 | import { route } from '@stacksjs/router' | |
| 8 | 5 | import { safeEvaluateObject } from './safe-evaluator' |
| 9 | 6 | |
| 10 | interface RouteDefinition { | |
| 11 | path: string | |
| 12 | name?: string | |
| 13 | params?: Record<string, any> | |
| 14 | } | |
| 15 | ||
| 16 | // Store for registered routes | |
| 17 | const routes: Record<string, RouteDefinition> = {} | |
| 18 | ||
| 19 | // Domain config | |
| 20 | let appUrl: string = '' | |
| 21 | ||
| 22 | /** | |
| 23 | * Set the base URL for the application | |
| 24 | */ | |
| 25 | export function setAppUrl(url: string): void { | |
| 26 | appUrl = url.replace(/\/$/, '') // Remove trailing slash if present | |
| 27 | } | |
| 28 | ||
| 29 | /** | |
| 30 | * Register a route with a name | |
| 31 | */ | |
| 32 | export function defineRoute(name: string, path: string, params: Record<string, any> = {}): void { | |
| 33 | routes[name] = { path, name, params } | |
| 34 | } | |
| 35 | ||
| 36 | /** | |
| 37 | * Define multiple routes at once | |
| 38 | */ | |
| 39 | export function defineRoutes(routeDefinitions: Record<string, string | RouteDefinition>): void { | |
| 40 | for (const [name, definition] of Object.entries(routeDefinitions)) { | |
| 41 | if (typeof definition === 'string') { | |
| 42 | routes[name] = { path: definition, name } | |
| 43 | } | |
| 44 | else { | |
| 45 | routes[name] = { ...definition, name } | |
| 46 | } | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | 7 | /** |
| 51 | * Generate a URL for a named route with parameters | |
| 52 | */ | |
| 53 | export function route( | |
| 54 | name: string, | |
| 55 | params: Record<string, any> = {}, | |
| 56 | absolute = false, | |
| 57 | ): string { | |
| 58 | const route = routes[name] | |
| 59 | ||
| 60 | if (!route) { | |
| 61 | console.warn(`Route [${name}] not defined.`) | |
| 62 | return '#undefined-route' | |
| 63 | } | |
| 64 | ||
| 65 | // Merge default params with provided params | |
| 66 | const mergedParams = { ...route.params, ...params } | |
| 67 | ||
| 68 | // Replace parameters in the path | |
| 69 | let path = route.path | |
| 70 | ||
| 71 | // Replace named parameters (:param) | |
| 72 | path = path.replace(/:(\w+)/g, (_, paramName) => { | |
| 73 | const value = mergedParams[paramName] | |
| 74 | ||
| 75 | if (value === undefined) { | |
| 76 | console.warn(`Missing parameter [${paramName}] for route [${name}].`) | |
| 77 | return `:${paramName}` | |
| 78 | } | |
| 79 | ||
| 80 | // Remove used parameter from the object | |
| 81 | delete mergedParams[paramName] | |
| 82 | ||
| 83 | return encodeURIComponent(String(value)) | |
| 84 | }) | |
| 85 | ||
| 86 | // Add remaining parameters as query string | |
| 87 | const remainingParams = Object.entries(mergedParams) | |
| 88 | if (remainingParams.length > 0) { | |
| 89 | const queryString = remainingParams | |
| 90 | .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`) | |
| 91 | .join('&') | |
| 92 | ||
| 93 | path += (path.includes('?') ? '&' : '?') + queryString | |
| 94 | } | |
| 95 | ||
| 96 | // Return absolute or relative URL | |
| 97 | return absolute && appUrl ? `${appUrl}${path}` : path | |
| 98 | } | |
| 99 | ||
| 100 | /** | |
| 101 | * Create a route URL directive processor for stx templates | |
| 8 | * Process @route directives in stx templates | |
| 9 | * (Kept in core stx because it depends on safe-evaluator) | |
| 102 | 10 | */ |
| 103 | 11 | export function processRouteDirectives(template: string): string { |
| 104 | 12 | return template.replace(/@route\(\s*(['"])([^'"]+)\1(?:\s*,\s*(\{[^}]+\}))?\s*(?:,\s*(true|false)\s*)?\)/g, (_, _quote, routeName, paramsJson, absolute) => { |
| @@ -117,12 +25,3 @@ export function processRouteDirectives(template: string): string { | ||
| 117 | 25 | }) |
| 118 | 26 | } |
| 119 | 27 | |
| 120 | /** | |
| 121 | * Reset all routes (mainly for testing) | |
| 122 | */ | |
| 123 | export function resetRoutes(): void { | |
| 124 | Object.keys(routes).forEach((key) => { | |
| 125 | delete routes[key] | |
| 126 | }) | |
| 127 | appUrl = '' | |
| 128 | } | |
| @@ -14,6 +14,10 @@ | ||
| 14 | 14 | "@stacksjs/sanitizer": ["./packages/sanitizer/src/index.ts"], |
| 15 | 15 | "@stacksjs/iconify-core": ["./packages/iconify-core/src/index.ts"], |
| 16 | 16 | "@stacksjs/iconify-generator": ["./packages/iconify-generator/src/index.ts"], |
| 17 | "@stacksjs/config": ["./packages/config/src/index.ts"], | |
| 18 | "@stacksjs/router": ["./packages/router/src/index.ts"], | |
| 19 | "@stacksjs/data": ["./packages/data/src/index.ts"], | |
| 20 | "@stacksjs/deploy": ["./packages/deploy/src/index.ts"], | |
| 17 | 21 | "bun-plugin-stx": ["./packages/bun-plugin/src/index.ts"], |
| 18 | 22 | "bun-plugin-stx/client": ["./packages/bun-plugin/src/client.ts"] |
| 19 | 23 | }, |