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 +1 -1
- package/src/boot-progress.ts +140 -0
- package/src/discovery.ts +10 -0
- package/src/server.ts +45 -12
package/package.json
CHANGED
|
@@ -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
|
-
//
|
|
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 ?
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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)) {
|