1tube 0.1.48 → 0.1.50

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "1tube",
3
- "version": "0.1.48",
3
+ "version": "0.1.50",
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,170 @@
1
+ /**
2
+ * Watches `--env-file` / `.env` paths and hot-applies secret changes.
3
+ *
4
+ * Deno only loads `--env-file` once at process start. With `--hmr`, a
5
+ * save to `.env` must:
6
+ * 1. Re-parse the file(s) into `Deno.env`
7
+ * 2. Respawn function workers / workerd so top-level env captures
8
+ * and workerd `fromEnvironment` bindings see the new values
9
+ *
10
+ * The functions-dir HMR loop never sees these paths (they live at the
11
+ * project root), so this watcher runs alongside it.
12
+ */
13
+
14
+ import {
15
+ applyEnvFiles,
16
+ type EnvApplyDiff,
17
+ type EnvSource,
18
+ envFileWatchDirs,
19
+ isEnvFileEvent,
20
+ } from "../env-file.ts";
21
+ import type { FsEventStream } from "./deno/hot-reloader.ts";
22
+
23
+ export interface EnvHotReloaderOptions {
24
+ /** Absolute paths of env files to watch (order = precedence). */
25
+ envFiles: readonly string[];
26
+ /** Called after a real env diff is applied. Should reload workers. */
27
+ onChanged: (diff: EnvApplyDiff) => void | Promise<void>;
28
+ debounceMs?: number;
29
+ leadingMs?: number;
30
+ /** Test seam: inject a fake watcher. */
31
+ watch?: (dirs: readonly string[]) => FsEventStream;
32
+ setTimer?: (cb: () => void, ms: number) => number;
33
+ clearTimer?: (id: number) => void;
34
+ log?: (line: string) => void;
35
+ /** Test seam: alternate env target (defaults to Deno.env). */
36
+ env?: EnvSource;
37
+ /** Test seam: override apply. */
38
+ apply?: (files: readonly string[], env: EnvSource) => EnvApplyDiff;
39
+ }
40
+
41
+ export interface EnvHotReloader {
42
+ start(): Promise<void>;
43
+ stop(): Promise<void>;
44
+ }
45
+
46
+ export function createEnvHotReloader(
47
+ opts: EnvHotReloaderOptions,
48
+ ): EnvHotReloader {
49
+ const log = opts.log ?? ((line) => console.log(line));
50
+ const envFiles = [...opts.envFiles];
51
+ const env = opts.env ?? Deno.env;
52
+ const apply = opts.apply ?? applyEnvFiles;
53
+ const setTimer = opts.setTimer ??
54
+ ((cb, ms) => setTimeout(cb, ms) as unknown as number);
55
+ const clearTimer = opts.clearTimer ?? ((id) => clearTimeout(id));
56
+ const debounceMs = opts.debounceMs ?? 200;
57
+ const leadingMs = opts.leadingMs !== undefined && opts.leadingMs > 0
58
+ ? opts.leadingMs
59
+ : 40;
60
+
61
+ const watcherFactory = opts.watch ??
62
+ ((dirs: readonly string[]) =>
63
+ Deno.watchFs([...dirs], { recursive: false }) as unknown as FsEventStream);
64
+
65
+ let stream: FsEventStream | null = null;
66
+ let stopped = false;
67
+ let consumeLoop: Promise<void> | null = null;
68
+ let timerId: number | null = null;
69
+ let flushing = false;
70
+ let pending = false;
71
+
72
+ const flush = async () => {
73
+ timerId = null;
74
+ if (flushing) return;
75
+ if (!pending) return;
76
+ pending = false;
77
+ flushing = true;
78
+ try {
79
+ const diff = apply(envFiles, env);
80
+ if (!diff.changed) return;
81
+ const parts: string[] = [];
82
+ if (diff.updated.length > 0) {
83
+ parts.push(`updated=${diff.updated.join(",")}`);
84
+ }
85
+ if (diff.added.length > 0) parts.push(`added=${diff.added.join(",")}`);
86
+ if (diff.removed.length > 0) {
87
+ parts.push(`removed=${diff.removed.join(",")}`);
88
+ }
89
+ log(
90
+ `[1tube] HMR env file changed (${parts.join("; ") || "secrets"}) � reloading functions`,
91
+ );
92
+ await opts.onChanged(diff);
93
+ } catch (err) {
94
+ const msg = err instanceof Error ? err.message : String(err);
95
+ log(`[1tube] HMR env reload FAILED: ${msg}`);
96
+ } finally {
97
+ flushing = false;
98
+ if (pending) {
99
+ timerId = setTimer(flush, debounceMs);
100
+ }
101
+ }
102
+ };
103
+
104
+ const schedule = () => {
105
+ pending = true;
106
+ if (flushing) return;
107
+ if (timerId !== null) {
108
+ // Leading-edge: first event of a quiet period already armed �
109
+ // don't keep pushing the flush out.
110
+ return;
111
+ }
112
+ timerId = setTimer(flush, leadingMs);
113
+ };
114
+
115
+ return {
116
+ start() {
117
+ if (stream || envFiles.length === 0) return Promise.resolve();
118
+ const dirs = envFileWatchDirs(envFiles).filter((d) => {
119
+ try {
120
+ return Deno.statSync(d).isDirectory;
121
+ } catch {
122
+ return false;
123
+ }
124
+ });
125
+ if (dirs.length === 0) return Promise.resolve();
126
+
127
+ // Seed ownership from the current files so a subsequent delete of
128
+ // a key from `.env` can be removed from Deno.env on the next save.
129
+ apply(envFiles, env);
130
+
131
+ stream = watcherFactory(dirs);
132
+ log(
133
+ `[1tube] HMR watching env: ${envFiles.join(", ")}`,
134
+ );
135
+ consumeLoop = (async () => {
136
+ try {
137
+ for await (const event of stream!) {
138
+ if (stopped) break;
139
+ if (event.paths.length === 0) continue;
140
+ if (!isEnvFileEvent(event.paths, envFiles)) continue;
141
+ schedule();
142
+ }
143
+ } catch (err) {
144
+ if (!stopped) log(`[1tube] HMR env watcher disabled: ${err}`);
145
+ }
146
+ })();
147
+ return Promise.resolve();
148
+ },
149
+ async stop() {
150
+ if (stopped) return;
151
+ stopped = true;
152
+ if (timerId !== null) {
153
+ clearTimer(timerId);
154
+ timerId = null;
155
+ }
156
+ try {
157
+ stream?.close();
158
+ } catch { /* */ }
159
+ stream = null;
160
+ if (consumeLoop) {
161
+ let timeoutId: number | undefined;
162
+ const bound = new Promise<void>((resolve) => {
163
+ timeoutId = setTimeout(resolve, 250) as unknown as number;
164
+ });
165
+ await Promise.race([consumeLoop, bound]);
166
+ if (timeoutId !== undefined) clearTimeout(timeoutId);
167
+ }
168
+ },
169
+ };
170
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Dotenv parse + apply helpers for HMR secret reloads.
3
+ *
4
+ * Deno's `--env-file` only loads once at process start. When `--hmr` is
5
+ * on we re-read the same files on change, push values into `Deno.env`,
6
+ * and ask the hot-reloader to respawn workers / workerd so top-level
7
+ * `const KEY = Deno.env.get(...)` captures pick up the new secrets.
8
+ */
9
+
10
+ import { basename, dirname, isAbsolute, resolve } from "node:path";
11
+
12
+ export type EnvSource = {
13
+ get(key: string): string | undefined;
14
+ set(key: string, value: string): void;
15
+ delete(key: string): void;
16
+ };
17
+
18
+ export interface EnvApplyDiff {
19
+ /** Absolute paths that were successfully read. */
20
+ files: string[];
21
+ added: string[];
22
+ updated: string[];
23
+ removed: string[];
24
+ /** True when at least one key was added, updated, or removed. */
25
+ changed: boolean;
26
+ }
27
+
28
+ /** Keys currently owned by watched env files (union across all files). */
29
+ const ownedKeys = new Set<string>();
30
+
31
+ /** Test seam: reset owned-key tracking between tests. */
32
+ export function resetEnvFileOwnership(): void {
33
+ ownedKeys.clear();
34
+ }
35
+
36
+ /**
37
+ * Parse a dotenv-format document into an ordered map.
38
+ *
39
+ * Mirrors Deno's `--env-file` rules closely enough for HMR:
40
+ * - `#` comments and blank lines are ignored
41
+ * - optional `export ` prefix
42
+ * - first occurrence of a key wins within a file
43
+ * - unquoted / single-quoted / double-quoted values
44
+ * - `\n` / `\r` / `\t` / `\\` / `\"` escapes inside double quotes
45
+ */
46
+ export function parseEnvFile(content: string): Map<string, string> {
47
+ const out = new Map<string, string>();
48
+ const lines = content.split(/\r?\n/);
49
+ for (const raw of lines) {
50
+ const line = raw.trim();
51
+ if (!line || line.startsWith("#")) continue;
52
+ const body = line.startsWith("export ")
53
+ ? line.slice("export ".length).trim()
54
+ : line;
55
+ const eq = body.indexOf("=");
56
+ if (eq <= 0) continue;
57
+ const key = body.slice(0, eq).trim();
58
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
59
+ if (out.has(key)) continue; // first wins
60
+ let value = body.slice(eq + 1).trim();
61
+ if (
62
+ (value.startsWith('"') && value.endsWith('"') && value.length >= 2) ||
63
+ (value.startsWith("'") && value.endsWith("'") && value.length >= 2)
64
+ ) {
65
+ const quote = value[0];
66
+ value = value.slice(1, -1);
67
+ if (quote === '"') {
68
+ value = value
69
+ .replace(/\\n/g, "\n")
70
+ .replace(/\\r/g, "\r")
71
+ .replace(/\\t/g, "\t")
72
+ .replace(/\\"/g, '"')
73
+ .replace(/\\\\/g, "\\");
74
+ }
75
+ } else {
76
+ // Unquoted: strip inline comments (`KEY=val # comment`).
77
+ const hash = value.indexOf(" #");
78
+ if (hash !== -1) value = value.slice(0, hash).trimEnd();
79
+ }
80
+ out.set(key, value);
81
+ }
82
+ return out;
83
+ }
84
+
85
+ /**
86
+ * Resolve which env files HMR should watch.
87
+ *
88
+ * Priority:
89
+ * 1. Explicit list (tests / future CLI)
90
+ * 2. `1TUBE_ENV_FILES` (comma-separated) � set by the launcher when
91
+ * the operator passes `--env-file=�`
92
+ * 3. Convention: `.env`, `.env.local`, `.env.development` under cwd
93
+ * when those files already exist
94
+ */
95
+ export function resolveWatchedEnvFiles(opts: {
96
+ cwd?: string;
97
+ explicit?: readonly string[];
98
+ envFilesEnv?: string | null;
99
+ } = {}): string[] {
100
+ const cwd = opts.cwd ?? Deno.cwd();
101
+ const collected: string[] = [];
102
+ if (opts.explicit) {
103
+ for (const p of opts.explicit) collected.push(p);
104
+ }
105
+ const fromEnv = opts.envFilesEnv === null
106
+ ? ""
107
+ : (opts.envFilesEnv ?? Deno.env.get("1TUBE_ENV_FILES") ?? "");
108
+ for (const part of fromEnv.split(",")) {
109
+ const t = part.trim();
110
+ if (t) collected.push(t);
111
+ }
112
+ if (collected.length === 0) {
113
+ for (const name of [".env", ".env.local", ".env.development"]) {
114
+ const candidate = resolve(cwd, name);
115
+ try {
116
+ if (Deno.statSync(candidate).isFile) collected.push(candidate);
117
+ } catch {
118
+ // absent � skip
119
+ }
120
+ }
121
+ }
122
+ const seen = new Set<string>();
123
+ const out: string[] = [];
124
+ for (const p of collected) {
125
+ const abs = isAbsolute(p) ? resolve(p) : resolve(cwd, p);
126
+ if (seen.has(abs)) continue;
127
+ seen.add(abs);
128
+ out.push(abs);
129
+ }
130
+ return out;
131
+ }
132
+
133
+ /**
134
+ * Re-read `files` (in order; later files override earlier) and sync
135
+ * into `env`. Keys that disappeared from every watched file since the
136
+ * last apply are deleted so secret rotations/removals take effect.
137
+ */
138
+ export function applyEnvFiles(
139
+ files: readonly string[],
140
+ env: EnvSource = Deno.env,
141
+ ): EnvApplyDiff {
142
+ const merged = new Map<string, string>();
143
+ const readFiles: string[] = [];
144
+ for (const file of files) {
145
+ let text: string;
146
+ try {
147
+ text = Deno.readTextFileSync(file);
148
+ } catch {
149
+ // Missing mid-edit (atomic save) � skip this file for now; the
150
+ // next fs event will retry. Don't wipe owned keys on a transient
151
+ // read failure.
152
+ continue;
153
+ }
154
+ readFiles.push(file);
155
+ const parsed = parseEnvFile(text);
156
+ // Later files win (matches Deno's last `--env-file` precedence).
157
+ for (const [k, v] of parsed) merged.set(k, v);
158
+ }
159
+
160
+ // If every file failed to read, leave state alone � likely a
161
+ // transient atomic-rename race.
162
+ if (files.length > 0 && readFiles.length === 0) {
163
+ return {
164
+ files: [],
165
+ added: [],
166
+ updated: [],
167
+ removed: [],
168
+ changed: false,
169
+ };
170
+ }
171
+
172
+ const added: string[] = [];
173
+ const updated: string[] = [];
174
+ const removed: string[] = [];
175
+
176
+ for (const [key, value] of merged) {
177
+ const prev = env.get(key);
178
+ if (prev === undefined) {
179
+ env.set(key, value);
180
+ added.push(key);
181
+ } else if (prev !== value) {
182
+ env.set(key, value);
183
+ updated.push(key);
184
+ }
185
+ }
186
+
187
+ for (const key of ownedKeys) {
188
+ if (!merged.has(key)) {
189
+ env.delete(key);
190
+ removed.push(key);
191
+ }
192
+ }
193
+
194
+ ownedKeys.clear();
195
+ for (const key of merged.keys()) ownedKeys.add(key);
196
+
197
+ added.sort();
198
+ updated.sort();
199
+ removed.sort();
200
+ return {
201
+ files: readFiles,
202
+ added,
203
+ updated,
204
+ removed,
205
+ changed: added.length + updated.length + removed.length > 0,
206
+ };
207
+ }
208
+
209
+ /** Parent directories to feed `Deno.watchFs` (atomic renames land here). */
210
+ export function envFileWatchDirs(files: readonly string[]): string[] {
211
+ const dirs = new Set<string>();
212
+ for (const f of files) {
213
+ dirs.add(dirname(f));
214
+ }
215
+ return [...dirs];
216
+ }
217
+
218
+ /** True when an fs event path is (or renames onto) one of our env files. */
219
+ export function isEnvFileEvent(
220
+ eventPaths: readonly string[],
221
+ envFiles: readonly string[],
222
+ ): boolean {
223
+ if (envFiles.length === 0) return false;
224
+ const targets = new Set(envFiles.map((p) => resolve(p)));
225
+ const byDir = new Map<string, Set<string>>();
226
+ for (const t of targets) {
227
+ const dir = dirname(t);
228
+ const name = basename(t);
229
+ let set = byDir.get(dir);
230
+ if (!set) {
231
+ set = new Set();
232
+ byDir.set(dir, set);
233
+ }
234
+ set.add(name);
235
+ }
236
+ for (const raw of eventPaths) {
237
+ const p = resolve(raw);
238
+ if (targets.has(p)) return true;
239
+ // Atomic save: event may report the temp sibling or just the dir.
240
+ const dir = dirname(p);
241
+ const names = byDir.get(dir);
242
+ if (!names) continue;
243
+ if (names.has(basename(p))) return true;
244
+ // Directory-level events (Windows) � treat as "maybe our file".
245
+ try {
246
+ if (Deno.statSync(p).isDirectory && byDir.has(p)) return true;
247
+ } catch {
248
+ // deleted path � if its basename matched we'd already returned;
249
+ // a missing temp file still warrants a re-read of that dir's targets.
250
+ if (byDir.has(dir)) return true;
251
+ }
252
+ }
253
+ return false;
254
+ }
package/src/launch.ts CHANGED
@@ -468,6 +468,14 @@ async function runServe(args: ServeArgs): Promise<never> {
468
468
  // Child env additions (merged onto the inherited environment).
469
469
  const childEnv: Record<string, string> = {};
470
470
  if (lockAbs) childEnv.ONETUBE_LOCK = lockAbs;
471
+ // Tell the gateway which `--env-file` paths to HMR-watch. Deno itself
472
+ // only loads them once at process start; the gateway re-applies on
473
+ // change so secret edits land without a full restart.
474
+ if (args.envFiles.length > 0) {
475
+ childEnv["1TUBE_ENV_FILES"] = args.envFiles
476
+ .map((p) => resolveToAbs(p))
477
+ .join(",");
478
+ }
471
479
  // Deno 2.9's 24h minimum-dependency-age default makes freshly published
472
480
  // pins fail to resolve; it must be set before the gateway's Deno starts.
473
481
  if (
package/src/server.ts CHANGED
@@ -74,6 +74,11 @@ import {
74
74
  createRewriteCache,
75
75
  type RewriteCache,
76
76
  } from "./backends/deno/source-rewriter.ts";
77
+ import {
78
+ createEnvHotReloader,
79
+ type EnvHotReloader,
80
+ } from "./backends/env-hot-reloader.ts";
81
+ import { resolveWatchedEnvFiles } from "./env-file.ts";
77
82
  import { createBootProgress, createScanProgress } from "./boot-progress.ts";
78
83
  import {
79
84
  createWorkerdWatchdog,
@@ -1227,6 +1232,7 @@ let workerdWatchdog: WorkerdWatchdog | null = null;
1227
1232
  const workerdNames = new Set<string>();
1228
1233
  let denoWorkerHost: DenoWorkerHost | null = null;
1229
1234
  let denoHotReloader: DenoHotReloader | null = null;
1235
+ let envHotReloader: EnvHotReloader | null = null;
1230
1236
  let denoSharedRuntimeRef: DenoSharedRuntime | null = null;
1231
1237
  let denoRewriteCacheRef: RewriteCache | null = null;
1232
1238
 
@@ -1705,6 +1711,46 @@ if (opts.backend === "workerd") {
1705
1711
  }
1706
1712
  }
1707
1713
 
1714
+ // Env-file HMR: Deno's `--env-file` only loads at process start, and
1715
+ // the functions-dir watcher never sees project-root `.env` saves.
1716
+ // Watch the same files the launcher forwarded (via 1TUBE_ENV_FILES)
1717
+ // or the conventional `.env*` in cwd, re-apply into Deno.env, and
1718
+ // respawn every function so secret rotations land immediately.
1719
+ if (opts.hmr && !opts.prebuiltDir) {
1720
+ const envFiles = resolveWatchedEnvFiles();
1721
+ if (envFiles.length > 0) {
1722
+ envHotReloader = createEnvHotReloader({
1723
+ envFiles,
1724
+ onChanged: async () => {
1725
+ if (workerdBackend) {
1726
+ const result = await workerdBackend.reload("all");
1727
+ console.log(
1728
+ `[1tube] HMR env reload ok in ${result.durationMs.toFixed(0)}ms ` +
1729
+ `(gen=${result.generation}; rebundled=all)`,
1730
+ );
1731
+ return;
1732
+ }
1733
+ if (denoWorkerHost) {
1734
+ const summary = await denoWorkerHost.reload(
1735
+ "all",
1736
+ "env file changed",
1737
+ );
1738
+ console.log(
1739
+ `[1tube] HMR env reload ok in ${summary.durationMs.toFixed(0)}ms` +
1740
+ (summary.reloaded.length > 0
1741
+ ? ` (reloaded=${summary.reloaded.join(",")})`
1742
+ : ""),
1743
+ );
1744
+ for (const e of summary.errors) {
1745
+ console.log(`[1tube] HMR ${e.name}: ${e.error}`);
1746
+ }
1747
+ }
1748
+ },
1749
+ });
1750
+ await envHotReloader.start();
1751
+ }
1752
+ }
1753
+
1708
1754
  // ---------------------------------------------------------------------------
1709
1755
  // HTTP Server
1710
1756
  // ---------------------------------------------------------------------------
@@ -2480,6 +2526,11 @@ async function shutdown(reason: string) {
2480
2526
  console.warn("[1tube] deno hot reloader stop() error:", err);
2481
2527
  });
2482
2528
  }
2529
+ if (envHotReloader) {
2530
+ envHotReloader.stop().catch((err) => {
2531
+ console.warn("[1tube] env hot reloader stop() error:", err);
2532
+ });
2533
+ }
2483
2534
 
2484
2535
  // Phase 1: stop accepting NEW connections. Already-accepted
2485
2536
  // requests keep running against the still-live workerd. This is