support server only manifest entries for hashed assets

This commit is contained in:
Jonathan Gamble
2025-11-09 20:40:36 -06:00
parent 281a900ba8
commit e4ada4b32d
4 changed files with 31 additions and 23 deletions
+15 -13
View File
@@ -10,27 +10,29 @@ import { isEquivalent } from './algo.ts';
export async function hash(): Promise<void> {
if (!env.begin('hash')) return;
const hashed: Manifest = {};
const pathOnly: { glob: string[] } = { glob: [] };
const hashRuns: { glob: string | string[]; update?: string; pkg?: Package }[] = [];
const withClient: { globs: string[] } = { globs: [] };
const serverOnly: { globs: string[] } = { globs: [] };
const hashRuns: { globs: string[]; omit: boolean; catalog?: string; pkg?: Package }[] = [];
for (const [pkg, { glob, update, ...rest }] of env.tasks('hash')) {
update ? hashRuns.push({ glob, update, pkg, ...rest }) : pathOnly.glob.push(glob);
hashLog(glob, '', pkg?.name);
for (const [pkg, { path, catalog, omit }] of env.tasks('hash')) {
if (catalog) hashRuns.push({ globs: [path], catalog, pkg, omit: omit ?? true });
else if (omit) serverOnly.globs.push(path);
else withClient.globs.push(path);
hashLog(path, '', pkg?.name);
}
if (pathOnly.glob.length) hashRuns.push(pathOnly);
if (withClient.globs.length) hashRuns.push({ globs: withClient.globs, omit: false });
if (serverOnly.globs.length) hashRuns.push({ globs: serverOnly.globs, omit: true });
await fs.promises.mkdir(env.hashOutDir).catch(() => {});
const symlinkHashes = await symlinkTargetHashes();
await Promise.all(
hashRuns.map(({ glob, update, pkg }) =>
hashRuns.map(({ globs, catalog, omit, pkg }) =>
makeTask({
pkg,
ctx: 'hash',
debounce: 300,
root: env.rootDir,
includes: Array<string>()
.concat(glob)
.map(path => ({ cwd: env.rootDir, path })),
includes: globs.map(path => ({ cwd: env.rootDir, path })),
execute: async (files, fullList) => {
const shouldLog = !isEquivalent(files, fullList);
await Promise.all(
@@ -40,16 +42,16 @@ export async function hash(): Promise<void> {
symlinkHashes[name] && !(await isLinkStale(hashedBasename(name, symlinkHashes[name])))
? symlinkHashes[name]
: await hashAndLink(name);
hashed[name] = { hash };
hashed[name] = omit ? { hash, omit } : { hash };
if (shouldLog) hashLog(src, hashedBasename(name, hash), pkg?.name);
}),
);
if (update && pkg?.root) {
if (catalog && pkg?.root) {
const replacements: Record<string, string> = {};
for (const src of fullList.map(f => relative(env.outDir, f))) {
replacements[src] = `hashed/${hashedBasename(src, hashed[src].hash!)}`;
}
const { name, hash } = await replaceAllWithHashUrls(update, replacements);
const { name, hash } = await replaceAllWithHashUrls(catalog, replacements);
hashed[name] = { hash };
if (shouldLog) hashLog(name, hashedBasename(name, hash), pkg.name);
}
+5 -3
View File
@@ -16,7 +16,7 @@ const manifest = {
};
let writeTimer: NodeJS.Timeout;
type SplitAsset = { hash?: string; path?: string; imports?: string[]; inline?: string };
type SplitAsset = { hash?: string; path?: string; imports?: string[]; inline?: string; omit?: boolean };
export type Manifest = { [key: string]: SplitAsset };
export type ManifestUpdate = Partial<Omit<typeof manifest, 'dirty'>>;
@@ -75,8 +75,10 @@ async function writeManifest() {
.map(pairLine)
.join(',');
const cssLines = Object.entries(manifest.css).map(pairLine).join(',');
const hashedLines = Object.entries(manifest.hashed).map(pairLine).join(',');
const hashedLines = Object.entries(manifest.hashed)
.filter(([, { omit }]) => !omit)
.map(([name, { hash }]) => pairLine([name, { hash }]))
.join(',');
clientJs.push(`s.manifest={\ncss:{${cssLines}},\njs:{${jsLines}},\nhashed:{${hashedLines}}\n};`);
const hashable = clientJs.join('\n');
+4 -3
View File
@@ -18,8 +18,9 @@ interface Bundle {
}
interface Hash {
glob: string; // glob for assets
update?: string; // file to update with hashed filenames
path: string; // glob for assets
catalog?: string; // file to update with hashed filenames
omit?: boolean; // omit from client manifest, default false
}
interface Sync {
@@ -123,7 +124,7 @@ async function parsePackage(root: string): Promise<Package> {
if ('hash' in build)
pkgInfo.hash = []
.concat(build.hash)
.map(g => (typeof g === 'string' ? { glob: normalize(g) } : normalizeObject(g))) as Hash[];
.map(g => (typeof g === 'string' ? { path: normalize(g) } : normalizeObject(g))) as Hash[];
if ('sync' in build)
pkgInfo.sync = Object.entries<string>(build.sync).map(x => ({
+7 -4
View File
@@ -167,11 +167,14 @@ Hash entries identify files for which a symlink named with their content hash wi
```
Entries may also take object form:
```json
"hash": { "glob": "<pattern>", "update": "<package-relative-path>" }
"hash": { "path": "<pattern>", "omit": true, "catalog": "<path-to-catalog>" }
```
When the object form is processed, symlinks for globbed files are created in /public/hashed same as before. Then the "update" file is processed and all occurrence of those globbed filenames are replaced with their hashed symlink URLs. The modified "update" file contents are also content-hashed and written to /public/hashed. This is useful when an asset references other files by name and those references must be updated to reflect the hashed URLs. Any asset mapping within a static json or text file can be kept current in this way.
When the object form is processed, symlinks for files globbed by the "path" pattern are created in /public/hashed same as before.
* "hash" sources must begin with `/public` to resolve correctly on production deployments.
* "update" files may not begin with `/` and are always package relative.
Setting the optional "omit" field to true will omit all "path" globbed items from the client manifest. They will stil appear in the server manifest.
The optional "catalog" field may identifies a mapping file to be transformed. All occurrences of filenames globbed by the "path" pattern within the catalog file are replaced with their hashed symlink URLs. The modified catalog file contents are also content-hashed and written to /public/hashed. This is useful when an asset references other files by name and those references must be updated to reflect the hashed URLs. Any asset mapping within a static json or text file can be kept current in this way.
* hash paths must begin with `/public` to resolve correctly on production deployments.
The node sources for ui/build are in the [/ui/.build](./.build) folder.