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:
Will Chen
2025-12-15 18:07:57 -08:00
committed by GitHub
parent a6d6a4cdaf
commit 91cf1e97c3
6 changed files with 854 additions and 66 deletions

View File

@@ -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);

View File

@@ -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}`);