@bitstillery/mithril 3.0.0-AA
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/api/mount-redraw.ts +173 -0
- package/api/router-server.ts +88 -0
- package/api/router.ts +670 -0
- package/browser.ts +10 -0
- package/htm.ts +7 -0
- package/hyperscript.ts +8 -0
- package/index.ts +134 -0
- package/jsx.d.ts +16 -0
- package/mount.ts +10 -0
- package/package.json +66 -0
- package/pathname/build.ts +40 -0
- package/pathname/compileTemplate.ts +49 -0
- package/pathname/parse.ts +19 -0
- package/querystring/build.ts +22 -0
- package/querystring/parse.ts +50 -0
- package/redraw.ts +10 -0
- package/render/cachedAttrsIsStaticMap.ts +10 -0
- package/render/delayedRemoval.ts +1 -0
- package/render/domFor.ts +23 -0
- package/render/emptyAttrs.ts +2 -0
- package/render/fragment.ts +11 -0
- package/render/hyperscript.ts +125 -0
- package/render/hyperscriptVnode.ts +21 -0
- package/render/render.ts +1307 -0
- package/render/renderToString.ts +494 -0
- package/render/ssrState.ts +366 -0
- package/render/trust.ts +6 -0
- package/render/vnode.ts +131 -0
- package/route.ts +11 -0
- package/server/logger.ts +223 -0
- package/server/session.ts +87 -0
- package/server/ssr.ts +205 -0
- package/server/ssrLogger.ts +13 -0
- package/server.ts +101 -0
- package/signal.ts +320 -0
- package/ssrContext.ts +87 -0
- package/state.ts +1026 -0
- package/store.ts +457 -0
- package/util/censor.ts +46 -0
- package/util/decodeURIComponentSafe.ts +34 -0
- package/util/hasOwn.ts +2 -0
- package/util/next_tick.ts +45 -0
- package/util/ssr.ts +385 -0
- package/util/uri.ts +126 -0
- package/version.ts +3 -0
package/server/logger.ts
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Isomorphic logger with colors and structured logging.
|
|
3
|
+
* Works in both server (Bun/Node) and client (browser) environments.
|
|
4
|
+
* Provides info, debug, warning, and error log levels with appropriate formatting.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Detect if we're running in a browser environment
|
|
8
|
+
const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined'
|
|
9
|
+
|
|
10
|
+
// ANSI color codes for terminal output (server only)
|
|
11
|
+
const colors = {
|
|
12
|
+
reset: '\x1b[0m',
|
|
13
|
+
bright: '\x1b[1m',
|
|
14
|
+
dim: '\x1b[2m',
|
|
15
|
+
|
|
16
|
+
// Text colors
|
|
17
|
+
black: '\x1b[30m',
|
|
18
|
+
red: '\x1b[31m',
|
|
19
|
+
green: '\x1b[32m',
|
|
20
|
+
yellow: '\x1b[33m',
|
|
21
|
+
blue: '\x1b[34m',
|
|
22
|
+
magenta: '\x1b[35m',
|
|
23
|
+
cyan: '\x1b[36m',
|
|
24
|
+
white: '\x1b[37m',
|
|
25
|
+
|
|
26
|
+
// Background colors
|
|
27
|
+
bgBlack: '\x1b[40m',
|
|
28
|
+
bgRed: '\x1b[41m',
|
|
29
|
+
bgGreen: '\x1b[42m',
|
|
30
|
+
bgYellow: '\x1b[43m',
|
|
31
|
+
bgBlue: '\x1b[44m',
|
|
32
|
+
bgMagenta: '\x1b[45m',
|
|
33
|
+
bgCyan: '\x1b[46m',
|
|
34
|
+
bgWhite: '\x1b[47m',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ANSI colors for server/terminal. In browser we use %c for proper DevTools styling.
|
|
38
|
+
const enableColors =
|
|
39
|
+
!isBrowser &&
|
|
40
|
+
(typeof process === 'undefined' || !process?.env || (process.env.NO_COLOR !== '1' && process.env.NO_COLOR !== 'true'))
|
|
41
|
+
|
|
42
|
+
function colorize(text: string, color: string): string {
|
|
43
|
+
return enableColors ? `${color}${text}${colors.reset}` : text
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getTimestamp(): string {
|
|
47
|
+
const now = new Date()
|
|
48
|
+
const hours = String(now.getHours()).padStart(2, '0')
|
|
49
|
+
const minutes = String(now.getMinutes()).padStart(2, '0')
|
|
50
|
+
const seconds = String(now.getSeconds()).padStart(2, '0')
|
|
51
|
+
const ms = String(now.getMilliseconds()).padStart(3, '0')
|
|
52
|
+
return `${hours}:${minutes}:${seconds}.${ms}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatLevel(level: 'info' | 'debug' | 'warn' | 'error'): string {
|
|
56
|
+
const levelMap = {
|
|
57
|
+
info: colorize('info', colors.bright + colors.cyan),
|
|
58
|
+
debug: colorize('debug', colors.bright + colors.blue),
|
|
59
|
+
warn: colorize('warn', colors.bright + colors.yellow),
|
|
60
|
+
error: colorize('error', colors.bright + colors.red),
|
|
61
|
+
}
|
|
62
|
+
return levelMap[level]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface LogContext {
|
|
66
|
+
pathname?: string
|
|
67
|
+
method?: string
|
|
68
|
+
sessionId?: string
|
|
69
|
+
route?: string
|
|
70
|
+
module?: string // Module name (e.g., 'identity', 'order') - will be shown as [module] prefix
|
|
71
|
+
[key: string]: any
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
class Logger {
|
|
75
|
+
// Default prefix: [ssr] for server infrastructure, [app] for application code
|
|
76
|
+
private prefix: string = '[ssr]'
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Set the log prefix (default: '[ssr]' for infrastructure, '[app]' for application code)
|
|
80
|
+
*/
|
|
81
|
+
setPrefix(prefix: string): void {
|
|
82
|
+
this.prefix = prefix
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private formatMessage(level: 'info' | 'debug' | 'warn' | 'error', message: string, context?: LogContext): string {
|
|
86
|
+
const timestamp = colorize(getTimestamp(), colors.dim + colors.white)
|
|
87
|
+
const levelStr = formatLevel(level)
|
|
88
|
+
|
|
89
|
+
// Always use the set prefix (e.g., [app] or [ssr])
|
|
90
|
+
const prefixStr = colorize(
|
|
91
|
+
this.prefix,
|
|
92
|
+
this.prefix === '[ssr]' ? colors.bright + colors.magenta : colors.bright + colors.cyan,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
// Include module in message if provided
|
|
96
|
+
let displayMessage = message
|
|
97
|
+
if (context?.module) {
|
|
98
|
+
displayMessage = `[${context.module}] ${message}`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let contextStr = ''
|
|
102
|
+
if (context) {
|
|
103
|
+
const contextParts: string[] = []
|
|
104
|
+
if (context.method) {
|
|
105
|
+
contextParts.push(colorize(context.method, colors.cyan))
|
|
106
|
+
}
|
|
107
|
+
if (context.pathname) {
|
|
108
|
+
contextParts.push(colorize(context.pathname, colors.green))
|
|
109
|
+
}
|
|
110
|
+
if (context.route) {
|
|
111
|
+
contextParts.push(colorize(`route:${context.route}`, colors.blue))
|
|
112
|
+
}
|
|
113
|
+
if (context.sessionId) {
|
|
114
|
+
contextParts.push(colorize(`session:${context.sessionId.slice(0, 8)}...`, colors.dim + colors.white))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Add any additional context fields (excluding module which is shown in message)
|
|
118
|
+
for (const [key, value] of Object.entries(context)) {
|
|
119
|
+
if (!['method', 'pathname', 'route', 'sessionId', 'module'].includes(key)) {
|
|
120
|
+
contextParts.push(colorize(`${key}:${value}`, colors.dim + colors.white))
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (contextParts.length > 0) {
|
|
125
|
+
contextStr = ' ' + contextParts.join(' ')
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return `${timestamp} ${prefixStr} ${levelStr}${contextStr} ${displayMessage}`
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private formatContextForBrowser(context?: LogContext): string[] {
|
|
133
|
+
if (!context) return []
|
|
134
|
+
const parts: string[] = []
|
|
135
|
+
if (context.method) parts.push(`Method: ${context.method}`)
|
|
136
|
+
if (context.pathname) parts.push(`Path: ${context.pathname}`)
|
|
137
|
+
if (context.route) parts.push(`Route: ${context.route}`)
|
|
138
|
+
if (context.sessionId) parts.push(`Session: ${context.sessionId.slice(0, 8)}...`)
|
|
139
|
+
for (const [key, value] of Object.entries(context)) {
|
|
140
|
+
if (!['method', 'pathname', 'route', 'sessionId', 'module'].includes(key)) {
|
|
141
|
+
parts.push(`${key}: ${value}`)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return parts
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private logBrowser(
|
|
148
|
+
level: 'info' | 'debug' | 'warn' | 'error',
|
|
149
|
+
message: string,
|
|
150
|
+
context?: LogContext,
|
|
151
|
+
error?: Error | unknown,
|
|
152
|
+
): void {
|
|
153
|
+
const displayMessage = context?.module ? `[${context.module}] ${message}` : message
|
|
154
|
+
const prefixStyle = this.prefix === '[ssr]' ? 'color: #d946ef; font-weight: bold' : 'color: #3b82f6; font-weight: bold'
|
|
155
|
+
const levelStyles: Record<string, string> = {
|
|
156
|
+
info: 'color: #22d3ee; font-weight: bold',
|
|
157
|
+
debug: 'color: #4ade80; font-weight: bold',
|
|
158
|
+
warn: 'color: #fbbf24; font-weight: bold',
|
|
159
|
+
error: 'color: #ef4444; font-weight: bold',
|
|
160
|
+
}
|
|
161
|
+
const logFn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log
|
|
162
|
+
const contextParts = this.formatContextForBrowser(context)
|
|
163
|
+
if (contextParts.length > 0 || error) {
|
|
164
|
+
console.group(`%c${this.prefix}%c ${level}%c ${displayMessage}`, prefixStyle, levelStyles[level], 'color: inherit')
|
|
165
|
+
contextParts.forEach((part) => logFn(` ${part}`))
|
|
166
|
+
if (error instanceof Error && error.stack) {
|
|
167
|
+
console.error('Stack trace:', error.stack)
|
|
168
|
+
}
|
|
169
|
+
console.groupEnd()
|
|
170
|
+
} else {
|
|
171
|
+
logFn(`%c${this.prefix}%c ${level}%c ${displayMessage}`, prefixStyle, levelStyles[level], 'color: inherit')
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
info(message: string, context?: LogContext): void {
|
|
176
|
+
if (isBrowser) {
|
|
177
|
+
this.logBrowser('info', message, context)
|
|
178
|
+
} else {
|
|
179
|
+
console.log(this.formatMessage('info', message, context))
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
debug(message: string, context?: LogContext): void {
|
|
184
|
+
const shouldLog =
|
|
185
|
+
globalThis.__SSR_MODE__ || (isBrowser && typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production')
|
|
186
|
+
if (!shouldLog) return
|
|
187
|
+
|
|
188
|
+
if (isBrowser) {
|
|
189
|
+
this.logBrowser('debug', message, context)
|
|
190
|
+
} else {
|
|
191
|
+
console.log(this.formatMessage('debug', message, context))
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
warn(message: string, context?: LogContext): void {
|
|
196
|
+
if (isBrowser) {
|
|
197
|
+
this.logBrowser('warn', message, context)
|
|
198
|
+
} else {
|
|
199
|
+
console.warn(this.formatMessage('warn', message, context))
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
error(message: string, error?: Error | unknown, context?: LogContext): void {
|
|
204
|
+
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
205
|
+
const baseMessage = error ? `${message}: ${errorMessage}` : message
|
|
206
|
+
|
|
207
|
+
if (isBrowser) {
|
|
208
|
+
this.logBrowser('error', baseMessage, context, error)
|
|
209
|
+
} else {
|
|
210
|
+
console.error(this.formatMessage('error', baseMessage, context))
|
|
211
|
+
if (error instanceof Error && error.stack) {
|
|
212
|
+
const stackTrace = colorize(error.stack, colors.dim + colors.red)
|
|
213
|
+
console.error(stackTrace)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Default logger instance for application code (will be set to [app] by app initialization)
|
|
220
|
+
export const logger = new Logger()
|
|
221
|
+
|
|
222
|
+
// Export Logger class for creating custom instances
|
|
223
|
+
export {Logger}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Session management interfaces and default in-memory implementation
|
|
2
|
+
|
|
3
|
+
export interface SessionData {
|
|
4
|
+
userId: string | null
|
|
5
|
+
data: Record<string, any>
|
|
6
|
+
createdAt: Date
|
|
7
|
+
expiresAt: Date
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SessionStore {
|
|
11
|
+
getSession(sessionId: string): SessionData | null
|
|
12
|
+
updateSession(sessionId: string, data: Record<string, any>): void
|
|
13
|
+
createSession(userId: string | null): string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Default in-memory session store implementation
|
|
17
|
+
// In production, this would be replaced with Redis, database, etc.
|
|
18
|
+
export class MemorySessionStore implements SessionStore {
|
|
19
|
+
private sessions = new Map<string, SessionData>()
|
|
20
|
+
|
|
21
|
+
createSession(userId: string | null): string {
|
|
22
|
+
const sessionId = `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
|
23
|
+
const now = Date.now()
|
|
24
|
+
this.sessions.set(sessionId, {
|
|
25
|
+
userId,
|
|
26
|
+
data: {},
|
|
27
|
+
createdAt: new Date(now),
|
|
28
|
+
expiresAt: new Date(now + 1000 * 60 * 60 * 24), // 24 hours
|
|
29
|
+
})
|
|
30
|
+
return sessionId
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
getSession(sessionId: string): SessionData | null {
|
|
34
|
+
const session = this.sessions.get(sessionId)
|
|
35
|
+
if (!session) return null
|
|
36
|
+
|
|
37
|
+
// Check expiration
|
|
38
|
+
if (session.expiresAt.getTime() < Date.now()) {
|
|
39
|
+
this.sessions.delete(sessionId)
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return session
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
getSessionByUserId(userId: string): string | null {
|
|
47
|
+
for (const [sessionId, session] of this.sessions.entries()) {
|
|
48
|
+
if (session.userId === userId) {
|
|
49
|
+
// Check expiration
|
|
50
|
+
if (session.expiresAt.getTime() >= Date.now()) {
|
|
51
|
+
return sessionId
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
updateSession(sessionId: string, data: Record<string, any>): void {
|
|
59
|
+
const session = this.sessions.get(sessionId)
|
|
60
|
+
if (session) {
|
|
61
|
+
// Merge data with existing session data
|
|
62
|
+
// Handle nested structures like session_data
|
|
63
|
+
session.data = {...session.data, ...data}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
deleteSession(sessionId: string): void {
|
|
68
|
+
this.sessions.delete(sessionId)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Clean up expired sessions
|
|
72
|
+
cleanup(): void {
|
|
73
|
+
const now = Date.now()
|
|
74
|
+
for (const [sessionId, session] of this.sessions.entries()) {
|
|
75
|
+
if (session.expiresAt.getTime() < now) {
|
|
76
|
+
this.sessions.delete(sessionId)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Helper function to extract session ID from request cookies
|
|
83
|
+
export function extractSessionId(req: Request): string | null {
|
|
84
|
+
const cookies = req.headers.get('cookie') || ''
|
|
85
|
+
const sessionIdMatch = cookies.match(/sessionId=([^;]+)/)
|
|
86
|
+
return sessionIdMatch ? sessionIdMatch[1] : null
|
|
87
|
+
}
|
package/server/ssr.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import {readFile} from 'fs/promises'
|
|
2
|
+
|
|
3
|
+
import m from '../server'
|
|
4
|
+
import {runWithContextAsync} from '../ssrContext'
|
|
5
|
+
|
|
6
|
+
import {extractSessionId} from './session'
|
|
7
|
+
import {logger} from './ssrLogger'
|
|
8
|
+
|
|
9
|
+
import type {SessionStore} from './session'
|
|
10
|
+
import type {SSRAccessContext} from '../ssrContext'
|
|
11
|
+
|
|
12
|
+
declare global {
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
14
|
+
var __SSR_MODE__: boolean | undefined
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
16
|
+
var __SSR_URL__: string | undefined
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
globalThis.__SSR_MODE__ = true
|
|
20
|
+
|
|
21
|
+
export interface SSROptions {
|
|
22
|
+
routes: Record<string, any>
|
|
23
|
+
/** Create per-request context (store, stateRegistry, sessionId, sessionData). */
|
|
24
|
+
createRequestContext: (req: Request) => SSRAccessContext
|
|
25
|
+
/** Load store and register state for this request; runs inside request context. */
|
|
26
|
+
initRequestContext: (context: SSRAccessContext) => void | Promise<void>
|
|
27
|
+
getHtmlTemplate: () => Promise<string>
|
|
28
|
+
appSelector?: string // Default: '#app'
|
|
29
|
+
stateScriptId?: string // Default: '__SSR_STATE__'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface BunSSRConfig {
|
|
33
|
+
port: number
|
|
34
|
+
templatePath: string
|
|
35
|
+
templateRoute?: string // Default: '/__template__'
|
|
36
|
+
htmlTemplate: any // Bun route handler
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Helper function to get Bun's processed HTML template with HMR scripts
|
|
41
|
+
* This ensures HMR scripts and proper asset serving work in development
|
|
42
|
+
*/
|
|
43
|
+
export async function getBunProcessedTemplate(
|
|
44
|
+
port: number,
|
|
45
|
+
templatePath: string,
|
|
46
|
+
templateRoute: string = '/__template__',
|
|
47
|
+
): Promise<string> {
|
|
48
|
+
// Fetch from Bun's route handler to get processed template with HMR scripts
|
|
49
|
+
// Use a special route that Bun processes but we don't use for SSR
|
|
50
|
+
const templateUrl = `http://localhost:${port}${templateRoute}`
|
|
51
|
+
try {
|
|
52
|
+
const response = await fetch(templateUrl)
|
|
53
|
+
if (response.ok) {
|
|
54
|
+
return await response.text()
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// Fallback: read template file directly (won't have HMR, but will work)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return await readFile(templatePath, 'utf-8')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Helper to check if a pathname is a Bun internal asset (should be handled by Bun)
|
|
65
|
+
*/
|
|
66
|
+
export function shouldHandleBunAssets(pathname: string): boolean {
|
|
67
|
+
return pathname.startsWith('/_bun/')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Create Bun.serve() configuration with template route for HMR support
|
|
72
|
+
*/
|
|
73
|
+
export function createBunSSRConfig(config: BunSSRConfig) {
|
|
74
|
+
return {
|
|
75
|
+
port: config.port,
|
|
76
|
+
routes: {
|
|
77
|
+
// Let Bun process the HTML template for HMR and asset serving
|
|
78
|
+
// Use a special route that we don't use for SSR, so fetch handler can intercept SSR routes
|
|
79
|
+
[config.templateRoute || '/__template__']: config.htmlTemplate,
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Create SSR response with serialized state and session cookie.
|
|
86
|
+
* Runs the whole request inside runWithContextAsync so getSSRContext() returns
|
|
87
|
+
* this request's context; initRequestContext then loads the store and registers state.
|
|
88
|
+
*/
|
|
89
|
+
export async function createSSRResponse(pathname: string, req: Request, options: SSROptions): Promise<Response> {
|
|
90
|
+
const context = options.createRequestContext(req)
|
|
91
|
+
|
|
92
|
+
return runWithContextAsync(context, async () => {
|
|
93
|
+
try {
|
|
94
|
+
await options.initRequestContext(context)
|
|
95
|
+
|
|
96
|
+
globalThis.__SSR_URL__ = req.url
|
|
97
|
+
|
|
98
|
+
const routeCount = Object.keys(options.routes).length
|
|
99
|
+
logger.debug('resolving route', {pathname, routeCount, routeExists: !!options.routes[pathname]})
|
|
100
|
+
|
|
101
|
+
const result = await m.route.resolve(pathname, options.routes, m.renderToString)
|
|
102
|
+
|
|
103
|
+
const appHtml = typeof result === 'string' ? result : result.html
|
|
104
|
+
const serializedState = typeof result === 'string' ? {} : result.state
|
|
105
|
+
const htmlLength = appHtml?.length || 0
|
|
106
|
+
|
|
107
|
+
logger.debug('route resolved', {pathname, htmlLength, resultType: typeof result === 'string' ? 'string' : 'object'})
|
|
108
|
+
|
|
109
|
+
if (!appHtml || appHtml.trim() === '' || appHtml.trim() === '<div></div>') {
|
|
110
|
+
logger.warn('empty html rendered', {pathname})
|
|
111
|
+
} else {
|
|
112
|
+
logger.info(`get ${pathname} → ${htmlLength} chars`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let html = await options.getHtmlTemplate()
|
|
116
|
+
|
|
117
|
+
const appSelector = options.appSelector || '#app'
|
|
118
|
+
|
|
119
|
+
let openingTagPattern: string
|
|
120
|
+
if (appSelector.startsWith('#')) {
|
|
121
|
+
const id = appSelector.slice(1)
|
|
122
|
+
openingTagPattern = `<([a-zA-Z][a-zA-Z0-9]*)\\s+id="${id}"([^>]*)>`
|
|
123
|
+
} else if (appSelector.startsWith('.')) {
|
|
124
|
+
const className = appSelector.slice(1)
|
|
125
|
+
openingTagPattern = `<([a-zA-Z][a-zA-Z0-9]*)([^>]*\\s+class="[^"]*\\b${className}\\b[^"]*"[^>]*)>`
|
|
126
|
+
} else {
|
|
127
|
+
openingTagPattern = `<${appSelector}([^>]*)>`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const fullPattern = new RegExp(`(${openingTagPattern})\\s*</([a-zA-Z][a-zA-Z0-9]*)>`, 'i')
|
|
131
|
+
html = html.replace(fullPattern, (_match, openingTag, closingTagName) => {
|
|
132
|
+
const tagMatch = openingTag.match(/^<([a-zA-Z][a-zA-Z0-9]*)/)
|
|
133
|
+
const elementName = tagMatch ? tagMatch[1] : closingTagName || 'div'
|
|
134
|
+
return `${openingTag}${appHtml}</${elementName}>`
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const stateScriptId = options.stateScriptId || '__SSR_STATE__'
|
|
138
|
+
const stateScript = `<script id="${stateScriptId}" type="application/json">${JSON.stringify(serializedState)}</script>`
|
|
139
|
+
html = html.replace('</head>', `${stateScript}</head>`)
|
|
140
|
+
|
|
141
|
+
const sessionId = context.sessionId ?? ''
|
|
142
|
+
|
|
143
|
+
return new Response(html, {
|
|
144
|
+
headers: {
|
|
145
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
146
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
147
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
148
|
+
'Set-Cookie': `sessionId=${sessionId}; Path=/; HttpOnly; SameSite=Lax`,
|
|
149
|
+
},
|
|
150
|
+
})
|
|
151
|
+
} catch (error) {
|
|
152
|
+
logger.error('ssr request failed', error, {
|
|
153
|
+
pathname,
|
|
154
|
+
method: req.method,
|
|
155
|
+
sessionId: context.sessionId,
|
|
156
|
+
})
|
|
157
|
+
return new Response('Internal Server Error', {status: 500})
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Create session update API handler factory
|
|
164
|
+
* Returns a handler function for POST /api/session requests
|
|
165
|
+
*/
|
|
166
|
+
export function createSessionUpdateHandler(
|
|
167
|
+
sessionStore: SessionStore,
|
|
168
|
+
extractSessionIdFn: (req: Request) => string | null = extractSessionId,
|
|
169
|
+
): (req: Request) => Promise<Response> {
|
|
170
|
+
return async (req: Request): Promise<Response> => {
|
|
171
|
+
const sessionId = extractSessionIdFn(req)
|
|
172
|
+
|
|
173
|
+
if (!sessionId) {
|
|
174
|
+
return new Response('No session ID', {status: 401})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
const body = await req.json()
|
|
179
|
+
// Blueprint extracts { session: {...} }, so body.session contains the actual session data
|
|
180
|
+
// body structure: { session: { user: {...}, serverData: '...', lastServerUpdate: ... } }
|
|
181
|
+
const sessionData = body.session_data || body.session || {} // Support both session_data and session for compatibility
|
|
182
|
+
|
|
183
|
+
// Update session store with new session state
|
|
184
|
+
// Store under session_data key in sessionStore.data
|
|
185
|
+
// sessionData structure: { user: {...}, serverData: '...', lastServerUpdate: ... }
|
|
186
|
+
sessionStore.updateSession(sessionId, {session_data: sessionData})
|
|
187
|
+
|
|
188
|
+
logger.debug('session updated', {
|
|
189
|
+
method: req.method,
|
|
190
|
+
sessionId,
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
return new Response(JSON.stringify({success: true}), {
|
|
194
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
195
|
+
headers: {'Content-Type': 'application/json'},
|
|
196
|
+
})
|
|
197
|
+
} catch (error) {
|
|
198
|
+
logger.error('failed to update session', error, {
|
|
199
|
+
method: req.method,
|
|
200
|
+
sessionId,
|
|
201
|
+
})
|
|
202
|
+
return new Response('Internal Server Error', {status: 500})
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSR-specific logger instance.
|
|
3
|
+
* SSR infrastructure code should import {logger} from this file
|
|
4
|
+
* to get a logger instance with [ssr] prefix.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {Logger} from './logger'
|
|
8
|
+
|
|
9
|
+
// Create SSR logger instance with [ssr] prefix
|
|
10
|
+
const logger = new Logger()
|
|
11
|
+
logger.setPrefix('[ssr]')
|
|
12
|
+
|
|
13
|
+
export {logger}
|
package/server.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import hyperscript from './render/hyperscript'
|
|
2
|
+
import {logger} from './server/logger'
|
|
3
|
+
import {version} from './version'
|
|
4
|
+
import {renderToStringFactory} from './render/renderToString'
|
|
5
|
+
|
|
6
|
+
import routerFactory from './api/router'
|
|
7
|
+
import parseQueryString from './querystring/parse'
|
|
8
|
+
import buildQueryString from './querystring/build'
|
|
9
|
+
import parsePathname from './pathname/parse'
|
|
10
|
+
import buildPathname from './pathname/build'
|
|
11
|
+
import Vnode from './render/vnode'
|
|
12
|
+
import censor from './util/censor'
|
|
13
|
+
|
|
14
|
+
import type {MithrilStatic, Hyperscript} from './index'
|
|
15
|
+
import type {Redraw} from './api/mount-redraw'
|
|
16
|
+
|
|
17
|
+
logger.debug(`mithril ssr v${version}`)
|
|
18
|
+
|
|
19
|
+
// Create server-side renderer
|
|
20
|
+
const {renderToString, renderToStringSync} = renderToStringFactory()
|
|
21
|
+
|
|
22
|
+
// Create isomorphic router instance (null window for server, mock mountRedraw)
|
|
23
|
+
const router = routerFactory(null, {
|
|
24
|
+
mount: () => {
|
|
25
|
+
throw new Error('m.mount is not available on server. Use m.route.resolve instead.')
|
|
26
|
+
},
|
|
27
|
+
redraw: () => {
|
|
28
|
+
throw new Error('m.redraw is not available on server.')
|
|
29
|
+
},
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// Set prefix to empty string for pathname-based routing (not hash-based)
|
|
33
|
+
router.prefix = ''
|
|
34
|
+
|
|
35
|
+
// Expose resolve method and Link component for server-side routing
|
|
36
|
+
const routerServer = {
|
|
37
|
+
resolve: router.resolve.bind(router),
|
|
38
|
+
Link: router.Link,
|
|
39
|
+
prefix: router.prefix,
|
|
40
|
+
get: router.get.bind(router),
|
|
41
|
+
set: router.set.bind(router),
|
|
42
|
+
param: router.param.bind(router),
|
|
43
|
+
params: router.params,
|
|
44
|
+
link: router.link.bind(router),
|
|
45
|
+
redirect: router.redirect.bind(router),
|
|
46
|
+
REDIRECT: router.REDIRECT,
|
|
47
|
+
SKIP: router.SKIP,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Server-side Mithril instance
|
|
51
|
+
const mServer: MithrilStatic &
|
|
52
|
+
Hyperscript & {
|
|
53
|
+
renderToString: typeof renderToString
|
|
54
|
+
renderToStringSync: typeof renderToStringSync
|
|
55
|
+
route: typeof routerServer
|
|
56
|
+
} = function m(this: any) {
|
|
57
|
+
return hyperscript.apply(this, arguments as any)
|
|
58
|
+
} as unknown as MithrilStatic &
|
|
59
|
+
Hyperscript & {
|
|
60
|
+
renderToString: typeof renderToString
|
|
61
|
+
renderToStringSync: typeof renderToStringSync
|
|
62
|
+
route: typeof routerServer
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
mServer.m = hyperscript as Hyperscript
|
|
66
|
+
mServer.trust = hyperscript.trust
|
|
67
|
+
mServer.fragment = hyperscript.fragment
|
|
68
|
+
mServer.Fragment = '['
|
|
69
|
+
mServer.renderToString = renderToString
|
|
70
|
+
mServer.renderToStringSync = renderToStringSync
|
|
71
|
+
mServer.route = routerServer as any
|
|
72
|
+
mServer.parseQueryString = parseQueryString
|
|
73
|
+
mServer.buildQueryString = buildQueryString
|
|
74
|
+
mServer.parsePathname = parsePathname
|
|
75
|
+
mServer.buildPathname = buildPathname
|
|
76
|
+
mServer.vnode = Vnode
|
|
77
|
+
mServer.censor = censor
|
|
78
|
+
|
|
79
|
+
// Placeholder implementations for server (not used but needed for type compatibility)
|
|
80
|
+
mServer.mount = () => {
|
|
81
|
+
throw new Error('m.mount is not available on server. Use m.renderToString instead.')
|
|
82
|
+
}
|
|
83
|
+
mServer.render = () => {
|
|
84
|
+
throw new Error('m.render is not available on server. Use m.renderToString instead.')
|
|
85
|
+
}
|
|
86
|
+
mServer.redraw = Object.assign(
|
|
87
|
+
() => {
|
|
88
|
+
throw new Error('m.redraw is not available on server.')
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
sync: () => {
|
|
92
|
+
throw new Error('m.redraw.sync is not available on server.')
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
) as Redraw
|
|
96
|
+
|
|
97
|
+
export default mServer
|
|
98
|
+
|
|
99
|
+
// Export SSR server utilities
|
|
100
|
+
export * from './server/session'
|
|
101
|
+
export * from './server/ssr'
|