Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
"test:react": "PACKAGE=react playwright test",
"test:svelte": "PACKAGE=svelte playwright test",
"test:vue": "PACKAGE=vue3 playwright test",
"test:core:react": "PACKAGE=react playwright test tests/core",
"test:core:svelte": "PACKAGE=svelte playwright test tests/core",
"test:core:vue": "PACKAGE=vue3 playwright test tests/core",
"test:core": "pnpm run -s test:core:react && pnpm run -s test:core:svelte && pnpm run -s test:core:vue",
"test:all": "pnpm run -s test:react && pnpm run -s test:svelte && pnpm run -s test:vue",
"playground:react": "cd playgrounds/react && composer run dev",
"playground:svelte4": "cd playgrounds/svelte4 && composer run dev",
"playground:svelte5": "cd playgrounds/svelte5 && composer run dev",
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export { shouldIntercept, shouldNavigate } from './navigationEvents'
export { hide as hideProgress, reveal as revealProgress, default as setupProgress } from './progress'
export { resetFormFields } from './resetFormFields'
export * from './types'
export { hrefToUrl, isUrlMethodPair, mergeDataIntoQueryString, urlWithoutHash } from './url'
export { appURL, asset, hrefToUrl, isBrowser, isUrlMethodPair, mergeDataIntoQueryString, urlWithoutHash } from './url'
export { type Router }

export const router = new Router()
13 changes: 13 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,19 @@ export type FormComponentState = {
isDirty: boolean
}

export type PreloadOptions = {
as?: string
crossorigin?: 'anonymous' | 'use-credentials'
type?: string
fetchpriority?: 'high' | 'low' | 'auto'
}

export type AssetOptions = {
secure?: boolean
fallbackUrl?: string
preload?: boolean | PreloadOptions
}

export type FormComponentSlotProps = FormComponentMethods & FormComponentState

export type FormComponentRef = FormComponentSlotProps
Expand Down
146 changes: 145 additions & 1 deletion packages/core/src/url.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import * as qs from 'qs'
import { hasFiles } from './files'
import { isFormData, objectToFormData } from './formData'
import { FormDataConvertible, Method, RequestPayload, UrlMethodPair, VisitOptions } from './types'
import {
AssetOptions,
FormDataConvertible,
Method,
PreloadOptions,
RequestPayload,
UrlMethodPair,
VisitOptions,
} from './types'

export function isBrowser(): boolean {
return typeof window !== 'undefined'
}

export function hrefToUrl(href: string | URL): URL {
return new URL(href.toString(), typeof window === 'undefined' ? undefined : window.location.toString())
Expand Down Expand Up @@ -87,6 +99,138 @@ export const isSameUrlWithoutHash = (url1: URL | Location, url2: URL | Location)
return urlWithoutHash(url1).href === urlWithoutHash(url2).href
}

