1tube 0.1.9 → 0.1.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "1tube",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "private": false,
5
5
  "description": "Self-hosted Supabase Edge Functions gateway for Deno, plus an ESLint plugin for consumers",
6
6
  "license": "MIT",
@@ -1,101 +1,98 @@
1
1
  /**
2
- * TTY-aware live boot progress for function discovery.
2
+ * Append-only boot progress reporter for function discovery.
3
3
  *
4
- * Problem: with `concurrency=8` (the discovery default), all eight imports
5
- * start in parallel and don't finish for several seconds. The previous
6
- * "print one line per completion" UI showed nothing during that window —
7
- * users assumed the process was hung. This module fills the gap with a
8
- * single in-place updated status line that shows what's currently being
9
- * compiled, plus elapsed time. Completed lines still print above it as
10
- * they finish, so scrollback is preserved.
4
+ * Why not a live in-place spinner? With many functions importing in
5
+ * parallel, their top-level `console.log()` calls hit the same TTY as our
6
+ * renderer. Any cursor-rewrite scheme (\r + clear-line, ANSI cursor
7
+ * positioning, alt-screen, even Ink) corrupts the moment a foreign writer
8
+ * lands a line — you get flicker, half-erased frames, or text glued to
9
+ * the spinner. Below 200 ms of redraw cadence the eye also reads it as
10
+ * blinking, which the operator perceives as "broken UI".
11
11
  *
12
- * On non-TTY stdouts (piped output, CI logs) the spinner is silent and we
13
- * fall back to printing "starting" markers — same total information, no
14
- * cursor manipulation that would render as garbage in a log file.
12
+ * So we don't redraw. We print one normal newline-terminated line every
13
+ * `pulseMs` saying "still working, X/Y done, in progress: …". It's not
14
+ * animated, but it's:
15
+ * - race-free with foreign stdout writers,
16
+ * - zero-cost (one syscall every couple of seconds),
17
+ * - safe to pipe to a file or capture in CI,
18
+ * - and identical between TTY and non-TTY paths (no branching).
19
+ *
20
+ * Pulses self-suppress when no work has changed since the previous tick,
21
+ * so a wedged import won't spam the log either.
15
22
  */
16
23
 
17
- const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
18
- const REFRESH_MS = 100;
19
- /** Truncate the in-flight name list so the line fits a typical terminal. */
20
- const MAX_LINE = 100;
24
+ const DEFAULT_PULSE_MS = 2000;
25
+ /** Cap inflight names rendered inline so a long list doesn't wrap. */
26
+ const MAX_INFLIGHT_RENDERED = 4;
21
27
 
22
28
  export interface BootProgress {
23
- /** Initialise / reset for a new batch of `total` items. */
29
+ /** Reset and arm the heartbeat for a new batch of `total` items. */
24
30
  start(total: number): void;
25
31
  /** An import has begun. */
26
32
  onStart(name: string): void;
27
- /** An item finished — print `line` (the formatted completion) above the spinner. */
33
+ /** Print a completion line and update internal counters. */
28
34
  onFinish(line: string, name: string): void;
29
- /** Stop refreshing and clear the live line. Call from a try/finally. */
35
+ /** Stop the heartbeat. Safe to call repeatedly. */
30
36
  stop(): void;
31
37
  }
32
38
 
33
39
  interface Sink {
34
- isTerminal(): boolean;
35
40
  writeSync(chunk: Uint8Array): number;
41
+ // Kept in the interface for future use / fakes; renderer ignores it.
42
+ isTerminal?(): boolean;
36
43
  }
37
44
 
