Support shared modules for supabase edge functions (#1964)
Credit: thanks @SlayTheDragons whose PR https://github.com/dyad-sh/dyad/pull/1665 paved the way for this implementation. <!-- CURSOR_SUMMARY --> > [!NOTE] > Adds _shared module support for Supabase edge functions with import-map packaging and automatic redeploys; updates deployment to include full function directories plus shared files, and adds path utilities and tests. > > - **Supabase Edge Functions** > - **Shared Modules Support**: Detect `_shared` changes and redeploy all functions; regular function changes deploy only that function. > - **Deployment Overhaul**: `deploySupabaseFunctions` now uploads full function directories plus `_shared` files via multipart form-data, sets `entrypoint_path`, and writes `import_map.json` (`_shared/` → `../_shared/`). > - **Function Discovery & Packaging**: Add file collection helpers (`listFilesWithStats`, `loadZipEntries`) and path utilities (`toPosixPath`, `findFunctionDirectory`, `stripSupabaseFunctionsPrefix`) with signature-based caching for `_shared`. > - **APIs & Utils**: Introduce `isSharedServerModule`, refine `isServerFunction` (excludes `_shared`), add `extractFunctionNameFromPath`, and `buildSignature`. > - **IPC Changes** > - Update file edit/rename/delete flows to track shared module edits and trigger full redeploys; otherwise deploy per-function using extracted name and `appPath`. > - **Prompts** > - Document `_shared` usage and import pattern in Supabase prompt. > - **Tests** > - Add tests for function/shared detection, name extraction, path handling, and signature building. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit f35599ec0e708e2ef6b7e78ae7901b29953a6dff. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds support for shared modules for Supabase edge functions. Shared code in supabase/functions/_shared is now bundled via an import map and triggers redeploys across all functions when changed. - **New Features** - Detects shared modules in supabase/functions/_shared and redeploys all functions when they change. - Deploys full function directories plus shared files, and writes an import_map.json that resolves "_shared/" imports. - Auto-deploys only the affected function on file changes; switches to redeploy-all when a shared module is touched. - **Refactors** - deploySupabaseFunction now uploads multiple files (function + shared) using multipart form-data and sets entrypoint/import map. - Added file collection, path utilities, and shared-file caching via content signatures to reduce redundant reads. - Updated deployAllSupabaseFunctions to skip non-function dirs (e.g., _shared) and use functionPath. - Added tests for function/shared detection, path handling, and signature building. <sup>Written for commit 302d84625d9e61477db9ada052a027b29ff18cef. Summary will update automatically on new commits.</sup> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { withLock } from "../ipc/utils/lock_utils";
|
||||
import { readSettings, writeSettings } from "../main/settings";
|
||||
import {
|
||||
@@ -7,8 +9,42 @@ import {
|
||||
import log from "electron-log";
|
||||
import { IS_TEST_BUILD } from "../ipc/utils/test_utils";
|
||||
|
||||
const fsPromises = fs.promises;
|
||||
|
||||
const logger = log.scope("supabase_management_client");
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Interfaces for file collection and caching
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ZipFileEntry {
|
||||
relativePath: string;
|
||||
content: Buffer;
|
||||
date: Date;
|
||||
}
|
||||
|
||||
export interface FileStatEntry {
|
||||
absolutePath: string;
|
||||
relativePath: string;
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface CachedSharedFiles {
|
||||
signature: string;
|
||||
files: ZipFileEntry[];
|
||||
}
|
||||
|
||||
interface FunctionFilesResult {
|
||||
files: ZipFileEntry[];
|
||||
signature: string;
|
||||
entrypointPath: string;
|
||||
cacheKey: string;
|
||||
}
|
||||
|
||||
// Caches for shared files to avoid re-reading unchanged files
|
||||
const sharedFilesCache = new Map<string, CachedSharedFiles>();
|
||||
|
||||
/**
|
||||
* Checks if the Supabase access token is expired or about to expire
|
||||
* Returns true if token needs to be refreshed
|
||||
@@ -223,33 +259,89 @@ export async function listSupabaseBranches({
|
||||
return jsonResponse;
|
||||
}
|
||||
|
||||
export async function deploySupabaseFunctions({
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Deploy Supabase Functions with shared module support
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function deploySupabaseFunction({
|
||||
supabaseProjectId,
|
||||
functionName,
|
||||
content,
|
||||
appPath,
|
||||
}: {
|
||||
supabaseProjectId: string;
|
||||
functionName: string;
|
||||
content: string;
|
||||
appPath: string;
|
||||
}): Promise<void> {
|
||||
logger.info(
|
||||
`Deploying Supabase function: ${functionName} to project: ${supabaseProjectId}`,
|
||||
);
|
||||
|
||||
const functionPath = path.join(
|
||||
appPath,
|
||||
"supabase",
|
||||
"functions",
|
||||
functionName,
|
||||
);
|
||||
|
||||
// 1) Collect function files
|
||||
const functionFiles = await collectFunctionFiles({
|
||||
functionPath,
|
||||
functionName,
|
||||
});
|
||||
|
||||
// 2) Collect shared files (from supabase/functions/_shared/)
|
||||
const sharedFiles = await getSharedFiles(appPath);
|
||||
|
||||
// 3) Combine all files
|
||||
const filesToUpload = [...functionFiles.files, ...sharedFiles.files];
|
||||
|
||||
// 4) Create an import map next to the function entrypoint
|
||||
const entrypointPath = functionFiles.entrypointPath;
|
||||
const entryDir = path.posix.dirname(entrypointPath);
|
||||
const importMapRelPath = path.posix.join(entryDir, "import_map.json");
|
||||
|
||||
const importMapObject = {
|
||||
imports: {
|
||||
// This resolves "_shared/" imports to the _shared directory
|
||||
// From {functionName}/index.ts, ../_shared/ goes up to root then into _shared/
|
||||
"_shared/": "../_shared/",
|
||||
},
|
||||
};
|
||||
|
||||
// Add the import map file into the upload list
|
||||
filesToUpload.push({
|
||||
relativePath: importMapRelPath,
|
||||
content: Buffer.from(JSON.stringify(importMapObject, null, 2)),
|
||||
date: new Date(),
|
||||
});
|
||||
|
||||
// 5) Prepare multipart form-data
|
||||
const supabase = await getSupabaseClient();
|
||||
const formData = new FormData();
|
||||
formData.append(
|
||||
"metadata",
|
||||
JSON.stringify({
|
||||
entrypoint_path: "index.ts",
|
||||
name: functionName,
|
||||
// See: https://github.com/dyad-sh/dyad/issues/1010
|
||||
verify_jwt: false,
|
||||
}),
|
||||
);
|
||||
formData.append("file", new Blob([content]), "index.ts");
|
||||
|
||||
// Metadata: instruct Supabase to use our import map
|
||||
const metadata = {
|
||||
entrypoint_path: entrypointPath,
|
||||
name: functionName,
|
||||
verify_jwt: false,
|
||||
import_map: importMapRelPath,
|
||||
};
|
||||
|
||||
formData.append("metadata", JSON.stringify(metadata));
|
||||
|
||||
// Add all files to form data
|
||||
for (const f of filesToUpload) {
|
||||
const buf: Buffer = f.content;
|
||||
const mime = guessMimeType(f.relativePath);
|
||||
const blob = new Blob([new Uint8Array(buf)], { type: mime });
|
||||
formData.append("file", blob, f.relativePath);
|
||||
}
|
||||
|
||||
// 6) Perform the deploy request
|
||||
const response = await fetch(
|
||||
`https://api.supabase.com/v1/projects/${supabaseProjectId}/functions/deploy?slug=${functionName}`,
|
||||
`https://api.supabase.com/v1/projects/${encodeURIComponent(
|
||||
supabaseProjectId,
|
||||
)}/functions/deploy?slug=${encodeURIComponent(functionName)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -266,9 +358,211 @@ export async function deploySupabaseFunctions({
|
||||
logger.info(
|
||||
`Deployed Supabase function: ${functionName} to project: ${supabaseProjectId}`,
|
||||
);
|
||||
return response.json();
|
||||
|
||||
await response.json();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// File collection helpers
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function collectFunctionFiles({
|
||||
functionPath,
|
||||
functionName,
|
||||
}: {
|
||||
functionPath: string;
|
||||
functionName: string;
|
||||
}): Promise<FunctionFilesResult> {
|
||||
const normalizedFunctionPath = path.resolve(functionPath);
|
||||
const stats = await fsPromises.stat(normalizedFunctionPath);
|
||||
|
||||
let functionDirectory: string | null = null;
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
functionDirectory = normalizedFunctionPath;
|
||||
}
|
||||
|
||||
if (!functionDirectory) {
|
||||
throw new Error(
|
||||
`Unable to locate directory for Supabase function ${functionName}`,
|
||||
);
|
||||
}
|
||||
|
||||
const indexPath = path.join(functionDirectory, "index.ts");
|
||||
|
||||
try {
|
||||
await fsPromises.access(indexPath);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Supabase function ${functionName} is missing an index.ts entrypoint`,
|
||||
);
|
||||
}
|
||||
|
||||
// Prefix function files with functionName so the directory structure allows
|
||||
// relative imports like "../_shared/" to resolve correctly
|
||||
const statEntries = await listFilesWithStats(functionDirectory, functionName);
|
||||
const signature = buildSignature(statEntries);
|
||||
const files = await loadZipEntries(statEntries);
|
||||
|
||||
return {
|
||||
files,
|
||||
signature,
|
||||
entrypointPath: path.posix.join(
|
||||
functionName,
|
||||
toPosixPath(path.relative(functionDirectory, indexPath)),
|
||||
),
|
||||
cacheKey: functionDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
async function getSharedFiles(appPath: string): Promise<CachedSharedFiles> {
|
||||
const sharedDirectory = path.join(
|
||||
appPath,
|
||||
"supabase",
|
||||
"functions",
|
||||
"_shared",
|
||||
);
|
||||
|
||||
try {
|
||||
const sharedStats = await fsPromises.stat(sharedDirectory);
|
||||
if (!sharedStats.isDirectory()) {
|
||||
return { signature: "", files: [] };
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error && error.code === "ENOENT") {
|
||||
return { signature: "", files: [] };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const statEntries = await listFilesWithStats(sharedDirectory, "_shared");
|
||||
const signature = buildSignature(statEntries);
|
||||
|
||||
const cached = sharedFilesCache.get(sharedDirectory);
|
||||
if (cached && cached.signature === signature) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const files = await loadZipEntries(statEntries);
|
||||
const result = { signature, files };
|
||||
sharedFilesCache.set(sharedDirectory, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function listFilesWithStats(
|
||||
directory: string,
|
||||
prefix: string,
|
||||
): Promise<FileStatEntry[]> {
|
||||
const dirents = await fsPromises.readdir(directory, { withFileTypes: true });
|
||||
dirents.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const entries: FileStatEntry[] = [];
|
||||
|
||||
for (const dirent of dirents) {
|
||||
const absolutePath = path.join(directory, dirent.name);
|
||||
const relativePath = path.posix.join(prefix, dirent.name);
|
||||
|
||||
if (dirent.isDirectory()) {
|
||||
const nestedEntries = await listFilesWithStats(
|
||||
absolutePath,
|
||||
relativePath,
|
||||
);
|
||||
entries.push(...nestedEntries);
|
||||
} else if (dirent.isFile() || dirent.isSymbolicLink()) {
|
||||
const stat = await fsPromises.stat(absolutePath);
|
||||
entries.push({
|
||||
absolutePath,
|
||||
relativePath,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
size: stat.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function buildSignature(entries: FileStatEntry[]): string {
|
||||
return entries
|
||||
.map(
|
||||
(entry) =>
|
||||
`${entry.relativePath}:${entry.mtimeMs.toString(16)}:${entry.size.toString(16)}`,
|
||||
)
|
||||
.sort()
|
||||
.join("|");
|
||||
}
|
||||
|
||||
async function loadZipEntries(
|
||||
entries: FileStatEntry[],
|
||||
): Promise<ZipFileEntry[]> {
|
||||
const files: ZipFileEntry[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const content = await fsPromises.readFile(entry.absolutePath);
|
||||
files.push({
|
||||
relativePath: toPosixPath(entry.relativePath),
|
||||
content,
|
||||
date: new Date(entry.mtimeMs),
|
||||
});
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Path helpers (exported for testing)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function toPosixPath(filePath: string): string {
|
||||
return filePath.split(path.sep).join(path.posix.sep);
|
||||
}
|
||||
|
||||
export function stripSupabaseFunctionsPrefix(
|
||||
relativePath: string,
|
||||
functionName: string,
|
||||
): string {
|
||||
const normalized = toPosixPath(relativePath).replace(/^\//, "");
|
||||
const slugPrefix = `supabase/functions/${functionName}/`;
|
||||
|
||||
if (normalized.startsWith(slugPrefix)) {
|
||||
const remainder = normalized.slice(slugPrefix.length);
|
||||
return remainder || "index.ts";
|
||||
}
|
||||
|
||||
const slugFilePrefix = `supabase/functions/${functionName}`;
|
||||
|
||||
if (normalized.startsWith(slugFilePrefix)) {
|
||||
const remainder = normalized.slice(slugFilePrefix.length);
|
||||
if (remainder.startsWith("/")) {
|
||||
const trimmed = remainder.slice(1);
|
||||
return trimmed || "index.ts";
|
||||
}
|
||||
const combined = `${functionName}${remainder}`;
|
||||
return combined || "index.ts";
|
||||
}
|
||||
|
||||
const basePrefix = "supabase/functions/";
|
||||
if (normalized.startsWith(basePrefix)) {
|
||||
const withoutBase = normalized.slice(basePrefix.length);
|
||||
return withoutBase || path.posix.basename(normalized);
|
||||
}
|
||||
|
||||
return normalized || path.posix.basename(relativePath);
|
||||
}
|
||||
|
||||
function guessMimeType(filePath: string): string {
|
||||
if (filePath.endsWith(".json")) return "application/json";
|
||||
if (filePath.endsWith(".ts")) return "application/typescript";
|
||||
if (filePath.endsWith(".mjs")) return "application/javascript";
|
||||
if (filePath.endsWith(".js")) return "application/javascript";
|
||||
if (filePath.endsWith(".wasm")) return "application/wasm";
|
||||
if (filePath.endsWith(".map")) return "application/json";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Error handling helpers
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function createResponseError(response: Response, action: string) {
|
||||
const errorBody = await safeParseErrorResponseBody(response);
|
||||
|
||||
|
||||
@@ -1,12 +1,60 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import log from "electron-log";
|
||||
import { deploySupabaseFunctions } from "./supabase_management_client";
|
||||
import { deploySupabaseFunction } from "./supabase_management_client";
|
||||
|
||||
const logger = log.scope("supabase_utils");
|
||||
|
||||
export function isServerFunction(filePath: string) {
|
||||
return filePath.startsWith("supabase/functions/");
|
||||
/**
|
||||
* Checks if a file path is a Supabase edge function
|
||||
* (i.e., inside supabase/functions/ but NOT in _shared/)
|
||||
*/
|
||||
export function isServerFunction(filePath: string): boolean {
|
||||
return (
|
||||
filePath.startsWith("supabase/functions/") &&
|
||||
!filePath.startsWith("supabase/functions/_shared/")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a file path is a shared module in supabase/functions/_shared/
|
||||
*/
|
||||
export function isSharedServerModule(filePath: string): boolean {
|
||||
return filePath.startsWith("supabase/functions/_shared/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the function name from a Supabase function file path.
|
||||
* Handles nested paths like "supabase/functions/hello/lib/utils.ts" → "hello"
|
||||
*
|
||||
* @param filePath - A path like "supabase/functions/{functionName}/..."
|
||||
* @returns The function name
|
||||
* @throws Error if the path is not a valid function path
|
||||
*/
|
||||
export function extractFunctionNameFromPath(filePath: string): string {
|
||||
// Normalize path separators to forward slashes
|
||||
const normalized = filePath.replace(/\\/g, "/");
|
||||
|
||||
// Match the pattern: supabase/functions/{functionName}/...
|
||||
// The function name is the segment immediately after "supabase/functions/"
|
||||
const match = normalized.match(/^supabase\/functions\/([^/]+)/);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`Invalid Supabase function path: ${filePath}. Expected format: supabase/functions/{functionName}/...`,
|
||||
);
|
||||
}
|
||||
|
||||
const functionName = match[1];
|
||||
|
||||
// Exclude _shared and other special directories
|
||||
if (functionName.startsWith("_")) {
|
||||
throw new Error(
|
||||
`Invalid Supabase function path: ${filePath}. Function names starting with "_" are reserved for special directories.`,
|
||||
);
|
||||
}
|
||||
|
||||
return functionName;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,7 +85,10 @@ export async function deployAllSupabaseFunctions({
|
||||
try {
|
||||
// Read all directories in supabase/functions
|
||||
const entries = await fs.readdir(functionsDir, { withFileTypes: true });
|
||||
const functionDirs = entries.filter((entry) => entry.isDirectory());
|
||||
// Filter out _shared and other non-function directories
|
||||
const functionDirs = entries.filter(
|
||||
(entry) => entry.isDirectory() && !entry.name.startsWith("_"),
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`Found ${functionDirs.length} functions to deploy in ${functionsDir}`,
|
||||
@@ -46,7 +97,8 @@ export async function deployAllSupabaseFunctions({
|
||||
// Deploy each function
|
||||
for (const functionDir of functionDirs) {
|
||||
const functionName = functionDir.name;
|
||||
const indexPath = path.join(functionsDir, functionName, "index.ts");
|
||||
const functionPath = path.join(functionsDir, functionName);
|
||||
const indexPath = path.join(functionPath, "index.ts");
|
||||
|
||||
// Check if index.ts exists
|
||||
try {
|
||||
@@ -59,13 +111,12 @@ export async function deployAllSupabaseFunctions({
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(indexPath, "utf-8");
|
||||
logger.info(`Deploying function: ${functionName}`);
|
||||
|
||||
await deploySupabaseFunctions({
|
||||
await deploySupabaseFunction({
|
||||
supabaseProjectId,
|
||||
functionName,
|
||||
content,
|
||||
appPath,
|
||||
});
|
||||
|
||||
logger.info(`Successfully deployed function: ${functionName}`);
|
||||
|
||||
Reference in New Issue
Block a user