@live-change/server 0.9.121 → 0.9.123

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,144 @@
1
+ import vm from 'vm'
2
+
3
+ class RenderContext {
4
+ constructor(settings, baseContext, script) {
5
+ this.settings = settings
6
+ this.root = this.settings.root || process.cwd()
7
+ this.timeouts = new Set()
8
+ this.intervals = new Set()
9
+ this.running = false
10
+ this.script = script
11
+ this.useCount = 0
12
+ this.maxUses = this.settings.contextMaxUses || 128
13
+
14
+ // Create wrapped setTimeout and setInterval that track IDs
15
+ const originalSetTimeout = setTimeout
16
+ const originalSetInterval = setInterval
17
+ const originalClearTimeout = clearTimeout
18
+ const originalClearInterval = clearInterval
19
+ const originalNextTick = process.nextTick
20
+
21
+ const wrappedSetTimeout = (callback, delay, ...args) => {
22
+ if(!this.running) return
23
+ const id = originalSetTimeout(callback, delay, ...args)
24
+ this.timeouts.add(id)
25
+ return id
26
+ }
27
+
28
+ const wrappedSetInterval = (callback, delay, ...args) => {
29
+ if(!this.running) return
30
+ const id = originalSetInterval(callback, delay, ...args)
31
+ this.intervals.add(id)
32
+ return id
33
+ }
34
+
35
+ const wrappedClearTimeout = (id) => {
36
+ if(!this.running) return
37
+ this.timeouts.delete(id)
38
+ return originalClearTimeout(id)
39
+ }
40
+
41
+ const wrappedClearInterval = (id) => {
42
+ if(!this.running) return
43
+ this.intervals.delete(id)
44
+ return originalClearInterval(id)
45
+ }
46
+
47
+ const wrappedNextTick = (callback, ...args) => {
48
+ if (!this.running) return
49
+ return originalNextTick(callback, ...args)
50
+ }
51
+
52
+ this.contextObject = {
53
+ //...globalThis,
54
+ ...baseContext,
55
+ exports: {},
56
+ setTimeout: wrappedSetTimeout,
57
+ setInterval: wrappedSetInterval,
58
+ clearTimeout: wrappedClearTimeout,
59
+ clearInterval: wrappedClearInterval,
60
+ //process: process,
61
+ process: {
62
+ env: {
63
+ NODE_ENV: 'production'
64
+ },
65
+ stdout: process.stdout,
66
+ stderr: process.stderr,
67
+ nextTick: wrappedNextTick
68
+ }
69
+ }
70
+
71
+ this.vmContext = vm.createContext(this.contextObject, {
72
+ name: 'SSR '+(new Date().toISOString()),
73
+ ///microtaskMode: 'afterEvaluate'
74
+ })
75
+
76
+ // Start context and execute script immediately
77
+ this.start()
78
+ this.script.runInContext(this.vmContext)
79
+ }
80
+
81
+ runScript(script) {
82
+ // Execute script in the existing context
83
+ // Note: this doesn't reset exports, so previous state is preserved
84
+ script.runInContext(this.vmContext)
85
+ return this.contextObject
86
+ }
87
+
88
+ // Increment use count and check if context should be replaced
89
+ incrementUse() {
90
+ this.useCount++
91
+ }
92
+
93
+ shouldReplace() {
94
+ return this.useCount >= this.maxUses
95
+ }
96
+
97
+ getExports() {
98
+ return this.contextObject.exports
99
+ }
100
+
101
+ // Start the context - allow nextTick operations
102
+ start() {
103
+ this.running = true
104
+ }
105
+
106
+ // Stop the context - prevent nextTick operations and clear timers
107
+ stop() {
108
+ this.running = false
109
+ this.clearTimeouts()
110
+ }
111
+
112
+ isRunning() {
113
+ return this.running
114
+ }
115
+
116
+ // Clear all active timeouts and intervals
117
+ clearTimeouts() {
118
+ // Clear all timeouts
119
+ for (const timeoutId of this.timeouts) {
120
+ clearTimeout(timeoutId)
121
+ }
122
+ this.timeouts.clear()
123
+
124
+ // Clear all intervals
125
+ for (const intervalId of this.intervals) {
126
+ clearInterval(intervalId)
127
+ }
128
+ this.intervals.clear()
129
+ }
130
+
131
+ // Get stats about active timers (useful for debugging)
132
+ getTimerStats() {
133
+ return {
134
+ running: this.running,
135
+ timeouts: this.timeouts.size,
136
+ intervals: this.intervals.size,
137
+ useCount: this.useCount,
138
+ maxUses: this.maxUses,
139
+ usagePercentage: Math.round((this.useCount / this.maxUses) * 100)
140
+ }
141
+ }
142
+ }
143
+
144
+ export default RenderContext
package/lib/Renderer.js CHANGED
@@ -6,12 +6,17 @@ import renderTemplate from './renderTemplate.js'
6
6
  import { SitemapStream } from 'sitemap'
