1tube 0.1.20 → 0.1.22

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/deno.json CHANGED
@@ -18,6 +18,7 @@
18
18
  "esbuild": "npm:esbuild@^0.28",
19
19
  "es-module-lexer": "npm:es-module-lexer@^2",
20
20
  "@deno/esbuild-plugin": "jsr:@deno/esbuild-plugin@^1.2",
21
+ "@deno/graph": "jsr:@deno/graph@^0.108",
21
22
  "@zip-js/zip-js": "jsr:@zip-js/zip-js@^2.8"
22
23
  },
23
24
  "lint": {
package/deno.lock CHANGED
@@ -2,6 +2,7 @@
2
2
  "version": "5",
3
3
  "specifiers": {
4
4
  "jsr:@deno/esbuild-plugin@^1.2.0": "1.2.1",
5
+ "jsr:@deno/graph@0.108": "0.108.1",
5
6
  "jsr:@deno/loader@~0.3.10": "0.3.14",
6
7
  "jsr:@std/assert@1": "1.0.19",
7
8
  "jsr:@std/fs@1": "1.0.22",
@@ -10,7 +11,10 @@
10
11
  "jsr:@std/path@^1.1.1": "1.1.4",
11
12
  "jsr:@std/path@^1.1.4": "1.1.4",
12
13
  "jsr:@zip-js/zip-js@^2.8.0": "2.8.26",
14
+ "npm:@ai-sdk/anthropic@2": "2.0.77_zod@3.25.76",
15
+ "npm:@ai-sdk/google@2": "2.0.70_zod@3.25.76",
13
16
  "npm:@ai-sdk/mcp@0": "0.0.15_zod@3.25.76",
17
+ "npm:@ai-sdk/openai@2": "2.0.103_zod@3.25.76",
14
18
  "npm:@google/genai@1.39.0": "1.39.0",
15
19
  "npm:@supabase/supabase-js@2": "2.93.3",
16
20
  "npm:ai@6": "6.0.67_zod@3.25.76",
@@ -31,6 +35,9 @@
31
35
  "npm:esbuild@~0.25.5"
32
36
  ]
33
37
  },
38
+ "@deno/graph@0.108.1": {
39
+ "integrity": "10ba877ae62f94987f4465aa94c355612382456f64bb9b1b9c1b473559f294af"
40
+ },
34
41
  "@deno/loader@0.3.14": {
35
42
  "integrity": "97bc63a6cc2d27a60bcdc953f588c5213331d866d44212eebb24cebfb9b011ca"
36
43
  },
@@ -60,6 +67,14 @@
60
67
  }
61
68
  },
