From 957f84b17ca118e8fdf27e78760188bbd0b0544a Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 9 Sep 2026 14:37:32 +0800 Subject: [PATCH] Joshenlim/fe 4348 find an alternative to the jsr stdpath dependency (#50111) ## Context Drops the `@std/path` dependency which is used in `EdgeFunctions.utils` as `npm.jsr.io` was putting up a Cloudflare bot challenge on some connections which blocks `pnpm install`. Instead, opting to directly port the exact required methods as self-contained functions. Also added some unit tests to check that UI behaviour remains status quo. ## To test: Important to test that everything in the edge functions UI remains status quo - [ ] Open an existing edge function with a single root-level file - should load as expected image - [ ] Open (or create) an edge function with nested folders - confirm file paths in the editor are shown correctly - Can create nested folders by using `../` as such image - [ ] Similarly, download the edge function as ZIP to verify that the nested folders are all correctly located - [ ] Open a function with `import_map.json` - confirm still detected as import map through the network tab GET request for the edge function code (Examples here with and without import map) image image - [ ] Deploy a new function via the editor - [ ] Update an existing function via the editor (Test adding new files etc) ## Summary by CodeRabbit - **Bug Fixes** - Improved Edge Functions file path handling across supported application environments. - Nested entrypoints, URL-based entrypoints, root-level files, and unmatched paths are now handled consistently. - Generated files retain their content and receive sequential identifiers reliably. - Improved compatibility when processing and displaying files in different application environments. - **Tests** - Added coverage for entrypoint path formatting, relative paths, fallback behavior, unchanged paths, and identifier assignment. --- .npmrc | 1 - .../EdgeFunctions/EdgeFunctions.utils.test.ts | 72 +++++++ .../EdgeFunctions/EdgeFunctions.utils.ts | 198 +++++++++++++++++- apps/studio/package.json | 1 - pnpm-lock.yaml | 8 - 5 files changed, 267 insertions(+), 13 deletions(-) delete mode 100644 .npmrc create mode 100644 apps/studio/components/interfaces/EdgeFunctions/EdgeFunctions.utils.test.ts diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 41583e36ca8..00000000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -@jsr:registry=https://npm.jsr.io diff --git a/apps/studio/components/interfaces/EdgeFunctions/EdgeFunctions.utils.test.ts b/apps/studio/components/interfaces/EdgeFunctions/EdgeFunctions.utils.test.ts new file mode 100644 index 00000000000..89d299dd0d4 --- /dev/null +++ b/apps/studio/components/interfaces/EdgeFunctions/EdgeFunctions.utils.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' + +import { formatFunctionBodyToFiles } from './EdgeFunctions.utils' + +describe('formatFunctionBodyToFiles', () => { + it('returns no files when there is no entrypoint path', () => { + const files = [{ name: 'index.ts', content: 'a' }] + const result = formatFunctionBodyToFiles({ functionBody: { files, metadata: {} } }) + expect(result).toEqual([]) + }) + + it('rewrites nested file paths relative to a nested entrypoint', () => { + const files = [ + { name: 'functions/hello/index.ts', content: 'a' }, + { name: 'functions/hello/utils/helper.ts', content: 'b' }, + ] + const result = formatFunctionBodyToFiles({ + functionBody: { files, metadata: { deno2_entrypoint_path: 'functions/hello/index.ts' } }, + }) + expect(result.map((f) => f.name)).toEqual(['index.ts', 'utils/helper.ts']) + }) + + it('leaves file paths unmodified when the entrypoint is a bare filename at the root', () => { + const files = [{ name: 'index.ts', content: 'a' }] + const result = formatFunctionBodyToFiles({ + functionBody: { files, metadata: { deno2_entrypoint_path: 'index.ts' } }, + }) + expect(result.map((f) => f.name)).toEqual(['index.ts']) + }) + + it('falls back to parsing a URL entrypoint when no file name matches', () => { + const files = [ + { name: 'functions/hello/index.ts', content: 'a' }, + { name: 'functions/hello/utils/helper.ts', content: 'b' }, + ] + const result = formatFunctionBodyToFiles({ + functionBody: { files, metadata: {} }, + entrypointPath: 'https://edge.supabase.com/deploy/abc123/main.ts', + }) + // the URL's parsed base path ('/deploy/abc123') shares no common prefix with + // the relative file names, so they're left unmodified (per commonPath) + expect(result.map((f) => f.name)).toEqual([ + 'functions/hello/index.ts', + 'functions/hello/utils/helper.ts', + ]) + }) + + it('leaves a file unmodified when it shares no common path with the base path', () => { + const files = [ + { name: 'functions/hello/index.ts', content: 'a' }, + { name: 'unrelated/other.ts', content: 'b' }, + ] + const result = formatFunctionBodyToFiles({ + functionBody: { files, metadata: { deno2_entrypoint_path: 'functions/hello/index.ts' } }, + }) + expect(result.map((f) => f.name)).toEqual(['index.ts', 'unrelated/other.ts']) + }) + + it('assigns sequential ids and preserves content', () => { + const files = [ + { name: 'a.ts', content: 'foo' }, + { name: 'b.ts', content: 'bar' }, + ] + const result = formatFunctionBodyToFiles({ + functionBody: { files, metadata: { deno2_entrypoint_path: 'a.ts' } }, + }) + expect(result).toEqual([ + { id: 1, name: 'a.ts', content: 'foo', state: 'unchanged' }, + { id: 2, name: 'b.ts', content: 'bar', state: 'unchanged' }, + ]) + }) +}) diff --git a/apps/studio/components/interfaces/EdgeFunctions/EdgeFunctions.utils.ts b/apps/studio/components/interfaces/EdgeFunctions/EdgeFunctions.utils.ts index 0eb953d73cf..ddba382e9d8 100644 --- a/apps/studio/components/interfaces/EdgeFunctions/EdgeFunctions.utils.ts +++ b/apps/studio/components/interfaces/EdgeFunctions/EdgeFunctions.utils.ts @@ -1,8 +1,200 @@ -import { common, dirname, relative } from '@std/path/posix' - import { FileData } from '@/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.types' import { EdgeFunctionBodyData } from '@/data/edge-functions/edge-function-body-query' +// This file is bundled for both the browser (Next.js/webpack) and the TanStack Start (Vite) client, +// which handle Node.js builtins differently — Vite stubs `path` out entirely for the browser, so +// `path.posix` is undefined at runtime there even though it type-checks and builds fine under webpack. +// These are self-contained posix path helpers (no `path` import) so behavior is identical everywhere. +// Ported from the POSIX implementations in @std/path (JSR), which this file used to depend on directly. + +function isPosixSeparator(code: number | undefined): boolean { + return code === 47 /* '/' */ +} + +function stripTrailingSeparators(segment: string): string { + if (segment.length <= 1) return segment + let end = segment.length + for (let i = segment.length - 1; i > 0; i--) { + if (isPosixSeparator(segment.charCodeAt(i))) end = i + else break + } + return segment.slice(0, end) +} + +// Resolves "." and ".." segments in a posix path. +function normalizeString(path: string, allowAboveRoot: boolean): string { + let res = '' + let lastSegmentLength = 0 + let lastSlash = -1 + let dots = 0 + let code: number | undefined + + for (let i = 0; i <= path.length; ++i) { + if (i < path.length) code = path.charCodeAt(i) + else if (isPosixSeparator(code)) break + else code = 47 + + if (isPosixSeparator(code)) { + if (lastSlash === i - 1 || dots === 1) { + // noop + } else if (lastSlash !== i - 1 && dots === 2) { + if ( + res.length < 2 || + lastSegmentLength !== 2 || + res.charCodeAt(res.length - 1) !== 46 /* '.' */ || + res.charCodeAt(res.length - 2) !== 46 /* '.' */ + ) { + if (res.length > 2) { + const lastSlashIndex = res.lastIndexOf('/') + if (lastSlashIndex === -1) { + res = '' + lastSegmentLength = 0 + } else { + res = res.slice(0, lastSlashIndex) + lastSegmentLength = res.length - 1 - res.lastIndexOf('/') + } + lastSlash = i + dots = 0 + continue + } else if (res.length === 2 || res.length === 1) { + res = '' + lastSegmentLength = 0 + lastSlash = i + dots = 0 + continue + } + } + if (allowAboveRoot) { + res += res.length > 0 ? '/..' : '..' + lastSegmentLength = 2 + } + } else { + res += res.length > 0 ? '/' + path.slice(lastSlash + 1, i) : path.slice(lastSlash + 1, i) + lastSegmentLength = i - lastSlash - 1 + } + lastSlash = i + dots = 0 + } else if (code === 46 /* '.' */ && dots !== -1) { + ++dots + } else { + dots = -1 + } + } + + return res +} + +// Normalizes an absolute posix path (resolves "." / ".."). Assumes `path` starts with "/". +function resolveAbsolute(path: string): string { + const normalized = normalizeString(path, false) + return normalized.length > 0 ? '/' + normalized : '/' +} + +function dirname(path: string): string { + if (path.length === 0) return '.' + + let end = -1 + let matchedNonSeparator = false + for (let i = path.length - 1; i >= 1; --i) { + if (isPosixSeparator(path.charCodeAt(i))) { + if (matchedNonSeparator) { + end = i + break + } + } else { + matchedNonSeparator = true + } + } + + if (end === -1) return isPosixSeparator(path.charCodeAt(0)) ? '/' : '.' + return stripTrailingSeparators(path.slice(0, end)) +} + +// `from`/`to` must be absolute (leading "/"). Returns the relative path from `from` to `to`. +function relative(from: string, to: string): string { + from = resolveAbsolute(from) + to = resolveAbsolute(to) + if (from === to) return '' + + let fromStart = 1 + const fromEnd = from.length + for (; fromStart < fromEnd; ++fromStart) { + if (!isPosixSeparator(from.charCodeAt(fromStart))) break + } + const fromLen = fromEnd - fromStart + + let toStart = 1 + const toEnd = to.length + for (; toStart < toEnd; ++toStart) { + if (!isPosixSeparator(to.charCodeAt(toStart))) break + } + const toLen = toEnd - toStart + + const length = fromLen < toLen ? fromLen : toLen + let lastCommonSep = -1 + let i = 0 + for (; i <= length; ++i) { + if (i === length) { + if (toLen > length) { + if (isPosixSeparator(to.charCodeAt(toStart + i))) { + return to.slice(toStart + i + 1) + } else if (i === 0) { + return to.slice(toStart + i) + } + } else if (fromLen > length) { + if (isPosixSeparator(from.charCodeAt(fromStart + i))) { + lastCommonSep = i + } else if (i === 0) { + lastCommonSep = 0 + } + } + break + } + const fromCode = from.charCodeAt(fromStart + i) + const toCode = to.charCodeAt(toStart + i) + if (fromCode !== toCode) break + else if (isPosixSeparator(fromCode)) lastCommonSep = i + } + + let out = '' + for (i = fromStart + lastCommonSep + 1; i <= fromEnd; ++i) { + if (i === fromEnd || isPosixSeparator(from.charCodeAt(i))) { + out += out.length === 0 ? '..' : '/..' + } + } + + if (out.length > 0) return out + to.slice(toStart + lastCommonSep) + + let start = toStart + lastCommonSep + if (isPosixSeparator(to.charCodeAt(start))) ++start + return to.slice(start) +} + +// Longest common path prefix across `paths`, comparing "/"-separated segments. +function commonPath(paths: string[]): string { + const [first = '', ...rest] = paths + const parts = first.split('/') + let endOfPrefix = parts.length + let append = '' + + for (const p of rest) { + const compare = p.split('/') + if (compare.length <= endOfPrefix) { + endOfPrefix = compare.length + append = '' + } + for (let i = 0; i < endOfPrefix; i++) { + if (compare[i] !== parts[i]) { + endOfPrefix = i + append = i === 0 ? '' : '/' + break + } + } + } + + return parts.slice(0, endOfPrefix).join('/') + append +} + export const getFallbackImportMapPath = (files: Omit[]) => { // try to find a deno.json or import_map.json file const regex = /^.*?(deno|import_map).json*$/i @@ -77,7 +269,7 @@ export const formatFunctionBodyToFiles = ({ try { // if the current file and base path doesn't share a common path, // return unmodified file - const common_path = common([base_path, file.name]) + const common_path = commonPath([base_path, file.name]) if (common_path === '' || common_path === '/tmp/') { return file } diff --git a/apps/studio/package.json b/apps/studio/package.json index 4603dac8d13..269958acd38 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -66,7 +66,6 @@ "@sentry/react": "^10.27.0", "@sentry/tanstackstart-react": "catalog:", "@smithy/property-provider": "^4.0.4", - "@std/path": "npm:@jsr/std__path@^1.0.8", "@stripe/react-stripe-js": "6.1.0", "@stripe/stripe-js": "9.1.0", "@stripe/sync-engine": "1.0.32", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 619b39d6aa7..12f82101721 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1008,9 +1008,6 @@ importers: '@smithy/property-provider': specifier: ^4.0.4 version: 4.0.4 - '@std/path': - specifier: npm:@jsr/std__path@^1.0.8 - version: '@jsr/std__path@1.0.8' '@stripe/react-stripe-js': specifier: 6.1.0 version: 6.1.0(@stripe/stripe-js@9.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -5175,9 +5172,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@jsr/std__path@1.0.8': - resolution: {integrity: sha512-eNBGlh/8ZVkMxtFH4bwIzlAeKoHYk5in4wrBZhi20zMdOiuX4QozP4+19mIXBT2lzHDjhuVLyECbhFeR304iDg==, tarball: https://npm.jsr.io/~/11/@jsr/std__path/1.0.8.tgz} - '@kwsites/file-exists@1.1.1': resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} @@ -21295,8 +21289,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@jsr/std__path@1.0.8': {} - '@kwsites/file-exists@1.1.1(supports-color@8.1.1)': dependencies: debug: 4.4.3(supports-color@8.1.1)