11import { dirname , posix , relative , resolve , sep } from 'path' ;
22import type polka from 'polka' ;
3- import type { SourceDescription } from 'rollup' ;
3+ import type {
4+ PartialResolvedId ,
5+ ResolveIdResult ,
6+ SourceDescription ,
7+ } from 'rollup' ;
48import type { Plugin } from '../plugin' ;
59import { createPluginContainer } from '../rollup-plugin-container' ;
610import { promises as fs } from 'fs' ;
@@ -11,29 +15,54 @@ import type {
1115} from '@ampproject/remapping/dist/types/types' ;
1216import MagicString from 'magic-string' ;
1317import { jsExts } from '../extensions-and-detection' ;
14- import * as esbuild from 'esbuild' ;
15- import { createCodeFrame } from 'simple-code-frame' ;
16- import * as colors from 'kolorist' ;
17- import { Console } from 'console' ;
18+ import { rejectBuild } from '../build-status-tracker' ;
19+ import { ErrorWithLocation } from '../error-with-location' ;
1820
1921interface JSMiddlewareOpts {
2022 root : string ;
2123 plugins : Plugin [ ] ;
2224 requestCache : Map < string , SourceDescription > ;
2325}
2426
27+ const getResolveCacheKey = ( spec : string , from : string ) =>
28+ `${ spec } %%FROM%%${ from } ` ;
29+
2530// Minimal version of https://github.com/preactjs/wmr/blob/main/packages/wmr/src/wmr-middleware.js
2631
2732export const jsMiddleware = ( {
2833 root,
2934 plugins,
3035 requestCache,
3136} : JSMiddlewareOpts ) : polka . Middleware => {
37+ interface ResolveCacheEntry {
38+ buildId : number ;
39+ resolved : PartialResolvedId ;
40+ }
41+ /**
42+ * The resolve cache is used so that if something has already been resolved from a previous build,
43+ * the buildId from the previous build gets used rather than the current buildId.
44+ * That way, modules can get correctly deduped in the browser,
45+ * and syntax/transform errors will get thrown from the _first_ runJS/loadJS that they were imported from.
46+ */
47+ const resolveCache = new Map < string , ResolveCacheEntry > ( ) ;
48+
49+ const setInResolveCache = (
50+ spec : string ,
51+ from : string ,
52+ buildId : number ,
53+ resolved : PartialResolvedId ,
54+ ) => resolveCache . set ( getResolveCacheKey ( spec , from ) , { buildId, resolved } ) ;
55+
56+ const getFromResolveCache = ( spec : string , from : string ) =>
57+ resolveCache . get ( getResolveCacheKey ( spec , from ) ) ;
58+
3259 const rollupPlugins = createPluginContainer ( plugins ) ;
3360
3461 rollupPlugins . buildStart ( ) ;
3562
3663 return async ( req , res , next ) => {
64+ const buildId =
65+ req . query [ 'build-id' ] !== undefined && Number ( req . query [ 'build-id' ] ) ;
3766 try {
3867 // Normalized path starting with slash
3968 const path = posix . normalize ( req . path ) ;
@@ -53,6 +82,7 @@ export const jsMiddleware = ({
5382 const params = new URLSearchParams ( req . query as Record < string , string > ) ;
5483 params . delete ( 'import' ) ;
5584 params . delete ( 'inline-code' ) ;
85+ params . delete ( 'build-id' ) ;
5686
5787 // Remove trailing =
5888 // This is necessary for rollup-plugin-vue, which ads ?lang.ts at the end of the id,
@@ -111,14 +141,37 @@ export const jsMiddleware = ({
111141 // Resolve all the imports and replace them, and inline the resulting resolved paths
112142 // This makes different ways of importing the same path (e.g. extensionless imports, etc.)
113143 // all dedupe to the same module so it is only executed once
114- code = await transformImports ( code , id , {
144+ code = await transformImports ( code , id , map , {
115145 async resolveId ( spec ) {
146+ const addBuildId = ( specifier : string ) => {
147+ const delimiter = / \? / . test ( specifier ) ? '&' : '?' ;
148+ return `${ specifier } ${ delimiter } build-id=${ localBuildId } ` ;
149+ } ;
150+
151+ // Default to the buildId corresponding to this module
152+ // But for any module which has previously been imported from another buildId,
153+ // Use the previous buildId (for module deduplication in the browser)
154+ let localBuildId = buildId ;
116155 if ( / ^ ( d a t a : | h t t p s ? : | \/ \/ ) / . test ( spec ) ) return spec ;
117156
118- const resolved = await rollupPlugins . resolveId ( spec , file ) ;
157+ const cached = getFromResolveCache ( spec , file ) ;
158+ let resolved : ResolveIdResult ;
159+ if ( cached ) {
160+ resolved = cached . resolved ;
161+ localBuildId = cached . buildId ;
162+ } else {
163+ resolved = await rollupPlugins . resolveId ( spec , file ) ;
164+ if ( resolved && buildId )
165+ setInResolveCache (
166+ spec ,
167+ file ,
168+ buildId ,
169+ typeof resolved === 'object' ? resolved : { id : resolved } ,
170+ ) ;
171+ }
119172 if ( resolved ) {
120173 spec = typeof resolved === 'object' ? resolved . id : resolved ;
121- if ( spec . startsWith ( '@npm/' ) ) return `/${ spec } ` ;
174+ if ( spec . startsWith ( '@npm/' ) ) return addBuildId ( `/${ spec } ` ) ;
122175 if ( / ^ ( \/ | \\ | [ a - z ] : \\ ) / i. test ( spec ) ) {
123176 // Change FS-absolute paths to relative
124177 spec = relative ( dirname ( file ) , spec ) . split ( sep ) . join ( posix . sep ) ;
@@ -130,20 +183,20 @@ export const jsMiddleware = ({
130183
131184 spec = relative ( root , spec ) . split ( sep ) . join ( posix . sep ) ;
132185 if ( ! / ^ ( \/ | [ \w - ] + : ) / . test ( spec ) ) spec = `/${ spec } ` ;
133- return spec ;
186+ return addBuildId ( spec ) ;
134187 }
135188 }
136189
137- // If it wasn't resovled , and doesn't have a js-like extension
138- // add the ?import query param so it is clear
190+ // If it wasn't resolved , and doesn't have a js-like extension
191+ // add the ?import query param to make it clear
139192 // that the request needs to end up as JS that can be imported
140193 if ( ! jsExts . test ( spec ) ) {
141194 // If there is already a query parameter, add &import
142195 const delimiter = / \? / . test ( spec ) ? '&' : '?' ;
143- return `${ spec } ${ delimiter } import` ;
196+ return addBuildId ( `${ spec } ${ delimiter } import` ) ;
144197 }
145198
146- return spec ;
199+ return addBuildId ( spec ) ;
147200 } ,
148201 } ) ;
149202
@@ -153,29 +206,18 @@ export const jsMiddleware = ({
153206 'Content-Length' : Buffer . byteLength ( code , 'utf-8' ) ,
154207 } ) ;
155208 res . end ( code ) ;
156-
157- // Start a esbuild build (just for the sake of parsing)
158- // That way, if there is a parsing error in the code resulting from the rollup transforms,
159- // we can display an error/code frame in the console
160- // instead of just a generic message from the browser saying it couldn't parse
161- // We are *not awaiting* this because we don't want to slow down sending the HTTP response
162- esbuild . transform ( code , { loader : 'js' } ) . catch ( ( error ) => {
163- const err = error . errors [ 0 ] ;
164- const { line, column } = err . location ;
165- const frame = createCodeFrame ( code as string , line - 1 , column ) ;
166- const message = `${ colors . red ( colors . bold ( err . text ) ) }
167-
168- ${ colors . red ( `${ id } :${ line } :${ ( column as number ) + 1 } ` ) }
169-
170- ${ frame }
171- ` ;
172-
173- // Create a new console instance instead of using the global one
174- // Because the global one is overridden by Jest, and it adds a misleading second stack trace and code frame below it
175- const console = new Console ( process . stdout , process . stderr ) ;
176- console . error ( message ) ;
177- } ) ;
178209 } catch ( error ) {
210+ if ( buildId ) {
211+ rejectBuild (
212+ Number ( buildId ) ,
213+ error instanceof ErrorWithLocation
214+ ? await error . toCodeFrame ( ) . catch ( ( ) => error )
215+ : error ,
216+ ) ;
217+
218+ res . statusCode = 500 ;
219+ return res . end ( ) ;
220+ }
179221 next ( error ) ;
180222 }
181223 } ;
0 commit comments