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

@@ -0,0 +1,352 @@
import { describe, it, expect } from "vitest";
import {
isServerFunction,
isSharedServerModule,
extractFunctionNameFromPath,
} from "@/supabase_admin/supabase_utils";
import {
toPosixPath,
stripSupabaseFunctionsPrefix,
buildSignature,
type FileStatEntry,
} from "@/supabase_admin/supabase_management_client";
describe("isServerFunction", () => {
describe("returns true for valid function paths", () => {
it("should return true for function index.ts", () => {
expect(isServerFunction("supabase/functions/hello/index.ts")).toBe(true);
});
it("should return true for nested function files", () => {
expect(isServerFunction("supabase/functions/hello/lib/utils.ts")).toBe(
true,
);
});
it("should return true for function with complex name", () => {
expect(isServerFunction("supabase/functions/send-email/index.ts")).toBe(
true,
);
});
});
describe("returns false for non-function paths", () => {
it("should return false for shared modules", () => {
expect(isServerFunction("supabase/functions/_shared/utils.ts")).toBe(
false,
);
});
it("should return false for regular source files", () => {
expect(isServerFunction("src/components/Button.tsx")).toBe(false);
});
it("should return false for root supabase files", () => {
expect(isServerFunction("supabase/config.toml")).toBe(false);
});
it("should return false for non-supabase paths", () => {
expect(isServerFunction("package.json")).toBe(false);
});
});
});
describe("isSharedServerModule", () => {
describe("returns true for _shared paths", () => {
it("should return true for files in _shared", () => {
expect(isSharedServerModule("supabase/functions/_shared/utils.ts")).toBe(
true,
);
});
it("should return true for nested _shared files", () => {
expect(
isSharedServerModule("supabase/functions/_shared/lib/helpers.ts"),
).toBe(true);
});
it("should return true for _shared directory itself", () => {
expect(isSharedServerModule("supabase/functions/_shared/")).toBe(true);
});
});
describe("returns false for non-_shared paths", () => {
it("should return false for regular functions", () => {
expect(isSharedServerModule("supabase/functions/hello/index.ts")).toBe(
false,
);
});
it("should return false for similar but different paths", () => {
expect(isSharedServerModule("supabase/functions/shared/utils.ts")).toBe(
false,
);
});
it("should return false for _shared in wrong location", () => {
expect(isSharedServerModule("src/_shared/utils.ts")).toBe(false);
});
});
});
describe("extractFunctionNameFromPath", () => {
describe("extracts function name correctly from nested paths", () => {
it("should extract function name from index.ts path", () => {
expect(
extractFunctionNameFromPath("supabase/functions/hello/index.ts"),
).toBe("hello");
});
it("should extract function name from deeply nested path", () => {
expect(
extractFunctionNameFromPath("supabase/functions/hello/lib/utils.ts"),
).toBe("hello");
});
it("should extract function name from very deeply nested path", () => {
expect(
extractFunctionNameFromPath(
"supabase/functions/hello/src/helpers/format.ts",
),
).toBe("hello");
});
it("should extract function name with dashes", () => {
expect(
extractFunctionNameFromPath("supabase/functions/send-email/index.ts"),
).toBe("send-email");
});
it("should extract function name with underscores", () => {
expect(
extractFunctionNameFromPath("supabase/functions/my_function/index.ts"),
).toBe("my_function");
});
});
describe("throws for invalid paths", () => {
it("should throw for _shared paths", () => {
expect(() =>
extractFunctionNameFromPath("supabase/functions/_shared/utils.ts"),
).toThrow(/Function names starting with "_" are reserved/);
});
it("should throw for other _ prefixed directories", () => {
expect(() =>
extractFunctionNameFromPath("supabase/functions/_internal/utils.ts"),
).toThrow(/Function names starting with "_" are reserved/);
});
it("should throw for non-supabase paths", () => {
expect(() =>
extractFunctionNameFromPath("src/components/Button.tsx"),
).toThrow(/Invalid Supabase function path/);
});
it("should throw for supabase root files", () => {
expect(() => extractFunctionNameFromPath("supabase/config.toml")).toThrow(
/Invalid Supabase function path/,
);
});
it("should throw for partial matches", () => {
expect(() => extractFunctionNameFromPath("supabase/functions")).toThrow(
/Invalid Supabase function path/,
);
});
});
describe("handles edge cases", () => {
it("should handle backslashes (Windows paths)", () => {
expect(
extractFunctionNameFromPath(
"supabase\\functions\\hello\\lib\\utils.ts",
),
).toBe("hello");
});
it("should handle mixed slashes", () => {
expect(
extractFunctionNameFromPath("supabase/functions\\hello/lib\\utils.ts"),
).toBe("hello");
});
});
});
describe("toPosixPath", () => {
it("should keep forward slashes unchanged", () => {
expect(toPosixPath("supabase/functions/hello/index.ts")).toBe(
"supabase/functions/hello/index.ts",
);
});
it("should handle empty string", () => {
expect(toPosixPath("")).toBe("");
});
it("should handle single filename", () => {
expect(toPosixPath("index.ts")).toBe("index.ts");
});
// Note: On Unix, path.sep is "/", so backslashes won't be converted
// This test is for documentation - actual behavior depends on platform
it("should handle path with no separators", () => {
expect(toPosixPath("filename")).toBe("filename");
});
});
describe("stripSupabaseFunctionsPrefix", () => {
describe("strips prefix correctly", () => {
it("should strip full prefix from index.ts", () => {
expect(
stripSupabaseFunctionsPrefix(
"supabase/functions/hello/index.ts",
"hello",
),
).toBe("index.ts");
});
it("should strip prefix from nested file", () => {
expect(
stripSupabaseFunctionsPrefix(
"supabase/functions/hello/lib/utils.ts",
"hello",
),
).toBe("lib/utils.ts");
});
it("should handle leading slash", () => {
expect(
stripSupabaseFunctionsPrefix(
"/supabase/functions/hello/index.ts",
"hello",
),
).toBe("index.ts");
});
});
describe("handles edge cases", () => {
it("should return filename when no prefix match", () => {
const result = stripSupabaseFunctionsPrefix("just-a-file.ts", "hello");
expect(result).toBe("just-a-file.ts");
});
it("should handle paths without function name", () => {
const result = stripSupabaseFunctionsPrefix(
"supabase/functions/other/index.ts",
"hello",
);
// Should strip base prefix and return the rest
expect(result).toBe("other/index.ts");
});
it("should handle empty relative path after prefix", () => {
// When the path is exactly the function directory
const result = stripSupabaseFunctionsPrefix(
"supabase/functions/hello",
"hello",
);
expect(result).toBe("hello");
});
});
});
describe("buildSignature", () => {
it("should build signature from single entry", () => {
const entries: FileStatEntry[] = [
{
absolutePath: "/app/file.ts",
relativePath: "file.ts",
mtimeMs: 1000,
size: 100,
},
];
const result = buildSignature(entries);
expect(result).toBe("file.ts:3e8:64");
});
it("should build signature from multiple entries sorted by relativePath", () => {
const entries: FileStatEntry[] = [
{
absolutePath: "/app/b.ts",
relativePath: "b.ts",
mtimeMs: 2000,
size: 200,
},
{
absolutePath: "/app/a.ts",
relativePath: "a.ts",
mtimeMs: 1000,
size: 100,
},
];
const result = buildSignature(entries);
// Should be sorted by relativePath
expect(result).toBe("a.ts:3e8:64|b.ts:7d0:c8");
});
it("should return empty string for empty array", () => {
const result = buildSignature([]);
expect(result).toBe("");
});
it("should produce different signatures for different mtimes", () => {
const entries1: FileStatEntry[] = [
{
absolutePath: "/app/file.ts",
relativePath: "file.ts",
mtimeMs: 1000,
size: 100,
},
];
const entries2: FileStatEntry[] = [
{
absolutePath: "/app/file.ts",
relativePath: "file.ts",
mtimeMs: 2000,
size: 100,
},
];
expect(buildSignature(entries1)).not.toBe(buildSignature(entries2));
});
it("should produce different signatures for different sizes", () => {
const entries1: FileStatEntry[] = [
{
absolutePath: "/app/file.ts",
relativePath: "file.ts",
mtimeMs: 1000,
size: 100,
},
];
const entries2: FileStatEntry[] = [
{
absolutePath: "/app/file.ts",
relativePath: "file.ts",
mtimeMs: 1000,
size: 200,
},
];
expect(buildSignature(entries1)).not.toBe(buildSignature(entries2));
});
it("should include path in signature for cache invalidation", () => {
const entries1: FileStatEntry[] = [
{
absolutePath: "/app/a.ts",
relativePath: "a.ts",
mtimeMs: 1000,
size: 100,
},
];
const entries2: FileStatEntry[] = [
{
absolutePath: "/app/b.ts",
relativePath: "b.ts",
mtimeMs: 1000,
size: 100,
},
];
expect(buildSignature(entries1)).not.toBe(buildSignature(entries2));
});
});

