@invisibleloop/pulse 0.1.39 → 0.2.0
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/.github/workflows/publish.yml +3 -2
- package/CLAUDE.md +21 -1
- package/benchmark/results/2026-03-27-09-20.json +188 -0
- package/benchmark/results/2026-03-27-09-21.json +188 -0
- package/benchmark/results/2026-03-27-09-29.json +188 -0
- package/benchmark/results/2026-03-27-10-14.json +188 -0
- package/benchmark/results/2026-03-27-10-18.json +232 -0
- package/docs/public/.pulse-ui-version +1 -1
- package/package.json +2 -2
- package/public/.pulse-ui-version +1 -1
- package/scripts/bench.js +483 -0
- package/scripts/release-version.js +56 -0
- package/src/cli/index.js +30 -0
- package/src/runtime/index.js +89 -7
- package/src/runtime/morph.test.js +317 -0
- package/src/runtime/navigate.js +132 -52
- package/src/runtime/runtime.test.js +24 -0
- package/src/runtime/ssr.js +168 -23
- package/src/runtime/ssr.test.js +255 -1
- package/src/server/index.js +88 -6
- package/src/server/server.test.js +104 -0
- package/src/spec/schema.js +32 -1
- package/src/spec/schema.test.js +55 -0
package/scripts/bench.js
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse — Performance Benchmark
|
|
3
|
+
*
|
|
4
|
+
* Measures server-side performance metrics before/after optimisation work.
|
|
5
|
+
* Starts an isolated test server — no external dependencies.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* node scripts/bench.js # run all suites, print table
|
|
9
|
+
* node scripts/bench.js --save # also write benchmark/results/YYYY-MM-DD-HH-MM.json
|
|
10
|
+
* node scripts/bench.js --compare # compare latest two saved results
|
|
11
|
+
*
|
|
12
|
+
* Metrics captured per suite:
|
|
13
|
+
* ttfb — Time to first byte (ms): headers received
|
|
14
|
+
* total — Full response time (ms): all bytes received
|
|
15
|
+
* bodyBytes — Raw response body size (bytes)
|
|
16
|
+
* serverData — Server-Timing: data fetcher duration (ms)
|
|
17
|
+
* serverRender— Server-Timing: view render duration (ms)
|
|
18
|
+
* serverTotal — Server-Timing: total server processing (ms)
|
|
19
|
+
*
|
|
20
|
+
* Suites:
|
|
21
|
+
* simple — Page with no server data (routing + render overhead only)
|
|
22
|
+
* data-1 — Page with 1 server fetcher (simulated 20ms DB query)
|
|
23
|
+
* data-3 — Page with 3 parallel server fetchers (20ms, 30ms, 10ms)
|
|
24
|
+
* nav-simple — Client navigation (X-Pulse-Navigate) on simple page
|
|
25
|
+
* nav-data — Client navigation on data page
|
|
26
|
+
* cached — Repeated request to a spec.cache page (in-process cache hit)
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import http from 'http'
|
|
30
|
+
import fs from 'fs'
|
|
31
|
+
import path from 'path'
|
|
32
|
+
import { performance } from 'perf_hooks'
|
|
33
|
+
import { fileURLToPath } from 'url'
|
|
34
|
+
import { createServer } from '../src/server/index.js'
|
|
35
|
+
|
|
36
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
37
|
+
const ROOT = path.join(__dirname, '..')
|
|
38
|
+
const RESULTS = path.join(ROOT, 'benchmark', 'results')
|
|
39
|
+
|
|
40
|
+
const WARMUP = 10
|
|
41
|
+
const ITERATIONS = 100
|
|
42
|
+
const PORT = 19876
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Benchmark specs — deterministic fake latency simulates real DB queries
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
const simpleSpec = {
|
|
49
|
+
route: '/bench/simple',
|
|
50
|
+
hydrate: '/bench/simple.js',
|
|
51
|
+
meta: { title: 'Bench — Simple', description: 'Benchmark page', styles: ['/pulse.css'] },
|
|
52
|
+
state: {},
|
|
53
|
+
view: () => `
|
|
54
|
+
<main id="main-content">
|
|
55
|
+
<h1>Simple page</h1>
|
|
56
|
+
<p>No server data — pure routing and render overhead.</p>
|
|
57
|
+
</main>`,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const data1Spec = {
|
|
61
|
+
route: '/bench/data-1',
|
|
62
|
+
hydrate: '/bench/data-1.js',
|
|
63
|
+
meta: { title: 'Bench — 1 Fetcher', description: 'Benchmark page', styles: ['/pulse.css'] },
|
|
64
|
+
state: {},
|
|
65
|
+
server: {
|
|
66
|
+
items: async () => {
|
|
67
|
+
await sleep(20)
|
|
68
|
+
return Array.from({ length: 50 }, (_, i) => ({ id: i, name: `Item ${i}`, value: i * 3.14 }))
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
view: (state, { items }) => `
|
|
72
|
+
<main id="main-content">
|
|
73
|
+
<h1>Data page — 1 fetcher</h1>
|
|
74
|
+
<ul>${items.map(it => `<li>${it.name}: ${it.value.toFixed(2)}</li>`).join('')}</ul>
|
|
75
|
+
</main>`,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const data3Spec = {
|
|
79
|
+
route: '/bench/data-3',
|
|
80
|
+
hydrate: '/bench/data-3.js',
|
|
81
|
+
meta: { title: 'Bench — 3 Fetchers', description: 'Benchmark page', styles: ['/pulse.css'] },
|
|
82
|
+
state: {},
|
|
83
|
+
server: {
|
|
84
|
+
fast: async () => { await sleep(10); return { label: 'fast', value: 42 } },
|
|
85
|
+
medium: async () => { await sleep(30); return { label: 'medium', value: 99 } },
|
|
86
|
+
slow: async () => { await sleep(20); return { label: 'slow', value: 7 } },
|
|
87
|
+
},
|
|
88
|
+
view: (state, { fast, medium, slow }) => `
|
|
89
|
+
<main id="main-content">
|
|
90
|
+
<h1>Data page — 3 parallel fetchers</h1>
|
|
91
|
+
<p>Fast: ${fast.value} · Medium: ${medium.value} · Slow: ${slow.value}</p>
|
|
92
|
+
</main>`,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Streaming scope specs — demonstrate segment-level data scoping
|
|
97
|
+
//
|
|
98
|
+
// Both specs have the same two fetchers:
|
|
99
|
+
// user — 20ms (needed by the shell 'header' segment)
|
|
100
|
+
// posts — 100ms (needed by the deferred 'feed' segment)
|
|
101
|
+
//
|
|
102
|
+
// scoped: shell only awaits 'user' → shell arrives at ~20ms
|
|
103
|
+
// unscoped: shell awaits ALL fetchers → shell blocked until ~100ms
|
|
104
|
+
//
|
|
105
|
+
// "shell time" = when the shell content chunk appears in the response stream.
|
|
106
|
+
// "total time" = when all bytes (including deferred) have arrived.
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
const streamScopedSpec = {
|
|
110
|
+
route: '/bench/stream-scoped',
|
|
111
|
+
hydrate: '/bench/stream-scoped.js',
|
|
112
|
+
meta: { title: 'Bench — Stream Scoped', description: 'Benchmark page', styles: ['/pulse.css'] },
|
|
113
|
+
state: {},
|
|
114
|
+
server: {
|
|
115
|
+
user: async () => { await sleep(20); return { name: 'Alice' } },
|
|
116
|
+
posts: async () => { await sleep(100); return [{ id: 1, title: 'Post 1' }, { id: 2, title: 'Post 2' }] },
|
|
117
|
+
},
|
|
118
|
+
stream: {
|
|
119
|
+
shell: ['header'],
|
|
120
|
+
deferred: ['feed'],
|
|
121
|
+
scope: { header: ['user'], feed: ['posts'] }, // ← key: shell doesn't wait for posts
|
|
122
|
+
},
|
|
123
|
+
view: {
|
|
124
|
+
header: (s, { user }) => `<header data-bench="shell">Hello ${user.name}</header>`,
|
|
125
|
+
feed: (s, { posts }) => `<main>${posts.map(p => `<h2>${p.title}</h2>`).join('')}</main>`,
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const streamUnscopedSpec = {
|
|
130
|
+
route: '/bench/stream-unscoped',
|
|
131
|
+
hydrate: '/bench/stream-unscoped.js',
|
|
132
|
+
meta: { title: 'Bench — Stream Unscoped', description: 'Benchmark page', styles: ['/pulse.css'] },
|
|
133
|
+
state: {},
|
|
134
|
+
server: {
|
|
135
|
+
user: async () => { await sleep(20); return { name: 'Alice' } },
|
|
136
|
+
posts: async () => { await sleep(100); return [{ id: 1, title: 'Post 1' }, { id: 2, title: 'Post 2' }] },
|
|
137
|
+
},
|
|
138
|
+
stream: {
|
|
139
|
+
shell: ['header'],
|
|
140
|
+
deferred: ['feed'],
|
|
141
|
+
// no scope — shell awaits ALL fetchers before writing (baseline behaviour)
|
|
142
|
+
},
|
|
143
|
+
view: {
|
|
144
|
+
header: (s, { user }) => `<header data-bench="shell">Hello ${user.name}</header>`,
|
|
145
|
+
feed: (s, { posts }) => `<main>${posts.map(p => `<h2>${p.title}</h2>`).join('')}</main>`,
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Cached spec — no server data so in-process page cache applies
|
|
150
|
+
const cachedSpec = {
|
|
151
|
+
route: '/bench/cached',
|
|
152
|
+
hydrate: '/bench/cached.js',
|
|
153
|
+
meta: { title: 'Bench — Cached', description: 'Benchmark page', styles: ['/pulse.css'] },
|
|
154
|
+
state: {},
|
|
155
|
+
cache: { public: true, maxAge: 60 },
|
|
156
|
+
view: () => `
|
|
157
|
+
<main id="main-content">
|
|
158
|
+
<h1>Cached page</h1>
|
|
159
|
+
<p>This response is served from the in-process page cache after the first request.</p>
|
|
160
|
+
</main>`,
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// HTTP helpers
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
function sleep(ms) {
|
|
168
|
+
return new Promise(r => setTimeout(r, ms))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function request(path, headers = {}) {
|
|
172
|
+
return new Promise((resolve, reject) => {
|
|
173
|
+
const t0 = performance.now()
|
|
174
|
+
let ttfb = null
|
|
175
|
+
|
|
176
|
+
const req = http.get({ hostname: 'localhost', port: PORT, path, headers }, res => {
|
|
177
|
+
ttfb = performance.now() - t0
|
|
178
|
+
const chunks = []
|
|
179
|
+
res.on('data', c => chunks.push(c))
|
|
180
|
+
res.on('end', () => {
|
|
181
|
+
const total = performance.now() - t0
|
|
182
|
+
const body = Buffer.concat(chunks)
|
|
183
|
+
const timing = parseServerTiming(res.headers['server-timing'])
|
|
184
|
+
resolve({ ttfb, total, bodyBytes: body.length, status: res.statusCode, timing, headers: res.headers })
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
req.on('error', reject)
|
|
189
|
+
req.setTimeout(5000, () => { req.destroy(new Error('timeout')) })
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Make a streaming HTTP request and track when a shell content marker
|
|
195
|
+
* first appears in the response body. Used for the stream-scope suites.
|
|
196
|
+
*
|
|
197
|
+
* shellTime — elapsed ms when the marker string appears in any chunk
|
|
198
|
+
* total — elapsed ms when all bytes have arrived (connection closed)
|
|
199
|
+
*/
|
|
200
|
+
function streamRequest(path, shellMarker) {
|
|
201
|
+
return new Promise((resolve, reject) => {
|
|
202
|
+
const t0 = performance.now()
|
|
203
|
+
let shellTime = null
|
|
204
|
+
let buffer = ''
|
|
205
|
+
|
|
206
|
+
const req = http.get({ hostname: 'localhost', port: PORT, path }, res => {
|
|
207
|
+
res.on('data', chunk => {
|
|
208
|
+
buffer += chunk.toString()
|
|
209
|
+
if (shellTime === null && buffer.includes(shellMarker)) {
|
|
210
|
+
shellTime = performance.now() - t0
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
res.on('end', () => {
|
|
214
|
+
resolve({ shellTime, total: performance.now() - t0, bodyBytes: buffer.length })
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
req.on('error', reject)
|
|
219
|
+
req.setTimeout(5000, () => req.destroy(new Error('timeout')))
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function parseServerTiming(header) {
|
|
224
|
+
if (!header) return {}
|
|
225
|
+
const result = {}
|
|
226
|
+
for (const part of header.split(',')) {
|
|
227
|
+
const [name, ...attrs] = part.trim().split(';')
|
|
228
|
+
const dur = attrs.find(a => a.trim().startsWith('dur='))
|
|
229
|
+
if (dur) result[name.trim()] = parseFloat(dur.split('=')[1])
|
|
230
|
+
}
|
|
231
|
+
return result
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// Statistics
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
function stats(values) {
|
|
239
|
+
const sorted = [...values].sort((a, b) => a - b)
|
|
240
|
+
const n = sorted.length
|
|
241
|
+
return {
|
|
242
|
+
mean: values.reduce((s, v) => s + v, 0) / n,
|
|
243
|
+
p50: sorted[Math.floor(n * 0.50)],
|
|
244
|
+
p95: sorted[Math.floor(n * 0.95)],
|
|
245
|
+
p99: sorted[Math.floor(n * 0.99)],
|
|
246
|
+
min: sorted[0],
|
|
247
|
+
max: sorted[n - 1],
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Run a suite
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
async function runSuite(label, path, headers = {}) {
|
|
256
|
+
process.stdout.write(` ${label.padEnd(20)}`)
|
|
257
|
+
|
|
258
|
+
// Warm up — prime any caches, JIT, keep-alive connections
|
|
259
|
+
for (let i = 0; i < WARMUP; i++) await request(path, headers)
|
|
260
|
+
|
|
261
|
+
const results = []
|
|
262
|
+
for (let i = 0; i < ITERATIONS; i++) {
|
|
263
|
+
results.push(await request(path, headers))
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const ttfbs = results.map(r => r.ttfb)
|
|
267
|
+
const totals = results.map(r => r.total)
|
|
268
|
+
const serverData = results.map(r => r.timing.data ?? 0).filter(v => v > 0)
|
|
269
|
+
const serverRend = results.map(r => r.timing.render ?? 0).filter(v => v > 0)
|
|
270
|
+
const bodyBytes = results[0].bodyBytes
|
|
271
|
+
|
|
272
|
+
const out = {
|
|
273
|
+
label,
|
|
274
|
+
path,
|
|
275
|
+
iterations: ITERATIONS,
|
|
276
|
+
ttfb: stats(ttfbs),
|
|
277
|
+
total: stats(totals),
|
|
278
|
+
bodyBytes,
|
|
279
|
+
serverData: serverData.length ? stats(serverData) : null,
|
|
280
|
+
serverRender: serverRend.length ? stats(serverRend) : null,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Quick inline summary
|
|
284
|
+
const t = n => n.toFixed(1).padStart(6)
|
|
285
|
+
process.stdout.write(
|
|
286
|
+
`ttfb p50${t(out.ttfb.p50)}ms p95${t(out.ttfb.p95)}ms p99${t(out.ttfb.p99)}ms` +
|
|
287
|
+
(out.serverData ? ` data p50${t(out.serverData.p50)}ms` : '') +
|
|
288
|
+
(out.serverRender ? ` render p50${t(out.serverRender.p50)}ms` : '') +
|
|
289
|
+
` body ${(bodyBytes / 1024).toFixed(1)}kB\n`
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
return out
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// Run a streaming scope suite
|
|
297
|
+
// Reports shell arrival time (when shell content first appears in the stream)
|
|
298
|
+
// vs total time (all bytes received). The gap shows how much sooner the
|
|
299
|
+
// browser can start rendering when deferred-only fetchers don't block the shell.
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
async function runStreamSuite(label, path) {
|
|
303
|
+
const SHELL_MARKER = 'data-bench="shell"'
|
|
304
|
+
process.stdout.write(` ${label.padEnd(22)}`)
|
|
305
|
+
|
|
306
|
+
for (let i = 0; i < WARMUP; i++) await streamRequest(path, SHELL_MARKER)
|
|
307
|
+
|
|
308
|
+
const results = []
|
|
309
|
+
for (let i = 0; i < ITERATIONS; i++) {
|
|
310
|
+
results.push(await streamRequest(path, SHELL_MARKER))
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const shellStats = stats(results.map(r => r.shellTime))
|
|
314
|
+
const totalStats = stats(results.map(r => r.total))
|
|
315
|
+
const bodyBytes = results[0].bodyBytes
|
|
316
|
+
|
|
317
|
+
const t = n => n.toFixed(1).padStart(6)
|
|
318
|
+
process.stdout.write(
|
|
319
|
+
`shell p50${t(shellStats.p50)}ms p95${t(shellStats.p95)}ms` +
|
|
320
|
+
` total p50${t(totalStats.p50)}ms p95${t(totalStats.p95)}ms` +
|
|
321
|
+
` body ${(bodyBytes / 1024).toFixed(1)}kB\n`
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
return { label, path, iterations: ITERATIONS, shellTime: shellStats, total: totalStats, bodyBytes }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// Bundle sizes
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
function measureBundles() {
|
|
332
|
+
const distDir = path.join(ROOT, 'public', 'dist')
|
|
333
|
+
if (!fs.existsSync(distDir)) return null
|
|
334
|
+
|
|
335
|
+
const files = fs.readdirSync(distDir).filter(f => f.endsWith('.js'))
|
|
336
|
+
const result = {}
|
|
337
|
+
for (const f of files) {
|
|
338
|
+
const bytes = fs.statSync(path.join(distDir, f)).size
|
|
339
|
+
const key = f.includes('runtime') ? 'runtime' : f.replace(/\.[^.]+$/, '').replace(/-[A-Z0-9]+$/, '')
|
|
340
|
+
result[key] = { file: f, bytes }
|
|
341
|
+
}
|
|
342
|
+
return result
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// Compare two result files
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
function compare() {
|
|
350
|
+
const files = fs.readdirSync(RESULTS)
|
|
351
|
+
.filter(f => f.endsWith('.json'))
|
|
352
|
+
.sort()
|
|
353
|
+
.slice(-2)
|
|
354
|
+
|
|
355
|
+
if (files.length < 2) {
|
|
356
|
+
console.log('Need at least 2 saved results to compare. Run with --save first.')
|
|
357
|
+
return
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const [before, after] = files.map(f => JSON.parse(fs.readFileSync(path.join(RESULTS, f), 'utf8')))
|
|
361
|
+
|
|
362
|
+
console.log(`\nComparing: ${files[0]} → ${files[1]}\n`)
|
|
363
|
+
console.log('Suite'.padEnd(22) + 'Metric'.padEnd(16) + 'Before'.padStart(10) + 'After'.padStart(10) + 'Delta'.padStart(10) + ' Change')
|
|
364
|
+
console.log('─'.repeat(78))
|
|
365
|
+
|
|
366
|
+
const fmt = n => n?.toFixed(2).padStart(10) ?? ' —'
|
|
367
|
+
const diff = (a, b) => {
|
|
368
|
+
if (a == null || b == null) return ' —'
|
|
369
|
+
const d = b - a
|
|
370
|
+
const pct = ((d / a) * 100).toFixed(1)
|
|
371
|
+
const sign = d > 0 ? '+' : ''
|
|
372
|
+
return `${(sign + d.toFixed(2)).padStart(10)} ${sign}${pct}%`
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
for (const suite of before.suites) {
|
|
376
|
+
const afterSuite = after.suites.find(s => s.label === suite.label)
|
|
377
|
+
if (!afterSuite) continue
|
|
378
|
+
|
|
379
|
+
// Streaming scope suites use shellTime instead of ttfb
|
|
380
|
+
const rows = suite.shellTime ? [
|
|
381
|
+
['shell p50', suite.shellTime.p50, afterSuite.shellTime?.p50],
|
|
382
|
+
['shell p95', suite.shellTime.p95, afterSuite.shellTime?.p95],
|
|
383
|
+
['total p50', suite.total.p50, afterSuite.total.p50],
|
|
384
|
+
['body kB', suite.bodyBytes / 1024, afterSuite.bodyBytes / 1024],
|
|
385
|
+
] : [
|
|
386
|
+
['ttfb p50', suite.ttfb.p50, afterSuite.ttfb.p50],
|
|
387
|
+
['ttfb p95', suite.ttfb.p95, afterSuite.ttfb.p95],
|
|
388
|
+
['total p50', suite.total.p50, afterSuite.total.p50],
|
|
389
|
+
['body kB', suite.bodyBytes / 1024, afterSuite.bodyBytes / 1024],
|
|
390
|
+
]
|
|
391
|
+
if (suite.serverData) rows.push(['server data p50', suite.serverData.p50, afterSuite.serverData?.p50])
|
|
392
|
+
if (suite.serverRender) rows.push(['server render p50', suite.serverRender.p50, afterSuite.serverRender?.p50])
|
|
393
|
+
|
|
394
|
+
rows.forEach(([metric, b, a], i) => {
|
|
395
|
+
const label = i === 0 ? suite.label : ''
|
|
396
|
+
console.log(label.padEnd(22) + metric.padEnd(16) + fmt(b) + fmt(a) + diff(b, a))
|
|
397
|
+
})
|
|
398
|
+
console.log()
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (before.bundles && after.bundles) {
|
|
402
|
+
console.log('Bundle sizes:')
|
|
403
|
+
for (const [key, b] of Object.entries(before.bundles)) {
|
|
404
|
+
const a = after.bundles[key]
|
|
405
|
+
if (!a) continue
|
|
406
|
+
const d = a.bytes - b.bytes
|
|
407
|
+
console.log(` ${key.padEnd(20)} ${(b.bytes/1024).toFixed(2)}kB → ${(a.bytes/1024).toFixed(2)}kB (${d > 0 ? '+' : ''}${d}B)`)
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
// Main
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
async function main() {
|
|
417
|
+
const args = process.argv.slice(2)
|
|
418
|
+
const doSave = args.includes('--save')
|
|
419
|
+
const doComp = args.includes('--compare')
|
|
420
|
+
|
|
421
|
+
if (doComp) { compare(); return }
|
|
422
|
+
|
|
423
|
+
console.log(`\nPulse Performance Benchmark`)
|
|
424
|
+
console.log(`Warmup: ${WARMUP} req · Iterations: ${ITERATIONS} req per suite\n`)
|
|
425
|
+
|
|
426
|
+
// Start benchmark server
|
|
427
|
+
const { server } = createServer(
|
|
428
|
+
[simpleSpec, data1Spec, data3Spec, cachedSpec, streamScopedSpec, streamUnscopedSpec],
|
|
429
|
+
{ port: PORT, stream: true, dev: false }
|
|
430
|
+
)
|
|
431
|
+
await sleep(100) // let server start
|
|
432
|
+
|
|
433
|
+
const suites = []
|
|
434
|
+
|
|
435
|
+
console.log('Server-side suites (streaming SSR):')
|
|
436
|
+
suites.push(await runSuite('simple', '/bench/simple'))
|
|
437
|
+
suites.push(await runSuite('data-1', '/bench/data-1'))
|
|
438
|
+
suites.push(await runSuite('data-3', '/bench/data-3'))
|
|
439
|
+
suites.push(await runSuite('cached', '/bench/cached'))
|
|
440
|
+
|
|
441
|
+
console.log('\nClient navigation suites (X-Pulse-Navigate):')
|
|
442
|
+
suites.push(await runSuite('nav/simple', '/bench/simple', { 'x-pulse-navigate': 'true' }))
|
|
443
|
+
suites.push(await runSuite('nav/data-1', '/bench/data-1', { 'x-pulse-navigate': 'true' }))
|
|
444
|
+
suites.push(await runSuite('nav/data-3', '/bench/data-3', { 'x-pulse-navigate': 'true' }))
|
|
445
|
+
|
|
446
|
+
console.log('\nStreaming scope suites (shell arrival time vs total):')
|
|
447
|
+
console.log(' (shell = when first view content appears in stream; total = all bytes received)')
|
|
448
|
+
suites.push(await runStreamSuite('stream/scoped', '/bench/stream-scoped'))
|
|
449
|
+
suites.push(await runStreamSuite('stream/unscoped', '/bench/stream-unscoped'))
|
|
450
|
+
|
|
451
|
+
// Bundle sizes
|
|
452
|
+
const bundles = measureBundles()
|
|
453
|
+
if (bundles) {
|
|
454
|
+
console.log('\nBundle sizes (uncompressed):')
|
|
455
|
+
for (const [key, { file, bytes }] of Object.entries(bundles)) {
|
|
456
|
+
console.log(` ${key.padEnd(20)} ${(bytes / 1024).toFixed(2)}kB (${file})`)
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Shut down
|
|
461
|
+
server.close()
|
|
462
|
+
|
|
463
|
+
// Save results
|
|
464
|
+
if (doSave) {
|
|
465
|
+
fs.mkdirSync(RESULTS, { recursive: true })
|
|
466
|
+
const now = new Date()
|
|
467
|
+
const pad = n => String(n).padStart(2, '0')
|
|
468
|
+
const name = `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())}-${pad(now.getHours())}-${pad(now.getMinutes())}.json`
|
|
469
|
+
const out = {
|
|
470
|
+
timestamp: now.toISOString(),
|
|
471
|
+
node: process.version,
|
|
472
|
+
suites,
|
|
473
|
+
bundles,
|
|
474
|
+
}
|
|
475
|
+
fs.writeFileSync(path.join(RESULTS, name), JSON.stringify(out, null, 2))
|
|
476
|
+
console.log(`\nSaved → benchmark/results/${name}`)
|
|
477
|
+
console.log('Run again with --save after changes, then --compare to see the diff.')
|
|
478
|
+
} else {
|
|
479
|
+
console.log('\nRun with --save to record this baseline.')
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
main().catch(err => { console.error(err); process.exit(1) })
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Determine the next version from conventional commits.
|
|
4
|
+
*
|
|
5
|
+
* Reads all commits since the last "chore: release X.Y.Z [skip ci]" commit,
|
|
6
|
+
* applies conventional commit rules to determine the bump level, then prints
|
|
7
|
+
* the new version string to stdout.
|
|
8
|
+
*
|
|
9
|
+
* Bump rules:
|
|
10
|
+
* BREAKING CHANGE footer or "type!:" prefix → major
|
|
11
|
+
* feat: → minor
|
|
12
|
+
* anything else (fix, perf, chore, docs…) → patch
|
|
13
|
+
*
|
|
14
|
+
* Usage: node scripts/release-version.js
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { execSync } from 'child_process'
|
|
18
|
+
import { readFileSync } from 'fs'
|
|
19
|
+
|
|
20
|
+
const pkg = JSON.parse(readFileSync('package.json', 'utf8'))
|
|
21
|
+
const [major, minor, patch] = pkg.version.split('.').map(Number)
|
|
22
|
+
|
|
23
|
+
// Collect commit subjects since the last release commit.
|
|
24
|
+
// git log outputs newest-first; we stop at the first release commit we see.
|
|
25
|
+
let commits = []
|
|
26
|
+
try {
|
|
27
|
+
const log = execSync('git log --pretty=format:%s', { encoding: 'utf8' }).trim()
|
|
28
|
+
for (const line of log.split('\n')) {
|
|
29
|
+
if (/^chore: release \d+\.\d+\.\d+/.test(line)) break
|
|
30
|
+
if (line) commits.push(line)
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
// No git history — default to patch
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Determine bump level
|
|
37
|
+
let bump = 'patch'
|
|
38
|
+
for (const msg of commits) {
|
|
39
|
+
// Breaking change: "type!:" or "BREAKING CHANGE" anywhere in the subject
|
|
40
|
+
if (/^[a-z]+(\([^)]+\))?!:/.test(msg) || msg.includes('BREAKING CHANGE')) {
|
|
41
|
+
bump = 'major'
|
|
42
|
+
break
|
|
43
|
+
}
|
|
44
|
+
// New feature: "feat:" or "feat(scope):"
|
|
45
|
+
if (/^feat(\([^)]+\))?:/.test(msg) && bump !== 'major') {
|
|
46
|
+
bump = 'minor'
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Compute new version
|
|
51
|
+
let next
|
|
52
|
+
if (bump === 'major') next = `${major + 1}.0.0`
|
|
53
|
+
else if (bump === 'minor') next = `${major}.${minor + 1}.0`
|
|
54
|
+
else next = `${major}.${minor}.${patch + 1}`
|
|
55
|
+
|
|
56
|
+
process.stdout.write(next + '\n')
|
package/src/cli/index.js
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
* pulse build production build → public/dist/
|
|
9
9
|
* pulse start production server (requires prior build)
|
|
10
10
|
* pulse update re-copy pulse-ui.css/js from installed package → public/
|
|
11
|
+
* pulse --version print installed version and exit
|
|
12
|
+
* pulse -v alias for --version
|
|
13
|
+
* pulse --help show usage and exit
|
|
14
|
+
* pulse -h alias for --help
|
|
11
15
|
*/
|
|
12
16
|
|
|
13
17
|
import path from 'path'
|
|
@@ -329,6 +333,32 @@ async function runStart(root) {
|
|
|
329
333
|
// ---------------------------------------------------------------------------
|
|
330
334
|
|
|
331
335
|
switch (command) {
|
|
336
|
+
case '--version':
|
|
337
|
+
case '-v': {
|
|
338
|
+
const pkgPath = new URL('../../package.json', import.meta.url).pathname
|
|
339
|
+
const { version } = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
|
|
340
|
+
console.log(version)
|
|
341
|
+
process.exit(0)
|
|
342
|
+
}
|
|
343
|
+
case '--help':
|
|
344
|
+
case '-h':
|
|
345
|
+
console.log(`
|
|
346
|
+
⚡ Pulse — spec-first frontend framework
|
|
347
|
+
|
|
348
|
+
Usage: pulse [command]
|
|
349
|
+
|
|
350
|
+
Commands:
|
|
351
|
+
(none) detect project or start scaffold wizard
|
|
352
|
+
dev start dev server
|
|
353
|
+
build production build → public/dist/
|
|
354
|
+
start production server (requires prior build)
|
|
355
|
+
update re-copy pulse-ui assets from installed package → public/
|
|
356
|
+
|
|
357
|
+
Options:
|
|
358
|
+
-v, --version print version and exit
|
|
359
|
+
-h, --help show this help
|
|
360
|
+
`)
|
|
361
|
+
process.exit(0)
|
|
332
362
|
case 'dev':
|
|
333
363
|
await runDev(CWD)
|
|
334
364
|
break
|