Răsfoiți Sursa

fix: node 24.9 internal issue

imccyu 2 săptămâni în urmă
părinte
comite
675efe73f2

+ 13 - 0
.github/workflows/ci.yml

@@ -252,6 +252,13 @@ jobs:
             name: node 22.19
             runner: ubuntu-latest
             gate_concurrency: '1'
+          # Pinned inside 24.0-24.11.1: those releases carry the v1 internal
+          # loader while reporting major 24, and every other job tracks the
+          # latest 24, which is v2. A bare `24` here would retest that same v2.
+          - node: '24.9'
+            name: node 24.9
+            runner: ubuntu-latest
+            gate_concurrency: '1'
           - node: 26
             name: node 26
             runner: ubuntu-latest
@@ -276,6 +283,12 @@ jobs:
           DSH_BUILD_CLIENT_PROFILE: official
         run: pnpm run check:node-compat
 
+      # Kept out of the gate aggregate: the shape a Node release carries only
+      # changes with the Node version, so this belongs to the version matrix
+      # rather than to every commit's checks.
+      - name: Check Loader internal shape detection
+        run: pnpm exec vitest run packages/boot/app-boot/tests/loader-shape.compat.spec.ts
+
   python-sdk:
     if: github.event_name == 'pull_request'
     runs-on: ubuntu-latest

+ 32 - 0
packages/boot/app-boot/tests/loader-shape.compat.spec.ts

@@ -0,0 +1,32 @@
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { Context } from '@deepseek-ai/cordis'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import { describe, expect, it } from 'vitest'
+
+describe('Loader internal shape detection', () => {
+  it('tags the running Node loader with the resolver signature that runtime accepts', async () => {
+    const dir = mkdtempSync(join(tmpdir(), 'dsh-loader-shape-'))
+    const baseUrl = pathToFileURL(dir).href + '/'
+    const ctx = new Context()
+    ctx.baseUrl = baseUrl
+    await ctx.plugin(Loader)
+    try {
+      const internal = ctx.loader.internal
+      expect(internal, 'Node module internals are unreachable; HMR reload and client-module resolution both need them').toBeDefined()
+      // Resolving through the tag is exactly what Hmr._resolve() and the
+      // client-modules registry do. A tag taken from the Node major instead of
+      // the loader's own API rejects every call on 24.0-24.11.1, which report
+      // major 24 while carrying the v1 loader: v2 arrived only in 24.12.0.
+      const resolved = internal!.version === 'v2'
+        ? internal!.resolveSync(baseUrl, { specifier: 'node:path', attributes: {} })
+        : internal!.resolveSync('node:path', baseUrl, {})
+      expect(resolved.url).toBe('node:path')
+    } finally {
+      await ctx.fiber.dispose()
+      rmSync(dir, { recursive: true, force: true })
+    }
+  })
+})

+ 1 - 0
vendor/README.md

@@ -48,6 +48,7 @@ Keep this log exhaustive — every divergence from upstream must be listed.
 16. **`cordis/package.json` publishes `src`**: added `src` to the `files` list, joining the other eight vendored packages. Cordis declares `"./src/*": "./src/*"` in its exports, so a tarball without `src` publishes an export map pointing at absent files; the release change judgement also reads `files` to decide whether a diff reaches the payload, and a package whose only published paths are build output has no tracked path to match.
 17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md).
 18. **Entry `disabled` interpolation in `loader/src/config/entry.ts`**: a `disabled: !!js` expression evaluates against the loader context at every mount decision; the raw node stays in the options, so write-back keeps the `!!js` form. `disabled` is the only interpolated metadata field. Covered by `packages/boot/app-boot/tests/user-patches.spec.ts` and `apps/cli/tests/windows-shell.spec.ts`.
+19. **`loader/src/internal.ts` runtime shape detection**: `ModuleLoader.fromInternal()` classifies the internal loader by which module-job API it owns — `getOrCreateModuleJob` for v2, `getModuleJobForImport` for v1 — instead of by Node major version. Upstream tags every major `>= 24` as v2, but the v2 interface arrived in Node 24.12.0, so 24.0–24.11.1 report major 24 while still carrying the v1 loader; consumers then called `resolveSync` with reversed parameters and every call threw. `dsh web` served an empty client graph (`__DSH_BOOT__.entries: []`) and HMR partial reload resolved no entry URL, both behind swallowed or warn-level errors. Arity cannot discriminate the two shapes, because each reports `resolveSync.length === 2`. A loader owning neither API is left unclassified rather than guessed, so consumers take their documented no-internals path. Covered on the `node-compat` Node version matrix, which pins 24.9 for the mistagged range.
 
 ## Sync procedure
 

+ 20 - 8
vendor/loader/src/internal.ts

@@ -117,16 +117,28 @@ export namespace ModuleLoader {
     } catch {}
   }
 
+  /**
+   * Locate and classify the running Node internal module loader.
+   *
+   * The shape is decided by which module-job API the loader owns, never by the
+   * Node version: v2 landed in 24.12.0, so a major-version test mistags every
+   * 24.0–24.11.1 loader as v2 and makes consumers call `resolveSync` with
+   * reversed parameters. Arity is not usable either — `resolveSync` reports 2
+   * under both shapes. A loader owning neither API is left unclassified rather
+   * than guessed, so consumers take their documented no-internals path.
+   * @returns the classified loader, or `undefined` when none is reachable or its shape is unknown.
+   */
   export function fromInternal(): ModuleLoader | undefined {
     if (_cachedLoader) return _cachedLoader
     const [major] = process.versions.node.split('.').map(Number)
-
-    if (major >= 24) {
-      const raw = requireInternal('internal/modules/esm/loader')?.getOrInitializeCascadedLoader()
-      if (raw) return _cachedLoader = Object.assign(raw, { version: 'v2' })
-    } else if (major >= 22) {
-      const raw = requireInternal('internal/modules/esm/loader')?.getOrInitializeCascadedLoader()
-      if (raw) return _cachedLoader = Object.assign(raw, { version: 'v1' })
-    }
+    if (major < 22) return
+
+    const raw = requireInternal('internal/modules/esm/loader')?.getOrInitializeCascadedLoader()
+    if (!raw) return
+    const version = typeof raw.getOrCreateModuleJob === 'function'
+      ? 'v2'
+      : typeof raw.getModuleJobForImport === 'function' ? 'v1' : undefined
+    if (!version) return
+    return _cachedLoader = Object.assign(raw, { version })
   }
 }