@live-change/server 0.9.122 → 0.9.124

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,62 +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
- nextTick: process.nextTick
143
- }
144
- }
145
- const requestContext = vm.createContext(contextObject, {
146
- name: 'SSR Render '+(new Date().toISOString()),
147
- ///microtaskMode: 'afterEvaluate'
148
- })
149
- this.script.runInContext(requestContext)
150
- return contextObject
151
- }
152
-
153
- async getRenderFunction() {
226
+ async getRenderFunction(context = null) {
154
227
  if(this.settings.dev) {
155
228
  /// Reload every request
156
229
  const entryPath = path.resolve(this.root, this.settings.serverEntry || 'src/entry-server.js')
157
230
  return (await this.vite.ssrLoadModule(entryPath)).render
158
231
  } else {
159
- const context = await this.createRenderContext()
160
- return context.exports.render
232
+ return context.getExports().render
161
233
  }
162
234
  }
163
235
 
164
- async getSitemapRenderFunction() {
236
+ async getSitemapRenderFunction(context = null) {
165
237
  if(this.settings.dev) {
166
238
  /// Reload every request
167
239
  const entryPath = path.resolve(this.root, this.settings.serverEntry || 'src/entry-server.js')
168
240
  return (await this.vite.ssrLoadModule(entryPath)).sitemap
169
241
  } else {
170
- const context = await this.createRenderContext()
171
- return context.exports.sitemap
242
+ return context.getExports().sitemap
172
243
  }
173
244
  }
174
245
 
175
246
  async renderSitemap(params, res) {
176
- try {
177
- 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()
178
249
 
250
+ try {
179
251
  res.header('Content-Type', 'application/xml')
180
252
  res.status(200)
181
253
  const smStream = new SitemapStream({
182
254
  hostname: (process.env.BASE_HREF ?? (domain ? `https://${domain}` : "https://sitemap.com"))+'/'
183
255
  })
184
256
  smStream.pipe(res)
185
- 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
+ })
186
262
 
187
263
  function write(routeInfo) {
188
264
  smStream.write(routeInfo)
@@ -196,6 +272,8 @@ class Renderer {
196
272
  res.status(503)
197
273
  res.end(`<h4>Internal server error</h4><pre>${err.stack || err.code || err}</pre>`)
198
274
  //if(profileOp) await profileLog.end({ ...profileOp, state: 'error', error: err })
275
+ } finally {
276
+ this.logRenderStats(`${url} (sitemap)`, startTime)
199
277
  }
200
278
  }
201
279
 
@@ -207,11 +285,47 @@ class Renderer {
207
285
  }
208
286
  }
209
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
+
210
318
  async close() {
211
319
  if(this.vite) {
212
320
  console.log("VITE CLOSE!!!")
213
321
  await this.vite.close()
214
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 = []
215
329
  }
216
330
 
217
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.122",
3
+ "version": "0.9.124",
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.122",
26
- "@live-change/dao-sockjs": "^0.9.122",
27
- "@live-change/db-server": "^0.9.122",
28
- "@live-change/framework": "^0.9.122",
25
+ "@live-change/dao": "^0.9.124",
26
+ "@live-change/dao-sockjs": "^0.9.124",
27
+ "@live-change/db-server": "^0.9.124",
28
+ "@live-change/framework": "^0.9.124",
29
29
  "@live-change/sockjs": "0.4.1",
30
- "@live-change/uid": "^0.9.122",
30
+ "@live-change/uid": "^0.9.124",
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": "e65f06f76a839f3ddf55a23eb4ff17cafafc2b84"
42
+ "gitHead": "b61ece032725f08f1225e7ecf48204a3a7eedbd0"
43
43
  }