Unverified 提交 91cf1e97 authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

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. -->
上级 a6d6a4cd
差异被折叠。
...@@ -35,7 +35,7 @@ import killPort from "kill-port"; ...@@ -35,7 +35,7 @@ import killPort from "kill-port";
import util from "util"; import util from "util";
import log from "electron-log"; import log from "electron-log";
import { import {
deploySupabaseFunctions, deploySupabaseFunction,
getSupabaseProjectName, getSupabaseProjectName,
} from "../../supabase_admin/supabase_management_client"; } from "../../supabase_admin/supabase_management_client";
import { createLoggedHandler } from "./safe_handle"; import { createLoggedHandler } from "./safe_handle";
...@@ -52,7 +52,12 @@ import { ...@@ -52,7 +52,12 @@ import {
} from "../utils/git_utils"; } from "../utils/git_utils";
import { safeSend } from "../utils/safe_sender"; import { safeSend } from "../utils/safe_sender";
import { normalizePath } from "../../../shared/normalizePath"; import { normalizePath } from "../../../shared/normalizePath";
import { isServerFunction } from "@/supabase_admin/supabase_utils"; import {
isServerFunction,
isSharedServerModule,
deployAllSupabaseFunctions,
extractFunctionNameFromPath,
} from "@/supabase_admin/supabase_utils";
import { getVercelTeamSlug } from "../utils/vercel_utils"; import { getVercelTeamSlug } from "../utils/vercel_utils";
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils"; import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
import { AppSearchResult } from "@/lib/schemas"; import { AppSearchResult } from "@/lib/schemas";
...@@ -997,6 +1002,8 @@ export function registerAppHandlers() { ...@@ -997,6 +1002,8 @@ export function registerAppHandlers() {
content, content,
}: { appId: number; filePath: string; content: string }, }: { appId: number; filePath: string; content: string },
): Promise<EditAppFileReturnType> => { ): Promise<EditAppFileReturnType> => {
// It should already be normalized, but just in case.
filePath = normalizePath(filePath);
const app = await db.query.apps.findFirst({ const app = await db.query.apps.findFirst({
where: eq(apps.id, appId), where: eq(apps.id, appId),
}); });
...@@ -1051,18 +1058,49 @@ export function registerAppHandlers() { ...@@ -1051,18 +1058,49 @@ export function registerAppHandlers() {
throw new Error(`Failed to write file: ${error.message}`); throw new Error(`Failed to write file: ${error.message}`);
} }
if (isServerFunction(filePath) && app.supabaseProjectId) { if (app.supabaseProjectId) {
try { // Check if shared module was modified - redeploy all functions
await deploySupabaseFunctions({ if (isSharedServerModule(filePath)) {
supabaseProjectId: app.supabaseProjectId, try {
functionName: path.basename(path.dirname(filePath)), logger.info(
content: content, `Shared module ${filePath} modified, redeploying all Supabase functions`,
}); );
} catch (error) { const deployErrors = await deployAllSupabaseFunctions({
logger.error(`Error deploying Supabase function ${filePath}:`, error); appPath,
return { supabaseProjectId: app.supabaseProjectId,
warning: `File saved, but failed to deploy Supabase function: ${filePath}: ${error}`, });
}; if (deployErrors.length > 0) {
return {
warning: `File saved, but some Supabase functions failed to deploy: ${deployErrors.join(", ")}`,
};
}
} catch (error) {
logger.error(
`Error redeploying Supabase functions after shared module change:`,
error,
);
return {
warning: `File saved, but failed to redeploy Supabase functions: ${error}`,
};
}
} else if (isServerFunction(filePath)) {
// Regular function file - deploy just this function
try {
const functionName = extractFunctionNameFromPath(filePath);
await deploySupabaseFunction({
supabaseProjectId: app.supabaseProjectId,
functionName,
appPath,
});
} catch (error) {
logger.error(
`Error deploying Supabase function ${filePath}:`,
error,
);
return {
warning: `File saved, but failed to deploy Supabase function: ${filePath}: ${error}`,
};
}
} }
} }
return {}; return {};
......
...@@ -10,10 +10,15 @@ import log from "electron-log"; ...@@ -10,10 +10,15 @@ import log from "electron-log";
import { executeAddDependency } from "./executeAddDependency"; import { executeAddDependency } from "./executeAddDependency";
import { import {
deleteSupabaseFunction, deleteSupabaseFunction,
deploySupabaseFunctions, deploySupabaseFunction,
executeSupabaseSql, executeSupabaseSql,
} from "../../supabase_admin/supabase_management_client"; } from "../../supabase_admin/supabase_management_client";
import { isServerFunction } from "../../supabase_admin/supabase_utils"; import {
isServerFunction,
isSharedServerModule,
deployAllSupabaseFunctions,
extractFunctionNameFromPath,
} from "../../supabase_admin/supabase_utils";
import { UserSettings } from "../../lib/schemas"; import { UserSettings } from "../../lib/schemas";
import { import {
gitCommit, gitCommit,
...@@ -45,18 +50,6 @@ interface Output { ...@@ -45,18 +50,6 @@ interface Output {
error: unknown; error: unknown;
} }
function getFunctionNameFromPath(input: string): string {
return path.basename(path.extname(input) ? path.dirname(input) : input);
}
async function readFileFromFunctionPath(input: string): Promise<string> {
// Sometimes, the path given is a directory, sometimes it's the file itself.
if (path.extname(input) === "") {
return readFile(path.join(input, "index.ts"), "utf8");
}
return readFile(input, "utf8");
}
export async function dryRunSearchReplace({ export async function dryRunSearchReplace({
fullResponse, fullResponse,
appPath, appPath,
...@@ -153,6 +146,8 @@ export async function processFullResponseActions( ...@@ -153,6 +146,8 @@ export async function processFullResponseActions(
const renamedFiles: string[] = []; const renamedFiles: string[] = [];
const deletedFiles: string[] = []; const deletedFiles: string[] = [];
let hasChanges = false; let hasChanges = false;
// Track if any shared modules were modified
let sharedModulesChanged = false;
const warnings: Output[] = []; const warnings: Output[] = [];
const errors: Output[] = []; const errors: Output[] = [];
...@@ -258,6 +253,11 @@ export async function processFullResponseActions( ...@@ -258,6 +253,11 @@ export async function processFullResponseActions(
for (const filePath of dyadDeletePaths) { for (const filePath of dyadDeletePaths) {
const fullFilePath = safeJoin(appPath, filePath); const fullFilePath = safeJoin(appPath, filePath);
// Track if this is a shared module
if (isSharedServerModule(filePath)) {
sharedModulesChanged = true;
}
// Delete the file if it exists // Delete the file if it exists
if (fs.existsSync(fullFilePath)) { if (fs.existsSync(fullFilePath)) {
if (fs.lstatSync(fullFilePath).isDirectory()) { if (fs.lstatSync(fullFilePath).isDirectory()) {
...@@ -278,11 +278,12 @@ export async function processFullResponseActions( ...@@ -278,11 +278,12 @@ export async function processFullResponseActions(
} else { } else {
logger.warn(`File to delete does not exist: ${fullFilePath}`); logger.warn(`File to delete does not exist: ${fullFilePath}`);
} }
// Only delete individual functions, not shared modules
if (isServerFunction(filePath)) { if (isServerFunction(filePath)) {
try { try {
await deleteSupabaseFunction({ await deleteSupabaseFunction({
supabaseProjectId: chatWithApp.app.supabaseProjectId!, supabaseProjectId: chatWithApp.app.supabaseProjectId!,
functionName: getFunctionNameFromPath(filePath), functionName: extractFunctionNameFromPath(filePath),
}); });
} catch (error) { } catch (error) {
errors.push({ errors.push({
...@@ -298,6 +299,11 @@ export async function processFullResponseActions( ...@@ -298,6 +299,11 @@ export async function processFullResponseActions(
const fromPath = safeJoin(appPath, tag.from); const fromPath = safeJoin(appPath, tag.from);
const toPath = safeJoin(appPath, tag.to); const toPath = safeJoin(appPath, tag.to);
// Track if this involves shared modules
if (isSharedServerModule(tag.from) || isSharedServerModule(tag.to)) {
sharedModulesChanged = true;
}
// Ensure target directory exists // Ensure target directory exists
const dirPath = path.dirname(toPath); const dirPath = path.dirname(toPath);
fs.mkdirSync(dirPath, { recursive: true }); fs.mkdirSync(dirPath, { recursive: true });
...@@ -319,11 +325,12 @@ export async function processFullResponseActions( ...@@ -319,11 +325,12 @@ export async function processFullResponseActions(
} else { } else {
logger.warn(`Source file for rename does not exist: ${fromPath}`); logger.warn(`Source file for rename does not exist: ${fromPath}`);
} }
// Only handle individual functions, not shared modules
if (isServerFunction(tag.from)) { if (isServerFunction(tag.from)) {
try { try {
await deleteSupabaseFunction({ await deleteSupabaseFunction({
supabaseProjectId: chatWithApp.app.supabaseProjectId!, supabaseProjectId: chatWithApp.app.supabaseProjectId!,
functionName: getFunctionNameFromPath(tag.from), functionName: extractFunctionNameFromPath(tag.from),
}); });
} catch (error) { } catch (error) {
warnings.push({ warnings.push({
...@@ -332,12 +339,13 @@ export async function processFullResponseActions( ...@@ -332,12 +339,13 @@ export async function processFullResponseActions(
}); });
} }
} }
if (isServerFunction(tag.to)) { // Deploy renamed function (skip if shared modules changed - will be handled later)
if (isServerFunction(tag.to) && !sharedModulesChanged) {
try { try {
await deploySupabaseFunctions({ await deploySupabaseFunction({
supabaseProjectId: chatWithApp.app.supabaseProjectId!, supabaseProjectId: chatWithApp.app.supabaseProjectId!,
functionName: getFunctionNameFromPath(tag.to), functionName: extractFunctionNameFromPath(tag.to),
content: await readFileFromFunctionPath(toPath), appPath,
}); });
} catch (error) { } catch (error) {
errors.push({ errors.push({
...@@ -353,6 +361,12 @@ export async function processFullResponseActions( ...@@ -353,6 +361,12 @@ export async function processFullResponseActions(
for (const tag of dyadSearchReplaceTags) { for (const tag of dyadSearchReplaceTags) {
const filePath = tag.path; const filePath = tag.path;
const fullFilePath = safeJoin(appPath, filePath); const fullFilePath = safeJoin(appPath, filePath);
// Track if this is a shared module
if (isSharedServerModule(filePath)) {
sharedModulesChanged = true;
}
try { try {
if (!fs.existsSync(fullFilePath)) { if (!fs.existsSync(fullFilePath)) {
// Do not show warning to user because we already attempt to do a <dyad-write> tag to fix it. // Do not show warning to user because we already attempt to do a <dyad-write> tag to fix it.
...@@ -372,13 +386,13 @@ export async function processFullResponseActions( ...@@ -372,13 +386,13 @@ export async function processFullResponseActions(
fs.writeFileSync(fullFilePath, result.content); fs.writeFileSync(fullFilePath, result.content);
writtenFiles.push(filePath); writtenFiles.push(filePath);
// If server function, redeploy // If server function (not shared), redeploy (skip if shared modules changed)
if (isServerFunction(filePath)) { if (isServerFunction(filePath) && !sharedModulesChanged) {
try { try {
await deploySupabaseFunctions({ await deploySupabaseFunction({
supabaseProjectId: chatWithApp.app.supabaseProjectId!, supabaseProjectId: chatWithApp.app.supabaseProjectId!,
functionName: path.basename(path.dirname(filePath)), functionName: extractFunctionNameFromPath(filePath),
content: result.content, appPath,
}); });
} catch (error) { } catch (error) {
errors.push({ errors.push({
...@@ -401,6 +415,11 @@ export async function processFullResponseActions( ...@@ -401,6 +415,11 @@ export async function processFullResponseActions(
let content: string | Buffer = tag.content; let content: string | Buffer = tag.content;
const fullFilePath = safeJoin(appPath, filePath); const fullFilePath = safeJoin(appPath, filePath);
// Track if this is a shared module
if (isSharedServerModule(filePath)) {
sharedModulesChanged = true;
}
// Check if content (stripped of whitespace) exactly matches a file ID and replace with actual file content // Check if content (stripped of whitespace) exactly matches a file ID and replace with actual file content
if (fileUploadsMap) { if (fileUploadsMap) {
const trimmedContent = tag.content.trim(); const trimmedContent = tag.content.trim();
...@@ -433,12 +452,17 @@ export async function processFullResponseActions( ...@@ -433,12 +452,17 @@ export async function processFullResponseActions(
fs.writeFileSync(fullFilePath, content); fs.writeFileSync(fullFilePath, content);
logger.log(`Successfully wrote file: ${fullFilePath}`); logger.log(`Successfully wrote file: ${fullFilePath}`);
writtenFiles.push(filePath); writtenFiles.push(filePath);
if (isServerFunction(filePath) && typeof content === "string") { // Deploy individual function (skip if shared modules changed - will be handled later)
if (
isServerFunction(filePath) &&
typeof content === "string" &&
!sharedModulesChanged
) {
try { try {
await deploySupabaseFunctions({ await deploySupabaseFunction({
supabaseProjectId: chatWithApp.app.supabaseProjectId!, supabaseProjectId: chatWithApp.app.supabaseProjectId!,
functionName: path.basename(path.dirname(filePath)), functionName: extractFunctionNameFromPath(filePath),
content: content, appPath,
}); });
} catch (error) { } catch (error) {
errors.push({ errors.push({
...@@ -449,6 +473,34 @@ export async function processFullResponseActions( ...@@ -449,6 +473,34 @@ export async function processFullResponseActions(
} }
} }
// If shared modules changed, redeploy all functions
if (sharedModulesChanged && chatWithApp.app.supabaseProjectId) {
try {
logger.info(
"Shared modules changed, redeploying all Supabase functions",
);
const deployErrors = await deployAllSupabaseFunctions({
appPath,
supabaseProjectId: chatWithApp.app.supabaseProjectId,
});
if (deployErrors.length > 0) {
for (const err of deployErrors) {
errors.push({
message:
"Failed to deploy Supabase function after shared module change",
error: err,
});
}
}
} catch (error) {
errors.push({
message:
"Failed to redeploy all Supabase functions after shared module change",
error: error,
});
}
}
// If we have any file changes, commit them all at once // If we have any file changes, commit them all at once
hasChanges = hasChanges =
writtenFiles.length > 0 || writtenFiles.length > 0 ||
......
...@@ -287,6 +287,7 @@ CREATE TRIGGER on_auth_user_created ...@@ -287,6 +287,7 @@ CREATE TRIGGER on_auth_user_created
1. Location: 1. Location:
- Write functions in the supabase/functions folder - Write functions in the supabase/functions folder
- Each function should be in a standalone directory where the main file is index.ts (e.g., supabase/functions/hello/index.ts) - Each function should be in a standalone directory where the main file is index.ts (e.g., supabase/functions/hello/index.ts)
- Reusable utilities belong in the supabase/functions/_shared folder. Import them in your edge functions with relative paths like ../_shared/logger.ts.
- Make sure you use <dyad-write> tags to make changes to edge functions. - Make sure you use <dyad-write> tags to make changes to edge functions.
- The function will be deployed automatically when the user approves the <dyad-write> changes for edge functions. - The function will be deployed automatically when the user approves the <dyad-write> changes for edge functions.
- Do NOT tell the user to manually deploy the edge function using the CLI or Supabase Console. It's unhelpful and not needed. - Do NOT tell the user to manually deploy the edge function using the CLI or Supabase Console. It's unhelpful and not needed.
......
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import log from "electron-log"; import log from "electron-log";
import { deploySupabaseFunctions } from "./supabase_management_client"; import { deploySupabaseFunction } from "./supabase_management_client";
const logger = log.scope("supabase_utils"); 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({ ...@@ -37,7 +85,10 @@ export async function deployAllSupabaseFunctions({
try { try {
// Read all directories in supabase/functions // Read all directories in supabase/functions
const entries = await fs.readdir(functionsDir, { withFileTypes: true }); 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( logger.info(
`Found ${functionDirs.length} functions to deploy in ${functionsDir}`, `Found ${functionDirs.length} functions to deploy in ${functionsDir}`,
...@@ -46,7 +97,8 @@ export async function deployAllSupabaseFunctions({ ...@@ -46,7 +97,8 @@ export async function deployAllSupabaseFunctions({
// Deploy each function // Deploy each function
for (const functionDir of functionDirs) { for (const functionDir of functionDirs) {
const functionName = functionDir.name; 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 // Check if index.ts exists
try { try {
...@@ -59,13 +111,12 @@ export async function deployAllSupabaseFunctions({ ...@@ -59,13 +111,12 @@ export async function deployAllSupabaseFunctions({
} }
try { try {
const content = await fs.readFile(indexPath, "utf-8");
logger.info(`Deploying function: ${functionName}`); logger.info(`Deploying function: ${functionName}`);
await deploySupabaseFunctions({ await deploySupabaseFunction({
supabaseProjectId, supabaseProjectId,
functionName, functionName,
content, appPath,
}); });
logger.info(`Successfully deployed function: ${functionName}`); logger.info(`Successfully deployed function: ${functionName}`);
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论