@@ -30,7 +30,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
3030import { mkdirSync , mkdtempSync , rmSync , writeFileSync } from 'node:fs' ;
3131import * as NodeModule from 'node:module' ;
3232import { tmpdir } from 'node:os' ;
33- import { join } from 'node:path' ;
33+ import { dirname , join } from 'node:path' ;
3434import { pathToFileURL } from 'node:url' ;
3535import {
3636 createHostImporter ,
@@ -575,3 +575,250 @@ describe('the undeclared fallback resolves from the CALLER (#10943)', () => {
575575 expect ( withBase . message ) . not . toMatch ( / d i d n o t p a s s ` f a l l b a c k I m p o r t ` / ) ;
576576 } ) ;
577577} ) ;
578+
579+ /**
580+ * #13330 — the DECLARED leg used to load the CommonJS build of a dual-published
581+ * package, giving the process a SECOND instance of everything that package
582+ * brings with it.
583+ *
584+ * The defect was invisible to every test that imports things the normal way,
585+ * because "the same module" is only ever one instance in a suite that never
586+ * crosses the seam. It surfaced as a shipped EE boot: `os serve` loaded
587+ * `@objectstack/service-cluster-redis` through this leg, the driver's load-time
588+ * `registerClusterDriver('redis', …)` ran against the CJS copy of
589+ * `@objectstack/service-cluster`, and the ESM Runtime read the ESM copy and
590+ * found nothing — `Cluster driver "redis" is not registered`, about a package
591+ * that was installed, declared and resolvable.
592+ *
593+ * The fixtures below are a miniature of exactly that: a dual-published package
594+ * holding module-scope state, and a second dual-published package whose only
595+ * job is a load-time write into it. What is asserted is the SHARED INSTANCE,
596+ * not the file name — a test that only checked which path was imported would
597+ * pass on a fix that loaded the right file into the wrong instance.
598+ */
599+ describe ( 'the declared leg loads the `import` build, not the `require` one (#13330)' , ( ) => {
600+ const REGISTRY = '@fixture/instance-registry' ;
601+ const DRIVER = '@fixture/instance-registry-driver' ;
602+ const CJS_ONLY = '@fixture/require-only' ;
603+ const SUBPATHS = '@fixture/dual-subpaths' ;
604+
605+ /** Module-scope state, published as both builds — the `tsup` dual-build shape. */
606+ const REGISTRY_ESM = `export const BUILD = 'esm';
607+ const registered = [];
608+ export function register(name) { registered.push(name); }
609+ export function listRegistered() { return [...registered]; }
610+ ` ;
611+ const REGISTRY_CJS = `const registered = [];
612+ exports.BUILD = 'cjs';
613+ exports.register = (name) => { registered.push(name); };
614+ exports.listRegistered = () => [...registered];
615+ ` ;
616+ /** A driver package: its entire contract is the load-time side effect. */
617+ const DRIVER_ESM = `import { register } from '${ REGISTRY } ';
618+ register('probe');
619+ export const BUILD = 'esm';
620+ ` ;
621+ const DRIVER_CJS = `const { register } = require('${ REGISTRY } ');
622+ register('probe');
623+ exports.BUILD = 'cjs';
624+ ` ;
625+
626+ const roots : string [ ] = [ ] ;
627+
628+ function writePackage ( root : string , name : string , exportsField : unknown , files : Record < string , string > ) : void {
629+ const dir = join ( root , 'node_modules' , ...name . split ( '/' ) ) ;
630+ mkdirSync ( dir , { recursive : true } ) ;
631+ writeFileSync (
632+ join ( dir , 'package.json' ) ,
633+ JSON . stringify ( {
634+ name,
635+ version : '0.0.0-fixture' ,
636+ type : 'module' ,
637+ main : 'dist/index.js' ,
638+ exports : exportsField ,
639+ } ) ,
640+ 'utf8' ,
641+ ) ;
642+ for ( const rel of Object . keys ( files ) ) {
643+ const target = join ( dir , rel ) ;
644+ mkdirSync ( dirname ( target ) , { recursive : true } ) ;
645+ writeFileSync ( target , files [ rel ] as string , 'utf8' ) ;
646+ }
647+ }
648+
649+ /**
650+ * The condition map `tsup` emits: nested, with `types` first, so the
651+ * resolution under test has to walk INTO the `import` branch rather than
652+ * match a flat string.
653+ */
654+ const DUAL : unknown = {
655+ '.' : {
656+ import : { types : './dist/index.d.ts' , default : './dist/index.js' } ,
657+ require : { types : './dist/index.d.cts' , default : './dist/index.cjs' } ,
658+ } ,
659+ } ;
660+
661+ /**
662+ * A fresh app per case. The ESM module cache is keyed by absolute URL, so a
663+ * shared fixture directory would let one case's load answer the next one's
664+ * question — the failure mode this whole suite exists to detect.
665+ */
666+ function app ( tag : string ) : string {
667+ const root = mkdtempSync ( join ( tmpdir ( ) , `os-instance-split-${ tag } -` ) ) ;
668+ roots . push ( root ) ;
669+ writeFileSync (
670+ join ( root , 'package.json' ) ,
671+ JSON . stringify ( {
672+ name : 'dual-build-host-fixture' ,
673+ type : 'module' ,
674+ dependencies : {
675+ [ REGISTRY ] : '0.0.0-fixture' ,
676+ [ DRIVER ] : '0.0.0-fixture' ,
677+ [ CJS_ONLY ] : '0.0.0-fixture' ,
678+ [ SUBPATHS ] : '0.0.0-fixture' ,
679+ } ,
680+ } ) ,
681+ 'utf8' ,
682+ ) ;
683+ writePackage ( root , REGISTRY , DUAL , {
684+ 'dist/index.js' : REGISTRY_ESM ,
685+ 'dist/index.cjs' : REGISTRY_CJS ,
686+ } ) ;
687+ writePackage ( root , DRIVER , DUAL , {
688+ 'dist/index.js' : DRIVER_ESM ,
689+ 'dist/index.cjs' : DRIVER_CJS ,
690+ } ) ;
691+ // Publishes ONE build, under the `require` condition only.
692+ writePackage ( root , CJS_ONLY , { '.' : { require : './dist/index.cjs' } } , {
693+ 'dist/index.cjs' : "exports.BUILD = 'cjs-only';\n" ,
694+ } ) ;
695+ writePackage (
696+ root ,
697+ SUBPATHS ,
698+ {
699+ '.' : { import : './dist/index.js' , require : './dist/index.cjs' } ,
700+ './named' : { import : './dist/named.js' , require : './dist/named.cjs' } ,
701+ './deep/*' : { import : './dist/deep/*.js' , require : './dist/deep/*.cjs' } ,
702+ } ,
703+ {
704+ 'dist/index.js' : "export const WHERE = 'root-esm';\n" ,
705+ 'dist/index.cjs' : "exports.WHERE = 'root-cjs';\n" ,
706+ 'dist/named.js' : "export const WHERE = 'named-esm';\n" ,
707+ 'dist/named.cjs' : "exports.WHERE = 'named-cjs';\n" ,
708+ 'dist/deep/leaf.js' : "export const WHERE = 'leaf-esm';\n" ,
709+ 'dist/deep/leaf.cjs' : "exports.WHERE = 'leaf-cjs';\n" ,
710+ } ,
711+ ) ;
712+ return root ;
713+ }
714+
715+ /** The instance an ESM consumer chain holds — what the Runtime reads. */
716+ function esmInstance ( root : string ) : Promise < { BUILD : string ; listRegistered : ( ) => string [ ] ; register : ( n : string ) => void } > {
717+ return import (
718+ pathToFileURL ( join ( root , 'node_modules' , ...REGISTRY . split ( '/' ) , 'dist' , 'index.js' ) ) . href
719+ ) ;
720+ }
721+
722+ /** The instance a CommonJS load lands in — where the registration used to go. */
723+ function cjsInstance ( root : string ) : Promise < { BUILD : string ; listRegistered : ( ) => string [ ] ; register : ( n : string ) => void } > {
724+ return import (
725+ pathToFileURL ( join ( root , 'node_modules' , ...REGISTRY . split ( '/' ) , 'dist' , 'index.cjs' ) ) . href
726+ ) ;
727+ }
728+
729+ // No `fallbackImport`: every fixture here is DECLARED, so the undeclared leg
730+ // (the only consumer of that base) is never reached.
731+ const importer = ( root : string ) => createHostImporter ( root ) ;
732+
733+ afterAll ( ( ) => {
734+ for ( const dir of roots ) rmSync ( dir , { recursive : true , force : true } ) ;
735+ } ) ;
736+
737+ it ( 'CONTROL: the reader can see BOTH answers, so an empty registry is a reading' , async ( ) => {
738+ // Every assertion below rests on `listRegistered()` being able to come back
739+ // non-empty. A probe that could only ever return `[]` would make the whole
740+ // describe pass on a broken fix — so it is proved here, on the same
741+ // instrument, before anything is measured with it.
742+ const root = app ( 'control' ) ;
743+ const esm = await esmInstance ( root ) ;
744+ const cjs = await cjsInstance ( root ) ;
745+
746+ expect ( esm . BUILD ) . toBe ( 'esm' ) ;
747+ expect ( cjs . BUILD ) . toBe ( 'cjs' ) ;
748+ expect ( esm . listRegistered ( ) ) . toEqual ( [ ] ) ;
749+
750+ // And the two really are separate instances: writing one leaves the other
751+ // untouched. That is the split; without it there would be no defect to fix.
752+ cjs . register ( 'control-probe' ) ;
753+ expect ( cjs . listRegistered ( ) ) . toEqual ( [ 'control-probe' ] ) ;
754+ expect ( esm . listRegistered ( ) ) . toEqual ( [ ] ) ;
755+ } ) ;
756+
757+ it ( 'PRECONDITION: host CJS resolution still answers the `require` condition' , ( ) => {
758+ // The cause, pinned separately from the fix. `hostRequire.resolve` is still
759+ // the host-anchored half of the answer and is deliberately unchanged; if a
760+ // future Node stopped returning the `require` entry here, this test says so
761+ // rather than leaving the fix looking like a no-op.
762+ const root = app ( 'precondition' ) ;
763+ expect ( createHostRequire ( root ) . resolve ( DRIVER ) ) . toMatch ( / d i s t [ / \\ ] i n d e x \. c j s $ / ) ;
764+ } ) ;
765+
766+ it ( 'loads the `import` build of a declared package' , async ( ) => {
767+ const root = app ( 'import-condition' ) ;
768+ expect ( ( await importer ( root ) ( DRIVER ) ) . BUILD ) . toBe ( 'esm' ) ;
769+ } ) ;
770+
771+ it ( "a driver's load-time registration lands in the instance an ESM caller reads" , async ( ) => {
772+ // The defect, stated as its consequence. Before the fix this was `[]`.
773+ const root = app ( 'visible' ) ;
774+ expect ( ( await esmInstance ( root ) ) . listRegistered ( ) ) . toEqual ( [ ] ) ;
775+ await importer ( root ) ( DRIVER ) ;
776+ expect ( ( await esmInstance ( root ) ) . listRegistered ( ) ) . toEqual ( [ 'probe' ] ) ;
777+ } ) ;
778+
779+ it ( 'and no longer lands in the CommonJS instance nothing reads' , async ( ) => {
780+ // The other direction: the registration MOVED, it was not duplicated. A
781+ // fix that loaded both builds would satisfy the previous case and still
782+ // leave a process holding two live copies of the package's state.
783+ const root = app ( 'cjs-empty' ) ;
784+ await importer ( root ) ( DRIVER ) ;
785+ expect ( ( await cjsInstance ( root ) ) . listRegistered ( ) ) . toEqual ( [ ] ) ;
786+ expect ( ( await esmInstance ( root ) ) . listRegistered ( ) ) . toEqual ( [ 'probe' ] ) ;
787+ } ) ;
788+
789+ it ( 'a package publishing only a `require` condition still loads' , async ( ) => {
790+ // Narrowness. There is no import entry to prefer, so the resolved CJS path
791+ // is used exactly as before — the fix may not turn a working load into a
792+ // failure.
793+ const root = app ( 'cjs-only' ) ;
794+ expect ( ( await importer ( root ) ( CJS_ONLY ) ) . BUILD ) . toBe ( 'cjs-only' ) ;
795+ } ) ;
796+
797+ it ( 'a package with no `exports` map at all is untouched' , async ( ) => {
798+ // `main` is the only entry such a package publishes and CJS resolution
799+ // already returned it; there is nothing to re-decide.
800+ const root = app ( 'no-exports' ) ;
801+ writeFixturePackage ( root , '@fixture/no-exports-map' , 'export const BUILD = "main";\n' ) ;
802+ writeFileSync (
803+ join ( root , 'package.json' ) ,
804+ JSON . stringify ( {
805+ name : 'dual-build-host-fixture' ,
806+ type : 'module' ,
807+ dependencies : { '@fixture/no-exports-map' : '0.0.0-fixture' } ,
808+ } ) ,
809+ 'utf8' ,
810+ ) ;
811+ expect ( ( await importer ( root ) ( '@fixture/no-exports-map' ) ) . BUILD ) . toBe ( 'main' ) ;
812+ } ) ;
813+
814+ it ( 'resolves a declared SUBPATH under the import condition' , async ( ) => {
815+ const root = app ( 'subpath' ) ;
816+ expect ( ( await importer ( root ) ( SUBPATHS ) ) . WHERE ) . toBe ( 'root-esm' ) ;
817+ expect ( ( await importer ( root ) ( `${ SUBPATHS } /named` ) ) . WHERE ) . toBe ( 'named-esm' ) ;
818+ } ) ;
819+
820+ it ( 'resolves a wildcard subpath pattern under the import condition' , async ( ) => {
821+ const root = app ( 'pattern' ) ;
822+ expect ( ( await importer ( root ) ( `${ SUBPATHS } /deep/leaf` ) ) . WHERE ) . toBe ( 'leaf-esm' ) ;
823+ } ) ;
824+ } ) ;
0 commit comments