mirror of
https://github.com/jlengrand/open-wc.git
synced 2026-03-10 08:31:19 +00:00
feat(es-dev-server): add plugin system
This commit is contained in:
@@ -23,6 +23,7 @@ npx es-dev-server --node-resolve --watch
|
||||
- [resolve bare module imports for use in the browser](#node-resolve) (`--node-resolve`)
|
||||
- auto-reload on file changes with the (`--watch`)
|
||||
- history API fallback for SPA routing (`--app-index index.html`)
|
||||
- [plugin API for extensions](#plugins)
|
||||
|
||||
[See all commands](#command-line-flags-and-configuration)
|
||||
|
||||
@@ -107,20 +108,20 @@ module.exports = {
|
||||
watch: true,
|
||||
nodeResolve: true,
|
||||
appIndex: 'demo/index.html',
|
||||
plugins: [],
|
||||
moduleDirs: ['node_modules', 'web_modules'],
|
||||
};
|
||||
```
|
||||
|
||||
In addition to the command-line flags, the configuration file accepts these additional options:
|
||||
|
||||
| name | type | description |
|
||||
| -------------------- | ------------------------- | -------------------------------------------------------- |
|
||||
| middlewares | array | Koa middlewares to add to the server, read more below. |
|
||||
| responseTransformers | array | Functions which transform the server's response. |
|
||||
| babelConfig | object | Babel config to run with the server. |
|
||||
| polyfillsLoader | object | Configuration for the polyfills loader, read more below. |
|
||||
| debug | boolean | Whether to turn on debug mode on the server. |
|
||||
| onServerStart | (config) => Promise<void> | Function called before server is started. |
|
||||
| name | type | description |
|
||||
| --------------- | ------- | --------------------------------------------------------- |
|
||||
| middlewares | array | Koa middlewares to add to the server. (read more below) |
|
||||
| plugins | array | Plugins to add to the server. (read more below) |
|
||||
| babelConfig | object | Babel config to run with the server. |
|
||||
| polyfillsLoader | object | Configuration for the polyfills loader. (read more below) |
|
||||
| debug | boolean | Whether to turn on debug mode on the server. |
|
||||
|
||||
## Serving files
|
||||
|
||||
@@ -289,7 +290,7 @@ In the future, we are hoping that [import maps](https://github.com/WICG/import-m
|
||||
|
||||
You can add your own middleware to es-dev-server using the `middlewares` property. The middleware should be a standard koa middleware. [Read more about koa here.](https://koajs.com/)
|
||||
|
||||
You can use middleware to modify, respond, or redirect any request/response to/from es-dev-server. For example to set up a proxy for API requests, serve virtual files, tweak code, etc.
|
||||
You can use middleware to modify respond to any request from the browser, for example to rewrite a URL or proxy to another server. For serving or manipulating files it's recommended to use plugins.
|
||||
|
||||
### Proxying requests
|
||||
|
||||
@@ -336,49 +337,220 @@ module.exports = {
|
||||
|
||||
</details>
|
||||
|
||||
## Response transformers
|
||||
## Plugins
|
||||
|
||||
With the `responseTransformers` property, you can transform the server's response before it is sent to the browser. This is useful for injecting code into your index.html, performing transformations on files or to serve virtual files programmatically.
|
||||
Plugins are objects with lifecycle hooks called by es-dev-server as it serves files to the browser. They can be used to serve virtual files, transform files, or resolve module imports.
|
||||
|
||||
### Adding plugins
|
||||
|
||||
A plugin is just an object that you add to the `plugins` array in your configuration file. You can add an object directly, or create one from a function somewhere:
|
||||
|
||||
<details>
|
||||
<summary>Read more</summary>
|
||||
|
||||
A response transformer is a function that receives the original response and returns an optionally modified response. This transformation happens before any other built-in transformations such as node resolve, babel, or compatibility. You can register multiple transformers, they are called in order.
|
||||
```js
|
||||
const awesomePlugin = require('awesome-plugin');
|
||||
|
||||
The functions can be sync or async, see the full signature below:
|
||||
|
||||
```typescript
|
||||
({ url: string, status: number, contentType: string, body: string }) => Promise<{ body?: string, contentType?: string } | null>
|
||||
```
|
||||
|
||||
Some examples:
|
||||
|
||||
Rewrite the base path of your `index.html`:
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
responseTransformers: [
|
||||
function rewriteBasePath({ url, status, contentType, body }) {
|
||||
if (url === '/' || url === '/index.html') {
|
||||
const rewritten = body.replace(/<base href=".*">/, '<base href="/foo/">');
|
||||
return { body: rewritten };
|
||||
}
|
||||
plugins: [
|
||||
// use a plugin
|
||||
awesomePlugin({ someOption: 'someProperty' }),
|
||||
// create an inline plugin
|
||||
{
|
||||
transform(context) {
|
||||
if (context.response.is('html')) {
|
||||
return { body: context.body.replace(/<base href=".*">/, '<base href="/foo/">') };
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
Serve a virtual file, for example an auto generated `index.html`:
|
||||
</details>
|
||||
|
||||
```javascript
|
||||
See the full type interface for all options:
|
||||
|
||||
<details>
|
||||
<summary>Read more</summary>
|
||||
|
||||
```ts
|
||||
import Koa, { Context } from 'koa';
|
||||
import { FSWatcher } from 'chokidar';
|
||||
import { Server } from 'net';
|
||||
import { ParsedConfig } from './config';
|
||||
|
||||
type ServeResult = void | { body: string; type?: string; headers?: Record<string, string> };
|
||||
type TransformResult = void | { body?: string; type?: string; headers?: Record<string, string> };
|
||||
|
||||
interface ServerArgs {
|
||||
config: ParsedConfig;
|
||||
app: Koa;
|
||||
server: Server;
|
||||
fileWatcher: FSWatcher;
|
||||
}
|
||||
|
||||
export interface Plugin {
|
||||
serverStart?(args: ServerArgs): void | Promise<void>;
|
||||
serve?(context: Context): ServeResult | Promise<ServeResult>;
|
||||
transform?(context: Context): TransformResult | Promise<TransformResult>;
|
||||
resolveImport?(args: { source: string; context: Context }): string | Promise<string>;
|
||||
resolveMimeType?(context: Context): undefined | string | Promise<undefined | string>;
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Hook: serve
|
||||
|
||||
The serve hook can be used to serve virtual files from the server. The first plugin to respond with a body is used. It can return a Promise.
|
||||
|
||||
<details>
|
||||
<summary>Read more</summary>
|
||||
|
||||
Serve an auto generated `index.html`:
|
||||
|
||||
```js
|
||||
const indexHTML = generateIndexHTML();
|
||||
|
||||
module.exports = {
|
||||
responseTransformers: [
|
||||
function serveIndex({ url, status, contentType, body }) {
|
||||
if (url === '/' || url === '/index.html') {
|
||||
return { body: indexHTML, contentType: 'text/html' };
|
||||
}
|
||||
plugins: [
|
||||
{
|
||||
serve(context) {
|
||||
if (context.path === '/index.html') {
|
||||
return { body: indexHTML };
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
Serve a virtual module:
|
||||
|
||||
```js
|
||||
const indexHTML = generateIndexHTML();
|
||||
|
||||
module.exports = {
|
||||
plugins: [
|
||||
{
|
||||
serve(context) {
|
||||
if (context.path === '/environment.js') {
|
||||
return { body: 'export default "development";' };
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
The file extension is used to infer the mime type to respond with. If you are using a non-standard file extension you can use the `type` property to set it explicitly:
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
plugins: [
|
||||
{
|
||||
serve(context) {
|
||||
if (context.path === '/foo.bar') {
|
||||
return { body: 'console.log("foo bar");', type: 'js' };
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Hook: resolveMimeType
|
||||
|
||||
An important thing to keep in mind is that es-dev-server serves actual files to the browser. For the browser to know how to interpret a file, it doesn't use the file extension. Instead, it uses [media or MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) which is set using the `content-type` header.
|
||||
|
||||
es-dev-server guesses the MIME type based on the file extension. When serving virtual files with non-standard file extensions, you can set the MIME type in the returned result (see the examples above). If you are transforming code from one format to another, you need to use the `resolveMimeType` hook.
|
||||
|
||||
<details>
|
||||
<summary>Read more</summary>
|
||||
|
||||
The returned MIME type can be a file extension, this will be used to set the corresponding default MIME type. For example `js` resolves to `application/javascript` and `css` to `text/css`.
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
plugins: [
|
||||
{
|
||||
resolveMimeType(context) {
|
||||
// change all MD files to HTML
|
||||
if (context.response.is('md')) {
|
||||
return 'html';
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
resolveMimeType(context) {
|
||||
// change all CSS files to JS, except for a specific file
|
||||
if (context.response.is('css') && context.path !== '/global.css') {
|
||||
return 'js';
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
It is also possible to set the full mime type directly:
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
plugins: [
|
||||
{
|
||||
resolveMimeType(context) {
|
||||
if (context.response.is('md')) {
|
||||
return 'text/html';
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Hook: transform
|
||||
|
||||
The transform hook is called for each file and can be used to transform the response. Multiple plugins can transform a single response. It can return a Promise.
|
||||
|
||||
You can change the response body, mime type, or set additional headers. Remember to include a `resolveMimeType` hook if needed.
|
||||
|
||||
<details>
|
||||
<summary>Read more</summary>
|
||||
|
||||
Rewrite the base path of your application for local development;
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
plugins: [
|
||||
{
|
||||
transform(context) {
|
||||
const transformedBody = body.replace(/<base href=".*">/, '<base href="/foo/">');
|
||||
return { body: rewritransformedBodytten };
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
Inject a script to set global variables during local development:
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
plugins: [
|
||||
{
|
||||
transform(context) {
|
||||
const transformedBody = body.replace(
|
||||
'</head>',
|
||||
'<script>window.process = { env: { NODE_ENV: "development" } }</script></head>',
|
||||
);
|
||||
return { body: transformedBody };
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -386,19 +558,28 @@ module.exports = {
|
||||
|
||||
Transform markdown to HTML:
|
||||
|
||||
```javascript
|
||||
```js
|
||||
const markdownToHTML = require('markdown-to-html-library');
|
||||
|
||||
module.exports = {
|
||||
responseTransformers: [
|
||||
async function transformMarkdown({ url, status, contentType, body }) {
|
||||
if (url === '/readme.md') {
|
||||
const html = await markdownToHTML(body);
|
||||
return {
|
||||
body: html,
|
||||
contentType: 'text/html',
|
||||
};
|
||||
}
|
||||
plugins: [
|
||||
{
|
||||
resolveMimeType(context) {
|
||||
// this ensures the browser interprets .md files as .html
|
||||
if (context.response.is('md')) {
|
||||
return 'html';
|
||||
}
|
||||
},
|
||||
|
||||
async transform(context) {
|
||||
// this will transform all MD files. if you only want to transform certain MD files
|
||||
// you can check context.path
|
||||
if (context.response.is('md')) {
|
||||
const html = await markdownToHTML(body);
|
||||
|
||||
return { body: html };
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -406,18 +587,27 @@ module.exports = {
|
||||
|
||||
Polyfill CSS modules in JS:
|
||||
|
||||
```javascript
|
||||
```js
|
||||
module.exports = {
|
||||
responseTransformers: [
|
||||
async function transformCSS({ url, status, contentType, body }) {
|
||||
if (url.endsWith('.css')) {
|
||||
const transformedBody = `
|
||||
const stylesheet = new CSSStyleSheet();
|
||||
stylesheet.replaceSync(${JSON.stringify(body)});
|
||||
export default stylesheet;
|
||||
`;
|
||||
return { body: transformedBody, contentType: 'application/javascript' };
|
||||
}
|
||||
plugins: [
|
||||
{
|
||||
resolveMimeType(context) {
|
||||
if (context.response.is('css')) {
|
||||
return 'js';
|
||||
}
|
||||
},
|
||||
|
||||
async transform(context) {
|
||||
if (context.response.is('css')) {
|
||||
const stylesheet = `
|
||||
const stylesheet = new CSSStyleSheet();
|
||||
stylesheet.replaceSync(${JSON.stringify(body)});
|
||||
export default stylesheet;
|
||||
`;
|
||||
|
||||
return { body: stylesheet };
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -425,37 +615,118 @@ module.exports = {
|
||||
|
||||
</details>
|
||||
|
||||
## Order of execution
|
||||
### Hook: resolveImport
|
||||
|
||||
The `resolveImport` hook is called for each module import. It can be used to resolve module imports before they reach the browser.
|
||||
|
||||
<details>
|
||||
<summary>Read more</summary>
|
||||
|
||||
<summary>View</summary>
|
||||
es-dev-server already resolves module imports when the `--node-resolve` flag is turned on. You can do the resolving yourself, or overwrite it for some files.
|
||||
|
||||
The order of execution for the es-dev-server is:
|
||||
The hook receives the import string and should return the string to replace it with. This should be a browser-compatible path, not a file path.
|
||||
|
||||
1. Middleware
|
||||
2. es-dev-server static file middleware
|
||||
3. Response transformers
|
||||
4. es-dev-server code transform middlewares
|
||||
5. es-dev-server response cache (it also caches the code transformations!)
|
||||
6. Deferred middleware
|
||||
|
||||
Take this into account when deciding between response transformers, custom middlewares, and whether or not you defer your custom middleware.
|
||||
|
||||
For example, a deferred custom middleware may be necessary if you need to do something with the response body **after** caching.
|
||||
|
||||
```javascript
|
||||
async function myMiddleware(ctx, next) {
|
||||
ctx.url = ctx.url.replace('foo', 'bar');
|
||||
// before es-dev-server
|
||||
await next();
|
||||
// deferred, after es-dev-server
|
||||
ctx.body = ctx.body.replace('foo', 'bar');
|
||||
}
|
||||
```js
|
||||
module.exports = {
|
||||
plugins: [
|
||||
{
|
||||
async resolveImport({ source, context }) {
|
||||
const resolvedImport = fancyResolveLibrary(source);
|
||||
return resolvedImport;
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Hook: serverStart
|
||||
|
||||
The `serverStart` hook is called when the server starts. It is the ideal location to boot up other servers you will proxy to. It receives the server config, which you can use if plugins need access to general information such as the `rootDir` or `appIndex`. It also receives the HTTP server, Koa app, and `chokidar` file watcher instance. These can be used for more advanced plugins. This hook can be async, and it awaited before actually booting the server and opening the browser.
|
||||
|
||||
<details>
|
||||
<summary>Read more</summary>
|
||||
|
||||
Accessing the serverStart parameters:
|
||||
|
||||
```js
|
||||
function myFancyPlugin() {
|
||||
let rootDir;
|
||||
|
||||
return {
|
||||
serverStart({ config, app, server, fileWatcher }) {
|
||||
// take the rootDir to access it later
|
||||
rootDir = config.rootDir;
|
||||
|
||||
// register a koa middleware directly
|
||||
app.use((context, next) => {
|
||||
console.log(context.path);
|
||||
return next();
|
||||
});
|
||||
|
||||
// register a file to be watched
|
||||
fileWatcher.add('/foo.md');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
plugins: [myFancyPlugin()],
|
||||
};
|
||||
```
|
||||
|
||||
Boot up another server for proxying in serverStart:
|
||||
|
||||
```js
|
||||
const proxy = require('koa-proxies');
|
||||
|
||||
module.exports = {
|
||||
plugins: [
|
||||
{
|
||||
async serverStart({ app }) {
|
||||
// set up a proxy for certain requests
|
||||
app.use(
|
||||
proxy('/api', {
|
||||
target: 'http://localhost:9001',
|
||||
}),
|
||||
);
|
||||
|
||||
// boot up the other server, because it is awaited es-dev-server will also wait for it
|
||||
await startOtherServer({ port: 9001 });
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Koa Context
|
||||
|
||||
The plugin hooks simply receive the Koa `Context` object. This contains information about the server's request and response. Check the [Koa documentation](https://koajs.com/) to learn more about this.
|
||||
|
||||
To transform specific kinds of files we don't recommend relying on file extensions. Other plugins may be using non-standard file extensions. Instead, you should use the server's MIME type or content-type header. You can easily check this using the `context.response.is()` function. This is used a lot in the examples above.
|
||||
|
||||
Because files can be requested with query parameters and hashes, we recommend using `context.path` for reading the path segment of the URL only. If you do need to access search parameters, we recommend using `context.URL.searchParams.get('my-parameter')`.
|
||||
|
||||
## Order of execution
|
||||
|
||||
The order of execution for the es-dev-server when a file request is received:
|
||||
|
||||
1. User middleware: before "next"
|
||||
2. Serve
|
||||
- Plugins: serve
|
||||
- es-dev-server: static file middleware (if no plugin match)
|
||||
3. Plugins: resolveMimeType
|
||||
4. Plugins: transform
|
||||
5. Resolve module imports
|
||||
- Plugins: resolveModuleImport
|
||||
- es-dev-server: node-resolve (if no plugin resolve)
|
||||
6. es-dev-server: babel + compatibility transforms
|
||||
7. es-dev-server: response cache (caches all JS files served, including plugin transforms)
|
||||
8. User middleware: after "next"
|
||||
|
||||
## Typescript support
|
||||
|
||||
Because es-dev-server doesn't do any bundling, it's easy to integrate it with typescript and doesn't require any extra tooling or plugins. Just run `tsc` on your code, and serve the compiled output with es-dev-server. You can run both `tsc` and es-dev-server in watch mode, changes will be picked up automatically.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<base href="/my-base-path/demo/node-resolve/">
|
||||
<base href="/my-base-path/demo/base-path/">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -23,17 +23,8 @@
|
||||
|
||||
<script type="module">
|
||||
/* eslint-disable */
|
||||
import { html, render } from 'lit-html';
|
||||
import { foo } from './module-b.js';
|
||||
|
||||
console.log('inline module 0', foo());
|
||||
|
||||
render(
|
||||
html`
|
||||
<strong>inline module 0 loaded correctlyX</strong>
|
||||
`,
|
||||
document.getElementById('inlineApp'),
|
||||
);
|
||||
</script>
|
||||
|
||||
<script type="module" src="./module-a.js"></script>
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
/* eslint-disable */
|
||||
import { html, render } from 'lit-html';
|
||||
|
||||
export const foo = () => 'module b foo';
|
||||
|
||||
console.log('module b');
|
||||
console.log('lit-html', html);
|
||||
|
||||
render(
|
||||
html`
|
||||
<strong>module b loaded correctlyX</strong>
|
||||
`,
|
||||
document.getElementById('app'),
|
||||
);
|
||||
|
||||
1
packages/es-dev-server/demo/static/README.md
Normal file
1
packages/es-dev-server/demo/static/README.md
Normal file
@@ -0,0 +1 @@
|
||||
erewrewrweewrw
|
||||
@@ -21,6 +21,14 @@
|
||||
document.getElementById('test').innerHTML = `<pre>${JSON.stringify(window.__tests, null, 2)}</pre>`;
|
||||
</script>
|
||||
|
||||
<script>
|
||||
(async() => {
|
||||
await fetch('/README.md');
|
||||
await fetch('/module.js');
|
||||
})()
|
||||
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -133,6 +133,7 @@
|
||||
"koa-proxies": "^0.8.1",
|
||||
"lit-html": "^1.0.0",
|
||||
"lodash-es": "^4.17.15",
|
||||
"mime-types": "^2.1.27",
|
||||
"node-fetch": "^2.6.0",
|
||||
"request": "^2.88.0",
|
||||
"selfsigned": "^1.10.4",
|
||||
|
||||
22
packages/es-dev-server/src/Plugin.ts
Normal file
22
packages/es-dev-server/src/Plugin.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import Koa, { Context } from 'koa';
|
||||
import { FSWatcher } from 'chokidar';
|
||||
import { Server } from 'net';
|
||||
import { ParsedConfig } from './config';
|
||||
|
||||
type ServeResult = void | { body: string; type?: string; headers?: Record<string, string> };
|
||||
type TransformResult = void | { body?: string; headers?: Record<string, string> };
|
||||
|
||||
interface ServerArgs {
|
||||
config: ParsedConfig;
|
||||
app: Koa;
|
||||
server: Server;
|
||||
fileWatcher: FSWatcher;
|
||||
}
|
||||
|
||||
export interface Plugin {
|
||||
serverStart?(args: ServerArgs): void | Promise<void>;
|
||||
serve?(context: Context): ServeResult | Promise<ServeResult>;
|
||||
transform?(context: Context): TransformResult | Promise<TransformResult>;
|
||||
resolveImport?(args: { source: string; context: Context }): string | Promise<string>;
|
||||
resolveMimeType?(context: Context): undefined | string | Promise<undefined | string>;
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import path from 'path';
|
||||
import { Middleware } from 'koa';
|
||||
import { defaultFileExtensions } from '@open-wc/building-utils';
|
||||
import { Options as NodeResolveOptions } from '@rollup/plugin-node-resolve';
|
||||
import { PolyfillsLoaderConfig } from './utils/inject-polyfills-loader';
|
||||
import { toBrowserPath, setDebug } from './utils/utils';
|
||||
import { compatibilityModes } from './constants';
|
||||
import { ResponseTransformer } from './middleware/response-transform';
|
||||
import { TransformOptions, Node } from '@babel/core';
|
||||
import { Plugin } from './Plugin';
|
||||
|
||||
// Config object for Koa compress middleware
|
||||
export interface CompressOptions {
|
||||
@@ -85,7 +85,7 @@ export interface Config {
|
||||
*/
|
||||
dedupeModules?: boolean | string[] | ((importee: string) => boolean);
|
||||
/** configuration for the polyfills loader */
|
||||
polyfillsLoader?: Partial<PolyfillsLoaderConfig>;
|
||||
polyfillsLoader?: boolean | Partial<PolyfillsLoaderConfig>;
|
||||
/**
|
||||
* preserve symlinks when resolving modules. Default false,
|
||||
* which is the default node behavior.
|
||||
@@ -97,6 +97,9 @@ export interface Config {
|
||||
* find directories with these names
|
||||
*/
|
||||
moduleDirs?: string[];
|
||||
/** plugins to load */
|
||||
plugins?: Plugin[];
|
||||
|
||||
/** whether to use the user's .babelrc babel config */
|
||||
babel?: boolean;
|
||||
/** file extensions to run babel on */
|
||||
@@ -144,7 +147,9 @@ export interface ParsedConfig {
|
||||
sslCert?: string;
|
||||
|
||||
// Code transformation
|
||||
plugins: Plugin[];
|
||||
nodeResolve: boolean | NodeResolveOptions;
|
||||
polyfillsLoader: boolean;
|
||||
polyfillsLoaderConfig?: Partial<PolyfillsLoaderConfig>;
|
||||
readUserBabelConfig: boolean;
|
||||
compatibilityMode: string;
|
||||
@@ -179,6 +184,7 @@ export function createConfig(config: Partial<Config>): ParsedConfig {
|
||||
watch = false,
|
||||
logErrorsToBrowser = false,
|
||||
polyfillsLoader,
|
||||
plugins = [],
|
||||
responseTransformers = [],
|
||||
debug = false,
|
||||
nodeResolve: nodeResolveArg = false,
|
||||
@@ -242,7 +248,15 @@ export function createConfig(config: Partial<Config>): ParsedConfig {
|
||||
openPath = basePath ? `${basePath}/` : '/';
|
||||
}
|
||||
|
||||
const fileExtensions = [...(fileExtensionsArg || []), ...defaultFileExtensions];
|
||||
const fileExtensions = [
|
||||
...(fileExtensionsArg || []),
|
||||
'.mjs',
|
||||
'.js',
|
||||
'.cjs',
|
||||
'.jsx',
|
||||
'.ts',
|
||||
'.tsx',
|
||||
];
|
||||
|
||||
let nodeResolve = nodeResolveArg;
|
||||
// some node resolve options can be set separately for convenience, primarily
|
||||
@@ -267,6 +281,8 @@ export function createConfig(config: Partial<Config>): ParsedConfig {
|
||||
}
|
||||
}
|
||||
|
||||
const polyfillsLoaderConfig = typeof polyfillsLoader === 'boolean' ? undefined : polyfillsLoader;
|
||||
|
||||
return {
|
||||
appIndex,
|
||||
appIndexDir,
|
||||
@@ -275,7 +291,8 @@ export function createConfig(config: Partial<Config>): ParsedConfig {
|
||||
babelModuleExclude,
|
||||
basePath,
|
||||
compatibilityMode: compatibility,
|
||||
polyfillsLoaderConfig: polyfillsLoader,
|
||||
polyfillsLoader: polyfillsLoader !== false,
|
||||
polyfillsLoaderConfig,
|
||||
compress,
|
||||
customBabelConfig: babelConfig,
|
||||
customMiddlewares: middlewares,
|
||||
@@ -283,6 +300,7 @@ export function createConfig(config: Partial<Config>): ParsedConfig {
|
||||
fileExtensions,
|
||||
hostname,
|
||||
http2,
|
||||
plugins,
|
||||
logStartup: !!logStartup,
|
||||
nodeResolve,
|
||||
openBrowser: open === true || typeof open === 'string',
|
||||
|
||||
@@ -4,20 +4,25 @@ import koaCompress from 'koa-compress';
|
||||
import chokidar from 'chokidar';
|
||||
import { createBasePathMiddleware } from './middleware/base-path';
|
||||
import { createHistoryAPIFallbackMiddleware } from './middleware/history-api-fallback';
|
||||
import { createCompatibilityTransformMiddleware } from './middleware/compatibility-transform';
|
||||
import { createWatchServedFilesMiddleware } from './middleware/watch-served-files';
|
||||
import { createPolyfillsLoaderMiddleware } from './middleware/polyfills-loader';
|
||||
import { createMessageChannelMiddleware } from './middleware/message-channel';
|
||||
import { createEtagCacheMiddleware } from './middleware/etag-cache';
|
||||
import { createResponseBodyCacheMiddleware } from './middleware/response-body-cache';
|
||||
import { setupBrowserReload } from './utils/setup-browser-reload';
|
||||
import { compatibilityModes } from './constants';
|
||||
import { createResponseTransformMiddleware } from './middleware/response-transform';
|
||||
import { createResolveModuleImports } from './utils/resolve-module-imports';
|
||||
import { createCompatibilityTransform } from './utils/compatibility-transform';
|
||||
import { logDebug } from './utils/utils';
|
||||
import { ParsedConfig } from './config';
|
||||
import { Middleware } from 'koa';
|
||||
import { createPluginServeMiddlware } from './middleware/plugin-serve';
|
||||
import { createPluginTransformMiddlware } from './middleware/plugin-transform';
|
||||
import { createPluginMimeTypeMiddleware } from './middleware/plugin-mime-type';
|
||||
import { Plugin } from './Plugin';
|
||||
import { resolveModuleImportsPlugin } from './plugins/resolveModuleImportsPlugin';
|
||||
import { nodeResolvePlugin } from './plugins/nodeResolvePlugin';
|
||||
import { fileExtensionsPlugin } from './plugins/fileExtensionsPlugin';
|
||||
import { babelTransformPlugin } from './plugins/babelTransformPlugin';
|
||||
import { polyfillsLoaderPlugin } from './plugins/polyfillsLoaderPlugin';
|
||||
|
||||
const defaultCompressOptions = {
|
||||
filter(contentType: string) {
|
||||
@@ -26,6 +31,10 @@ const defaultCompressOptions = {
|
||||
},
|
||||
};
|
||||
|
||||
function hasHook(plugins: Plugin[], hook: string) {
|
||||
return plugins.some(plugin => hook in plugin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates middlewares based on the given configuration. The middlewares can be
|
||||
* used by a koa server using `app.use()`:
|
||||
@@ -37,9 +46,6 @@ export function createMiddlewares(
|
||||
const {
|
||||
appIndex,
|
||||
appIndexDir,
|
||||
babelExclude,
|
||||
babelModernExclude,
|
||||
babelModuleExclude,
|
||||
basePath,
|
||||
compatibilityMode,
|
||||
compress,
|
||||
@@ -48,8 +54,10 @@ export function createMiddlewares(
|
||||
responseTransformers,
|
||||
fileExtensions,
|
||||
nodeResolve,
|
||||
polyfillsLoader,
|
||||
polyfillsLoaderConfig,
|
||||
readUserBabelConfig,
|
||||
plugins,
|
||||
rootDir,
|
||||
watch,
|
||||
logErrorsToBrowser,
|
||||
@@ -76,35 +84,29 @@ export function createMiddlewares(
|
||||
);
|
||||
}
|
||||
|
||||
const setupCompatibility =
|
||||
customBabelConfig || compatibilityMode !== compatibilityModes.NONE || readUserBabelConfig;
|
||||
const setupCompatibility = compatibilityMode !== compatibilityModes.NONE;
|
||||
const setupBabel = customBabelConfig || readUserBabelConfig;
|
||||
const setupHistoryFallback = appIndex;
|
||||
const setupMessageChanel = watch || (logErrorsToBrowser && (setupCompatibility || nodeResolve));
|
||||
|
||||
const resolveModuleImports = nodeResolve
|
||||
? createResolveModuleImports(
|
||||
rootDir,
|
||||
fileExtensions,
|
||||
typeof nodeResolve === 'boolean' ? undefined : nodeResolve,
|
||||
)
|
||||
: undefined;
|
||||
const transformJs =
|
||||
setupCompatibility || nodeResolve
|
||||
? createCompatibilityTransform(
|
||||
{
|
||||
rootDir,
|
||||
readUserBabelConfig,
|
||||
nodeResolve,
|
||||
compatibilityMode,
|
||||
customBabelConfig,
|
||||
fileExtensions,
|
||||
babelExclude,
|
||||
babelModernExclude,
|
||||
babelModuleExclude,
|
||||
},
|
||||
resolveModuleImports,
|
||||
)
|
||||
: undefined;
|
||||
if (fileExtensions.length > 0) {
|
||||
plugins.unshift(fileExtensionsPlugin());
|
||||
}
|
||||
|
||||
if (nodeResolve || hasHook(plugins, 'resolveId')) {
|
||||
plugins.push(resolveModuleImportsPlugin());
|
||||
if (nodeResolve) {
|
||||
plugins.push(nodeResolvePlugin());
|
||||
}
|
||||
}
|
||||
|
||||
if (setupCompatibility || setupBabel) {
|
||||
plugins.push(babelTransformPlugin());
|
||||
}
|
||||
|
||||
if (polyfillsLoader && setupCompatibility) {
|
||||
plugins.push(polyfillsLoaderPlugin());
|
||||
}
|
||||
|
||||
// strips a base path from requests
|
||||
if (basePath) {
|
||||
@@ -147,30 +149,6 @@ export function createMiddlewares(
|
||||
}),
|
||||
);
|
||||
|
||||
// compile code using babel and/or resolve module imports
|
||||
if ((setupCompatibility || nodeResolve) && transformJs) {
|
||||
middlewares.push(
|
||||
createCompatibilityTransformMiddleware({
|
||||
rootDir,
|
||||
fileExtensions,
|
||||
transformJs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// injects polyfills and shims for compatibility with older browsers
|
||||
if ((setupCompatibility || nodeResolve) && transformJs) {
|
||||
middlewares.push(
|
||||
createPolyfillsLoaderMiddleware({
|
||||
compatibilityMode,
|
||||
polyfillsLoaderConfig,
|
||||
rootDir,
|
||||
appIndex,
|
||||
transformJs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// serves index.html for non-file requests for SPA routing
|
||||
if (setupHistoryFallback && typeof appIndex === 'string' && typeof appIndexDir === 'string') {
|
||||
middlewares.push(createHistoryAPIFallbackMiddleware({ appIndex, appIndexDir }));
|
||||
@@ -180,11 +158,17 @@ export function createMiddlewares(
|
||||
setupBrowserReload({ fileWatcher, watchDebounce });
|
||||
}
|
||||
|
||||
middlewares.push(createPluginTransformMiddlware({ plugins }));
|
||||
|
||||
// DEPRECATED: Response transformers (now split up in serve and transform in plugins)
|
||||
if (responseTransformers) {
|
||||
middlewares.push(createResponseTransformMiddleware({ responseTransformers }));
|
||||
}
|
||||
|
||||
// serve sstatic files
|
||||
middlewares.push(createPluginMimeTypeMiddleware({ plugins }));
|
||||
middlewares.push(createPluginServeMiddlware({ plugins }));
|
||||
|
||||
// serve static files
|
||||
middlewares.push(
|
||||
koaStatic(rootDir, {
|
||||
hidden: true,
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from './create-server';
|
||||
export * from './start-server';
|
||||
|
||||
export * from './constants';
|
||||
export * from './Plugin';
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { Middleware } from 'koa';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import {
|
||||
getBodyAsString,
|
||||
getRequestFilePath,
|
||||
isPolyfill,
|
||||
RequestCancelledError,
|
||||
shoudlTransformToModule,
|
||||
logDebug,
|
||||
} from '../utils/utils';
|
||||
import { sendMessageToActiveBrowsers } from '../utils/message-channel';
|
||||
import { ResolveSyntaxError } from '../utils/resolve-module-imports';
|
||||
import { getUserAgentCompat } from '../utils/user-agent-compat';
|
||||
import { TransformJs } from '../utils/compatibility-transform';
|
||||
|
||||
export interface CompatibilityTransformMiddleware {
|
||||
rootDir: string;
|
||||
fileExtensions: string[];
|
||||
transformJs: TransformJs;
|
||||
}
|
||||
|
||||
function logError(errorMessage: string) {
|
||||
// strip babel ansi color codes because they're not colored correctly for the browser terminal
|
||||
sendMessageToActiveBrowsers('error-message', JSON.stringify(stripAnsi(errorMessage)));
|
||||
|
||||
/* eslint-disable-next-line no-console */
|
||||
console.error(`\n${errorMessage}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a middleware which runs all served js code through babel. Different babel configs
|
||||
* are loaded based on the server's configuration.
|
||||
*/
|
||||
export function createCompatibilityTransformMiddleware(
|
||||
cfg: CompatibilityTransformMiddleware,
|
||||
): Middleware {
|
||||
return async function compatibilityMiddleware(ctx, next) {
|
||||
const baseURL = ctx.url.split('?')[0].split('#')[0];
|
||||
if (isPolyfill(ctx.url) || !cfg.fileExtensions.some(ext => baseURL.endsWith(ext))) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (ctx.headers.accept.includes('text/html')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
await next();
|
||||
|
||||
// should be a 2xx response
|
||||
if (ctx.status < 200 || ctx.status >= 300) {
|
||||
return undefined;
|
||||
}
|
||||
const transformModule = shoudlTransformToModule(ctx.url);
|
||||
const filePath = getRequestFilePath(ctx, cfg.rootDir);
|
||||
// if there is no file path, this file was not served statically
|
||||
if (!filePath) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Ensure we respond with js content type
|
||||
ctx.response.set('content-type', 'text/javascript');
|
||||
|
||||
try {
|
||||
const code = await getBodyAsString(ctx);
|
||||
const uaCompat = getUserAgentCompat(ctx);
|
||||
const transformedCode = await cfg.transformJs({
|
||||
uaCompat,
|
||||
filePath,
|
||||
code,
|
||||
transformModule,
|
||||
});
|
||||
ctx.body = transformedCode;
|
||||
ctx.status = 200;
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
if (error instanceof RequestCancelledError) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ResolveSyntaxError is thrown when resolveModuleImports runs into a syntax error from
|
||||
// the lexer, but babel didn't see any errors. this means either a bug in the lexer, or
|
||||
// some experimental syntax. log a message and return the module untransformed to the
|
||||
// browser
|
||||
if (error instanceof ResolveSyntaxError) {
|
||||
logError(
|
||||
`Could not resolve module imports in ${ctx.url}: Unable to parse the module, this can be due to experimental syntax or a bug in the parser.`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
logDebug(error);
|
||||
|
||||
let errorMessage = error.message;
|
||||
|
||||
// replace babel error messages file path with the request url for readability
|
||||
if (errorMessage.startsWith(filePath)) {
|
||||
errorMessage = errorMessage.replace(filePath, ctx.url);
|
||||
}
|
||||
|
||||
errorMessage = `Error compiling: ${errorMessage}`;
|
||||
|
||||
// send compile error to browser for logging
|
||||
ctx.body = errorMessage;
|
||||
ctx.status = 500;
|
||||
logError(errorMessage);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Middleware } from 'koa';
|
||||
import path from 'path';
|
||||
|
||||
interface HistoryAPIFallbackMiddlewareConfig {
|
||||
appIndex: string;
|
||||
@@ -13,9 +14,8 @@ export function createHistoryAPIFallbackMiddleware(
|
||||
cfg: HistoryAPIFallbackMiddlewareConfig,
|
||||
): Middleware {
|
||||
return function historyAPIFallback(ctx, next) {
|
||||
// . character hints at a file request (could possibly check with regex for file ext)
|
||||
const cleanUrl = ctx.url.split('?')[0].split('#')[0];
|
||||
if (ctx.method !== 'GET' || cleanUrl.includes('.')) {
|
||||
if (ctx.method !== 'GET' || path.extname(ctx.path)) {
|
||||
// not a GET, or a direct file request
|
||||
return next();
|
||||
}
|
||||
|
||||
|
||||
34
packages/es-dev-server/src/middleware/plugin-mime-type.ts
Normal file
34
packages/es-dev-server/src/middleware/plugin-mime-type.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Context, Middleware } from 'koa';
|
||||
import path from 'path';
|
||||
import { Plugin } from '../Plugin';
|
||||
import { getBodyAsString, RequestCancelledError, isUtf8 } from '../utils/utils';
|
||||
|
||||
export interface PluginServeMiddlewareConfig {
|
||||
plugins: Plugin[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a middleware which allows plugins to resolve the mime type.
|
||||
*/
|
||||
export function createPluginMimeTypeMiddleware(cfg: PluginServeMiddlewareConfig): Middleware {
|
||||
const mimeTypePlugins = cfg.plugins.filter(p => 'resolveMimeType' in p);
|
||||
if (mimeTypePlugins.length === 0) {
|
||||
// nothing to transform
|
||||
return (ctx, next) => next();
|
||||
}
|
||||
|
||||
return async function pluginMimeTypeMiddleware(context, next) {
|
||||
await next();
|
||||
|
||||
if (context.status < 200 || context.status >= 300) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const plugin of mimeTypePlugins) {
|
||||
const type = await plugin.resolveMimeType?.(context);
|
||||
if (type) {
|
||||
context.type = type;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
49
packages/es-dev-server/src/middleware/plugin-serve.ts
Normal file
49
packages/es-dev-server/src/middleware/plugin-serve.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Context, Middleware } from 'koa';
|
||||
import path from 'path';
|
||||
import { Plugin } from '../Plugin';
|
||||
|
||||
export interface PluginServeMiddlewareConfig {
|
||||
plugins: Plugin[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a middleware which allows plugins to serve files instead of looking it up in the file system.
|
||||
*/
|
||||
export function createPluginServeMiddlware(cfg: PluginServeMiddlewareConfig): Middleware {
|
||||
const servePlugins = cfg.plugins.filter(p => 'serve' in p);
|
||||
if (servePlugins.length === 0) {
|
||||
// nothing to serve
|
||||
return (ctx, next) => next();
|
||||
}
|
||||
|
||||
return async function pluginServeMiddleware(context, next) {
|
||||
for (const plugin of servePlugins) {
|
||||
const response = await plugin.serve?.(context);
|
||||
|
||||
if (response) {
|
||||
if (response.body == null) {
|
||||
throw new Error(
|
||||
'A serve result must contain a body. Use the transform hook to change only the mime type.',
|
||||
);
|
||||
}
|
||||
|
||||
context.body = response.body;
|
||||
if (response.type != null) {
|
||||
context.type = response.type;
|
||||
} else {
|
||||
context.type = path.extname(path.basename(context.path));
|
||||
}
|
||||
|
||||
if (response.headers) {
|
||||
for (const [k, v] of Object.entries(response.headers)) {
|
||||
context.response.set(k, v);
|
||||
}
|
||||
}
|
||||
|
||||
context.status = 200;
|
||||
return;
|
||||
}
|
||||
}
|
||||
return next();
|
||||
};
|
||||
}
|
||||
57
packages/es-dev-server/src/middleware/plugin-transform.ts
Normal file
57
packages/es-dev-server/src/middleware/plugin-transform.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Context, Middleware } from 'koa';
|
||||
import path from 'path';
|
||||
import { Plugin } from '../Plugin';
|
||||
import { getBodyAsString, RequestCancelledError, isUtf8 } from '../utils/utils';
|
||||
|
||||
export interface PluginServeMiddlewareConfig {
|
||||
plugins: Plugin[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a middleware which allows plugins to transform files before they are served to the browser.
|
||||
*/
|
||||
export function createPluginTransformMiddlware(cfg: PluginServeMiddlewareConfig): Middleware {
|
||||
const transformPlugins = cfg.plugins.filter(p => 'transform' in p);
|
||||
if (transformPlugins.length === 0) {
|
||||
// nothing to transform
|
||||
return (ctx, next) => next();
|
||||
}
|
||||
|
||||
return async function pluginTransformMiddleware(context, next) {
|
||||
await next();
|
||||
|
||||
if (context.status < 200 || context.status >= 300) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!isUtf8(context)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// responses are streams initially, but to allow transforming we turn it
|
||||
// into a string first
|
||||
try {
|
||||
context.body = await getBodyAsString(context);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestCancelledError) {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const plugin of transformPlugins) {
|
||||
const response = await plugin.transform?.(context);
|
||||
if (response) {
|
||||
if (response.body != null) {
|
||||
context.body = response.body;
|
||||
}
|
||||
|
||||
if (response.headers) {
|
||||
for (const [k, v] of Object.entries(response.headers)) {
|
||||
context.response.set(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
/* eslint-disable no-console */
|
||||
import path from 'path';
|
||||
import { URLSearchParams } from 'url';
|
||||
import { Middleware } from 'koa';
|
||||
import { GeneratedFile } from 'polyfills-loader';
|
||||
|
||||
import { injectPolyfillsLoader } from '../utils/inject-polyfills-loader';
|
||||
import {
|
||||
isIndexHTMLResponse,
|
||||
getBodyAsString,
|
||||
toBrowserPath,
|
||||
isInlineScript,
|
||||
RequestCancelledError,
|
||||
toFilePath,
|
||||
} from '../utils/utils';
|
||||
import { getUserAgentCompat } from '../utils/user-agent-compat';
|
||||
import { PolyfillsLoaderConfig } from '../utils/inject-polyfills-loader';
|
||||
import { TransformJs } from '../utils/compatibility-transform';
|
||||
|
||||
interface IndexHTMLData {
|
||||
indexHTML: string;
|
||||
lastModified: string;
|
||||
inlineScripts: GeneratedFile[];
|
||||
}
|
||||
|
||||
export interface PolyfillsLoaderMiddlewareConfig {
|
||||
compatibilityMode: string;
|
||||
appIndex?: string;
|
||||
rootDir: string;
|
||||
polyfillsLoaderConfig?: Partial<PolyfillsLoaderConfig>;
|
||||
transformJs: TransformJs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates middleware which injects polyfills and code into the served application which allows
|
||||
* it to run on legacy browsers.
|
||||
*/
|
||||
export function createPolyfillsLoaderMiddleware(cfg: PolyfillsLoaderMiddlewareConfig): Middleware {
|
||||
// polyfills, keyed by url
|
||||
const polyfills = new Map<string, string>();
|
||||
|
||||
// index html data, keyed by url
|
||||
const indexHTMLData = new Map<string, IndexHTMLData>();
|
||||
|
||||
return async function polyfillsLoaderMiddleware(ctx, next) {
|
||||
// serve polyfill from memory if url matches
|
||||
const polyfill = polyfills.get(ctx.url);
|
||||
if (polyfill) {
|
||||
ctx.body = polyfill;
|
||||
// aggresively cache polyfills, they are hashed so content changes bust the cache
|
||||
ctx.response.set('cache-control', 'public, max-age=31536000');
|
||||
ctx.response.set('content-type', 'text/javascript');
|
||||
return undefined;
|
||||
}
|
||||
const uaCompat = getUserAgentCompat(ctx);
|
||||
|
||||
/**
|
||||
* serve extracted inline module if url matches. an inline module requests has this
|
||||
* structure:
|
||||
* `/inline-script-<index>?source=<index-html-path>`
|
||||
* for example:
|
||||
* `/inline-script-2?source=/src/index-html`
|
||||
* source query parameter is the index.html the inline module came from, index is the index
|
||||
* of the inline module in that index.html. We use these to look up the correct code to
|
||||
* serve
|
||||
*/
|
||||
if (isInlineScript(ctx.url)) {
|
||||
const [url, queryString] = ctx.url.split('?');
|
||||
const params = new URLSearchParams(queryString);
|
||||
const sourcePath = params.get('source');
|
||||
if (!sourcePath) {
|
||||
throw new Error(`${ctx.url} is missing a source param`);
|
||||
}
|
||||
|
||||
const data = indexHTMLData.get(uaCompat.browserTarget + decodeURIComponent(sourcePath));
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const name = path.basename(url);
|
||||
const inlineScript = data.inlineScripts.find(f => f.path.split('?')[0] === name);
|
||||
if (!inlineScript) {
|
||||
throw new Error(`Could not find inline module for ${ctx.url}`);
|
||||
}
|
||||
|
||||
ctx.body = inlineScript.content;
|
||||
ctx.response.set('content-type', 'text/javascript');
|
||||
ctx.response.set('cache-control', 'no-cache');
|
||||
ctx.response.set('last-modified', data.lastModified);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
await next();
|
||||
|
||||
// check if we are serving an index.html
|
||||
if (!(await isIndexHTMLResponse(ctx, cfg.appIndex))) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lastModified = ctx.response.headers['last-modified'];
|
||||
|
||||
// return cached index.html if it did not change
|
||||
const data = indexHTMLData.get(uaCompat.browserTarget + ctx.url);
|
||||
// if there is no lastModified cached, the HTML file is not served from the
|
||||
// file system
|
||||
if (data && data.lastModified && lastModified && data.lastModified === lastModified) {
|
||||
ctx.body = data.indexHTML;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const indexFilePath = path.join(cfg.rootDir, toFilePath(ctx.url));
|
||||
|
||||
try {
|
||||
// transforms index.html to make the code load correctly with the right polyfills and shims
|
||||
const htmlString = await getBodyAsString(ctx);
|
||||
const result = await injectPolyfillsLoader({
|
||||
htmlString,
|
||||
indexUrl: ctx.url,
|
||||
indexFilePath,
|
||||
transformJs: cfg.transformJs,
|
||||
compatibilityMode: cfg.compatibilityMode,
|
||||
polyfillsLoaderConfig: cfg.polyfillsLoaderConfig,
|
||||
uaCompat,
|
||||
});
|
||||
|
||||
// set new index.html
|
||||
ctx.body = result.indexHTML;
|
||||
|
||||
const polyfillsMap = new Map();
|
||||
result.polyfills.forEach(file => {
|
||||
polyfillsMap.set(file.path, file);
|
||||
});
|
||||
|
||||
// cache index for later use
|
||||
indexHTMLData.set(uaCompat.browserTarget + ctx.url, {
|
||||
...result,
|
||||
inlineScripts: result.inlineScripts,
|
||||
lastModified,
|
||||
});
|
||||
|
||||
// cache polyfills for serving
|
||||
result.polyfills.forEach(p => {
|
||||
let root = ctx.url.endsWith('/') ? ctx.url : path.posix.dirname(ctx.url);
|
||||
if (!root.endsWith('/')) {
|
||||
root = `${root}/`;
|
||||
}
|
||||
polyfills.set(`${root}${toBrowserPath(p.path)}`, p.content);
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof RequestCancelledError) {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
@@ -103,8 +103,7 @@ export function createResponseBodyCacheMiddleware(cfg: ResponseBodyCacheMiddlewa
|
||||
return;
|
||||
}
|
||||
|
||||
const strippedUrl = ctx.url.split('?')[0].split('#')[0];
|
||||
if (isGeneratedFile(ctx.url) || !cfg.fileExtensions.some(ext => strippedUrl.endsWith(ext))) {
|
||||
if (isGeneratedFile(ctx.url) || !ctx.response.is('js')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
79
packages/es-dev-server/src/plugins/babelTransformPlugin.ts
Normal file
79
packages/es-dev-server/src/plugins/babelTransformPlugin.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/* eslint-disable no-console */
|
||||
import { Plugin } from '../Plugin';
|
||||
import { createCompatibilityTransform, TransformJs } from '../utils/compatibility-transform';
|
||||
import { getUserAgentCompat } from '../utils/user-agent-compat';
|
||||
import { sendMessageToActiveBrowsers } from '../utils/message-channel';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import { getTextContent, setTextContent } from '@open-wc/building-utils/dom5-fork';
|
||||
import { findJsScripts } from '@open-wc/building-utils';
|
||||
import { parse as parseHtml, serialize as serializeHtml } from 'parse5';
|
||||
import { URL, pathToFileURL, fileURLToPath } from 'url';
|
||||
import { Context } from 'koa';
|
||||
import { isPolyfill } from '../utils/utils';
|
||||
|
||||
function createFilePath(context: Context, rootDir: string) {
|
||||
return fileURLToPath(new URL(`.${context.path}`, `${pathToFileURL(rootDir)}/`));
|
||||
}
|
||||
|
||||
export function babelTransformPlugin(): Plugin {
|
||||
let rootDir: string;
|
||||
let compatibilityTransform: TransformJs;
|
||||
|
||||
async function transformJs(context: Context, code: string) {
|
||||
const filePath = createFilePath(context, rootDir);
|
||||
const transformModule = context.URL.searchParams.has('transform-systemjs');
|
||||
const uaCompat = getUserAgentCompat(context);
|
||||
return compatibilityTransform({
|
||||
uaCompat,
|
||||
filePath,
|
||||
code,
|
||||
transformModule,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
serverStart({ config }) {
|
||||
({ rootDir } = config);
|
||||
compatibilityTransform = createCompatibilityTransform(config);
|
||||
},
|
||||
|
||||
async transform(context) {
|
||||
// transform a single file
|
||||
if (context.response.is('js') && !isPolyfill(context.url)) {
|
||||
try {
|
||||
return { body: await transformJs(context, context.body) };
|
||||
} catch (error) {
|
||||
const filePath = createFilePath(context, rootDir);
|
||||
let errorMessage = `Error compiling: ${error.message}`;
|
||||
|
||||
if (errorMessage.startsWith(filePath)) {
|
||||
errorMessage = errorMessage.replace(filePath, context.url);
|
||||
}
|
||||
|
||||
// send compile error to browser for logging
|
||||
context.body = errorMessage;
|
||||
context.status = 500;
|
||||
sendMessageToActiveBrowsers('error-message', JSON.stringify(stripAnsi(errorMessage)));
|
||||
console.error(`\n${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
// transform inline JS
|
||||
if (context.response.is('html')) {
|
||||
const documentAst = parseHtml(context.body);
|
||||
const scriptNodes = findJsScripts(documentAst, {
|
||||
jsScripts: true,
|
||||
jsModules: true,
|
||||
});
|
||||
|
||||
for (const node of scriptNodes) {
|
||||
const code = getTextContent(node);
|
||||
const resolvedCode = await transformJs(context, code);
|
||||
setTextContent(node, resolvedCode);
|
||||
}
|
||||
|
||||
return { body: serializeHtml(documentAst) };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
20
packages/es-dev-server/src/plugins/fileExtensionsPlugin.ts
Normal file
20
packages/es-dev-server/src/plugins/fileExtensionsPlugin.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Plugin } from '../Plugin';
|
||||
|
||||
/**
|
||||
* Plugin which serves configured file extensions as JS.
|
||||
*/
|
||||
export function fileExtensionsPlugin(): Plugin {
|
||||
let fileExtensions: string[];
|
||||
|
||||
return {
|
||||
serverStart({ config }) {
|
||||
fileExtensions = config.fileExtensions.map(ext => (ext.startsWith('.') ? ext : `.${ext}`));
|
||||
},
|
||||
|
||||
resolveMimeType(context) {
|
||||
if (fileExtensions.some(ext => context.path.endsWith(ext))) {
|
||||
return 'js';
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
95
packages/es-dev-server/src/plugins/nodeResolvePlugin.ts
Normal file
95
packages/es-dev-server/src/plugins/nodeResolvePlugin.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import createRollupResolve from '@rollup/plugin-node-resolve';
|
||||
import { Plugin as RollupPlugin } from 'rollup';
|
||||
import path from 'path';
|
||||
import { URL, pathToFileURL, fileURLToPath } from 'url';
|
||||
import whatwgUrl from 'whatwg-url';
|
||||
import { Plugin } from '../Plugin';
|
||||
import { toBrowserPath } from '../utils/utils';
|
||||
|
||||
const nodeResolvePackageJson = require('@rollup/plugin-node-resolve/package.json');
|
||||
|
||||
const fakePluginContext = {
|
||||
meta: {
|
||||
rollupVersion: nodeResolvePackageJson.peerDependencies.rollup,
|
||||
},
|
||||
warn(...msg: string[]) {
|
||||
console.warn('[es-dev-server] node-resolve: ', ...msg);
|
||||
},
|
||||
};
|
||||
|
||||
export function nodeResolvePlugin(): Plugin {
|
||||
let fileExtensions: string[];
|
||||
let rootDir: string;
|
||||
let nodeResolve: RollupPlugin;
|
||||
|
||||
return {
|
||||
async serverStart({ config }) {
|
||||
({ rootDir, fileExtensions } = config);
|
||||
const options = {
|
||||
rootDir,
|
||||
// allow resolving polyfills for nodejs libs
|
||||
preferBuiltins: false,
|
||||
extensions: fileExtensions,
|
||||
...(typeof config.nodeResolve === 'object' ? config.nodeResolve : {}),
|
||||
};
|
||||
nodeResolve = createRollupResolve(options);
|
||||
|
||||
// call buildStart
|
||||
const preserveSymlinks = options?.customResolveOptions?.preserveSymlinks;
|
||||
nodeResolve.buildStart?.call(fakePluginContext as any, { preserveSymlinks });
|
||||
},
|
||||
|
||||
async resolveImport({ source, context }) {
|
||||
if (whatwgUrl.parseURL(source) != null) {
|
||||
// don't resolve urls
|
||||
return source;
|
||||
}
|
||||
const [withoutHash, hash] = source.split('#');
|
||||
const [importPath, params] = withoutHash.split('?');
|
||||
|
||||
const relativeImport = importPath.startsWith('.') || importPath.startsWith('/');
|
||||
const jsFileImport = fileExtensions.includes(path.extname(importPath));
|
||||
// for performance, don't resolve relative imports of js files. we only do this for js files,
|
||||
// because an import like ./foo/bar.css might actually need to resolve to ./foo/bar.css.js
|
||||
if (relativeImport && jsFileImport) {
|
||||
return source;
|
||||
}
|
||||
|
||||
const fileUrl = new URL(`.${context.path}`, `${pathToFileURL(rootDir)}/`);
|
||||
const filePath = fileURLToPath(fileUrl);
|
||||
|
||||
// do the actual resolve using the rolluo plugin
|
||||
const result = await nodeResolve.resolveId?.call(
|
||||
fakePluginContext as any,
|
||||
importPath,
|
||||
filePath,
|
||||
);
|
||||
let resolvedImportFilePath;
|
||||
|
||||
if (result) {
|
||||
if (typeof result === 'string') {
|
||||
resolvedImportFilePath = result;
|
||||
} else if (typeof result.id === 'string') {
|
||||
resolvedImportFilePath = result.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedImportFilePath) {
|
||||
throw new Error(
|
||||
`Could not resolve import "${importPath}" in "${path.relative(
|
||||
process.cwd(),
|
||||
filePath,
|
||||
)}".`,
|
||||
);
|
||||
}
|
||||
|
||||
const resolveRelativeTo = path.extname(filePath) ? path.dirname(filePath) : filePath;
|
||||
const relativeImportFilePath = path.relative(resolveRelativeTo, resolvedImportFilePath);
|
||||
const suffix = `${params ? `?${params}` : ''}${hash ? `#${hash}` : ''}`;
|
||||
const resolvedImportPath = `${toBrowserPath(relativeImportFilePath)}${suffix}`;
|
||||
return resolvedImportPath.startsWith('/') || resolvedImportPath.startsWith('.')
|
||||
? resolvedImportPath
|
||||
: `./${resolvedImportPath}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
144
packages/es-dev-server/src/plugins/polyfillsLoaderPlugin.ts
Normal file
144
packages/es-dev-server/src/plugins/polyfillsLoaderPlugin.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import path from 'path';
|
||||
import { GeneratedFile } from 'polyfills-loader';
|
||||
|
||||
import { injectPolyfillsLoader } from '../utils/inject-polyfills-loader';
|
||||
import { toBrowserPath, isInlineScript, RequestCancelledError, toFilePath } from '../utils/utils';
|
||||
import { getUserAgentCompat } from '../utils/user-agent-compat';
|
||||
import { PolyfillsLoaderConfig } from '../utils/inject-polyfills-loader';
|
||||
import { Plugin } from '../Plugin';
|
||||
|
||||
interface IndexHTMLData {
|
||||
indexHTML: string;
|
||||
lastModified: string;
|
||||
inlineScripts: GeneratedFile[];
|
||||
}
|
||||
|
||||
export interface PolyfillsLoaderMiddlewareConfig {}
|
||||
|
||||
/**
|
||||
* Creates plugin which injects polyfills and code into HTML pages which allows
|
||||
* it to run on legacy browsers.
|
||||
*/
|
||||
export function polyfillsLoaderPlugin(): Plugin {
|
||||
// index html data, keyed by url
|
||||
const indexHTMLData = new Map<string, IndexHTMLData>();
|
||||
// polyfills, keyed by request path
|
||||
const polyfills = new Map<string, string>();
|
||||
|
||||
let compatibilityMode: string;
|
||||
let rootDir: string;
|
||||
let polyfillsLoaderConfig: Partial<PolyfillsLoaderConfig>;
|
||||
|
||||
return {
|
||||
serverStart({ config }) {
|
||||
({ compatibilityMode, rootDir, polyfillsLoaderConfig = {} } = config);
|
||||
},
|
||||
|
||||
async serve(context) {
|
||||
const uaCompat = getUserAgentCompat(context);
|
||||
|
||||
/**
|
||||
* serve extracted inline module if url matches. an inline module requests has this
|
||||
* structure:
|
||||
* `/inline-script-<index>?source=<index-html-path>`
|
||||
* for example:
|
||||
* `/inline-script-2?source=/src/index-html`
|
||||
* source query parameter is the index.html the inline module came from, index is the index
|
||||
* of the inline module in that index.html. We use these to look up the correct code to
|
||||
* serve
|
||||
*/
|
||||
if (isInlineScript(context.url)) {
|
||||
const sourcePath = context.URL.searchParams.get('source');
|
||||
if (!sourcePath) {
|
||||
throw new Error(`${context.url} is missing a source param`);
|
||||
}
|
||||
|
||||
const data = indexHTMLData.get(`${uaCompat.browserTarget}${sourcePath}`);
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const name = path.basename(context.path);
|
||||
const inlineScript = data.inlineScripts.find(f => f.path.split('?')[0] === name);
|
||||
if (!inlineScript) {
|
||||
throw new Error(`Could not find inline module for ${context.url}`);
|
||||
}
|
||||
|
||||
return {
|
||||
body: inlineScript.content,
|
||||
headers: {
|
||||
'cache-control': 'no-cache',
|
||||
'last-modified': data.lastModified,
|
||||
} as Record<string, string>,
|
||||
};
|
||||
}
|
||||
|
||||
// serve polyfill from memory if url matches
|
||||
const polyfill = polyfills.get(context.url);
|
||||
if (polyfill) {
|
||||
// aggresively cache polyfills, they are hashed so content changes bust the cache
|
||||
return { body: polyfill, headers: { 'cache-control': 'public, max-age=31536000' } };
|
||||
}
|
||||
},
|
||||
|
||||
async transform(context) {
|
||||
// check if we are serving a HTML file
|
||||
if (!context.response.is('html')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const uaCompat = getUserAgentCompat(context);
|
||||
const lastModified = context.response.headers['last-modified'];
|
||||
|
||||
// return cached index.html if it did not change
|
||||
const data = indexHTMLData.get(`${uaCompat.browserTarget}${context.path}`);
|
||||
// if there is no lastModified cached, the HTML file is not served from the
|
||||
// file system
|
||||
if (data && data?.lastModified === lastModified) {
|
||||
return { body: data.indexHTML };
|
||||
}
|
||||
|
||||
const indexFilePath = path.join(rootDir, toFilePath(context.path));
|
||||
try {
|
||||
// transforms index.html to make the code load correctly with the right polyfills and shims
|
||||
const result = await injectPolyfillsLoader({
|
||||
htmlString: context.body,
|
||||
indexUrl: context.url,
|
||||
indexFilePath,
|
||||
compatibilityMode,
|
||||
polyfillsLoaderConfig,
|
||||
uaCompat,
|
||||
});
|
||||
|
||||
// set new index.html
|
||||
context.body = result.indexHTML;
|
||||
|
||||
const polyfillsMap = new Map();
|
||||
result.polyfills.forEach(file => {
|
||||
polyfillsMap.set(file.path, file);
|
||||
});
|
||||
|
||||
// cache index for later use
|
||||
indexHTMLData.set(`${uaCompat.browserTarget}${context.url}`, {
|
||||
...result,
|
||||
inlineScripts: result.inlineScripts,
|
||||
lastModified,
|
||||
});
|
||||
|
||||
// cache polyfills for serving
|
||||
result.polyfills.forEach(p => {
|
||||
let root = context.path.endsWith('/') ? context.path : path.posix.dirname(context.path);
|
||||
if (!root.endsWith('/')) {
|
||||
root = `${root}/`;
|
||||
}
|
||||
polyfills.set(`${root}${toBrowserPath(p.path)}`, p.content);
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof RequestCancelledError) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
234
packages/es-dev-server/src/plugins/resolveModuleImportsPlugin.ts
Normal file
234
packages/es-dev-server/src/plugins/resolveModuleImportsPlugin.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import deepmerge from 'deepmerge';
|
||||
import { URL, pathToFileURL, fileURLToPath } from 'url';
|
||||
import { Context } from 'koa';
|
||||
//@ts-ignore
|
||||
import { parse } from 'es-module-lexer';
|
||||
import {
|
||||
getAttribute,
|
||||
getTextContent,
|
||||
remove,
|
||||
setTextContent,
|
||||
} from '@open-wc/building-utils/dom5-fork';
|
||||
import { findJsScripts } from '@open-wc/building-utils';
|
||||
import {
|
||||
parse as parseHtml,
|
||||
serialize as serializeHtml,
|
||||
Document as DocumentAst,
|
||||
Node as NodeAst,
|
||||
} from 'parse5';
|
||||
import { Plugin } from '../Plugin';
|
||||
import { createBabelTransform, defaultConfig } from '../utils/babel-transform';
|
||||
|
||||
export type ResolveImport = (source: string) => string | undefined | Promise<string | undefined>;
|
||||
|
||||
interface ParsedImport {
|
||||
s: number;
|
||||
e: number;
|
||||
ss: number;
|
||||
se: number;
|
||||
d: number;
|
||||
}
|
||||
|
||||
const CONCAT_NO_PACKAGE_ERROR =
|
||||
'Dynamic import with a concatenated string should start with a valid full package name.';
|
||||
|
||||
const babelTransform = createBabelTransform(
|
||||
// @ts-ignore
|
||||
deepmerge(defaultConfig, {
|
||||
babelrc: false,
|
||||
configFile: false,
|
||||
}),
|
||||
);
|
||||
|
||||
async function createSyntaxError(code: string, filePath: string, originalError: Error) {
|
||||
// if es-module-lexer cannot parse the file, use babel to generate a user-friendly error message
|
||||
await babelTransform(filePath, code);
|
||||
// ResolveSyntaxError is thrown when resolveModuleImports runs into a syntax error from
|
||||
// the lexer, but babel didn't see any errors. this means either a bug in the lexer, or
|
||||
// some experimental syntax. log a message and return the module untransformed to the
|
||||
// browser
|
||||
console.error(
|
||||
`Could not resolve module imports in ${filePath}: Unable to parse the module, this can be due to experimental syntax or a bug in the parser.`,
|
||||
);
|
||||
throw originalError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an import which is a concatenated string (for ex. import('my-package/files/${filename}'))
|
||||
*
|
||||
* Resolving is done by taking the package name and resolving that, then prefixing the resolves package
|
||||
* to the import. This requires the full package name to be present in the string.
|
||||
*/
|
||||
async function resolveConcatenatedImport(
|
||||
importSpecifier: string,
|
||||
resolveImport: ResolveImport,
|
||||
): Promise<string> {
|
||||
let pathToResolve = importSpecifier;
|
||||
let pathToAppend = '';
|
||||
|
||||
const parts = importSpecifier.split('/');
|
||||
if (importSpecifier.startsWith('@')) {
|
||||
if (parts.length < 2) {
|
||||
throw new Error(CONCAT_NO_PACKAGE_ERROR);
|
||||
}
|
||||
|
||||
pathToResolve = `${parts[0]}/${parts[1]}`;
|
||||
pathToAppend = parts.slice(2, parts.length).join('/');
|
||||
} else {
|
||||
if (parts.length < 1) {
|
||||
throw new Error(CONCAT_NO_PACKAGE_ERROR);
|
||||
}
|
||||
|
||||
[pathToResolve] = parts;
|
||||
pathToAppend = parts.slice(1, parts.length).join('/');
|
||||
}
|
||||
|
||||
// TODO: instead of package, we could resolve the bare import and take the first one or two segments
|
||||
// this will make it less hardcoded to node resolution
|
||||
const packagePath = `${pathToResolve}/package.json`;
|
||||
const resolvedPackage = await resolveImport(packagePath);
|
||||
if (!resolvedPackage) {
|
||||
throw new Error(`Could not resolve conatenated dynamic import, could not find ${packagePath}`);
|
||||
}
|
||||
|
||||
const packageDir = resolvedPackage.substring(0, resolvedPackage.length - 'package.json'.length);
|
||||
return `${packageDir}${pathToAppend}`;
|
||||
}
|
||||
|
||||
async function maybeResolveImport(
|
||||
importSpecifier: string,
|
||||
concatenatedString: boolean,
|
||||
resolveImport: ResolveImport,
|
||||
) {
|
||||
let resolvedImportFilePath;
|
||||
|
||||
if (concatenatedString) {
|
||||
// if this dynamic import is a concatenated string, try our best to resolve. Otherwise leave it untouched and resolve it at runtime.
|
||||
try {
|
||||
resolvedImportFilePath =
|
||||
(await resolveConcatenatedImport(importSpecifier, resolveImport)) ?? importSpecifier;
|
||||
} catch (error) {
|
||||
return importSpecifier;
|
||||
}
|
||||
} else {
|
||||
resolvedImportFilePath = (await resolveImport(importSpecifier)) ?? importSpecifier;
|
||||
}
|
||||
return resolvedImportFilePath;
|
||||
}
|
||||
|
||||
export async function resolveModuleImports(
|
||||
code: string,
|
||||
filePath: string,
|
||||
resolveImport: ResolveImport,
|
||||
) {
|
||||
let imports: ParsedImport[];
|
||||
try {
|
||||
[imports] = await parse(code, filePath);
|
||||
} catch (error) {
|
||||
throw await createSyntaxError(code, filePath, error);
|
||||
}
|
||||
|
||||
let resolvedSource = '';
|
||||
let lastIndex = 0;
|
||||
|
||||
for (const imp of imports) {
|
||||
const { s: start, e: end, d: dynamicImportIndex } = imp;
|
||||
|
||||
if (dynamicImportIndex === -1) {
|
||||
// static import
|
||||
const importSpecifier = code.substring(start, end);
|
||||
const resolvedImport = await maybeResolveImport(importSpecifier, false, resolveImport);
|
||||
|
||||
resolvedSource += `${code.substring(lastIndex, start)}${resolvedImport}`;
|
||||
lastIndex = end;
|
||||
} else if (dynamicImportIndex >= 0) {
|
||||
// dynamic import
|
||||
const dynamicStart = start + 1;
|
||||
const dynamicEnd = end - 1;
|
||||
|
||||
const importSpecifier = code.substring(dynamicStart, dynamicEnd);
|
||||
const stringSymbol = code[dynamicStart - 1];
|
||||
const isStringLiteral = [`\``, "'", '"'].includes(stringSymbol);
|
||||
const concatenatedString =
|
||||
stringSymbol === `\`` || importSpecifier.includes("'") || importSpecifier.includes('"');
|
||||
const resolvedImport = isStringLiteral
|
||||
? await maybeResolveImport(importSpecifier, concatenatedString, resolveImport)
|
||||
: importSpecifier;
|
||||
|
||||
resolvedSource += `${code.substring(lastIndex, dynamicStart)}${resolvedImport}`;
|
||||
lastIndex = dynamicEnd;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastIndex < code.length - 1) {
|
||||
resolvedSource += `${code.substring(lastIndex, code.length)}`;
|
||||
}
|
||||
|
||||
return resolvedSource;
|
||||
}
|
||||
|
||||
async function resolveWithPluginHooks(
|
||||
context: Context,
|
||||
jsCode: string,
|
||||
rootDir: string,
|
||||
resolvePlugins: Plugin[],
|
||||
) {
|
||||
const fileUrl = new URL(context.path, `${pathToFileURL(rootDir)}/`);
|
||||
const filePath = fileURLToPath(fileUrl);
|
||||
|
||||
async function resolveImport(source: string) {
|
||||
for (const plugin of resolvePlugins) {
|
||||
const resolved = await plugin.resolveImport?.({ source, context });
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
return resolveModuleImports(jsCode, filePath, resolveImport);
|
||||
}
|
||||
|
||||
export function resolveModuleImportsPlugin(): Plugin {
|
||||
let rootDir: string;
|
||||
let resolvePlugins: Plugin[];
|
||||
|
||||
return {
|
||||
serverStart({ config }) {
|
||||
({ rootDir } = config);
|
||||
resolvePlugins = config.plugins.filter(pl => !!pl.resolveImport);
|
||||
},
|
||||
|
||||
async transform(context) {
|
||||
if (resolvePlugins.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// served JS code
|
||||
if (context.response.is('js')) {
|
||||
const bodyWithResolvedImports = await resolveWithPluginHooks(
|
||||
context,
|
||||
context.body,
|
||||
rootDir,
|
||||
resolvePlugins,
|
||||
);
|
||||
return { body: bodyWithResolvedImports };
|
||||
}
|
||||
|
||||
// resolve inline scripts
|
||||
if (context.response.is('html')) {
|
||||
const documentAst = parseHtml(context.body);
|
||||
const scriptNodes = findJsScripts(documentAst, {
|
||||
jsScripts: true,
|
||||
jsModules: true,
|
||||
inlineJsScripts: true,
|
||||
});
|
||||
|
||||
for (const node of scriptNodes) {
|
||||
const code = getTextContent(node);
|
||||
const resolvedCode = await resolveWithPluginHooks(context, code, rootDir, resolvePlugins);
|
||||
setTextContent(node, resolvedCode);
|
||||
}
|
||||
|
||||
return { body: serializeHtml(documentAst) };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -46,10 +46,17 @@ export async function startServer(cfg: ParsedConfig, fileWatcher = chokidar.watc
|
||||
stopServer();
|
||||
});
|
||||
|
||||
// Deprecated: replaced by plugins
|
||||
if (cfg.onServerStart) {
|
||||
await cfg.onServerStart(cfg);
|
||||
}
|
||||
|
||||
// call server start plugin hooks in parallel
|
||||
const startHooks = cfg.plugins
|
||||
.filter(pl => !!pl.serverStart)
|
||||
.map(pl => pl.serverStart?.({ config: cfg, app, server, fileWatcher }));
|
||||
await Promise.all(startHooks);
|
||||
|
||||
// start the server, open the browser and log messages
|
||||
await new Promise(resolve =>
|
||||
server.listen({ port, host: cfg.hostname }, () => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { TransformOptions } from '@babel/core';
|
||||
import { Options } from '@rollup/plugin-node-resolve';
|
||||
import { BabelTransform } from './babel-transform';
|
||||
import { UserAgentCompat } from './user-agent-compat';
|
||||
import { ResolveModuleImports } from './resolve-module-imports';
|
||||
import minimatch from 'minimatch';
|
||||
import {
|
||||
createCompatibilityBabelTransform,
|
||||
@@ -35,10 +34,7 @@ export interface FileData {
|
||||
|
||||
export type TransformJs = (file: FileData) => Promise<string>;
|
||||
|
||||
export function createCompatibilityTransform(
|
||||
cfg: CompatibilityTransformConfig,
|
||||
resolveModuleImports?: ResolveModuleImports,
|
||||
): TransformJs {
|
||||
export function createCompatibilityTransform(cfg: CompatibilityTransformConfig): TransformJs {
|
||||
/** @type {Map} */
|
||||
const babelTransforms = new Map<string, BabelTransform>();
|
||||
const minCompatibilityTransform = createMinCompatibilityBabelTransform(cfg);
|
||||
@@ -146,13 +142,11 @@ export function createCompatibilityTransform(
|
||||
async function compatibilityTransform(file: FileData) {
|
||||
const excludeFromBabel = cfg.babelExclude.some(pattern => minimatch(file.filePath, pattern));
|
||||
const transformBabel = !excludeFromBabel && shouldTransformBabel(file);
|
||||
const transformModuleImports = !excludeFromBabel && cfg.nodeResolve;
|
||||
const transformModules = shouldTransformModules(file);
|
||||
let transformedCode = file.code;
|
||||
|
||||
logDebug(
|
||||
`Compatibility transform babel: ${transformBabel}, ` +
|
||||
`imports: ${transformModuleImports}, ` +
|
||||
`modules: ${transformModules} ` +
|
||||
`for request: ${file.filePath}`,
|
||||
);
|
||||
@@ -166,14 +160,6 @@ export function createCompatibilityTransform(
|
||||
transformedCode = await compatTransform(file.filePath, transformedCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve module imports. This isn't a babel plugin because if only node-resolve is configured,
|
||||
* we don't need to run babel which makes it a lot faster
|
||||
*/
|
||||
if (transformModuleImports && resolveModuleImports) {
|
||||
transformedCode = await resolveModuleImports(file.filePath, transformedCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* If this browser doesn't support es modules, compile it to systemjs using babel.
|
||||
*/
|
||||
|
||||
@@ -40,7 +40,6 @@ export interface InjectPolyfillsLoaderConfig {
|
||||
htmlString: string;
|
||||
indexUrl: string;
|
||||
indexFilePath: string;
|
||||
transformJs: TransformJs;
|
||||
polyfillsLoaderConfig?: Partial<PolyfillsLoaderConfig>;
|
||||
}
|
||||
|
||||
@@ -94,10 +93,7 @@ function getPolyfillsConfig(cfg: InjectPolyfillsLoaderConfig): PolyfillsConfig {
|
||||
}
|
||||
|
||||
function findScripts(cfg: InjectPolyfillsLoaderConfig, documentAst: DocumentAst) {
|
||||
const scriptNodes = findJsScripts(
|
||||
documentAst,
|
||||
cfg.polyfillsLoaderConfig && cfg.polyfillsLoaderConfig.exclude,
|
||||
);
|
||||
const scriptNodes = findJsScripts(documentAst, cfg.polyfillsLoaderConfig?.exclude);
|
||||
|
||||
const files: File[] = [];
|
||||
const inlineScripts: GeneratedFile[] = [];
|
||||
@@ -133,34 +129,6 @@ function hasPolyfills(polyfills?: PolyfillsConfig) {
|
||||
return (custom && custom.length > 0) || Object.values(rest).some(v => v !== false);
|
||||
}
|
||||
|
||||
async function transformInlineScripts(
|
||||
cfg: InjectPolyfillsLoaderConfig,
|
||||
inlineScriptNodes: NodeAst[],
|
||||
) {
|
||||
const asyncTransforms = [];
|
||||
|
||||
for (const scriptNode of inlineScriptNodes) {
|
||||
// we need to refer to an actual file for node resolve to work properly
|
||||
const filePath = cfg.indexFilePath.endsWith(path.sep)
|
||||
? path.join(cfg.indexFilePath, 'index.html')
|
||||
: cfg.indexFilePath;
|
||||
|
||||
const asyncTransform = cfg
|
||||
.transformJs({
|
||||
filePath,
|
||||
uaCompat: cfg.uaCompat,
|
||||
code: getTextContent(scriptNode),
|
||||
transformModule: false,
|
||||
})
|
||||
.then((code: string) => {
|
||||
setTextContent(scriptNode, code);
|
||||
});
|
||||
asyncTransforms.push(asyncTransform);
|
||||
}
|
||||
|
||||
await Promise.all(asyncTransforms);
|
||||
}
|
||||
|
||||
/**
|
||||
* transforms index.html, extracting any modules and import maps and adds them back
|
||||
* with the appropriate polyfills, shims and a script loader so that they can be loaded
|
||||
@@ -175,7 +143,7 @@ export async function injectPolyfillsLoader(
|
||||
cfg.compatibilityMode === compatibilityModes.MAX;
|
||||
|
||||
const documentAst = parse(cfg.htmlString);
|
||||
const { files, inlineScripts, scriptNodes, inlineScriptNodes } = findScripts(cfg, documentAst);
|
||||
const { files, inlineScripts, scriptNodes } = findScripts(cfg, documentAst);
|
||||
|
||||
const polyfillsConfig = getPolyfillsConfig(cfg);
|
||||
const polyfillsLoaderConfig = deepmerge(
|
||||
@@ -194,12 +162,6 @@ export async function injectPolyfillsLoader(
|
||||
|
||||
if (!hasPolyfills(polyfillsLoaderConfig.polyfills) && !polyfillModules) {
|
||||
// no polyfils module polyfills, so we don't need to inject a loader
|
||||
if (inlineScripts && inlineScripts.length > 0) {
|
||||
// there are inline scripts, we need to transform them
|
||||
// transformInlineScripts mutates documentAst
|
||||
await transformInlineScripts(cfg, inlineScriptNodes);
|
||||
return { indexHTML: serialize(documentAst), inlineScripts, polyfills: [] };
|
||||
}
|
||||
return { indexHTML: cfg.htmlString, inlineScripts: [], polyfills: [] };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
/* eslint-disable no-await-in-loop, no-restricted-syntax, no-console, max-classes-per-file */
|
||||
import whatwgUrl from 'whatwg-url';
|
||||
import pathIsInside from 'path-is-inside';
|
||||
import deepmerge from 'deepmerge';
|
||||
//@ts-ignore
|
||||
import { parse } from 'es-module-lexer';
|
||||
import createRollupResolve, { Options as NodeResolveOptions } from '@rollup/plugin-node-resolve';
|
||||
import path from 'path';
|
||||
import { toBrowserPath } from './utils';
|
||||
import { createBabelTransform, defaultConfig } from './babel-transform';
|
||||
const nodeResolvePackageJson = require('@rollup/plugin-node-resolve/package.json');
|
||||
|
||||
interface ResolveConfig {
|
||||
rootDir: string;
|
||||
fileExtensions: string[];
|
||||
nodeResolve: (importee: string, importer: string) => Promise<string>;
|
||||
}
|
||||
|
||||
interface ParsedImport {
|
||||
s: number;
|
||||
e: number;
|
||||
ss: number;
|
||||
se: number;
|
||||
d: number;
|
||||
}
|
||||
|
||||
export type ResolveModuleImports = (importer: string, source: string) => Promise<string>;
|
||||
|
||||
const CONCAT_NO_PACKAGE_ERROR =
|
||||
'Dynamic import with a concatenated string should start with a valid full package name.';
|
||||
|
||||
const babelTransform = createBabelTransform(
|
||||
// @ts-ignore
|
||||
deepmerge(defaultConfig, {
|
||||
babelrc: false,
|
||||
configFile: false,
|
||||
}),
|
||||
);
|
||||
|
||||
export class ResolveSyntaxError extends Error {}
|
||||
export class ModuleNotFoundError extends Error {}
|
||||
|
||||
/**
|
||||
* Resolves an import which is a concatenated string (for ex. import('my-package/files/${filename}'))
|
||||
*
|
||||
* Resolving is done by taking the package name and resolving that, then prefixing the resolves package
|
||||
* to the import. This requires the full package name to be present in the string.
|
||||
*/
|
||||
async function resolveConcatenatedImport(
|
||||
importer: string,
|
||||
importee: string,
|
||||
cfg: ResolveConfig,
|
||||
): Promise<string> {
|
||||
let pathToResolve = importee;
|
||||
let pathToAppend = '';
|
||||
|
||||
const parts = importee.split('/');
|
||||
if (importee.startsWith('@')) {
|
||||
if (parts.length < 2) {
|
||||
throw new Error(CONCAT_NO_PACKAGE_ERROR);
|
||||
}
|
||||
|
||||
pathToResolve = `${parts[0]}/${parts[1]}`;
|
||||
pathToAppend = parts.slice(2, parts.length).join('/');
|
||||
} else {
|
||||
if (parts.length < 1) {
|
||||
throw new Error(CONCAT_NO_PACKAGE_ERROR);
|
||||
}
|
||||
|
||||
[pathToResolve] = parts;
|
||||
pathToAppend = parts.slice(1, parts.length).join('/');
|
||||
}
|
||||
|
||||
const resolvedPackage = await cfg.nodeResolve(`${pathToResolve}/package.json`, importer);
|
||||
|
||||
const packageDir = resolvedPackage.substring(0, resolvedPackage.length - 'package.json'.length);
|
||||
return `${packageDir}${pathToAppend}`;
|
||||
}
|
||||
|
||||
async function createSyntaxError(sourceFilename: string, source: string, originalError: Error) {
|
||||
// if es-module-lexer cannot parse the file, use babel to generate a user-friendly error message
|
||||
await babelTransform(sourceFilename, source);
|
||||
// if babel did not have any error, throw a syntax error and log the original error
|
||||
console.error(originalError);
|
||||
return new ResolveSyntaxError();
|
||||
}
|
||||
|
||||
async function maybeResolveImport(
|
||||
importer: string,
|
||||
importee: string,
|
||||
concatenatedString: boolean,
|
||||
cfg: ResolveConfig,
|
||||
) {
|
||||
// don't touch url imports
|
||||
if (whatwgUrl.parseURL(importee) !== null) {
|
||||
return importee;
|
||||
}
|
||||
|
||||
const relativeImport = importee.startsWith('.') || importee.startsWith('/');
|
||||
const jsFileImport = cfg.fileExtensions.includes(path.extname(importee));
|
||||
|
||||
// for performance, don't resolve relative imports of js files. we only do this for js files,
|
||||
// because an import like ./foo/bar.css might actually need to resolve to ./foo/bar.css.js
|
||||
if (relativeImport && jsFileImport) {
|
||||
return importee;
|
||||
}
|
||||
|
||||
const sourceFileDir = path.dirname(importer);
|
||||
|
||||
try {
|
||||
let resolvedImportFilePath;
|
||||
|
||||
if (concatenatedString) {
|
||||
// if this dynamic import is a concatenated string, try our best to resolve. Otherwise leave it untouched and resolve it at runtime.
|
||||
try {
|
||||
resolvedImportFilePath = await resolveConcatenatedImport(importer, importee, cfg);
|
||||
} catch (error) {
|
||||
return importee;
|
||||
}
|
||||
} else {
|
||||
resolvedImportFilePath = await cfg.nodeResolve(importee, importer);
|
||||
}
|
||||
|
||||
if (!pathIsInside(resolvedImportFilePath, cfg.rootDir)) {
|
||||
throw new Error(
|
||||
`Import "${importee}" resolved to the file "${resolvedImportFilePath}" which is outside the web server root, and cannot be served ` +
|
||||
'by es-dev-server. Install the module locally in the current project, or expand the root directory. ' +
|
||||
'If this is a symlink or if you used npm link, you can run es-dev-server with the --preserve-symlinks option',
|
||||
);
|
||||
}
|
||||
|
||||
const relativeImportFilePath = path.relative(sourceFileDir, resolvedImportFilePath);
|
||||
const resolvedimportee = toBrowserPath(relativeImportFilePath);
|
||||
return resolvedimportee.startsWith('.') ? resolvedimportee : `./${resolvedimportee}`;
|
||||
} catch (error) {
|
||||
// make module not found error message shorter
|
||||
if (error instanceof ModuleNotFoundError) {
|
||||
const relativeImportFilePath = path.relative(cfg.rootDir, importer);
|
||||
const resolvedimportee = toBrowserPath(relativeImportFilePath);
|
||||
const relativePathToErrorFile = resolvedimportee.startsWith('.')
|
||||
? resolvedimportee
|
||||
: `./${resolvedimportee}`;
|
||||
throw new Error(`Could not resolve import "${importee}" in "${relativePathToErrorFile}".`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getImportee(importee: string) {
|
||||
const [withoutParams, params] = importee.split('?');
|
||||
const [withoutHash, hash] = withoutParams.split('#');
|
||||
return [withoutHash, `${params ? `?${params}` : ''}${hash ? `#${hash}` : ''}`];
|
||||
}
|
||||
|
||||
async function resolveModuleImportsWithConfig(
|
||||
importer: string,
|
||||
source: string,
|
||||
cfg: ResolveConfig,
|
||||
) {
|
||||
let imports: ParsedImport[];
|
||||
try {
|
||||
[imports] = await parse(source, importer);
|
||||
} catch (error) {
|
||||
throw await createSyntaxError(importer, source, error);
|
||||
}
|
||||
|
||||
let resolvedSource = '';
|
||||
let lastIndex = 0;
|
||||
|
||||
for (const imp of imports) {
|
||||
const { s: start, e: end, d: dynamicImportIndex } = imp;
|
||||
|
||||
if (dynamicImportIndex === -1) {
|
||||
// static import
|
||||
const [importee, importeeSuffix] = getImportee(source.substring(start, end));
|
||||
const resolvedimportee = await maybeResolveImport(importer, importee, false, cfg);
|
||||
|
||||
resolvedSource += `${source.substring(lastIndex, start)}${resolvedimportee}${importeeSuffix}`;
|
||||
lastIndex = end;
|
||||
} else if (dynamicImportIndex >= 0) {
|
||||
// dynamic import
|
||||
const dynamicStart = start + 1;
|
||||
const dynamicEnd = end - 1;
|
||||
|
||||
const [importee, importeeSuffix] = getImportee(source.substring(dynamicStart, dynamicEnd));
|
||||
const stringSymbol = source[dynamicStart - 1];
|
||||
const isStringLiteral = [`\``, "'", '"'].includes(stringSymbol);
|
||||
const concatenatedString =
|
||||
stringSymbol === `\`` || importee.includes("'") || importee.includes('"');
|
||||
const resolvedimportee = isStringLiteral
|
||||
? await maybeResolveImport(importer, importee, concatenatedString, cfg)
|
||||
: importee;
|
||||
|
||||
resolvedSource += `${source.substring(
|
||||
lastIndex,
|
||||
dynamicStart,
|
||||
)}${resolvedimportee}${importeeSuffix}`;
|
||||
lastIndex = dynamicEnd;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastIndex < source.length - 1) {
|
||||
resolvedSource += `${source.substring(lastIndex, source.length)}`;
|
||||
}
|
||||
|
||||
return resolvedSource;
|
||||
}
|
||||
|
||||
const fakePluginContext = {
|
||||
meta: {
|
||||
rollupVersion: nodeResolvePackageJson.peerDependencies.rollup,
|
||||
},
|
||||
warn(...msg: string[]) {
|
||||
console.warn('[es-dev-server] node-resolve: ', ...msg);
|
||||
},
|
||||
};
|
||||
|
||||
export function createResolveModuleImports(
|
||||
rootDir: string,
|
||||
fileExtensions: string[],
|
||||
opts: NodeResolveOptions = {},
|
||||
): ResolveModuleImports {
|
||||
const rollupResolve = createRollupResolve({
|
||||
rootDir,
|
||||
// allow resolving polyfills for nodejs libs
|
||||
preferBuiltins: false,
|
||||
extensions: fileExtensions,
|
||||
...opts,
|
||||
});
|
||||
|
||||
const preserveSymlinks =
|
||||
(opts && opts.customResolveOptions && opts.customResolveOptions.preserveSymlinks) || false;
|
||||
(rollupResolve as any).buildStart.call(fakePluginContext as any, { preserveSymlinks });
|
||||
|
||||
async function nodeResolve(importee: string, importer: string) {
|
||||
const result = await (rollupResolve as any).resolveId.call(
|
||||
fakePluginContext as any,
|
||||
importee,
|
||||
importer,
|
||||
);
|
||||
if (!result || !result.id) {
|
||||
throw new ModuleNotFoundError();
|
||||
}
|
||||
return result.id;
|
||||
}
|
||||
|
||||
return (importer, source) =>
|
||||
resolveModuleImportsWithConfig(importer, source, {
|
||||
rootDir,
|
||||
fileExtensions,
|
||||
nodeResolve,
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import isStream from 'is-stream';
|
||||
import getStream from 'get-stream';
|
||||
import Stream from 'stream';
|
||||
import path from 'path';
|
||||
import mimeTypes from 'mime-types';
|
||||
import { Context, Request } from 'koa';
|
||||
import { isBinaryFile } from 'isbinaryfile';
|
||||
import { virtualFilePrefix } from '../constants';
|
||||
@@ -29,6 +30,11 @@ export class IsBinaryFileError extends Error {}
|
||||
*/
|
||||
const filePathsForRequests = new WeakMap<Request, string>();
|
||||
|
||||
export function isUtf8(context: Context) {
|
||||
const charSet = mimeTypes.charset(context.response.get('content-type'));
|
||||
return charSet === false ? false : charSet.toLowerCase() === 'utf-8';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the body value as string. If the response is a stream, the
|
||||
* stream is drained and the result is returned. Because koa-static stores
|
||||
@@ -113,9 +119,11 @@ export class SSEStream extends Stream.Transform {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the index html response, or null if this wasn't an index.html response
|
||||
* Returns whether this is a HTML document response
|
||||
*
|
||||
* TODO: Rename to isHTMLResponse
|
||||
*/
|
||||
export async function isIndexHTMLResponse(ctx: Context, appIndex?: string): Promise<boolean> {
|
||||
export function isIndexHTMLResponse(ctx: Context, appIndex?: string): boolean {
|
||||
if (ctx.status < 200 || ctx.status >= 300) {
|
||||
return false;
|
||||
}
|
||||
@@ -141,7 +149,7 @@ export function isPolyfill(url: string) {
|
||||
return url.includes('/polyfills/');
|
||||
}
|
||||
|
||||
export function shoudlTransformToModule(url: string) {
|
||||
export function shouldTransformToModule(url: string) {
|
||||
return url.includes('transform-systemjs');
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
<!-- absolute path -->
|
||||
<script type="module" src="/src/app.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import { message } from 'my-module';
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,43 @@
|
||||
/* eslint-disable no-restricted-syntax, no-await-in-loop */
|
||||
import { expect } from 'chai';
|
||||
import fetch from 'node-fetch';
|
||||
import path from 'path';
|
||||
import { startServer as originalStartServer, createConfig } from '../../src/es-dev-server';
|
||||
|
||||
const host = 'http://localhost:8080/';
|
||||
|
||||
const defaultConfig = {
|
||||
port: 8080,
|
||||
rootDir: path.resolve(__dirname, '..', 'fixtures', 'simple'),
|
||||
compatibility: 'none',
|
||||
};
|
||||
|
||||
function startServer(...plugins) {
|
||||
return originalStartServer(
|
||||
createConfig({
|
||||
...defaultConfig,
|
||||
plugins,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe('plugin-mime-type middleware', () => {
|
||||
it('can set the mime type of a file', async () => {
|
||||
const { server } = await startServer({
|
||||
resolveMimeType(ctx) {
|
||||
if (ctx.path === '/text-files/hello-world.txt') {
|
||||
return 'js';
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`${host}text-files/hello-world.txt`);
|
||||
|
||||
expect(response.status).to.equal(200);
|
||||
expect(response.headers.get('content-type')).to.include('application/javascript');
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
155
packages/es-dev-server/test/middleware/plugin-serve.test.ts
Normal file
155
packages/es-dev-server/test/middleware/plugin-serve.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/* eslint-disable no-restricted-syntax, no-await-in-loop */
|
||||
import { expect } from 'chai';
|
||||
import fetch from 'node-fetch';
|
||||
import path from 'path';
|
||||
import { startServer as originalStartServer, createConfig } from '../../src/es-dev-server';
|
||||
import { Plugin } from '../../src/Plugin';
|
||||
|
||||
const host = 'http://localhost:8080/';
|
||||
|
||||
const defaultConfig = {
|
||||
port: 8080,
|
||||
rootDir: path.resolve(__dirname, '..', 'fixtures', 'simple'),
|
||||
compatibility: 'none',
|
||||
};
|
||||
|
||||
function startServer(...plugins: Plugin[]) {
|
||||
return originalStartServer(
|
||||
createConfig({
|
||||
...defaultConfig,
|
||||
plugins,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe('plugin-serve middleware', () => {
|
||||
it('can serve non-existing files', async () => {
|
||||
const { server } = await startServer({
|
||||
serve(ctx) {
|
||||
if (ctx.path === '/non-existing.js') {
|
||||
return { body: 'serving non-existing.js' };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`${host}non-existing.js`);
|
||||
const responseText = await response.text();
|
||||
|
||||
expect(response.status).to.equal(200);
|
||||
expect(responseText).to.include('serving non-existing.js');
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('the first plugin to serve a file wins', async () => {
|
||||
const { server } = await startServer(
|
||||
{
|
||||
serve(ctx) {
|
||||
if (ctx.path === '/non-existing.js') {
|
||||
return { body: 'serve a' };
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
serve(ctx) {
|
||||
if (ctx.path === '/non-existing.js') {
|
||||
return { body: 'serve b' };
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${host}non-existing.js`);
|
||||
const responseText = await response.text();
|
||||
|
||||
expect(response.status).to.equal(200);
|
||||
expect(responseText).to.include('serve a');
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('sets a default content type', async () => {
|
||||
const { server } = await startServer({
|
||||
serve(ctx) {
|
||||
if (ctx.path === '/non-existing.js') {
|
||||
return { body: 'serving non-existing.js' };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`${host}non-existing.js`);
|
||||
const responseText = await response.text();
|
||||
|
||||
expect(response.status).to.equal(200);
|
||||
expect(responseText).to.include('serving non-existing.js');
|
||||
expect(response.headers.get('content-type')).to.equal(
|
||||
'application/javascript; charset=utf-8',
|
||||
);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('can set the content type', async () => {
|
||||
const { server } = await startServer({
|
||||
serve(ctx) {
|
||||
if (ctx.path === '/foo.bar') {
|
||||
return { body: 'serving non-existing.html', type: 'css' };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`${host}foo.bar`);
|
||||
expect(response.status).to.equal(200);
|
||||
expect(response.headers.get('content-type')).to.equal('text/css; charset=utf-8');
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('can overwrite existing files', async () => {
|
||||
const { server } = await startServer({
|
||||
serve(ctx) {
|
||||
if (ctx.path === '/index.html') {
|
||||
return { body: 'overwritten index.html' };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`${host}index.html`);
|
||||
const responseText = await response.text();
|
||||
|
||||
expect(response.status).to.equal(200);
|
||||
expect(responseText).to.include('overwritten index.html');
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('can set headers', async () => {
|
||||
const { server } = await startServer({
|
||||
serve(ctx) {
|
||||
if (ctx.path === '/index.html') {
|
||||
return { body: '...', headers: { 'x-foo': 'bar' } };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`${host}index.html`);
|
||||
const responseText = await response.text();
|
||||
|
||||
expect(response.status).to.equal(200);
|
||||
expect(response.headers.get('x-foo')).to.equal('bar');
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
139
packages/es-dev-server/test/middleware/plugin-transform.test.ts
Normal file
139
packages/es-dev-server/test/middleware/plugin-transform.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/* eslint-disable no-restricted-syntax, no-await-in-loop */
|
||||
import { expect } from 'chai';
|
||||
import fetch from 'node-fetch';
|
||||
import path from 'path';
|
||||
import { startServer as originalStartServer, createConfig } from '../../src/es-dev-server';
|
||||
|
||||
const host = 'http://localhost:8080/';
|
||||
|
||||
const defaultConfig = {
|
||||
port: 8080,
|
||||
rootDir: path.resolve(__dirname, '..', 'fixtures', 'simple'),
|
||||
compatibility: 'none',
|
||||
};
|
||||
|
||||
function startServer(...plugins) {
|
||||
return originalStartServer(
|
||||
createConfig({
|
||||
...defaultConfig,
|
||||
plugins,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe('plugin-transform middleware', () => {
|
||||
it('can transform a served file', async () => {
|
||||
const { server } = await startServer({
|
||||
transform(ctx) {
|
||||
if (ctx.path === '/text-files/hello-world.txt') {
|
||||
return { body: `${ctx.body} injected text` };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`${host}text-files/hello-world.txt`);
|
||||
const responseText = await response.text();
|
||||
|
||||
expect(response.status).to.equal(200);
|
||||
expect(responseText).to.equal('Hello world! injected text');
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('can transform a served file from a plugin', async () => {
|
||||
const { server } = await startServer({
|
||||
serve(ctx) {
|
||||
if (ctx.path === '/non-existing.js') {
|
||||
return { body: 'my non existing file' };
|
||||
}
|
||||
},
|
||||
|
||||
transform(ctx) {
|
||||
if (ctx.path === '/non-existing.js') {
|
||||
return { body: `${ctx.body} injected text` };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`${host}non-existing.js`);
|
||||
const responseText = await response.text();
|
||||
|
||||
expect(response.status).to.equal(200);
|
||||
expect(responseText).to.equal('my non existing file injected text');
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('multiple plugins can transform a response', async () => {
|
||||
const { server } = await startServer(
|
||||
{
|
||||
transform(ctx) {
|
||||
if (ctx.path === '/text-files/hello-world.txt') {
|
||||
return { body: `${ctx.body} INJECT_A` };
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
transform(ctx) {
|
||||
if (ctx.path === '/text-files/hello-world.txt') {
|
||||
return { body: `${ctx.body} INJECT_B` };
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${host}text-files/hello-world.txt`);
|
||||
const responseText = await response.text();
|
||||
|
||||
expect(response.status).to.equal(200);
|
||||
expect(responseText).to.equal('Hello world! INJECT_A INJECT_B');
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('only handles 2xx range requests', async () => {
|
||||
const { server } = await startServer({
|
||||
transform(ctx) {
|
||||
if (ctx.path === '/non-existing.js') {
|
||||
return { body: `${ctx.body} injected text` };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`${host}non-existing.js`);
|
||||
const responseText = await response.text();
|
||||
|
||||
expect(response.status).to.equal(404);
|
||||
expect(responseText).to.equal('Not Found');
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('can set headers', async () => {
|
||||
const { server } = await startServer({
|
||||
transform(ctx) {
|
||||
if (ctx.path === '/index.html') {
|
||||
return { body: '...', headers: { 'x-foo': 'bar' } };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`${host}index.html`);
|
||||
const responseText = await response.text();
|
||||
|
||||
expect(response.status).to.equal(200);
|
||||
expect(response.headers.get('x-foo')).to.equal('bar');
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -23,14 +23,16 @@ async function fetchText(url, userAgent) {
|
||||
headers: { 'user-agent': userAgent },
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
console.log(response);
|
||||
throw new Error('Server did not respond with 200');
|
||||
}
|
||||
|
||||
expect(response.status).to.equal(200);
|
||||
return response.text();
|
||||
}
|
||||
|
||||
function expectIncludes(text: string, expected: string) {
|
||||
if (!text.includes(expected)) {
|
||||
throw new Error(`Expected "${expected}" in string: \n\n${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
interface Features {
|
||||
esModules?: boolean;
|
||||
objectSpread?: boolean;
|
||||
@@ -52,34 +54,39 @@ async function expectCompatibilityTransform(userAgent, features: Features = {})
|
||||
userAgent,
|
||||
);
|
||||
|
||||
expect(stage4Features).to.include(
|
||||
expectIncludes(
|
||||
stage4Features,
|
||||
features.objectSpread ? '_objectSpread({}, foo);' : 'bar = { ...foo',
|
||||
);
|
||||
expect(stage4Features).to.include(
|
||||
expectIncludes(
|
||||
stage4Features,
|
||||
features.asyncFunction ? '_asyncFunction = _asyncToGenerator(' : 'async function',
|
||||
);
|
||||
expect(stage4Features).to.include(features.exponentiation ? 'Math.pow(2, 4)' : '2 ** 4');
|
||||
expect(stage4Features).to.include(features.classes ? 'Foo = function Foo() {' : 'class Foo {');
|
||||
expect(stage4Features).to.include(
|
||||
/* eslint-disable-next-line no-template-curly-in-string */
|
||||
expectIncludes(stage4Features, features.exponentiation ? 'Math.pow(2, 4)' : '2 ** 4');
|
||||
expectIncludes(stage4Features, features.classes ? 'Foo = function Foo() {' : 'class Foo {');
|
||||
|
||||
expectIncludes(
|
||||
stage4Features,
|
||||
features.templateLiteral ? '"template ".concat(\'literal\')' : "template ${'literal'}",
|
||||
);
|
||||
|
||||
expect(esModules).to.include(
|
||||
expectIncludes(
|
||||
esModules,
|
||||
features.esModules
|
||||
? 'System.register(["lit-html", "./module-features-a.js"]'
|
||||
: "import module from './module-features-a.js';",
|
||||
);
|
||||
expect(esModules).to.include("import('./module-features-b.js')");
|
||||
expect(esModules).to.include(features.esModules ? 'meta.url.indexOf' : 'import.meta.url.indexOf');
|
||||
expectIncludes(esModules, "import('./module-features-b.js')");
|
||||
expectIncludes(esModules, features.esModules ? 'meta.url.indexOf' : 'import.meta.url.indexOf');
|
||||
|
||||
expect(stage4Features).to.include(
|
||||
expectIncludes(
|
||||
stage4Features,
|
||||
features.optionalChaining
|
||||
? "lorem == null ? void 0 : lorem.ipsum) === 'lorem ipsum' && (lorem == null ? void 0 : (_lorem$ipsum = lorem.ipsum) == null ? void 0 : _lorem$ipsum.foo) === undefined;"
|
||||
: 'lorem?.ipsum?.foo',
|
||||
);
|
||||
|
||||
expect(stage4Features).to.include(
|
||||
expectIncludes(
|
||||
stage4Features,
|
||||
features.nullishCoalescing
|
||||
? "(buz != null ? buz : 'nullish colaesced') === 'nullish colaesced'"
|
||||
: "buz ?? 'nullish colaesced'",
|
||||
@@ -90,11 +97,11 @@ async function expectSupportStage3(userAgent) {
|
||||
const classFields = await fetchText('stage-3-class-fields.js', userAgent);
|
||||
const privateFields = await fetchText('stage-3-private-class-fields.js', userAgent);
|
||||
|
||||
expect(classFields).to.include("myField = 'foo';");
|
||||
expect(privateFields).to.include("#foo = 'bar';");
|
||||
expectIncludes(classFields, "myField = 'foo';");
|
||||
expectIncludes(privateFields, "#foo = 'bar';");
|
||||
}
|
||||
|
||||
describe('compatibility transform middleware', () => {
|
||||
describe('babelTransformPlugin', () => {
|
||||
describe('compatibilityMode NONE', () => {
|
||||
let server;
|
||||
beforeEach(async () => {
|
||||
@@ -394,9 +401,9 @@ describe('compatibility transform middleware', () => {
|
||||
const responseText = await response.text();
|
||||
|
||||
expect(response.status).to.equal(200);
|
||||
expect(responseText).to.include('function _classCallCheck(instance, Constructor) {');
|
||||
expect(responseText).to.include('Foo = function Foo() {');
|
||||
expect(responseText).to.include("bar = 'buz';");
|
||||
expectIncludes(responseText, 'function _classCallCheck(instance, Constructor) {');
|
||||
expectIncludes(responseText, 'Foo = function Foo() {');
|
||||
expectIncludes(responseText, "bar = 'buz';");
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
@@ -421,8 +428,8 @@ describe('compatibility transform middleware', () => {
|
||||
|
||||
expect(responseHtml.status).to.equal(200);
|
||||
expect(responseFoo.status).to.equal(200);
|
||||
expect(responseTextHtml).to.include("foo = 'bar';");
|
||||
expect(responseTextFoo).to.include("bar = 'foo';");
|
||||
expectIncludes(responseTextHtml, "foo = 'bar';");
|
||||
expectIncludes(responseTextFoo, "bar = 'foo';");
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
@@ -463,10 +470,11 @@ describe('compatibility transform middleware', () => {
|
||||
const responseText = await response.text();
|
||||
|
||||
expect(response.status).to.equal(200);
|
||||
expect(responseText).to.include(
|
||||
expectIncludes(
|
||||
responseText,
|
||||
'function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }',
|
||||
);
|
||||
expect(responseText).to.include('Foo = function Foo() {');
|
||||
expectIncludes(responseText, 'Foo = function Foo() {');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -486,10 +494,11 @@ describe('compatibility transform middleware', () => {
|
||||
const responseText = await response.text();
|
||||
|
||||
expect(response.status).to.equal(200);
|
||||
expect(responseText).to.include(
|
||||
expectIncludes(
|
||||
responseText,
|
||||
"import { message } from './node_modules/my-module/index.js';",
|
||||
);
|
||||
expect(responseText).to.include('async function* asyncGenerator()');
|
||||
expectIncludes(responseText, 'async function* asyncGenerator()');
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
@@ -509,7 +518,8 @@ describe('compatibility transform middleware', () => {
|
||||
const response = await fetch(`${host}app.js?transform-systemjs`);
|
||||
const responseText = await response.text();
|
||||
expect(response.status).to.equal(200);
|
||||
expect(responseText).to.include(
|
||||
expectIncludes(
|
||||
responseText,
|
||||
'System.register(["./node_modules/my-module/index.js", "./src/local-module.js"], function',
|
||||
);
|
||||
} finally {
|
||||
@@ -543,7 +553,7 @@ describe('compatibility transform middleware', () => {
|
||||
const responseText = await response.text();
|
||||
|
||||
expect(response.status).to.equal(200);
|
||||
expect(responseText).to.include("const bar = 'buz';");
|
||||
expectIncludes(responseText, "const bar = 'buz';");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -554,73 +564,20 @@ describe('compatibility transform middleware', () => {
|
||||
createConfig({
|
||||
port: 8080,
|
||||
compatibility: compatibilityModes.MAX,
|
||||
polyfillsLoader: false,
|
||||
rootDir: path.resolve(__dirname, '..', 'fixtures', 'inline-script'),
|
||||
}),
|
||||
));
|
||||
|
||||
const indexResponse = await fetch(`${host}index.html`, {
|
||||
headers: { accept: 'text/html' },
|
||||
});
|
||||
expect(indexResponse.status).to.equal(200);
|
||||
const inlineModuleResponse = await fetch(
|
||||
`${host}${virtualFilePrefix}inline-script-1.js?source=/index.html`,
|
||||
);
|
||||
expect(inlineModuleResponse.status).to.equal(200);
|
||||
const inlineModuleText = await inlineModuleResponse.text();
|
||||
expect(inlineModuleText).to.include('function asyncGenerator() {');
|
||||
const responseText = await fetchText('index.html', userAgents['Chrome 78']);
|
||||
expectIncludes(responseText, 'function _classCallCheck(instance, Constructor)');
|
||||
expectIncludes(responseText, 'function _AwaitValue(value) { this.wrapped = value; }');
|
||||
expectIncludes(responseText, 'function _asyncGenerator() {');
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
describe('node resolve flag', () => {
|
||||
it('transforms module imports', async () => {
|
||||
const { server } = await startServer(
|
||||
createConfig({
|
||||
compatibility: compatibilityModes.NONE,
|
||||
rootDir: path.resolve(__dirname, '..', 'fixtures', 'simple'),
|
||||
port: 8080,
|
||||
nodeResolve: true,
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${host}app.js`);
|
||||
const responseText = await response.text();
|
||||
|
||||
expect(response.status).to.equal(200);
|
||||
expect(responseText).to.include(
|
||||
"import { message } from './node_modules/my-module/index.js';",
|
||||
);
|
||||
expect(responseText).to.include('async function* asyncGenerator()');
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('transforms module imports when compiling to systemjs', async () => {
|
||||
const { server } = await startServer(
|
||||
createConfig({
|
||||
compatibility: compatibilityModes.MAX,
|
||||
rootDir: path.resolve(__dirname, '..', 'fixtures', 'simple'),
|
||||
port: 8080,
|
||||
nodeResolve: true,
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${host}app.js?transform-systemjs`);
|
||||
const responseText = await response.text();
|
||||
expect(response.status).to.equal(200);
|
||||
expect(responseText).to.include(
|
||||
'System.register(["./node_modules/my-module/index.js", "./src/local-module.js"], function',
|
||||
);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('combining node resolve and compatibility', () => {
|
||||
let server;
|
||||
beforeEach(async () => {
|
||||
@@ -693,7 +650,6 @@ describe('compatibility transform middleware', () => {
|
||||
port: 8080,
|
||||
compatibility: compatibilityModes.MAX,
|
||||
rootDir: path.resolve(__dirname, '..', 'fixtures', 'simple'),
|
||||
fileExtensions: ['.html'],
|
||||
}),
|
||||
));
|
||||
});
|
||||
@@ -712,18 +668,5 @@ describe('compatibility transform middleware', () => {
|
||||
expect(response.headers.get('content-type')).to.equal('text/html; charset=utf-8');
|
||||
expect(responseText.startsWith('<html>')).to.be.true;
|
||||
});
|
||||
|
||||
it('does not transform JS files requested with content-type text/html', async () => {
|
||||
const response = await fetch(`${host}app.js`, {
|
||||
headers: { accept: 'text/html' },
|
||||
});
|
||||
const responseText = await response.text();
|
||||
|
||||
expect(response.status).to.equal(200);
|
||||
expect(response.headers.get('content-type')).to.equal(
|
||||
'application/javascript; charset=utf-8',
|
||||
);
|
||||
expect(responseText.startsWith("import { message } from 'my-module';")).to.be.true;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import path from 'path';
|
||||
import { expect } from 'chai';
|
||||
import fetch from 'node-fetch';
|
||||
import { startServer } from '../../src/start-server';
|
||||
import { createConfig } from '../../src/config';
|
||||
import { Plugin } from '../../src/Plugin';
|
||||
|
||||
const host = 'http://localhost:8080/';
|
||||
|
||||
describe('fileExtensonsPlugin', () => {
|
||||
it('serves configured file extensions as JS', async () => {
|
||||
const plugins: Plugin[] = [
|
||||
{
|
||||
serve(context) {
|
||||
console.log('serve', context.path);
|
||||
if (context.path === '/foo.bar') {
|
||||
return { body: 'console.log("foo.bar");' };
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let server;
|
||||
try {
|
||||
({ server } = await startServer(
|
||||
createConfig({
|
||||
port: 8080,
|
||||
rootDir: path.resolve(__dirname, '..', 'fixtures', 'simple'),
|
||||
compatibility: 'none',
|
||||
fileExtensions: ['.bar'],
|
||||
plugins,
|
||||
}),
|
||||
));
|
||||
|
||||
const response = await fetch(`${host}foo.bar`);
|
||||
const responseText = await response.text();
|
||||
|
||||
expect(response.status).to.equal(200);
|
||||
expect(responseText).to.include('console.log("foo.bar");');
|
||||
expect(response.headers.get('content-type')).to.include('application/javascript');
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('the content type is available in plugins', async () => {
|
||||
const plugins: Plugin[] = [
|
||||
{
|
||||
serve(context) {
|
||||
if (context.path === '/foo.bar') {
|
||||
return { body: 'console.log("foo.bar");' };
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
transform(context) {
|
||||
if (context.response.is('js')) {
|
||||
return { body: 'TRANSFORMED' };
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let server;
|
||||
try {
|
||||
({ server } = await startServer(
|
||||
createConfig({
|
||||
port: 8080,
|
||||
rootDir: path.resolve(__dirname, '..', 'fixtures', 'simple'),
|
||||
compatibility: 'none',
|
||||
fileExtensions: ['.bar'],
|
||||
plugins,
|
||||
}),
|
||||
));
|
||||
|
||||
const response = await fetch(`${host}foo.bar`);
|
||||
const responseText = await response.text();
|
||||
|
||||
expect(response.status).to.equal(200);
|
||||
expect(responseText).to.include('TRANSFORMED');
|
||||
expect(response.headers.get('content-type')).to.include('application/javascript');
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
285
packages/es-dev-server/test/plugins/nodeResolvePlugin.test.ts
Normal file
285
packages/es-dev-server/test/plugins/nodeResolvePlugin.test.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import path from 'path';
|
||||
import { expect } from 'chai';
|
||||
import fetch from 'node-fetch';
|
||||
import { startServer } from '../../src/start-server';
|
||||
import { createConfig } from '../../src/config';
|
||||
|
||||
const host = 'http://localhost:8080';
|
||||
|
||||
interface TestOptions {
|
||||
testFile: string;
|
||||
source: string;
|
||||
expected: string;
|
||||
fileExtensions?: string[];
|
||||
nodeResolve?: boolean | object;
|
||||
preserveSymlinks?: boolean;
|
||||
}
|
||||
|
||||
async function testNodeResolve({
|
||||
testFile,
|
||||
source,
|
||||
expected,
|
||||
fileExtensions,
|
||||
nodeResolve = true,
|
||||
preserveSymlinks,
|
||||
}: TestOptions) {
|
||||
let server;
|
||||
try {
|
||||
({ server } = await startServer(
|
||||
createConfig({
|
||||
port: 8080,
|
||||
rootDir: path.resolve(__dirname, '..', 'fixtures', 'simple'),
|
||||
compatibility: 'none',
|
||||
nodeResolve,
|
||||
fileExtensions,
|
||||
preserveSymlinks,
|
||||
plugins: [
|
||||
{
|
||||
serve(context) {
|
||||
if (context.path === testFile) {
|
||||
return { body: source };
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
));
|
||||
|
||||
const response = await fetch(`${host}${testFile}`);
|
||||
const responseText = await response.text();
|
||||
|
||||
expect(response.status).to.equal(200);
|
||||
expect(responseText).to.equal(expected);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolve-module-imports', () => {
|
||||
it('resolves bare imports', async () => {
|
||||
await testNodeResolve({
|
||||
testFile: '/src/test-file.js',
|
||||
source: [
|
||||
"import 'my-module';",
|
||||
"import foo from 'my-module';",
|
||||
"import { bar } from 'my-module';",
|
||||
].join('\n'),
|
||||
expected: [
|
||||
"import '../node_modules/my-module/index.js';",
|
||||
"import foo from '../node_modules/my-module/index.js';",
|
||||
"import { bar } from '../node_modules/my-module/index.js';",
|
||||
].join('\n'),
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves basic exports', async () => {
|
||||
await testNodeResolve({
|
||||
testFile: '/src/test-file.js',
|
||||
source: [
|
||||
//
|
||||
"export * from 'my-module';",
|
||||
"export { foo } from 'my-module';",
|
||||
].join('\n'),
|
||||
expected: [
|
||||
//
|
||||
"export * from '../node_modules/my-module/index.js';",
|
||||
"export { foo } from '../node_modules/my-module/index.js';",
|
||||
].join('\n'),
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves imports to a folder with index.js', async () => {
|
||||
await testNodeResolve({
|
||||
testFile: '/src/test-file.js',
|
||||
source: [
|
||||
//
|
||||
"export * from 'my-module/bar';",
|
||||
].join('\n'),
|
||||
expected: [
|
||||
//
|
||||
"export * from '../node_modules/my-module/bar/index.js';",
|
||||
].join('\n'),
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves imports to a file with bare import', async () => {
|
||||
await testNodeResolve({
|
||||
testFile: '/src/test-file.js',
|
||||
source: [
|
||||
//
|
||||
"import 'my-module/bar/index.js';",
|
||||
].join('\n'),
|
||||
expected: [
|
||||
//
|
||||
"import '../node_modules/my-module/bar/index.js';",
|
||||
].join('\n'),
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves using the module field from a package.json', async () => {
|
||||
await testNodeResolve({
|
||||
testFile: '/src/test-file.js',
|
||||
source: [
|
||||
//
|
||||
"import 'my-module-2';",
|
||||
].join('\n'),
|
||||
expected: [
|
||||
//
|
||||
"import '../node_modules/my-module-2/my-module-2.js';",
|
||||
].join('\n'),
|
||||
});
|
||||
});
|
||||
|
||||
it('does not resolve imports with configured file extensions', async () => {
|
||||
await testNodeResolve({
|
||||
testFile: '/src/test-file.js',
|
||||
source: [
|
||||
//
|
||||
"import './local-module.js';",
|
||||
"import '../local-module.js';",
|
||||
"import './../local-module.js';",
|
||||
"import '../../local-module.js';",
|
||||
"import '/local-module.js';",
|
||||
"import 'my-module';",
|
||||
].join('\n'),
|
||||
expected: [
|
||||
//
|
||||
"import './local-module.js';",
|
||||
"import '../local-module.js';",
|
||||
"import './../local-module.js';",
|
||||
"import '../../local-module.js';",
|
||||
"import '/local-module.js';",
|
||||
"import '../node_modules/my-module/index.js';",
|
||||
].join('\n'),
|
||||
});
|
||||
});
|
||||
|
||||
it('does resolve imports with non-configured file extensions', async () => {
|
||||
await testNodeResolve({
|
||||
testFile: '/src/test-file.js',
|
||||
source: [
|
||||
//
|
||||
"import './styles.css';",
|
||||
].join('\n'),
|
||||
expected: [
|
||||
//
|
||||
"import './styles.css.js';",
|
||||
].join('\n'),
|
||||
});
|
||||
});
|
||||
|
||||
it('favors .mjs over .js', async () => {
|
||||
await testNodeResolve({
|
||||
testFile: '/src/test-file.js',
|
||||
source: [
|
||||
//
|
||||
"import './foo';",
|
||||
].join('\n'),
|
||||
expected: [
|
||||
//
|
||||
"import './foo.mjs';",
|
||||
].join('\n'),
|
||||
});
|
||||
});
|
||||
|
||||
it('adds file extensions to non-bare imports', async () => {
|
||||
await testNodeResolve({
|
||||
testFile: '/src/test-file.js',
|
||||
source: [
|
||||
//
|
||||
"import './local-module';",
|
||||
"import 'my-module';",
|
||||
].join('\n'),
|
||||
expected: [
|
||||
//
|
||||
"import './local-module.js';",
|
||||
"import '../node_modules/my-module/index.js';",
|
||||
].join('\n'),
|
||||
});
|
||||
});
|
||||
|
||||
it('handles imports with query params or hashes', async () => {
|
||||
await testNodeResolve({
|
||||
testFile: '/src/test-file.js',
|
||||
source: [
|
||||
//
|
||||
"import 'my-module?foo=bar';",
|
||||
"import 'my-module?foo=bar&lorem=ipsum';",
|
||||
"import 'my-module#foo';",
|
||||
"import 'my-module?foo=bar#bar';",
|
||||
"import './local-module.js?foo=bar';",
|
||||
"import './local-module.js#foo';",
|
||||
"import './local-module.js?foo=bar#bar';",
|
||||
|
||||
"import('my-module?foo=bar');",
|
||||
"import('my-module#foo');",
|
||||
"import('my-module?foo=bar#bar');",
|
||||
"import('./local-module.js?foo=bar');",
|
||||
"import('./local-module.js#foo');",
|
||||
"import('./local-module.js?foo=bar#bar');",
|
||||
].join('\n'),
|
||||
expected: [
|
||||
//
|
||||
"import '../node_modules/my-module/index.js?foo=bar';",
|
||||
"import '../node_modules/my-module/index.js?foo=bar&lorem=ipsum';",
|
||||
"import '../node_modules/my-module/index.js#foo';",
|
||||
"import '../node_modules/my-module/index.js?foo=bar#bar';",
|
||||
"import './local-module.js?foo=bar';",
|
||||
"import './local-module.js#foo';",
|
||||
"import './local-module.js?foo=bar#bar';",
|
||||
"import('../node_modules/my-module/index.js?foo=bar');",
|
||||
"import('../node_modules/my-module/index.js#foo');",
|
||||
"import('../node_modules/my-module/index.js?foo=bar#bar');",
|
||||
"import('./local-module.js?foo=bar');",
|
||||
"import('./local-module.js#foo');",
|
||||
"import('./local-module.js?foo=bar#bar');",
|
||||
].join('\n'),
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves nested node_modules', async () => {
|
||||
await testNodeResolve({
|
||||
testFile: '/node_modules/my-module-2/foo.js',
|
||||
source: [
|
||||
//
|
||||
"import 'my-module';",
|
||||
"import 'my-module/bar/index.js';",
|
||||
].join('\n'),
|
||||
expected: [
|
||||
//
|
||||
"import './node_modules/my-module/index.js';",
|
||||
"import './node_modules/my-module/bar/index.js';",
|
||||
].join('\n'),
|
||||
});
|
||||
});
|
||||
|
||||
it('does not preserve symlinks when false', async () => {
|
||||
await testNodeResolve({
|
||||
testFile: '/src/test-file.js',
|
||||
source: [
|
||||
//
|
||||
"import 'symlinked-package';",
|
||||
].join('\n'),
|
||||
expected: [
|
||||
//
|
||||
"import '../symlinked-package/index.js';",
|
||||
].join('\n'),
|
||||
preserveSymlinks: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not preserve symlinks when true', async () => {
|
||||
await testNodeResolve({
|
||||
testFile: '/src/test-file.js',
|
||||
source: [
|
||||
//
|
||||
"import 'symlinked-package';",
|
||||
].join('\n'),
|
||||
expected: [
|
||||
//
|
||||
"import '../node_modules/symlinked-package/index.js';",
|
||||
].join('\n'),
|
||||
preserveSymlinks: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,18 +7,19 @@ import { compatibilityModes, virtualFilePrefix } from '../../src/constants';
|
||||
import { userAgents } from '../user-agents';
|
||||
|
||||
const update = process.argv.includes('--update-snapshots');
|
||||
const host = 'http://localhost:8080/';
|
||||
const host = 'http://localhost:8080';
|
||||
|
||||
const snapshotsDir = path.join(__dirname, '..', 'snapshots', 'polyfills-loader');
|
||||
|
||||
async function expectSnapshotMatches(name) {
|
||||
const response = await fetch(`${host}index.html`, {
|
||||
const response = await fetch(`${host}/index.html`, {
|
||||
headers: {
|
||||
accept: 'text/html',
|
||||
'user-agent': userAgents['Chrome 78'],
|
||||
},
|
||||
});
|
||||
expect(response.status).to.equal(200);
|
||||
|
||||
const responseText = await response.text();
|
||||
const filePath = path.join(snapshotsDir, `${name}.html`);
|
||||
|
||||
@@ -35,7 +36,7 @@ async function expectSnapshotMatches(name) {
|
||||
}
|
||||
}
|
||||
|
||||
describe('polyfills-loader middleware', () => {
|
||||
describe('polyfillsLoaderPlugin', () => {
|
||||
describe('snapshot tests', () => {
|
||||
Object.values(compatibilityModes).forEach(compatibility => {
|
||||
it(`injects polyfills into an index.html file with compatibility ${compatibility}`, async () => {
|
||||
@@ -171,13 +172,15 @@ describe('polyfills-loader middleware', () => {
|
||||
}),
|
||||
));
|
||||
|
||||
const indexResponse = await fetch(`${host}index.html`, {
|
||||
const indexResponse = await fetch(`${host}/index.html`, {
|
||||
headers: { accept: 'text/html' },
|
||||
});
|
||||
expect(indexResponse.status).to.equal(200);
|
||||
const fetchPolyfillResponse = await fetch(`${host}polyfills/fetch.js`);
|
||||
const fetchPolyfillResponse = await fetch(`${host}/polyfills/fetch.js`);
|
||||
expect(fetchPolyfillResponse.status).to.equal(200);
|
||||
expect(fetchPolyfillResponse.headers.get('content-type')).to.equal('text/javascript');
|
||||
expect(fetchPolyfillResponse.headers.get('content-type')).to.include(
|
||||
'application/javascript',
|
||||
);
|
||||
expect(fetchPolyfillResponse.headers.get('cache-control')).to.equal(
|
||||
'public, max-age=31536000',
|
||||
);
|
||||
@@ -198,16 +201,16 @@ describe('polyfills-loader middleware', () => {
|
||||
}),
|
||||
));
|
||||
|
||||
const indexResponse = await fetch(`${host}index.html`, {
|
||||
headers: { accept: 'text/html' },
|
||||
});
|
||||
const indexResponse = await fetch(`${host}/index.html`);
|
||||
expect(indexResponse.status).to.equal(200);
|
||||
|
||||
const inlineModule0Response = await fetch(
|
||||
`${host}${virtualFilePrefix}inline-script-0.js?source=/index.html`,
|
||||
);
|
||||
expect(inlineModule0Response.status).to.equal(200);
|
||||
expect(inlineModule0Response.headers.get('content-type')).to.equal('text/javascript');
|
||||
expect(inlineModule0Response.headers.get('content-type')).to.include(
|
||||
'application/javascript',
|
||||
);
|
||||
expect(inlineModule0Response.headers.get('cache-control')).to.equal('no-cache');
|
||||
expect(await inlineModule0Response.text()).to.include('class Foo {}');
|
||||
} finally {
|
||||
@@ -226,7 +229,7 @@ describe('polyfills-loader middleware', () => {
|
||||
}),
|
||||
));
|
||||
|
||||
const indexResponse = await fetch(`${host}index.html`, {
|
||||
const indexResponse = await fetch(`${host}/index.html`, {
|
||||
headers: { accept: 'text/html' },
|
||||
});
|
||||
expect(indexResponse.status).to.equal(200);
|
||||
@@ -235,7 +238,9 @@ describe('polyfills-loader middleware', () => {
|
||||
`${host}${virtualFilePrefix}inline-script-1.js?source=/index.html`,
|
||||
);
|
||||
expect(inlineModule1Response.status).to.equal(200);
|
||||
expect(inlineModule1Response.headers.get('content-type')).to.equal('text/javascript');
|
||||
expect(inlineModule1Response.headers.get('content-type')).to.include(
|
||||
'application/javascript',
|
||||
);
|
||||
expect(inlineModule1Response.headers.get('cache-control')).to.equal('no-cache');
|
||||
expect(await inlineModule1Response.text()).to.include("import './src/local-module.js';");
|
||||
} finally {
|
||||
@@ -254,7 +259,7 @@ describe('polyfills-loader middleware', () => {
|
||||
}),
|
||||
));
|
||||
|
||||
const indexResponse = await fetch(`${host}no-modules.html`);
|
||||
const indexResponse = await fetch(`${host}/no-modules.html`);
|
||||
expect(indexResponse.status).to.equal(200);
|
||||
expect(await indexResponse.text()).to.include('<title>My app</title>');
|
||||
} finally {
|
||||
@@ -0,0 +1,328 @@
|
||||
import path from 'path';
|
||||
import { expect } from 'chai';
|
||||
import fetch from 'node-fetch';
|
||||
import {
|
||||
resolveModuleImports,
|
||||
resolveModuleImportsPlugin,
|
||||
} from '../../src/plugins/resolveModuleImportsPlugin';
|
||||
import { startServer } from '../../src/start-server';
|
||||
import { createConfig } from '../../src/config';
|
||||
import { Plugin } from '../../src/Plugin';
|
||||
|
||||
const defaultFilePath = '/root/my-file.js';
|
||||
const defaultResolveImport = src => `RESOLVED__${src}`;
|
||||
|
||||
describe('resolveModuleImports()', () => {
|
||||
it('resolves regular imports', async () => {
|
||||
const result = await resolveModuleImports(
|
||||
[
|
||||
'import "my-module";',
|
||||
'import foo from "my-module";',
|
||||
'import { bar } from "my-module";',
|
||||
'import "./my-module.js";',
|
||||
'import "https://my-cdn.com/my-package.js";',
|
||||
].join('\n'),
|
||||
defaultFilePath,
|
||||
defaultResolveImport,
|
||||
);
|
||||
|
||||
expect(result.split('\n')).to.eql([
|
||||
'import "RESOLVED__my-module";',
|
||||
'import foo from "RESOLVED__my-module";',
|
||||
'import { bar } from "RESOLVED__my-module";',
|
||||
'import "RESOLVED__./my-module.js";',
|
||||
'import "RESOLVED__https://my-cdn.com/my-package.js";',
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves basic exports', async () => {
|
||||
const result = await resolveModuleImports(
|
||||
[
|
||||
//
|
||||
"export * from 'my-module';",
|
||||
"export { foo } from 'my-module';",
|
||||
].join('\n'),
|
||||
defaultFilePath,
|
||||
defaultResolveImport,
|
||||
);
|
||||
|
||||
expect(result.split('\n')).to.eql([
|
||||
//
|
||||
"export * from 'RESOLVED__my-module';",
|
||||
"export { foo } from 'RESOLVED__my-module';",
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves imports to a file with bare import', async () => {
|
||||
const result = await resolveModuleImports(
|
||||
"import 'my-module/bar/index.js'",
|
||||
defaultFilePath,
|
||||
defaultResolveImport,
|
||||
);
|
||||
|
||||
expect(result).to.eql("import 'RESOLVED__my-module/bar/index.js");
|
||||
});
|
||||
|
||||
it('resolves dynamic imports', async () => {
|
||||
const result = await resolveModuleImports(
|
||||
[
|
||||
'function lazyLoad() { return import("my-module-2"); }',
|
||||
'import("my-module");',
|
||||
'import("./local-module.js");',
|
||||
].join('\n'),
|
||||
defaultFilePath,
|
||||
defaultResolveImport,
|
||||
);
|
||||
|
||||
expect(result.split('\n')).to.eql([
|
||||
'function lazyLoad() { return import("RESOLVED__my-module-2"); }',
|
||||
'import("RESOLVED__my-module");',
|
||||
'import("RESOLVED__./local-module.js");',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not touch import.meta.url', async () => {
|
||||
const result = await resolveModuleImports(
|
||||
[
|
||||
//
|
||||
'console.log(import.meta.url);',
|
||||
"import 'my-module';",
|
||||
].join('\n'),
|
||||
defaultFilePath,
|
||||
defaultResolveImport,
|
||||
);
|
||||
|
||||
expect(result.split('\n')).to.eql([
|
||||
'console.log(import.meta.url);',
|
||||
"import 'RESOLVED__my-module';",
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not touch comments', async () => {
|
||||
const result = await resolveModuleImports(
|
||||
[
|
||||
//
|
||||
"import 'my-module';",
|
||||
"// Example: import('my-module');",
|
||||
].join('\n'),
|
||||
defaultFilePath,
|
||||
defaultResolveImport,
|
||||
);
|
||||
|
||||
expect(result.split('\n')).to.eql([
|
||||
"import 'RESOLVED__my-module';",
|
||||
"// Example: import('my-module');",
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not resolve imports in regular code', async () => {
|
||||
const result = await resolveModuleImports(
|
||||
[
|
||||
//
|
||||
'function myimport() { }',
|
||||
'function my_import() { }',
|
||||
'function importShim() { }',
|
||||
"class Foo { import() { return 'foo' } }",
|
||||
].join('\n'),
|
||||
defaultFilePath,
|
||||
defaultResolveImport,
|
||||
);
|
||||
|
||||
expect(result.split('\n')).to.eql([
|
||||
'function myimport() { }',
|
||||
'function my_import() { }',
|
||||
'function importShim() { }',
|
||||
"class Foo { import() { return 'foo' } }",
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves the package of dynamic imports with string concatenation', async () => {
|
||||
const result = await resolveModuleImports(
|
||||
[
|
||||
//
|
||||
'import(`@namespace/my-module-3/dynamic-files/${file}.js`);',
|
||||
'import(`my-module/dynamic-files/${file}.js`);',
|
||||
'import("my-module/dynamic-files" + "/" + file + ".js");',
|
||||
'import("my-module/dynamic-files/" + file + ".js");',
|
||||
'import("my-module/dynamic-files".concat(file).concat(".js"));',
|
||||
].join('\n'),
|
||||
defaultFilePath,
|
||||
defaultResolveImport,
|
||||
);
|
||||
|
||||
expect(result.split('\n')).to.eql([
|
||||
'import(`RESOLVED__@namespace/my-module-3/dynamic-files/${file}.js`);',
|
||||
'import(`RESOLVED__my-module/dynamic-files/${file}.js`);',
|
||||
'import("RESOLVED__my-module/dynamic-files" + "/" + file + ".js");',
|
||||
'import("RESOLVED__my-module/dynamic-files/" + file + ".js");',
|
||||
'import("RESOLVED__my-module/dynamic-files".concat(file).concat(".js"));',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not change import with string concatenation cannot be resolved', async () => {
|
||||
await resolveModuleImports(
|
||||
[
|
||||
'const file = "a";',
|
||||
'import(`@namespace/non-existing/dynamic-files/${file}.js`);',
|
||||
'import(`non-existing/dynamic-files/${file}.js`);',
|
||||
'import(totallyDynamic);',
|
||||
'import(`${file}.js`);',
|
||||
].join('\n'),
|
||||
defaultFilePath,
|
||||
defaultResolveImport,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not change import with string concatenation cannot be resolved', async () => {
|
||||
await resolveModuleImports(
|
||||
[
|
||||
'const file = "a";',
|
||||
'import(`@namespace/non-existing/dynamic-files/${file}.js`);',
|
||||
'import(`non-existing/dynamic-files/${file}.js`);',
|
||||
'import(totallyDynamic);',
|
||||
'import(`${file}.js`);',
|
||||
].join('\n'),
|
||||
defaultFilePath,
|
||||
defaultResolveImport,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const host = 'http://localhost:8080/';
|
||||
|
||||
describe('resolveModuleImportsPlugin', () => {
|
||||
it('lets plugins resolve imports using the resolveImport hook', async () => {
|
||||
const plugins: Plugin[] = [
|
||||
{
|
||||
resolveImport({ source }) {
|
||||
return `RESOLVED__${source}`;
|
||||
},
|
||||
},
|
||||
resolveModuleImportsPlugin(),
|
||||
];
|
||||
|
||||
let server;
|
||||
try {
|
||||
({ server } = await startServer(
|
||||
createConfig({
|
||||
port: 8080,
|
||||
rootDir: path.resolve(__dirname, '..', 'fixtures', 'simple'),
|
||||
compatibility: 'none',
|
||||
plugins,
|
||||
}),
|
||||
));
|
||||
|
||||
const response = await fetch(`${host}app.js`);
|
||||
const responseText = await response.text();
|
||||
|
||||
expect(response.status).to.equal(200);
|
||||
expect(responseText).to.include("import { message } from 'RESOLVED__my-module';");
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('resolved imports in inline modules in HTML files', async () => {
|
||||
const plugins: Plugin[] = [
|
||||
{
|
||||
resolveImport({ source }) {
|
||||
return `RESOLVED__${source}`;
|
||||
},
|
||||
},
|
||||
resolveModuleImportsPlugin(),
|
||||
];
|
||||
|
||||
let server;
|
||||
try {
|
||||
({ server } = await startServer(
|
||||
createConfig({
|
||||
port: 8080,
|
||||
rootDir: path.resolve(__dirname, '..', 'fixtures', 'simple'),
|
||||
compatibility: 'none',
|
||||
plugins,
|
||||
}),
|
||||
));
|
||||
|
||||
const response = await fetch(`${host}index.html`);
|
||||
const responseText = await response.text();
|
||||
|
||||
expect(response.status).to.equal(200);
|
||||
expect(responseText).to.include("import { message } from 'RESOLVED__my-module';");
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('unmatched resolve leaves import untouched', async () => {
|
||||
const plugins: Plugin[] = [
|
||||
{
|
||||
resolveImport() {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
resolveModuleImportsPlugin(),
|
||||
];
|
||||
|
||||
let server;
|
||||
try {
|
||||
({ server } = await startServer(
|
||||
createConfig({
|
||||
port: 8080,
|
||||
rootDir: path.resolve(__dirname, '..', 'fixtures', 'simple'),
|
||||
compatibility: 'none',
|
||||
plugins,
|
||||
}),
|
||||
));
|
||||
|
||||
const response = await fetch(`${host}app.js`);
|
||||
const responseText = await response.text();
|
||||
|
||||
expect(response.status).to.equal(200);
|
||||
expect(responseText).to.include("import { message } from 'my-module';");
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('first matching plugin takes priority', async () => {
|
||||
const plugins: Plugin[] = [
|
||||
{
|
||||
resolveImport({ source, context }) {
|
||||
if (context.path === '/src/local-module.js') {
|
||||
return `RESOLVED__A__${source}`;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
resolveImport({ source }) {
|
||||
return `RESOLVED__B__${source}`;
|
||||
},
|
||||
},
|
||||
resolveModuleImportsPlugin(),
|
||||
];
|
||||
|
||||
let server;
|
||||
try {
|
||||
({ server } = await startServer(
|
||||
createConfig({
|
||||
port: 8080,
|
||||
rootDir: path.resolve(__dirname, '..', 'fixtures', 'simple'),
|
||||
compatibility: 'none',
|
||||
plugins,
|
||||
}),
|
||||
));
|
||||
|
||||
const responseA = await fetch(`${host}src/local-module.js`);
|
||||
const responseB = await fetch(`${host}app.js`);
|
||||
const responseTextA = await responseA.text();
|
||||
const responseTextB = await responseB.text();
|
||||
|
||||
expect(responseA.status).to.equal(200);
|
||||
expect(responseB.status).to.equal(200);
|
||||
expect(responseTextA).to.include("import { message } from 'RESOLVED__A__my-module';");
|
||||
expect(responseTextB).to.include("import { message } from 'RESOLVED__B__my-module';");
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,8 @@
|
||||
<!-- absolute path -->
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<script>(function () {
|
||||
@@ -67,6 +69,8 @@
|
||||
return loadScript('../src/app.js', 'module');
|
||||
}, function () {
|
||||
return loadScript('/src/app.js', 'module');
|
||||
}, function () {
|
||||
return loadScript('./inline-script-3.js?source=%2Findex.html', 'module');
|
||||
}].reduce(function (a, c) {
|
||||
return a.then(c);
|
||||
}, Promise.resolve());
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
<!-- absolute path -->
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<script>(function () {
|
||||
@@ -67,6 +69,8 @@
|
||||
return loadScript('../src/app.js', 'module');
|
||||
}, function () {
|
||||
return loadScript('/src/app.js', 'module');
|
||||
}, function () {
|
||||
return loadScript('./inline-script-3.js?source=%2Findex.html', 'module');
|
||||
}].reduce(function (a, c) {
|
||||
return a.then(c);
|
||||
}, Promise.resolve());
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
<!-- absolute path -->
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<script>(function () {
|
||||
@@ -75,6 +77,8 @@
|
||||
return loadScript('../src/app.js', 'module');
|
||||
}, function () {
|
||||
return loadScript('/src/app.js', 'module');
|
||||
}, function () {
|
||||
return loadScript('./inline-script-3.js?source=%2Findex.html', 'module');
|
||||
}].reduce(function (a, c) {
|
||||
return a.then(c);
|
||||
}, Promise.resolve());
|
||||
|
||||
@@ -4,47 +4,42 @@
|
||||
|
||||
<body>
|
||||
|
||||
<script>
|
||||
const message = 'foo';
|
||||
console.log(`The message is: ${message}`);
|
||||
<script>var _window$foo$bar, _window, _window$foo;
|
||||
|
||||
class Foo {
|
||||
const message = 'foo';
|
||||
console.log(`The message is: ${message}`);
|
||||
|
||||
}
|
||||
class Foo {}
|
||||
|
||||
const bar = 'buz';
|
||||
const bar = 'buz';
|
||||
console.log((_window$foo$bar = (_window = window) == null ? void 0 : (_window$foo = _window.foo) == null ? void 0 : _window$foo.bar) != null ? _window$foo$bar : 'x');
|
||||
|
||||
console.log(window?.foo?.bar ?? 'x');
|
||||
async function* asyncGenerator() {
|
||||
await Promise.resolve();
|
||||
yield 0;
|
||||
await Promise.resolve();
|
||||
yield 1;
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbImluZGV4Lmh0bWwiXSwibmFtZXMiOlsibWVzc2FnZSIsImNvbnNvbGUiLCJsb2ciLCJGb28iLCJiYXIiLCJ3aW5kb3ciLCJmb28iLCJhc3luY0dlbmVyYXRvciIsIlByb21pc2UiLCJyZXNvbHZlIl0sIm1hcHBpbmdzIjoiOztBQUNJLE1BQU1BLE9BQU8sR0FBRyxLQUFoQjtBQUNBQyxPQUFPLENBQUNDLEdBQVIsQ0FBYSxtQkFBa0JGLE9BQVEsRUFBdkM7O0FBRUEsTUFBTUcsR0FBTixDQUFVOztBQUlWLE1BQU1DLEdBQUcsR0FBRyxLQUFaO0FBRUFILE9BQU8sQ0FBQ0MsR0FBUiwrQkFBWUcsTUFBWixvQ0FBWSxRQUFRQyxHQUFwQixxQkFBWSxZQUFhRixHQUF6Qiw4QkFBZ0MsR0FBaEM7O0FBRUEsZ0JBQWdCRyxjQUFoQixHQUFpQztBQUMvQixRQUFNQyxPQUFPLENBQUNDLE9BQVIsRUFBTjtBQUNBLFFBQU0sQ0FBTjtBQUNBLFFBQU1ELE9BQU8sQ0FBQ0MsT0FBUixFQUFOO0FBQ0EsUUFBTSxDQUFOO0FBQ0QiLCJzb3VyY2VzQ29udGVudCI6WyJcbiAgICBjb25zdCBtZXNzYWdlID0gJ2Zvbyc7XG4gICAgY29uc29sZS5sb2coYFRoZSBtZXNzYWdlIGlzOiAke21lc3NhZ2V9YCk7XG5cbiAgICBjbGFzcyBGb28ge1xuXG4gICAgfVxuXG4gICAgY29uc3QgYmFyID0gJ2J1eic7XG5cbiAgICBjb25zb2xlLmxvZyh3aW5kb3c/LmZvbz8uYmFyID8/ICd4Jyk7XG5cbiAgICBhc3luYyBmdW5jdGlvbiogYXN5bmNHZW5lcmF0b3IoKSB7XG4gICAgICBhd2FpdCBQcm9taXNlLnJlc29sdmUoKTtcbiAgICAgIHlpZWxkIDA7XG4gICAgICBhd2FpdCBQcm9taXNlLnJlc29sdmUoKTtcbiAgICAgIHlpZWxkIDE7XG4gICAgfVxuICAiXX0=</script>
|
||||
|
||||
async function* asyncGenerator() {
|
||||
await Promise.resolve();
|
||||
yield 0;
|
||||
await Promise.resolve();
|
||||
yield 1;
|
||||
}
|
||||
</script>
|
||||
<script type="module">var _window$foo$bar, _window, _window$foo;
|
||||
|
||||
<script type="module">
|
||||
import { message } from 'my-module';
|
||||
import './src/local-module.js';
|
||||
import { message } from './node_modules/my-module/index.js';
|
||||
import './src/local-module.js';
|
||||
console.log(`The message is: ${message}`);
|
||||
|
||||
console.log(`The message is: ${message}`);
|
||||
class Foo {}
|
||||
|
||||
class Foo {
|
||||
const bar = 'buz';
|
||||
console.log((_window$foo$bar = (_window = window) == null ? void 0 : (_window$foo = _window.foo) == null ? void 0 : _window$foo.bar) != null ? _window$foo$bar : 'x');
|
||||
|
||||
}
|
||||
|
||||
const bar = 'buz';
|
||||
|
||||
console.log(window?.foo?.bar ?? 'x');
|
||||
|
||||
async function* asyncGenerator() {
|
||||
await Promise.resolve();
|
||||
yield 0;
|
||||
await Promise.resolve();
|
||||
yield 1;
|
||||
}
|
||||
</script>
|
||||
async function* asyncGenerator() {
|
||||
await Promise.resolve();
|
||||
yield 0;
|
||||
await Promise.resolve();
|
||||
yield 1;
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbImluZGV4Lmh0bWwiXSwibmFtZXMiOlsibWVzc2FnZSIsImNvbnNvbGUiLCJsb2ciLCJGb28iLCJiYXIiLCJ3aW5kb3ciLCJmb28iLCJhc3luY0dlbmVyYXRvciIsIlByb21pc2UiLCJyZXNvbHZlIl0sIm1hcHBpbmdzIjoiOztBQUNJLFNBQVNBLE9BQVQsUUFBd0IsbUNBQXhCO0FBQ0EsT0FBTyx1QkFBUDtBQUVBQyxPQUFPLENBQUNDLEdBQVIsQ0FBYSxtQkFBa0JGLE9BQVEsRUFBdkM7O0FBRUEsTUFBTUcsR0FBTixDQUFVOztBQUlWLE1BQU1DLEdBQUcsR0FBRyxLQUFaO0FBRUFILE9BQU8sQ0FBQ0MsR0FBUiwrQkFBWUcsTUFBWixvQ0FBWSxRQUFRQyxHQUFwQixxQkFBWSxZQUFhRixHQUF6Qiw4QkFBZ0MsR0FBaEM7O0FBRUEsZ0JBQWdCRyxjQUFoQixHQUFpQztBQUMvQixRQUFNQyxPQUFPLENBQUNDLE9BQVIsRUFBTjtBQUNBLFFBQU0sQ0FBTjtBQUNBLFFBQU1ELE9BQU8sQ0FBQ0MsT0FBUixFQUFOO0FBQ0EsUUFBTSxDQUFOO0FBQ0QiLCJzb3VyY2VzQ29udGVudCI6WyJcbiAgICBpbXBvcnQgeyBtZXNzYWdlIH0gZnJvbSAnLi9ub2RlX21vZHVsZXMvbXktbW9kdWxlL2luZGV4LmpzJztcbiAgICBpbXBvcnQgJy4vc3JjL2xvY2FsLW1vZHVsZS5qcyc7XG5cbiAgICBjb25zb2xlLmxvZyhgVGhlIG1lc3NhZ2UgaXM6ICR7bWVzc2FnZX1gKTtcblxuICAgIGNsYXNzIEZvbyB7XG5cbiAgICB9XG5cbiAgICBjb25zdCBiYXIgPSAnYnV6JztcblxuICAgIGNvbnNvbGUubG9nKHdpbmRvdz8uZm9vPy5iYXIgPz8gJ3gnKTtcblxuICAgIGFzeW5jIGZ1bmN0aW9uKiBhc3luY0dlbmVyYXRvcigpIHtcbiAgICAgIGF3YWl0IFByb21pc2UucmVzb2x2ZSgpO1xuICAgICAgeWllbGQgMDtcbiAgICAgIGF3YWl0IFByb21pc2UucmVzb2x2ZSgpO1xuICAgICAgeWllbGQgMTtcbiAgICB9XG4gICJdfQ==</script>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ async function* asyncGenerator() {
|
||||
await Promise.resolve();
|
||||
yield 1;
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbImluZGV4Lmh0bWwiXSwibmFtZXMiOlsibWVzc2FnZSIsImNvbnNvbGUiLCJsb2ciLCJGb28iLCJiYXIiLCJ3aW5kb3ciLCJmb28iLCJhc3luY0dlbmVyYXRvciIsIlByb21pc2UiLCJyZXNvbHZlIl0sIm1hcHBpbmdzIjoiOztBQUNJLFNBQVNBLE9BQVQsUUFBd0IsV0FBeEI7QUFDQSxPQUFPLHVCQUFQO0FBRUFDLE9BQU8sQ0FBQ0MsR0FBUixDQUFhLG1CQUFrQkYsT0FBUSxFQUF2Qzs7QUFFQSxNQUFNRyxHQUFOLENBQVU7O0FBSVYsTUFBTUMsR0FBRyxHQUFHLEtBQVo7QUFFQUgsT0FBTyxDQUFDQyxHQUFSLCtCQUFZRyxNQUFaLG9DQUFZLFFBQVFDLEdBQXBCLHFCQUFZLFlBQWFGLEdBQXpCLDhCQUFnQyxHQUFoQzs7QUFFQSxnQkFBZ0JHLGNBQWhCLEdBQWlDO0FBQy9CLFFBQU1DLE9BQU8sQ0FBQ0MsT0FBUixFQUFOO0FBQ0EsUUFBTSxDQUFOO0FBQ0EsUUFBTUQsT0FBTyxDQUFDQyxPQUFSLEVBQU47QUFDQSxRQUFNLENBQU47QUFDRCIsInNvdXJjZXNDb250ZW50IjpbIlxuICAgIGltcG9ydCB7IG1lc3NhZ2UgfSBmcm9tICdteS1tb2R1bGUnO1xuICAgIGltcG9ydCAnLi9zcmMvbG9jYWwtbW9kdWxlLmpzJztcblxuICAgIGNvbnNvbGUubG9nKGBUaGUgbWVzc2FnZSBpczogJHttZXNzYWdlfWApO1xuXG4gICAgY2xhc3MgRm9vIHtcblxuICAgIH1cblxuICAgIGNvbnN0IGJhciA9ICdidXonO1xuXG4gICAgY29uc29sZS5sb2cod2luZG93Py5mb28/LmJhciA/PyAneCcpO1xuXG4gICAgYXN5bmMgZnVuY3Rpb24qIGFzeW5jR2VuZXJhdG9yKCkge1xuICAgICAgYXdhaXQgUHJvbWlzZS5yZXNvbHZlKCk7XG4gICAgICB5aWVsZCAwO1xuICAgICAgYXdhaXQgUHJvbWlzZS5yZXNvbHZlKCk7XG4gICAgICB5aWVsZCAxO1xuICAgIH1cbiAgIl19</script>
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbImluZGV4Lmh0bWwiXSwibmFtZXMiOlsibWVzc2FnZSIsImNvbnNvbGUiLCJsb2ciLCJGb28iLCJiYXIiLCJ3aW5kb3ciLCJmb28iLCJhc3luY0dlbmVyYXRvciIsIlByb21pc2UiLCJyZXNvbHZlIl0sIm1hcHBpbmdzIjoiOztBQUNJLFNBQVNBLE9BQVQsUUFBd0IsbUNBQXhCO0FBQ0EsT0FBTyx1QkFBUDtBQUVBQyxPQUFPLENBQUNDLEdBQVIsQ0FBYSxtQkFBa0JGLE9BQVEsRUFBdkM7O0FBRUEsTUFBTUcsR0FBTixDQUFVOztBQUlWLE1BQU1DLEdBQUcsR0FBRyxLQUFaO0FBRUFILE9BQU8sQ0FBQ0MsR0FBUiwrQkFBWUcsTUFBWixvQ0FBWSxRQUFRQyxHQUFwQixxQkFBWSxZQUFhRixHQUF6Qiw4QkFBZ0MsR0FBaEM7O0FBRUEsZ0JBQWdCRyxjQUFoQixHQUFpQztBQUMvQixRQUFNQyxPQUFPLENBQUNDLE9BQVIsRUFBTjtBQUNBLFFBQU0sQ0FBTjtBQUNBLFFBQU1ELE9BQU8sQ0FBQ0MsT0FBUixFQUFOO0FBQ0EsUUFBTSxDQUFOO0FBQ0QiLCJzb3VyY2VzQ29udGVudCI6WyJcbiAgICBpbXBvcnQgeyBtZXNzYWdlIH0gZnJvbSAnLi9ub2RlX21vZHVsZXMvbXktbW9kdWxlL2luZGV4LmpzJztcbiAgICBpbXBvcnQgJy4vc3JjL2xvY2FsLW1vZHVsZS5qcyc7XG5cbiAgICBjb25zb2xlLmxvZyhgVGhlIG1lc3NhZ2UgaXM6ICR7bWVzc2FnZX1gKTtcblxuICAgIGNsYXNzIEZvbyB7XG5cbiAgICB9XG5cbiAgICBjb25zdCBiYXIgPSAnYnV6JztcblxuICAgIGNvbnNvbGUubG9nKHdpbmRvdz8uZm9vPy5iYXIgPz8gJ3gnKTtcblxuICAgIGFzeW5jIGZ1bmN0aW9uKiBhc3luY0dlbmVyYXRvcigpIHtcbiAgICAgIGF3YWl0IFByb21pc2UucmVzb2x2ZSgpO1xuICAgICAgeWllbGQgMDtcbiAgICAgIGF3YWl0IFByb21pc2UucmVzb2x2ZSgpO1xuICAgICAgeWllbGQgMTtcbiAgICB9XG4gICJdfQ==</script>
|
||||
|
||||
<script type="module" src="./app.js"></script>
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
<!-- absolute path -->
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<script>(function () {
|
||||
@@ -74,6 +76,8 @@
|
||||
return System.import('../src/app.js');
|
||||
}, function () {
|
||||
return System.import('/src/app.js');
|
||||
}, function () {
|
||||
return System.import('./inline-script-3.js?source=%2Findex.html');
|
||||
}].reduce(function (a, c) {
|
||||
return a.then(c);
|
||||
}, Promise.resolve());
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
<!-- absolute path -->
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<script>(function () {
|
||||
@@ -67,6 +69,8 @@
|
||||
return loadScript('../src/app.js', 'module');
|
||||
}, function () {
|
||||
return loadScript('/src/app.js', 'module');
|
||||
}, function () {
|
||||
return loadScript('./inline-script-3.js?source=%2Findex.html', 'module');
|
||||
}].reduce(function (a, c) {
|
||||
return a.then(c);
|
||||
}, Promise.resolve());
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<html><head>
|
||||
<title>My app</title>
|
||||
</head>
|
||||
|
||||
@@ -15,6 +13,10 @@
|
||||
<!-- absolute path -->
|
||||
<script type="module" src="/src/app.js"></script>
|
||||
|
||||
</body>
|
||||
<script type="module">
|
||||
import { message } from './node_modules/my-module/index.js';
|
||||
</script>
|
||||
|
||||
</html>
|
||||
|
||||
|
||||
</body></html>
|
||||
@@ -1,4 +1,7 @@
|
||||
/* eslint-disable no-restricted-syntax, no-await-in-loop */
|
||||
import Koa from 'koa';
|
||||
import { Server } from 'net';
|
||||
import { FSWatcher } from 'chokidar';
|
||||
import { expect } from 'chai';
|
||||
import fetch from 'node-fetch';
|
||||
import path from 'path';
|
||||
@@ -134,7 +137,7 @@ describe('server', () => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
it('calls server start and stop functions', async () => {
|
||||
it('calls the onServerStart', async () => {
|
||||
let onStart = false;
|
||||
const { server } = await startServer(
|
||||
createConfig({
|
||||
@@ -147,4 +150,63 @@ describe('server', () => {
|
||||
expect(onStart).to.be.true;
|
||||
server.close();
|
||||
});
|
||||
|
||||
it('calls server start plugin hook', async () => {
|
||||
let startArgs;
|
||||
const { server } = await startServer(
|
||||
createConfig({
|
||||
plugins: [
|
||||
{
|
||||
serverStart(args) {
|
||||
startArgs = args;
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(startArgs).to.exist;
|
||||
expect(startArgs.app).to.be.an.instanceOf(Koa);
|
||||
expect(startArgs.server).to.be.an.instanceOf(Server);
|
||||
expect(startArgs.fileWatcher).to.be.an.instanceOf(FSWatcher);
|
||||
expect(startArgs.config).to.be.an('object');
|
||||
|
||||
server.close();
|
||||
});
|
||||
|
||||
it('waits on server start hooks before starting', async () => {
|
||||
let aFinished = false;
|
||||
let bFinished = false;
|
||||
|
||||
const { server } = await startServer(
|
||||
createConfig({
|
||||
plugins: [
|
||||
{
|
||||
serverStart() {
|
||||
return new Promise(resolve =>
|
||||
setTimeout(() => {
|
||||
aFinished = true;
|
||||
resolve();
|
||||
}, 5),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
serverStart() {
|
||||
return new Promise(resolve =>
|
||||
setTimeout(() => {
|
||||
bFinished = true;
|
||||
resolve();
|
||||
}, 10),
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(aFinished).to.be.true;
|
||||
expect(bFinished).to.be.true;
|
||||
server.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,340 +0,0 @@
|
||||
/**
|
||||
* @typedef {object} TestResolveOverrides
|
||||
* @property {string} [baseDir]
|
||||
* @property {string[]} [fileExtensions]
|
||||
* @property {string} [importer]
|
||||
* @property {import('@rollup/plugin-node-resolve').Options} [resolveOptions]
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { expect } from 'chai';
|
||||
import { createResolveModuleImports } from '../../src/utils/resolve-module-imports';
|
||||
|
||||
const updateSnapshots = process.argv.includes('--update-snapshots');
|
||||
const snapshotsDir = path.resolve(__dirname, '..', 'snapshots', 'resolve-module-imports');
|
||||
const baseDir = path.resolve(__dirname, '..', 'fixtures', 'simple');
|
||||
const importer = path.resolve(baseDir, 'src', 'foo.js');
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string} source
|
||||
* @param {TestResolveOverrides} ovr
|
||||
*/
|
||||
async function expectMatchesSnapshot(name, source, ovr = {}) {
|
||||
const file = path.resolve(snapshotsDir, `${name}.js`);
|
||||
|
||||
const resolveModuleImports = createResolveModuleImports(
|
||||
ovr.baseDir || baseDir,
|
||||
ovr.fileExtensions || ['.mjs', '.js'],
|
||||
ovr.resolveOptions,
|
||||
);
|
||||
|
||||
const resolvedSource = await resolveModuleImports(ovr.importer || importer, source);
|
||||
|
||||
if (updateSnapshots) {
|
||||
fs.writeFileSync(file, resolvedSource, 'utf-8');
|
||||
} else {
|
||||
if (!fs.existsSync(file)) {
|
||||
throw new Error(`Snapshot ${name} does not exist.`);
|
||||
}
|
||||
|
||||
const snapshot = fs.readFileSync(file, 'utf-8');
|
||||
expect(resolvedSource).to.equal(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolve-module-imports', () => {
|
||||
it('resolves bare imports', async () => {
|
||||
await expectMatchesSnapshot(
|
||||
'basic-imports',
|
||||
`
|
||||
import 'my-module';
|
||||
import foo from 'my-module';
|
||||
import { bar } from 'my-module';
|
||||
`,
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves basic exports', async () => {
|
||||
await expectMatchesSnapshot(
|
||||
'basic-exports',
|
||||
`
|
||||
export * from 'my-module';
|
||||
export { foo } from 'my-module';
|
||||
`,
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves imports to a folder with index.js', async () => {
|
||||
await expectMatchesSnapshot(
|
||||
'folder-index-js',
|
||||
`
|
||||
import 'my-module/bar';
|
||||
`,
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves imports to a file with bare import', async () => {
|
||||
await expectMatchesSnapshot(
|
||||
'folder-index-js',
|
||||
`
|
||||
import 'my-module/bar/index.js';
|
||||
`,
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves using the module field from a package.json', async () => {
|
||||
await expectMatchesSnapshot(
|
||||
'module-field',
|
||||
`
|
||||
import 'my-module-2';
|
||||
`,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not resolve imports with configured file extensions', async () => {
|
||||
await expectMatchesSnapshot(
|
||||
'configured-extenions',
|
||||
`
|
||||
import './local-module.js';
|
||||
import '../local-module.js';
|
||||
import './../local-module.js';
|
||||
import '../../local-module.js';
|
||||
import '/local-module.js';
|
||||
import 'my-module';
|
||||
`,
|
||||
);
|
||||
});
|
||||
|
||||
it('does resolve imports with non-configured file extensions', async () => {
|
||||
await expectMatchesSnapshot(
|
||||
'not-configured-extenions',
|
||||
`
|
||||
import './styles.css';
|
||||
`,
|
||||
);
|
||||
});
|
||||
|
||||
it('favors .mjs over .js', async () => {
|
||||
await expectMatchesSnapshot(
|
||||
'mjs',
|
||||
`
|
||||
import './foo';
|
||||
`,
|
||||
);
|
||||
});
|
||||
|
||||
it('adds file extensions to non-bare imports', async () => {
|
||||
await expectMatchesSnapshot(
|
||||
'file-extension',
|
||||
`
|
||||
import './local-module';
|
||||
import 'my-module';
|
||||
`,
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves dynamic imports', async () => {
|
||||
await expectMatchesSnapshot(
|
||||
'dynamic imports',
|
||||
`
|
||||
import 'my-module';
|
||||
|
||||
function lazyLoad() {
|
||||
return import('my-module-2');
|
||||
}
|
||||
|
||||
import('my-module');
|
||||
|
||||
import('./local-module.js');
|
||||
`,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not touch import.meta.url', async () => {
|
||||
await expectMatchesSnapshot(
|
||||
'import-meta',
|
||||
`
|
||||
import 'my-module';
|
||||
|
||||
console.log(import.meta.url);
|
||||
`,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not touch comments', async () => {
|
||||
await expectMatchesSnapshot(
|
||||
'comments',
|
||||
`
|
||||
import 'my-module';
|
||||
|
||||
/**
|
||||
* Example: import 'my-module';
|
||||
* import('my-module.js');
|
||||
*/
|
||||
function doSomething() {
|
||||
|
||||
}
|
||||
`,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not touch urls', async () => {
|
||||
await expectMatchesSnapshot(
|
||||
'urls',
|
||||
`
|
||||
import 'https://my-cdn.com/my-package.js';
|
||||
`,
|
||||
);
|
||||
});
|
||||
|
||||
it('handles imports with query params or hashes', async () => {
|
||||
await expectMatchesSnapshot(
|
||||
'query-params-and-hashes',
|
||||
`
|
||||
import 'my-module?foo=bar';
|
||||
import 'my-module#foo';
|
||||
import 'my-module?foo=bar#bar';
|
||||
import './local-module.js?foo=bar';
|
||||
import './local-module.js#foo';
|
||||
import './local-module.js?foo=bar#bar';
|
||||
|
||||
import('my-module?foo=bar');
|
||||
import('my-module#foo');
|
||||
import('my-module?foo=bar#bar');
|
||||
import('./local-module.js?foo=bar');
|
||||
import('./local-module.js#foo');
|
||||
import('./local-module.js?foo=bar#bar');
|
||||
`,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not get confused by import in regular code', async () => {
|
||||
await expectMatchesSnapshot(
|
||||
'import-in-code',
|
||||
`
|
||||
function myimport() {
|
||||
|
||||
}
|
||||
|
||||
function my_import() {
|
||||
|
||||
}
|
||||
|
||||
function importShim() {
|
||||
|
||||
}
|
||||
|
||||
class Foo {
|
||||
import() {
|
||||
return 'foo';
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves the package of dynamic imports with string concatenation', async () => {
|
||||
await expectMatchesSnapshot(
|
||||
'string-concatenation',
|
||||
`const file = 'a';
|
||||
import(\`@namespace/my-module-3/dynamic-files/\${file}.js\`);
|
||||
import(\`my-module/dynamic-files/\${file}.js\`);
|
||||
import('my-module/dynamic-files' + '/' + file + '.js');
|
||||
import("my-module/dynamic-files/" + file + ".js");
|
||||
import('my-module/dynamic-files'.concat(file).concat('.js'));
|
||||
`,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw an error when a dynamic import with string concatenation cannot be resolved', async () => {
|
||||
await expectMatchesSnapshot(
|
||||
'string-concat-errors',
|
||||
`const file = 'a';
|
||||
import(\`@namespace/non-existing/dynamic-files/\${file}.js\`);
|
||||
import(\`non-existing/dynamic-files/\${file}.js\`);
|
||||
import(totallyDynamic);
|
||||
import(\`\${file}.js\`);
|
||||
`,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when it cannot find an import', async () => {
|
||||
let thrown = false;
|
||||
|
||||
try {
|
||||
const resolveModuleImports = createResolveModuleImports(baseDir, ['.mjs', '.js'], {});
|
||||
await resolveModuleImports(importer, 'import "nope";');
|
||||
} catch (error) {
|
||||
thrown = true;
|
||||
expect(error.message).to.equal(`Could not resolve import "nope" in "./src/foo.js".`);
|
||||
}
|
||||
|
||||
expect(thrown).to.equal(true);
|
||||
});
|
||||
|
||||
it('resolves nested node_modules', async () => {
|
||||
await expectMatchesSnapshot(
|
||||
'nested-node_modules',
|
||||
`
|
||||
import 'my-module';
|
||||
import 'my-module/bar/index.js';
|
||||
`,
|
||||
{
|
||||
importer: path.resolve(baseDir, 'node_modules', 'my-module-2', 'foo.js'),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves from root node_modules when dedupe is enabled', async () => {
|
||||
await expectMatchesSnapshot(
|
||||
'deduped-node_modules',
|
||||
`
|
||||
import 'my-module';
|
||||
import 'my-module/bar/index.js';
|
||||
`,
|
||||
{
|
||||
importer: path.resolve(baseDir, 'node_modules', 'my-module-2', 'foo.js'),
|
||||
resolveOptions: {
|
||||
dedupe: importee => !['.', '/'].includes(importee[0]),
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('does not resolve relative imports from root when dedupe is enabled', async () => {
|
||||
await expectMatchesSnapshot(
|
||||
'relative-deduped-node_modules',
|
||||
`
|
||||
import './my-module-2';
|
||||
`,
|
||||
{
|
||||
importer: path.resolve(baseDir, 'node_modules', 'my-module-2', 'foo.js'),
|
||||
resolveOptions: {
|
||||
dedupe: importee => !['.', '/'].includes(importee[0]),
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('does not preserve symlinks when false', async () => {
|
||||
await expectMatchesSnapshot(
|
||||
'preserve-symlinks-false',
|
||||
`
|
||||
import 'symlinked-package';
|
||||
`,
|
||||
{ resolveOptions: { customResolveOptions: { preserveSymlinks: false } } },
|
||||
);
|
||||
});
|
||||
|
||||
it('does preserve symlinks when true', async () => {
|
||||
await expectMatchesSnapshot(
|
||||
'preserve-symlinks-true',
|
||||
`
|
||||
import 'symlinked-package';
|
||||
`,
|
||||
{ resolveOptions: { customResolveOptions: { preserveSymlinks: true } } },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -75,8 +75,8 @@ async function fetchKarmaHTML(karmaHost, name, importMap) {
|
||||
.map(
|
||||
path => `import('${path}')
|
||||
.catch((e) => {
|
||||
console.log('Error loading test file: ${path.split('?')[0].replace('/base', '')}');
|
||||
throw e;
|
||||
console.error('Error loading test file: ${path.split('?')[0].replace('/base', '')}');
|
||||
console.error(e.stack);
|
||||
})`,
|
||||
)
|
||||
.join(',')}])
|
||||
|
||||
@@ -12408,7 +12408,7 @@ mime-db@1.44.0, "mime-db@>= 1.43.0 < 2":
|
||||
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.44.0.tgz#fa11c5eb0aca1334b4233cb4d52f10c5a6272f92"
|
||||
integrity sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==
|
||||
|
||||
mime-types@^2.1.12, mime-types@^2.1.18, mime-types@^2.1.25, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24:
|
||||
mime-types@^2.1.12, mime-types@^2.1.18, mime-types@^2.1.25, mime-types@^2.1.27, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24:
|
||||
version "2.1.27"
|
||||
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.27.tgz#47949f98e279ea53119f5722e0f34e529bec009f"
|
||||
integrity sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==
|
||||
|
||||
Reference in New Issue
Block a user