pac code push reads every file of the build output with readFile(path, "utf-8") before uploading it. Any binary file (.woff2, .woff, .ttf, binary images, etc.) is therefore decoded as UTF-8 text and re-encoded on upload, which destroys its content: every byte ≥ 0x80 that does not form a valid UTF-8 sequence is replaced/expanded. The corrupted files are then served by the app storage proxy with a correct MIME type and HTTP 200, which makes the failure very hard to diagnose: fonts download "successfully" and then fail OTS parsing in the browser.
Combined with the app host CSP (font-src 'self', which also forbids data: fonts in CSS), this currently makes it impossible to ship a web font in a code app by any documented means.
Environment
- Power Platform CLI 2.7.4+g06bb2eb (.NET 10.0.7), installed as a dotnet tool on Windows 11
- Node.js v24.11.1
- Standard Vite + React code app (created per the "create an app from scratch" quickstart)
Steps to reproduce
- Create any Vite code app and add a font file to the build output, e.g. any
.woff2 in dist/assets/, referenced from CSS via @font-face { src: url(./assets/my-font.woff2); }.
pnpm build && pac code push (push succeeds).
- Open the published app, or fetch the font directly from the app storage proxy:
https://<env>.environment.api.powerplatformusercontent.com/powerapps/appruntime/<appId>/t/<tenantId>/storageproxy/<version>/assets/my-font.woff2
Expected
The served file is byte-identical to the local dist/assets/my-font.woff2.
Actual
The served file is corrupted. Measured on a real push (Material Icons woff2/woff):
| File |
Local size |
Served size |
First differing byte |
SHA-256 match |
material-icons-*.woff2 |
128 352 B |
231 689 B (×1.80) |
index 10 |
no |
material-icons-*.woff |
164 912 B |
299 808 B (×1.82) |
index 10 |
no |
The magic bytes (wOF2/wOFF, pure ASCII) survive; corruption starts at the first byte ≥ 0x80. The ~×1.8 growth is the signature of binary data round-tripped through a UTF-8 text decode/encode. In the browser, the font downloads with HTTP 200 and content-type: font/woff2, then fails with:
Failed to decode downloaded font: https://…/assets/material-icons-….woff2
OTS parsing error: Size of decompressed WOFF 2.0 is less than compressed size
OTS parsing error: incorrect file size in WOFF header
Text assets (.html, .css, .js, .svg) are served byte-identical, since they are valid UTF-8.
Root cause
In the code-apps tooling bundled with the CLI (tools/net10.0/any/Bin.js of microsoft.powerapps.cli.tool 2.7.4), the upload loop reads every file as UTF-8 text (de-minified excerpt):
let D = await vF(V, X), E = V.replace(/^\.\//, ""), S = 0;
for (let o of D) {
let z1 = await X.readFile(o, "utf-8"); // <-- every file, including binaries
S += Buffer.byteLength(z1, "utf-8");
let u1 = o.replace(E, "").replace(/\\/g, "/").replace(/^\//, "");
let c1 = `${Y}/${u1}${Z}`;
await W.put(c1, { headers: { "Content-Ty…" } });
}
readFile(path, "utf-8") is lossy for non-UTF-8 content: invalid sequences are replaced, and re-serialization expands high bytes into multi-byte sequences. The upload should read files as Buffer (no encoding) and send the raw bytes.
Impact
- No web font can be deployed as a file. The host CSP also blocks external fonts (
font-src 'self') and data: fonts in CSS, so icon fonts (e.g. Material Icons used by many design systems, including ENGIE's Fluid) silently degrade to ligature text in production while working in local dev.
- Any other binary asset placed in the build output (raster images, wasm, etc.) is corrupted the same way.
- The failure is silent: push succeeds, files are served with 200 + correct MIME, and the only symptom is an OTS/decoder error in the browser console.
Workaround (for anyone hitting this)
Ship no binary files at all: inline fonts as base64 inside the JS bundle (text survives the upload) and register them at runtime with document.fonts.add(new FontFace(family, arrayBuffer, descriptors)). Constructing a FontFace from an ArrayBuffer performs no fetch, so font-src does not apply. With Vite: import woff2 from "./font.woff2?inline", decode the data URI with atob, and register on startup.
Suggested fix
In the upload loop, read files without an encoding (await X.readFile(o)) and upload the resulting Buffer unchanged; compute sizes with buffer.length. Alternatively, restrict the UTF-8 path to known text MIME types.
pac code pushreads every file of the build output withreadFile(path, "utf-8")before uploading it. Any binary file (.woff2,.woff,.ttf, binary images, etc.) is therefore decoded as UTF-8 text and re-encoded on upload, which destroys its content: every byte ≥0x80that does not form a valid UTF-8 sequence is replaced/expanded. The corrupted files are then served by the app storage proxy with a correct MIME type and HTTP 200, which makes the failure very hard to diagnose: fonts download "successfully" and then fail OTS parsing in the browser.Combined with the app host CSP (
font-src 'self', which also forbidsdata:fonts in CSS), this currently makes it impossible to ship a web font in a code app by any documented means.Environment
Steps to reproduce
.woff2indist/assets/, referenced from CSS via@font-face { src: url(./assets/my-font.woff2); }.pnpm build && pac code push(push succeeds).https://<env>.environment.api.powerplatformusercontent.com/powerapps/appruntime/<appId>/t/<tenantId>/storageproxy/<version>/assets/my-font.woff2Expected
The served file is byte-identical to the local
dist/assets/my-font.woff2.Actual
The served file is corrupted. Measured on a real push (Material Icons woff2/woff):
material-icons-*.woff2material-icons-*.woffThe magic bytes (
wOF2/wOFF, pure ASCII) survive; corruption starts at the first byte ≥0x80. The ~×1.8 growth is the signature of binary data round-tripped through a UTF-8 text decode/encode. In the browser, the font downloads with HTTP 200 andcontent-type: font/woff2, then fails with:Text assets (
.html,.css,.js,.svg) are served byte-identical, since they are valid UTF-8.Root cause
In the code-apps tooling bundled with the CLI (
tools/net10.0/any/Bin.jsofmicrosoft.powerapps.cli.tool2.7.4), the upload loop reads every file as UTF-8 text (de-minified excerpt):readFile(path, "utf-8")is lossy for non-UTF-8 content: invalid sequences are replaced, and re-serialization expands high bytes into multi-byte sequences. The upload should read files asBuffer(no encoding) and send the raw bytes.Impact
font-src 'self') anddata:fonts in CSS, so icon fonts (e.g. Material Icons used by many design systems, including ENGIE's Fluid) silently degrade to ligature text in production while working in local dev.Workaround (for anyone hitting this)
Ship no binary files at all: inline fonts as base64 inside the JS bundle (text survives the upload) and register them at runtime with
document.fonts.add(new FontFace(family, arrayBuffer, descriptors)). Constructing aFontFacefrom anArrayBufferperforms no fetch, sofont-srcdoes not apply. With Vite:import woff2 from "./font.woff2?inline", decode the data URI withatob, and register on startup.Suggested fix
In the upload loop, read files without an encoding (
await X.readFile(o)) and upload the resultingBufferunchanged; compute sizes withbuffer.length. Alternatively, restrict the UTF-8 path to known text MIME types.