7
7
  import vm from 'vm'
8
8
  import { createRequire } from 'module'
9
+ import RenderContext from './RenderContext.js'
9
10
 
10
11
  class Renderer {
11
12
  constructor(manifest, settings) {
12
13
  this.manifest = manifest
13
14
  this.settings = settings
14
15
  this.root = this.settings.root || process.cwd()
16
+ this.contextPool = []
17
+ this.waitingQueue = []
18
+ this.poolSize = this.settings.contextPoolSize || 2
19
+ this.maxQueueSize = this.settings.maxQueueSize || 20 // 10x pool size seems reasonable
15
20
  }
16
21
 
17
22
  async start() {
@@ -29,11 +34,94 @@ class Renderer {
29
34
  require: createRequire(serverEntryPath),
30
35
  __dirname: path.dirname(serverEntryPath),
31
36
  }
37
+
38
+ // Create pool of render contexts
39
+ for (let i = 0; i < this.poolSize; i++) {
40
+ const context = new RenderContext(this.settings, this.baseContext, this.script)
41
+ this.contextPool.push(context)
42
+ }
43
+
32
44
  const templatePath = path.resolve(this.root, this.settings.templatePath ?? './dist/client/index.html')
33
45
  this.template = await fs.promises.readFile(templatePath, { encoding: 'utf-8' })
34
46
  }
35
47
  }
36
48
 
