@open-mercato/shared 0.9.1-develop.7475.1.28ca93ed97 → 0.9.1-develop.7477.1.64ba865101
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/lib/bootstrap/next-subpath-resolve-hook-worker.js +6 -0
- package/dist/lib/bootstrap/next-subpath-resolve-hook-worker.js.map +7 -0
- package/dist/lib/bootstrap/next-subpath-resolve-hook.js +62 -0
- package/dist/lib/bootstrap/next-subpath-resolve-hook.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/bootstrap/__tests__/next-subpath-resolve-hook.test.ts +174 -0
- package/src/lib/bootstrap/next-subpath-resolve-hook-worker.ts +3 -0
- package/src/lib/bootstrap/next-subpath-resolve-hook.ts +130 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/lib/bootstrap/next-subpath-resolve-hook-worker.ts"],
|
|
4
|
+
"sourcesContent": ["import { resolveNextSubpathAsync } from './next-subpath-resolve-hook'\n\nexport const resolve = resolveNextSubpathAsync\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,+BAA+B;AAEjC,MAAM,UAAU;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import * as nodeModule from "node:module";
|
|
2
|
+
const NEXT_BARE_SUBPATH = /^next\/[A-Za-z0-9_-]+$/;
|
|
3
|
+
let installed = false;
|
|
4
|
+
function isModuleNotFound(error) {
|
|
5
|
+
return Boolean(error) && typeof error === "object" && error.code === "ERR_MODULE_NOT_FOUND";
|
|
6
|
+
}
|
|
7
|
+
function resolveNextSubpath(specifier, context, nextResolve) {
|
|
8
|
+
if (!NEXT_BARE_SUBPATH.test(specifier)) return nextResolve(specifier, context);
|
|
9
|
+
try {
|
|
10
|
+
return nextResolve(specifier, context);
|
|
11
|
+
} catch (error) {
|
|
12
|
+
if (!isModuleNotFound(error)) throw error;
|
|
13
|
+
try {
|
|
14
|
+
return nextResolve(`${specifier}.js`, context);
|
|
15
|
+
} catch {
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
async function resolveNextSubpathAsync(specifier, context, nextResolve) {
|
|
21
|
+
if (!NEXT_BARE_SUBPATH.test(specifier)) return nextResolve(specifier, context);
|
|
22
|
+
try {
|
|
23
|
+
return await nextResolve(specifier, context);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (!isModuleNotFound(error)) throw error;
|
|
26
|
+
try {
|
|
27
|
+
return await nextResolve(`${specifier}.js`, context);
|
|
28
|
+
} catch {
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function runtimeRegisterHooks() {
|
|
34
|
+
const candidate = nodeModule.registerHooks;
|
|
35
|
+
return typeof candidate === "function" ? candidate : void 0;
|
|
36
|
+
}
|
|
37
|
+
function runtimeRegisterHookModule() {
|
|
38
|
+
const candidate = nodeModule.register;
|
|
39
|
+
return typeof candidate === "function" ? candidate : void 0;
|
|
40
|
+
}
|
|
41
|
+
function installNextSubpathResolveHook(registerHooks = runtimeRegisterHooks(), registerHookModule = runtimeRegisterHookModule()) {
|
|
42
|
+
if (installed) return false;
|
|
43
|
+
if (typeof registerHooks === "function") {
|
|
44
|
+
registerHooks({ resolve: resolveNextSubpath });
|
|
45
|
+
} else if (typeof registerHookModule === "function") {
|
|
46
|
+
registerHookModule(new URL("./next-subpath-resolve-hook-worker.js", import.meta.url), import.meta.url);
|
|
47
|
+
} else {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
installed = true;
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
function resetNextSubpathResolveHookForTests() {
|
|
54
|
+
installed = false;
|
|
55
|
+
}
|
|
56
|
+
export {
|
|
57
|
+
installNextSubpathResolveHook,
|
|
58
|
+
resetNextSubpathResolveHookForTests,
|
|
59
|
+
resolveNextSubpath,
|
|
60
|
+
resolveNextSubpathAsync
|
|
61
|
+
};
|
|
62
|
+
//# sourceMappingURL=next-subpath-resolve-hook.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/lib/bootstrap/next-subpath-resolve-hook.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Node ESM resolution for `next/<subpath>` outside the Next bundler (#6118, #7102).\n *\n * Installed by the `mercato` CLI entry, so every CLI command and worker it\n * runs can load package dists that import `next/dynamic`, `next/link`,\n * `next/server` \u2026 The `next/dynamic` imports in particular MUST keep the bare\n * specifier: Next's `next_dynamic` compiler transform only matches it exactly.\n *\n * `mercato ai_assistant mcp:serve-http` (and `mcp:serve`, `mcp:dev`,\n * `mcp:list-tools`) run in plain Node, and the AI API operation runner loads\n * API route modules with a runtime `import()`. Those modules import\n * `NextResponse` from `'next/server'`: a bare package subpath that only a\n * bundler resolves, because `next` ships no `exports` map and Node's ESM\n * resolver does no extension guessing. Every route with that import (287 of\n * them on the 0.7.0 packages) therefore fails to load with\n * `ERR_MODULE_NOT_FOUND \u2026 node_modules/next/server`, and every tool backed by\n * such a route returns \"Failed to load route module\".\n *\n * `next/server.js`, `next/headers.js`, `next/navigation.js` \u2026 do exist as files,\n * which is why `@open-mercato/shared` imports `'next/headers.js'`. Rather than\n * rewriting several hundred imports across every package (and every\n * third-party module), this hook retries a failed bare `next/<subpath>` with\n * the `.js` suffix. It only runs in the processes that install it, only for\n * `next/*` specifiers, and only after the normal resolution failed, so it\n * becomes a no-op the day `next` publishes an `exports` map.\n */\nimport * as nodeModule from 'node:module'\n\ntype ResolveContext = Record<string, unknown>\ntype ResolveResult = { url: string; format?: string | null | undefined; shortCircuit?: boolean }\ntype NextResolve = (specifier: string, context?: ResolveContext) => ResolveResult\ntype AsyncNextResolve = (specifier: string, context?: ResolveContext) => ResolveResult | Promise<ResolveResult>\n\n/** Bare `next/<one segment>` specifiers, e.g. `next/server`, `next/headers`. */\nconst NEXT_BARE_SUBPATH = /^next\\/[A-Za-z0-9_-]+$/\n\nlet installed = false\n\nfunction isModuleNotFound(error: unknown): boolean {\n return Boolean(error) && typeof error === 'object' && (error as { code?: unknown }).code === 'ERR_MODULE_NOT_FOUND'\n}\n\n/**\n * The resolve hook itself. Exported so it can be tested without touching the\n * process-global hook chain.\n */\nexport function resolveNextSubpath(\n specifier: string,\n context: ResolveContext,\n nextResolve: NextResolve,\n): ResolveResult {\n if (!NEXT_BARE_SUBPATH.test(specifier)) return nextResolve(specifier, context)\n try {\n return nextResolve(specifier, context)\n } catch (error) {\n if (!isModuleNotFound(error)) throw error\n try {\n return nextResolve(`${specifier}.js`, context)\n } catch {\n // Neither form resolves (Next is not installed at all): surface the\n // original error, which names the specifier the caller wrote.\n throw error\n }\n }\n}\n\nexport async function resolveNextSubpathAsync(\n specifier: string,\n context: ResolveContext,\n nextResolve: AsyncNextResolve,\n): Promise<ResolveResult> {\n if (!NEXT_BARE_SUBPATH.test(specifier)) return nextResolve(specifier, context)\n try {\n return await nextResolve(specifier, context)\n } catch (error) {\n if (!isModuleNotFound(error)) throw error\n try {\n return await nextResolve(`${specifier}.js`, context)\n } catch {\n throw error\n }\n }\n}\n\ntype RegisterHooks = (hooks: { resolve: typeof resolveNextSubpath }) => unknown\ntype RegisterHookModule = (specifier: string | URL, parentURL?: string | URL) => unknown\n\n/**\n * `module.registerHooks` exists from Node 22.15. It is read off the namespace\n * rather than imported by name: in the published ESM build a named import of an\n * export the running Node lacks fails while the module links, before the check\n * in `installNextSubpathResolveHook` can run, and the package still declares\n * `node >= 22.0.0`.\n */\nfunction runtimeRegisterHooks(): RegisterHooks | undefined {\n const candidate = (nodeModule as unknown as { registerHooks?: unknown }).registerHooks\n return typeof candidate === 'function' ? (candidate as RegisterHooks) : undefined\n}\n\nfunction runtimeRegisterHookModule(): RegisterHookModule | undefined {\n const candidate = (nodeModule as unknown as { register?: unknown }).register\n return typeof candidate === 'function' ? (candidate as RegisterHookModule) : undefined\n}\n\n/**\n * Install the hook once per process. Returns `true` when it was installed by\n * this call, `false` when it already was or when the runtime has neither\n * registration API. Node 22.15+ uses the synchronous hook API; earlier\n * supported Node 22 releases use the asynchronous hook API.\n */\nexport function installNextSubpathResolveHook(\n registerHooks: RegisterHooks | null | undefined = runtimeRegisterHooks(),\n registerHookModule: RegisterHookModule | null | undefined = runtimeRegisterHookModule(),\n): boolean {\n if (installed) return false\n if (typeof registerHooks === 'function') {\n registerHooks({ resolve: resolveNextSubpath })\n } else if (typeof registerHookModule === 'function') {\n registerHookModule(new URL('./next-subpath-resolve-hook-worker.js', import.meta.url), import.meta.url)\n } else {\n return false\n }\n installed = true\n return true\n}\n\n/** Test seam: forget that the hook was installed. */\nexport function resetNextSubpathResolveHookForTests(): void {\n installed = false\n}\n"],
|
|
5
|
+
"mappings": "AA0BA,YAAY,gBAAgB;AAQ5B,MAAM,oBAAoB;AAE1B,IAAI,YAAY;AAEhB,SAAS,iBAAiB,OAAyB;AACjD,SAAO,QAAQ,KAAK,KAAK,OAAO,UAAU,YAAa,MAA6B,SAAS;AAC/F;AAMO,SAAS,mBACd,WACA,SACA,aACe;AACf,MAAI,CAAC,kBAAkB,KAAK,SAAS,EAAG,QAAO,YAAY,WAAW,OAAO;AAC7E,MAAI;AACF,WAAO,YAAY,WAAW,OAAO;AAAA,EACvC,SAAS,OAAO;AACd,QAAI,CAAC,iBAAiB,KAAK,EAAG,OAAM;AACpC,QAAI;AACF,aAAO,YAAY,GAAG,SAAS,OAAO,OAAO;AAAA,IAC/C,QAAQ;AAGN,YAAM;AAAA,IACR;AAAA,EACF;AACF;AAEA,eAAsB,wBACpB,WACA,SACA,aACwB;AACxB,MAAI,CAAC,kBAAkB,KAAK,SAAS,EAAG,QAAO,YAAY,WAAW,OAAO;AAC7E,MAAI;AACF,WAAO,MAAM,YAAY,WAAW,OAAO;AAAA,EAC7C,SAAS,OAAO;AACd,QAAI,CAAC,iBAAiB,KAAK,EAAG,OAAM;AACpC,QAAI;AACF,aAAO,MAAM,YAAY,GAAG,SAAS,OAAO,OAAO;AAAA,IACrD,QAAQ;AACN,YAAM;AAAA,IACR;AAAA,EACF;AACF;AAYA,SAAS,uBAAkD;AACzD,QAAM,YAAa,WAAsD;AACzE,SAAO,OAAO,cAAc,aAAc,YAA8B;AAC1E;AAEA,SAAS,4BAA4D;AACnE,QAAM,YAAa,WAAiD;AACpE,SAAO,OAAO,cAAc,aAAc,YAAmC;AAC/E;AAQO,SAAS,8BACd,gBAAkD,qBAAqB,GACvE,qBAA4D,0BAA0B,GAC7E;AACT,MAAI,UAAW,QAAO;AACtB,MAAI,OAAO,kBAAkB,YAAY;AACvC,kBAAc,EAAE,SAAS,mBAAmB,CAAC;AAAA,EAC/C,WAAW,OAAO,uBAAuB,YAAY;AACnD,uBAAmB,IAAI,IAAI,yCAAyC,YAAY,GAAG,GAAG,YAAY,GAAG;AAAA,EACvG,OAAO;AACL,WAAO;AAAA,EACT;AACA,cAAY;AACZ,SAAO;AACT;AAGO,SAAS,sCAA4C;AAC1D,cAAY;AACd;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.9.1-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.9.1-develop.7477.1.64ba865101';\nexport const appVersion = APP_VERSION;\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.9.1-develop.
|
|
3
|
+
"version": "0.9.1-develop.7477.1.64ba865101",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -121,7 +121,7 @@
|
|
|
121
121
|
"@mikro-orm/core": "^7.1.14",
|
|
122
122
|
"@mikro-orm/decorators": "^7.1.14",
|
|
123
123
|
"@mikro-orm/postgresql": "^7.1.14",
|
|
124
|
-
"@open-mercato/cache": "0.9.1-develop.
|
|
124
|
+
"@open-mercato/cache": "0.9.1-develop.7477.1.64ba865101",
|
|
125
125
|
"@types/html-to-text": "^9.0.4",
|
|
126
126
|
"@types/sanitize-html": "^2.16.1",
|
|
127
127
|
"dotenv": "^17.4.2",
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import {
|
|
2
|
+
installNextSubpathResolveHook,
|
|
3
|
+
resetNextSubpathResolveHookForTests,
|
|
4
|
+
resolveNextSubpath,
|
|
5
|
+
resolveNextSubpathAsync,
|
|
6
|
+
} from '../next-subpath-resolve-hook'
|
|
7
|
+
|
|
8
|
+
function notFound(specifier: string): Error & { code: string } {
|
|
9
|
+
return Object.assign(new Error(`Cannot find module '${specifier}'`), { code: 'ERR_MODULE_NOT_FOUND' })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('resolveNextSubpath (#6118)', () => {
|
|
13
|
+
const context = { parentURL: 'file:///app/route.js' }
|
|
14
|
+
|
|
15
|
+
it('leaves specifiers other than next/<subpath> to the default resolver', () => {
|
|
16
|
+
const nextResolve = jest.fn(() => ({ url: 'file:///app/node_modules/zod/index.js' }))
|
|
17
|
+
|
|
18
|
+
const result = resolveNextSubpath('zod', context, nextResolve)
|
|
19
|
+
|
|
20
|
+
expect(result.url).toBe('file:///app/node_modules/zod/index.js')
|
|
21
|
+
expect(nextResolve).toHaveBeenCalledTimes(1)
|
|
22
|
+
expect(nextResolve).toHaveBeenCalledWith('zod', context)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('does not touch a next/<subpath> that resolves on its own', () => {
|
|
26
|
+
// The day `next` ships an exports map the hook must step aside.
|
|
27
|
+
const nextResolve = jest.fn(() => ({ url: 'file:///app/node_modules/next/server.js' }))
|
|
28
|
+
|
|
29
|
+
resolveNextSubpath('next/server', context, nextResolve)
|
|
30
|
+
|
|
31
|
+
expect(nextResolve).toHaveBeenCalledTimes(1)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('retries a bare next/<subpath> with the .js suffix when Node cannot resolve it', () => {
|
|
35
|
+
// `next` has no exports map and Node ESM does no extension guessing, so
|
|
36
|
+
// `next/server` fails while `next/server.js` is a real file.
|
|
37
|
+
const nextResolve = jest
|
|
38
|
+
.fn()
|
|
39
|
+
.mockImplementationOnce(() => { throw notFound('/app/node_modules/next/server') })
|
|
40
|
+
.mockImplementationOnce(() => ({ url: 'file:///app/node_modules/next/server.js' }))
|
|
41
|
+
|
|
42
|
+
const result = resolveNextSubpath('next/server', context, nextResolve)
|
|
43
|
+
|
|
44
|
+
expect(result.url).toBe('file:///app/node_modules/next/server.js')
|
|
45
|
+
expect(nextResolve).toHaveBeenNthCalledWith(1, 'next/server', context)
|
|
46
|
+
expect(nextResolve).toHaveBeenNthCalledWith(2, 'next/server.js', context)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('covers the other subpaths route modules import', () => {
|
|
50
|
+
for (const subpath of ['next/headers', 'next/navigation', 'next/cache']) {
|
|
51
|
+
const nextResolve = jest
|
|
52
|
+
.fn()
|
|
53
|
+
.mockImplementationOnce(() => { throw notFound(subpath) })
|
|
54
|
+
.mockImplementationOnce((resolvedSpecifier: string) => ({
|
|
55
|
+
url: `file:///app/node_modules/${resolvedSpecifier}`,
|
|
56
|
+
}))
|
|
57
|
+
expect(resolveNextSubpath(subpath, context, nextResolve).url).toBe(`file:///app/node_modules/${subpath}.js`)
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('does not retry deeper or already-suffixed specifiers', () => {
|
|
62
|
+
for (const specifier of ['next/server.js', 'next/dist/server/web/spec-extension/response', 'next']) {
|
|
63
|
+
const nextResolve = jest.fn(() => { throw notFound(specifier) })
|
|
64
|
+
expect(() => resolveNextSubpath(specifier, context, nextResolve)).toThrow(/Cannot find module/)
|
|
65
|
+
expect(nextResolve).toHaveBeenCalledTimes(1)
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('rethrows the original error when the .js form is missing too', () => {
|
|
70
|
+
const original = notFound('/app/node_modules/next/server')
|
|
71
|
+
const nextResolve = jest
|
|
72
|
+
.fn()
|
|
73
|
+
.mockImplementationOnce(() => { throw original })
|
|
74
|
+
.mockImplementationOnce(() => { throw notFound('/app/node_modules/next/server.js') })
|
|
75
|
+
|
|
76
|
+
expect(() => resolveNextSubpath('next/server', context, nextResolve)).toThrow(original)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('rethrows errors that are not ERR_MODULE_NOT_FOUND without retrying', () => {
|
|
80
|
+
const syntaxError = Object.assign(new Error('bad package.json'), { code: 'ERR_INVALID_PACKAGE_CONFIG' })
|
|
81
|
+
const nextResolve = jest.fn(() => { throw syntaxError })
|
|
82
|
+
|
|
83
|
+
expect(() => resolveNextSubpath('next/server', context, nextResolve)).toThrow(syntaxError)
|
|
84
|
+
expect(nextResolve).toHaveBeenCalledTimes(1)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('installNextSubpathResolveHook', () => {
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
resetNextSubpathResolveHookForTests()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('registers the resolve hook once per process', () => {
|
|
94
|
+
const register = jest.fn()
|
|
95
|
+
|
|
96
|
+
expect(installNextSubpathResolveHook(register, null)).toBe(true)
|
|
97
|
+
expect(installNextSubpathResolveHook(register, null)).toBe(false)
|
|
98
|
+
|
|
99
|
+
expect(register).toHaveBeenCalledTimes(1)
|
|
100
|
+
expect(register).toHaveBeenCalledWith({ resolve: resolveNextSubpath })
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('registers an asynchronous hook on Node 22.0-22.14', () => {
|
|
104
|
+
const registerHookModule = jest.fn()
|
|
105
|
+
|
|
106
|
+
expect(installNextSubpathResolveHook(null, registerHookModule)).toBe(true)
|
|
107
|
+
expect(registerHookModule).toHaveBeenCalledWith(
|
|
108
|
+
expect.objectContaining({ href: expect.stringMatching(/next-subpath-resolve-hook-worker\.js$/) }),
|
|
109
|
+
expect.stringMatching(/^file:/),
|
|
110
|
+
)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('is a no-op on a runtime without either registration API', () => {
|
|
114
|
+
expect(installNextSubpathResolveHook(null, null)).toBe(false)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('resolveNextSubpathAsync', () => {
|
|
119
|
+
const context = { parentURL: 'file:///app/route.js' }
|
|
120
|
+
|
|
121
|
+
it('retries a bare next/<subpath> with the .js suffix', async () => {
|
|
122
|
+
const nextResolve = jest
|
|
123
|
+
.fn()
|
|
124
|
+
.mockRejectedValueOnce(notFound('/app/node_modules/next/server'))
|
|
125
|
+
.mockResolvedValueOnce({ url: 'file:///app/node_modules/next/server.js' })
|
|
126
|
+
|
|
127
|
+
const result = await resolveNextSubpathAsync('next/server', context, nextResolve)
|
|
128
|
+
|
|
129
|
+
expect(result.url).toBe('file:///app/node_modules/next/server.js')
|
|
130
|
+
expect(nextResolve).toHaveBeenNthCalledWith(1, 'next/server', context)
|
|
131
|
+
expect(nextResolve).toHaveBeenNthCalledWith(2, 'next/server.js', context)
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe('installNextSubpathResolveHook against the runtime node:module', () => {
|
|
136
|
+
afterEach(() => {
|
|
137
|
+
jest.dontMock('node:module')
|
|
138
|
+
jest.resetModules()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('installs through node:module.register on Node 22.0-22.14', () => {
|
|
142
|
+
// A named import of `registerHooks` would throw while the module loads on
|
|
143
|
+
// such a runtime; the helper must load and use the older registration API.
|
|
144
|
+
jest.resetModules()
|
|
145
|
+
const register = jest.fn()
|
|
146
|
+
jest.doMock('node:module', () => ({ register }))
|
|
147
|
+
let installed: boolean | undefined
|
|
148
|
+
jest.isolateModules(() => {
|
|
149
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
150
|
+
const hookModule = require('../next-subpath-resolve-hook') as typeof import('../next-subpath-resolve-hook')
|
|
151
|
+
installed = hookModule.installNextSubpathResolveHook()
|
|
152
|
+
})
|
|
153
|
+
expect(installed).toBe(true)
|
|
154
|
+
expect(register).toHaveBeenCalledTimes(1)
|
|
155
|
+
expect(register.mock.calls[0][0]).toEqual(
|
|
156
|
+
expect.objectContaining({ href: expect.stringMatching(/next-subpath-resolve-hook-worker\.js$/) }),
|
|
157
|
+
)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('installs through node:module.registerHooks when the runtime has it', () => {
|
|
161
|
+
jest.resetModules()
|
|
162
|
+
const register = jest.fn()
|
|
163
|
+
jest.doMock('node:module', () => ({ registerHooks: register }))
|
|
164
|
+
let installed: boolean | undefined
|
|
165
|
+
jest.isolateModules(() => {
|
|
166
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
167
|
+
const hookModule = require('../next-subpath-resolve-hook') as typeof import('../next-subpath-resolve-hook')
|
|
168
|
+
installed = hookModule.installNextSubpathResolveHook()
|
|
169
|
+
})
|
|
170
|
+
expect(installed).toBe(true)
|
|
171
|
+
expect(register).toHaveBeenCalledTimes(1)
|
|
172
|
+
expect(register.mock.calls[0][0]).toEqual({ resolve: expect.any(Function) })
|
|
173
|
+
})
|
|
174
|
+
})
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node ESM resolution for `next/<subpath>` outside the Next bundler (#6118, #7102).
|
|
3
|
+
*
|
|
4
|
+
* Installed by the `mercato` CLI entry, so every CLI command and worker it
|
|
5
|
+
* runs can load package dists that import `next/dynamic`, `next/link`,
|
|
6
|
+
* `next/server` … The `next/dynamic` imports in particular MUST keep the bare
|
|
7
|
+
* specifier: Next's `next_dynamic` compiler transform only matches it exactly.
|
|
8
|
+
*
|
|
9
|
+
* `mercato ai_assistant mcp:serve-http` (and `mcp:serve`, `mcp:dev`,
|
|
10
|
+
* `mcp:list-tools`) run in plain Node, and the AI API operation runner loads
|
|
11
|
+
* API route modules with a runtime `import()`. Those modules import
|
|
12
|
+
* `NextResponse` from `'next/server'`: a bare package subpath that only a
|
|
13
|
+
* bundler resolves, because `next` ships no `exports` map and Node's ESM
|
|
14
|
+
* resolver does no extension guessing. Every route with that import (287 of
|
|
15
|
+
* them on the 0.7.0 packages) therefore fails to load with
|
|
16
|
+
* `ERR_MODULE_NOT_FOUND … node_modules/next/server`, and every tool backed by
|
|
17
|
+
* such a route returns "Failed to load route module".
|
|
18
|
+
*
|
|
19
|
+
* `next/server.js`, `next/headers.js`, `next/navigation.js` … do exist as files,
|
|
20
|
+
* which is why `@open-mercato/shared` imports `'next/headers.js'`. Rather than
|
|
21
|
+
* rewriting several hundred imports across every package (and every
|
|
22
|
+
* third-party module), this hook retries a failed bare `next/<subpath>` with
|
|
23
|
+
* the `.js` suffix. It only runs in the processes that install it, only for
|
|
24
|
+
* `next/*` specifiers, and only after the normal resolution failed, so it
|
|
25
|
+
* becomes a no-op the day `next` publishes an `exports` map.
|
|
26
|
+
*/
|
|
27
|
+
import * as nodeModule from 'node:module'
|
|
28
|
+
|
|
29
|
+
type ResolveContext = Record<string, unknown>
|
|
30
|
+
type ResolveResult = { url: string; format?: string | null | undefined; shortCircuit?: boolean }
|
|
31
|
+
type NextResolve = (specifier: string, context?: ResolveContext) => ResolveResult
|
|
32
|
+
type AsyncNextResolve = (specifier: string, context?: ResolveContext) => ResolveResult | Promise<ResolveResult>
|
|
33
|
+
|
|
34
|
+
/** Bare `next/<one segment>` specifiers, e.g. `next/server`, `next/headers`. */
|
|
35
|
+
const NEXT_BARE_SUBPATH = /^next\/[A-Za-z0-9_-]+$/
|
|
36
|
+
|
|
37
|
+
let installed = false
|
|
38
|
+
|
|
39
|
+
function isModuleNotFound(error: unknown): boolean {
|
|
40
|
+
return Boolean(error) && typeof error === 'object' && (error as { code?: unknown }).code === 'ERR_MODULE_NOT_FOUND'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The resolve hook itself. Exported so it can be tested without touching the
|
|
45
|
+
* process-global hook chain.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveNextSubpath(
|
|
48
|
+
specifier: string,
|
|
49
|
+
context: ResolveContext,
|
|
50
|
+
nextResolve: NextResolve,
|
|
51
|
+
): ResolveResult {
|
|
52
|
+
if (!NEXT_BARE_SUBPATH.test(specifier)) return nextResolve(specifier, context)
|
|
53
|
+
try {
|
|
54
|
+
return nextResolve(specifier, context)
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (!isModuleNotFound(error)) throw error
|
|
57
|
+
try {
|
|
58
|
+
return nextResolve(`${specifier}.js`, context)
|
|
59
|
+
} catch {
|
|
60
|
+
// Neither form resolves (Next is not installed at all): surface the
|
|
61
|
+
// original error, which names the specifier the caller wrote.
|
|
62
|
+
throw error
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function resolveNextSubpathAsync(
|
|
68
|
+
specifier: string,
|
|
69
|
+
context: ResolveContext,
|
|
70
|
+
nextResolve: AsyncNextResolve,
|
|
71
|
+
): Promise<ResolveResult> {
|
|
72
|
+
if (!NEXT_BARE_SUBPATH.test(specifier)) return nextResolve(specifier, context)
|
|
73
|
+
try {
|
|
74
|
+
return await nextResolve(specifier, context)
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (!isModuleNotFound(error)) throw error
|
|
77
|
+
try {
|
|
78
|
+
return await nextResolve(`${specifier}.js`, context)
|
|
79
|
+
} catch {
|
|
80
|
+
throw error
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type RegisterHooks = (hooks: { resolve: typeof resolveNextSubpath }) => unknown
|
|
86
|
+
type RegisterHookModule = (specifier: string | URL, parentURL?: string | URL) => unknown
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `module.registerHooks` exists from Node 22.15. It is read off the namespace
|
|
90
|
+
* rather than imported by name: in the published ESM build a named import of an
|
|
91
|
+
* export the running Node lacks fails while the module links, before the check
|
|
92
|
+
* in `installNextSubpathResolveHook` can run, and the package still declares
|
|
93
|
+
* `node >= 22.0.0`.
|
|
94
|
+
*/
|
|
95
|
+
function runtimeRegisterHooks(): RegisterHooks | undefined {
|
|
96
|
+
const candidate = (nodeModule as unknown as { registerHooks?: unknown }).registerHooks
|
|
97
|
+
return typeof candidate === 'function' ? (candidate as RegisterHooks) : undefined
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function runtimeRegisterHookModule(): RegisterHookModule | undefined {
|
|
101
|
+
const candidate = (nodeModule as unknown as { register?: unknown }).register
|
|
102
|
+
return typeof candidate === 'function' ? (candidate as RegisterHookModule) : undefined
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Install the hook once per process. Returns `true` when it was installed by
|
|
107
|
+
* this call, `false` when it already was or when the runtime has neither
|
|
108
|
+
* registration API. Node 22.15+ uses the synchronous hook API; earlier
|
|
109
|
+
* supported Node 22 releases use the asynchronous hook API.
|
|
110
|
+
*/
|
|
111
|
+
export function installNextSubpathResolveHook(
|
|
112
|
+
registerHooks: RegisterHooks | null | undefined = runtimeRegisterHooks(),
|
|
113
|
+
registerHookModule: RegisterHookModule | null | undefined = runtimeRegisterHookModule(),
|
|
114
|
+
): boolean {
|
|
115
|
+
if (installed) return false
|
|
116
|
+
if (typeof registerHooks === 'function') {
|
|
117
|
+
registerHooks({ resolve: resolveNextSubpath })
|
|
118
|
+
} else if (typeof registerHookModule === 'function') {
|
|
119
|
+
registerHookModule(new URL('./next-subpath-resolve-hook-worker.js', import.meta.url), import.meta.url)
|
|
120
|
+
} else {
|
|
121
|
+
return false
|
|
122
|
+
}
|
|
123
|
+
installed = true
|
|
124
|
+
return true
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Test seam: forget that the hook was installed. */
|
|
128
|
+
export function resetNextSubpathResolveHookForTests(): void {
|
|
129
|
+
installed = false
|
|
130
|
+
}
|