1tube 0.1.26 → 0.1.28

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.lock CHANGED
@@ -14,8 +14,8 @@
14
14
  "npm:es-module-lexer@2": "2.1.0",
15
15
  "npm:esbuild@0.28": "0.28.0",
16
16
  "npm:esbuild@~0.25.5": "0.25.12",
17
- "npm:hono@4": "4.12.15",
18
- "npm:hono@^4.12.14": "4.12.15"
17
+ "npm:hono@4": "4.12.18",
18
+ "npm:hono@^4.12.18": "4.12.18"
19
19
  },
20
20
  "jsr": {
21
21
  "@deno/esbuild-plugin@1.2.1": {
@@ -387,8 +387,8 @@
387
387
  "scripts": true,
388
388
  "bin": true
389
389
  },
390
- "hono@4.12.15": {
391
- "integrity": "sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg=="
390
+ "hono@4.12.18": {
391
+ "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ=="
392
392
  }
393
393
  },
394
394
  "workspace": {
@@ -404,7 +404,7 @@
404
404
  ],
405
405
  "packageJson": {
406
406
  "dependencies": [
407
- "npm:hono@^4.12.14"
407
+ "npm:hono@^4.12.18"
408
408
  ]
409
409
  }
410
410
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "1tube",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
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",
@@ -42,6 +42,6 @@
42
42
  "prepublishOnly": "bun run typecheck && bun run check && bun run smoke:bin"
43
43
  },
44
44
  "dependencies": {
45
- "hono": "^4.12.14"
45
+ "hono": "^4.12.18"
46
46
  }
47
47
  }
