1tube 0.1.8 → 0.1.9

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.8",
3
+ "version": "0.1.9",
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",
@@ -0,0 +1,140 @@
1
+ /**
2
+ * TTY-aware live boot progress for function discovery.
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.
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.
15
+ */
16
+
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;
21
+
22
+ export interface BootProgress {
23
+ /** Initialise / reset for a new batch of `total` items. */
24
+ start(total: number): void;
25
+ /** An import has begun. */
26
+ onStart(name: string): void;
27
+ /** An item finished — print `line` (the formatted completion) above the spinner. */
28
+ onFinish(line: string, name: string): void;
29
+ /** Stop refreshing and clear the live line. Call from a try/finally. */
30
+ stop(): void;
31
+ }
32
+
33
+ interface Sink {
34
+ isTerminal(): boolean;
35
+ writeSync(chunk: Uint8Array): number;
36
+ }
37
+
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 {
43
+ const enc = new TextEncoder();
44
+ const isTty = (() => {
45
+ try {
46
+ return sink.isTerminal();
47
+ } catch {
48
+ return false;
49
+ }
50
+ })();
51
+
52
+ const active = new Set<string>();
53
+ let completed = 0;
54
+ let total = 0;
55
+ let startedAt = 0;
56
+ 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 = "";
61
+
62
+ const write = (s: string) => {
63
+ try {
64
+ sink.writeSync(enc.encode(s));
65
+ } catch {
66
+ // closed pipe — give up silently rather than crash boot.
67
+ }
68
+ };
69
+
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
+ };
76
+
77
+ const draw = () => {
78
+ if (!isTty) return;
79
+ 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;
99
+ };
100
+
101
+ return {
102
+ start(t) {
103
+ total = t;
104
+ completed = 0;
105
+ active.clear();
106
+ 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();
113
+ }
114
+ },
115
+ onStart(name) {
116
+ 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
+ },
125
+ onFinish(line, name) {
126
+ active.delete(name);
127
+ completed++;
128
+ eraseLive();
129
+ write(line + "\n");
130
+ draw();
131
+ },
132
+ stop() {
133
+ if (timer !== undefined) {
134
+ clearInterval(timer);
135
+ timer = undefined;
136
+ }
137
+ eraseLive();
138
+ },
139
+ };
140
+ }
package/src/discovery.ts CHANGED
@@ -34,6 +34,14 @@ export interface DiscoveryOptions {
34
34
  concurrency?: number;
35
35
  /** Invoked once per function as it finishes (success or failure). */
36
36
  onProgress?: (p: DiscoveryProgress) => void;
37
+ /**
38
+ * Invoked once per function the moment its dynamic import is dispatched
39
+ * (or, in lazy mode, the moment it's registered as a candidate). Useful
40
+ * for live progress UIs that want to show what's currently in-flight,
41
+ * not just what's completed — important because a single transpile can
42
+ * block a worker for several seconds with no completion events.
43
+ */
44
+ onStart?: (p: { name: string; index: number; total: number }) => void;
37
45
  /**
38
46
  * When true, candidates are registered for lazy import on first dispatch.
39
47
  * Functions whose manifest sets `warm: true` are still imported eagerly
@@ -109,6 +117,7 @@ export async function discoverAndLoad(
109
117
  }
110
118
 
111
119
  const total = work.length;
120
+ let started = 0;
112
121
  let completed = 0;
113
122
 
114
123
  const loadOne = async (name: string): Promise<void> => {
@@ -121,6 +130,7 @@ export async function discoverAndLoad(
121
130
  moduleUrl.searchParams.set("v", options.cacheBust);
122
131
  }
123
132
 
133
+ options?.onStart?.({ name, index: ++started, total });
124
134
  const start = performance.now();
125
135
  try {
126
136
  // Load the manifest first so it is attached to the registry before the
package/src/server.ts CHANGED
@@ -21,6 +21,7 @@ import { bodyLimit } from "npm:hono@4/body-limit";
21
21
  import { relative, sep as SEPARATOR } from "node:path";
22
22
  import { currentRequestStorage, FunctionRegistry } from "./registry.ts";
23
23
  import { discoverAndLoad, type DiscoveryProgress } from "./discovery.ts";
24
+ import { createBootProgress } from "./boot-progress.ts";
24
25
  import { validateRequest } from "./gateway/auth.ts";
25
26
  import { watchdogBody } from "./gateway/body-watchdog.ts";
26
27
  import { corsMiddleware } from "./gateway/cors.ts";
@@ -64,9 +65,12 @@ function parseArgs(): CliOpts {
64
65
 
65
66
  let dev = envFlag("1TUBE_DEV");
66
67
  let hmr = envFlag("1TUBE_HMR");
67
- // Lazy-load is on by default; opt out with --eager / 1TUBE_LAZY=0.
68
+ // Eager loading is the default — first-request latency reflects real
69
+ // function cost (no on-demand transpile tax). Opt in to lazy with
70
+ // --lazy or 1TUBE_LAZY=1 when boot speed matters more than first-hit
71
+ // latency (e.g. sites with hundreds of rarely-called functions).
68
72
  const lazyEnv = Deno.env.get("1TUBE_LAZY");
69
- let lazy = lazyEnv === undefined ? true : !(lazyEnv === "0" || lazyEnv === "false");
73
+ let lazy = lazyEnv === undefined ? false : (lazyEnv === "1" || lazyEnv === "true");
70
74
  let port = parseInt(Deno.env.get("PORT") || "3100", 10);
71
75
  let host = (Deno.env.get("1TUBE_HOST") || "127.0.0.1").trim();
72
76
  let functionsPath = Deno.env.get("FUNCTIONS_PATH") || "./supabase/functions";
@@ -298,16 +302,45 @@ async function reloadFunctions(
298
302
  // HMR reloads always import eagerly so the change is observable on the
299
303
  // very next request — even when boot-time discovery was lazy.
300
304
  const useLazy = opts.lazy && isFullReload;
301
- const { loaded, skipped, errors, removed, deferred } = await discoverAndLoad(
302
- opts.functionsPath,
303
- registry,
304
- {
305
- cacheBust,
306
- only: isFullReload ? undefined : changedFunctionNames,
307
- onProgress: (p) => console.log(progressLine(p)),
308
- lazy: useLazy,
309
- },
310
- );
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);
316
+
317
+ let result;
318
+ try {
319
+ if (useSpinner) progress.start(0); // total filled in once we see the first onStart
320
+ result = await discoverAndLoad(
321
+ opts.functionsPath,
322
+ registry,
323
+ {
324
+ cacheBust,
325
+ 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)),
337
+ lazy: useLazy,
338
+ },
339
+ );
340
+ } finally {
341
+ progress.stop();
342
+ }
343
+ const { loaded, skipped, errors, removed, deferred } = result;
311
344
 
312
345
  for (const name of removed) {
313
346
  if (registry.delete(name)) {