Use swc parse for flight server and client loaders (#33713)
* Add `parse` method for next-swc * Use shared next-swc to parse rsc components AST * Remove the invalid case of parsing `ExportAllDecalaration` (we didn't support it well before, so I deleted. need to support later) Co-authored-by: Donny/강동윤 <29931815+kdy1@users.noreply.github.com> Co-authored-by: JJ Kasper <22380829+ijjk@users.noreply.github.com>
This commit is contained in:
parent
8a1c947df1
commit
3bda6e66b6
12 changed files with 217 additions and 84 deletions
|
@ -44,7 +44,7 @@ mod bundle;
|
|||
mod minify;
|
||||
mod transform;
|
||||
mod util;
|
||||
|
||||
mod parse;
|
||||
|
||||
static COMPILER: Lazy<Arc<Compiler>> = Lazy::new(|| {
|
||||
let cm = Arc::new(SourceMap::new(FilePathMapping::empty()));
|
||||
|
@ -68,6 +68,8 @@ fn init(mut exports: JsObject) -> napi::Result<()> {
|
|||
|
||||
exports.create_named_method("minify", minify::minify)?;
|
||||
exports.create_named_method("minifySync", minify::minify_sync)?;
|
||||
|
||||
exports.create_named_method("parse", parse::parse)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
70
packages/next-swc/crates/napi/src/parse.rs
Normal file
70
packages/next-swc/crates/napi/src/parse.rs
Normal file
|
@ -0,0 +1,70 @@
|
|||
use crate::util::{deserialize_json, CtxtExt, MapErr};
|
||||
use anyhow::Context as _;
|
||||
use napi::{CallContext, Either, Env, JsObject, JsString, JsUndefined, Task};
|
||||
use std::sync::Arc;
|
||||
use swc::{config::ParseOptions, try_with_handler};
|
||||
use swc_common::{FileName, FilePathMapping, SourceMap};
|
||||
|
||||
pub struct ParseTask {
|
||||
pub filename: FileName,
|
||||
pub src: String,
|
||||
pub options: String,
|
||||
}
|
||||
|
||||
pub fn complete_parse<'a>(env: &Env, ast_json: String) -> napi::Result<JsString> {
|
||||
env.create_string_from_std(ast_json)
|
||||
}
|
||||
|
||||
impl Task for ParseTask {
|
||||
type Output = String;
|
||||
type JsValue = JsString;
|
||||
|
||||
fn compute(&mut self) -> napi::Result<Self::Output> {
|
||||
let c = swc::Compiler::new(Arc::new(SourceMap::new(FilePathMapping::empty())));
|
||||
|
||||
let options: ParseOptions = deserialize_json(&self.options).convert_err()?;
|
||||
let fm =
|
||||
c.cm.new_source_file(self.filename.clone(), self.src.clone());
|
||||
let program = try_with_handler(c.cm.clone(), false, |handler| {
|
||||
c.parse_js(
|
||||
fm,
|
||||
&handler,
|
||||
options.target,
|
||||
options.syntax,
|
||||
options.is_module,
|
||||
options.comments,
|
||||
)
|
||||
})
|
||||
.convert_err()?;
|
||||
|
||||
let ast_json = serde_json::to_string(&program)
|
||||
.context("failed to serialize Program")
|
||||
.convert_err()?;
|
||||
|
||||
Ok(ast_json)
|
||||
}
|
||||
|
||||
fn resolve(self, env: Env, result: Self::Output) -> napi::Result<Self::JsValue> {
|
||||
complete_parse(&env, result)
|
||||
}
|
||||
}
|
||||
|
||||
#[js_function(3)]
|
||||
pub fn parse(ctx: CallContext) -> napi::Result<JsObject> {
|
||||
let src = ctx.get::<JsString>(0)?.into_utf8()?.as_str()?.to_string();
|
||||
let options = ctx.get_buffer_as_string(1)?;
|
||||
let filename = ctx.get::<Either<JsString, JsUndefined>>(2)?;
|
||||
let filename = if let Either::A(value) = filename {
|
||||
FileName::Real(value.into_utf8()?.as_str()?.to_owned().into())
|
||||
} else {
|
||||
FileName::Anon
|
||||
};
|
||||
|
||||
ctx.env
|
||||
.spawn(ParseTask {
|
||||
filename,
|
||||
src,
|
||||
options,
|
||||
})
|
||||
.map(|t| t.promise_object())
|
||||
}
|
|
@ -72,6 +72,9 @@ async function loadWasm() {
|
|||
minify(src, options) {
|
||||
return Promise.resolve(bindings.minifySync(src.toString(), options))
|
||||
},
|
||||
parse(src, options) {
|
||||
return Promise.resolve(bindings.parse(src.toString(), options))
|
||||
},
|
||||
}
|
||||
return wasmBindings
|
||||
} catch (e) {
|
||||
|
@ -179,6 +182,10 @@ function loadNative() {
|
|||
bundle(options) {
|
||||
return bindings.bundle(toBuffer(options))
|
||||
},
|
||||
|
||||
parse(src, options) {
|
||||
return bindings.parse(src, toBuffer(options ?? {}))
|
||||
},
|
||||
}
|
||||
return nativeBindings
|
||||
}
|
||||
|
@ -219,3 +226,8 @@ export async function bundle(options) {
|
|||
let bindings = loadBindingsSync()
|
||||
return bindings.bundle(toBuffer(options))
|
||||
}
|
||||
|
||||
export async function parse(src, options) {
|
||||
let bindings = loadBindingsSync()
|
||||
return bindings.parse(src, options).then((astStr) => JSON.parse(astStr))
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ const regeneratorRuntimePath = require.resolve(
|
|||
'next/dist/compiled/regenerator-runtime'
|
||||
)
|
||||
|
||||
function getBaseSWCOptions({
|
||||
export function getBaseSWCOptions({
|
||||
filename,
|
||||
jest,
|
||||
development,
|
||||
|
@ -45,9 +45,9 @@ function getBaseSWCOptions({
|
|||
pragma: 'React.createElement',
|
||||
pragmaFrag: 'React.Fragment',
|
||||
throwIfNamespace: true,
|
||||
development: development,
|
||||
development: !!development,
|
||||
useBuiltins: true,
|
||||
refresh: hasReactRefresh,
|
||||
refresh: !!hasReactRefresh,
|
||||
},
|
||||
optimizer: {
|
||||
simplify: false,
|
||||
|
|
|
@ -5,29 +5,16 @@
|
|||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import * as acorn from 'next/dist/compiled/acorn'
|
||||
|
||||
type ResolveContext = {
|
||||
conditions: Array<string>
|
||||
parentURL: string | void
|
||||
}
|
||||
|
||||
type ResolveFunction = (
|
||||
specifier: string,
|
||||
context: ResolveContext,
|
||||
resolve: ResolveFunction
|
||||
) => { url: string } | Promise<{ url: string }>
|
||||
|
||||
type TransformSourceFunction = (url: string, callback: () => void) => void
|
||||
|
||||
type Source = string | ArrayBuffer | Uint8Array
|
||||
|
||||
let stashedResolve: null | ResolveFunction = null
|
||||
// TODO: add ts support for next-swc api
|
||||
// @ts-ignore
|
||||
import { parse } from '../../swc'
|
||||
// @ts-ignore
|
||||
import { getBaseSWCOptions } from '../../swc/options'
|
||||
|
||||
function addExportNames(names: string[], node: any) {
|
||||
switch (node.type) {
|
||||
case 'Identifier':
|
||||
names.push(node.name)
|
||||
names.push(node.value)
|
||||
return
|
||||
case 'ObjectPattern':
|
||||
for (let i = 0; i < node.properties.length; i++)
|
||||
|
@ -56,50 +43,25 @@ function addExportNames(names: string[], node: any) {
|
|||
}
|
||||
}
|
||||
|
||||
function resolveClientImport(
|
||||
specifier: string,
|
||||
parentURL: string
|
||||
): { url: string } | Promise<{ url: string }> {
|
||||
// Resolve an import specifier as if it was loaded by the client. This doesn't use
|
||||
// the overrides that this loader does but instead reverts to the default.
|
||||
// This resolution algorithm will not necessarily have the same configuration
|
||||
// as the actual client loader. It should mostly work and if it doesn't you can
|
||||
// always convert to explicit exported names instead.
|
||||
const conditions = ['node', 'import']
|
||||
if (stashedResolve === null) {
|
||||
throw new Error(
|
||||
'Expected resolve to have been called before transformSource'
|
||||
)
|
||||
}
|
||||
return stashedResolve(specifier, { conditions, parentURL }, stashedResolve)
|
||||
}
|
||||
|
||||
async function parseExportNamesInto(
|
||||
resourcePath: string,
|
||||
transformedSource: string,
|
||||
names: Array<string>,
|
||||
parentURL: string,
|
||||
loadModule: TransformSourceFunction
|
||||
names: Array<string>
|
||||
): Promise<void> {
|
||||
const { body } = acorn.parse(transformedSource, {
|
||||
ecmaVersion: 11,
|
||||
sourceType: 'module',
|
||||
}) as any
|
||||
const opts = getBaseSWCOptions({
|
||||
filename: resourcePath,
|
||||
globalWindow: true,
|
||||
})
|
||||
|
||||
const { body } = await parse(transformedSource, {
|
||||
...opts.jsc.parser,
|
||||
isModule: true,
|
||||
})
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const node = body[i]
|
||||
switch (node.type) {
|
||||
case 'ExportAllDeclaration':
|
||||
if (node.exported) {
|
||||
addExportNames(names, node.exported)
|
||||
continue
|
||||
} else {
|
||||
const { url } = await resolveClientImport(
|
||||
node.source.value,
|
||||
parentURL
|
||||
)
|
||||
const source = ''
|
||||
parseExportNamesInto(source, names, url, loadModule)
|
||||
continue
|
||||
}
|
||||
// TODO: support export * from module path
|
||||
// case 'ExportAllDeclaration':
|
||||
case 'ExportDefaultDeclaration':
|
||||
names.push('default')
|
||||
continue
|
||||
|
@ -129,8 +91,8 @@ async function parseExportNamesInto(
|
|||
|
||||
export default async function transformSource(
|
||||
this: any,
|
||||
source: Source
|
||||
): Promise<Source> {
|
||||
source: string
|
||||
): Promise<string> {
|
||||
const { resourcePath, resourceQuery } = this
|
||||
|
||||
if (resourceQuery !== '?flight') return source
|
||||
|
@ -142,12 +104,7 @@ export default async function transformSource(
|
|||
}
|
||||
|
||||
const names: string[] = []
|
||||
await parseExportNamesInto(
|
||||
transformedSource as string,
|
||||
names,
|
||||
url + resourceQuery,
|
||||
this.loadModule
|
||||
)
|
||||
await parseExportNamesInto(resourcePath, transformedSource, names)
|
||||
|
||||
// next.js/packages/next/<component>.js
|
||||
if (/[\\/]next[\\/](link|image)\.js$/.test(url)) {
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
import * as acorn from 'next/dist/compiled/acorn'
|
||||
// TODO: add ts support for next-swc api
|
||||
// @ts-ignore
|
||||
import { parse } from '../../swc'
|
||||
// @ts-ignore
|
||||
import { getBaseSWCOptions } from '../../swc/options'
|
||||
import { getRawPageExtensions } from '../../utils'
|
||||
|
||||
function isClientComponent(importSource: string, pageExtensions: string[]) {
|
||||
|
@ -28,6 +32,7 @@ export function isImageImport(importSource: string) {
|
|||
}
|
||||
|
||||
async function parseImportsInfo(
|
||||
resourcePath: string,
|
||||
source: string,
|
||||
imports: Array<string>,
|
||||
isClientCompilation: boolean,
|
||||
|
@ -36,21 +41,22 @@ async function parseImportsInfo(
|
|||
source: string
|
||||
defaultExportName: string
|
||||
}> {
|
||||
const { body } = acorn.parse(source, {
|
||||
ecmaVersion: 11,
|
||||
sourceType: 'module',
|
||||
}) as any
|
||||
const opts = getBaseSWCOptions({
|
||||
filename: resourcePath,
|
||||
globalWindow: isClientCompilation,
|
||||
})
|
||||
|
||||
const ast = await parse(source, { ...opts.jsc.parser, isModule: true })
|
||||
const { body } = ast
|
||||
const beginPos = ast.span.start
|
||||
let transformedSource = ''
|
||||
let lastIndex = 0
|
||||
let defaultExportName = 'RSCComponent'
|
||||
|
||||
let defaultExportName
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const node = body[i]
|
||||
switch (node.type) {
|
||||
case 'ImportDeclaration': {
|
||||
const importSource = node.source.value
|
||||
|
||||
if (!isClientCompilation) {
|
||||
if (
|
||||
!(
|
||||
|
@ -61,10 +67,11 @@ async function parseImportsInfo(
|
|||
) {
|
||||
continue
|
||||
}
|
||||
transformedSource += source.substring(
|
||||
const importDeclarations = source.substring(
|
||||
lastIndex,
|
||||
node.source.start - 1
|
||||
node.source.span.start - beginPos
|
||||
)
|
||||
transformedSource += importDeclarations
|
||||
transformedSource += JSON.stringify(`${node.source.value}?flight`)
|
||||
} else {
|
||||
// For the client compilation, we skip all modules imports but
|
||||
|
@ -84,16 +91,16 @@ async function parseImportsInfo(
|
|||
}
|
||||
}
|
||||
|
||||
lastIndex = node.source.end
|
||||
lastIndex = node.source.span.end - beginPos
|
||||
imports.push(`require(${JSON.stringify(importSource)})`)
|
||||
continue
|
||||
}
|
||||
case 'ExportDefaultDeclaration': {
|
||||
const def = node.declaration
|
||||
const def = node.decl
|
||||
if (def.type === 'Identifier') {
|
||||
defaultExportName = def.name
|
||||
} else if (def.type === 'FunctionDeclaration') {
|
||||
defaultExportName = def.id.name
|
||||
} else if (def.type === 'FunctionExpression') {
|
||||
defaultExportName = def.identifier.value
|
||||
}
|
||||
break
|
||||
}
|
||||
|
@ -129,6 +136,7 @@ export default async function transformSource(
|
|||
const imports: string[] = []
|
||||
const { source: transformedSource, defaultExportName } =
|
||||
await parseImportsInfo(
|
||||
resourcePath,
|
||||
source,
|
||||
imports,
|
||||
isClientCompilation,
|
||||
|
@ -150,7 +158,9 @@ export default async function transformSource(
|
|||
const noop = `export const __rsc_noop__=()=>{${imports.join(';')}}`
|
||||
const defaultExportNoop = isClientCompilation
|
||||
? `export default function ${defaultExportName}(){}\n${defaultExportName}.__next_rsc__=1;`
|
||||
: `${defaultExportName}.__next_rsc__=1;`
|
||||
: defaultExportName
|
||||
? `${defaultExportName}.__next_rsc__=1;`
|
||||
: ''
|
||||
|
||||
const transformed = transformedSource + '\n' + noop + '\n' + defaultExportNoop
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export * from './client-exports'
|
20
test/integration/react-streaming-and-server-components/app/components/client-exports-all.js
vendored
Normal file
20
test/integration/react-streaming-and-server-components/app/components/client-exports-all.js
vendored
Normal file
|
@ -0,0 +1,20 @@
|
|||
export * from './client-exports'
|
||||
|
||||
// TODO: add exports all test case in pages
|
||||
/**
|
||||
|
||||
import * as all from '../components/client-exports-all'
|
||||
import * as allClient from '../components/client-exports-all.client'
|
||||
|
||||
export default function Page() {
|
||||
const { a, b, c, d, e } = all
|
||||
const { a: ac, b: bc, c: cc, d: dc, e: ec } = allClient
|
||||
return (
|
||||
<div>
|
||||
<div id='server'>{a}{b}{c}{d}{e[0]}</div>
|
||||
<div id='client'>{ac}{bc}{cc}{dc}{ec[0]}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
*/
|
10
test/integration/react-streaming-and-server-components/app/components/client-exports.js
vendored
Normal file
10
test/integration/react-streaming-and-server-components/app/components/client-exports.js
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
const a = 'a'
|
||||
const b = 'b'
|
||||
const _c = 'c'
|
||||
const _d = 'd'
|
||||
const _e = 'e'
|
||||
const _eArr = [_e]
|
||||
|
||||
export const c = _c
|
||||
export { a, b }
|
||||
export { _d as d, _eArr as e }
|
|
@ -0,0 +1,25 @@
|
|||
import * as all from '../components/client-exports-all'
|
||||
import * as allClient from '../components/client-exports-all.client'
|
||||
|
||||
export default function Page() {
|
||||
const { a, b, c, d, e } = all
|
||||
const { a: ac, b: bc, c: cc, d: dc, e: ec } = allClient
|
||||
return (
|
||||
<div>
|
||||
<div id="server">
|
||||
{a}
|
||||
{b}
|
||||
{c}
|
||||
{d}
|
||||
{e[0]}
|
||||
</div>
|
||||
<div id="client">
|
||||
{ac}
|
||||
{bc}
|
||||
{cc}
|
||||
{dc}
|
||||
{ec[0]}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import { a, b, c, d, e } from '../components/client-exports'
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div>
|
||||
{a}
|
||||
{b}
|
||||
{c}
|
||||
{d}
|
||||
{e[0]}
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -400,6 +400,19 @@ async function runBasicTests(context, env) {
|
|||
expect(imageTag.attr('src')).toContain('data:image')
|
||||
})
|
||||
|
||||
it('should handle multiple named exports correctly', async () => {
|
||||
const clientExportsHTML = await renderViaHTTP(
|
||||
context.appPort,
|
||||
'/client-exports'
|
||||
)
|
||||
const $clientExports = cheerio.load(clientExportsHTML)
|
||||
expect($clientExports('div[hidden] > div').text()).toBe('abcde')
|
||||
|
||||
const browser = await webdriver(context.appPort, '/client-exports')
|
||||
const text = await browser.waitForElementByCss('#__next').text()
|
||||
expect(text).toBe('abcde')
|
||||
})
|
||||
|
||||
it('should support multi-level server component imports', async () => {
|
||||
const html = await renderViaHTTP(context.appPort, '/multi')
|
||||
expect(html).toContain('bar.server.js:')
|
||||
|
|
Loading…
Reference in a new issue