fix(ci): switch Windows install to hoisted linker; narrow FormData iter

Pushing further to actually get Windows render verification green, not just
work around it.

## What's wrong on Windows

Bun 1.3's default `isolated` linker creates nested workspace junctions under
`packages/*/node_modules/` on Windows GHA runners. Those junctions don't
materialize reliably — Node's `realpathSync` returns `EPERM` on stat, and
ESM resolution returns `ERR_MODULE_NOT_FOUND`. Every Windows build since
PR #748 has tripped this in one of three places:

- `packages/producer/build.mjs` importing `esbuild`
- `packages/producer/scripts/generate-font-data.ts` reading `@fontsource/*`
- `packages/producer` running `tsc` to emit `.d.ts`s

Long-running bun bugs: oven-sh/bun#23615, #18354, #10146.

## Fix

**1. `--linker=hoisted` for the Windows install step** (workflow change,
Windows only). Hoisted layout puts deps as real directories at the workspace
root + workspace package node_modules. No junctions, no Windows-specific
path quirks. Linux CI keeps the default isolated linker; the lockfile is
linker-agnostic so `--frozen-lockfile` is still valid.

**2. Source-level FormData narrowing in `packages/core/src/studio-api/routes/files.ts`**
(needed because the hoisted layout exposes a `@types/node@25` typecheck
issue that the isolated layout hides). With v25 + an `onmessage` global in
scope, the ambient `FormData.entries()` infers `[string, string]` instead of
`[string, File | string]`, so the `value instanceof File` check breaks at
`TS2358`. Cast the iterator to a `[string, FileLike | string]` shape and
narrow via `typeof value === "string"`. Identical runtime behavior; works
under both v24 (isolated layout, what Linux CI sees) and v25 (hoisted, what
Windows CI sees with this change).

## Verification

- `bun install --frozen-lockfile` (isolated, default): full build green
- `bun install --frozen-lockfile --linker=hoisted`: full build green, core
  typecheck passes, `@hyperframes/core` 853 tests pass
- Format/lint clean on both layouts
This commit is contained in:
James
2026-05-13 03:27:58 +00:00
parent c8a3e5fc4d
commit 8348e19fd9
2 changed files with 35 additions and 4 deletions
+20 -2
View File
@@ -150,7 +150,16 @@ jobs:
- name: Install dependencies
shell: pwsh
run: bun install --frozen-lockfile
# Force the pre-1.3 "hoisted" linker on Windows. Bun 1.3.x's default
# "isolated" linker creates nested workspace junctions that don't
# materialize reliably on Windows GHA runners — manifests as
# `Cannot find package 'esbuild'` walking up from
# `packages/producer/build.mjs` and `EPERM stat` on
# `packages/producer/node_modules/@fontsource/*` from generators.
# See oven-sh/bun#23615, #18354, #10146. Linux CI keeps isolated;
# the lockfile is linker-agnostic so this flag is safe with
# --frozen-lockfile.
run: bun install --frozen-lockfile --linker=hoisted
- name: Build all packages
shell: pwsh
@@ -371,7 +380,16 @@ jobs:
- name: Install dependencies
shell: pwsh
run: bun install --frozen-lockfile
# Force the pre-1.3 "hoisted" linker on Windows. Bun 1.3.x's default
# "isolated" linker creates nested workspace junctions that don't
# materialize reliably on Windows GHA runners — manifests as
# `Cannot find package 'esbuild'` walking up from
# `packages/producer/build.mjs` and `EPERM stat` on
# `packages/producer/node_modules/@fontsource/*` from generators.
# See oven-sh/bun#23615, #18354, #10146. Linux CI keeps isolated;
# the lockfile is linker-agnostic so this flag is safe with
# --frozen-lockfile.
run: bun install --frozen-lockfile --linker=hoisted
- name: Build
shell: pwsh
+15 -2
View File
@@ -306,8 +306,21 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
const skipped: string[] = [];
const invalid: Array<{ name: string; reason: string }> = [];
for (const [, value] of formData.entries()) {
if (!(value instanceof File)) continue;
// @types/node v25 narrows the ambient `FormData.entries()` to
// `[string, string]` in workspaces where another dep declares an
// `onmessage` global (it trips the worker branch of v25's conditional
// File type). At runtime the value is still `File | string` — cast the
// iterator so the rest of this block keeps type-checking on every
// bun-install layout (hoisted on Windows surfaces this; isolated on
// Linux happens to keep v24 in scope).
type FileLike = {
readonly name: string;
readonly size: number;
arrayBuffer(): Promise<ArrayBuffer>;
};
const entries = formData.entries() as unknown as Iterable<[string, FileLike | string]>;
for (const [, value] of entries) {
if (typeof value === "string") continue;
// Strip path separators — browsers may include directory components
const name = value.name.split("/").pop()?.split("\\").pop() ?? "";