Skip to content
Merged
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 .changeset/quick-assets-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@blinkk/root': patch
---

feat: add `useAssetUrl()` hook for resolving compiled asset URLs
1 change: 1 addition & 0 deletions packages/root/src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export {Body} from './components/Body.js';
export {Head} from './components/Head.js';
export {Html, HTML_CONTEXT} from './components/Html.js';
export {Script} from './components/Script.js';
export {useAsset, useAssetMap, useAssetUrl} from './hooks/useAssetMap.js';
export {
StringParamsContext,
StringParamsProvider,
Expand Down
120 changes: 120 additions & 0 deletions packages/root/src/core/hooks/useAssetMap.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import {renderToString} from 'preact-render-to-string';
import {expect, test} from 'vitest';
import {renderJsxToString} from '../../jsx/jsx-render.js';
import type {Asset, AssetMap} from '../../render/asset-map/asset-map.js';
import {
ASSET_MAP_CONTEXT,
useAsset,
useAssetMap,
useAssetUrl,
} from './useAssetMap.js';

/**
* Returns a stub asset map backed by a `src` -> `assetUrl` mapping, mimicking
* the lookups performed by `BuildAssetMap` and `DevServerAssetMap`.
*/
function testAssetMap(assetUrls: Record<string, string>): AssetMap {
return {
get: (src: string): Asset | null => {
if (!(src in assetUrls)) {
return null;
}
return {
src: src,
assetUrl: assetUrls[src],
getCssDeps: async () => [],
getJsDeps: async () => [],
getModulePreloadDeps: async () => [],
};
},
};
}

const assetMap = testAssetMap({
'bundles/main.ts': '/assets/main.abcd1234.js',
// Route files are in the manifest for their CSS deps, but have no asset URL.
'routes/index.tsx': '',
});

/**
* The renderer picks between `preact-render-to-string` and `@blinkk/root/jsx`
* depending on the `jsxRenderer` config, so the hook is verified against both.
*/
const renderers: Array<[string, (vnode: any) => string]> = [
['preact-render-to-string', (vnode) => renderToString(vnode)],
['@blinkk/root/jsx', (vnode) => renderJsxToString(vnode, {mode: 'minimal'})],
];

/** Renders a component with the asset map provided by the renderer. */
function render(
vnode: any,
options?: {assetMap?: AssetMap; renderJsx?: (vnode: any) => string}
) {
const renderJsx = options?.renderJsx || renderers[0][1];
if (!options?.assetMap) {
return renderJsx(vnode);
}
return renderJsx(
<ASSET_MAP_CONTEXT.Provider value={options.assetMap}>
{vnode}
</ASSET_MAP_CONTEXT.Provider>
);
}

test.each(renderers)(
'useAssetUrl() returns the compiled asset url (%s)',
(_name, renderJsx) => {
function Page() {
return <div data-main={useAssetUrl('bundles/main.ts')} />;
}
expect(render(<Page />, {assetMap, renderJsx})).toBe(
'<div data-main="/assets/main.abcd1234.js"></div>'
);
}
);

test('useAssetUrl() accepts a leading slash', () => {
function Page() {
return <div data-main={useAssetUrl('/bundles/main.ts')} />;
}
expect(render(<Page />, {assetMap})).toBe(
'<div data-main="/assets/main.abcd1234.js"></div>'
);
});

test('useAssetUrl() throws when the src is not in the asset map', () => {
function Page() {
return <div data-main={useAssetUrl('/bundles/missing.ts')} />;
}
expect(() => render(<Page />, {assetMap})).toThrow(
'asset not found: /bundles/missing.ts'
);
});

test('useAssetUrl() throws when the asset has no serving url', () => {
function Page() {
return <div data-main={useAssetUrl('routes/index.tsx')} />;
}
expect(() => render(<Page />, {assetMap})).toThrow(
'asset not found: routes/index.tsx'
);
});

test('useAsset() returns the asset from the module graph', () => {
let asset: Asset | null = null;
function Page() {
asset = useAsset('/bundles/main.ts');
return <div />;
}
render(<Page />, {assetMap});
expect(asset!.src).toBe('bundles/main.ts');
expect(asset!.assetUrl).toBe('/assets/main.abcd1234.js');
});

test('useAssetMap() throws when rendered outside of the provider', () => {
function Page() {
useAssetMap();
return <div />;
}
expect(() => render(<Page />)).toThrow('ASSET_MAP_CONTEXT not found');
});
47 changes: 47 additions & 0 deletions packages/root/src/core/hooks/useAssetMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {createContext} from 'preact';
import {useContext} from 'preact/hooks';
import type {Asset, AssetMap} from '../../render/asset-map/asset-map.js';

export const ASSET_MAP_CONTEXT = createContext<AssetMap | null>(null);

/**
* Returns the asset map for the current render.
*/
export function useAssetMap(): AssetMap {
const assetMap = useContext(ASSET_MAP_CONTEXT);
if (!assetMap) {
throw new Error('ASSET_MAP_CONTEXT not found');
}
return assetMap;
}

/**
* Returns an asset from the project's module graph. Paths are relative to the
* project root and may optionally start with `/`.
*/
export function useAsset(src: string): Asset {
const normalizedSrc = src.replace(/^\/+/, '');
const asset = useAssetMap().get(normalizedSrc);
if (!asset) {
throw new Error(`asset not found: ${src}`);
}
return asset;
}

/**
* Returns the serving URL for a file compiled from the project's module graph.
*
* Usage:
*
* ```tsx
* const customJs = useAssetUrl('/bundles/foo.ts');
* return <div data-custom-js={customJs} />;
* ```
*/
export function useAssetUrl(src: string): string {
const assetUrl = useAsset(src).assetUrl;
if (!assetUrl) {
throw new Error(`asset not found: ${src}`);
}
return assetUrl;
}
2 changes: 1 addition & 1 deletion packages/root/src/render/asset-map/asset-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,5 @@ export interface AssetMap {
* Returns the asset for a given src file. The `src` value should be a path
* relative to the project root, e.g. "routes/index.tsx".
*/
get: (src: string) => Promise<Asset | null>;
get: (src: string) => Asset | null;
}
14 changes: 11 additions & 3 deletions packages/root/src/render/asset-map/build-asset-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,16 @@ export class BuildAssetMap implements AssetMap {
this.srcToAsset = new Map();
}

async get(src: string): Promise<Asset | null> {
get(src: string): Asset | null {
const asset = this.find(src);
if (asset) {
return asset;
}
console.log(`could not find build asset: ${src}`);
return null;
}

private find(src: string): BuildAsset | undefined {
const asset = this.srcToAsset.get(src);
if (asset) {
return asset;
Expand All @@ -50,8 +59,7 @@ export class BuildAssetMap implements AssetMap {
return asset;
}
}
console.log(`could not find build asset: ${src}`);
return null;
return undefined;
}

private add(asset: BuildAsset) {
Expand Down
70 changes: 70 additions & 0 deletions packages/root/src/render/asset-map/dev-asset-map.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type {ModuleGraph} from 'vite';
import {afterEach, beforeEach, expect, test, vi} from 'vitest';
import {RootConfig} from '../../core/config.js';
import {DevServerAssetMap} from './dev-asset-map.js';

let workspaceDir: string;
let rootDir: string;
let assetMap: DevServerAssetMap;

// A module graph that never resolves a file, which exercises the fallback
// branches that serve files that never made it into vite's module graph.
const emptyModuleGraph = {
getModulesByFile: () => undefined,
} as unknown as ModuleGraph;

beforeEach(() => {
workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'root-dev-assets-'));
// `searchForWorkspaceRoot()` walks up for a `pnpm-workspace.yaml`, so the
// workspace root is deterministic within the temp dir.
fs.writeFileSync(path.join(workspaceDir, 'pnpm-workspace.yaml'), '');
rootDir = path.join(workspaceDir, 'site');
fs.mkdirSync(rootDir);
assetMap = new DevServerAssetMap({rootDir} as RootConfig, emptyModuleGraph);
});

afterEach(() => {
if (workspaceDir) {
fs.rmSync(workspaceDir, {recursive: true, force: true});
}
});

test('serves files within the project from the root dir', () => {
const asset = assetMap.get('bundles/main.ts');
expect(asset!.assetUrl).toBe('/bundles/main.ts');
});

test('serves sibling dirs sharing the root dir prefix from /@fs/', () => {
// `<workspace>/site-legacy` is a sibling of `<workspace>/site`, not a child
// of it, even though its path starts with the root dir's path.
const filePath = path.join(workspaceDir, 'site-legacy/main.ts');
fs.mkdirSync(path.dirname(filePath));
fs.writeFileSync(filePath, 'export default 1;\n');

const asset = assetMap.get('../site-legacy/main.ts');
expect(asset!.assetUrl).toBe(`/@fs/${filePath}`);
});

test('serves workspace files that do not exist on disk', () => {
const filePath = path.join(workspaceDir, 'packages/ui/main.ts');
const asset = assetMap.get('../packages/ui/main.ts');
expect(asset!.assetUrl).toBe(`/@fs/${filePath}`);
});

test('returns null for files outside of the workspace', () => {
// The missing-asset lookup logs via console.log; stub it so the test output
// stays clean, and assert it fired.
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
try {
const src = path.relative(rootDir, path.join(os.tmpdir(), 'outside.ts'));
expect(assetMap.get(src)).toBeNull();
expect(logSpy).toHaveBeenCalledWith(
`could not find asset in asset map: ${src}`
);
} finally {
logSpy.mockRestore();
}
});
13 changes: 9 additions & 4 deletions packages/root/src/render/asset-map/dev-asset-map.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import path from 'node:path';
import {ModuleGraph, ModuleNode, searchForWorkspaceRoot} from 'vite';
import {RootConfig} from '../../core/config.js';
import {directoryContains} from '../../utils/fsutils.js';
import {Asset, AssetMap} from './asset-map.js';

export class DevServerAssetMap implements AssetMap {
Expand All @@ -13,7 +12,7 @@ export class DevServerAssetMap implements AssetMap {
this.moduleGraph = moduleGraph;
}

async get(src: string): Promise<Asset | null> {
get(src: string): Asset | null {
const file = path.resolve(this.rootConfig.rootDir, src);

const viteModules = this.moduleGraph.getModulesByFile(file);
Expand All @@ -27,7 +26,7 @@ export class DevServerAssetMap implements AssetMap {

// On dev, in some cases the module doesn't make it into the module graph
// so return a generic asset.
if (file.startsWith(this.rootConfig.rootDir)) {
if (directoryContains(this.rootConfig.rootDir, file)) {
const assetUrl = file.slice(this.rootConfig.rootDir.length);
return {
src: src,
Expand All @@ -38,7 +37,7 @@ export class DevServerAssetMap implements AssetMap {
};
}
const workspaceRoot = searchForWorkspaceRoot(this.rootConfig.rootDir);
if (await directoryContains(workspaceRoot, file)) {
if (directoryContains(workspaceRoot, file)) {
const assetUrl = `/@fs/${file}`;
return {
src: src,
Expand All @@ -58,6 +57,12 @@ export class DevServerAssetMap implements AssetMap {
}
}

/** Returns true when a path is contained by a directory. */
function directoryContains(dir: string, file: string): boolean {
const relativePath = path.relative(dir, file);
return relativePath !== '..' && !relativePath.startsWith(`..${path.sep}`);
}

export class DevServerAsset implements Asset {
src: string;
moduleId: string;
Expand Down
17 changes: 10 additions & 7 deletions packages/root/src/render/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from 'preact';
import {HtmlContext, HTML_CONTEXT} from '../core/components/Html.js';
import {RootConfig} from '../core/config.js';
import {ASSET_MAP_CONTEXT} from '../core/hooks/useAssetMap.js';
import {getTranslations, I18N_CONTEXT} from '../core/hooks/useI18nContext.js';
import {
RequestContext,
Expand Down Expand Up @@ -189,13 +190,15 @@ export class Renderer {
scriptDeps: [],
};
const vdom = (
<REQUEST_CONTEXT.Provider value={ctx}>
<I18N_CONTEXT.Provider value={{locale, translations}}>
<HTML_CONTEXT.Provider value={htmlContext}>
<Component {...props} />
</HTML_CONTEXT.Provider>
</I18N_CONTEXT.Provider>
</REQUEST_CONTEXT.Provider>
<ASSET_MAP_CONTEXT.Provider value={this.assetMap}>
<REQUEST_CONTEXT.Provider value={ctx}>
<I18N_CONTEXT.Provider value={{locale, translations}}>
<HTML_CONTEXT.Provider value={htmlContext}>
<Component {...props} />
</HTML_CONTEXT.Provider>
</I18N_CONTEXT.Provider>
</REQUEST_CONTEXT.Provider>
</ASSET_MAP_CONTEXT.Provider>
);

// Create a hook to auto-inject nonce values.
Expand Down
Loading