@lightningjs/blits 2.13.0 → 2.14.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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ # 2.14.0
4
+
5
+ _19 oct 2026_
6
+
7
+ - Added built-in profiler support to for both internal and app dev use
8
+ - Added benchmark for performance comparison (and GitHub workflow to run on PRs)
9
+
3
10
  ## 2.13.0
4
11
 
5
12
  _13 oct 2026_
package/eslint.config.cjs CHANGED
@@ -37,6 +37,7 @@ module.exports = defineConfig([
37
37
  ...globals.browser,
38
38
  ...globals.node,
39
39
  globalThis: false,
40
+ __DEV__: false,
40
41
  },
41
42
 
42
43
  parser: babelParser,
package/index.d.ts CHANGED
@@ -1111,6 +1111,15 @@ declare module '@lightningjs/blits' {
1111
1111
  * the Blits App
1112
1112
  */
1113
1113
  export interface Settings {
1114
+ /**
1115
+ * Start the performance profiler when the App launches
1116
+ *
1117
+ * The profiler is also available on `globalThis.__BLITS_PROFILER__`, so it
1118
+ * can be started and stopped from the devtools console
1119
+ *
1120
+ * @default false
1121
+ */
1122
+ profiler?: boolean,
1114
1123
  /**
1115
1124
  * Width of the Application
1116
1125
  */
@@ -1477,6 +1486,98 @@ declare module '@lightningjs/blits' {
1477
1486
  }
1478
1487
 
1479
1488
 
1489
+ export interface ProfilerEntry {
1490
+ /**
1491
+ * Number of times the measurement was recorded
1492
+ */
1493
+ calls: number,
1494
+ /**
1495
+ * Total time spent, in milliseconds
1496
+ */
1497
+ totalMs: number,
1498
+ /**
1499
+ * Slowest single measurement, in milliseconds
1500
+ */
1501
+ maxMs: number,
1502
+ /**
1503
+ * Average time per call, in milliseconds
1504
+ */
1505
+ averageMs: number
1506
+ }
1507
+
1508
+ export interface ProfilerFrameSummary {
1509
+ medianMs: number,
1510
+ p95Ms: number,
1511
+ p99Ms: number,
1512
+ maxMs: number
1513
+ }
1514
+
1515
+ export interface ProfilerFrameLabel {
1516
+ calls: number,
1517
+ totalMs: number
1518
+ }
1519
+
1520
+ export interface ProfilerFrameSnapshot {
1521
+ frameCount: number,
1522
+ retainedFrameLimit: number,
1523
+ frameInterval: ProfilerFrameSummary,
1524
+ instrumentedTime: ProfilerFrameSummary,
1525
+ slowFramePercent: number,
1526
+ slowestFrames: Array<{
1527
+ intervalMs: number,
1528
+ instrumentedMs: number,
1529
+ labels: Record<string, ProfilerFrameLabel>
1530
+ }>,
1531
+ labels: Record<string, ProfilerFrameLabel & {
1532
+ callsPerFrame: number,
1533
+ averageMsPerFrame: number
1534
+ }>
1535
+ }
1536
+
1537
+ export interface ProfilerSnapshot {
1538
+ measurements: Record<string, ProfilerEntry>,
1539
+ frames: ProfilerFrameSnapshot
1540
+ }
1541
+
1542
+ export interface Profiler {
1543
+ /**
1544
+ * Marks the start of a measurement
1545
+ */
1546
+ mark(name: string): void,
1547
+ /**
1548
+ * Ends the most recent measurement started with the same name
1549
+ */
1550
+ markEnd(name: string): void,
1551
+ /**
1552
+ * Clears previously collected measurements and starts recording
1553
+ */
1554
+ start(options?: { frames?: boolean, maxFrames?: number, targetFps?: number }): void,
1555
+ /**
1556
+ * Stops recording and returns the collected measurements
1557
+ */
1558
+ stop(): ProfilerSnapshot,
1559
+ /**
1560
+ * Clears all collected measurements, without changing the enabled state
1561
+ */
1562
+ reset(): void,
1563
+ /**
1564
+ * Returns the measurements collected so far
1565
+ */
1566
+ snapshot(): ProfilerSnapshot,
1567
+ /**
1568
+ * Returns frame-oriented results collected by default or with start({ frames: true })
1569
+ */
1570
+ frameSnapshot(): ProfilerFrameSnapshot,
1571
+ /**
1572
+ * Logs marker and frame results as console tables and returns the combined snapshot
1573
+ */
1574
+ report(): ProfilerSnapshot,
1575
+ /**
1576
+ * Whether the profiler is currently recording
1577
+ */
1578
+ readonly enabled: boolean
1579
+ }
1580
+
1480
1581
  /**
1481
1582
  * Blits App Framework
1482
1583
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightningjs/blits",
3
- "version": "2.13.0",
3
+ "version": "2.14.0",
4
4
  "description": "Blits: The Lightning 3 App Development Framework",
5
5
  "bin": "bin/index.js",
6
6
  "exports": {
@@ -49,6 +49,10 @@
49
49
  "default": "./src/testing/index.js"
50
50
  },
51
51
  "./symbols": "./src/lib/symbols.js",
52
+ "./profiler": {
53
+ "types": "./src/lib/profiler.d.ts",
54
+ "default": "./src/lib/profiler.js"
55
+ },
52
56
  "./blitsFileConverter": "./src/lib/blitsfileconverter/blitsfileconverter.js",
53
57
  "./components": "./src/components/FPScounter.js"
54
58
  },
@@ -62,6 +66,10 @@
62
66
  "postpublish": "node scripts/postpublish.js",
63
67
  "prepare": "husky",
64
68
  "pack-preview": "npm pack --dry-run",
69
+ "bench": "npm --prefix benchmarks run bench --",
70
+ "bench:compare": "npm --prefix benchmarks run bench:compare --",
71
+ "bench:profile": "npm --prefix benchmarks run bench:profile --",
72
+ "bench:serve": "npm --prefix benchmarks run serve",
65
73
  "release": "npm publish --access public"
66
74
  },
67
75
  "types": "./index.d.ts",
package/src/launch.js CHANGED
@@ -20,6 +20,11 @@ import { initLog, Log } from './lib/log.js'
20
20
  import engine from './engine.js'
21
21
  import blitsPackageInfo from '../package.json' with { type: 'json' }
22
22
  import { configurePlatform } from './platform.js'
23
+ import profiler from './lib/profiler.js'
24
+
25
+ // Vite replaces __DEV__ with a boolean at build time. The typeof check keeps
26
+ // the source usable outside Vite (for example in the test runner).
27
+ const isDev = typeof __DEV__ !== 'undefined' && __DEV__
23
28
 
24
29
  /**
25
30
  * @typedef {Object} Font
@@ -93,6 +98,7 @@ async function rendererVersion() {
93
98
  * @property {boolean} [enableMouse] - Enable mouse support (hover and click-to-focus). Defaults to false.
94
99
  * @property {number} [maxFPS] - Maximum FPS
95
100
  * @property {function} [platform] - Platform factory for non-browser environments
101
+ * @property {boolean} [profiler] - Start the performance profiler on launch
96
102
  */
97
103
 
98
104
  /**
@@ -130,6 +136,12 @@ export default (App, target, settings) => {
130
136
 
131
137
  initLog()
132
138
 
139
+ if (isDev === true) {
140
+ // expose the profiler, so it can be controlled from the devtools console
141
+ globalThis.__BLITS_PROFILER__ = profiler
142
+ if (Settings.get('profiler', false) === true) profiler.start()
143
+ }
144
+
133
145
  if (warnLegacyRendererPlatform === true) {
134
146
  Log.warn(
135
147
  'Passing a renderer platform via settings.platform is deprecated. Use settings.rendererPlatform instead.'
@@ -144,4 +156,7 @@ export default (App, target, settings) => {
144
156
  stage.element = engine.Element
145
157
 
146
158
  renderer = engine.Launch(App, target, settings)
159
+ if (isDev === true) {
160
+ profiler.setRenderer(renderer)
161
+ }
147
162
  }
@@ -0,0 +1,62 @@
1
+ /*
2
+ * Copyright 2026 Comcast Cable Communications Management, LLC
3
+ * Licensed under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License.
5
+ * You may obtain a copy of the License at
6
+ *
7
+ * http://www.apache.org/licenses/LICENSE-2.0
8
+ *
9
+ * Unless required by applicable law or agreed to in writing, software
10
+ * distributed under the License is distributed on an "AS IS" BASIS,
11
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ * See the License for the specific language governing permissions and
13
+ * limitations under the License.
14
+ *
15
+ * SPDX-License-Identifier: Apache-2.0
16
+ */
17
+
18
+ import type { Profiler } from '@lightningjs/blits'
19
+
20
+ export type {
21
+ Profiler,
22
+ ProfilerEntry,
23
+ ProfilerFrameLabel,
24
+ ProfilerFrameSummary,
25
+ ProfilerFrameSnapshot,
26
+ ProfilerSnapshot,
27
+ } from '@lightningjs/blits'
28
+
29
+ /** Session-bound measurement token. Pass unchanged to profileEnd. */
30
+ export interface ProfileToken {
31
+ readonly sessionId: number
32
+ readonly startTime: number
33
+ }
34
+
35
+ export interface ProfilerFrameOptions {
36
+ frames?: boolean
37
+ maxFrames?: number
38
+ targetFps?: number
39
+ }
40
+
41
+ /**
42
+ * Marks the start of a measurement.
43
+ *
44
+ * Returns `0` when the profiler is disabled.
45
+ */
46
+ export function profileBegin(): ProfileToken | 0
47
+
48
+ /**
49
+ * Records a measurement started with `profileBegin`
50
+ */
51
+ export function profileEnd(name: string, start: ProfileToken | 0): void
52
+
53
+ /**
54
+ * Measures the execution time of a (synchronous) function
55
+ */
56
+ export function profile<T>(name: string, fn: () => T): T
57
+
58
+ /** Attaches the profiler to a renderer that emits frameTick events. */
59
+ export function setProfilerRenderer(renderer: unknown): void
60
+
61
+ declare const profiler: Profiler
62
+ export default profiler
@@ -0,0 +1,386 @@
1
+ /*
2
+ * Copyright 2026 Comcast Cable Communications Management, LLC
3
+ * Licensed under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License.
5
+ * You may obtain a copy of the License at
6
+ *
7
+ * http://www.apache.org/licenses/LICENSE-2.0
8
+ *
9
+ * Unless required by applicable law or agreed to in writing, software
10
+ * distributed under the License is distributed on an "AS IS" BASIS,
11
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ * See the License for the specific language governing permissions and
13
+ * limitations under the License.
14
+ *
15
+ * SPDX-License-Identifier: Apache-2.0
16
+ */
17
+
18
+ import { Log } from './log.js'
19
+
20
+ /**
21
+ * @typedef {Object} ProfilerEntry
22
+ * @property {number} calls - Number of times the measurement was recorded
23
+ * @property {number} totalMs - Total time spent, in milliseconds
24
+ * @property {number} maxMs - Slowest single measurement, in milliseconds
25
+ * @property {number} averageMs - Average time per call, in milliseconds
26
+ */
27
+
28
+ const stats = new Map()
29
+ const activeMarks = new Map()
30
+ let enabled = false
31
+ let sessionId = 0
32
+ let activeSpan
33
+ let renderer
34
+ let frameTickHandler
35
+ let frameConfig
36
+ let activeFrame
37
+ let frameHistory = []
38
+
39
+ const hasPerformance = typeof globalThis.performance === 'object'
40
+
41
+ const now = () => (hasPerformance === true ? globalThis.performance.now() : Date.now())
42
+
43
+ const percentile = (values, percent) => {
44
+ if (values.length === 0) return 0
45
+ const index = Math.min(values.length - 1, Math.floor(values.length * percent))
46
+ return values.slice().sort((a, b) => a - b)[index]
47
+ }
48
+
49
+ const frameSummary = (values) => ({
50
+ medianMs: percentile(values, 0.5),
51
+ p95Ms: percentile(values, 0.95),
52
+ p99Ms: percentile(values, 0.99),
53
+ maxMs: values.length === 0 ? 0 : Math.max(...values),
54
+ })
55
+
56
+ const clearFrames = () => {
57
+ activeFrame = undefined
58
+ frameHistory = []
59
+ }
60
+
61
+ const finishFrame = (data) => {
62
+ if (activeFrame === undefined || activeFrame.sessionId !== sessionId) return
63
+ activeFrame.intervalMs = typeof data.delta === 'number' ? data.delta : 0
64
+ frameHistory.push(activeFrame)
65
+ if (frameHistory.length > frameConfig.maxFrames) frameHistory.shift()
66
+ }
67
+
68
+ const beginFrame = (data) => {
69
+ finishFrame(data)
70
+ activeFrame = {
71
+ sessionId,
72
+ intervalMs: 0,
73
+ totalMs: 0,
74
+ labels: new Map(),
75
+ }
76
+ }
77
+
78
+ const onFrameTick = (...args) => {
79
+ if (enabled !== true || frameConfig === undefined) return
80
+ const data = args[args.length - 1]
81
+ if (!data || typeof data.time !== 'number') return
82
+ beginFrame(data)
83
+ }
84
+
85
+ /**
86
+ * Attaches optional frame collection to a renderer that emits `frameTick`.
87
+ * @param {{on?: Function, off?: Function}} nextRenderer RendererMain instance
88
+ * @returns {void}
89
+ */
90
+ export const setProfilerRenderer = (nextRenderer) => {
91
+ if (renderer === nextRenderer) return
92
+ if (renderer && frameTickHandler && typeof renderer.off === 'function') {
93
+ renderer.off('frameTick', frameTickHandler)
94
+ }
95
+ renderer = nextRenderer
96
+ frameTickHandler = undefined
97
+ if (renderer && typeof renderer.on === 'function') {
98
+ frameTickHandler = onFrameTick
99
+ renderer.on('frameTick', frameTickHandler)
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Marks the start of a measurement.
105
+ *
106
+ * Returns `0` when the profiler is disabled, which keeps the overhead
107
+ * down to a single boolean check.
108
+ *
109
+ * @returns {{sessionId: number, startTime: number} | 0} Token to pass into {@link profileEnd}
110
+ */
111
+ export const profileBegin = () => {
112
+ if (enabled !== true) return 0
113
+ const token = {
114
+ sessionId,
115
+ startTime: now(),
116
+ parent: activeSpan,
117
+ frame: activeFrame,
118
+ }
119
+ activeSpan = token
120
+ return token
121
+ }
122
+
123
+ /**
124
+ * Records a measurement started with {@link profileBegin}.
125
+ *
126
+ * @param {string} name - Label the measurement is aggregated under
127
+ * @param {{sessionId: number, startTime: number} | 0} start - Token returned by {@link profileBegin}
128
+ * @returns {void}
129
+ */
130
+ export const profileEnd = (name, start) => {
131
+ if (enabled !== true || !start || start.sessionId !== sessionId) return
132
+
133
+ let entry = stats.get(name)
134
+ if (entry === undefined) {
135
+ entry = { calls: 0, totalMs: 0, maxMs: 0 }
136
+ stats.set(name, entry)
137
+ }
138
+
139
+ const elapsed = now() - start.startTime
140
+ start.closed = true
141
+ entry.calls++
142
+ entry.totalMs += elapsed
143
+ if (elapsed > entry.maxMs) entry.maxMs = elapsed
144
+
145
+ if (start.frame && start.frame.sessionId === sessionId) {
146
+ let frameEntry = start.frame.labels.get(name)
147
+ if (frameEntry === undefined) {
148
+ frameEntry = { calls: 0, totalMs: 0 }
149
+ start.frame.labels.set(name, frameEntry)
150
+ }
151
+ frameEntry.calls++
152
+ frameEntry.totalMs += elapsed
153
+ if (start.parent === undefined || start.parent.frame !== start.frame)
154
+ start.frame.totalMs += elapsed
155
+ }
156
+
157
+ if (activeSpan === start) {
158
+ activeSpan = start.parent
159
+ while (activeSpan !== undefined && activeSpan.closed === true) activeSpan = activeSpan.parent
160
+ }
161
+ }
162
+
163
+ const mark = (name) => {
164
+ if (enabled !== true) return
165
+
166
+ let marks = activeMarks.get(name)
167
+ if (marks === undefined) {
168
+ marks = []
169
+ activeMarks.set(name, marks)
170
+ }
171
+ marks.push(profileBegin())
172
+ }
173
+
174
+ const markEnd = (name) => {
175
+ if (enabled !== true) return
176
+
177
+ const marks = activeMarks.get(name)
178
+ if (marks === undefined) return
179
+
180
+ const start = marks.pop()
181
+ if (marks.length === 0) activeMarks.delete(name)
182
+ profileEnd(name, start)
183
+ }
184
+
185
+ /**
186
+ * Measures the execution time of a (synchronous) function.
187
+ *
188
+ * @template T
189
+ * @param {string} name - Label the measurement is aggregated under
190
+ * @param {() => T} fn - Function to measure
191
+ * @returns {T} The return value of `fn`
192
+ */
193
+ export const profile = (name, fn) => {
194
+ if (enabled !== true) return fn()
195
+ const start = profileBegin()
196
+ try {
197
+ return fn()
198
+ } finally {
199
+ profileEnd(name, start)
200
+ }
201
+ }
202
+
203
+ const measurementsSnapshot = () =>
204
+ Object.fromEntries(
205
+ [...stats].map(([name, entry]) => [name, { ...entry, averageMs: entry.totalMs / entry.calls }])
206
+ )
207
+
208
+ const frameSnapshot = () => {
209
+ const config = frameConfig || { maxFrames: 0, targetFrameMs: 1000 / 60 }
210
+ const intervals = frameHistory.map((frame) => frame.intervalMs)
211
+ const instrumented = frameHistory.map((frame) => frame.totalMs)
212
+ const labels = new Map()
213
+ for (const frame of frameHistory) {
214
+ for (const [name, entry] of frame.labels) {
215
+ let total = labels.get(name)
216
+ if (total === undefined) {
217
+ total = { calls: 0, totalMs: 0 }
218
+ labels.set(name, total)
219
+ }
220
+ total.calls += entry.calls
221
+ total.totalMs += entry.totalMs
222
+ }
223
+ }
224
+ const frameCount = frameHistory.length
225
+ const slowFrames = intervals.filter((value) => value > config.targetFrameMs * 1.5).length
226
+ const slowestFrames = frameHistory
227
+ .slice()
228
+ .sort((a, b) => b.totalMs - a.totalMs)
229
+ .slice(0, 10)
230
+ .map((frame) => ({
231
+ intervalMs: frame.intervalMs,
232
+ instrumentedMs: frame.totalMs,
233
+ labels: Object.fromEntries(frame.labels),
234
+ }))
235
+ return {
236
+ frameCount,
237
+ retainedFrameLimit: config.maxFrames,
238
+ frameInterval: frameSummary(intervals),
239
+ instrumentedTime: frameSummary(instrumented),
240
+ slowFramePercent: frameCount === 0 ? 0 : (slowFrames / frameCount) * 100,
241
+ slowestFrames,
242
+ labels: Object.fromEntries(
243
+ [...labels].map(([name, entry]) => [
244
+ name,
245
+ {
246
+ ...entry,
247
+ callsPerFrame: frameCount === 0 ? 0 : entry.calls / frameCount,
248
+ averageMsPerFrame: frameCount === 0 ? 0 : entry.totalMs / frameCount,
249
+ },
250
+ ])
251
+ ),
252
+ }
253
+ }
254
+
255
+ const snapshot = () => ({
256
+ measurements: measurementsSnapshot(),
257
+ frames: frameSnapshot(),
258
+ })
259
+
260
+ const report = () => {
261
+ const result = snapshot()
262
+ const measurements = result.measurements
263
+ const frameLabels = result.frames.labels
264
+ const slowFrames = Object.fromEntries(
265
+ result.frames.slowestFrames.map((frame, index) => [
266
+ `#${index + 1}`,
267
+ {
268
+ intervalMs: frame.intervalMs,
269
+ instrumentedMs: frame.instrumentedMs,
270
+ labels: Object.entries(frame.labels)
271
+ .sort(([, a], [, b]) => b.totalMs - a.totalMs)
272
+ .map(
273
+ ([name, entry]) =>
274
+ `${name}: ${entry.totalMs.toFixed(2)} ms (${entry.calls} ${
275
+ entry.calls === 1 ? 'call' : 'calls'
276
+ })`
277
+ )
278
+ .join(' | '),
279
+ },
280
+ ])
281
+ )
282
+
283
+ Log.info('Profiler summary')
284
+ console.table({
285
+ 'Measurements tracked': Object.keys(measurements).length,
286
+ 'Frames analyzed': result.frames.frameCount,
287
+ 'Median frame interval (ms)': result.frames.frameInterval.medianMs,
288
+ 'p95 frame interval (ms)': result.frames.frameInterval.p95Ms,
289
+ 'Median instrumented time (ms)': result.frames.instrumentedTime.medianMs,
290
+ 'p95 instrumented time (ms)': result.frames.instrumentedTime.p95Ms,
291
+ 'Slow frames (%)': result.frames.slowFramePercent,
292
+ })
293
+ if (Object.keys(measurements).length) {
294
+ Log.info('Profiler: all marker timings')
295
+ console.table(measurements)
296
+ }
297
+ if (Object.keys(frameLabels).length) {
298
+ Log.info('Profiler: marker timings within retained frames')
299
+ console.table(frameLabels)
300
+ }
301
+ if (Object.keys(slowFrames).length) {
302
+ Log.info('Profiler: most expensive retained frames')
303
+ console.table(slowFrames)
304
+ }
305
+ return result
306
+ }
307
+
308
+ /**
309
+ * Lightweight, opt-in profiler used to measure performance bottlenecks
310
+ * in Blits internals and in App code.
311
+ */
312
+ const profiler = {
313
+ /**
314
+ * Clears previously collected measurements and starts recording
315
+ * @returns {void}
316
+ */
317
+ start(options = {}) {
318
+ sessionId++
319
+ stats.clear()
320
+ activeMarks.clear()
321
+ activeSpan = undefined
322
+ frameConfig =
323
+ options.frames !== false
324
+ ? {
325
+ maxFrames: options.maxFrames || 300,
326
+ targetFrameMs: 1000 / (options.targetFps || 60),
327
+ }
328
+ : undefined
329
+ clearFrames()
330
+ enabled = true
331
+ },
332
+ /**
333
+ * Marks the start of a measurement
334
+ * @param {string} name - Label the measurement is aggregated under
335
+ * @returns {void}
336
+ */
337
+ mark,
338
+ /**
339
+ * Ends the most recent measurement started with the same name
340
+ * @param {string} name - Label passed to {@link mark}
341
+ * @returns {void}
342
+ */
343
+ markEnd,
344
+ /**
345
+ * Stops recording and returns the collected measurements
346
+ * @returns {Object.<string, ProfilerEntry>}
347
+ */
348
+ stop() {
349
+ enabled = false
350
+ activeMarks.clear()
351
+ activeSpan = undefined
352
+ return snapshot()
353
+ },
354
+ /**
355
+ * Clears collected measurements and pending spans, without changing the enabled state
356
+ * @returns {void}
357
+ */
358
+ reset() {
359
+ sessionId++
360
+ stats.clear()
361
+ activeMarks.clear()
362
+ activeSpan = undefined
363
+ clearFrames()
364
+ },
365
+ /**
366
+ * Returns the measurements collected so far
367
+ * @returns {Object.<string, ProfilerEntry>}
368
+ */
369
+ snapshot,
370
+ report,
371
+ setRenderer: setProfilerRenderer,
372
+ /**
373
+ * Returns frame-oriented measurements collected by default or with start({ frames: true }).
374
+ * @returns {object}
375
+ */
376
+ frameSnapshot,
377
+ /**
378
+ * Whether the profiler is currently recording
379
+ * @returns {boolean}
380
+ */
381
+ get enabled() {
382
+ return enabled
383
+ },
384
+ }
385
+
386
+ export default profiler