@open-mercato/shared 0.9.1-develop.7575.1.8480826a9b → 0.10.1-develop.7577.1.477f2486e5
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/AGENTS.md +4 -4
- package/dist/lib/auth/server.js +1 -1
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/bootstrap/dynamicLoader.js +46 -6
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/bootstrap/factory.js +4 -3
- package/dist/lib/bootstrap/factory.js.map +2 -2
- 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/catalog-visibility/canonicalizeEffectiveScope.js +33 -0
- package/dist/lib/catalog-visibility/canonicalizeEffectiveScope.js.map +7 -0
- package/dist/lib/catalog-visibility/hashEffectiveScope.js +9 -0
- package/dist/lib/catalog-visibility/hashEffectiveScope.js.map +7 -0
- package/dist/lib/catalog-visibility/index.js +4 -0
- package/dist/lib/catalog-visibility/index.js.map +2 -2
- package/dist/lib/commands/command-bus.js +45 -13
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/commands/types.js.map +2 -2
- package/dist/lib/crud/exporters.js +1 -1
- package/dist/lib/crud/exporters.js.map +2 -2
- package/dist/lib/crud/ids.js +13 -1
- package/dist/lib/crud/ids.js.map +2 -2
- package/dist/lib/db/advisoryLock.js +172 -0
- package/dist/lib/db/advisoryLock.js.map +7 -0
- package/dist/lib/db/pg-errors.js +14 -0
- package/dist/lib/db/pg-errors.js.map +2 -2
- package/dist/lib/localization/index.js +9 -1
- package/dist/lib/localization/index.js.map +2 -2
- package/dist/lib/localization/translatable-fields.js +26 -0
- package/dist/lib/localization/translatable-fields.js.map +2 -2
- package/dist/lib/number.js +6 -0
- package/dist/lib/number.js.map +2 -2
- package/dist/lib/openapi/generator.js +84 -67
- package/dist/lib/openapi/generator.js.map +2 -2
- package/dist/lib/query/engine.js +45 -10
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/join-utils.js +1 -1
- package/dist/lib/query/join-utils.js.map +2 -2
- package/dist/lib/query/overlap.js +58 -0
- package/dist/lib/query/overlap.js.map +7 -0
- package/dist/lib/query/types.js.map +2 -2
- package/dist/lib/url.js +24 -0
- package/dist/lib/url.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/lib/webhooks/body.js +7 -3
- package/dist/lib/webhooks/body.js.map +2 -2
- package/dist/lib/webhooks/index.js +2 -0
- package/dist/lib/webhooks/index.js.map +2 -2
- package/dist/modules/integrations/types.js.map +2 -2
- package/dist/modules/notifications/safeHref.js +20 -0
- package/dist/modules/notifications/safeHref.js.map +7 -0
- package/dist/modules/notifications/types.js +4 -0
- package/dist/modules/notifications/types.js.map +3 -3
- package/dist/modules/payment_gateways/types.js +14 -1
- package/dist/modules/payment_gateways/types.js.map +2 -2
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/search.js.map +2 -2
- package/dist/modules/widgets/injection-loader.js +14 -3
- package/dist/modules/widgets/injection-loader.js.map +2 -2
- package/package.json +10 -3
- package/src/lib/__tests__/number.test.ts +24 -1
- package/src/lib/__tests__/url.cross-site.test.ts +140 -0
- package/src/lib/auth/__tests__/server.test.ts +14 -0
- package/src/lib/auth/server.ts +1 -1
- package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.appModuleOverrides.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.cacheRecovery.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.externalSubpath.test.ts +141 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/factory.test.ts +18 -6
- package/src/lib/bootstrap/__tests__/next-subpath-resolve-hook.test.ts +174 -0
- package/src/lib/bootstrap/dynamicLoader.ts +63 -5
- package/src/lib/bootstrap/factory.ts +8 -3
- package/src/lib/bootstrap/next-subpath-resolve-hook-worker.ts +3 -0
- package/src/lib/bootstrap/next-subpath-resolve-hook.ts +130 -0
- package/src/lib/bootstrap/types.ts +5 -1
- package/src/lib/catalog-visibility/__tests__/hashEffectiveScope.test.ts +121 -0
- package/src/lib/catalog-visibility/canonicalizeEffectiveScope.ts +41 -0
- package/src/lib/catalog-visibility/hashEffectiveScope.ts +8 -0
- package/src/lib/catalog-visibility/index.ts +2 -0
- package/src/lib/commands/__tests__/command-bus.test.ts +100 -1
- package/src/lib/commands/command-bus.ts +42 -10
- package/src/lib/commands/command-interceptor.ts +4 -0
- package/src/lib/commands/types.ts +14 -0
- package/src/lib/crud/__tests__/exporters.test.ts +18 -0
- package/src/lib/crud/__tests__/ids.test.ts +34 -1
- package/src/lib/crud/exporters.ts +1 -1
- package/src/lib/crud/ids.ts +24 -0
- package/src/lib/db/__tests__/advisoryLock.integration.test.ts +343 -0
- package/src/lib/db/__tests__/advisoryLock.test.ts +564 -0
- package/src/lib/db/__tests__/pg-errors.test.ts +68 -1
- package/src/lib/db/advisoryLock.ts +271 -0
- package/src/lib/db/pg-errors.ts +35 -0
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +23 -0
- package/src/lib/localization/__tests__/translatable-fields.test.ts +17 -0
- package/src/lib/localization/index.ts +8 -1
- package/src/lib/localization/translatable-fields.ts +46 -0
- package/src/lib/number.ts +13 -0
- package/src/lib/openapi/__tests__/optional-path-params.test.ts +102 -0
- package/src/lib/openapi/generator.ts +106 -77
- package/src/lib/query/__tests__/empty-ids-filter.pg.test.ts +89 -0
- package/src/lib/query/__tests__/engine.empty-in-filter.test.ts +79 -0
- package/src/lib/query/__tests__/overlap.test.ts +219 -0
- package/src/lib/query/engine.ts +58 -8
- package/src/lib/query/join-utils.ts +1 -1
- package/src/lib/query/overlap.ts +106 -0
- package/src/lib/query/types.ts +13 -1
- package/src/lib/url.ts +30 -0
- package/src/lib/webhooks/__tests__/body.test.ts +48 -0
- package/src/lib/webhooks/body.ts +11 -4
- package/src/lib/webhooks/index.ts +1 -0
- package/src/modules/integrations/types.ts +6 -0
- package/src/modules/notifications/safeHref.ts +19 -0
- package/src/modules/notifications/types.ts +7 -0
- package/src/modules/payment_gateways/__tests__/types.test.ts +44 -0
- package/src/modules/payment_gateways/types.ts +52 -6
- package/src/modules/registry.ts +5 -0
- package/src/modules/search.ts +39 -0
- package/src/modules/widgets/__tests__/injection-loader.partitioned-registration.test.ts +78 -0
- package/src/modules/widgets/injection-loader.ts +33 -3
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*
|
|
4
|
+
* The CLI bundle externalizes every bare package import. Node's ESM resolver only maps an
|
|
5
|
+
* extensionless subpath such as `next/link` when the package publishes an `exports` map; for a
|
|
6
|
+
* legacy package (no `exports`, like `next`) it requires the file extension and the bundle
|
|
7
|
+
* fails with ERR_MODULE_NOT_FOUND (#6993). The external plugin therefore rewrites such
|
|
8
|
+
* subpaths to the file `require.resolve` finds, and leaves everything else untouched.
|
|
9
|
+
*/
|
|
10
|
+
import { spawnSync } from 'node:child_process'
|
|
11
|
+
import fs from 'node:fs'
|
|
12
|
+
import os from 'node:os'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
|
|
15
|
+
import { compileAppSourceFile, createCliBundlePlugins } from '../dynamicLoader'
|
|
16
|
+
|
|
17
|
+
function writePackage(appRoot: string, name: string, manifest: Record<string, unknown>, files: Record<string, string>) {
|
|
18
|
+
const packageDir = path.join(appRoot, 'node_modules', ...name.split('/'))
|
|
19
|
+
fs.mkdirSync(packageDir, { recursive: true })
|
|
20
|
+
fs.writeFileSync(path.join(packageDir, 'package.json'), JSON.stringify({ name, version: '1.0.0', ...manifest }))
|
|
21
|
+
for (const [relativePath, content] of Object.entries(files)) {
|
|
22
|
+
const filePath = path.join(packageDir, relativePath)
|
|
23
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
24
|
+
fs.writeFileSync(filePath, content)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function bundleWithCliPlugins(appRoot: string, source: string): Promise<{ outfile: string; output: string }> {
|
|
29
|
+
const esbuild = await import('esbuild')
|
|
30
|
+
const generatedDir = path.join(appRoot, '.mercato', 'generated')
|
|
31
|
+
fs.mkdirSync(generatedDir, { recursive: true })
|
|
32
|
+
const entry = path.join(generatedDir, 'entry.ts')
|
|
33
|
+
fs.writeFileSync(entry, source)
|
|
34
|
+
const outfile = path.join(generatedDir, 'entry.mjs')
|
|
35
|
+
await esbuild.build({
|
|
36
|
+
entryPoints: [entry],
|
|
37
|
+
outfile,
|
|
38
|
+
absWorkingDir: appRoot,
|
|
39
|
+
bundle: true,
|
|
40
|
+
format: 'esm',
|
|
41
|
+
platform: 'node',
|
|
42
|
+
target: 'node18',
|
|
43
|
+
plugins: createCliBundlePlugins(appRoot),
|
|
44
|
+
})
|
|
45
|
+
return { outfile, output: fs.readFileSync(outfile, 'utf8') }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('createCliBundlePlugins — externalized package subpaths', () => {
|
|
49
|
+
let appRoot: string
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
appRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'om-external-subpath-')))
|
|
53
|
+
fs.writeFileSync(path.join(appRoot, 'package.json'), JSON.stringify({ name: 'app', type: 'module' }))
|
|
54
|
+
writePackage(appRoot, 'legacy-pkg', { main: 'index.js' }, {
|
|
55
|
+
'index.js': "module.exports = 'legacy-root'\n",
|
|
56
|
+
'link.js': "module.exports = 'legacy-link'\n",
|
|
57
|
+
'dist/nested/index.js': "module.exports = 'legacy-nested'\n",
|
|
58
|
+
})
|
|
59
|
+
writePackage(appRoot, '@scope/legacy-scoped', {}, {
|
|
60
|
+
'tools.js': "module.exports = 'scoped-tools'\n",
|
|
61
|
+
})
|
|
62
|
+
writePackage(appRoot, 'exports-pkg', { exports: { './feature': './lib/feature.js' } }, {
|
|
63
|
+
'lib/feature.js': "module.exports = 'exports-feature'\n",
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
fs.rmSync(appRoot, { recursive: true, force: true })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('appends the resolved file to extensionless subpaths of packages without an exports map', async () => {
|
|
72
|
+
const { output } = await bundleWithCliPlugins(appRoot, [
|
|
73
|
+
"import link from 'legacy-pkg/link'",
|
|
74
|
+
"import nested from 'legacy-pkg/dist/nested'",
|
|
75
|
+
"import tools from '@scope/legacy-scoped/tools'",
|
|
76
|
+
'export const values = [link, nested, tools]',
|
|
77
|
+
'',
|
|
78
|
+
].join('\n'))
|
|
79
|
+
|
|
80
|
+
expect(output).toContain('"legacy-pkg/link.js"')
|
|
81
|
+
expect(output).toContain('"legacy-pkg/dist/nested/index.js"')
|
|
82
|
+
expect(output).toContain('"@scope/legacy-scoped/tools.js"')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('keeps package roots, exports-mapped subpaths, builtins and unresolvable specifiers unchanged', async () => {
|
|
86
|
+
const { output } = await bundleWithCliPlugins(appRoot, [
|
|
87
|
+
"import root from 'legacy-pkg'",
|
|
88
|
+
"import feature from 'exports-pkg/feature'",
|
|
89
|
+
"import explicit from 'legacy-pkg/link.js'",
|
|
90
|
+
"import { readFile } from 'fs/promises'",
|
|
91
|
+
"import missing from 'missing-pkg/sub'",
|
|
92
|
+
'export const values = [root, feature, explicit, readFile, missing]',
|
|
93
|
+
'',
|
|
94
|
+
].join('\n'))
|
|
95
|
+
|
|
96
|
+
expect(output).toContain('"legacy-pkg"')
|
|
97
|
+
expect(output).toContain('"exports-pkg/feature"')
|
|
98
|
+
expect(output).toContain('"legacy-pkg/link.js"')
|
|
99
|
+
expect(output).toContain('"fs/promises"')
|
|
100
|
+
expect(output).toContain('"missing-pkg/sub"')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('produces a bundle Node ESM can load', async () => {
|
|
104
|
+
const { outfile } = await bundleWithCliPlugins(appRoot, [
|
|
105
|
+
"import link from 'legacy-pkg/link'",
|
|
106
|
+
"import feature from 'exports-pkg/feature'",
|
|
107
|
+
'console.log(JSON.stringify([link, feature]))',
|
|
108
|
+
'',
|
|
109
|
+
].join('\n'))
|
|
110
|
+
|
|
111
|
+
const result = spawnSync(process.execPath, [outfile], { encoding: 'utf8' })
|
|
112
|
+
|
|
113
|
+
expect(result.stderr).not.toContain('ERR_MODULE_NOT_FOUND')
|
|
114
|
+
expect(result.status).toBe(0)
|
|
115
|
+
expect(JSON.parse(result.stdout.trim())).toEqual(['legacy-link', 'exports-feature'])
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('records the rewritten package manifest so a package upgrade invalidates the cached bundle', async () => {
|
|
119
|
+
fs.writeFileSync(path.join(appRoot, 'tsconfig.json'), JSON.stringify({ compilerOptions: { target: 'ES2022' } }))
|
|
120
|
+
const generatedDir = path.join(appRoot, '.mercato', 'generated')
|
|
121
|
+
fs.mkdirSync(generatedDir, { recursive: true })
|
|
122
|
+
const entry = path.join(generatedDir, 'registry.generated.ts')
|
|
123
|
+
fs.writeFileSync(entry, "import link from 'legacy-pkg/link'\nexport const value = link\n")
|
|
124
|
+
const outFile = path.join(generatedDir, 'registry.generated.mjs')
|
|
125
|
+
|
|
126
|
+
await compileAppSourceFile(entry, { appRoot, outFile })
|
|
127
|
+
const metadata = JSON.parse(fs.readFileSync(`${outFile}.cache.json`, 'utf8')) as {
|
|
128
|
+
dependencies: Record<string, string>
|
|
129
|
+
}
|
|
130
|
+
expect(Object.keys(metadata.dependencies)).toContain('node_modules/legacy-pkg/package.json')
|
|
131
|
+
|
|
132
|
+
const firstOutputMtime = fs.statSync(outFile).mtimeMs
|
|
133
|
+
await compileAppSourceFile(entry, { appRoot, outFile })
|
|
134
|
+
expect(fs.statSync(outFile).mtimeMs).toBe(firstOutputMtime)
|
|
135
|
+
|
|
136
|
+
writePackage(appRoot, 'legacy-pkg', { exports: { './link': './link.js' } }, {})
|
|
137
|
+
await compileAppSourceFile(entry, { appRoot, outFile })
|
|
138
|
+
expect(fs.readFileSync(outFile, 'utf8')).toContain('"legacy-pkg/link"')
|
|
139
|
+
expect(fs.readFileSync(outFile, 'utf8')).not.toContain('"legacy-pkg/link.js"')
|
|
140
|
+
})
|
|
141
|
+
})
|
|
@@ -133,7 +133,7 @@ describe('dynamic loader app tsconfig and content-addressed cache', () => {
|
|
|
133
133
|
loadBootstrapDataInNode(appRoot)
|
|
134
134
|
const after = readCacheMetadata(appRoot, 'entities')
|
|
135
135
|
|
|
136
|
-
expect(after.version).toBe(
|
|
136
|
+
expect(after.version).toBe(5)
|
|
137
137
|
expect(after.inputHash).not.toBe(before.inputHash)
|
|
138
138
|
})
|
|
139
139
|
|
|
@@ -287,6 +287,6 @@ describe('dynamic loader app tsconfig and content-addressed cache', () => {
|
|
|
287
287
|
|
|
288
288
|
loadBootstrapDataInNode(appRoot)
|
|
289
289
|
|
|
290
|
-
expect(readCacheMetadata(appRoot, 'entities').version).toBe(
|
|
290
|
+
expect(readCacheMetadata(appRoot, 'entities').version).toBe(5)
|
|
291
291
|
})
|
|
292
292
|
})
|
|
@@ -62,7 +62,8 @@ describe('partitioned bootstrap registration', () => {
|
|
|
62
62
|
})
|
|
63
63
|
|
|
64
64
|
it('keeps API-only bootstrap from replacing core injection widgets', async () => {
|
|
65
|
-
const
|
|
65
|
+
const entry = { moduleId: 'example', key: 'example:widget', source: 'app' as const, loader: async () => ({}) as never }
|
|
66
|
+
const apiBootstrap = createBootstrap({ ...emptyBootstrapData, injectionWidgetEntries: [entry] }, {
|
|
66
67
|
registrationKey: 'api-only',
|
|
67
68
|
skipUiRegistries: true,
|
|
68
69
|
skipCoreInjectionWidgets: true,
|
|
@@ -71,11 +72,22 @@ describe('partitioned bootstrap registration', () => {
|
|
|
71
72
|
apiBootstrap()
|
|
72
73
|
await waitForAsyncRegistration()
|
|
73
74
|
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
// The API partition still contributes its entries — module API routes resolve widget
|
|
76
|
+
// contributions on the request path — but in `merge` mode, so it can never shrink a
|
|
77
|
+
// registry the full page bootstrap already published in the same process.
|
|
78
|
+
expect(registerCoreInjectionWidgetsMock).toHaveBeenCalledWith([entry], { mode: 'merge' })
|
|
79
|
+
// The raw widget entries also travel with the tables so a `key`-spelled injection
|
|
80
|
+
// override resolves to the `widgetId` the slots reference (#5152).
|
|
81
|
+
expect(registerCoreInjectionTablesMock).toHaveBeenCalledWith([], [entry])
|
|
79
82
|
expect(registerEnabledModuleIdsMock).toHaveBeenCalledTimes(1)
|
|
80
83
|
})
|
|
84
|
+
|
|
85
|
+
it('lets the full bootstrap replace the registry outright', async () => {
|
|
86
|
+
const fullBootstrap = createBootstrap(emptyBootstrapData, { registrationKey: 'full-only' })
|
|
87
|
+
|
|
88
|
+
fullBootstrap()
|
|
89
|
+
await waitForAsyncRegistration()
|
|
90
|
+
|
|
91
|
+
expect(registerCoreInjectionWidgetsMock).toHaveBeenCalledWith([], undefined)
|
|
92
|
+
})
|
|
81
93
|
})
|
|
@@ -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
|
+
})
|
|
@@ -16,7 +16,7 @@ import { CLIENT_ONLY_STUB_NAMESPACE, createClientOnlyStubPlugin } from './client
|
|
|
16
16
|
import path from 'node:path'
|
|
17
17
|
import fs from 'node:fs'
|
|
18
18
|
import crypto from 'node:crypto'
|
|
19
|
-
import { createRequire } from 'node:module'
|
|
19
|
+
import { createRequire, isBuiltin } from 'node:module'
|
|
20
20
|
import { pathToFileURL } from 'node:url'
|
|
21
21
|
|
|
22
22
|
let activeBootstrapLoads = 0
|
|
@@ -77,6 +77,44 @@ class GeneratedFileNotFoundError extends Error {
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
function splitBareSpecifier(specifier: string): { packageName: string; subpath: string } | null {
|
|
81
|
+
const segments = specifier.split('/')
|
|
82
|
+
const nameLength = specifier.startsWith('@') ? 2 : 1
|
|
83
|
+
if (segments.length <= nameLength || segments.slice(0, nameLength).some((segment) => !segment)) return null
|
|
84
|
+
return {
|
|
85
|
+
packageName: segments.slice(0, nameLength).join('/'),
|
|
86
|
+
subpath: segments.slice(nameLength).join('/'),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Rewrites an externalized `pkg/subpath` specifier to the file Node's ESM resolver can load
|
|
92
|
+
* when `pkg` publishes no `exports` map (#6993). Builtins, packages with `exports`, subpaths
|
|
93
|
+
* that already carry an extension and anything that fails to resolve keep the original
|
|
94
|
+
* specifier, so the bundle never gets worse than before.
|
|
95
|
+
*/
|
|
96
|
+
function rewriteLegacySubpathSpecifier(
|
|
97
|
+
specifier: string,
|
|
98
|
+
appRequire: NodeRequire,
|
|
99
|
+
onRewrite: (manifestPath: string) => void,
|
|
100
|
+
): string {
|
|
101
|
+
if (isBuiltin(specifier)) return specifier
|
|
102
|
+
const parts = splitBareSpecifier(specifier)
|
|
103
|
+
if (!parts || path.posix.extname(parts.subpath)) return specifier
|
|
104
|
+
try {
|
|
105
|
+
const manifestPath = appRequire.resolve(`${parts.packageName}/package.json`)
|
|
106
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { exports?: unknown }
|
|
107
|
+
if (manifest.exports != null) return specifier
|
|
108
|
+
const resolvedFile = appRequire.resolve(specifier)
|
|
109
|
+
const relativeFile = path.relative(path.dirname(manifestPath), resolvedFile)
|
|
110
|
+
if (!relativeFile || relativeFile.startsWith('..') || path.isAbsolute(relativeFile)) return specifier
|
|
111
|
+
onRewrite(manifestPath)
|
|
112
|
+
return `${parts.packageName}/${relativeFile.split(path.sep).join('/')}`
|
|
113
|
+
} catch {
|
|
114
|
+
return specifier
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
80
118
|
/**
|
|
81
119
|
* esbuild plugins for the CLI bundle, in resolution order. The client-only stub must come
|
|
82
120
|
* first so it wins over the alias and external plugins for `*.client` dynamic imports.
|
|
@@ -85,7 +123,10 @@ class GeneratedFileNotFoundError extends Error {
|
|
|
85
123
|
* `createClientOnlyStubPlugin` in isolation stays green if the plugin is dropped from this
|
|
86
124
|
* list, which would silently reintroduce #4623.
|
|
87
125
|
*/
|
|
88
|
-
export function createCliBundlePlugins(
|
|
126
|
+
export function createCliBundlePlugins(
|
|
127
|
+
appRoot: string,
|
|
128
|
+
options: { onLegacySubpathRewrite?: (manifestPath: string) => void } = {},
|
|
129
|
+
): import('esbuild').Plugin[] {
|
|
89
130
|
// Plugin to resolve the @/ alias the way the app tsconfig maps it:
|
|
90
131
|
// `@/.mercato/*` to the app root, every other `@/*` to the app's src/ directory.
|
|
91
132
|
const aliasPlugin: import('esbuild').Plugin = {
|
|
@@ -113,6 +154,19 @@ export function createCliBundlePlugins(appRoot: string): import('esbuild').Plugi
|
|
|
113
154
|
},
|
|
114
155
|
}
|
|
115
156
|
|
|
157
|
+
// Node's ESM resolver only maps an extensionless package subpath (`next/link`) when the
|
|
158
|
+
// package publishes an `exports` map; legacy packages need the real file path instead.
|
|
159
|
+
const appRequire = createRequire(path.join(appRoot, 'package.json'))
|
|
160
|
+
const externalSpecifierCache = new Map<string, string>()
|
|
161
|
+
const onLegacySubpathRewrite = options.onLegacySubpathRewrite ?? (() => undefined)
|
|
162
|
+
const resolveExternalSpecifier = (specifier: string): string => {
|
|
163
|
+
const cached = externalSpecifierCache.get(specifier)
|
|
164
|
+
if (cached !== undefined) return cached
|
|
165
|
+
const resolved = rewriteLegacySubpathSpecifier(specifier, appRequire, onLegacySubpathRewrite)
|
|
166
|
+
externalSpecifierCache.set(specifier, resolved)
|
|
167
|
+
return resolved
|
|
168
|
+
}
|
|
169
|
+
|
|
116
170
|
// Plugin to mark non-JSON package imports as external
|
|
117
171
|
const externalNonJsonPlugin: import('esbuild').Plugin = {
|
|
118
172
|
name: 'external-non-json',
|
|
@@ -129,7 +183,7 @@ export function createCliBundlePlugins(appRoot: string): import('esbuild').Plugi
|
|
|
129
183
|
return null // Let esbuild handle it
|
|
130
184
|
}
|
|
131
185
|
// Otherwise mark as external
|
|
132
|
-
return { path: args.path, external: true }
|
|
186
|
+
return { path: resolveExternalSpecifier(args.path), external: true }
|
|
133
187
|
})
|
|
134
188
|
},
|
|
135
189
|
}
|
|
@@ -137,7 +191,7 @@ export function createCliBundlePlugins(appRoot: string): import('esbuild').Plugi
|
|
|
137
191
|
return [createClientOnlyStubPlugin(), aliasPlugin, externalNonJsonPlugin]
|
|
138
192
|
}
|
|
139
193
|
|
|
140
|
-
const DYNAMIC_LOADER_CACHE_VERSION =
|
|
194
|
+
const DYNAMIC_LOADER_CACHE_VERSION = 5
|
|
141
195
|
|
|
142
196
|
type DynamicLoaderCacheMetadata = {
|
|
143
197
|
version: number
|
|
@@ -433,6 +487,7 @@ async function compileAppSourceFileWithActiveEsbuild(
|
|
|
433
487
|
// Dynamically import esbuild only when needed
|
|
434
488
|
const esbuild = await getEsbuildRuntime()
|
|
435
489
|
|
|
490
|
+
const rewrittenPackageManifests = new Set<string>()
|
|
436
491
|
// Use esbuild.build with bundling to handle JSON imports
|
|
437
492
|
const result = await esbuild.build({
|
|
438
493
|
entryPoints: [tsPath],
|
|
@@ -444,7 +499,9 @@ async function compileAppSourceFileWithActiveEsbuild(
|
|
|
444
499
|
platform: 'node',
|
|
445
500
|
target: 'node18',
|
|
446
501
|
tsconfig: appTsconfig,
|
|
447
|
-
plugins: createCliBundlePlugins(appRoot
|
|
502
|
+
plugins: createCliBundlePlugins(appRoot, {
|
|
503
|
+
onLegacySubpathRewrite: (manifestPath) => rewrittenPackageManifests.add(manifestPath),
|
|
504
|
+
}),
|
|
448
505
|
// Allow JSON imports
|
|
449
506
|
loader: { '.json': 'json' },
|
|
450
507
|
})
|
|
@@ -455,6 +512,7 @@ async function compileAppSourceFileWithActiveEsbuild(
|
|
|
455
512
|
dependencies: {
|
|
456
513
|
...collectDependencyHashes(appRoot, result.metafile.inputs),
|
|
457
514
|
...hashFilesRelativeTo(appRoot, tsconfigPaths),
|
|
515
|
+
...hashFilesRelativeTo(appRoot, [...rewrittenPackageManifests]),
|
|
458
516
|
},
|
|
459
517
|
}
|
|
460
518
|
fs.writeFileSync(metadataPath, JSON.stringify(metadata))
|
|
@@ -143,9 +143,14 @@ async function registerWidgetsAndOptionalPackages(data: BootstrapData, options:
|
|
|
143
143
|
// browser-facing UI registries. API-only bootstraps avoid loading @open-mercato/ui.
|
|
144
144
|
try {
|
|
145
145
|
const coreInjection = await import('@open-mercato/core/modules/widgets/lib/injection')
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
146
|
+
// A partitioned runtime still needs its own entries — server-side resolvers read this
|
|
147
|
+
// registry on request paths — it just must not shrink what a fuller partition already
|
|
148
|
+
// published in the same process. Merge does both; skipping registration altogether left
|
|
149
|
+
// API-only processes with an unregistered registry that throws on first read.
|
|
150
|
+
coreInjection.registerCoreInjectionWidgets(
|
|
151
|
+
data.injectionWidgetEntries,
|
|
152
|
+
options.skipCoreInjectionWidgets ? { mode: 'merge' } : undefined,
|
|
153
|
+
)
|
|
149
154
|
coreInjection.registerCoreInjectionTables(data.injectionTables, data.injectionWidgetEntries)
|
|
150
155
|
coreInjection.registerEnabledModuleIds(
|
|
151
156
|
data.modules.map((module) => module.id).filter((id): id is string => typeof id === 'string' && id.length > 0),
|
|
@@ -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
|
+
}
|
|
@@ -73,7 +73,11 @@ export interface BootstrapOptions {
|
|
|
73
73
|
registrationKey?: string
|
|
74
74
|
/** Skip browser-facing dashboard/injection registries in API-only runtimes. */
|
|
75
75
|
skipUiRegistries?: boolean
|
|
76
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* Keep an existing core injection-widget registry intact in partitioned runtimes: this
|
|
78
|
+
* bootstrap's entries are merged in rather than published as the whole registry, so a
|
|
79
|
+
* partition that registers second never shrinks what the first one already exposed.
|
|
80
|
+
*/
|
|
77
81
|
skipCoreInjectionWidgets?: boolean
|
|
78
82
|
onRegistrationComplete?: () => void
|
|
79
83
|
appDiRegistrar?: AppDiRegistrar
|