Files
Joshen Lim 957f84b17c 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
<img width="310" height="176" alt="image"
src="https://github.com/user-attachments/assets/4d724ab0-33bb-4093-a574-52984b2743fd"
/>
- [ ] 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
<img width="319" height="228" alt="image"
src="https://github.com/user-attachments/assets/dd345b1f-7c45-47e9-975c-f2f2e53a0106"
/>
- [ ] 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)
<img width="333" height="245" alt="image"
src="https://github.com/user-attachments/assets/90fc90e3-4a62-493b-9246-ed7e3b662e96"
/>
<img width="290" height="237" alt="image"
src="https://github.com/user-attachments/assets/2f692362-c9d4-4394-bc5a-4f84ab5fb6f1"
/>
- [ ] Deploy a new function via the editor
- [ ] Update an existing function via the editor (Test adding new files
etc)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-09 14:37:32 +08:00

299 lines
9.1 KiB
TypeScript

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<FileData, 'id' | 'content' | 'state'>[]) => {
// try to find a deno.json or import_map.json file
const regex = /^.*?(deno|import_map).json*$/i
return files.find(({ name }) => regex.test(name))?.name
}
export const getFallbackEntrypointPath = (files: Omit<FileData, 'id' | 'content' | 'state'>[]) => {
// when there's no matching entrypoint path is set,
// we use few heuristics to find an entrypoint file
// 1. If the function has only a single TS / JS file, if so set it as entrypoint
const jsFiles = files.filter(
({ name }) =>
name.endsWith('.js') || name.endsWith('.ts') || name.endsWith('.jsx') || name.endsWith('.tsx')
)
if (jsFiles.length === 1) {
return jsFiles[0].name
} else if (jsFiles.length) {
// 2. If function has a `index` or `main` file use it as the entrypoint
const regex = /^.*?(index|main).*$/i
const matchingFile = jsFiles.find(({ name }) => regex.test(name))
// 3. if no valid index / main file found, we set the entrypoint expliclty to first JS file
return matchingFile ? matchingFile.name : jsFiles[0].name
} else {
// no potential entrypoint files found, this will most likely result in an error on deploy
return 'index.ts'
}
}
export const getStaticPatterns = (files: Omit<FileData, 'id' | 'content' | 'state'>[]) => {
return files
.filter(({ name }) => !name.match(/\.(js|ts|jsx|tsx|json|wasm)$/i))
.map(({ name }) => name)
}
function getBasePath(entrypoint: string | undefined, fileNames: string[]): string {
if (!entrypoint) {
return '/'
}
let candidate = fileNames.find((name) => entrypoint.endsWith(name))
if (candidate) {
return dirname(candidate)
} else {
try {
return dirname(new URL(entrypoint).pathname)
} catch (e) {
console.error('Failed to parse entrypoint', entrypoint)
return '/'
}
}
}
export const formatFunctionBodyToFiles = ({
functionBody,
entrypointPath,
}: {
functionBody: EdgeFunctionBodyData
entrypointPath?: string
}) => {
const entrypoint_path = functionBody.metadata?.deno2_entrypoint_path ?? entrypointPath
// Set files from API response when available
if (entrypoint_path) {
const base_path = getBasePath(
entrypoint_path,
functionBody.files.map((file) => file.name)
)
const filesWithRelPath = functionBody.files
// set file paths relative to entrypoint
.map((file: { name: string; content: string }) => {
try {
// if the current file and base path doesn't share a common path,
// return unmodified file
const common_path = commonPath([base_path, file.name])
if (common_path === '' || common_path === '/tmp/') {
return file
}
// prepend "/" to turn relative paths to absolute
file.name = relative('/' + base_path, '/' + file.name)
return file
} catch (e) {
console.error(e)
// return unmodified file
return file
}
})
return filesWithRelPath.map((file: { name: string; content: string }, index: number) => {
return {
id: index + 1,
name: file.name,
content: file.content,
state: 'unchanged',
} as FileData
})
}
return []
}