Skip to content

Commit 931ff60

Browse files
committed
feat: support auto detect native modules
1 parent 203035d commit 931ff60

File tree

2 files changed

+112
-38
lines changed

2 files changed

+112
-38
lines changed

src/index.ts

Lines changed: 72 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,15 @@ import {
99
import type { Configuration } from 'webpack'
1010
import type { NodeLoaderOptions, WebpackAssetRelocatorLoader } from './types'
1111
import { COLOURS } from 'vite-plugin-utils/function'
12-
import { createCjs, ensureDir, getInteropSnippet, getNatives } from './utils'
12+
import {
13+
type NativeRecord,
14+
type NativeRecordType,
15+
createCjs,
16+
ensureDir,
17+
getInteropSnippet,
18+
getDependenciesNatives,
19+
resolveNativeRecord,
20+
} from './utils'
1321

1422
export interface NativeOptions {
1523
/** @default 'node_natives' */
@@ -29,11 +37,15 @@ const TAG = '[vite-plugin-native]'
2937
const loader1 = '@vercel/webpack-asset-relocator-loader'
3038
const NativeExt = '.native.cjs'
3139
const InteropExt = '.interop.mjs'
40+
// https://github.com/vitejs/vite/blob/v5.3.1/packages/vite/src/node/plugins/index.ts#L55
41+
const bareImportRE = /^(?![a-zA-Z]:)[\w@](?!.*:\/\/)/
3242
// `nativesMap` is placed in the global scope and can be effective for multiple builds.
3343
const nativesMap = new Map<string, {
34-
built: boolean
44+
status: 'built' | 'resolved'
3545
nativeFilename: string
3646
interopFilename: string
47+
type: NativeRecordType
48+
nodeFiles: string[]
3749
}>
3850

3951
export default function native(options: NativeOptions): Plugin {
@@ -49,64 +61,91 @@ export default function native(options: NativeOptions): Plugin {
4961
const outDir = config.build?.outDir ?? 'dist'
5062
output = normalizePath(path.join(resolvedRoot, outDir, assetsDir))
5163

52-
const natives = await getNatives(resolvedRoot)
53-
options.natives ??= natives
64+
const depsNativeRecord = await getDependenciesNatives(resolvedRoot)
65+
const depsNatives = [...depsNativeRecord.keys()]
5466

55-
if (typeof options.natives === 'function') {
56-
options.natives = options.natives(natives)
67+
if (options.natives) {
68+
const natives = Array.isArray(options.natives)
69+
? options.natives
70+
: options.natives(depsNatives)
71+
// TODO: bundle modules based on `natives`.
5772
}
5873

59-
const aliases: Alias[] = []
6074
const withDistAssetBase = (p: string) => (assetsDir && p) ? `${assetsDir}/${p}` : p
6175

62-
options.natives.length && ensureDir(output)
76+
let detectedNativeRecord: NativeRecord = new Map
77+
let detectedNatives: string[] = []
78+
79+
const alias: Alias = {
80+
find: /(.*)/,
81+
// Keep `customResolver` receive original source.
82+
// @see https://github.com/rollup/plugins/blob/alias-v5.1.0/packages/alias/src/index.ts#L92
83+
replacement: '$1',
84+
async customResolver(source, importer) {
85+
if (!importer) return
86+
if (!bareImportRE.test(source)) return
87+
88+
if (!depsNativeRecord.has(source)) {
89+
// Auto detection.
90+
// e.g. serialport -> @serialport/bindings-cpp
91+
const nativeRecord = await resolveNativeRecord(source, importer)
92+
if (nativeRecord) {
93+
detectedNativeRecord = new Map([...detectedNativeRecord, ...nativeRecord])
94+
detectedNatives = [...depsNativeRecord.keys()]
95+
}
96+
}
97+
98+
if ([...depsNatives, ...detectedNatives].includes(source)) {
99+
const nativeFilename = path.join(output, source + NativeExt)
100+
const interopFilename = path.join(output, source + InteropExt)
63101

64-
for (const native of options.natives) {
65-
const nativeFilename = path.join(output, native + NativeExt)
66-
const interopFilename = path.join(output, native + InteropExt)
102+
if (!nativesMap.get(source)) {
103+
ensureDir(output)
67104

68-
aliases.push({
69-
find: native,
70-
replacement: interopFilename,
71-
customResolver(source) {
72-
const record = nativesMap.get(native)
73-
if (!record?.built) {
74105
// Generate Vite and Webpack interop file.
75-
const code = getInteropSnippet(native, `./${withDistAssetBase(native + NativeExt)}`)
76-
fs.writeFileSync(interopFilename, code)
106+
fs.writeFileSync(
107+
interopFilename,
108+
getInteropSnippet(source, `./${withDistAssetBase(source + NativeExt)}`),
109+
)
77110

78111
// We did not immediately call the `webpackBundle()` build here
79112
// because `build.emptyOutDir = true` will cause the built file to be removed.
80113

114+
const isDetected = detectedNativeRecord.has(source)
115+
81116
// Collect modules that are explicitly used.
82-
nativesMap.set(native, { built: false, nativeFilename, interopFilename })
117+
nativesMap.set(source, {
118+
status: 'resolved',
119+
nativeFilename,
120+
interopFilename,
121+
type: isDetected ? 'detected' : 'dependencies',
122+
nodeFiles: isDetected
123+
? detectedNativeRecord.get(source)?.nativeFiles!
124+
: depsNativeRecord.get(source)?.nativeFiles!,
125+
})
83126
}
84127

85-
return { id: source }
86-
},
87-
})
128+
return { id: interopFilename }
129+
}
130+
},
88131
}
89132

90-
const aliasKeys = aliases.map(({ find }) => find as string)
91-
92-
modifyAlias(config, aliases)
133+
modifyAlias(config, [alias])
93134
// Run build are not necessary.
94-
modifyOptimizeDeps(config, aliasKeys)
95-
},
96-
resolveId() {
97-
// TODO: dynamic detect by bare moduleId. e.g. serialport
135+
modifyOptimizeDeps(config, [...depsNatives, ...detectedNatives])
98136
},
99137
async buildEnd(error) {
100138
if (error) return
101139

102140
// Must be explicitly specify use Webpack.
103141
if (options.webpack) {
104142
for (const [native, info] of nativesMap) {
105-
if (info.built) continue
143+
if (info.status === 'built') continue
106144

107145
try {
108146
await webpackBundle(native, output, options.webpack)
109-
info.built = true
147+
// TODO: force copy *.node files to dist/node_modules path if Webpack can't bundle it correctly.
148+
info.status = 'built'
110149
} catch (error: any) {
111150
console.error(`\n${TAG}`, error)
112151
process.exit(1)
@@ -148,7 +187,7 @@ async function webpackBundle(
148187
webpackOpts: NonNullable<NativeOptions['webpack']>
149188
) {
150189
webpackOpts[loader1] ??= {}
151-
const { validate, webpack } = cjs.require('webpack') as typeof import('webpack');
190+
const { validate, webpack } = cjs.require('webpack') as typeof import('webpack')
152191
const assetBase = webpackOpts[loader1].outputAssetBase ??= 'native_modules'
153192

154193
return new Promise<null>(async (resolve, reject) => {

src/utils.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ import glob from 'fast-glob'
66
import _libEsm from 'lib-esm'
77
import { node_modules as findNodeModules } from 'vite-plugin-utils/function'
88

9+
export type NativeRecordType = 'dependencies' | 'detected'
10+
export type NativeRecord = Map<string, {
11+
type: NativeRecordType
12+
path: string
13+
nativeFiles: string[]
14+
}>
15+
916
// @ts-ignore
1017
const libEsm: typeof import('lib-esm').default = _libEsm.default || _libEsm
1118
const cjs = createCjs(import.meta.url)
@@ -26,10 +33,16 @@ export function createCjs(url = import.meta.url) {
2633
}
2734
}
2835

29-
export async function getNatives(root = process.cwd()) {
36+
export async function globNativeFiles(cwd: string) {
37+
// @see https://github.com/electron/forge/blob/v7.4.0/packages/plugin/webpack/src/WebpackPlugin.ts#L192
38+
const nativeFiles = await glob('**/*.node', { cwd })
39+
return nativeFiles
40+
}
41+
42+
export async function getDependenciesNatives(root = process.cwd()): Promise<NativeRecord> {
3043
const node_modules_paths = findNodeModules(root)
3144
// Native modules of package.json
32-
const natives = []
45+
const natives: NativeRecord = new Map
3346

3447
for (const node_modules_path of node_modules_paths) {
3548
const pkgId = path.join(node_modules_path, '../package.json')
@@ -40,11 +53,14 @@ export async function getNatives(root = process.cwd()) {
4053

4154
for (const dep of deps) {
4255
const depPath = path.join(node_modules_path, dep)
43-
// @see https://github.com/electron/forge/blob/v7.4.0/packages/plugin/webpack/src/WebpackPlugin.ts#L192
44-
const nativeFiles = await glob('**/*.node', { cwd: depPath })
56+
const nativeFiles = await globNativeFiles(depPath)
4557

4658
if (nativeFiles.length) {
47-
natives.push(dep)
59+
natives.set(dep, {
60+
type: 'dependencies',
61+
path: depPath,
62+
nativeFiles,
63+
})
4864
}
4965
}
5066
}
@@ -73,3 +89,22 @@ export function ensureDir(dir: string) {
7389
}
7490
return dir
7591
}
92+
93+
export async function resolveNativeRecord(source: string, importer: string): Promise<NativeRecord | undefined> {
94+
let modulePath: string | undefined
95+
try {
96+
const modulePackageJson = cjs.require.resolve(`${source}/package.json`, {
97+
paths: [importer],
98+
})
99+
modulePath = path.dirname(modulePackageJson)
100+
} catch { }
101+
102+
if (modulePath) {
103+
const nodeFiles = await globNativeFiles(modulePath)
104+
return new Map().set(source, {
105+
type: 'deep-dependencies',
106+
path: modulePath,
107+
nodeFiles,
108+
})
109+
}
110+
}

0 commit comments

Comments
 (0)