diff --git a/package.json b/package.json index 6bc14ec52..01029be1a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 843665256..30ee35374 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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() diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index be8b8d753..1fd6b1481 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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 diff --git a/packages/core/src/url.ts b/packages/core/src/url.ts index a8c07f7fa..2bb08d9f4 100644 --- a/packages/core/src/url.ts +++ b/packages/core/src/url.ts @@ -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()) @@ -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> & 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 } diff --git a/tests/core/url.test.ts b/tests/core/url.test.ts index e946491d0..fc0fe7a26 100644 --- a/tests/core/url.test.ts +++ b/tests/core/url.test.ts @@ -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', () => { @@ -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 } } + 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) + }) + }) +})