62
69
  "npm": {
70
+ "@ai-sdk/anthropic@2.0.77_zod@3.25.76": {
71
+ "integrity": "sha512-8n7ApEzFOxqVvT3HyqLrEQlgUx/2nUmPFLTGY3fNKwUA8KVNU3Ovd2C66Qh1Y93Iq5NkHsOWuLiTyAZpRKQhgw==",
72
+ "dependencies": [
73
+ "@ai-sdk/provider@2.0.1",
74
+ "@ai-sdk/provider-utils@3.0.23_zod@3.25.76",
75
+ "zod"
76
+ ]
77
+ },
63
78
  "@ai-sdk/gateway@3.0.32_zod@3.25.76": {
64
79
  "integrity": "sha512-7clZRr07P9rpur39t1RrbIe7x8jmwnwUWI8tZs+BvAfX3NFgdSVGGIaT7bTz2pb08jmLXzTSDbrOTqAQ7uBkBQ==",
65
80
  "dependencies": [
@@ -69,6 +84,14 @@
69
84
  "zod"
70
85
  ]
71
86
  },
87
+ "@ai-sdk/google@2.0.70_zod@3.25.76": {
88
+ "integrity": "sha512-NDMTvMo6vnPHDTA94FBOh3YMv0lxWDohYmFSGYhg0IimHMcOcC1ZV7E2KMLjzHOz5S7uasTITW7V3X5T+ozInQ==",
89
+ "dependencies": [
90
+ "@ai-sdk/provider@2.0.1",
91
+ "@ai-sdk/provider-utils@3.0.23_zod@3.25.76",
92
+ "zod"
93
+ ]
94
+ },
72
95
  "@ai-sdk/mcp@0.0.15_zod@3.25.76": {
73
96
  "integrity": "sha512-bjWQGNh7zplf0sqW1RnSF9HzKGo+fCvD5BPTSSA+Fw/on+OAI6S6rBc/KHBWNyjJbIZZ9XvT6WpGYKdZHaKqlA==",
74
97
  "dependencies": [
@@ -78,6 +101,14 @@
78
101
  "zod"
79
102
  ]
80
103
  },
104
+ "@ai-sdk/openai@2.0.103_zod@3.25.76": {
105
+ "integrity": "sha512-FDwY060LV/D5th+LeaxpSKcot5eXjzNzHguDf0NU1K+v7rxYZFWbldQPZarNo/IpD/WJE9RojgrFAcZ1e8KyvQ==",
106
+ "dependencies": [
107
+ "@ai-sdk/provider@2.0.1",
108
+ "@ai-sdk/provider-utils@3.0.23_zod@3.25.76",
109
+ "zod"
110
+ ]
111
+ },
81
112
  "@ai-sdk/provider-utils@3.0.22_zod@3.25.76": {
82
113
  "integrity": "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw==",
83
114
  "dependencies": [
@@ -87,6 +118,15 @@
87
118
  "zod"
88
119
  ]
89
120
  },
121
+ "@ai-sdk/provider-utils@3.0.23_zod@3.25.76": {
122
+ "integrity": "sha512-60GYsRj5wIJQRcq5YwYJq4KhwLeStceXEJiZdecP1miiH+6FMmrnc7lZDOJoQ6m9lrudEb+uI4LEwddLz5+rPQ==",
123
+ "dependencies": [
124
+ "@ai-sdk/provider@2.0.1",
125
+ "@standard-schema/spec",
126
+ "eventsource-parser",
127
+ "zod"
128
+ ]
129
+ },
90
130
  "@ai-sdk/provider-utils@4.0.13_zod@3.25.76": {
91
131
  "integrity": "sha512-HHG72BN4d+OWTcq2NwTxOm/2qvk1duYsnhCDtsbYwn/h/4zeqURu1S0+Cn0nY2Ysq9a9HGKvrYuMn9bgFhR2Og==",
92
132
  "dependencies": [
@@ -861,6 +901,7 @@
861
901
  "workspace": {
862
902
  "dependencies": [
863
903
  "jsr:@deno/esbuild-plugin@^1.2.0",
904
+ "jsr:@deno/graph@0.108",
864
905
  "jsr:@std/assert@1",
865
906
  "jsr:@std/path@1",
866
907
  "jsr:@zip-js/zip-js@^2.8.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "1tube",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
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,233 @@
1
+ /**
2
+ * Per-function dependency graph for the Deno backend's HMR.
3
+ *
4
+ * Wraps `jsr:@deno/graph` (the same module-graph engine the Deno CLI uses)
5
+ * to compute the set of `file://` URLs that each function transitively
6
+ * imports. With those graphs in hand, a single fs change maps to the exact
7
+ * set of functions whose Worker needs to be restarted — no more
8
+ * "anything under `_shared/` triggers a full reload" heuristic.
9
+ *
10
+ * Only `file://` URLs are tracked. `npm:`, `jsr:`, `https:` and `node:`
11
+ * deps cannot fire local fs watcher events, so they would never be
12
+ * "changed" anyway; including them in the reverse index is wasted RAM.
13
+ *
14
+ * Bare specifiers from `deno.json` `imports` are resolved through a
15
+ * tiny in-process import-map resolver (longest-prefix-wins, exact-match
16
+ * preferred). This is the Deno CLI's own algorithm reduced to its
17
+ * essentials — sufficient for typical edge-function projects.
18
+ * import "@db/util" → imports: { "@db/util": "./_shared/util.ts" }
19
+ * resolves to the file URL the user actually edits, and the graph
20
+ * tracks it correctly.
21
+ */
22
+
23
+ import { createGraph } from "@deno/graph";
24
+ import { fileURLToPath, pathToFileURL } from "node:url";
25
+
26
+ // Local re-export for callers that prefer std/path-style names. Underlying
27
+ // implementations come from node:url so this module loads from node_modules
28
+ // in any host project without extra import-map plumbing.
29
+ export { fileURLToPath, pathToFileURL };
30
+
31
+ export interface DepGraphOptions {
32
+ /**
33
+ * Parsed `imports` field from the host project's deno.json. Used to
34
+ * resolve bare specifiers to file URLs when the user wires
35
+ * `_shared` etc. through an import map alias. Optional; if absent,
36
+ * only relative imports are tracked precisely.
37
+ */
38
+ importMap?: Readonly<Record<string, string>>;
39
+ /**
40
+ * Absolute path of the deno.json the import map came from. Import
41
+ * map values are resolved relative to this. Required when
42
+ * `importMap` is set.
43
+ */
44
+ importMapBase?: string;
45
+ }
46
+
47
+ export interface DepGraph {
48
+ /**
49
+ * Build (or rebuild) the graph for one function entry. Replaces any
50
+ * prior graph for `name`. Resolves once the graph is ready; transient
51
+ * graph errors (missing files mid-edit) are swallowed so a save
52
+ * burst with a half-written file doesn't crash the watcher.
53
+ */
54
+ refresh(name: string, entryFileUrl: string): Promise<void>;
55
+ /** Drop a function's graph. Used when an `index.ts` is deleted. */
56
+ forget(name: string): void;
57
+ /**
58
+ * Names whose graph contains any of `changedFiles`. Inputs are
59
+ * accepted as either absolute paths or `file://` URLs; both are
60
+ * normalized internally.
61
+ */
62
+ affected(changedFiles: Iterable<string>): Set<string>;
63
+ /** Total number of distinct file URLs currently tracked. */
64
+ readonly size: number;
65
+ }
66
+
67
+ /**
68
+ * Normalize a path-or-URL to a canonical `file:///...` string with
69
+ * forward-slash separators (matches what deno_graph emits for module
70
+ * specifiers). Falls back to the original string on parse failure so
71
+ * non-file specifiers aren't accidentally mangled.
72
+ */
73
+ function normalize(pathOrUrl: string): string {
74
+ if (pathOrUrl.startsWith("file://")) return new URL(pathOrUrl).href;
75
+ try {
76
+ return pathToFileURL(pathOrUrl).href;
77
+ } catch {
78
+ return pathOrUrl;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Resolve `specifier` against `importMap` using the same prefix rules
84
+ * Deno applies. Returns the rewritten specifier or null when no entry
85
+ * matches.
86
+ */
87
+ function applyImportMap(
88
+ specifier: string,
89
+ importMap: Readonly<Record<string, string>>,
90
+ base: string,
91
+ ): string | null {
92
+ const exact = importMap[specifier];
93
+ if (exact !== undefined) return resolveAgainst(exact, base);
94
+ let bestKey: string | null = null;
95
+ for (const key of Object.keys(importMap)) {
96
+ if (!key.endsWith("/")) continue;
97
+ if (!specifier.startsWith(key)) continue;
98
+ if (bestKey === null || key.length > bestKey.length) bestKey = key;
99
+ }
100
+ if (bestKey === null) return null;
101
+ const target = importMap[bestKey] + specifier.slice(bestKey.length);
102
+ return resolveAgainst(target, base);
103
+ }
104
+
105
+ function resolveAgainst(target: string, base: string): string {
106
+ // Bare URLs (npm:/jsr:/https:/file:) pass through unchanged. Relative
107
+ // paths get resolved against the import map's deno.json directory so
108
+ // `./_shared/x.ts` in deno.json points where the user expects.
109
+ if (/^[a-z][a-z0-9+.-]*:/i.test(target)) return target;
110
+ const baseUrl = base.startsWith("file://") ? base : pathToFileURL(base).href;
111
+ return new URL(target, baseUrl).href;
112
+ }
113
+
114
+ export function createDepGraph(options?: DepGraphOptions): DepGraph {
115
+ const perName = new Map<string, Set<string>>();
116
+ const reverse = new Map<string, Set<string>>();
117
+
118
+ const importMap = options?.importMap;
119
+ const importMapBase = options?.importMapBase;
120
+
121
+ const resolve = importMap && importMapBase
122
+ ? (specifier: string, referrer: string): string => {
123
+ const rewritten = applyImportMap(specifier, importMap, importMapBase);
124
+ if (rewritten !== null) return rewritten;
125
+ // Fall through to standard resolution against the referrer.
126
+ try {
127
+ return new URL(specifier, referrer).href;
128
+ } catch {
129
+ return specifier;
130
+ }
131
+ }
132
+ : undefined;
133
+
134
+ function indexAdd(name: string, fileUrl: string): void {
135
+ let set = reverse.get(fileUrl);
136
+ if (!set) {
137
+ set = new Set();
138
+ reverse.set(fileUrl, set);
139
+ }
140
+ set.add(name);
141
+ }
142
+
143
+ function indexRemove(name: string, fileUrl: string): void {
144
+ const set = reverse.get(fileUrl);
145
+ if (!set) return;
146
+ set.delete(name);
147
+ if (set.size === 0) reverse.delete(fileUrl);
148
+ }
149
+
150
+ return {
151
+ async refresh(name, entryFileUrl) {
152
+ const entry = normalize(entryFileUrl);
153
+ let graph;
154
+ try {
155
+ graph = await createGraph([entry], resolve ? { resolve } : undefined);
156
+ } catch {
157
+ // A save mid-flush can leave a syntactically broken file on
158
+ // disk; deno_graph throws. Drop the graph for `name` so the
159
+ // affected-set reverts to "this entry only" and let the next
160
+ // (clean) save rebuild it. Better than dying.
161
+ const old = perName.get(name);
162
+ if (old) {
163
+ for (const f of old) indexRemove(name, f);
164
+ }
165
+ const minimal = new Set<string>([entry]);
166
+ perName.set(name, minimal);
167
+ indexAdd(name, entry);
168
+ return;
169
+ }
170
+
171
+ const fresh = new Set<string>();
172
+ for (const mod of graph.modules ?? []) {
173
+ const spec: string | undefined = mod?.specifier;
174
+ if (typeof spec !== "string") continue;
175
+ if (!spec.startsWith("file://")) continue;
176
+ fresh.add(spec);
177
+ }
178
+ // Always include the entry, even if deno_graph short-circuited
179
+ // due to a load error on a child.
180
+ fresh.add(entry);
181
+
182
+ const old = perName.get(name);
183
+ if (old) {
184
+ for (const f of old) {
185
+ if (!fresh.has(f)) indexRemove(name, f);
186
+ }
187
+ }
188
+ for (const f of fresh) {
189
+ if (!old || !old.has(f)) indexAdd(name, f);
190
+ }
191
+ perName.set(name, fresh);
192
+ },
193
+
194
+ forget(name) {
195
+ const old = perName.get(name);
196
+ if (!old) return;
197
+ for (const f of old) indexRemove(name, f);
198
+ perName.delete(name);
199
+ },
200
+
201
+ affected(changedFiles) {
202
+ const out = new Set<string>();
203
+ for (const raw of changedFiles) {
204
+ const url = normalize(raw);
205
+ const owners = reverse.get(url);
206
+ if (!owners) continue;
207
+ for (const n of owners) out.add(n);
208
+ }
209
+ return out;
210
+ },
211
+
212
+ get size() {
213
+ return reverse.size;
214
+ },
215
+ };
216
+ }
217
+
218
+ /**
219
+ * Best-effort path resolver for paths that may have come from
220
+ * `Deno.watchFs` (which emits OS-native paths). Exposed for tests
221
+ * and callers that want to compare a file URL against a watched
222
+ * path without going through the graph.
223
+ */
224
+ export function fileUrlOrPath(input: string): string {
225
+ if (input.startsWith("file://")) {
226
+ try {
227
+ return fileURLToPath(input);
228
+ } catch {
229
+ return input;
230
+ }
231
+ }
232
+ return input;
233
+ }
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Hot reloader for the Deno backend (Worker-per-function model).
3
+ *
4
+ * The flow:
5
+ *
6
+ * Deno.watchFs ─▶ pendingPaths set ─▶ debounce(200ms)
7
+ * │
8
+ * ▼
9
+ * affected = depGraph.affected(paths)
10
+ * affected ∪= names with index.ts created/deleted
11
+ * │
12
+ * ▼
13
+ * host.reload(affected)
14
+ *
15
+ * The dep-graph (built and maintained by {@link DenoWorkerHost}) is the
16
+ * source of truth for "which function does this file belong to" — much
17
+ * more precise than the historical
18
+ * "first directory of the relative path, with `_*` promoted to full
19
+ * reload" heuristic used by the workerd backend.
20
+ *
21
+ * Two seams keep this testable without spinning up real Workers:
22
+ *
23
+ * - {@link createDenoReloadDebouncer}: the pure debounce + flush
24
+ * buffer, exported on its own so unit tests can drive it with a
25
+ * fake clock and assert pending-set semantics.
26
+ * - {@link createDenoHotReloader}: the loop that consumes a watcher
27
+ * iterable. Tests pass an async iterator of fake events instead
28
+ * of the real `Deno.watchFs` to avoid filesystem flake.
29
+ */
30
+
31
+ import type { DenoWorkerHost, ReloadSummary } from "./worker-host.ts";
32
+ import type { DepGraph } from "./dep-graph.ts";
33
+
34
+ export type FsEventStream = AsyncIterable<{ paths: readonly string[] }> & {
35
+ close(): void;
36
+ };
37
+
38
+ export interface DenoHotReloaderOptions {
39
+ host: DenoWorkerHost;
40
+ functionsDir: string;
41
+ /** Defaults to 200ms — same as the workerd path. */
42
+ debounceMs?: number;
43
+ /** Test seam: factory for the watcher iterable. */
44
+ watch?: (dir: string) => FsEventStream;
45
+ /** Test seams for the debouncer's clock. */
46
+ setTimer?: (cb: () => void, ms: number) => number;
47
+ clearTimer?: (id: number) => void;
48
+ /** Override the log function (defaults to console.log). */
49
+ log?: (line: string) => void;
50
+ /** Optional hook fired after each reload, with the summary. */
51
+ onReloaded?: (summary: ReloadSummary) => void;
52
+ }
53
+
54
+ export interface DenoHotReloader {
55
+ start(): Promise<void>;
56
+ stop(): Promise<void>;
57
+ }
58
+
59
+ export interface DenoReloadDebouncer {
60
+ push(paths: readonly string[]): void;
61
+ cancel(): void;
62
+ /** Drain the pending-path set without flushing. Test seam. */
63
+ drain(): string[];
64
+ readonly isFlushing: boolean;
65
+ }
66
+
67
+ export interface DenoReloadDebouncerOptions {
68
+ debounceMs: number;
69
+ flushFn: (paths: readonly string[]) => Promise<void>;
70
+ setTimer?: (cb: () => void, ms: number) => number;
71
+ clearTimer?: (id: number) => void;
72
+ }
73
+
74
+ /**
75
+ * Path-set debouncer. Coalesces fs events arriving inside a single
76
+ * `debounceMs` window into one `flushFn` call carrying the union.
77
+ *
78
+ * If a flush is already in progress when the timer fires, it re-arms
79
+ * itself so changes saved during the in-flight reload aren't lost.
80
+ */
81
+ export function createDenoReloadDebouncer(
82
+ opts: DenoReloadDebouncerOptions,
83
+ ): DenoReloadDebouncer {
84
+ const setTimer = opts.setTimer ??
85
+ ((cb, ms) => setTimeout(cb, ms) as unknown as number);
86
+ const clearTimer = opts.clearTimer ?? ((id) => clearTimeout(id));
87
+
88
+ const pending = new Set<string>();
89
+ let timerId: number | null = null;
90
+ let flushing = false;
91
+
92
+ const flush = async () => {
93
+ timerId = null;
94
+ if (flushing) return;
95
+ if (pending.size === 0) return;
96
+ const paths = [...pending];
97
+ pending.clear();
98
+ flushing = true;
99
+ try {
100
+ await opts.flushFn(paths);
101
+ } finally {
102
+ flushing = false;
103
+ if (pending.size > 0) {
104
+ if (timerId !== null) clearTimer(timerId);
105
+ timerId = setTimer(flush, opts.debounceMs);
106
+ }
107
+ }
108
+ };
109
+
110
+ return {
111
+ push(paths) {
112
+ let added = false;
113
+ for (const p of paths) {
114
+ if (!pending.has(p)) {
115
+ pending.add(p);
116
+ added = true;
117
+ }
118
+ }
119
+ if (!added) return;
120
+ if (timerId !== null) clearTimer(timerId);
121
+ timerId = setTimer(flush, opts.debounceMs);
122
+ },
123
+ cancel() {
124
+ if (timerId !== null) {
125
+ clearTimer(timerId);
126
+ timerId = null;
127
+ }
128
+ },
129
+ drain() {
130
+ const out = [...pending];
131
+ pending.clear();
132
+ return out;
133
+ },
134
+ get isFlushing() {
135
+ return flushing;
136
+ },
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Compute the set of function names that need reloading given a batch
142
+ * of changed file paths and the current dep-graph. Exposed for tests.
143
+ *
144
+ * Three sources, in order of precision:
145
+ *
146
+ * 1. Files inside the dep-graph: their owners are affected. This is the
147
+ * precise case — exactly the functions that transitively import the
148
+ * changed file.
149
+ * 2. Files matching `<functionsDir>/<name>/index.ts`: include `<name>`.
150
+ * Catches newly-added function dirs whose entry hasn't been graphed
151
+ * yet, plus the entry's own edits.
152
+ * 3. Path is the directory `<functionsDir>/<name>` itself or anything
153
+ * under it (subtree change, including whole-dir removal). This is
154
+ * the case `Deno.watchFs` emits on Windows when a function dir is
155
+ * deleted — events come back as paths to the dir, not to the
156
+ * nonexistent files inside. Only matched against `knownNames` so
157
+ * random sibling-dir activity doesn't trigger spurious reloads.
158
+ */
159
+ export function computeAffected(
160
+ paths: readonly string[],
161
+ depGraph: DepGraph,
162
+ options: { functionsDir?: string; knownNames?: Iterable<string> } = {},
163
+ ): Set<string> {
164
+ const out = depGraph.affected(paths);
165
+ for (const p of paths) {
166
+ const m = /[\\/]([^\\/]+)[\\/]index\.ts$/.exec(p);
167
+ if (m && m[1]) out.add(m[1]);
168
+ }
169
+ if (options.functionsDir && options.knownNames) {
170
+ const fnDir = options.functionsDir.replace(/[\\/]$/, "");
171
+ for (const name of options.knownNames) {
172
+ // Cheap substring test — avoids escaping the name for a regex
173
+ // and works for both forward- and back-slashed paths.
174
+ const needleA = `${fnDir}\\${name}`;
175
+ const needleB = `${fnDir}/${name}`;
176
+ for (const p of paths) {
177
+ if (p === needleA || p === needleB) {
178
+ out.add(name);
179
+ break;
180
+ }
181
+ if (p.startsWith(needleA + "\\") || p.startsWith(needleB + "/")) {
182
+ out.add(name);
183
+ break;
184
+ }
185
+ }
186
+ }
187
+ }
188
+ return out;
189
+ }
190
+
191
+ export function createDenoHotReloader(
192
+ opts: DenoHotReloaderOptions,
193
+ ): DenoHotReloader {
194
+ const log = opts.log ?? ((line) => console.log(line));
195
+ const watcherFactory = opts.watch ?? ((dir: string) =>
196
+ Deno.watchFs(dir, { recursive: true }) as unknown as FsEventStream
197
+ );
198
+
199
+ let stream: FsEventStream | null = null;
200
+ let stopped = false;
201
+ let consumeLoop: Promise<void> | null = null;
202
+
203
+ const debouncer = createDenoReloadDebouncer({
204
+ debounceMs: opts.debounceMs ?? 200,
205
+ setTimer: opts.setTimer,
206
+ clearTimer: opts.clearTimer,
207
+ flushFn: async (paths) => {
208
+ const affected = computeAffected(paths, opts.host.depGraph, {
209
+ functionsDir: opts.functionsDir,
210
+ knownNames: opts.host.list(),
211
+ });
212
+ if (affected.size === 0) return;
213
+ const reason = `${affected.size} function(s) changed: ${
214
+ [...affected].sort().join(", ")
215
+ }`;
216
+ log(`[1tube] HMR ${reason}`);
217
+ try {
218
+ const summary = await opts.host.reload(affected, reason);
219
+ const parts: string[] = [];
220
+ if (summary.reloaded.length > 0) {
221
+ parts.push(`reloaded=${summary.reloaded.join(",")}`);
222
+ }
223
+ if (summary.added.length > 0) {
224
+ parts.push(`added=${summary.added.join(",")}`);
225
+ }
226
+ if (summary.removed.length > 0) {
227
+ parts.push(`removed=${summary.removed.join(",")}`);
228
+ }
229
+ log(
230
+ `[1tube] HMR reload ok in ${summary.durationMs.toFixed(0)}ms${
231
+ parts.length > 0 ? ` (${parts.join("; ")})` : ""
232
+ }`,
233
+ );
234
+ for (const e of summary.errors) {
235
+ log(`[1tube] HMR ${e.name}: ${e.error}`);
236
+ }
237
+ opts.onReloaded?.(summary);
238
+ } catch (err) {
239
+ const msg = err instanceof Error ? err.message : String(err);
240
+ log(`[1tube] HMR reload FAILED — keeping previous workers alive. Error: ${msg}`);
241
+ }
242
+ },
243
+ });
244
+
245
+ return {
246
+ async start() {
247
+ if (stream) return;
248
+ stream = watcherFactory(opts.functionsDir);
249
+ log(`[1tube] HMR watching: ${opts.functionsDir}`);
250
+ consumeLoop = (async () => {
251
+ try {
252
+ for await (const event of stream!) {
253
+ if (stopped) break;
254
+ if (event.paths.length === 0) continue;
255
+ debouncer.push(event.paths);
256
+ }
257
+ } catch (err) {
258
+ if (!stopped) log(`[1tube] HMR watcher disabled: ${err}`);
259
+ }
260
+ })();
261
+ },
262
+ async stop() {
263
+ if (stopped) return;
264
+ stopped = true;
265
+ debouncer.cancel();
266
+ try {
267
+ stream?.close();
268
+ } catch { /* */ }
269
+ stream = null;
270
+ if (consumeLoop) {
271
+ let timeoutId: number | undefined;
272
+ const bound = new Promise<void>((resolve) => {
273
+ timeoutId = setTimeout(resolve, 250) as unknown as number;
274
+ });
275
+ await Promise.race([consumeLoop, bound]);
276
+ if (timeoutId !== undefined) clearTimeout(timeoutId);
277
+ }
278
+ },
279
+ };
280
+ }