shimmer-worker.ts 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  1. import { parentPort, workerData } from 'worker_threads';
  2. import { writeSync } from 'fs';
  3. import { getGlyphs } from './glyphs';
  4. import type { ShimmerWorkerMessage } from './types';
  5. // Write directly to fd 1 (stdout) instead of writeStdout().
  6. // In Node.js worker threads, process.stdout is proxied through the main
  7. // thread's event loop — so if the main thread is blocked (e.g. SQLite),
  8. // stdout writes from the worker queue up and the animation freezes.
  9. // fs.writeSync(1, ...) is a direct kernel syscall that bypasses this.
  10. //
  11. // Side effect: bypasses Node's TTY-aware encoding conversion on Windows,
  12. // so UTF-8 bytes hit the console raw and mojibake on OEM codepages.
  13. // `getGlyphs()` returns ASCII fallbacks on Windows to avoid this (#168).
  14. function writeStdout(s: string): void {
  15. writeSync(1, s);
  16. }
  17. const G = getGlyphs();
  18. const SPINNER_GLYPHS = G.spinner;
  19. const ANIM_INTERVAL = 150;
  20. const FRAMES_PER_GLYPH = 3;
  21. const RST = '\x1b[0m';
  22. const DM = '\x1b[2m';
  23. const GRN = '\x1b[32m';
  24. const BOLD = '\x1b[1m';
  25. const startTime: number = workerData.startTime;
  26. function animFrame(): number {
  27. return Math.floor((Date.now() - startTime) / ANIM_INTERVAL);
  28. }
  29. function lerp(a: number, b: number, t: number): number {
  30. return Math.round(a + (b - a) * t);
  31. }
  32. function shimmerColor(frame: number): string {
  33. const t = (Math.sin(frame * 2 * Math.PI / 13) + 1) / 2;
  34. const r = lerp(160, 251, t);
  35. const g = lerp(100, 191, t);
  36. const b = lerp(9, 36, t);
  37. return `\x1b[38;2;${r};${g};${b}m${BOLD}`;
  38. }
  39. function formatNumber(n: number): string {
  40. return n.toLocaleString();
  41. }
  42. function renderBar(frame: number, filled: number, empty: number): string {
  43. if (filled === 0) return `${DM}${G.barEmpty.repeat(empty)}${RST}`;
  44. const cycleFrames = 24;
  45. const shimmerPos = ((frame % cycleFrames) / cycleFrames) * (filled + 6) - 3;
  46. const shimmerWidth = 3;
  47. let bar = '';
  48. for (let i = 0; i < filled; i++) {
  49. const dist = Math.abs(i - shimmerPos);
  50. const t = Math.max(0, 1 - dist / shimmerWidth);
  51. const r = lerp(160, 251, t);
  52. const g = lerp(100, 191, t);
  53. const b = lerp(9, 36, t);
  54. bar += `\x1b[38;2;${r};${g};${b}m${BOLD}${G.barFilled}`;
  55. }
  56. bar += `${RST}${DM}${G.barEmpty.repeat(empty)}${RST}`;
  57. return bar;
  58. }
  59. // Mutable state
  60. let currentMessage = '';
  61. let currentPercent = -1;
  62. let currentCount = 0;
  63. function render(): void {
  64. if (!currentMessage) return;
  65. const frame = animFrame();
  66. const glyphIdx = Math.floor(frame / FRAMES_PER_GLYPH) % SPINNER_GLYPHS.length;
  67. const glyph = SPINNER_GLYPHS[glyphIdx] ?? SPINNER_GLYPHS[0] ?? '.';
  68. const color = shimmerColor(frame);
  69. let line: string;
  70. if (currentPercent >= 0) {
  71. const barWidth = 25;
  72. const filled = Math.round(barWidth * currentPercent / 100);
  73. const empty = barWidth - filled;
  74. line = `${DM}${G.rail}${RST} ${color}${glyph}${RST} ${currentMessage} ${renderBar(frame, filled, empty)} ${currentPercent}%`;
  75. } else if (currentCount > 0) {
  76. line = `${DM}${G.rail}${RST} ${color}${glyph}${RST} ${currentMessage}... ${formatNumber(currentCount)} found`;
  77. } else {
  78. line = `${DM}${G.rail}${RST} ${color}${glyph}${RST} ${currentMessage}...`;
  79. }
  80. writeStdout(`\r\x1b[K${line}`);
  81. }
  82. function finishPhase(): void {
  83. if (!currentMessage) return;
  84. writeStdout(`\r\x1b[K`);
  85. let detail = '';
  86. if (currentPercent >= 0) detail = ` ${G.dash} done`;
  87. else if (currentCount > 0) detail = ` ${G.dash} ${formatNumber(currentCount)} found`;
  88. writeStdout(`${DM}${G.rail}${RST} ${GRN}${G.phaseDone}${RST} ${currentMessage}${detail}\n`);
  89. currentMessage = '';
  90. currentPercent = -1;
  91. currentCount = 0;
  92. }
  93. // Render loop — independent of main thread
  94. const tickInterval = setInterval(render, 50);
  95. parentPort!.on('message', (msg: ShimmerWorkerMessage) => {
  96. if (msg.type === 'update') {
  97. currentMessage = msg.phaseName;
  98. currentPercent = msg.percent;
  99. currentCount = msg.count;
  100. } else if (msg.type === 'finish-phase') {
  101. finishPhase();
  102. } else if (msg.type === 'stop') {
  103. clearInterval(tickInterval);
  104. finishPhase();
  105. parentPort!.postMessage({ type: 'stopped' });
  106. }
  107. });