function toHost(input: string): string | null {
if (!input) return null

try {
const url = input.match(/^https?:\/\//i) ? new URL(input) : new URL(`http://${input}`)
const defaultPorts = new Set(['', '80', '443'])
return defaultPorts.has(url.port) ? url.hostname : url.host
} catch {
const cleaned = input
.replace(/^https?:\/\//i, '')
.replace(/\/.*/, '')
.trim()
return cleaned || null
}
}

function envAppHost(): { host: string; protocol?: string } | null {
const env = typeof process !== 'undefined' ? process.env : undefined
if (!env) return null

const candidate = env.APP_URL || env.VITE_APP_URL

if (!candidate) return null

try {
const useUrl = candidate.match(/^https?:\/\//i) ? candidate : `http://${candidate}`
const url = new URL(useUrl)
const defaultPorts = new Set(['', '80', '443'])
const host = defaultPorts.has(url.port) ? url.hostname : url.host
const protocol = url.protocol.replace(':', '')
return { host, protocol }
} catch {
const host = toHost(candidate)
return host ? { host } : null
}
}

export function appURL(fallbackUrl?: string): string {
if (!isBrowser()) {
const fromFallback = toHost(fallbackUrl || '')
if (fromFallback) return fromFallback

const fromEnv = envAppHost()

if (fromEnv?.host) return fromEnv.host

return ''
}

const { host, port, hostname } = window.location
const defaultPorts = new Set(['', '80', '443'])

return defaultPorts.has(port) ? hostname : host
}

export function asset(path: string, options?: AssetOptions): string
export function asset(path: string, secureOrOptions?: boolean | AssetOptions, fallbackUrl?: string): string
export function asset(path: string, secureOrOptions?: boolean | AssetOptions, fallbackUrl?: string): string {
return _assetInternal(path, secureOrOptions as any, fallbackUrl)
}

function _assetInternal(path: string, secureOrOptions?: boolean | AssetOptions, fallbackUrl?: string): string {
const cleanPath = (path || '').replace(/^\/+/, '')

const { secure, fallback, preload } = (() => {
if (typeof secureOrOptions === 'object' && secureOrOptions !== null) {
return {
secure: secureOrOptions.secure,
fallback: secureOrOptions.fallbackUrl,
preload: secureOrOptions.preload,
}
}
return { secure: secureOrOptions as boolean | undefined, fallback: fallbackUrl, preload: undefined }
})()

const host = appURL(fallback)

let protocol = 'https'
if (secure === true) {
protocol = 'https'
} else if (secure === undefined) {
if (isBrowser()) {
protocol = window.location.protocol.replace(':', '') || 'https'
} else {
const env = envAppHost()
protocol = env?.protocol || 'https'
}
} else {
protocol = isBrowser() ? window.location.protocol.replace(':', '') || 'http' : 'http'
}

const url = host ? `${protocol}://${host}/${cleanPath}` : `/${cleanPath}`

if (preload && isBrowser()) {
const preloadOptions: PreloadOptions = typeof preload === 'object' ? preload : {}
const asHint = preloadOptions.as || guessAsFromPath(cleanPath)
if (asHint) {
injectPreloadLink(url, { ...preloadOptions, as: asHint })
}
}

return url
}

function guessAsFromPath(path: string): string | null {
const lower = path.toLowerCase()
if (lower.endsWith('.js') || lower.endsWith('.mjs') || lower.endsWith('.cjs')) return 'script'
if (lower.endsWith('.css')) return 'style'
if (/(\.woff2?|\.ttf|\.otf|\.eot)$/i.test(lower)) return 'font'
if (/(\.avif|\.webp|\.png|\.jpe?g|\.gif|\.svg)$/i.test(lower)) return 'image'
if (/(\.mp4|\.webm|\.ogv)$/i.test(lower)) return 'video'
if (/(\.mp3|\.ogg|\.wav)$/i.test(lower)) return 'audio'
if (lower.endsWith('.json')) return 'fetch'
return null
}

function injectPreloadLink(href: string, opts: Required<Pick<PreloadOptions, 'as'>> & PreloadOptions) {
try {
const links = Array.from(document.head.querySelectorAll('link[rel="preload"]')) as HTMLLinkElement[]
if (links.some((l) => l.href === href)) return

const link = document.createElement('link')
link.rel = 'preload'
link.as = opts.as
link.href = href
if (opts.crossorigin) link.crossOrigin = opts.crossorigin
if (opts.type) link.type = opts.type
if (opts.fetchpriority) link.setAttribute('fetchpriority', opts.fetchpriority)
document.head.appendChild(link)
} catch {}
}

export function isUrlMethodPair(href: unknown): href is UrlMethodPair {
return href !== null && typeof href === 'object' && href !== undefined && 'url' in href && 'method' in href
}
148 changes: 147 additions & 1 deletion tests/core/url.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import test, { expect } from '@playwright/test'
import { mergeDataIntoQueryString } from '../../packages/core/src/url'
import { appURL, asset, mergeDataIntoQueryString } from '../../packages/core/src/url'

test.describe('url.ts', () => {
test.describe('mergeDataIntoQueryString', () => {
Expand Down Expand Up @@ -257,3 +257,149 @@ test.describe('url.ts', () => {
})
})
})

test.describe('appURL and asset helpers', () => {
test.describe('appURL (SSR)', () => {
test('returns empty when no env and no fallback (SSR)', () => {
const g = globalThis as unknown as { process?: { env?: Record<string, string> } }
const oldAppUrl = g.process?.env?.APP_URL
const oldViteAppUrl = g.process?.env?.VITE_APP_URL
if (g.process?.env) {
delete g.process.env.APP_URL
delete g.process.env.VITE_APP_URL
}
;(globalThis as any).window = undefined

expect(appURL()).toBe('')

if (g.process?.env) {
if (oldAppUrl !== undefined) g.process.env.APP_URL = oldAppUrl
if (oldViteAppUrl !== undefined) g.process.env.VITE_APP_URL = oldViteAppUrl
}
})

test('uses explicit fallback when provided (SSR)', () => {
;(globalThis as any).window = undefined
expect(appURL('example.com')).toBe('example.com')
expect(appURL('http://example.com:8080')).toBe('example.com:8080')
})

test('parses APP_URL with protocol and default/non-default ports (SSR)', () => {
const g: any = globalThis as any
const oldAppUrl = g.process?.env?.APP_URL
;(globalThis as any).window = undefined

if (!g.process) g.process = {}
if (!g.process.env) g.process.env = {}
g.process.env.APP_URL = 'https://example.com'
expect(appURL()).toBe('example.com')

g.process.env.APP_URL = 'http://example.com:8080'
expect(appURL()).toBe('example.com:8080')

g.process.env.APP_URL = 'example.com:443'
expect(appURL()).toBe('example.com')

// Restore
if (oldAppUrl !== undefined) g.process.env.APP_URL = oldAppUrl
else delete g.process.env.APP_URL
})
})

test.describe('asset (SSR)', () => {
test('returns site-relative path when no host is known (SSR)', () => {
const g: any = globalThis as any
const oldAppUrl = g.process?.env?.APP_URL
const oldViteAppUrl = g.process?.env?.VITE_APP_URL
if (g.process?.env) {
delete g.process.env.APP_URL
delete g.process.env.VITE_APP_URL
}
;(globalThis as any).window = undefined

const url = asset('css/app.css')
expect(url).toBe('/css/app.css')

if (g.process?.env) {
if (oldAppUrl !== undefined) g.process.env.APP_URL = oldAppUrl
if (oldViteAppUrl !== undefined) g.process.env.VITE_APP_URL = oldViteAppUrl
}
})

test('uses fallbackUrl with https by default (SSR)', () => {
;(globalThis as any).window = undefined
const url = asset('build/app.js', { fallbackUrl: 'example.com' })
expect(url).toBe('https://example.com/build/app.js')
})

test('respects secure=false (SSR)', () => {
;(globalThis as any).window = undefined
const url = asset('build/app.js', { fallbackUrl: 'example.com', secure: false })
expect(url).toBe('http://example.com/build/app.js')
})

test('uses APP_URL protocol and host (SSR)', () => {
const g: any = globalThis as any
const oldAppUrl = g.process?.env?.APP_URL
if (!g.process) g.process = {}
if (!g.process.env) g.process.env = {}
g.process.env.APP_URL = 'http://foo.test:3000'
;(globalThis as any).window = undefined
const url = asset('img/logo.png')
expect(url).toBe('http://foo.test:3000/img/logo.png')
if (oldAppUrl !== undefined) g.process.env.APP_URL = oldAppUrl
else delete g.process.env.APP_URL
})
})

test.describe('asset (Browser)', () => {
test('builds absolute URL based on window.location (default ports)', () => {
;(globalThis as any).window = {
location: { protocol: 'https:', host: 'example.com', hostname: 'example.com', port: '' },
}
const url = asset('file.json')
expect(url).toBe('https://example.com/file.json')
})

test('builds absolute URL with non-default port', () => {
;(globalThis as any).window = {
location: { protocol: 'https:', host: 'example.com:8080', hostname: 'example.com', port: '8080' },
}
const url = asset('file.json')
expect(url).toBe('https://example.com:8080/file.json')
})

test('injects preload link once and avoids duplicates', () => {
const appended: any[] = []
;(globalThis as any).document = {
head: {
querySelectorAll: () => appended.filter((el) => el.rel === 'preload'),
appendChild: (el: any) => appended.push(el),
},
createElement: (_tag: string) => ({
rel: '',
as: '',
href: '',
type: '',
setAttribute(name: string, value: string) {
;(this as any)[name] = value
},
}),
}
;(globalThis as any).window = {
location: { protocol: 'https:', host: 'example.com', hostname: 'example.com', port: '' },
}

const url1 = asset('styles/app.css', { preload: true })
expect(url1).toBe('https://example.com/styles/app.css')
expect(appended.length).toBe(1)
expect(appended[0].rel).toBe('preload')
expect(appended[0].as).toBe('style')
expect(appended[0].href).toBe(url1)

const url2 = asset('styles/app.css', { preload: true })
expect(url2).toBe(url1)
expect(appended.length).toBe(1)
})
})
})