ReviewOS

also looking at this

stacks/stx

chore: wip stx improvements

#1649
Merged fingersandmind wants to merge feat/stx-improvements into main
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.

ECOSYSTEM_TODO.mdmodified+7-7
Changes to ECOSYSTEM_TODO.md
@@ -9,7 +9,7 @@
99- [x] Implement `.env` file loader using `Bun.env`
1010- [x] Implement `validateEnv()` with startup error reporting
1111- [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
1313- [x] Add `@stacksjs/config` to root `tsconfig.json` paths
1414- [x] Add to workspace in root `package.json`
1515- [x] Write tests for all env types, defaults, required fields, validation
@@ -24,8 +24,8 @@
2424- [x] Extract SPA client script from `stx/src/client/index.ts`
2525- [x] Implement nested layout resolution (`_layout.stx` chain)
2626- [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
2929- [x] Write tests for: file scanning, pattern matching, dynamic params, middleware chain, layout nesting, named routes
3030- [x] Add to `tsconfig.json` paths and workspace
3131
@@ -37,8 +37,8 @@
3737- [x] Implement `useAsyncData()` and `useFetch()` composables
3838- [x] Implement loader result caching with stale-while-revalidate
3939- [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
4242- [x] Write tests for loaders, actions, composables, caching, serialization
4343- [x] Add to `tsconfig.json` paths and workspace
4444
@@ -49,8 +49,8 @@
4949- [x] Implement `bunServerAdapter` with compression, static file serving, TLS
5050- [x] Implement `staticAdapter` wrapping existing SSG
5151- [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
5454- [x] Write tests for each adapter
5555- [x] Add to `tsconfig.json` paths and workspace
5656
packages/config/build.tsadded+14-0
Changes to packages/config/build.ts
@@ -0,0 +1,14 @@
1import plugin from 'bun-plugin-dtsx'
2
3await 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
14console.log('Build complete!')
packages/config/package.jsonadded+49-0
Changes to packages/config/package.json
@@ -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}
packages/config/src/env.tsadded+41-0
Changes to packages/config/src/env.ts
@@ -0,0 +1,41 @@
1import process from 'node:process'
2import type { EnvVarDef, TypedEnv } from './types'
3import { getEnvValue, loadEnvFile } from './loader'
4import { validateEnv } from './validation'
5
6export 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
31export function isProduction(): boolean {
32 return process.env.NODE_ENV === 'production'
33}
34
35export function isDevelopment(): boolean {
36 return !isProduction() || process.env.STX_DEBUG === 'true'
37}
38
39export function isTest(): boolean {
40 return process.env.NODE_ENV === 'test'
41}
packages/config/src/index.tsadded+4-0
Changes to packages/config/src/index.ts
@@ -0,0 +1,4 @@
1export { defineEnv, isDevelopment, isProduction, isTest } from './env'
2export { getEnvValue, loadEnvFile } from './loader'
3export { validateEnv } from './validation'
4export type { EnvAccessor, EnvType, EnvValidationError, EnvVarDef, TypedEnv } from './types'
packages/config/src/loader.tsadded+64-0
Changes to packages/config/src/loader.ts
@@ -0,0 +1,64 @@
1import process from 'node:process'
2import { existsSync, readFileSync } from 'node:fs'
3import { resolve } from 'node:path'
4
5export 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
46export 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}
packages/config/src/types.tsadded+29-0
Changes to packages/config/src/types.ts
@@ -0,0 +1,29 @@
1export type EnvType = 'string' | 'number' | 'boolean' | 'url' | 'email' | 'port'
2
3export 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
12export 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
19export interface EnvValidationError {
20 key: string
21 message: string
22}
23
24export interface EnvAccessor {
25 get: (key: string) => string | undefined
26 getOrThrow: (key: string) => string
27 has: (key: string) => boolean
28 all: () => Record<string, string>
29}
packages/config/src/validation.tsadded+84-0
Changes to packages/config/src/validation.ts
@@ -0,0 +1,84 @@
1import type { EnvType, EnvValidationError, EnvVarDef } from './types'
2
3const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
4const URL_RE = /^https?:\/\/.+/
5
6function 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
39export 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}
packages/config/test/env.test.tsadded+172-0
Changes to packages/config/test/env.test.ts
@@ -0,0 +1,172 @@
1import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2import { defineEnv, isDevelopment, isProduction, isTest } from '../src/env'
3
4describe('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
152describe('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})
packages/config/test/validation.test.tsadded+110-0
Changes to packages/config/test/validation.test.ts
@@ -0,0 +1,110 @@
1import { describe, expect, test } from 'bun:test'
2import { validateEnv } from '../src/validation'
3
4describe('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})
packages/data/build.tsadded+14-0
Changes to packages/data/build.ts
@@ -0,0 +1,14 @@
1import plugin from 'bun-plugin-dtsx'
2
3await 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
14console.log('Build complete!')
packages/data/package.jsonadded+48-0
Changes to packages/data/package.json
@@ -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}
packages/data/src/cache.tsadded+50-0
Changes to packages/data/src/cache.ts
@@ -0,0 +1,50 @@
1import type { CacheEntry } from './types'
2
3const dataCache = new Map<string, CacheEntry>()
4
5export 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
18export 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
26export function invalidateCache(key: string): void {
27 dataCache.delete(key)
28}
29
30export function clearDataCache(): void {
31 dataCache.clear()
32}
33
34export 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}
packages/data/src/composables.tsadded+83-0
Changes to packages/data/src/composables.ts
@@ -0,0 +1,83 @@
1import type { AsyncDataResult, FetchOptions } from './types'
2
3export 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
46export 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}
packages/data/src/index.tsadded+29-0
Changes to packages/data/src/index.ts
@@ -0,0 +1,29 @@
1export {
2 createActionContext,
3 createLoaderContext,
4 defineAction,
5 defineLoader,
6 defineLoaders,
7 executeAction,
8 executeLoader,
9} from './loader'
10
11export { useAsyncData, useFetch } from './composables'
12
13export { clearDataCache, getCached, invalidateCache, setCached, staleWhileRevalidate } from './cache'
14
15export { deserialize, serialize } from './serialization'
16
17export 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'
packages/data/src/loader.tsadded+73-0
Changes to packages/data/src/loader.ts
@@ -0,0 +1,73 @@
1import type { ActionContext, ActionDefinition, ActionHandler, ActionResult, LoaderContext, LoaderDefinition, LoaderHandler } from './types'
2
3export function defineLoader<T>(handler: LoaderHandler<T>): LoaderDefinition<T> {
4 return {
5 _type: 'loader',
6 handler,
7 }
8}
9
10export 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
20export function defineAction<T>(handler: ActionHandler<T>): ActionDefinition<T> {
21 return {
22 _type: 'action',
23 handler,
24 }
25}
26
27function 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
39export 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
56export 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
65export 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
70export 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}
packages/data/src/serialization.tsadded+52-0
Changes to packages/data/src/serialization.ts
@@ -0,0 +1,52 @@
1function 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
30export function serialize(value: unknown): string {
31 return JSON.stringify(prepareForSerialization(value))
32}
33
34export 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}
packages/data/src/types.tsadded+59-0
Changes to packages/data/src/types.ts
@@ -0,0 +1,59 @@
1export interface LoaderContext {
2 params: Record<string, string>
3 request: Request
4 cookies: CookieAccessor
5 headers: Headers
6 url: URL
7}
8
9export interface CookieAccessor {
10 get(name: string): string | undefined
11 getAll(): Record<string, string>
12}
13
14export interface ActionContext extends LoaderContext {
15 formData: () => Promise<FormData>
16 json: <T>() => Promise<T>
17}
18
19export interface ActionResult<T = unknown> {
20 data?: T
21 errors?: Record<string, string[]>
22 redirect?: string
23 status?: number
24}
25
26export type LoaderHandler<T> = (ctx: LoaderContext) => Promise<T> | T
27
28export interface LoaderDefinition<T = unknown> {
29 _type: 'loader'
30 handler: LoaderHandler<T>
31}
32
33export type ActionHandler<T> = (ctx: ActionContext) => Promise<ActionResult<T>>
34
35export interface ActionDefinition<T = unknown> {
36 _type: 'action'
37 handler: ActionHandler<T>
38}
39
40export interface FetchOptions extends RequestInit {
41 key?: string
42 lazy?: boolean
43 pick?: string[]
44 server?: boolean
45 default?: () => unknown
46}
47
48export interface AsyncDataResult<T> {
49 data: T | null
50 pending: boolean
51 error: Error | null
52 refresh: () => Promise<void>
53}
54
55export interface CacheEntry<T = unknown> {
56 value: T
57 timestamp: number
58 ttl: number
59}
packages/data/test/cache.test.tsadded+65-0
Changes to packages/data/test/cache.test.ts
@@ -0,0 +1,65 @@
1import { afterEach, describe, expect, test } from 'bun:test'
2import { clearDataCache, getCached, invalidateCache, setCached, staleWhileRevalidate } from '../src/cache'
3
4describe('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
41describe('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})
packages/data/test/loader.test.tsadded+118-0
Changes to packages/data/test/loader.test.ts
@@ -0,0 +1,118 @@
1import { describe, expect, test } from 'bun:test'
2import { createActionContext, createLoaderContext, defineAction, defineLoader, defineLoaders, executeAction, executeLoader } from '../src/loader'
3
4describe('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
14describe('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
25describe('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
34describe('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
48describe('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
63describe('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
87describe('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})
packages/data/test/serialization.test.tsadded+64-0
Changes to packages/data/test/serialization.test.ts
@@ -0,0 +1,64 @@
1import { describe, expect, test } from 'bun:test'
2import { deserialize, serialize } from '../src/serialization'
3
4describe('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})
packages/deploy/build.tsadded+14-0
Changes to packages/deploy/build.ts
@@ -0,0 +1,14 @@
1import plugin from 'bun-plugin-dtsx'
2
3await 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
14console.log('Build complete!')
packages/deploy/package.jsonadded+48-0
Changes to packages/deploy/package.json
@@ -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}
packages/deploy/src/adapter.tsadded+5-0
Changes to packages/deploy/src/adapter.ts
@@ -0,0 +1,5 @@
1import type { DeployAdapter } from './types'
2
3export function defineAdapter(adapter: DeployAdapter): DeployAdapter {
4 return adapter
5}
packages/deploy/src/adapters/bun-server.tsadded+109-0
Changes to packages/deploy/src/adapters/bun-server.ts
@@ -0,0 +1,109 @@
1import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
2import { extname, join, relative } from 'node:path'
3import type { AdapterBuildConfig, AdapterBuildResult, BunServerConfig, DeployAdapter } from '../types'
4
5const 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
23function 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
34function 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'
39import { join, extname } from 'node:path'
40
41const MIME_TYPES = ${JSON.stringify(MIME_TYPES, null, 2)}
42
43const PUBLIC_DIR = import.meta.dir
44
45Bun.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
83console.log('Server running at http://${hostname}:${port}')
84`
85}
86
87export 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}
packages/deploy/src/adapters/static.tsadded+64-0
Changes to packages/deploy/src/adapters/static.ts
@@ -0,0 +1,64 @@
1import { existsSync, mkdirSync, readdirSync, statSync, copyFileSync } from 'node:fs'
2import { join, relative } from 'node:path'
3import type { AdapterBuildConfig, AdapterBuildResult, DeployAdapter, StaticAdapterConfig } from '../types'
4
5function 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
19function 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
30export 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}
packages/deploy/src/index.tsadded+17-0
Changes to packages/deploy/src/index.ts
@@ -0,0 +1,17 @@
1export { defineAdapter } from './adapter'
2export { bunServerAdapter } from './adapters/bun-server'
3export { staticAdapter } from './adapters/static'
4export { detectRuntime, isEdgeRuntime } from './runtime'
5
6export type {
7 AdapterBuildConfig,
8 AdapterBuildResult,
9 AdapterDeployConfig,
10 BunServerConfig,
11 DeployAdapter,
12 DeployAppConfig,
13 DeployResult,
14 RuntimeInfo,
15 RuntimePlatform,
16 StaticAdapterConfig,
17} from './types'
packages/deploy/src/runtime.tsadded+81-0
Changes to packages/deploy/src/runtime.ts
@@ -0,0 +1,81 @@
1import type { RuntimeInfo, RuntimePlatform } from './types'
2
3export 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
78export function isEdgeRuntime(): boolean {
79 const rt = detectRuntime()
80 return ['cloudflare', 'deno', 'vercel', 'netlify'].includes(rt.platform)
81}
packages/deploy/src/types.tsadded+63-0
Changes to packages/deploy/src/types.ts
@@ -0,0 +1,63 @@
1export type RuntimePlatform = 'bun' | 'node' | 'deno' | 'cloudflare' | 'vercel' | 'netlify' | 'unknown'
2
3export 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
13export interface AdapterBuildConfig {
14 entry: string | string[]
15 outDir: string
16 production: boolean
17}
18
19export interface AdapterBuildResult {
20 outputDir: string
21 entrypoint?: string
22 files: string[]
23}
24
25export interface AdapterDeployConfig {
26 outputDir: string
27 production: boolean
28 token?: string
29 siteId?: string
30}
31
32export interface DeployResult {
33 success: boolean
34 url?: string
35 deployId?: string
36 logs: string[]
37 duration: number
38}
39
40export 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
47export interface DeployAppConfig {
48 adapter?: DeployAdapter | 'bun' | 'static'
49 outputDir?: string
50 production?: boolean
51}
52
53export interface BunServerConfig {
54 port?: number
55 hostname?: string
56 compression?: boolean
57 tls?: { cert: string, key: string }
58}
59
60export interface StaticAdapterConfig {
61 prerender?: boolean
62 fallback?: string
63}
packages/deploy/test/adapters.test.tsadded+84-0
Changes to packages/deploy/test/adapters.test.ts
@@ -0,0 +1,84 @@
1import { afterEach, describe, expect, test } from 'bun:test'
2import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
3import { join } from 'node:path'
4import { bunServerAdapter } from '../src/adapters/bun-server'
5import { staticAdapter } from '../src/adapters/static'
6import { defineAdapter } from '../src/adapter'
7
8const TEST_DIR = join(import.meta.dir, '.test-output')
9
10describe('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
37describe('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
74describe('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})
packages/deploy/test/runtime.test.tsadded+17-0
Changes to packages/deploy/test/runtime.test.ts
@@ -0,0 +1,17 @@
1import { describe, expect, test } from 'bun:test'
2import { detectRuntime, isEdgeRuntime } from '../src/runtime'
3
4describe('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})
packages/router/build.tsadded+14-0
Changes to packages/router/build.ts
@@ -0,0 +1,14 @@
1import plugin from 'bun-plugin-dtsx'
2
3await 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
14console.log('Build complete!')
packages/router/package.jsonadded+48-0
Changes to packages/router/package.json
@@ -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}
packages/stx/src/client/index.ts packages/router/src/client.tscopied+0-13
Changes to packages/router/src/client.ts
@@ -1,16 +1,3 @@
1/**
2 * STX Client-Side Modules
3 *
4 * Browser-side functionality for STX applications.
5 */
6
7export * from './router'
8export * 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 */
141export function getRouterScript(): string {
152 return `
163;(function(){
packages/router/src/codegen.tsadded+26-0
Changes to packages/router/src/codegen.ts
@@ -0,0 +1,26 @@
1import fs from 'node:fs'
2import path from 'node:path'
3import type { Route } from './types'
4
5export 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}
packages/router/src/file-router.tsadded+150-0
Changes to packages/router/src/file-router.ts
@@ -0,0 +1,150 @@
1import fs from 'node:fs'
2import path from 'node:path'
3import type { Route, RouteMatch, RouterConfig } from './types'
4import { generateRouteTypes } from './codegen'
5import { filePathToPattern, patternToRegex } from './matcher'
6import { resolveLayoutChain } from './nested-layouts'
7
8function 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
45export 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
131export function createRouter(baseDir: string, config?: RouterConfig): Router {
132 return new Router(baseDir, config)
133}
134
135export 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
144export 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}
packages/router/src/index.tsadded+58-0
Changes to packages/router/src/index.ts
@@ -0,0 +1,58 @@
1// File-based router
2export { createRouter, findErrorPage, formatRoutes, Router } from './file-router'
3
4// Route pattern matching
5export { filePathToPattern, matchRoute, patternToRegex } from './matcher'
6
7// Named routes
8export { defineRoute, defineRoutes, resetRoutes, route, setAppUrl } from './named-routes'
9
10// Middleware system
11export {
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
30export { resolveLayoutChain } from './nested-layouts'
31
32// Route type generation
33export { generateRouteTypes } from './codegen'
34
35// Client-side SPA navigation
36export { getRouterScript } from './client'
37
38// Types
39export 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'
packages/router/src/matcher.tsadded+71-0
Changes to packages/router/src/matcher.ts
@@ -0,0 +1,71 @@
1import path from 'node:path'
2
3export 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
28export 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
56export 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}
packages/router/src/middleware.tsadded+366-0
Changes to packages/router/src/middleware.ts
@@ -0,0 +1,366 @@
1import fs from 'node:fs'
2import path from 'node:path'
3import 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
25export 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
37export 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
51export 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
65function 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
83function 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
103export 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
131export 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
157export 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
187const middlewareRegistry = new Map<string, RouteMiddlewareDefinition>()
188
189export function registerMiddleware(name: string, middleware: RouteMiddlewareDefinition): void {
190 middlewareRegistry.set(name, middleware)
191}
192
193export function getMiddleware(name: string): RouteMiddlewareDefinition | undefined {
194 return middlewareRegistry.get(name)
195}
196
197export function hasMiddleware(name: string): boolean {
198 return middlewareRegistry.has(name)
199}
200
201export function clearMiddleware(): void {
202 middlewareRegistry.clear()
203}
204
205export function getMiddlewareNames(): string[] {
206 return Array.from(middlewareRegistry.keys())
207}
208
209// =============================================================================
210// Middleware Loading
211// =============================================================================
212
213export 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
253function 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
264export 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
279export 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
303export 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}
packages/router/src/named-routes.tsadded+73-0
Changes to packages/router/src/named-routes.ts
@@ -0,0 +1,73 @@
1interface RouteDefinition {
2 path: string
3 name?: string
4 params?: Record<string, any>
5}
6
7const routes: Record<string, RouteDefinition> = {}
8let appUrl: string = ''
9
10export function setAppUrl(url: string): void {
11 appUrl = url.replace(/\/$/, '')
12}
13
14export function defineRoute(name: string, routePath: string, params: Record<string, any> = {}): void {
15 routes[name] = { path: routePath, name, params }
16}
17
18export 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
29export 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
68export function resetRoutes(): void {
69 for (const key of Object.keys(routes)) {
70 delete routes[key]
71 }
72 appUrl = ''
73}
packages/router/src/nested-layouts.tsadded+20-0
Changes to packages/router/src/nested-layouts.ts
@@ -0,0 +1,20 @@
1import fs from 'node:fs'
2import path from 'node:path'
3
4export 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}
packages/router/src/types.tsadded+120-0
Changes to packages/router/src/types.ts
@@ -0,0 +1,120 @@
1export 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
12export interface RouteMatch {
13 route: Route
14 params: Record<string, string>
15}
16
17export interface RouterConfig {
18 pagesDir?: string
19 extensions?: string[]
20 layouts?: boolean
21 middleware?: boolean
22 trailingSlash?: boolean
23 caseSensitive?: boolean
24}
25
26export 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
34export 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
43export 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
50export 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
57export 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
69export type MiddlewareMode = 'universal' | 'server' | 'client'
70
71export type RouteMiddlewareHandler = (
72 context: MiddlewareContext,
73) => void | Promise<void> | NavigationResult | Promise<NavigationResult | void>
74
75export interface MiddlewareOptions {
76 mode?: MiddlewareMode
77}
78
79export interface RouteMiddlewareDefinition {
80 handler: RouteMiddlewareHandler
81 mode: MiddlewareMode
82}
83
84export interface NavigateToOptions {
85 replace?: boolean
86 external?: boolean
87 redirectCode?: 301 | 302 | 303 | 307 | 308
88}
89
90export interface NavigateToResult {
91 type: 'redirect'
92 path: string
93 options: NavigateToOptions
94}
95
96export interface NavigationError {
97 statusCode: number
98 message: string
99}
100
101export interface AbortNavigationResult {
102 type: 'abort'
103 error: NavigationError
104}
105
106export type NavigationResult = NavigateToResult | AbortNavigationResult
107
108export interface MiddlewareResult {
109 passed: boolean
110 redirect?: NavigateToResult
111 abort?: AbortNavigationResult
112 state: Record<string, unknown>
113 responseHeaders: Headers
114}
115
116interface RouteDefinition {
117 path: string
118 name?: string
119 params?: Record<string, any>
120}
packages/router/test/matcher.test.tsadded+110-0
Changes to packages/router/test/matcher.test.ts
@@ -0,0 +1,110 @@
1import { describe, expect, test } from 'bun:test'
2import { filePathToPattern, matchRoute, patternToRegex } from '../src/matcher'
3
4describe('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
36describe('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
73describe('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})
packages/router/test/middleware.test.tsadded+131-0
Changes to packages/router/test/middleware.test.ts
@@ -0,0 +1,131 @@
1import { afterEach, describe, expect, test } from 'bun:test'
2import {
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
15describe('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
30describe('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
44describe('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
57describe('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
75describe('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})
packages/router/test/named-routes.test.tsadded+50-0
Changes to packages/router/test/named-routes.test.ts
@@ -0,0 +1,50 @@
1import { afterEach, describe, expect, test } from 'bun:test'
2import { defineRoute, defineRoutes, resetRoutes, route, setAppUrl } from '../src/named-routes'
3
4describe('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})
packages/stx/src/client/index.tsmodified+2-107
Changes to packages/stx/src/client/index.ts
@@ -7,110 +7,5 @@
77export * from './router'
88export * from './directive'
99
10/**
11 * Generate the browser router script as a string
12 * This can be injected into pages or served as a static file
13 */
14export 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
11export { getRouterScript } from '@stacksjs/router'
packages/stx/src/env.tsmodified+3-17
Changes to packages/stx/src/env.ts
@@ -1,17 +1,3 @@
1import process from 'node:process'
2
3/**
4 * Returns true when running in production mode
5 */
6export 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 */
14export function isDevelopment(): boolean {
15 return !isProduction()
16 || process.env.STX_DEBUG === 'true'
17}
1// Re-export from @stacksjs/config
2export { defineEnv, isDevelopment, isProduction, isTest, loadEnvFile, validateEnv } from '@stacksjs/config'
3export type { EnvAccessor, EnvType, EnvValidationError, EnvVarDef, TypedEnv } from '@stacksjs/config'
packages/stx/src/plugin.tsmodified+1-1
Changes to packages/stx/src/plugin.ts
@@ -3,7 +3,7 @@ import type { StxOptions } from './'
33import path from 'node:path'
44import { buildWebComponents, cacheTemplate, checkCache, extractVariables, loadStxConfig, processDirectives } from './'
55import { devHelpers, errorLogger, safeExecuteAsync, StxFileError, StxRuntimeError } from './error-handling'
6import { isDevelopment } from './env'
6import { isDevelopment } from '@stacksjs/config'
77import { escapeHtml } from './expressions'
88import { performanceMonitor } from './performance-utils'
99
packages/stx/src/route-middleware.tsmodified+36-703
Changes to packages/stx/src/route-middleware.ts
@@ -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
32import fs from 'node:fs'
33import path from 'node:path'
34import type { PageMeta } from './head'
35
36// =============================================================================
37// Types
38// =============================================================================
39
40/**
41 * Route location information
42 */
43export 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 */
59export 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 */
73export 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 */
91export 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 */
105export 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 */
129export type MiddlewareMode = 'universal' | 'server' | 'client'
130
131/**
132 * Middleware handler function
133 */
134export type RouteMiddlewareHandler = (
135 context: MiddlewareContext
136) => void | Promise<void> | NavigationResult | Promise<NavigationResult | void>
137
138/**
139 * Middleware definition options
140 */
141export 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 */
149export interface RouteMiddlewareDefinition {
150 /** Middleware handler */
151 handler: RouteMiddlewareHandler
152 /** Execution mode */
153 mode: MiddlewareMode
154}
155
156/**
157 * Navigation redirect result
158 */
159export interface NavigateToResult {
160 type: 'redirect'
161 path: string
162 options: NavigateToOptions
163}
164
165/**
166 * Navigation abort result
167 */
168export interface AbortNavigationResult {
169 type: 'abort'
170 error: NavigationError
171}
172
173/**
174 * Result from middleware (redirect or abort)
175 */
176export type NavigationResult = NavigateToResult | AbortNavigationResult
177
178/**
179 * Options for navigateTo()
180 */
181export 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 */
193export 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 */
220export 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 */
247export 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 */
285export 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 */
302function 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 */
323function 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 */
360export 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 */
391export 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 */
420export 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 */
460const middlewareRegistry = new Map<string, RouteMiddlewareDefinition>()
461
462/**
463 * Register a middleware by name
464 */
465export function registerMiddleware(name: string, middleware: RouteMiddlewareDefinition): void {
466 middlewareRegistry.set(name, middleware)
467}
468
469/**
470 * Get a middleware by name
471 */
472export function getMiddleware(name: string): RouteMiddlewareDefinition | undefined {
473 return middlewareRegistry.get(name)
474}
475
476/**
477 * Check if a middleware is registered
478 */
479export function hasMiddleware(name: string): boolean {
480 return middlewareRegistry.has(name)
481}
482
483/**
484 * Clear all registered middleware
485 */
486export function clearMiddleware(): void {
487 middlewareRegistry.clear()
488}
489
490/**
491 * Get all registered middleware names
492 */
493export 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 */
504export 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 */
557function 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 */
571export 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 */
589export 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 */
616export 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 */
636export 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
2export {
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
20export 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'
packages/stx/src/router.tsmodified+10-266
Changes to packages/stx/src/router.ts
@@ -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
2import { matchRoute, Router as _Router } from '@stacksjs/router'
3import type { Route, RouterConfig } from '@stacksjs/router'
134
14import fs from 'node:fs'
15import path from 'node:path'
16
17export 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
30export interface RouteMatch {
31 route: Route
32 params: Record<string, string>
33}
5export type { Route, RouteMatch } from '@stacksjs/router'
6export { findErrorPage, formatRoutes, generateRouteTypes, matchRoute } from '@stacksjs/router'
347
8// Backwards-compatible createRouter that returns Route[] (original signature)
359export interface RouterOptions {
36 /** Directory containing page files (default: 'pages') */
3710 pagesDir?: string
38 /** File extensions to consider as pages (default: ['.stx']) */
3911 extensions?: string[]
4012}
4113
42/**
43 * Scans a directory recursively for page files
44 */
45function 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 */
88function 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 */
121function 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 */
15414export 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
19517}
19618
197/**
198 * Matches a URL pathname against routes
199 */
200export 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 */
22319export 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 */
232export 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 */
245export 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 */
272export 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
27822}
packages/stx/src/routes.tsmodified+5-106
Changes to packages/stx/src/routes.ts
@@ -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
2export { defineRoute, defineRoutes, resetRoutes, route, setAppUrl } from '@stacksjs/router'
73
4import { route } from '@stacksjs/router'
85import { safeEvaluateObject } from './safe-evaluator'
96
10interface RouteDefinition {
11 path: string
12 name?: string
13 params?: Record<string, any>
14}
15
16// Store for registered routes
17const routes: Record<string, RouteDefinition> = {}
18
19// Domain config
20let appUrl: string = ''
21
22/**
23 * Set the base URL for the application
24 */
25export 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 */
32export 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 */
39export 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
507/**
51 * Generate a URL for a named route with parameters
52 */
53export 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)
10210 */
10311export function processRouteDirectives(template: string): string {
10412 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 {
11725 })
11826}
11927
120/**
121 * Reset all routes (mainly for testing)
122 */
123export function resetRoutes(): void {
124 Object.keys(routes).forEach((key) => {
125 delete routes[key]
126 })
127 appUrl = ''
128}
tsconfig.jsonmodified+4-0
Changes to tsconfig.json
@@ -14,6 +14,10 @@
1414 "@stacksjs/sanitizer": ["./packages/sanitizer/src/index.ts"],
1515 "@stacksjs/iconify-core": ["./packages/iconify-core/src/index.ts"],
1616 "@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"],
1721 "bun-plugin-stx": ["./packages/bun-plugin/src/index.ts"],
1822 "bun-plugin-stx/client": ["./packages/bun-plugin/src/client.ts"]
1923 },