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