49
+ // Get a context from pool, wait if none available
50
+ async getContext() {
51
+ return new Promise((resolve, reject) => {
52
+ if (this.contextPool.length > 0) {
53
+ // Context available immediately
54
+ const context = this.contextPool.pop()
55
+ resolve(context)
56
+ } else {
57
+ // Check queue limit before adding to waiting queue
58
+ if (this.waitingQueue.length >= this.maxQueueSize) {
59
+ reject(new Error(`Render queue is full (${this.maxQueueSize} waiting requests). Server overloaded.`))
60
+ return
61
+ }
62
+ // No context available, add to waiting queue
63
+ this.waitingQueue.push(resolve)
64
+ }
65
+ })
66
+ }
67
+
68
+ // Release context back to pool and serve waiting requests
69
+ releaseContext(context) {
70
+ if (this.waitingQueue.length > 0) {
71
+ // Someone is waiting, give context directly to them
72
+ const resolve = this.waitingQueue.shift()
73
+ resolve(context)
74
+ } else {
75
+ // No one waiting, return to pool
76
+ this.contextPool.push(context)
77
+ }
78
+ }
79
+
80
+ // Higher-order function for context management with timeout
81
+ async withContext(callback) {
82
+ if (this.settings.dev) {
83
+ // In dev mode, no context needed
84
+ return await callback(null)
85
+ } else {
86
+ const context = await this.getContext()
87
+ const timeout = this.settings.renderTimeout || 10000 // 10 seconds default
88
+ let isTimedOut = false
89
+ let shouldReplaceContext = false
90
+
91
+ // Increment usage counter
92
+ context.incrementUse()
93
+
94
+ try {
95
+ // Race between callback and timeout
96
+ const result = await Promise.race([
97
+ callback(context),
98
+ new Promise((_, reject) => {
99
+ setTimeout(() => {
100
+ isTimedOut = true
101
+ reject(new Error(`Render timeout after ${timeout}ms`))
102
+ }, timeout)
103
+ })
104
+ ])
105
+
106
+ return result
107
+ } catch (error) {
108
+ // Mark context for replacement on any error
109
+ shouldReplaceContext = true
110
+ throw error
111
+ } finally {
112
+ if (isTimedOut || shouldReplaceContext || context.shouldReplace()) {
113
+ // Stop the context and create a replacement
114
+ context.stop()
115
+ const newContext = new RenderContext(this.settings, this.baseContext, this.script)
116
+ this.releaseContext(newContext)
117
+ } else {
118
+ // Normal release back to pool
119
+ this.releaseContext(context)
120
+ }
121
+ }
122
+ }
123
+ }
124
+
37
125
  async setupVite() {
38
126
  this.vite = await vite.createServer({
39
127
  root: this.root,
@@ -54,38 +142,46 @@ class Renderer {
54
142
 
55
143
  async renderPage(params) {
56
144
  const { url, headers, dao, clientIp, credentials, windowId, version, now, domain } = params
145
+ const startTime = Date.now()
57
146
 
58
- const render = await this.getRenderFunction()
59
- const { html: appHtml, modules, data, meta, response } = await render(params)
60
-
61
- //console.log("META:", meta)
62
-
63
- const preloadLinks = this.renderPreloadLinks(modules)
64
-
65
- const appDataScript = ` <script>` +
66
- ` window.__DAO_CACHE__= ${serialize(data, { isJSON: true })}\n`+
67
- (this.settings.fastAuth ? ''
68
- : ` window.__CREDENTIALS__= ${serialize(credentials, { isJSON: true })}\n`)+
69
- ` window.__VERSION__ = ${serialize(version, { isJSON: true })}\n`+
70
- ` window.__WINDOW_ID__ = ${serialize(windowId, { isJSON: true })}\n`+
71
- ` window.__NOW__ = ${serialize(now, { isJSON: true })}\n`+
72
- ` console.info("SOFTWARE VERSION:" + window.__VERSION__)\n`+
73
- `</script>\n`
74
-
75
- const template = await this.prepareTemplate(url)
76
-
77
- const html = renderTemplate(template, {
78
- '<html>': (meta.htmlAttrs ? `<html ${meta.htmlAttrs}>` : '<html>'),
79
- '<head>': (meta.headAttrs ? `<head ${meta.headAttrs}>` : '<head>'),
80
- '<!--head-->': (meta.headTags || '') + '\n' + preloadLinks,
81
- '<body>': (meta.bodyAttrs ? `<body ${meta.bodyAttrs}>` : '<body>') + '\n' + (meta.bodyPrepend || ''),
82
- '<!--body-tags-open-->': meta.bodyTagsOpen || '',
83
- '<!--body-tags-->': meta.bodyTags || '',
84
- '<!--app-html-->': appHtml,
85
- '<!--app-data-->': appDataScript
86
- })
147
+ try {
148
+ // Use withContext only for the actual rendering part
149
+ const { html: appHtml, modules, data, meta, response } = await this.withContext(async (context) => {
150
+ const render = await this.getRenderFunction(context)
151
+ return await render(params)
152
+ })
153
+
154
+ //console.log("META:", meta)
155
+
156
+ const preloadLinks = this.renderPreloadLinks(modules)
87
157
 
88
- return { html, response }
158
+ const appDataScript = ` <script>` +
159
+ ` window.__DAO_CACHE__= ${serialize(data, { isJSON: true })}\n`+
160
+ (this.settings.fastAuth ? ''
161
+ : ` window.__CREDENTIALS__= ${serialize(credentials, { isJSON: true })}\n`)+
162
+ ` window.__VERSION__ = ${serialize(version, { isJSON: true })}\n`+
163
+ ` window.__WINDOW_ID__ = ${serialize(windowId, { isJSON: true })}\n`+
164
+ ` window.__NOW__ = ${serialize(now, { isJSON: true })}\n`+
165
+ ` console.info("SOFTWARE VERSION:" + window.__VERSION__)\n`+
166
+ `</script>\n`
167
+
168
+ const template = await this.prepareTemplate(url)
169
+
170
+ const html = renderTemplate(template, {
171
+ '<html>': (meta.htmlAttrs ? `<html ${meta.htmlAttrs}>` : '<html>'),
172
+ '<head>': (meta.headAttrs ? `<head ${meta.headAttrs}>` : '<head>'),
173
+ '<!--head-->': (meta.headTags || '') + '\n' + preloadLinks,
174
+ '<body>': (meta.bodyAttrs ? `<body ${meta.bodyAttrs}>` : '<body>') + '\n' + (meta.bodyPrepend || ''),
175
+ '<!--body-tags-open-->': meta.bodyTagsOpen || '',
176
+ '<!--body-tags-->': meta.bodyTags || '',
177
+ '<!--app-html-->': appHtml,
178
+ '<!--app-data-->': appDataScript
179
+ })
180
+
181
+ return { html, response }
182
+ } finally {
183
+ this.logRenderStats(url, startTime)
184
+ }
89
185
  }
90
186
 
91
187
  renderPreloadLink(file) {
@@ -127,61 +223,42 @@ class Renderer {
127
223
  return template
128
224
  }
129
225
 
130
- async createRenderContext() {
131
- const contextObject = {
132
- //...globalThis,
133
- ...this.baseContext,
134
- exports: {},
135
- //process: process,
136
- process: {
137
- env: {
138
- NODE_ENV: 'production'
139
- },
140
- stdout: process.stdout,
141
- stderr: process.stderr,
142
- }
143
- }
144
- const requestContext = vm.createContext(contextObject, {
145
- name: 'SSR Render '+(new Date().toISOString()),
146
- ///microtaskMode: 'afterEvaluate'
147
- })
148
- this.script.runInContext(requestContext)
149
- return contextObject
150
- }
151
-
152
- async getRenderFunction() {
226
+ async getRenderFunction(context = null) {
153
227
  if(this.settings.dev) {
154
228
  /// Reload every request
155
229
  const entryPath = path.resolve(this.root, this.settings.serverEntry || 'src/entry-server.js')
156
230
  return (await this.vite.ssrLoadModule(entryPath)).render
157
231
  } else {
158
- const context = await this.createRenderContext()
159
- return context.exports.render
232
+ return context.getExports().render
160
233
  }
161
234
  }
162
235
 
163
- async getSitemapRenderFunction() {
236
+ async getSitemapRenderFunction(context = null) {
164
237
  if(this.settings.dev) {
165
238
  /// Reload every request
166
239
  const entryPath = path.resolve(this.root, this.settings.serverEntry || 'src/entry-server.js')
167
240
  return (await this.vite.ssrLoadModule(entryPath)).sitemap
168
241
  } else {
169
- const context = await this.createRenderContext()
170
- return context.exports.sitemap
242
+ return context.getExports().sitemap
171
243
  }
172
244
  }
173
245
 
174
246
  async renderSitemap(params, res) {
175
- try {
176
- const { url, headers, dao, clientIp, credentials, windowId, version, now, domain } = params
247
+ const { url, headers, dao, clientIp, credentials, windowId, version, now, domain } = params
248
+ const startTime = Date.now()
177
249
 
250
+ try {
178
251
  res.header('Content-Type', 'application/xml')
179
252
  res.status(200)
180
253
  const smStream = new SitemapStream({
181
254
  hostname: (process.env.BASE_HREF ?? (domain ? `https://${domain}` : "https://sitemap.com"))+'/'
182
255
  })
183
256
  smStream.pipe(res)
184
- const sitemapFunction = await this.getSitemapRenderFunction()
257
+
258
+ // Use withContext only for the actual sitemap function execution
259
+ const sitemapFunction = await this.withContext(async (context) => {
260
+ return await this.getSitemapRenderFunction(context)
261
+ })
185
262
 
186
263
  function write(routeInfo) {
187
264
  smStream.write(routeInfo)
@@ -195,6 +272,8 @@ class Renderer {
195
272
  res.status(503)
196
273
  res.end(`<h4>Internal server error</h4><pre>${err.stack || err.code || err}</pre>`)
197
274
  //if(profileOp) await profileLog.end({ ...profileOp, state: 'error', error: err })
275
+ } finally {
276
+ this.logRenderStats(`${url} (sitemap)`, startTime)
198
277
  }
199
278
  }
200
279
 
@@ -206,11 +285,47 @@ class Renderer {
206
285
  }
207
286
  }
208
287
 
288
+ // Log render statistics in compact format
289
+ logRenderStats(url, startTime) {
290
+ if (this.settings.dev) return // Skip logging in dev mode
291
+
292
+ const renderTime = Date.now() - startTime
293
+ const stats = this.getPoolStats()
294
+
295
+ // Get context usage info if available
296
+ let contextInfo = ''
297
+ for(const context of this.contextPool) {
298
+ const contextStats = context.getTimerStats()
299
+ contextInfo += `ctx:${contextStats.useCount}/${contextStats.maxUses} `
300
+ }
301
+
302
+ // Compact one-line format: URL | time | pool status | queue status | context usage
303
+ console.log(`RENDER ${url} | ${renderTime}ms | pool:${stats.availableContexts}/${stats.poolSize} | queue:${stats.waitingRequests}/${stats.maxQueueSize} | ${contextInfo}`)
304
+ }
305
+
306
+ // Get pool statistics for monitoring
307
+ getPoolStats() {
308
+ return {
309
+ poolSize: this.poolSize,
310
+ maxQueueSize: this.maxQueueSize,
311
+ availableContexts: this.contextPool.length,
312
+ waitingRequests: this.waitingQueue.length,
313
+ activeContexts: this.poolSize - this.contextPool.length,
314
+ queueUtilization: Math.round((this.waitingQueue.length / this.maxQueueSize) * 100)
315
+ }
316
+ }
317
+
209
318
  async close() {
210
319
  if(this.vite) {
211
320
  console.log("VITE CLOSE!!!")
212
321
  await this.vite.close()
213
322
  }
323
+ // Clear any pending timeouts/intervals from all contexts in pool
324
+ for (const context of this.contextPool) {
325
+ context.clearTimeouts()
326
+ }
327
+ // Clear waiting queue
328
+ this.waitingQueue = []
214
329
  }
215
330
 
216
331
  }
package/lib/SsrServer.js CHANGED
@@ -101,7 +101,7 @@ class SsrServer {
101
101
  }
102
102
  })
103
103
 
104
- this.express.get('/sitemap.xml', async (req, res) => {
104
+ this.express.get('/sitemap.xml', async (req, res) => {
105
105
  if(this.settings.spa) {
106
106
  res.status(404).end()
107
107
  return
@@ -132,6 +132,7 @@ class SsrServer {
132
132
  }
133
133
  })
134
134
  this.express.use('*', async (req, res) => {
135
+ console.log("RENDERING PAGE", req.originalUrl)
135
136
  if(fbRedirect(req, res)) return
136
137
  if(this.settings.spa) {
137
138
  if(this.settings.dev) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@live-change/server",
3
- "version": "0.9.121",
3
+ "version": "0.9.123",
4
4
  "description": "Live Change Framework - server",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -22,12 +22,12 @@
22
22
  "type": "module",
23
23
  "homepage": "https://github.com/live-change/live-change-stack",
24
24
  "dependencies": {
25
- "@live-change/dao": "^0.9.121",
26
- "@live-change/dao-sockjs": "^0.9.121",
27
- "@live-change/db-server": "^0.9.121",
28
- "@live-change/framework": "^0.9.121",
25
+ "@live-change/dao": "^0.9.123",
26
+ "@live-change/dao-sockjs": "^0.9.123",
27
+ "@live-change/db-server": "^0.9.123",
28
+ "@live-change/framework": "^0.9.123",
29
29
  "@live-change/sockjs": "0.4.1",
30
- "@live-change/uid": "^0.9.121",
30
+ "@live-change/uid": "^0.9.123",
31
31
  "dotenv": "^17.2.1",
32
32
  "express": "^4.18.2",
33
33
  "express-static-gzip": "2.1.7",
@@ -39,5 +39,5 @@
39
39
  "websocket": "^1.0.34",
40
40
  "yargs": "^17.7.2"
41
41
  },
42
- "gitHead": "4052c94c9fcd898af1ed1ef7d497e2fc807b5cb0"
42
+ "gitHead": "302f2ca92788cd4e3414fc9a5815263a3f063f3e"
43
43
  }