View File

@@ -35,7 +35,7 @@ import killPort from "kill-port";
import util from "util";
import log from "electron-log";
import {
deploySupabaseFunctions,
deploySupabaseFunction,
getSupabaseProjectName,
} from "../../supabase_admin/supabase_management_client";
import { createLoggedHandler } from "./safe_handle";
@@ -52,7 +52,12 @@ import {
} from "../utils/git_utils";
import { safeSend } from "../utils/safe_sender";
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 { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
import { AppSearchResult } from "@/lib/schemas";
@@ -997,6 +1002,8 @@ export function registerAppHandlers() {
content,
}: { appId: number; filePath: string; content: string },
): Promise<EditAppFileReturnType> => {
// It should already be normalized, but just in case.
filePath = normalizePath(filePath);
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
@@ -1051,18 +1058,49 @@ export function registerAppHandlers() {
throw new Error(`Failed to write file: ${error.message}`);
}
if (isServerFunction(filePath) && app.supabaseProjectId) {
try {
await deploySupabaseFunctions({
supabaseProjectId: app.supabaseProjectId,
functionName: path.basename(path.dirname(filePath)),
content: content,
});
} catch (error) {
logger.error(`Error deploying Supabase function ${filePath}:`, error);
return {
warning: `File saved, but failed to deploy Supabase function: ${filePath}: ${error}`,
};
if (app.supabaseProjectId) {
// Check if shared module was modified - redeploy all functions
if (isSharedServerModule(filePath)) {
try {
logger.info(
`Shared module ${filePath} modified, redeploying all Supabase functions`,
);
const deployErrors = await deployAllSupabaseFunctions({
appPath,
supabaseProjectId: app.supabaseProjectId,
});
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 {};

View File

@@ -10,10 +10,15 @@ import log from "electron-log";
import { executeAddDependency } from "./executeAddDependency";
import {
deleteSupabaseFunction,
deploySupabaseFunctions,
deploySupabaseFunction,
executeSupabaseSql,
} 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 {
gitCommit,
@@ -45,18 +50,6 @@ interface Output {
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({
fullResponse,
appPath,
@@ -153,6 +146,8 @@ export async function processFullResponseActions(
const renamedFiles: string[] = [];
const deletedFiles: string[] = [];
let hasChanges = false;
// Track if any shared modules were modified
let sharedModulesChanged = false;
const warnings: Output[] = [];
const errors: Output[] = [];
@@ -258,6 +253,11 @@ export async function processFullResponseActions(
for (const filePath of dyadDeletePaths) {
const fullFilePath = safeJoin(appPath, filePath);
// Track if this is a shared module
if (isSharedServerModule(filePath)) {
sharedModulesChanged = true;
}
// Delete the file if it exists
if (fs.existsSync(fullFilePath)) {
if (fs.lstatSync(fullFilePath).isDirectory()) {
@@ -278,11 +278,12 @@ export async function processFullResponseActions(
} else {
logger.warn(`File to delete does not exist: ${fullFilePath}`);
}
// Only delete individual functions, not shared modules
if (isServerFunction(filePath)) {
try {
await deleteSupabaseFunction({
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
functionName: getFunctionNameFromPath(filePath),
functionName: extractFunctionNameFromPath(filePath),
});
} catch (error) {
errors.push({
@@ -298,6 +299,11 @@ export async function processFullResponseActions(
const fromPath = safeJoin(appPath, tag.from);
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
const dirPath = path.dirname(toPath);
fs.mkdirSync(dirPath, { recursive: true });
@@ -319,11 +325,12 @@ export async function processFullResponseActions(
} else {
logger.warn(`Source file for rename does not exist: ${fromPath}`);
}
// Only handle individual functions, not shared modules
if (isServerFunction(tag.from)) {
try {
await deleteSupabaseFunction({
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
functionName: getFunctionNameFromPath(tag.from),
functionName: extractFunctionNameFromPath(tag.from),
});
} catch (error) {
warnings.push({
@@ -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 {
await deploySupabaseFunctions({
await deploySupabaseFunction({
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
functionName: getFunctionNameFromPath(tag.to),
content: await readFileFromFunctionPath(toPath),
functionName: extractFunctionNameFromPath(tag.to),
appPath,
});
} catch (error) {
errors.push({
@@ -353,6 +361,12 @@ export async function processFullResponseActions(
for (const tag of dyadSearchReplaceTags) {
const filePath = tag.path;
const fullFilePath = safeJoin(appPath, filePath);
// Track if this is a shared module
if (isSharedServerModule(filePath)) {
sharedModulesChanged = true;
}
try {
if (!fs.existsSync(fullFilePath)) {
// 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(
fs.writeFileSync(fullFilePath, result.content);
writtenFiles.push(filePath);
// If server function, redeploy
if (isServerFunction(filePath)) {
// If server function (not shared), redeploy (skip if shared modules changed)
if (isServerFunction(filePath) && !sharedModulesChanged) {
try {
await deploySupabaseFunctions({
await deploySupabaseFunction({
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
functionName: path.basename(path.dirname(filePath)),
content: result.content,
functionName: extractFunctionNameFromPath(filePath),
appPath,
});
} catch (error) {
errors.push({
@@ -401,6 +415,11 @@ export async function processFullResponseActions(
let content: string | Buffer = tag.content;
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
if (fileUploadsMap) {
const trimmedContent = tag.content.trim();
@@ -433,12 +452,17 @@ export async function processFullResponseActions(
fs.writeFileSync(fullFilePath, content);
logger.log(`Successfully wrote file: ${fullFilePath}`);
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 {
await deploySupabaseFunctions({
await deploySupabaseFunction({
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
functionName: path.basename(path.dirname(filePath)),
content: content,
functionName: extractFunctionNameFromPath(filePath),
appPath,
});
} catch (error) {
errors.push({
@@ -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
hasChanges =
writtenFiles.length > 0 ||

View File

@@ -287,6 +287,7 @@ CREATE TRIGGER on_auth_user_created
1. Location:
- 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)
- 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.
- 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.

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