1tube 0.1.21 → 0.1.23
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 +1 -0
- package/deno.lock +41 -0
- package/package.json +1 -1
- package/src/backends/deno/dep-graph.ts +233 -0
- package/src/backends/deno/hot-reloader.ts +304 -0
- package/src/backends/deno/worker-entry.ts +202 -0
- package/src/backends/deno/worker-host.ts +466 -0
- package/src/discovery.ts +10 -31
- package/src/registry.ts +55 -2
- package/src/server.ts +230 -577
- package/src/deno-hmr-snapshot.ts +0 -159
package/deno.json
CHANGED
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
|
@@ -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,304 @@
|
|
|
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 the entry's own edits.
|
|
151
|
+
* 3. Path is the directory `<functionsDir>/<name>` itself or any path
|
|
152
|
+
* under it (subtree change). This is necessary for two cases the
|
|
153
|
+
* other rules miss:
|
|
154
|
+
* - **Linux**: `Deno.watchFs` is recursive only over the dirs
|
|
155
|
+
* that existed at start. A *new* function dir reports a single
|
|
156
|
+
* `<functionsDir>/<name>` create event — child file events
|
|
157
|
+
* never fire because inotify doesn't auto-watch the new subdir.
|
|
158
|
+
* - **Windows**: deleting a function dir tree emits dir-path
|
|
159
|
+
* events instead of `index.ts` events.
|
|
160
|
+
* Either way the affected name is the first path component after
|
|
161
|
+
* `functionsDir`. We let `discoverCandidates` filter out junk
|
|
162
|
+
* (e.g. `_shared`) on the reload pass so this rule stays simple
|
|
163
|
+
* and doesn't need the host's knownNames set.
|
|
164
|
+
*/
|
|
165
|
+
export function computeAffected(
|
|
166
|
+
paths: readonly string[],
|
|
167
|
+
depGraph: DepGraph,
|
|
168
|
+
options: { functionsDir?: string } = {},
|
|
169
|
+
): Set<string> {
|
|
170
|
+
const out = depGraph.affected(paths);
|
|
171
|
+
for (const p of paths) {
|
|
172
|
+
const m = /[\\/]([^\\/]+)[\\/]index\.ts$/.exec(p);
|
|
173
|
+
if (m && m[1]) out.add(m[1]);
|
|
174
|
+
}
|
|
175
|
+
if (options.functionsDir) {
|
|
176
|
+
const fnDir = options.functionsDir.replace(/[\\/]$/, "");
|
|
177
|
+
for (const p of paths) {
|
|
178
|
+
// Strip the prefix; anything left should look like
|
|
179
|
+
// `/<name>` or `/<name>/<rest>`. Cross-platform: try both
|
|
180
|
+
// separators and accept whichever the OS produced.
|
|
181
|
+
let rest: string | null = null;
|
|
182
|
+
if (p.startsWith(fnDir + "/")) rest = p.slice(fnDir.length + 1);
|
|
183
|
+
else if (p.startsWith(fnDir + "\\")) rest = p.slice(fnDir.length + 1);
|
|
184
|
+
else if (p === fnDir) continue;
|
|
185
|
+
if (rest === null) continue;
|
|
186
|
+
const seg = rest.split(/[\\/]/, 1)[0];
|
|
187
|
+
// Skip private/shared dirs the host already filters out — keeps
|
|
188
|
+
// the affected set tight even before `reload()` runs.
|
|
189
|
+
if (!seg || seg.startsWith("_") || seg.endsWith("_shared")) continue;
|
|
190
|
+
out.add(seg);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function createDenoHotReloader(
|
|
197
|
+
opts: DenoHotReloaderOptions,
|
|
198
|
+
): DenoHotReloader {
|
|
199
|
+
const log = opts.log ?? ((line) => console.log(line));
|
|
200
|
+
const watcherFactory = opts.watch ?? ((dir: string) =>
|
|
201
|
+
Deno.watchFs(dir, { recursive: true }) as unknown as FsEventStream
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
let stream: FsEventStream | null = null;
|
|
205
|
+
let stopped = false;
|
|
206
|
+
let consumeLoop: Promise<void> | null = null;
|
|
207
|
+
|
|
208
|
+
const debouncer = createDenoReloadDebouncer({
|
|
209
|
+
debounceMs: opts.debounceMs ?? 200,
|
|
210
|
+
setTimer: opts.setTimer,
|
|
211
|
+
clearTimer: opts.clearTimer,
|
|
212
|
+
flushFn: async (paths) => {
|
|
213
|
+
const raw = computeAffected(paths, opts.host.depGraph, {
|
|
214
|
+
functionsDir: opts.functionsDir,
|
|
215
|
+
});
|
|
216
|
+
// Filter to names that are either currently loaded or have an
|
|
217
|
+
// `index.ts` on disk right now. This drops spurious entries
|
|
218
|
+
// synthesized from non-function files at the root of
|
|
219
|
+
// `functionsDir` (README.md, .gitignore, …) so we don't spend
|
|
220
|
+
// a reload pass producing an empty summary.
|
|
221
|
+
const known = new Set(opts.host.list());
|
|
222
|
+
const affected = new Set<string>();
|
|
223
|
+
for (const name of raw) {
|
|
224
|
+
if (known.has(name)) {
|
|
225
|
+
affected.add(name);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
const indexPath = `${opts.functionsDir}/${name}/index.ts`;
|
|
230
|
+
const stat = await Deno.stat(indexPath);
|
|
231
|
+
if (stat.isFile) affected.add(name);
|
|
232
|
+
} catch {
|
|
233
|
+
// No index.ts → not a function; drop.
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (affected.size === 0) return;
|
|
237
|
+
const reason = `${affected.size} function(s) changed: ${
|
|
238
|
+
[...affected].sort().join(", ")
|
|
239
|
+
}`;
|
|
240
|
+
log(`[1tube] HMR ${reason}`);
|
|
241
|
+
try {
|
|
242
|
+
const summary = await opts.host.reload(affected, reason);
|
|
243
|
+
const parts: string[] = [];
|
|
244
|
+
if (summary.reloaded.length > 0) {
|
|
245
|
+
parts.push(`reloaded=${summary.reloaded.join(",")}`);
|
|
246
|
+
}
|
|
247
|
+
if (summary.added.length > 0) {
|
|
248
|
+
parts.push(`added=${summary.added.join(",")}`);
|
|
249
|
+
}
|
|
250
|
+
if (summary.removed.length > 0) {
|
|
251
|
+
parts.push(`removed=${summary.removed.join(",")}`);
|
|
252
|
+
}
|
|
253
|
+
log(
|
|
254
|
+
`[1tube] HMR reload ok in ${summary.durationMs.toFixed(0)}ms${
|
|
255
|
+
parts.length > 0 ? ` (${parts.join("; ")})` : ""
|
|
256
|
+
}`,
|
|
257
|
+
);
|
|
258
|
+
for (const e of summary.errors) {
|
|
259
|
+
log(`[1tube] HMR ${e.name}: ${e.error}`);
|
|
260
|
+
}
|
|
261
|
+
opts.onReloaded?.(summary);
|
|
262
|
+
} catch (err) {
|
|
263
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
264
|
+
log(`[1tube] HMR reload FAILED — keeping previous workers alive. Error: ${msg}`);
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
async start() {
|
|
271
|
+
if (stream) return;
|
|
272
|
+
stream = watcherFactory(opts.functionsDir);
|
|
273
|
+
log(`[1tube] HMR watching: ${opts.functionsDir}`);
|
|
274
|
+
consumeLoop = (async () => {
|
|
275
|
+
try {
|
|
276
|
+
for await (const event of stream!) {
|
|
277
|
+
if (stopped) break;
|
|
278
|
+
if (event.paths.length === 0) continue;
|
|
279
|
+
debouncer.push(event.paths);
|
|
280
|
+
}
|
|
281
|
+
} catch (err) {
|
|
282
|
+
if (!stopped) log(`[1tube] HMR watcher disabled: ${err}`);
|
|
283
|
+
}
|
|
284
|
+
})();
|
|
285
|
+
},
|
|
286
|
+
async stop() {
|
|
287
|
+
if (stopped) return;
|
|
288
|
+
stopped = true;
|
|
289
|
+
debouncer.cancel();
|
|
290
|
+
try {
|
|
291
|
+
stream?.close();
|
|
292
|
+
} catch { /* */ }
|
|
293
|
+
stream = null;
|
|
294
|
+
if (consumeLoop) {
|
|
295
|
+
let timeoutId: number | undefined;
|
|
296
|
+
const bound = new Promise<void>((resolve) => {
|
|
297
|
+
timeoutId = setTimeout(resolve, 250) as unknown as number;
|
|
298
|
+
});
|
|
299
|
+
await Promise.race([consumeLoop, bound]);
|
|
300
|
+
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|