1tube 0.1.8 → 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.8",
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",
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Append-only boot progress reporter for function discovery.
3
+ *
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
+ *
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.
22
+ */
23
+
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;
27
+
28
+ export interface BootProgress {
29
+ /** Reset and arm the heartbeat for a new batch of `total` items. */
30
+ start(total: number): void;
31
+ /** An import has begun. */
32
+ onStart(name: string): void;
33
+ /** Print a completion line and update internal counters. */
34
+ onFinish(line: string, name: string): void;
35
+ /** Stop the heartbeat. Safe to call repeatedly. */
36
+ stop(): void;
37
+ }
38
+
39
+ interface Sink {
40
+ writeSync(chunk: Uint8Array): number;
41
+ // Kept in the interface for future use / fakes; renderer ignores it.
42
+ isTerminal?(): boolean;
43
+ }
44
+
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 {
54
+ const enc = new TextEncoder();
55
+ const pulseMs = opts.pulseMs ?? DEFAULT_PULSE_MS;
56
+
57
+ const active = new Set<string>();
58
+ let completed = 0;
59
+ let total = 0;
60
+ let startedAt = 0;
61
+ let timer: number | undefined;
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 = "";
66
+
67
+ const write = (s: string) => {
68
+ try {
69
+ sink.writeSync(enc.encode(s));
70
+ } catch {
71
+ // Closed pipe — never crash boot over a broken stdout.
72
+ }
73
+ };
74
+
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
+ );
83
+ };
84
+
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;
91
+ const elapsed = ((performance.now() - startedAt) / 1000).toFixed(1);
92
+ write(
93
+ `[1tube] \x1b[2m… still working: ${completed}/${total} loaded, ` +
94
+ `${active.size} in progress (${renderInflight()}) (${elapsed}s)\x1b[0m\n`,
95
+ );
96
+ };
97
+
98
+ return {
99
+ start(t) {
100
+ total = t;
101
+ completed = 0;
102
+ active.clear();
103
+ startedAt = performance.now();
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;
111
+ }
112
+ },
113
+ onStart(name) {
114
+ active.add(name);
115
+ },
116
+ onFinish(line, name) {
117
+ active.delete(name);
118
+ completed++;
119
+ write(line + "\n");
120
+ // Reset the snapshot so the next pulse always reflects the new state
121
+ // immediately rather than waiting another `pulseMs`.
122
+ lastSnapshot = "";
123
+ },
124
+ stop() {
125
+ if (timer !== undefined) {
126
+ clearInterval(timer);
127
+ timer = undefined;
128
+ }
129
+ },
130
+ };
131
+ }
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,40 @@ 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
+ // 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
+
317
+ let result;
318
+ try {
319
+ result = await discoverAndLoad(
320
+ opts.functionsPath,
321
+ registry,
322
+ {
323
+ cacheBust,
324
+ only: isFullReload ? undefined : changedFunctionNames,
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),
332
+ lazy: useLazy,
333
+ },
334
+ );
335
+ } finally {
336
+ progress.stop();
337
+ }
338
+ const { loaded, skipped, errors, removed, deferred } = result;
311
339
 
312
340
  for (const name of removed) {
313
341
  if (registry.delete(name)) {