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
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,16 @@ is live.
`index.html` is served with `Cache-Control: max-age=600`, so a returning visitor can see the
previous version for up to ten minutes. Everything else is content-hashed and updates immediately.

Three details that matter for Pages:

Four details that matter for Pages:

- **`_framework/dotnet.js` is cache-busted on purpose.** Pages serves it with `max-age=14400` and
offers no way to say otherwise per path. It is the one file the .NET SDK does not content-hash,
and it holds the hashed names of everything else — so a returning visitor with a four-hour-old
copy asks for assemblies the current deploy no longer has, gets a 404 mid-boot, and sits on the
loading overlay forever. `vite.config.ts` hashes that file into `__FRAMEWORK_ID__` and `wasm.ts`
loads it as `dotnet.js?v=<id>`, which changes exactly when something it loads does. This bites
only on deploys that change the .NET side, which is why it stayed hidden for several web-only
deploys before it did not.
- The Vite build uses a **relative base**, so one artifact works both at the root of the custom
domain and under the `/protobuf-net.dev/` subpath of the default `*.github.io` URL. Anything that
resolves the .NET runtime at load time must go through `import.meta.env.BASE_URL`; a leading
Expand Down
16 changes: 16 additions & 0 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"test:engine": "dotnet test ../src/ProtoGen.Wasm.Tests/ProtoGen.Wasm.Tests.csproj --nologo"
},
"devDependencies": {
"@types/node": "^26.3.0",
"typescript": "^5.6.3",
"vite": "^6.0.7",
"vitest": "^4.1.10"
Expand Down
7 changes: 7 additions & 0 deletions web/scripts/build-wasm.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ const publishFramework = resolve(
);
const target = resolve(webRoot, 'public/_framework');

// dotnet publish copies into its output without clearing it first, and the runtime's filenames are
// content-hashed, so every rebuild that changes the engine leaves the previous build's files
// alongside the current ones - which then ship. Harmless bytes rather than a fault, since the boot
// manifest names exactly one of them, but it grows without limit and makes the output a poor guide
// to what is actually being loaded.
await rm(publishFramework, { recursive: true, force: true });

console.log('> dotnet publish ProtoGen.Wasm -c Release');
const result = spawnSync('dotnet', ['publish', project, '-c', 'Release', '--nologo'], {
stdio: 'inherit',
Expand Down
42 changes: 42 additions & 0 deletions web/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,45 @@ import { loadInterop, engineVersion } from './wasm';

type ViewName = 'schema' | 'decode';

/** How long a boot can go quiet before the overlay says something about it. */
const SLOW_BOOT_MS = 10_000;

/**
* Adds a line under the spinner once a boot has gone on long enough to be worth explaining.
*
* Additive on purpose, and not a diagnosis. From in here a slow connection and a loader waiting on
* a file that will never arrive look the same: the runtime does not always reject when an asset is
* missing, it can simply never finish, and a spinner has no way to tell those apart either. The
* advice happens to be the same for both, so it does not need to know which one it is looking at —
* and if the answer is just a slow connection, nothing has been taken away from the page.
*/
function hintIfSlow(overlay: HTMLElement | null): () => void {
const timer = setTimeout(() => {
const inner = overlay?.querySelector('.boot-inner');
if (!inner) return;

const hint = document.createElement('p');
hint.className = 'boot-hint';
hint.append(
'Still going. The engine is around 1.7 MB and compiles on the first visit, so this can take ' +
'a moment. If it never finishes, this browser may be holding an out-of-date copy — ',
);

// a plain reload is free to serve the same cached files again; a URL the cache has never seen
// is not, and a fresh index.html names the current bundle and runtime
const reload = document.createElement('a');
const url = new URL(location.href);
url.searchParams.set('reload', Date.now().toString(36));
reload.href = url.href;
reload.textContent = 'fetch the current one';
hint.append(reload, '.');

inner.append(hint);
}, SLOW_BOOT_MS);

return () => clearTimeout(timer);
}

function currentView(): ViewName {
return location.hash.replace('#', '') === 'decode' ? 'decode' : 'schema';
}
Expand All @@ -26,6 +65,7 @@ async function main(): Promise<void> {
window.addEventListener('hashchange', () => showView(currentView()));
showView(currentView());

const stopHint = hintIfSlow(overlay);
try {
// one boot, shared by both views; everything after this is synchronous C# calls
await loadInterop();
Expand All @@ -38,6 +78,8 @@ async function main(): Promise<void> {
overlay.append(message);
}
return;
} finally {
stopHint();
}

initSchemaView();
Expand Down
7 changes: 7 additions & 0 deletions web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,13 @@ details[open] > summary .twisty:not(.empty) {
text-align: center;
}

/* added under the spinner when a boot drags on; the spinner stays, because it still might arrive */
.boot-hint {
margin: 0;
font-size: 13px;
line-height: 1.5;
}

.spinner {
width: 28px;
height: 28px;
Expand Down
16 changes: 14 additions & 2 deletions web/src/wasm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,29 @@ interface DotnetHost {

let interopPromise: Promise<Interop> | undefined;

/** Injected by vite.config.ts: a hash of dotnet.js, which changes when anything it loads does. */
declare const __FRAMEWORK_ID__: string;

/**
* Boots the .NET runtime once and caches the result. `_framework` is emitted by the .NET SDK and
* copied verbatim into public/, so it is loaded at runtime rather than bundled - the boot process
* resolves its own content-hashed filenames and must not be rewritten by Vite.
*/
export function loadInterop(): Promise<Interop> {
interopPromise ??= (async () => {
// resolved against the document, not this module: the bundle lives under assets/, whereas
// Resolved against the document, not this module: the bundle lives under assets/, whereas
// _framework sits beside index.html. BASE_URL keeps this correct whether the site is served
// from a domain root or a subpath.
const runtimeUrl = new URL(`${import.meta.env.BASE_URL}_framework/dotnet.js`, document.baseURI).href;
//
// The query is a cache key, not a parameter. dotnet.js is the one file here the SDK does not
// content-hash, and it holds the hashed names of everything else, so a visitor holding a
// cached copy from a previous deploy would ask for assemblies that no longer exist and never
// get past the loading overlay. Everything dotnet.js goes on to fetch resolves relative to
// itself, without the query, and is hashed already.
const runtimeUrl = new URL(
`${import.meta.env.BASE_URL}_framework/dotnet.js?v=${__FRAMEWORK_ID__}`,
document.baseURI,
).href;
const { dotnet } = (await import(/* @vite-ignore */ runtimeUrl)) as { dotnet: DotnetHost };

const { getAssemblyExports, getConfig } = await dotnet.create();
Expand Down
2 changes: 1 addition & 1 deletion web/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"types": ["vite/client", "node"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
Expand Down
29 changes: 29 additions & 0 deletions web/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,39 @@
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vite';

/**
* A build id for the .NET runtime, used to cache-bust `_framework/dotnet.js`.
*
* Every other file the runtime loads is content-hashed by the SDK, but `dotnet.js` is not — and it
* is the file that *names* all the hashed ones. GitHub Pages serves it with `max-age=14400` and
* offers no way to say otherwise per path, so a returning visitor can hold a four-hour-old
* `dotnet.js` that asks for assemblies this deploy no longer has. That is a 404 during boot and a
* site that never finishes loading, for exactly the people who have been here before.
*
* Hashing `dotnet.js` itself is the precise invalidation key: the hashed names are embedded in it,
* so it changes when, and only when, something it loads does.
*/
function frameworkId(): string {
try {
const path = fileURLToPath(new URL('./public/_framework/dotnet.js', import.meta.url));
return createHash('sha256').update(readFileSync(path)).digest('hex').slice(0, 8);
} catch {
// nothing published yet: both `npm run dev` and `npm run build` publish first, so this is only
// reached by running vite directly, where a stale cache is not a concern anyway
return 'dev';
}
}

export default defineConfig({
// relative, so one build works both at the root of the custom domain and under the
// /protogen-site/ subpath of the default *.github.io URL. Anything resolving the runtime at
// load time must go through import.meta.env.BASE_URL rather than assuming a leading slash.
base: './',
define: {
__FRAMEWORK_ID__: JSON.stringify(frameworkId()),
},
build: {
target: 'es2022',
outDir: 'dist',
Expand Down
Loading