@@ -40,6 +40,8 @@ export interface DepGraphOptions {
40
40
  * only relative imports are tracked precisely.
41
41
  */
42
42
  importMap?: Readonly<Record<string, string>>;
43
+ /** Parsed `scopes` field from the host project's deno.json import map. */
44
+ importMapScopes?: Readonly<Record<string, Readonly<Record<string, string>>>>;
43
45
  /**
44
46
  * Absolute path of the deno.json the import map came from. Import
45
47
  * map values are resolved relative to this. Required when
@@ -56,6 +58,16 @@ export interface DepGraph {
56
58
  * burst with a half-written file doesn't crash the watcher.
57
59
  */
58
60
  refresh(name: string, entryFileUrl: string): Promise<void>;
61
+ /**
62
+ * Build one module graph for many function entries and populate each
63
+ * function's reachable file set from that shared graph. Initial eager boot
64
+ * uses this when shared-module rewriting needs every function's file list:
65
+ * it avoids N overlapping `deno_graph` walks over the same `_shared`, npm,
66
+ * and JSR dependencies.
67
+ */
68
+ refreshMany(
69
+ entries: Iterable<{ name: string; entryFileUrl: string }>,
70
+ ): Promise<void>;
59
71
  /** Drop a function's graph. Used when an `index.ts` is deleted. */
60
72
  forget(name: string): void;
61
73
  /**
@@ -113,6 +125,36 @@ function applyImportMap(
113
125
  return resolveAgainst(target, base);
114
126
  }
115
127
 
128
+ function resolveImportMap(
129
+ specifier: string,
130
+ referrer: string,
131
+ importMap: Readonly<Record<string, string>> | undefined,
132
+ scopes:
133
+ | Readonly<Record<string, Readonly<Record<string, string>>>>
134
+ | undefined,
135
+ base: string,
136
+ ): string | null {
137
+ let bestScope: string | null = null;
138
+ if (scopes) {
139
+ for (const rawScope of Object.keys(scopes)) {
140
+ const scopeUrl = resolveAgainst(rawScope, base);
141
+ if (!referrer.startsWith(scopeUrl)) continue;
142
+ if (bestScope === null || scopeUrl.length > bestScope.length) {
143
+ bestScope = scopeUrl;
144
+ }
145
+ }
146
+ }
147
+ if (bestScope !== null && scopes) {
148
+ for (const rawScope of Object.keys(scopes)) {
149
+ if (resolveAgainst(rawScope, base) !== bestScope) continue;
150
+ const scoped = applyImportMap(specifier, scopes[rawScope], base);
151
+ if (scoped !== null) return scoped;
152
+ break;
153
+ }
154
+ }
155
+ return importMap ? applyImportMap(specifier, importMap, base) : null;
156
+ }
157
+
116
158
  function resolveAgainst(target: string, base: string): string {
117
159
  // Bare URLs (npm:/jsr:/https:/file:) pass through unchanged. Relative
118
160
  // paths get resolved against the import map's deno.json directory so
@@ -127,11 +169,18 @@ export function createDepGraph(options?: DepGraphOptions): DepGraph {
127
169
  const reverse = new Map<string, Set<string>>();
128
170
 
129
171
  const importMap = options?.importMap;
172
+ const importMapScopes = options?.importMapScopes;
130
173
  const importMapBase = options?.importMapBase;
131
174
 
132
- const resolve = importMap && importMapBase
175
+ const resolve = importMapBase && (importMap || importMapScopes)
133
176
  ? (specifier: string, referrer: string): string => {
134
- const rewritten = applyImportMap(specifier, importMap, importMapBase);
177
+ const rewritten = resolveImportMap(
178
+ specifier,
179
+ referrer,
180
+ importMap,
181
+ importMapScopes,
182
+ importMapBase,
183
+ );
135
184
  if (rewritten !== null) return rewritten;
136
185
  // Fall through to standard resolution against the referrer.
137
186
  try {
@@ -158,6 +207,41 @@ export function createDepGraph(options?: DepGraphOptions): DepGraph {
158
207
  if (set.size === 0) reverse.delete(fileUrl);
159
208
  }
160
209
 
210
+ function replaceGraph(name: string, fresh: Set<string>): void {
211
+ const old = perName.get(name);
212
+ if (old) {
213
+ for (const f of old) {
214
+ if (!fresh.has(f)) indexRemove(name, f);
215
+ }
216
+ }
217
+ for (const f of fresh) {
218
+ if (!old || !old.has(f)) indexAdd(name, f);
219
+ }
220
+ perName.set(name, fresh);
221
+ }
222
+
223
+ function minimalGraph(entry: string): Set<string> {
224
+ return new Set<string>([entry]);
225
+ }
226
+
227
+ function fileDepsOf(mod: unknown): string[] {
228
+ const deps = (mod as { dependencies?: unknown[] })?.dependencies;
229
+ if (!Array.isArray(deps)) return [];
230
+ const out: string[] = [];
231
+ for (const dep of deps) {
232
+ const d = dep as {
233
+ code?: { specifier?: unknown };
234
+ type?: { specifier?: unknown };
235
+ };
236
+ for (const target of [d.code?.specifier, d.type?.specifier]) {
237
+ if (typeof target === "string" && target.startsWith("file://")) {
238
+ out.push(target);
239
+ }
240
+ }
241
+ }
242
+ return out;
243
+ }
244
+
161
245
  return {
162
246
  async refresh(name, entryFileUrl) {
163
247
  const entry = normalize(entryFileUrl);
@@ -169,13 +253,7 @@ export function createDepGraph(options?: DepGraphOptions): DepGraph {
169
253
  // disk; deno_graph throws. Drop the graph for `name` so the
170
254
  // affected-set reverts to "this entry only" and let the next
171
255
  // (clean) save rebuild it. Better than dying.
172
- const old = perName.get(name);
173
- if (old) {
174
- for (const f of old) indexRemove(name, f);
175
- }
176
- const minimal = new Set<string>([entry]);
177
- perName.set(name, minimal);
178
- indexAdd(name, entry);
256
+ replaceGraph(name, minimalGraph(entry));
179
257
  return;
180
258
  }
181
259
 
@@ -189,17 +267,49 @@ export function createDepGraph(options?: DepGraphOptions): DepGraph {
189
267
  // Always include the entry, even if deno_graph short-circuited
190
268
  // due to a load error on a child.
191
269
  fresh.add(entry);
270
+ replaceGraph(name, fresh);
271
+ },
192
272
 
193
- const old = perName.get(name);
194
- if (old) {
195
- for (const f of old) {
196
- if (!fresh.has(f)) indexRemove(name, f);
197
- }
273
+ async refreshMany(entries) {
274
+ const normalized = [...entries].map((e) => ({
275
+ name: e.name,
276
+ entry: normalize(e.entryFileUrl),
277
+ }));
278
+ if (normalized.length === 0) return;
279
+
280
+ let graph;
281
+ try {
282
+ graph = await createGraph(
283
+ normalized.map((e) => e.entry),
284
+ resolve ? { resolve } : undefined,
285
+ );
286
+ } catch {
287
+ for (const e of normalized) replaceGraph(e.name, minimalGraph(e.entry));
288
+ return;
198
289
  }
199
- for (const f of fresh) {
200
- if (!old || !old.has(f)) indexAdd(name, f);
290
+
291
+ const modules = new Map<string, unknown>();
292
+ const adjacency = new Map<string, string[]>();
293
+ for (const mod of graph.modules ?? []) {
294
+ const spec = (mod as { specifier?: unknown })?.specifier;
295
+ if (typeof spec !== "string" || !spec.startsWith("file://")) continue;
296
+ modules.set(spec, mod);
297
+ adjacency.set(spec, fileDepsOf(mod));
298
+ }
299
+
300
+ for (const e of normalized) {
301
+ const fresh = new Set<string>([e.entry]);
302
+ const stack = [e.entry];
303
+ while (stack.length > 0) {
304
+ const current = stack.pop()!;
305
+ for (const dep of adjacency.get(current) ?? []) {
306
+ if (fresh.has(dep)) continue;
307
+ fresh.add(dep);
308
+ if (modules.has(dep)) stack.push(dep);
309
+ }
310
+ }
311
+ replaceGraph(e.name, fresh);
201
312
  }
202
- perName.set(name, fresh);
203
313
  },
204
314
 
205
315
  filesFor(name) {
@@ -32,6 +32,7 @@ import type { DenoWorkerHost, ReloadSummary } from "./worker-host.ts";
32
32
  import type { DepGraph } from "./dep-graph.ts";
33
33
  import type { DenoSharedRuntime } from "./shared-runtime.ts";
34
34
  import type { RewriteCache } from "./source-rewriter.ts";
35
+ import { resolve } from "node:path";
35
36
 
36
37
  export type FsEventStream = AsyncIterable<{ paths: readonly string[] }> & {
37
38
  close(): void;
@@ -159,31 +160,30 @@ export function createDenoReloadDebouncer(
159
160
  * changed file.
160
161
  * 2. Files matching `<functionsDir>/<name>/index.ts`: include `<name>`.
161
162
  * Catches the entry's own edits.
162
- * 3. Path is the directory `<functionsDir>/<name>` itself or any path
163
- * under it (subtree change). This is necessary for two cases the
164
- * other rules miss:
163
+ * 3. Optionally, path is the directory `<functionsDir>/<name>` itself. This is
164
+ * necessary for two cases the other rules miss:
165
165
  * - **Linux**: `Deno.watchFs` is recursive only over the dirs
166
166
  * that existed at start. A *new* function dir reports a single
167
167
  * `<functionsDir>/<name>` create event — child file events
168
168
  * never fire because inotify doesn't auto-watch the new subdir.
169
169
  * - **Windows**: deleting a function dir tree emits dir-path
170
170
  * events instead of `index.ts` events.
171
- * Either way the affected name is the first path component after
172
- * `functionsDir`. We let `discoverCandidates` filter out junk
173
- * (e.g. `_shared`) on the reload pass so this rule stays simple
174
- * and doesn't need the host's knownNames set.
171
+ * Callers that can stat the candidate directory should disable this
172
+ * fallback and decide new/removed/existing precisely. A plain parent-dir
173
+ * event can also be emitted for a child sidecar edit, and reloading that
174
+ * function would be noisy when the dep graph has no edge to the file.
175
175
  */
176
176
  export function computeAffected(
177
177
  paths: readonly string[],
178
178
  depGraph: DepGraph,
179
- options: { functionsDir?: string } = {},
179
+ options: { functionsDir?: string; includeDirectoryEvents?: boolean } = {},
180
180
  ): Set<string> {
181
181
  const out = depGraph.affected(paths);
182
182
  for (const p of paths) {
183
183
  const m = /[\\/]([^\\/]+)[\\/]index\.ts$/.exec(p);
184
184
  if (m && m[1]) out.add(m[1]);
185
185
  }
186
- if (options.functionsDir) {
186
+ if (options.functionsDir && options.includeDirectoryEvents !== false) {
187
187
  const fnDir = options.functionsDir.replace(/[\\/]$/, "");
188
188
  for (const p of paths) {
189
189
  // Strip the prefix; anything left should look like
@@ -194,7 +194,8 @@ export function computeAffected(
194
194
  else if (p.startsWith(fnDir + "\\")) rest = p.slice(fnDir.length + 1);
195
195
  else if (p === fnDir) continue;
196
196
  if (rest === null) continue;
197
- const seg = rest.split(/[\\/]/, 1)[0];
197
+ if (/[\\/]/.test(rest)) continue;
198
+ const seg = rest;
198
199
  // Skip private/shared dirs the host already filters out — keeps
199
200
  // the affected set tight even before `reload()` runs.
200
201
  if (!seg || seg.startsWith("_") || seg.endsWith("_shared")) continue;
@@ -204,13 +205,32 @@ export function computeAffected(
204
205
  return out;
205
206
  }
206
207
 
208
+ function directoryEventFunctionNames(
209
+ paths: readonly string[],
210
+ functionsDir: string,
211
+ ): Set<string> {
212
+ const out = new Set<string>();
213
+ const fnDir = functionsDir.replace(/[\\/]$/, "");
214
+ for (const p of paths) {
215
+ let rest: string | null = null;
216
+ if (p.startsWith(fnDir + "/")) rest = p.slice(fnDir.length + 1);
217
+ else if (p.startsWith(fnDir + "\\")) rest = p.slice(fnDir.length + 1);
218
+ else continue;
219
+ if (!rest || /[\\/]/.test(rest)) continue;
220
+ if (rest.startsWith("_") || rest.endsWith("_shared")) continue;
221
+ out.add(rest);
222
+ }
223
+ return out;
224
+ }
225
+
207
226
  export function createDenoHotReloader(
208
227
  opts: DenoHotReloaderOptions,
209
228
  ): DenoHotReloader {
210
229
  const log = opts.log ?? ((line) => console.log(line));
211
- const watcherFactory = opts.watch ?? ((dir: string) =>
212
- Deno.watchFs(dir, { recursive: true }) as unknown as FsEventStream
213
- );
230
+ const functionsDir = resolve(opts.functionsDir);
231
+ const watcherFactory = opts.watch ??
232
+ ((dir: string) =>
233
+ Deno.watchFs(dir, { recursive: true }) as unknown as FsEventStream);
214
234
 
215
235
  let stream: FsEventStream | null = null;
216
236
  let stopped = false;
@@ -252,7 +272,8 @@ export function createDenoHotReloader(
252
272
  }
253
273
 
254
274
  const raw = computeAffected(paths, opts.host.depGraph, {
255
- functionsDir: opts.functionsDir,
275
+ functionsDir,
276
+ includeDirectoryEvents: false,
256
277
  });
257
278
  // Files that aren't owned by any single function (shared
258
279
  // modules, anything imported by ≥2 functions) need every
@@ -265,6 +286,17 @@ export function createDenoHotReloader(
265
286
  // `functionsDir` (README.md, .gitignore, …) so we don't spend
266
287
  // a reload pass producing an empty summary.
267
288
  const known = new Set(opts.host.list());
289
+ for (
290
+ const name of directoryEventFunctionNames(paths, functionsDir)
291
+ ) {
292
+ const indexPath = `${functionsDir}/${name}/index.ts`;
293
+ try {
294
+ const stat = await Deno.stat(indexPath);
295
+ if (stat.isFile && !known.has(name)) raw.add(name);
296
+ } catch {
297
+ if (known.has(name)) raw.add(name);
298
+ }
299
+ }
268
300
  const affected = new Set<string>();
269
301
  for (const name of raw) {
270
302
  if (known.has(name)) {
@@ -272,7 +304,7 @@ export function createDenoHotReloader(
272
304
  continue;
273
305
  }
274
306
  try {
275
- const indexPath = `${opts.functionsDir}/${name}/index.ts`;
307
+ const indexPath = `${functionsDir}/${name}/index.ts`;
276
308
  const stat = await Deno.stat(indexPath);
277
309
  if (stat.isFile) affected.add(name);
278
310
  } catch {
@@ -307,16 +339,18 @@ export function createDenoHotReloader(
307
339
  opts.onReloaded?.(summary);
308
340
  } catch (err) {
309
341
  const msg = err instanceof Error ? err.message : String(err);
310
- log(`[1tube] HMR reload FAILED — keeping previous workers alive. Error: ${msg}`);
342
+ log(
343
+ `[1tube] HMR reload FAILED — keeping previous workers alive. Error: ${msg}`,
344
+ );
311
345
  }
312
346
  },
313
347
  });
314
348
 
315
349
  return {
316
- async start() {
317
- if (stream) return;
318
- stream = watcherFactory(opts.functionsDir);
319
- log(`[1tube] HMR watching: ${opts.functionsDir}`);
350
+ start() {
351
+ if (stream) return Promise.resolve();
352
+ stream = watcherFactory(functionsDir);
353
+ log(`[1tube] HMR watching: ${functionsDir}`);
320
354
  consumeLoop = (async () => {
321
355
  try {
322
356
  for await (const event of stream!) {
@@ -328,6 +362,7 @@ export function createDenoHotReloader(
328
362
  if (!stopped) log(`[1tube] HMR watcher disabled: ${err}`);
329
363
  }
330
364
  })();
365
+ return Promise.resolve();
331
366
  },
332
367
  async stop() {
333
368
  if (stopped) return;