38
- /**
39
- * Build a progress renderer. `sink` defaults to Deno.stdout but can be
40
- * overridden in tests.
41
- */
42
- export function createBootProgress(sink: Sink = Deno.stdout): BootProgress {
45
+ export interface BootProgressOptions {
46
+ /** Heartbeat cadence in ms. Default 2000. Set ≤0 to disable heartbeats. */
47
+ pulseMs?: number;
48
+ }
49
+
50
+ export function createBootProgress(
51
+ sink: Sink = Deno.stdout,
52
+ opts: BootProgressOptions = {},
53
+ ): BootProgress {
43
54
  const enc = new TextEncoder();
44
- const isTty = (() => {
45
- try {
46
- return sink.isTerminal();
47
- } catch {
48
- return false;
49
- }
50
- })();
55
+ const pulseMs = opts.pulseMs ?? DEFAULT_PULSE_MS;
51
56
 
52
57
  const active = new Set<string>();
53
58
  let completed = 0;
54
59
  let total = 0;
55
60
  let startedAt = 0;
56
61
  let timer: number | undefined;
57
- let frame = 0;
58
- // Track how many cells the live line currently occupies so we can erase
59
- // it cleanly even if it wrapped.
60
- let lastDrawn = "";
62
+ // Snapshot used to suppress a heartbeat when nothing has moved since the
63
+ // last pulse — important when a single slow import blocks the worker
64
+ // pool and we'd otherwise emit identical lines forever.
65
+ let lastSnapshot = "";
61
66
 
62
67
  const write = (s: string) => {
63
68
  try {
64
69
  sink.writeSync(enc.encode(s));
65
70
  } catch {
66
- // closed pipe — give up silently rather than crash boot.
71
+ // Closed pipe — never crash boot over a broken stdout.
67
72
  }
68
73
  };
69
74
 
70
- const eraseLive = () => {
71
- if (!isTty || lastDrawn.length === 0) return;
72
- // CR + clear-to-end-of-line. Works in any VT100-compatible terminal.
73
- write("\r\x1b[K");
74
- lastDrawn = "";
75
+ const renderInflight = (): string => {
76
+ if (active.size === 0) return "(idle)";
77
+ const names = [...active];
78
+ if (names.length <= MAX_INFLIGHT_RENDERED) return names.join(", ");
79
+ return (
80
+ names.slice(0, MAX_INFLIGHT_RENDERED).join(", ") +
81
+ ` +${names.length - MAX_INFLIGHT_RENDERED} more`
82
+ );
75
83
  };
76
84
 
77
- const draw = () => {
78
- if (!isTty) return;
85
+ const pulse = () => {
86
+ if (total === 0) return;
87
+ if (completed >= total) return;
88
+ const snapshot = `${completed}|${[...active].sort().join(",")}`;
89
+ if (snapshot === lastSnapshot) return;
90
+ lastSnapshot = snapshot;
79
91
  const elapsed = ((performance.now() - startedAt) / 1000).toFixed(1);
80
- const spin = SPINNER[frame++ % SPINNER.length];
81
- const names = [...active];
82
- let inflight: string;
83
- if (names.length === 0) {
84
- inflight = "waiting…";
85
- } else if (names.length <= 4) {
86
- inflight = names.join(", ");
87
- } else {
88
- inflight = names.slice(0, 4).join(", ") + ` +${names.length - 4} more`;
89
- }
90
- let line =
91
- `\x1b[36m${spin}\x1b[0m \x1b[1m${completed}/${total}\x1b[0m loaded, ` +
92
- `${active.size} in progress: \x1b[2m${inflight}\x1b[0m \x1b[2m(${elapsed}s)\x1b[0m`;
93
- if (line.length > MAX_LINE) {
94
- line = line.slice(0, MAX_LINE - 1) + "…";
95
- }
96
- eraseLive();
97
- write(line);
98
- lastDrawn = line;
92
+ write(
93
+ `[1tube] \x1b[2m… still working: ${completed}/${total} loaded, ` +
94
+ `${active.size} in progress (${renderInflight()}) (${elapsed}s)\x1b[0m\n`,
95
+ );
99
96
  };
100
97
 
101
98
  return {
@@ -104,37 +101,31 @@ export function createBootProgress(sink: Sink = Deno.stdout): BootProgress {
104
101
  completed = 0;
105
102
  active.clear();
106
103
  startedAt = performance.now();
107
- frame = 0;
108
- lastDrawn = "";
109
- if (isTty && t > 0) {
110
- timer = setInterval(draw, REFRESH_MS) as unknown as number;
111
- // Draw immediately so users see *something* before the first onStart.
112
- draw();
104
+ lastSnapshot = "";
105
+ if (timer !== undefined) {
106
+ clearInterval(timer);
107
+ timer = undefined;
108
+ }
109
+ if (pulseMs > 0 && t > 0) {
110
+ timer = setInterval(pulse, pulseMs) as unknown as number;
113
111
  }
114
112
  },
115
113
  onStart(name) {
116
114
  active.add(name);
117
- // Non-TTY: announce starts inline so logs still show progress between
118
- // completions. We rate-limit to one announcement per batch by only
119
- // emitting when nothing else is in-flight yet.
120
- if (!isTty && active.size <= 4) {
121
- write(`[1tube] \x1b[2m→ compiling ${name}\x1b[0m\n`);
122
- }
123
- draw();
124
115
  },
125
116
  onFinish(line, name) {
126
117
  active.delete(name);
127
118
  completed++;
128
- eraseLive();
129
119
  write(line + "\n");
130
- draw();
120
+ // Reset the snapshot so the next pulse always reflects the new state
121
+ // immediately rather than waiting another `pulseMs`.
122
+ lastSnapshot = "";
131
123
  },
132
124
  stop() {
133
125
  if (timer !== undefined) {
134
126
  clearInterval(timer);
135
127
  timer = undefined;
136
128
  }
137
- eraseLive();
138
129
  },
139
130
  };
140
131
  }
package/src/server.ts CHANGED
@@ -303,37 +303,32 @@ async function reloadFunctions(
303
303
  // very next request — even when boot-time discovery was lazy.
304
304
  const useLazy = opts.lazy && isFullReload;
305
305
 
306
- // Live progress: shows a spinner with currently in-flight imports so the
307
- // operator doesn't stare at a silent terminal during the multi-second
308
- // first-transpile window. Falls back to plain "starting…" lines when
309
- // stdout isn't a TTY (CI logs, output redirection).
310
- const progress = createBootProgress();
311
- // Skip the live spinner for tiny HMR reloads (one or two functions) —
312
- // the existing per-completion line is enough and a spinner would just
313
- // flicker on screen.
314
- const useSpinner = isFullReload ||
315
- (changedFunctionNames instanceof Set && changedFunctionNames.size > 2);
306
+ // Append-only "still working" heartbeat. Does not redraw, does not race
307
+ // with foreign console.log() from imported modules — see
308
+ // src/boot-progress.ts for the design rationale. Heartbeat is silent for
309
+ // tiny HMR reloads (≤2 functions) where the per-completion line is
310
+ // already enough signal.
311
+ const isTinyReload = changedFunctionNames instanceof Set &&
312
+ changedFunctionNames.size <= 2;
313
+ const progress = createBootProgress(Deno.stdout, {
314
+ pulseMs: isTinyReload ? 0 : 2000,
315
+ });
316
316
 
317
317
  let result;
318
318
  try {
319
- if (useSpinner) progress.start(0); // total filled in once we see the first onStart
320
319
  result = await discoverAndLoad(
321
320
  opts.functionsPath,
322
321
  registry,
323
322
  {
324
323
  cacheBust,
325
324
  only: isFullReload ? undefined : changedFunctionNames,
326
- onStart: useSpinner
327
- ? (p) => {
328
- // Late-bind the total on the first start event — by then we
329
- // know the real work-list size.
330
- if (p.index === 1) progress.start(p.total);
331
- progress.onStart(p.name);
332
- }
333
- : undefined,
334
- onProgress: useSpinner
335
- ? (p) => progress.onFinish(progressLine(p), p.name)
336
- : (p) => console.log(progressLine(p)),
325
+ onStart: (p) => {
326
+ // Late-bind the total on the first start event — by then we
327
+ // know the real work-list size after `only`/exists filtering.
328
+ if (p.index === 1) progress.start(p.total);
329
+ progress.onStart(p.name);
330
+ },
331
+ onProgress: (p) => progress.onFinish(progressLine(p), p.name),
337
332
  lazy: useLazy,
338
333
  },
339
334
  );