1tube 0.1.0

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.
@@ -0,0 +1,91 @@
1
+ /**
2
+ * ESLint plugin for projects consuming 1tube.
3
+ *
4
+ * Prevents accidentally building edge-function URLs from SUPABASE_URL
5
+ * (which bypasses the 1tube gateway) and from using EDGE_URL directly
6
+ * (which skips the named constant pattern in api.ts).
7
+ *
8
+ * Usage in eslint.config.js (flat config):
9
+ *
10
+ * import oneTube from "1tube/eslint-plugin";
11
+ *
12
+ * export default defineConfig({
13
+ * plugins: { "1tube": oneTube },
14
+ * rules: {
15
+ * "1tube/no-supabase-function-urls": "error",
16
+ * "1tube/no-direct-edge-url": "error",
17
+ * },
18
+ * });
19
+ */
20
+
21
+ const plugin = {
22
+ rules: {
23
+ "no-supabase-function-urls": {
24
+ meta: {
25
+ type: "problem",
26
+ docs: {
27
+ description:
28
+ "Disallow building edge-function URLs from API_CONFIG.SUPABASE_URL",
29
+ },
30
+ messages: {
31
+ useFnConstant:
32
+ "Don't build function URLs from API_CONFIG.SUPABASE_URL — use the named API_CONFIG.*_URL constants (routed via EDGE_URL / 1tube). Add a new constant in api.ts if one is missing.",
33
+ },
34
+ schema: [],
35
+ },
36
+ create(context) {
37
+ return {
38
+ TemplateLiteral(node) {
39
+ const exprs = node.expressions;
40
+ const quasis = node.quasis;
41
+ for (let i = 0; i < exprs.length; i++) {
42
+ const expr = exprs[i];
43
+ if (
44
+ expr.type === "MemberExpression" &&
45
+ expr.object.type === "Identifier" &&
46
+ expr.object.name === "API_CONFIG" &&
47
+ expr.property.type === "Identifier" &&
48
+ expr.property.name === "SUPABASE_URL" &&
49
+ quasis[i + 1] &&
50
+ /^\/functions\b/.test(quasis[i + 1].value.raw)
51
+ ) {
52
+ context.report({ node: expr, messageId: "useFnConstant" });
53
+ }
54
+ }
55
+ },
56
+ };
57
+ },
58
+ },
59
+
60
+ "no-direct-edge-url": {
61
+ meta: {
62
+ type: "problem",
63
+ docs: {
64
+ description:
65
+ "Disallow using API_CONFIG.EDGE_URL directly to construct URLs",
66
+ },
67
+ messages: {
68
+ useNamedConstant:
69
+ "Don't use API_CONFIG.EDGE_URL directly — use the named API_CONFIG.*_URL constants instead (e.g. API_CONFIG.AUTH_ME_URL). Add a new constant in api.ts if one is missing.",
70
+ },
71
+ schema: [],
72
+ },
73
+ create(context) {
74
+ return {
75
+ MemberExpression(node) {
76
+ if (
77
+ node.object.type === "Identifier" &&
78
+ node.object.name === "API_CONFIG" &&
79
+ node.property.type === "Identifier" &&
80
+ node.property.name === "EDGE_URL"
81
+ ) {
82
+ context.report({ node, messageId: "useNamedConstant" });
83
+ }
84
+ },
85
+ };
86
+ },
87
+ },
88
+ },
89
+ };
90
+
91
+ export default plugin;
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "1tube",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "Self-hosted Supabase Edge Functions gateway for Deno, plus an ESLint plugin for consumers",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/lofcz/1tube.git"
11
+ },
12
+ "homepage": "https://github.com/lofcz/1tube",
13
+ "bugs": {
14
+ "url": "https://github.com/lofcz/1tube/issues"
15
+ },
16
+ "exports": {
17
+ "./eslint-plugin": "./eslint-plugin.js",
18
+ "./package.json": "./package.json"
19
+ },
20
+ "files": [
21
+ "eslint-plugin.js",
22
+ "src",
23
+ "deno.json",
24
+ "deno.lock",
25
+ "README.md",
26
+ "LICENSE",
27
+ ".env.example"
28
+ ],
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "scripts": {
33
+ "dev": "deno task dev",
34
+ "serve": "deno task serve",
35
+ "typecheck": "deno check src/server.ts eslint-plugin.js",
36
+ "check": "deno cache src/server.ts",
37
+ "prepublishOnly": "bun run typecheck && bun run check"
38
+ },
39
+ "dependencies": {
40
+ "hono": "^4"
41
+ }
42
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Discovers and loads edge function modules from the filesystem.
3
+ *
4
+ * Scans `functionsDir` for subdirectories containing an `index.ts`.
5
+ * Skips directories starting with `_` or ending with `_shared`.
6
+ * Each discovered module is dynamically imported — the top-level `serve()` call
7
+ * in the module registers the handler via the global FunctionRegistry.
8
+ */
9
+
10
+ import { join, toFileUrl } from "jsr:@std/path@^1";
11
+ import type { FunctionRegistry } from "./registry.ts";
12
+
13
+ export async function discoverAndLoad(
14
+ functionsDir: string,
15
+ registry: FunctionRegistry,
16
+ ): Promise<{ loaded: string[]; skipped: string[]; errors: Array<{ name: string; error: string }> }> {
17
+ const loaded: string[] = [];
18
+ const skipped: string[] = [];
19
+ const errors: Array<{ name: string; error: string }> = [];
20
+
21
+ const resolvedDir = await Deno.realPath(functionsDir);
22
+
23
+ const entries: string[] = [];
24
+ for await (const entry of Deno.readDir(resolvedDir)) {
25
+ if (!entry.isDirectory) continue;
26
+ if (entry.name.startsWith("_") || entry.name.endsWith("_shared")) {
27
+ skipped.push(entry.name);
28
+ continue;
29
+ }
30
+ entries.push(entry.name);
31
+ }
32
+
33
+ entries.sort();
34
+
35
+ for (const name of entries) {
36
+ const indexPath = join(resolvedDir, name, "index.ts");
37
+
38
+ try {
39
+ await Deno.stat(indexPath);
40
+ } catch {
41
+ skipped.push(name);
42
+ continue;
43
+ }
44
+
45
+ registry.setCurrentFunction(name);
46
+
47
+ try {
48
+ await import(toFileUrl(indexPath).href);
49
+ loaded.push(name);
50
+ } catch (err) {
51
+ const msg = err instanceof Error ? err.message : String(err);
52
+ errors.push({ name, error: msg });
53
+
54
+ const isEnvIssue =
55
+ msg.includes("is required") ||
56
+ msg.includes("must be set") ||
57
+ msg.includes("API key") ||
58
+ msg.includes("undefined");
59
+
60
+ if (isEnvIssue) {
61
+ console.error(`[1tube] Failed to load "${name}" (missing env var): ${msg}`);
62
+ } else {
63
+ console.error(`[1tube] Failed to load "${name}": ${msg}`);
64
+ }
65
+ }
66
+ }
67
+
68
+ return { loaded, skipped, errors };
69
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * JWT authentication for the gateway.
3
+ *
4
+ * Validates HMAC-SHA256 JWTs using the same secret as Supabase,
5
+ * producing tokens fully compatible with Supabase RLS policies.
6
+ */
7
+
8
+ import type { AuthContext, JWTPayload } from "../registry.ts";
9
+
10
+ const JWT_SECRET = Deno.env.get("JWT_SECRET") || "";
11
+ if (!JWT_SECRET) {
12
+ console.warn("[1tube] JWT_SECRET not set — authenticated endpoints will reject all requests");
13
+ }
14
+
15
+ const encoder = new TextEncoder();
16
+ const decoder = new TextDecoder();
17
+
18
+ let _cachedKey: CryptoKey | null = null;
19
+
20
+ async function getKey(): Promise<CryptoKey> {
21
+ if (_cachedKey) return _cachedKey;
22
+ _cachedKey = await crypto.subtle.importKey(
23
+ "raw",
24
+ encoder.encode(JWT_SECRET),
25
+ { name: "HMAC", hash: "SHA-256" },
26
+ false,
27
+ ["verify"],
28
+ );
29
+ return _cachedKey;
30
+ }
31
+
32
+ function base64UrlDecode(str: string): Uint8Array {
33
+ const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
34
+ const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
35
+ const binary = atob(padded);
36
+ return Uint8Array.from(binary, (c) => c.charCodeAt(0));
37
+ }
38
+
39
+ function parseBearer(header: string | null): string | null {
40
+ if (!header) return null;
41
+ const match = header.match(/^Bearer\s+(.+)$/i);
42
+ return match ? match[1] : null;
43
+ }
44
+
45
+ async function verifyToken(token: string): Promise<JWTPayload | null> {
46
+ try {
47
+ const parts = token.split(".");
48
+ if (parts.length !== 3) return null;
49
+
50
+ const [headerB64, payloadB64, signatureB64] = parts;
51
+ const key = await getKey();
52
+ const sigBytes = base64UrlDecode(signatureB64);
53
+
54
+ const isValid = await crypto.subtle.verify(
55
+ "HMAC",
56
+ key,
57
+ sigBytes.buffer as ArrayBuffer,
58
+ encoder.encode(`${headerB64}.${payloadB64}`),
59
+ );
60
+ if (!isValid) return null;
61
+
62
+ const payload = JSON.parse(decoder.decode(base64UrlDecode(payloadB64))) as JWTPayload;
63
+
64
+ if (payload.exp < Math.floor(Date.now() / 1000)) return null;
65
+
66
+ return payload;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Validate the Authorization header and return an AuthContext, or null if invalid.
74
+ */
75
+ export async function validateRequest(req: Request): Promise<AuthContext | null> {
76
+ const token = parseBearer(req.headers.get("Authorization"));
77
+ if (!token) return null;
78
+
79
+ const payload = await verifyToken(token);
80
+ if (!payload) return null;
81
+
82
+ return {
83
+ userId: payload.sub,
84
+ email: payload.email,
85
+ payload,
86
+ rawToken: token,
87
+ };
88
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * CORS handling for the gateway.
3
+ * Mirrors the Supabase Edge Functions CORS behavior.
4
+ */
5
+
6
+ import type { Context, Next } from "npm:hono@4";
7
+
8
+ const CORS_HEADERS: Record<string, string> = {
9
+ "Access-Control-Allow-Origin": "*",
10
+ "Access-Control-Allow-Headers":
11
+ "authorization, x-client-info, apikey, content-type, x-application-name, user-agent",
12
+ "Access-Control-Allow-Methods": "POST, GET, OPTIONS, PUT, PATCH, DELETE",
13
+ };
14
+
15
+ export async function corsMiddleware(c: Context, next: Next) {
16
+ if (c.req.method === "OPTIONS") {
17
+ return new Response(null, { status: 204, headers: CORS_HEADERS });
18
+ }
19
+
20
+ await next();
21
+
22
+ for (const [key, value] of Object.entries(CORS_HEADERS)) {
23
+ c.res.headers.set(key, value);
24
+ }
25
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Structured request logging and metrics collection.
3
+ *
4
+ * Logs every request as a JSON line to stdout.
5
+ * Tracks per-function metrics (count, latency, errors) for the /metrics endpoint.
6
+ */
7
+
8
+ import type { Context, Next } from "npm:hono@4";
9
+
10
+ export interface FunctionMetrics {
11
+ invocations: number;
12
+ errors: number;
13
+ totalDurationMs: number;
14
+ lastInvocation: number;
15
+ }
16
+
17
+ const metricsMap = new Map<string, FunctionMetrics>();
18
+ let globalRequests = 0;
19
+ let globalErrors = 0;
20
+ const startedAt = Date.now();
21
+
22
+ function getMetrics(name: string): FunctionMetrics {
23
+ let m = metricsMap.get(name);
24
+ if (!m) {
25
+ m = { invocations: 0, errors: 0, totalDurationMs: 0, lastInvocation: 0 };
26
+ metricsMap.set(name, m);
27
+ }
28
+ return m;
29
+ }
30
+
31
+ export async function loggingMiddleware(c: Context, next: Next) {
32
+ const start = performance.now();
33
+ const fnName = c.req.path.replace("/functions/v1/", "") || "unknown";
34
+
35
+ await next();
36
+
37
+ const durationMs = Math.round(performance.now() - start);
38
+ const status = c.res.status;
39
+ const isError = status >= 400;
40
+
41
+ const m = getMetrics(fnName);
42
+ m.invocations++;
43
+ m.totalDurationMs += durationMs;
44
+ m.lastInvocation = Date.now();
45
+ globalRequests++;
46
+
47
+ if (isError) {
48
+ m.errors++;
49
+ globalErrors++;
50
+ }
51
+
52
+ const userId = (c.get("userId") as string) || null;
53
+ const userTag = userId ? ` [user:${userId.slice(0, 8)}]` : "";
54
+ const statusColor = isError ? "\x1b[31m" : "\x1b[32m";
55
+ const reset = "\x1b[0m";
56
+ const dim = "\x1b[2m";
57
+
58
+ const line = `\x1b[36m[1tube]${reset} ${c.req.method} /functions/v1/${fnName} → ${statusColor}${status}${reset} ${dim}(${durationMs}ms)${reset}${userTag}`;
59
+
60
+ if (isError) {
61
+ console.error(line);
62
+ } else {
63
+ console.log(line);
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Returns collected metrics for the /metrics endpoint.
69
+ */
70
+ export function getCollectedMetrics(): {
71
+ uptime_ms: number;
72
+ total_requests: number;
73
+ total_errors: number;
74
+ functions: Record<string, FunctionMetrics>;
75
+ } {
76
+ return {
77
+ uptime_ms: Date.now() - startedAt,
78
+ total_requests: globalRequests,
79
+ total_errors: globalErrors,
80
+ functions: Object.fromEntries(metricsMap),
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Prometheus-compatible text format for /metrics.
86
+ */
87
+ export function getPrometheusMetrics(): string {
88
+ const lines: string[] = [];
89
+
90
+ lines.push("# HELP onetube_uptime_seconds Gateway uptime in seconds");
91
+ lines.push("# TYPE onetube_uptime_seconds gauge");
92
+ lines.push(`onetube_uptime_seconds ${Math.floor((Date.now() - startedAt) / 1000)}`);
93
+
94
+ lines.push("# HELP onetube_requests_total Total number of function invocations");
95
+ lines.push("# TYPE onetube_requests_total counter");
96
+ lines.push(`onetube_requests_total ${globalRequests}`);
97
+
98
+ lines.push("# HELP onetube_errors_total Total number of error responses");
99
+ lines.push("# TYPE onetube_errors_total counter");
100
+ lines.push(`onetube_errors_total ${globalErrors}`);
101
+
102
+ lines.push("# HELP onetube_function_invocations_total Invocations per function");
103
+ lines.push("# TYPE onetube_function_invocations_total counter");
104
+ for (const [name, m] of metricsMap) {
105
+ lines.push(`onetube_function_invocations_total{function="${name}"} ${m.invocations}`);
106
+ }
107
+
108
+ lines.push("# HELP onetube_function_errors_total Errors per function");
109
+ lines.push("# TYPE onetube_function_errors_total counter");
110
+ for (const [name, m] of metricsMap) {
111
+ lines.push(`onetube_function_errors_total{function="${name}"} ${m.errors}`);
112
+ }
113
+
114
+ lines.push("# HELP onetube_function_duration_ms_avg Average request duration per function");
115
+ lines.push("# TYPE onetube_function_duration_ms_avg gauge");
116
+ for (const [name, m] of metricsMap) {
117
+ const avg = m.invocations > 0 ? Math.round(m.totalDurationMs / m.invocations) : 0;
118
+ lines.push(`onetube_function_duration_ms_avg{function="${name}"} ${avg}`);
119
+ }
120
+
121
+ return lines.join("\n") + "\n";
122
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * In-memory token bucket rate limiter.
3
+ * Keyed by user ID (authenticated) or IP (public endpoints).
4
+ */
5
+
6
+ import type { Context, Next } from "npm:hono@4";
7
+
8
+ interface Bucket {
9
+ tokens: number;
10
+ lastRefill: number;
11
+ }
12
+
13
+ interface RateLimitConfig {
14
+ /** Requests per minute (default). */
15
+ defaultRpm: number;
16
+ /** Per-function overrides. */
17
+ overrides: Record<string, number>;
18
+ /** Max buckets to track before evicting oldest. */
19
+ maxBuckets: number;
20
+ }
21
+
22
+ const DEFAULT_CONFIG: RateLimitConfig = {
23
+ defaultRpm: 120,
24
+ overrides: {
25
+ "ai-chat": 30,
26
+ "pdf-generate": 10,
27
+ "discussion-chat": 30,
28
+ "questions-generate": 20,
29
+ },
30
+ maxBuckets: 10_000,
31
+ };
32
+
33
+ const buckets = new Map<string, Bucket>();
34
+
35
+ function getOrCreateBucket(key: string, rpm: number): Bucket {
36
+ const now = Date.now();
37
+ let bucket = buckets.get(key);
38
+
39
+ if (!bucket) {
40
+ if (buckets.size >= DEFAULT_CONFIG.maxBuckets) {
41
+ const oldest = buckets.keys().next().value!;
42
+ buckets.delete(oldest);
43
+ }
44
+ bucket = { tokens: rpm, lastRefill: now };
45
+ buckets.set(key, bucket);
46
+ return bucket;
47
+ }
48
+
49
+ const elapsed = (now - bucket.lastRefill) / 60_000;
50
+ const refill = Math.floor(elapsed * rpm);
51
+ if (refill > 0) {
52
+ bucket.tokens = Math.min(rpm, bucket.tokens + refill);
53
+ bucket.lastRefill = now;
54
+ }
55
+
56
+ return bucket;
57
+ }
58
+
59
+ function resolveKey(c: Context): string {
60
+ const userId = c.get("userId") as string | undefined;
61
+ if (userId) return `user:${userId}`;
62
+
63
+ return `ip:${c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || "unknown"}`;
64
+ }
65
+
66
+ export function createRateLimiter(config: Partial<RateLimitConfig> = {}) {
67
+ const cfg = { ...DEFAULT_CONFIG, ...config };
68
+
69
+ return async (c: Context, next: Next) => {
70
+ const fnName = c.req.param("name");
71
+ const rpm = (fnName && cfg.overrides[fnName]) || cfg.defaultRpm;
72
+ const key = `${resolveKey(c)}:${fnName || "global"}`;
73
+ const bucket = getOrCreateBucket(key, rpm);
74
+
75
+ if (bucket.tokens <= 0) {
76
+ return c.json(
77
+ { error: "Rate limit exceeded", retryAfterSeconds: Math.ceil(60 / rpm) },
78
+ 429,
79
+ );
80
+ }
81
+
82
+ bucket.tokens--;
83
+ await next();
84
+ };
85
+ }
package/src/health.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Health check and metrics endpoints.
3
+ */
4
+
5
+ import type { Context } from "npm:hono@4";
6
+ import type { FunctionRegistry } from "./registry.ts";
7
+ import { getCollectedMetrics, getPrometheusMetrics } from "./gateway/logging.ts";
8
+
9
+ export function createHealthHandler(registry: FunctionRegistry) {
10
+ return (c: Context) => {
11
+ const metrics = getCollectedMetrics();
12
+ return c.json({
13
+ status: "ok",
14
+ function_count: registry.size,
15
+ functionList: registry.list(),
16
+ ...metrics,
17
+ });
18
+ };
19
+ }
20
+
21
+ export function createMetricsHandler() {
22
+ return (c: Context) => {
23
+ return new Response(getPrometheusMetrics(), {
24
+ headers: { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" },
25
+ });
26
+ };
27
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Global function registry that captures handlers from edge function modules.
3
+ *
4
+ * When a function module calls `serve(handler, opts)` from `_shared/handler.ts`,
5
+ * the shim checks for `globalThis.__edgeFunctionRegistry`. If present, the handler
6
+ * is stored here instead of starting a Deno.serve() per function.
7
+ */
8
+
9
+ export interface AuthContext {
10
+ userId: string;
11
+ email: string;
12
+ payload: JWTPayload;
13
+ rawToken: string;
14
+ }
15
+
16
+ export interface JWTPayload {
17
+ sub: string;
18
+ email: string;
19
+ exp: number;
20
+ iat: number;
21
+ role: string;
22
+ iss: string;
23
+ aud: string;
24
+ }
25
+
26
+ export type AuthenticatedHandler = (
27
+ req: Request,
28
+ auth: AuthContext,
29
+ ) => Response | Promise<Response>;
30
+
31
+ export type PublicHandler = (req: Request) => Response | Promise<Response>;
32
+
33
+ export interface RegisteredFunction {
34
+ handler: AuthenticatedHandler | PublicHandler;
35
+ isPublic: boolean;
36
+ /** Per-function wall clock timeout in ms. Undefined = use gateway default.
37
+ * Only compatible with 1tube edge functions runtime. Ignored by Supabase Edge Functions runtime.
38
+ */
39
+ timeoutMs?: number;
40
+ }
41
+
42
+ export class FunctionRegistry {
43
+ private handlers = new Map<string, RegisteredFunction>();
44
+ private _currentName = "";
45
+
46
+ /** Set by the discovery loader before importing each function module. */
47
+ setCurrentFunction(name: string): void {
48
+ this._currentName = name;
49
+ }
50
+
51
+ /**
52
+ * Called by the `serve()` shim in `_shared/handler.ts`.
53
+ * Captures the handler instead of starting a server.
54
+ */
55
+ register(
56
+ handler: AuthenticatedHandler | PublicHandler,
57
+ opts: { public: boolean; timeoutMs?: number },
58
+ ): void {
59
+ if (!this._currentName) {
60
+ throw new Error(
61
+ "[1tube] registry.register() called without setCurrentFunction(). " +
62
+ "This is a bug in the function loader.",
63
+ );
64
+ }
65
+ this.handlers.set(this._currentName, {
66
+ handler,
67
+ isPublic: opts.public,
68
+ timeoutMs: opts.timeoutMs,
69
+ });
70
+ }
71
+
72
+ get(name: string): RegisteredFunction | undefined {
73
+ return this.handlers.get(name);
74
+ }
75
+
76
+ list(): string[] {
77
+ return [...this.handlers.keys()].sort();
78
+ }
79
+
80
+ get size(): number {
81
+ return this.handlers.size;
82
+ }
83
+ }